added Properties to created Tasks
This commit is contained in:
1 parent
99a494608f
commit
8078e34a2c
10 files changed
+790
-49
No files matched your search
@@ -12,6 +12,16 @@ GET http://localhost:31009/v1/spaces/{{spaceId}}/types/bafyreidtsobmbfmf6gl7bfwy
|
|||||||
Authorization: Bearer {{apiKey}}
|
Authorization: Bearer {{apiKey}}
|
||||||
Anytype-Version: 0.1
|
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 Property
|
||||||
GET http://localhost:31009/v1/spaces/{{spaceId}}/properties/bafyreibs7xr3zphtvjt3t4bk3jhcxlllo6jskrsatnuvkrcs65k3kfuany
|
GET http://localhost:31009/v1/spaces/{{spaceId}}/properties/bafyreibs7xr3zphtvjt3t4bk3jhcxlllo6jskrsatnuvkrcs65k3kfuany
|
||||||
Authorization: Bearer {{apiKey}}
|
Authorization: Bearer {{apiKey}}
|
||||||
|
|||||||
@@ -1,16 +1,35 @@
|
|||||||
package ch.ergon.anytask
|
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.Alignment
|
||||||
import androidx.compose.ui.unit.DpSize
|
import androidx.compose.ui.unit.DpSize
|
||||||
import androidx.compose.ui.unit.dp
|
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.api.AnyTypeApi
|
||||||
|
import ch.ergon.anytask.config.ConfigResult
|
||||||
import ch.ergon.anytask.config.loadConfig
|
import ch.ergon.anytask.config.loadConfig
|
||||||
|
import ch.ergon.anytask.config.saveConfig
|
||||||
import ch.ergon.anytask.hotkey.HotkeyManager
|
import ch.ergon.anytask.hotkey.HotkeyManager
|
||||||
|
import ch.ergon.anytask.ui.SettingsWindow
|
||||||
import ch.ergon.anytask.ui.TaskWindow
|
import ch.ergon.anytask.ui.TaskWindow
|
||||||
import org.apache.logging.log4j.LogManager
|
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
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
private val logger = LogManager.getLogger("Main")
|
private val logger = LogManager.getLogger("Main")
|
||||||
@@ -21,25 +40,38 @@ fun main() {
|
|||||||
|
|
||||||
application {
|
application {
|
||||||
var isVisible by remember { mutableStateOf(false) }
|
var isVisible by remember { mutableStateOf(false) }
|
||||||
|
var isSettingsVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val windowState = rememberWindowState(
|
val windowState = rememberWindowState(
|
||||||
position = WindowPosition.Aligned(Alignment.Center),
|
position = WindowPosition.Aligned(Alignment.Center),
|
||||||
size = DpSize(480.dp, 420.dp)
|
size = DpSize(480.dp, 520.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
val config = remember { loadConfig() }
|
var configResult by remember { mutableStateOf(loadConfig()) }
|
||||||
val client = remember { AnyTypeApi(baseUrl = config.connection.baseUrl, apiKey = config.connection.apiKey, spaceId = config.spaceId) }
|
var client by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
AnyTypeApi(
|
||||||
|
baseUrl = configResult.config.connection.baseUrl,
|
||||||
|
apiKey = configResult.config.connection.apiKey,
|
||||||
|
spaceId = configResult.config.spaceId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
HotkeyManager.start(onHotkey = { isVisible = !isVisible })
|
HotkeyManager.start(onHotkey = { isVisible = !isVisible })
|
||||||
setupSystemTray(onShow = { isVisible = true }, onExit = { exitApplication() })
|
setupSystemTray(
|
||||||
|
onShow = { isVisible = true },
|
||||||
|
onSettings = { isSettingsVisible = true },
|
||||||
|
onExit = { exitApplication() },
|
||||||
|
)
|
||||||
client.getSpaces()
|
client.getSpaces()
|
||||||
.onSuccess { spaces ->
|
.onSuccess { spaces ->
|
||||||
logger.info("Available spaces:")
|
logger.info("Available spaces:")
|
||||||
spaces.forEach { logger.info("Space: ${it.id} ${it.name}") }
|
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) {
|
if (space == null) {
|
||||||
logger.error("Selected space not found: ${config.spaceId}")
|
logger.error("Selected space not found: ${configResult.config.spaceId}")
|
||||||
exitProcess(-1)
|
exitProcess(-1)
|
||||||
} else {
|
} else {
|
||||||
logger.info("Selected space: ${space.name} (${space.id})")
|
logger.info("Selected space: ${space.name} (${space.id})")
|
||||||
@@ -52,6 +84,7 @@ fun main() {
|
|||||||
onDispose { HotkeyManager.stop() }
|
onDispose { HotkeyManager.stop() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task window
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = { isVisible = false },
|
onCloseRequest = { isVisible = false },
|
||||||
visible = isVisible,
|
visible = isVisible,
|
||||||
@@ -70,10 +103,42 @@ fun main() {
|
|||||||
}
|
}
|
||||||
TaskWindow(
|
TaskWindow(
|
||||||
client = client,
|
client = client,
|
||||||
|
config = configResult.config,
|
||||||
isVisible = isVisible,
|
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
|
if (!SystemTray.isSupported()) return
|
||||||
|
|
||||||
val size = SystemTray.getSystemTray().trayIconSize
|
val size = SystemTray.getSystemTray().trayIconSize
|
||||||
@@ -96,6 +161,7 @@ private fun setupSystemTray(onShow: () -> Unit, onExit: () -> Unit) {
|
|||||||
|
|
||||||
val menu = PopupMenu().apply {
|
val menu = PopupMenu().apply {
|
||||||
add(MenuItem("Open").also { it.addActionListener { onShow() } })
|
add(MenuItem("Open").also { it.addActionListener { onShow() } })
|
||||||
|
add(MenuItem("Settings").also { it.addActionListener { onSettings() } })
|
||||||
addSeparator()
|
addSeparator()
|
||||||
add(MenuItem("Quit").also { it.addActionListener { onExit() } })
|
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.CreateObjectRequest
|
||||||
import ch.ergon.anytask.api.dto.CreateObjectResponse
|
import ch.ergon.anytask.api.dto.CreateObjectResponse
|
||||||
import ch.ergon.anytask.api.dto.ObjectType
|
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.Space
|
||||||
import ch.ergon.anytask.api.dto.SpacesResponse
|
import ch.ergon.anytask.api.dto.SpacesResponse
|
||||||
import ch.ergon.anytask.api.dto.TypesResponse
|
import ch.ergon.anytask.api.dto.TypesResponse
|
||||||
import io.ktor.client.*
|
import kotlinx.serialization.json.JsonElement
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.engine.cio.*
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.plugins.*
|
import io.ktor.client.engine.cio.CIO
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
import io.ktor.client.plugins.HttpSend
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.plugins.plugin
|
||||||
import io.ktor.http.*
|
import io.ktor.client.request.bearerAuth
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
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 kotlinx.serialization.json.Json
|
||||||
import org.apache.logging.log4j.LogManager
|
import org.apache.logging.log4j.LogManager
|
||||||
import kotlin.time.DurationUnit.MILLISECONDS
|
import kotlin.time.DurationUnit.MILLISECONDS
|
||||||
@@ -22,7 +33,7 @@ import kotlin.time.toDuration
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Client for the local AnyType API (anytype-heart middleware).
|
* 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.
|
* apiKey and spaceId are loaded from anytask.yaml.
|
||||||
*/
|
*/
|
||||||
class AnyTypeApi(
|
class AnyTypeApi(
|
||||||
@@ -71,13 +82,36 @@ class AnyTypeApi(
|
|||||||
response.body<TypesResponse>().data
|
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 {
|
runCatching {
|
||||||
val response = http.post("$baseUrl/v1/spaces/$spaceId/objects") {
|
val response = http.post("$baseUrl/v1/spaces/$spaceId/objects") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
header("Anytype-Version", "0.1")
|
header("Anytype-Version", "0.1")
|
||||||
bearerAuth(apiKey)
|
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
|
response.body<CreateObjectResponse>().obj.id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package ch.ergon.anytask.api.dto
|
|||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class CreateObjectRequest(
|
data class CreateObjectRequest(
|
||||||
val name: String,
|
val name: String,
|
||||||
val body: String,
|
val body: String,
|
||||||
@SerialName("type_key") val typeKey: 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
|
package ch.ergon.anytask.config
|
||||||
|
|
||||||
import com.charleskorn.kaml.Yaml
|
import com.charleskorn.kaml.Yaml
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.apache.logging.log4j.LogManager
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
private val logger = LogManager.getLogger("Configuration")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Connection(
|
data class Connection(
|
||||||
val apiKey: String = "",
|
val apiKey: String = "",
|
||||||
val baseUrl: String = "http://localhost:31009",
|
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
|
@Serializable
|
||||||
data class Configuration(
|
data class Configuration(
|
||||||
val connection: Connection = Connection(),
|
val connection: Connection = Connection(),
|
||||||
val spaceId: String = "",
|
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 yaml = Yaml.default
|
||||||
val serializer = Configuration.serializer()
|
val serializer = Configuration.serializer()
|
||||||
|
|
||||||
@@ -25,14 +46,27 @@ fun loadConfig(): Configuration {
|
|||||||
File(System.getProperty("user.home"), ".anytask/anytask.yaml"),
|
File(System.getProperty("user.home"), ".anytask/anytask.yaml"),
|
||||||
File("anytask.yaml"),
|
File("anytask.yaml"),
|
||||||
)
|
)
|
||||||
externalLocations.firstOrNull { it.exists() }?.let {
|
externalLocations.firstOrNull { it.exists() }?.let { file ->
|
||||||
return yaml.decodeFromString(serializer, it.readText())
|
val config = yaml.decodeFromString(serializer, file.readText())
|
||||||
|
logger.info("Loaded config from ${file.absolutePath}")
|
||||||
|
return ConfigResult(config, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: classpath resource (development)
|
// Fallback: classpath resource (development)
|
||||||
Configuration::class.java.getResourceAsStream("/anytask.yaml")?.let {
|
Configuration::class.java.getResourceAsStream("/anytask.yaml")?.let { stream ->
|
||||||
return yaml.decodeFromString(serializer, it.bufferedReader().readText())
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package ch.ergon.anytask.ui
|
package ch.ergon.anytask.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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.Button
|
||||||
|
import androidx.compose.material.Checkbox
|
||||||
import androidx.compose.material.CircularProgressIndicator
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
|
import androidx.compose.material.DropdownMenu
|
||||||
|
import androidx.compose.material.DropdownMenuItem
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.OutlinedButton
|
||||||
import androidx.compose.material.OutlinedTextField
|
import androidx.compose.material.OutlinedTextField
|
||||||
import androidx.compose.material.Surface
|
import androidx.compose.material.Surface
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
@@ -19,6 +27,7 @@ import androidx.compose.material.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
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.input.key.type
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import ch.ergon.anytask.api.AnyTypeApi
|
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
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TaskWindow(client: AnyTypeApi, isVisible: Boolean, onDismiss: () -> Unit) {
|
fun TaskWindow(client: AnyTypeApi, config: Configuration, isVisible: Boolean, onDismiss: () -> Unit) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var title by remember { mutableStateOf("") }
|
var title by remember { mutableStateOf("") }
|
||||||
var details by remember { mutableStateOf("") }
|
var details by remember { mutableStateOf("") }
|
||||||
var isLoading by remember { mutableStateOf(false) }
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
val propertyValues = remember { mutableStateMapOf<String, String>() }
|
||||||
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
fun submit() {
|
fun submit() {
|
||||||
if (title.isBlank()) return
|
if (title.isBlank()) return
|
||||||
|
val typeKey = config.taskTypeKey.ifEmpty { "ot-task" }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = null
|
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 {
|
.onSuccess {
|
||||||
title = ""
|
title = ""
|
||||||
details = ""
|
details = ""
|
||||||
|
propertyValues.clear()
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
.onFailure { errorMessage = it.message }
|
.onFailure { errorMessage = it.message }
|
||||||
@@ -86,31 +111,44 @@ fun TaskWindow(client: AnyTypeApi, isVisible: Boolean, onDismiss: () -> Unit) {
|
|||||||
Text("New Task", style = MaterialTheme.typography.h6)
|
Text("New Task", style = MaterialTheme.typography.h6)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
Column(modifier = Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||||
value = title,
|
OutlinedTextField(
|
||||||
onValueChange = { title = it },
|
value = title,
|
||||||
label = { Text("Title") },
|
onValueChange = { title = it },
|
||||||
singleLine = true,
|
label = { Text("Title") },
|
||||||
modifier = Modifier
|
singleLine = true,
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.focusRequester(focusRequester),
|
.fillMaxWidth()
|
||||||
)
|
.focusRequester(focusRequester),
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = details,
|
value = details,
|
||||||
onValueChange = { details = it },
|
onValueChange = { details = it },
|
||||||
label = { Text("Details (optional)") },
|
label = { Text("Details (optional)") },
|
||||||
modifier = Modifier.fillMaxWidth().height(120.dp),
|
modifier = Modifier.fillMaxWidth().height(120.dp),
|
||||||
maxLines = 4,
|
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) {
|
if (errorMessage != null) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(errorMessage!!, color = MaterialTheme.colors.error, style = MaterialTheme.typography.caption)
|
Text(errorMessage!!, color = MaterialTheme.colors.error, style = MaterialTheme.typography.caption)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -128,3 +166,141 @@ fun TaskWindow(client: AnyTypeApi, isVisible: Boolean, onDismiss: () -> Unit) {
|
|||||||
|
|
||||||
LaunchedEffect(isVisible) { if (isVisible) focusRequester.requestFocus() }
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||