Project init by claude
This commit is contained in:
1 parent
0f63311c6a
commit
830765cb13
7 files changed
+345
No files matched your search
@@ -0,0 +1,47 @@
|
|||||||
|
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
kotlin("jvm") version "2.1.0"
|
||||||
|
id("org.jetbrains.compose") version "1.7.1"
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0"
|
||||||
|
kotlin("plugin.serialization") version "2.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
google()
|
||||||
|
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(compose.desktop.currentOs)
|
||||||
|
|
||||||
|
// Global hotkeys
|
||||||
|
implementation("com.github.kwhat:jnativehook:2.2.2")
|
||||||
|
|
||||||
|
// AnyType API (HTTP)
|
||||||
|
val ktorVersion = "3.0.0"
|
||||||
|
implementation("io.ktor:ktor-client-core:$ktorVersion")
|
||||||
|
implementation("io.ktor:ktor-client-cio:$ktorVersion")
|
||||||
|
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
|
||||||
|
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
|
||||||
|
|
||||||
|
// Coroutines (Swing/AWT dispatcher)
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.9.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
compose.desktop {
|
||||||
|
application {
|
||||||
|
mainClass = "ch.ergon.anytask.MainKt"
|
||||||
|
|
||||||
|
nativeDistributions {
|
||||||
|
targetFormats(TargetFormat.Dmg)
|
||||||
|
packageName = "AnyTask"
|
||||||
|
packageVersion = "1.0.0"
|
||||||
|
|
||||||
|
macOS {
|
||||||
|
bundleID = "ch.ergon.anytask"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||||
|
kotlin.daemon.jvm.options=-Xmx2g
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = "AnyTask"
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package ch.ergon.anytask
|
||||||
|
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.*
|
||||||
|
import ch.ergon.anytask.api.AnyTypeClient
|
||||||
|
import ch.ergon.anytask.hotkey.HotkeyManager
|
||||||
|
import ch.ergon.anytask.ui.TaskWindow
|
||||||
|
import java.awt.*
|
||||||
|
|
||||||
|
fun main() {
|
||||||
|
// Kein Dock-Icon auf macOS (Agent-App)
|
||||||
|
System.setProperty("apple.awt.UIElement", "true")
|
||||||
|
|
||||||
|
application {
|
||||||
|
var isVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val windowState = rememberWindowState(
|
||||||
|
position = WindowPosition.Aligned(Alignment.Center),
|
||||||
|
size = DpSize(480.dp, 320.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
val client = remember { AnyTypeClient() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
HotkeyManager.start(onHotkey = { isVisible = !isVisible })
|
||||||
|
setupSystemTray(onShow = { isVisible = true }, onExit = { exitApplication() })
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { HotkeyManager.stop() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Window(
|
||||||
|
onCloseRequest = { isVisible = false },
|
||||||
|
visible = isVisible,
|
||||||
|
state = windowState,
|
||||||
|
title = "AnyTask",
|
||||||
|
resizable = false,
|
||||||
|
alwaysOnTop = true,
|
||||||
|
undecorated = false,
|
||||||
|
) {
|
||||||
|
TaskWindow(
|
||||||
|
client = client,
|
||||||
|
onDismiss = { isVisible = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupSystemTray(onShow: () -> Unit, onExit: () -> Unit) {
|
||||||
|
if (!SystemTray.isSupported()) return
|
||||||
|
|
||||||
|
val size = SystemTray.getSystemTray().trayIconSize
|
||||||
|
val icon = createTrayIcon(size)
|
||||||
|
|
||||||
|
val menu = PopupMenu().apply {
|
||||||
|
add(MenuItem("Öffnen").also { it.addActionListener { onShow() } })
|
||||||
|
addSeparator()
|
||||||
|
add(MenuItem("Beenden").also { it.addActionListener { onExit() } })
|
||||||
|
}
|
||||||
|
|
||||||
|
val trayIcon = TrayIcon(icon, "AnyTask", menu).apply {
|
||||||
|
isImageAutoSize = true
|
||||||
|
addActionListener { onShow() } // Doppelklick
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemTray.getSystemTray().add(trayIcon)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTrayIcon(size: Dimension): Image {
|
||||||
|
val img = java.awt.image.BufferedImage(size.width, size.height, java.awt.image.BufferedImage.TYPE_INT_ARGB)
|
||||||
|
val g = img.createGraphics().apply {
|
||||||
|
setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||||
|
color = Color(100, 149, 237) // Cornflower Blue
|
||||||
|
fillOval(1, 1, size.width - 2, size.height - 2)
|
||||||
|
color = Color.WHITE
|
||||||
|
font = Font("SansSerif", Font.BOLD, (size.height * 0.55).toInt())
|
||||||
|
val fm = getFontMetrics(font)
|
||||||
|
val label = "A"
|
||||||
|
drawString(label, (size.width - fm.stringWidth(label)) / 2, (size.height + fm.ascent - fm.descent) / 2)
|
||||||
|
}
|
||||||
|
g.dispose()
|
||||||
|
return img
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package ch.ergon.anytask.api
|
||||||
|
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.engine.cio.*
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client für die lokale AnyType-API (anytype-heart Middleware).
|
||||||
|
* Die API läuft standardmässig auf http://localhost:31007.
|
||||||
|
*
|
||||||
|
* TODO: App-Key via Einstellungen konfigurierbar machen.
|
||||||
|
*/
|
||||||
|
class AnyTypeClient(
|
||||||
|
private val baseUrl: String = "http://localhost:31007",
|
||||||
|
private val appKey: String = "", // wird nach erstem Login gesetzt
|
||||||
|
) {
|
||||||
|
private val http = HttpClient(CIO) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json { ignoreUnknownKeys = true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createTask(title: String, details: String = "", spaceId: String = ""): Result<String> =
|
||||||
|
runCatching {
|
||||||
|
val response = http.post("$baseUrl/v1/spaces/$spaceId/objects") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
header("Anytype-Version", "0.1")
|
||||||
|
bearerAuth(appKey)
|
||||||
|
setBody(CreateObjectRequest(title = title, details = details, typeKey = "ot-task"))
|
||||||
|
}
|
||||||
|
response.body<CreateObjectResponse>().objectId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() = http.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class CreateObjectRequest(
|
||||||
|
val title: String,
|
||||||
|
val details: String,
|
||||||
|
val typeKey: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class CreateObjectResponse(
|
||||||
|
val objectId: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package ch.ergon.anytask.hotkey
|
||||||
|
|
||||||
|
import com.github.kwhat.jnativehook.GlobalScreen
|
||||||
|
import com.github.kwhat.jnativehook.NativeHookException
|
||||||
|
import com.github.kwhat.jnativehook.keyboard.NativeKeyEvent
|
||||||
|
import com.github.kwhat.jnativehook.keyboard.NativeKeyListener
|
||||||
|
import java.awt.EventQueue
|
||||||
|
import java.util.logging.Level
|
||||||
|
import java.util.logging.Logger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lauscht auf eine systemweite Tastenkombination: Ctrl+Shift+Space
|
||||||
|
* Erfordert auf macOS die Berechtigung "Eingabegeräte überwachen" (Accessibility).
|
||||||
|
*/
|
||||||
|
object HotkeyManager {
|
||||||
|
|
||||||
|
private var listener: NativeKeyListener? = null
|
||||||
|
|
||||||
|
fun start(onHotkey: () -> Unit) {
|
||||||
|
// JNativeHook-Logging unterdrücken
|
||||||
|
Logger.getLogger(GlobalScreen::class.java.`package`.name).apply {
|
||||||
|
level = Level.OFF
|
||||||
|
useParentHandlers = false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
GlobalScreen.registerNativeHook()
|
||||||
|
} catch (e: NativeHookException) {
|
||||||
|
System.err.println("JNativeHook konnte nicht registriert werden: ${e.message}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listener = object : NativeKeyListener {
|
||||||
|
override fun nativeKeyPressed(e: NativeKeyEvent) {
|
||||||
|
if (isHotkey(e)) {
|
||||||
|
// Callback auf den AWT-EDT dispatchen (Compose läuft dort)
|
||||||
|
EventQueue.invokeLater(onHotkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.also { GlobalScreen.addNativeKeyListener(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
listener?.let { GlobalScreen.removeNativeKeyListener(it) }
|
||||||
|
try {
|
||||||
|
GlobalScreen.unregisterNativeHook()
|
||||||
|
} catch (_: NativeHookException) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ctrl+Shift+Space */
|
||||||
|
private fun isHotkey(e: NativeKeyEvent): Boolean =
|
||||||
|
e.keyCode == NativeKeyEvent.VC_SPACE &&
|
||||||
|
e.modifiers and NativeKeyEvent.CTRL_MASK != 0 &&
|
||||||
|
e.modifiers and NativeKeyEvent.SHIFT_MASK != 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package ch.ergon.anytask.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.input.key.*
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import ch.ergon.anytask.api.AnyTypeClient
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TaskWindow(client: AnyTypeClient, 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 focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
fun submit() {
|
||||||
|
if (title.isBlank()) return
|
||||||
|
scope.launch {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = null
|
||||||
|
client.createTask(title = title.trim(), details = details.trim())
|
||||||
|
.onSuccess {
|
||||||
|
title = ""
|
||||||
|
details = ""
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
.onFailure { errorMessage = it.message }
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(24.dp)
|
||||||
|
.onKeyEvent { event ->
|
||||||
|
when {
|
||||||
|
event.type == KeyEventType.KeyDown && event.key == Key.Escape -> {
|
||||||
|
onDismiss(); true
|
||||||
|
}
|
||||||
|
event.type == KeyEventType.KeyDown &&
|
||||||
|
event.key == Key.Enter &&
|
||||||
|
event.isMetaPressed -> {
|
||||||
|
submit(); true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text("Neue Aufgabe", style = MaterialTheme.typography.h6)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = title,
|
||||||
|
onValueChange = { title = it },
|
||||||
|
label = { Text("Titel") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = details,
|
||||||
|
onValueChange = { details = it },
|
||||||
|
label = { Text("Details (optional)") },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(100.dp),
|
||||||
|
maxLines = 4,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (errorMessage != null) {
|
||||||
|
Text(errorMessage!!, color = MaterialTheme.colors.error, style = MaterialTheme.typography.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Abbrechen") }
|
||||||
|
Button(onClick = ::submit, enabled = title.isNotBlank() && !isLoading) {
|
||||||
|
if (isLoading) CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
|
else Text("Erstellen ⌘↵")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
}
|
||||||