From 830765cb134d016b0a92d5898e6c82c5cedc0910 Mon Sep 17 00:00:00 2001 From: Thomas Boksberger Date: Fri, 3 Apr 2026 09:42:49 +0200 Subject: [PATCH] Project init by claude --- build.gradle.kts | 47 ++++++++ gradle.properties | 2 + settings.gradle.kts | 1 + src/main/kotlin/ch/ergon/anytask/Main.kt | 87 +++++++++++++++ .../ch/ergon/anytask/api/AnyTypeClient.kt | 53 ++++++++++ .../ch/ergon/anytask/hotkey/HotkeyManager.kt | 55 ++++++++++ .../kotlin/ch/ergon/anytask/ui/TaskWindow.kt | 100 ++++++++++++++++++ 7 files changed, 345 insertions(+) create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 settings.gradle.kts create mode 100644 src/main/kotlin/ch/ergon/anytask/Main.kt create mode 100644 src/main/kotlin/ch/ergon/anytask/api/AnyTypeClient.kt create mode 100644 src/main/kotlin/ch/ergon/anytask/hotkey/HotkeyManager.kt create mode 100644 src/main/kotlin/ch/ergon/anytask/ui/TaskWindow.kt diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..2ce0b24 --- /dev/null +++ b/build.gradle.kts @@ -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" + } + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..facfa3f --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +kotlin.daemon.jvm.options=-Xmx2g diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..5f9afcf --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "AnyTask" diff --git a/src/main/kotlin/ch/ergon/anytask/Main.kt b/src/main/kotlin/ch/ergon/anytask/Main.kt new file mode 100644 index 0000000..e7b4193 --- /dev/null +++ b/src/main/kotlin/ch/ergon/anytask/Main.kt @@ -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 +} diff --git a/src/main/kotlin/ch/ergon/anytask/api/AnyTypeClient.kt b/src/main/kotlin/ch/ergon/anytask/api/AnyTypeClient.kt new file mode 100644 index 0000000..138418f --- /dev/null +++ b/src/main/kotlin/ch/ergon/anytask/api/AnyTypeClient.kt @@ -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 = + 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().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, +) diff --git a/src/main/kotlin/ch/ergon/anytask/hotkey/HotkeyManager.kt b/src/main/kotlin/ch/ergon/anytask/hotkey/HotkeyManager.kt new file mode 100644 index 0000000..8f0bc20 --- /dev/null +++ b/src/main/kotlin/ch/ergon/anytask/hotkey/HotkeyManager.kt @@ -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 +} diff --git a/src/main/kotlin/ch/ergon/anytask/ui/TaskWindow.kt b/src/main/kotlin/ch/ergon/anytask/ui/TaskWindow.kt new file mode 100644 index 0000000..a153840 --- /dev/null +++ b/src/main/kotlin/ch/ergon/anytask/ui/TaskWindow.kt @@ -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(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() } +}