added Properties to created Tasks

This commit is contained in:
Thomas Boksberger committed 2026-04-03 16:46:32 +02:00
1 parent 99a494608f
commit 8078e34a2c
10 files changed
+790 -49

No files matched your search

+10
View File
@@ -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}}
+77 -11
View File
@@ -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() } })
}
@@ -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<TypesResponse>().data
}
suspend fun createTask(title: String, details: String = ""): Result<String> =
suspend fun searchObjects(typeKey: String): Result<List<SearchObject>> =
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<SearchObjectsResponse>().data
}
suspend fun createObject(
title: String,
details: String = "",
typeKey: String,
properties: List<JsonElement> = emptyList(),
): Result<String> =
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<CreateObjectResponse>().obj.id
}
@@ -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,
val properties: List<JsonElement> = emptyList(),
)
@@ -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)))
}
}
@@ -0,0 +1,9 @@
package ch.ergon.anytask.api.dto
import kotlinx.serialization.Serializable
@Serializable
data class SearchObject(
val id: String,
val name: String,
)
@@ -0,0 +1,8 @@
package ch.ergon.anytask.api.dto
import kotlinx.serialization.Serializable
@Serializable
data class SearchObjectsResponse(
val data: List<SearchObject>,
)
@@ -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<String> = 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<PropertyConfig> = 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}")
}
@@ -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<PropertyConfig>().also { it.addAll(config.properties) } }
var types by remember { mutableStateOf<List<ObjectType>>(emptyList()) }
var isLoadingTypes by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// Property edit state
var editingIndex by remember { mutableStateOf<Int?>(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<Pair<String, String>>,
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<PropertyConfig>,
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<Property>,
allTypes: List<ObjectType>,
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")
}
}
}
}
+196 -20
View File
@@ -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<String?>(null) }
val propertyValues = remember { mutableStateMapOf<String, String>() }
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(),
@@ -128,3 +166,141 @@ fun TaskWindow(client: AnyTypeApi, isVisible: Boolean, onDismiss: () -> Unit) {
LaunchedEffect(isVisible) { if (isVisible) focusRequester.requestFocus() }
}
@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<String>,
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<List<SearchObject>>(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)
}
}
}
}
}
}
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.