From 8078e34a2cb4ea05ef34147aacc63b36c9f3bac3 Mon Sep 17 00:00:00 2001 From: Thomas Boksberger Date: Fri, 3 Apr 2026 16:46:32 +0200 Subject: [PATCH] added Properties to created Tasks --- httpClient/anyType.http | 10 + src/main/kotlin/ch/ergon/anytask/Main.kt | 88 ++++- .../kotlin/ch/ergon/anytask/api/AnyTypeApi.kt | 58 ++- .../anytask/api/dto/CreateObjectRequest.kt | 4 +- .../ch/ergon/anytask/api/dto/PropertyValue.kt | 34 ++ .../ch/ergon/anytask/api/dto/SearchObject.kt | 9 + .../anytask/api/dto/SearchObjectsResponse.kt | 8 + .../ch/ergon/anytask/config/Configuration.kt | 46 ++- .../ch/ergon/anytask/ui/SettingsWindow.kt | 368 ++++++++++++++++++ .../kotlin/ch/ergon/anytask/ui/TaskWindow.kt | 218 ++++++++++- 10 files changed, 792 insertions(+), 51 deletions(-) create mode 100644 src/main/kotlin/ch/ergon/anytask/api/dto/PropertyValue.kt create mode 100644 src/main/kotlin/ch/ergon/anytask/api/dto/SearchObject.kt create mode 100644 src/main/kotlin/ch/ergon/anytask/api/dto/SearchObjectsResponse.kt create mode 100644 src/main/kotlin/ch/ergon/anytask/ui/SettingsWindow.kt diff --git a/httpClient/anyType.http b/httpClient/anyType.http index 5c2790b..c654cad 100644 --- a/httpClient/anyType.http +++ b/httpClient/anyType.http @@ -12,6 +12,16 @@ GET http://localhost:31009/v1/spaces/{{spaceId}}/types/bafyreidtsobmbfmf6gl7bfwy Authorization: Bearer {{apiKey}} Anytype-Version: 0.1 +### Get objects of type +POST http://localhost:31009/v1/spaces/{{spaceId}}/search?type=project +Authorization: Bearer {{apiKey}} +Anytype-Version: 0.1 +Content-Type: application/json + +{ + "types": ["project"] +} + ### Get Property GET http://localhost:31009/v1/spaces/{{spaceId}}/properties/bafyreibs7xr3zphtvjt3t4bk3jhcxlllo6jskrsatnuvkrcs65k3kfuany Authorization: Bearer {{apiKey}} diff --git a/src/main/kotlin/ch/ergon/anytask/Main.kt b/src/main/kotlin/ch/ergon/anytask/Main.kt index 9155e50..63fb4f3 100644 --- a/src/main/kotlin/ch/ergon/anytask/Main.kt +++ b/src/main/kotlin/ch/ergon/anytask/Main.kt @@ -1,16 +1,35 @@ package ch.ergon.anytask -import androidx.compose.runtime.* +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.* +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState import ch.ergon.anytask.api.AnyTypeApi +import ch.ergon.anytask.config.ConfigResult import ch.ergon.anytask.config.loadConfig +import ch.ergon.anytask.config.saveConfig import ch.ergon.anytask.hotkey.HotkeyManager +import ch.ergon.anytask.ui.SettingsWindow import ch.ergon.anytask.ui.TaskWindow import org.apache.logging.log4j.LogManager -import java.awt.* +import java.awt.Color +import java.awt.Dimension +import java.awt.Font +import java.awt.Image +import java.awt.MenuItem +import java.awt.PopupMenu +import java.awt.RenderingHints +import java.awt.SystemTray +import java.awt.TrayIcon import kotlin.system.exitProcess private val logger = LogManager.getLogger("Main") @@ -21,25 +40,38 @@ fun main() { application { var isVisible by remember { mutableStateOf(false) } + var isSettingsVisible by remember { mutableStateOf(false) } val windowState = rememberWindowState( position = WindowPosition.Aligned(Alignment.Center), - size = DpSize(480.dp, 420.dp) + size = DpSize(480.dp, 520.dp) ) - val config = remember { loadConfig() } - val client = remember { AnyTypeApi(baseUrl = config.connection.baseUrl, apiKey = config.connection.apiKey, spaceId = config.spaceId) } + var configResult by remember { mutableStateOf(loadConfig()) } + var client by remember { + mutableStateOf( + AnyTypeApi( + baseUrl = configResult.config.connection.baseUrl, + apiKey = configResult.config.connection.apiKey, + spaceId = configResult.config.spaceId, + ) + ) + } LaunchedEffect(Unit) { HotkeyManager.start(onHotkey = { isVisible = !isVisible }) - setupSystemTray(onShow = { isVisible = true }, onExit = { exitApplication() }) + setupSystemTray( + onShow = { isVisible = true }, + onSettings = { isSettingsVisible = true }, + onExit = { exitApplication() }, + ) client.getSpaces() .onSuccess { spaces -> logger.info("Available spaces:") spaces.forEach { logger.info("Space: ${it.id} ${it.name}") } - val space = spaces.find { space -> space.id == config.spaceId } + val space = spaces.find { space -> space.id == configResult.config.spaceId } if (space == null) { - logger.error("Selected space not found: ${config.spaceId}") + logger.error("Selected space not found: ${configResult.config.spaceId}") exitProcess(-1) } else { logger.info("Selected space: ${space.name} (${space.id})") @@ -52,6 +84,7 @@ fun main() { onDispose { HotkeyManager.stop() } } + // Task window Window( onCloseRequest = { isVisible = false }, visible = isVisible, @@ -70,10 +103,42 @@ fun main() { } TaskWindow( client = client, + config = configResult.config, isVisible = isVisible, - onDismiss = { isVisible = false } + onDismiss = { isVisible = false }, ) } + + // Settings window + if (isSettingsVisible) { + val settingsState = rememberWindowState( + position = WindowPosition.Aligned(Alignment.Center), + size = DpSize(650.dp, 700.dp), + ) + Window( + onCloseRequest = { isSettingsVisible = false }, + state = settingsState, + title = "AnyTask Settings", + resizable = true, + ) { + SettingsWindow( + config = configResult.config, + client = client, + onSave = { newConfig -> + saveConfig(newConfig, configResult.sourceFile) + configResult = ConfigResult(newConfig, configResult.sourceFile) + client.close() + client = AnyTypeApi( + baseUrl = newConfig.connection.baseUrl, + apiKey = newConfig.connection.apiKey, + spaceId = newConfig.spaceId, + ) + isSettingsVisible = false + }, + onCancel = { isSettingsVisible = false }, + ) + } + } } } @@ -88,7 +153,7 @@ private fun activateMacOsApp() { } } -private fun setupSystemTray(onShow: () -> Unit, onExit: () -> Unit) { +private fun setupSystemTray(onShow: () -> Unit, onSettings: () -> Unit, onExit: () -> Unit) { if (!SystemTray.isSupported()) return val size = SystemTray.getSystemTray().trayIconSize @@ -96,6 +161,7 @@ private fun setupSystemTray(onShow: () -> Unit, onExit: () -> Unit) { val menu = PopupMenu().apply { add(MenuItem("Open").also { it.addActionListener { onShow() } }) + add(MenuItem("Settings").also { it.addActionListener { onSettings() } }) addSeparator() add(MenuItem("Quit").also { it.addActionListener { onExit() } }) } diff --git a/src/main/kotlin/ch/ergon/anytask/api/AnyTypeApi.kt b/src/main/kotlin/ch/ergon/anytask/api/AnyTypeApi.kt index 175c54a..178ec6b 100644 --- a/src/main/kotlin/ch/ergon/anytask/api/AnyTypeApi.kt +++ b/src/main/kotlin/ch/ergon/anytask/api/AnyTypeApi.kt @@ -3,18 +3,29 @@ package ch.ergon.anytask.api import ch.ergon.anytask.api.dto.CreateObjectRequest import ch.ergon.anytask.api.dto.CreateObjectResponse import ch.ergon.anytask.api.dto.ObjectType +import ch.ergon.anytask.api.dto.SearchObject +import ch.ergon.anytask.api.dto.SearchObjectsResponse import ch.ergon.anytask.api.dto.Space import ch.ergon.anytask.api.dto.SpacesResponse import ch.ergon.anytask.api.dto.TypesResponse -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.engine.cio.* -import io.ktor.client.plugins.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.JsonElement +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpSend +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.plugin +import io.ktor.client.request.bearerAuth +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import org.apache.logging.log4j.LogManager import kotlin.time.DurationUnit.MILLISECONDS @@ -22,7 +33,7 @@ import kotlin.time.toDuration /** * Client for the local AnyType API (anytype-heart middleware). - * The API runs on http://localhost:31007 by default. + * The API runs on http://localhost:31009 by default. * apiKey and spaceId are loaded from anytask.yaml. */ class AnyTypeApi( @@ -71,13 +82,36 @@ class AnyTypeApi( response.body().data } - suspend fun createTask(title: String, details: String = ""): Result = + suspend fun searchObjects(typeKey: String): Result> = + runCatching { + val response = http.post("$baseUrl/v1/spaces/$spaceId/search") { + contentType(ContentType.Application.Json) + header("Anytype-Version", "0.1") + bearerAuth(apiKey) + setBody(mapOf("types" to listOf(typeKey))) + } + response.body().data + } + + suspend fun createObject( + title: String, + details: String = "", + typeKey: String, + properties: List = emptyList(), + ): Result = runCatching { val response = http.post("$baseUrl/v1/spaces/$spaceId/objects") { contentType(ContentType.Application.Json) header("Anytype-Version", "0.1") bearerAuth(apiKey) - setBody(CreateObjectRequest(name = title, body = details, typeKey = "ot-task")) + setBody( + CreateObjectRequest( + name = title, + body = details, + typeKey = typeKey, + properties = properties, + ) + ) } response.body().obj.id } diff --git a/src/main/kotlin/ch/ergon/anytask/api/dto/CreateObjectRequest.kt b/src/main/kotlin/ch/ergon/anytask/api/dto/CreateObjectRequest.kt index aba55ce..0b78aaa 100644 --- a/src/main/kotlin/ch/ergon/anytask/api/dto/CreateObjectRequest.kt +++ b/src/main/kotlin/ch/ergon/anytask/api/dto/CreateObjectRequest.kt @@ -2,10 +2,12 @@ package ch.ergon.anytask.api.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement @Serializable data class CreateObjectRequest( val name: String, val body: String, @SerialName("type_key") val typeKey: String, -) \ No newline at end of file + val properties: List = emptyList(), +) diff --git a/src/main/kotlin/ch/ergon/anytask/api/dto/PropertyValue.kt b/src/main/kotlin/ch/ergon/anytask/api/dto/PropertyValue.kt new file mode 100644 index 0000000..a1e587a --- /dev/null +++ b/src/main/kotlin/ch/ergon/anytask/api/dto/PropertyValue.kt @@ -0,0 +1,34 @@ +package ch.ergon.anytask.api.dto + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +fun buildPropertyValue(key: String, format: String, value: String): JsonElement? { + if (value.isBlank()) return null + + return when (format) { + "text" -> JsonObject(mapOf("key" to JsonPrimitive(key), "text" to JsonPrimitive(value))) + "number" -> JsonObject(mapOf("key" to JsonPrimitive(key), "number" to JsonPrimitive(value.toDoubleOrNull() ?: 0.0))) + "select" -> JsonObject(mapOf("key" to JsonPrimitive(key), "select" to JsonPrimitive(value))) + "multi_select" -> { + val items = value.split(",").map { it.trim() }.filter { it.isNotEmpty() } + JsonObject(mapOf("key" to JsonPrimitive(key), "multi_select" to JsonArray(items.map { JsonPrimitive(it) }))) + } + "date" -> JsonObject(mapOf("key" to JsonPrimitive(key), "date" to JsonPrimitive(value))) + "checkbox" -> JsonObject(mapOf("key" to JsonPrimitive(key), "checkbox" to JsonPrimitive(value.toBooleanStrictOrNull() ?: false))) + "url" -> JsonObject(mapOf("key" to JsonPrimitive(key), "url" to JsonPrimitive(value))) + "email" -> JsonObject(mapOf("key" to JsonPrimitive(key), "email" to JsonPrimitive(value))) + "phone" -> JsonObject(mapOf("key" to JsonPrimitive(key), "phone" to JsonPrimitive(value))) + "files" -> { + val ids = value.split(",").map { it.trim() }.filter { it.isNotEmpty() } + JsonObject(mapOf("key" to JsonPrimitive(key), "files" to JsonArray(ids.map { JsonPrimitive(it) }))) + } + "object", "objects" -> { + val ids = value.split(",").map { it.trim() }.filter { it.isNotEmpty() } + JsonObject(mapOf("key" to JsonPrimitive(key), "objects" to JsonArray(ids.map { JsonPrimitive(it) }))) + } + else -> JsonObject(mapOf("key" to JsonPrimitive(key), "text" to JsonPrimitive(value))) + } +} diff --git a/src/main/kotlin/ch/ergon/anytask/api/dto/SearchObject.kt b/src/main/kotlin/ch/ergon/anytask/api/dto/SearchObject.kt new file mode 100644 index 0000000..c3e87b5 --- /dev/null +++ b/src/main/kotlin/ch/ergon/anytask/api/dto/SearchObject.kt @@ -0,0 +1,9 @@ +package ch.ergon.anytask.api.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class SearchObject( + val id: String, + val name: String, +) diff --git a/src/main/kotlin/ch/ergon/anytask/api/dto/SearchObjectsResponse.kt b/src/main/kotlin/ch/ergon/anytask/api/dto/SearchObjectsResponse.kt new file mode 100644 index 0000000..082316d --- /dev/null +++ b/src/main/kotlin/ch/ergon/anytask/api/dto/SearchObjectsResponse.kt @@ -0,0 +1,8 @@ +package ch.ergon.anytask.api.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class SearchObjectsResponse( + val data: List, +) diff --git a/src/main/kotlin/ch/ergon/anytask/config/Configuration.kt b/src/main/kotlin/ch/ergon/anytask/config/Configuration.kt index 20cad4f..e6d6c89 100644 --- a/src/main/kotlin/ch/ergon/anytask/config/Configuration.kt +++ b/src/main/kotlin/ch/ergon/anytask/config/Configuration.kt @@ -1,22 +1,43 @@ package ch.ergon.anytask.config import com.charleskorn.kaml.Yaml +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import org.apache.logging.log4j.LogManager import java.io.File +private val logger = LogManager.getLogger("Configuration") + @Serializable data class Connection( val apiKey: String = "", val baseUrl: String = "http://localhost:31009", ) +@Serializable +data class PropertyConfig( + val key: String, + val name: String = "", + val format: String = "", + val indicator: String = "", + @SerialName("possible_values") val possibleValues: List = emptyList(), + @SerialName("object_type_key") val objectTypeKey: String = "", +) + @Serializable data class Configuration( val connection: Connection = Connection(), val spaceId: String = "", + @SerialName("task_type_key") val taskTypeKey: String = "", + val properties: List = emptyList(), ) -fun loadConfig(): Configuration { +data class ConfigResult( + val config: Configuration, + val sourceFile: File?, +) + +fun loadConfig(): ConfigResult { val yaml = Yaml.default val serializer = Configuration.serializer() @@ -25,14 +46,27 @@ fun loadConfig(): Configuration { File(System.getProperty("user.home"), ".anytask/anytask.yaml"), File("anytask.yaml"), ) - externalLocations.firstOrNull { it.exists() }?.let { - return yaml.decodeFromString(serializer, it.readText()) + externalLocations.firstOrNull { it.exists() }?.let { file -> + val config = yaml.decodeFromString(serializer, file.readText()) + logger.info("Loaded config from ${file.absolutePath}") + return ConfigResult(config, file) } // Fallback: classpath resource (development) - Configuration::class.java.getResourceAsStream("/anytask.yaml")?.let { - return yaml.decodeFromString(serializer, it.bufferedReader().readText()) + Configuration::class.java.getResourceAsStream("/anytask.yaml")?.let { stream -> + val config = yaml.decodeFromString(serializer, stream.bufferedReader().readText()) + logger.info("Loaded config from classpath:/anytask.yaml") + return ConfigResult(config, null) } - return Configuration() + logger.warn("No config found, using defaults") + return ConfigResult(Configuration(), null) +} + +fun saveConfig(config: Configuration, sourceFile: File?) { + val file = sourceFile ?: File(System.getProperty("user.home"), ".anytask/anytask.yaml") + file.parentFile?.mkdirs() + val yaml = Yaml.default + file.writeText(yaml.encodeToString(Configuration.serializer(), config)) + logger.info("Saved config to ${file.absolutePath}") } diff --git a/src/main/kotlin/ch/ergon/anytask/ui/SettingsWindow.kt b/src/main/kotlin/ch/ergon/anytask/ui/SettingsWindow.kt new file mode 100644 index 0000000..ddeebba --- /dev/null +++ b/src/main/kotlin/ch/ergon/anytask/ui/SettingsWindow.kt @@ -0,0 +1,368 @@ +package ch.ergon.anytask.ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import ch.ergon.anytask.api.AnyTypeApi +import ch.ergon.anytask.api.dto.ObjectType +import ch.ergon.anytask.api.dto.Property +import ch.ergon.anytask.config.Configuration +import ch.ergon.anytask.config.Connection +import ch.ergon.anytask.config.PropertyConfig +import kotlinx.coroutines.launch + +@Composable +fun SettingsWindow( + config: Configuration, + client: AnyTypeApi, + onSave: (Configuration) -> Unit, + onCancel: () -> Unit, +) { + val scope = rememberCoroutineScope() + + var apiKey by remember { mutableStateOf(config.connection.apiKey) } + var selectedTypeKey by remember { mutableStateOf(config.taskTypeKey) } + val propertyConfigs = remember { mutableStateListOf().also { it.addAll(config.properties) } } + + var types by remember { mutableStateOf>(emptyList()) } + var isLoadingTypes by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + // Property edit state + var editingIndex by remember { mutableStateOf(null) } // null=none, -1=new + + // Load types on start + LaunchedEffect(Unit) { + isLoadingTypes = true + client.getTypes() + .onSuccess { types = it } + .onFailure { errorMessage = "Failed to load types: ${it.message}" } + isLoadingTypes = false + } + + val selectedType = types.find { it.key == selectedTypeKey } + val availableProperties = selectedType?.properties.orEmpty() + + fun buildConfig(): Configuration = Configuration( + connection = Connection(apiKey = apiKey, baseUrl = config.connection.baseUrl), + spaceId = config.spaceId, + taskTypeKey = selectedTypeKey, + properties = propertyConfigs.toList(), + ) + + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { + Text("Settings", style = MaterialTheme.typography.h6) + Spacer(modifier = Modifier.height(16.dp)) + + // ApiKey + OutlinedTextField( + value = apiKey, + onValueChange = { apiKey = it }, + label = { Text("API Key") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(12.dp)) + + // TaskType dropdown + DropdownSelector( + label = "Task Type", + selectedValue = selectedType?.name ?: selectedTypeKey, + options = types.map { it.key to it.name }, + onSelect = { key -> + selectedTypeKey = key + propertyConfigs.clear() + editingIndex = null + }, + isLoading = isLoadingTypes, + ) + Spacer(modifier = Modifier.height(16.dp)) + + // Properties section + Text("Properties", style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(8.dp)) + + if (propertyConfigs.isEmpty() && editingIndex == null) { + Text( + "No properties configured. Click + to add one.", + style = MaterialTheme.typography.caption, + color = Color.Gray, + ) + } + + // Properties table + if (propertyConfigs.isNotEmpty()) { + PropertyTable( + configs = propertyConfigs, + onEdit = { index -> editingIndex = index }, + onDelete = { index -> + propertyConfigs.removeAt(index) + if (editingIndex == index) editingIndex = null + }, + ) + } + + // Add button + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + IconButton( + onClick = { editingIndex = -1 }, + enabled = selectedType != null, + ) { + Icon(Icons.Default.Add, contentDescription = "Add property") + } + } + + // Property edit panel + if (editingIndex != null) { + Divider(modifier = Modifier.padding(vertical = 8.dp)) + PropertyEditPanel( + availableProperties = availableProperties.filter { prop -> + propertyConfigs.none { it.key == prop.key } || (editingIndex!! >= 0 && propertyConfigs[editingIndex!!].key == prop.key) + }, + allTypes = types, + existing = if (editingIndex!! >= 0) propertyConfigs[editingIndex!!] else null, + onConfirm = { newConfig -> + if (editingIndex!! >= 0) { + propertyConfigs[editingIndex!!] = newConfig + } else { + propertyConfigs.add(newConfig) + } + editingIndex = null + }, + onCancel = { editingIndex = null }, + ) + } + + if (errorMessage != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text(errorMessage!!, color = MaterialTheme.colors.error, style = MaterialTheme.typography.caption) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Save / Cancel + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onCancel) { Text("Cancel") } + Button(onClick = { onSave(buildConfig()) }) { Text("Save") } + } + } + } + } +} + +@Composable +private fun DropdownSelector( + label: String, + selectedValue: String, + options: List>, + onSelect: (String) -> Unit, + isLoading: Boolean = false, +) { + var expanded by remember { mutableStateOf(false) } + + Column { + Text(label, style = MaterialTheme.typography.caption) + Box { + OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.height(16.dp).width(16.dp), strokeWidth = 2.dp) + } else { + Text(selectedValue.ifEmpty { "Select..." }) + } + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { (key, name) -> + DropdownMenuItem(onClick = { + onSelect(key) + expanded = false + }) { + Text(name) + } + } + } + } + } +} + +@Composable +private fun PropertyTable( + configs: List, + onEdit: (Int) -> Unit, + onDelete: (Int) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().border(1.dp, Color.LightGray)) { + // Header + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) { + Text("Name", fontWeight = FontWeight.Bold, modifier = Modifier.weight(2f)) + Text("Type", fontWeight = FontWeight.Bold, modifier = Modifier.weight(1.5f)) + Text("Ind.", fontWeight = FontWeight.Bold, modifier = Modifier.weight(0.7f)) + Text("Values", fontWeight = FontWeight.Bold, modifier = Modifier.weight(2f)) + Text("Obj Type", fontWeight = FontWeight.Bold, modifier = Modifier.weight(1.5f)) + Spacer(modifier = Modifier.width(40.dp)) + } + Divider() + + // Rows + LazyColumn(modifier = Modifier.fillMaxWidth().height((configs.size * 36).coerceAtMost(200).dp)) { + itemsIndexed(configs) { index, prop -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onEdit(index) } + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(prop.name, modifier = Modifier.weight(2f), maxLines = 1) + Text(prop.format, modifier = Modifier.weight(1.5f), maxLines = 1) + Text(prop.indicator, modifier = Modifier.weight(0.7f), maxLines = 1) + Text( + prop.possibleValues.joinToString(", "), + modifier = Modifier.weight(2f), + maxLines = 1, + ) + Text(prop.objectTypeKey, modifier = Modifier.weight(1.5f), maxLines = 1) + IconButton(onClick = { onDelete(index) }) { + Icon(Icons.Default.Delete, contentDescription = "Delete", tint = Color.Gray) + } + } + if (index < configs.size - 1) Divider() + } + } + } +} + +@Composable +private fun PropertyEditPanel( + availableProperties: List, + allTypes: List, + existing: PropertyConfig?, + onConfirm: (PropertyConfig) -> Unit, + onCancel: () -> Unit, +) { + var selectedPropertyKey by remember(existing) { mutableStateOf(existing?.key ?: "") } + var indicator by remember(existing) { mutableStateOf(existing?.indicator ?: "") } + var possibleValuesText by remember(existing) { mutableStateOf(existing?.possibleValues?.joinToString("\n") ?: "") } + var objectTypeKey by remember(existing) { mutableStateOf(existing?.objectTypeKey ?: "") } + + val selectedProperty = availableProperties.find { it.key == selectedPropertyKey } + val format = selectedProperty?.format ?: existing?.format ?: "" + + Column( + modifier = Modifier.fillMaxWidth().padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Edit Property", style = MaterialTheme.typography.subtitle2, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.weight(1f)) + if (format.isNotEmpty()) { + Text("Type: $format", style = MaterialTheme.typography.caption, color = Color.Gray) + } + } + + // Property dropdown + DropdownSelector( + label = "Property", + selectedValue = selectedProperty?.name ?: selectedPropertyKey, + options = availableProperties.map { it.key to it.name }, + onSelect = { key -> selectedPropertyKey = key }, + ) + + // Indicator + OutlinedTextField( + value = indicator, + onValueChange = { if (it.length <= 1) indicator = it }, + label = { Text("Indicator") }, + singleLine = true, + modifier = Modifier.width(100.dp), + ) + + // Format-specific fields + when (format) { + "select", "multi_select" -> { + OutlinedTextField( + value = possibleValuesText, + onValueChange = { possibleValuesText = it }, + label = { Text("Possible Values (one per line)") }, + modifier = Modifier.fillMaxWidth().height(120.dp), + maxLines = 10, + ) + } + "object", "objects" -> { + DropdownSelector( + label = "Object Type", + selectedValue = allTypes.find { it.key == objectTypeKey }?.name ?: objectTypeKey, + options = allTypes.map { it.key to it.name }, + onSelect = { key -> objectTypeKey = key }, + ) + } + } + + // Confirm / Cancel + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onCancel) { Text("Cancel") } + Button( + onClick = { + val values = possibleValuesText.lines().map { it.trim() }.filter { it.isNotEmpty() } + onConfirm( + PropertyConfig( + key = selectedPropertyKey, + name = selectedProperty?.name ?: existing?.name ?: "", + format = format, + indicator = indicator, + possibleValues = values, + objectTypeKey = objectTypeKey, + ) + ) + }, + enabled = selectedPropertyKey.isNotEmpty(), + ) { + Text("OK") + } + } + } +} diff --git a/src/main/kotlin/ch/ergon/anytask/ui/TaskWindow.kt b/src/main/kotlin/ch/ergon/anytask/ui/TaskWindow.kt index 1e5daad..7075244 100644 --- a/src/main/kotlin/ch/ergon/anytask/ui/TaskWindow.kt +++ b/src/main/kotlin/ch/ergon/anytask/ui/TaskWindow.kt @@ -1,6 +1,7 @@ package ch.ergon.anytask.ui import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -9,9 +10,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button +import androidx.compose.material.Checkbox import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton import androidx.compose.material.OutlinedTextField import androidx.compose.material.Surface import androidx.compose.material.Text @@ -19,6 +27,7 @@ import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -35,27 +44,43 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import ch.ergon.anytask.api.AnyTypeApi +import ch.ergon.anytask.api.dto.SearchObject +import ch.ergon.anytask.config.Configuration +import ch.ergon.anytask.api.dto.buildPropertyValue +import ch.ergon.anytask.config.PropertyConfig import kotlinx.coroutines.launch @Composable -fun TaskWindow(client: AnyTypeApi, isVisible: Boolean, onDismiss: () -> Unit) { +fun TaskWindow(client: AnyTypeApi, config: Configuration, isVisible: Boolean, onDismiss: () -> Unit) { val scope = rememberCoroutineScope() var title by remember { mutableStateOf("") } var details by remember { mutableStateOf("") } var isLoading by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } + val propertyValues = remember { mutableStateMapOf() } val focusRequester = remember { FocusRequester() } fun submit() { if (title.isBlank()) return + val typeKey = config.taskTypeKey.ifEmpty { "ot-task" } scope.launch { isLoading = true errorMessage = null - client.createTask(title = title.trim(), details = details.trim()) + val propElements = config.properties.mapNotNull { prop -> + val value = propertyValues[prop.key] ?: "" + buildPropertyValue(prop.key, prop.format, value) + } + client.createObject( + title = title.trim(), + details = details.trim(), + typeKey = typeKey, + properties = propElements, + ) .onSuccess { title = "" details = "" + propertyValues.clear() onDismiss() } .onFailure { errorMessage = it.message } @@ -86,31 +111,44 @@ fun TaskWindow(client: AnyTypeApi, isVisible: Boolean, onDismiss: () -> Unit) { Text("New Task", style = MaterialTheme.typography.h6) Spacer(modifier = Modifier.height(16.dp)) - OutlinedTextField( - value = title, - onValueChange = { title = it }, - label = { Text("Title") }, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester), - ) - Spacer(modifier = Modifier.height(12.dp)) + Column(modifier = Modifier.weight(1f).verticalScroll(rememberScrollState())) { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) + Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( - value = details, - onValueChange = { details = it }, - label = { Text("Details (optional)") }, - modifier = Modifier.fillMaxWidth().height(120.dp), - maxLines = 4, - ) + OutlinedTextField( + value = details, + onValueChange = { details = it }, + label = { Text("Details (optional)") }, + modifier = Modifier.fillMaxWidth().height(120.dp), + maxLines = 4, + ) + + // Dynamic property fields + config.properties.forEach { prop -> + Spacer(modifier = Modifier.height(12.dp)) + PropertyField( + prop = prop, + value = propertyValues[prop.key] ?: "", + onValueChange = { propertyValues[prop.key] = it }, + client = client, + ) + } + } if (errorMessage != null) { Spacer(modifier = Modifier.height(8.dp)) Text(errorMessage!!, color = MaterialTheme.colors.error, style = MaterialTheme.typography.caption) } - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), @@ -127,4 +165,142 @@ fun TaskWindow(client: AnyTypeApi, isVisible: Boolean, onDismiss: () -> Unit) { } LaunchedEffect(isVisible) { if (isVisible) focusRequester.requestFocus() } -} \ No newline at end of file +} + +@Composable +private fun PropertyField( + prop: PropertyConfig, + value: String, + onValueChange: (String) -> Unit, + client: AnyTypeApi, +) { + when (prop.format) { + "select", "multi_select" -> { + SelectField( + label = prop.name, + value = value, + options = prop.possibleValues, + onValueChange = onValueChange, + ) + } + "object", "objects" -> { + ObjectField( + label = prop.name, + value = value, + objectTypeKey = prop.objectTypeKey, + client = client, + onValueChange = onValueChange, + ) + } + "checkbox" -> { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = value == "true", + onCheckedChange = { onValueChange(if (it) "true" else "false") }, + ) + Text(prop.name) + } + } + else -> { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(prop.name) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + placeholder = { + if (prop.format == "date") Text("YYYY-MM-DD") + else null + }, + ) + } + } +} + +@Composable +private fun SelectField( + label: String, + value: String, + options: List, + onValueChange: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + Column { + Text(label, style = MaterialTheme.typography.caption) + Box { + OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { + Text(value.ifEmpty { "Select..." }) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem(onClick = { + onValueChange("") + expanded = false + }) { + Text("(none)", style = MaterialTheme.typography.caption) + } + options.forEach { option -> + DropdownMenuItem(onClick = { + onValueChange(option) + expanded = false + }) { + Text(option) + } + } + } + } + } +} + +@Composable +private fun ObjectField( + label: String, + value: String, + objectTypeKey: String, + client: AnyTypeApi, + onValueChange: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + var objects by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(false) } + + LaunchedEffect(objectTypeKey) { + if (objectTypeKey.isNotEmpty()) { + isLoading = true + client.searchObjects(objectTypeKey) + .onSuccess { objects = it } + isLoading = false + } + } + + val selectedName = objects.find { it.id == value }?.name ?: value + + Column { + Text(label, style = MaterialTheme.typography.caption) + Box { + OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.height(16.dp).width(16.dp), strokeWidth = 2.dp) + } else { + Text(selectedName.ifEmpty { "Select..." }) + } + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem(onClick = { + onValueChange("") + expanded = false + }) { + Text("(none)", style = MaterialTheme.typography.caption) + } + objects.forEach { obj -> + DropdownMenuItem(onClick = { + onValueChange(obj.id) + expanded = false + }) { + Text(obj.name) + } + } + } + } + } +}