added Properties to created Tasks
This commit is contained in:
1 parent
99a494608f
commit
8078e34a2c
10 files changed
+773
-32
No files matched your search
@@ -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}}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,6 +111,7 @@ fun TaskWindow(client: AnyTypeApi, isVisible: Boolean, onDismiss: () -> Unit) {
|
||||
Text("New Task", style = MaterialTheme.typography.h6)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
@@ -105,12 +131,24 @@ fun TaskWindow(client: AnyTypeApi, isVisible: Boolean, onDismiss: () -> Unit) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||