diff options
| author | ketsuban <ketsuban@kukuri.dev> | 2026-08-29 07:14:23 +0000 |
|---|---|---|
| committer | ketsuban <ketsuban@kukuri.dev> | 2026-08-29 07:14:23 +0000 |
| commit | 9969ad9e7669dbae5027574d34da72306f4c6958 (patch) | |
| tree | ba7170b61c0d0ad3b8e71616615ba15596390998 | |
| parent | 695fc4ed3fa3e4e86affb3c558f7c04a428342d9 (diff) | |
| download | kukuri-9969ad9e7669dbae5027574d34da72306f4c6958.tar.gz kukuri-9969ad9e7669dbae5027574d34da72306f4c6958.tar.bz2 kukuri-9969ad9e7669dbae5027574d34da72306f4c6958.zip | |
Rooms+QR+Calendar+Experiment: shared app store; room-scoped signaling (roomId routing, atomic join); QR matrix render/paste-join; calendar union view + notifications; Excel-like formulas + GUI-Lisp demos
45 files changed, 2686 insertions, 108 deletions
diff --git a/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/Main.kt b/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/Main.kt index caf4368..02fad1e 100644 --- a/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/Main.kt +++ b/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/Main.kt @@ -15,10 +15,12 @@ fun main(args: Array<String>) { val endpoint = args.firstOrNull { it.startsWith("--signaling=") }?.removePrefix("--signaling=") ?: "ws://127.0.0.1:8091/ws" val embed = args.contains("--embed-signaling") + val room = args.firstOrNull { it.startsWith("--room=") }?.removePrefix("--room=") ?: "default" val runtime = DesktopDistributionRuntime( selfPeerId = selfPeerId, signalingEndpoint = endpoint, embedSignalingServer = embed, + roomId = room, ) Thread { runBlocking { diff --git a/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/p2p/P2pDemoMain.kt b/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/p2p/P2pDemoMain.kt index 5872ec6..65b07e7 100644 --- a/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/p2p/P2pDemoMain.kt +++ b/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/p2p/P2pDemoMain.kt @@ -26,8 +26,8 @@ fun main(args: Array<String>) { println("usage: p2p --role=signaling|makepack|sender|receiver [options]") println(" signaling --port=8091") println(" makepack --out=/tmp/pack.kzip --size=524288") - println(" sender --endpoint=127.0.0.1:8091 --name=alice --pack=/tmp/pack.kzip") - println(" receiver --endpoint=127.0.0.1:8091 --name=bob --out=/tmp/received.kzip") + println(" sender --endpoint=127.0.0.1:8091 --name=alice --pack=/tmp/pack.kzip --room=demo-room") + println(" receiver --endpoint=127.0.0.1:8091 --name=bob --out=/tmp/received.kzip --room=demo-room") } } } @@ -84,16 +84,18 @@ private fun runMakePack(args: Array<String>) { private fun runSender(args: Array<String>) { val endpoint = arg(args, "endpoint", "127.0.0.1:8091") val name = arg(args, "name", "alice") + val room = arg(args, "room", "demo-room") val packPath = arg(args, "pack", "/tmp/fsmp-demo-pack.kzip") val packBytes = Files.readAllBytes(Paths.get(packPath)) val runtime = DesktopDistributionRuntime( selfPeerId = name, signalingEndpoint = "ws://$endpoint/ws", + roomId = room, ) runtime.offerPack("demo-pack-1", packBytes) runBlocking { runtime.start() - println("[sender:$name] started, pack=${packBytes.size} bytes, waiting for peers...") + println("[sender:$name] started room=$room, pack=${packBytes.size} bytes, waiting for peers...") val deadline = System.currentTimeMillis() + 120_000 while (System.currentTimeMillis() < deadline) { val snap = runtime.snapshot.value @@ -111,17 +113,19 @@ private fun runSender(args: Array<String>) { private fun runReceiver(args: Array<String>) { val endpoint = arg(args, "endpoint", "127.0.0.1:8091") val name = arg(args, "name", "bob") + val room = arg(args, "room", "demo-room") val outPath = arg(args, "out", "/tmp/fsmp-demo-received.kzip") var verifiedResult = false val done = kotlinx.coroutines.CompletableDeferred<Unit>() val runtime = DesktopDistributionRuntime( selfPeerId = name, signalingEndpoint = "ws://$endpoint/ws", + roomId = room, ) runtime.onPackReceived { packId, bytes, verified -> verifiedResult = verified Files.write(Paths.get(outPath), bytes) - println("[receiver:$name] pack=$packId verified=$verified bytes=${bytes.size} -> $outPath") + println("[receiver:$name] room=$room pack=$packId verified=$verified bytes=${bytes.size} -> $outPath") done.complete(Unit) } runBlocking { diff --git a/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/runtime/DesktopDistributionRuntime.kt b/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/runtime/DesktopDistributionRuntime.kt index b820420..4f36967 100644 --- a/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/runtime/DesktopDistributionRuntime.kt +++ b/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/runtime/DesktopDistributionRuntime.kt @@ -34,6 +34,7 @@ class DesktopDistributionRuntime( private val signalingEndpoint: String = "ws://127.0.0.1:8091/ws", private val embedSignalingServer: Boolean = false, private val signalingPort: Int = 8091, + private val roomId: String = "default", ) : DistributionRuntime, ConfigRuntime { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -91,8 +92,8 @@ class DesktopDistributionRuntime( signalingServer = server endpoint = "ws://127.0.0.1:${server.actualPort()}/ws" } - lastEvent = "starting $endpoint" - val client = WsSignalingClient(endpoint, selfPeerId) + lastEvent = "starting $endpoint room=$roomId" + val client = WsSignalingClient(endpoint, selfPeerId, roomId = roomId) val mesh = WebRtcMeshTransport( peerId = PeerId(selfPeerId), signaling = client, diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/mesh/WebRtcMeshTransport.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/mesh/WebRtcMeshTransport.kt index 0b38e96..7be5737 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/mesh/WebRtcMeshTransport.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/mesh/WebRtcMeshTransport.kt @@ -62,7 +62,7 @@ class WebRtcMeshTransport( suspend fun start() { state = FsmpTransportState.CONNECTING signaling.connect() - val join = PeerJoinSignal(peerId.value, capabilitiesJson = "{}") + val join = PeerJoinSignal(peerId.value, capabilitiesJson = "{}", roomId = signaling.roomId) signaling.send( SignalingEnvelope( type = TYPE_JOIN, diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/KtorSignalingClient.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/KtorSignalingClient.kt index ca6d49f..8f9b01d 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/KtorSignalingClient.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/KtorSignalingClient.kt @@ -8,6 +8,7 @@ class KtorSignalingClient( val endpointUrl: String, val selfPeerId: String, private val transmit: suspend (ByteArray) -> Unit, + override val roomId: String = "default", ) : SignalingClient { private val _incoming = MutableSharedFlow<SignalingEnvelope>( diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt index 3555df4..b0d11f0 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt @@ -7,4 +7,5 @@ data class PeerJoinSignal( val peerId: String, val displayName: String = "", val capabilitiesJson: String = "{}", + val roomId: String = "default", ) diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingClient.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingClient.kt index 5592740..b5dd5d1 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingClient.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingClient.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow interface SignalingClient { + val roomId: String get() = "default" + val incoming: SharedFlow<SignalingEnvelope> val connected: Boolean diff --git a/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/PeerJoinSignalSerializationTest.kt b/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/PeerJoinSignalSerializationTest.kt new file mode 100644 index 0000000..1811ad8 --- /dev/null +++ b/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/PeerJoinSignalSerializationTest.kt @@ -0,0 +1,29 @@ +package jp.orgflow.transport + +import jp.orgflow.transport.signaling.PeerJoinSignal +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class PeerJoinSignalSerializationTest { + + @Test + fun roomIdSerializesAndDeserializes() { + val signal = PeerJoinSignal(peerId = "peer-1", roomId = "room-a") + val encoded = Json.encodeToString(PeerJoinSignal.serializer(), signal) + val decoded = Json.decodeFromString(PeerJoinSignal.serializer(), encoded) + assertEquals("peer-1", decoded.peerId) + assertEquals("room-a", decoded.roomId) + } + + @Test + fun legacyPayloadWithoutRoomIdDecodesToDefault() { + val decoded = Json.decodeFromString(PeerJoinSignal.serializer(), "{\"peerId\":\"peer-1\"}") + assertEquals("default", decoded.roomId) + } + + @Test + fun defaultRoomIdIsDefault() { + assertEquals("default", PeerJoinSignal(peerId = "peer-1").roomId) + } +} diff --git a/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt b/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt index 17f6492..8994e2b 100644 --- a/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt +++ b/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt @@ -19,6 +19,7 @@ import kotlinx.serialization.json.Json class WsSignalingClient( private val endpoint: String, private val selfPeerId: String, + override val roomId: String = "default", private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), ) : SignalingClient { diff --git a/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServer.kt b/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServer.kt index 24be049..7342efa 100644 --- a/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServer.kt +++ b/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServer.kt @@ -20,7 +20,7 @@ class WsSignalingServer( private val port: Int = 0, ) { private val json = Json { ignoreUnknownKeys = true } - private val writers = ConcurrentHashMap<String, OutputStream>() + private val rooms = ConcurrentHashMap<String, ConcurrentHashMap<String, OutputStream>>() private val writeLocks = ConcurrentHashMap<String, Any>() private val nonce = AtomicLong(0) @@ -87,12 +87,20 @@ class WsSignalingServer( val joinEnvelope = parse(firstMessage) ?: return if (joinEnvelope.type != TYPE_JOIN) return val peerId = joinEnvelope.senderPeerId - writers[peerId] = output - writeLocks[peerId] = Any() - System.err.println("[signaling] registered $peerId from ${socket.remoteSocketAddress}") + val roomId = joinRoomId(joinEnvelope) + val room = roomFor(roomId) + val roster: List<String> + val joinTargets: List<String> + synchronized(room) { + room[peerId] = output + writeLocks[peerId] = Any() + roster = rosterPayload(room, roomId, peerId) + joinTargets = room.keys.filter { it != peerId } + } + System.err.println("[signaling] registered $peerId in room $roomId from ${socket.remoteSocketAddress}") - rosterPayload(peerId).forEach { payload -> sendText(peerId, payload) } - writers.keys.filter { it != peerId }.forEach { other -> + roster.forEach { payload -> sendText(peerId, payload) } + joinTargets.forEach { other -> sendText( other, json.encodeToString( @@ -115,16 +123,16 @@ class WsSignalingServer( val target = envelope.targetPeerId val payload = json.encodeToString(SignalingEnvelope.serializer(), envelope) if (target == null) { - writers.keys.filter { it != peerId }.forEach { other -> sendText(other, payload) } + room.keys.filter { it != peerId }.forEach { other -> sendText(other, payload) } } else { - System.err.println("[signaling] relay ${envelope.type} $peerId -> $target (${writers.containsKey(target)})") + System.err.println("[signaling] relay ${envelope.type} $peerId -> $target (${writerFor(target) != null})") sendText(target, payload) } } } finally { - writers.remove(peerId) + room.remove(peerId) writeLocks.remove(peerId) - writers.keys.forEach { other -> + room.keys.forEach { other -> sendText( other, json.encodeToString( @@ -144,7 +152,7 @@ class WsSignalingServer( } private fun sendText(peerId: String, text: String) { - val output = writers[peerId] ?: return + val output = writerFor(peerId) ?: return val lock = writeLocks[peerId] ?: return synchronized(lock) { runCatching { @@ -154,7 +162,11 @@ class WsSignalingServer( } } - private fun rosterPayload(peerId: String): List<String> = writers.keys.filter { it != peerId }.map { other -> + private fun rosterPayload( + room: ConcurrentHashMap<String, OutputStream>, + roomId: String, + peerId: String, + ): List<String> = room.keys.filter { it != peerId }.map { other -> json.encodeToString( SignalingEnvelope.serializer(), SignalingEnvelope( @@ -162,11 +174,25 @@ class WsSignalingServer( senderPeerId = other, nonce = nonce.incrementAndGet(), timestampMs = nowMs(), - payloadJson = json.encodeToString(PeerJoinSignal.serializer(), PeerJoinSignal(other)), + payloadJson = json.encodeToString(PeerJoinSignal.serializer(), PeerJoinSignal(other, roomId = roomId)), ), ) } + private fun roomFor(roomId: String): ConcurrentHashMap<String, OutputStream> = + rooms.computeIfAbsent(roomId) { ConcurrentHashMap() } + + private fun joinRoomId(envelope: SignalingEnvelope): String = try { + json.decodeFromString(PeerJoinSignal.serializer(), envelope.payloadJson).roomId.ifBlank { DEFAULT_ROOM } + } catch (e: Exception) { + DEFAULT_ROOM + } + + private fun writerFor(peerId: String): OutputStream? { + rooms.values.forEach { room -> room[peerId]?.let { return it } } + return null + } + private fun readTextFrame(input: InputStream): String? { val header = ByteArray(2) if (!fill(input, header)) return null @@ -239,6 +265,7 @@ class WsSignalingServer( const val TYPE_JOIN = "join" const val TYPE_PEER_JOINED = "peer-joined" const val TYPE_PEER_LEFT = "peer-left" + const val DEFAULT_ROOM = "default" const val WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" const val OPCODE_TEXT = 0x1 const val OPCODE_CLOSE = 0x8 diff --git a/modules/fsmp-transport/src/jvmTest/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServerRoomRoutingTest.kt b/modules/fsmp-transport/src/jvmTest/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServerRoomRoutingTest.kt new file mode 100644 index 0000000..c16c799 --- /dev/null +++ b/modules/fsmp-transport/src/jvmTest/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServerRoomRoutingTest.kt @@ -0,0 +1,149 @@ +package jp.orgflow.transport.signaling.server + +import java.net.URI +import java.net.http.HttpClient +import java.net.http.WebSocket +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail +import jp.orgflow.transport.signaling.PeerJoinSignal +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +class WsSignalingServerRoomRoutingTest { + + private class TestClient(endpoint: String) { + private val received = LinkedBlockingQueue<String>() + private val connected = CompletableFuture<Unit>() + private val json = Json { ignoreUnknownKeys = true } + val socket: WebSocket = HttpClient.newHttpClient().newWebSocketBuilder().buildAsync( + URI.create(endpoint), + object : WebSocket.Listener { + override fun onOpen(webSocket: WebSocket): Unit { + webSocket.request(1) + connected.complete(Unit) + } + + override fun onText(webSocket: WebSocket, data: CharSequence, last: Boolean): CompletionStage<*>? { + webSocket.request(1) + if (last) received.add(data.toString()) + return null + } + }, + ).get() + + fun join(peerId: String, roomId: String) { + socket.sendText(envelope("join", peerId, payloadJson = json.encodeToString(PeerJoinSignal.serializer(), PeerJoinSignal(peerId, roomId = roomId))), true).get() + } + + fun joinLegacy(peerId: String) { + socket.sendText(envelope("join", peerId, payloadJson = "{\"peerId\":\"$peerId\"}"), true).get() + } + + fun send(type: String, senderPeerId: String, targetPeerId: String? = null) { + socket.sendText(envelope(type, senderPeerId, targetPeerId, payloadJson = "{}"), true).get() + } + + fun abort() { + socket.abort() + } + + fun awaitMessage(timeoutMs: Long = 5000): JsonObject { + val text = received.poll(timeoutMs, TimeUnit.MILLISECONDS) + ?: fail("no message within ${timeoutMs}ms") + return Json.parseToJsonElement(text).jsonObject + } + + fun expectSilence(timeoutMs: Long = 600): Boolean = received.poll(timeoutMs, TimeUnit.MILLISECONDS) == null + + private fun envelope(type: String, senderPeerId: String, targetPeerId: String? = null, payloadJson: String): String { + val obj = kotlinx.serialization.json.buildJsonObject { + put("type", type) + put("senderPeerId", senderPeerId) + if (targetPeerId != null) put("targetPeerId", targetPeerId) + put("nonce", System.nanoTime()) + put("timestampMs", System.currentTimeMillis()) + put("payloadJson", payloadJson) + } + return obj.toString() + } + } + + private fun field(envelope: JsonObject, key: String): String = envelope[key]!!.jsonPrimitive.content + + @Test + fun peersInSameRoomSeeEachOtherButOtherRoomsDoNot() { + val server = WsSignalingServer(port = 0) + server.start() + val endpoint = "ws://127.0.0.1:${server.actualPort()}/ws" + try { + val alice = TestClient(endpoint) + alice.join("alice", "room-a") + val bob = TestClient(endpoint) + bob.join("bob", "room-a") + val carol = TestClient(endpoint) + carol.join("carol", "room-b") + + val rosterForBob = bob.awaitMessage() + assertEquals("peer-joined", field(rosterForBob, "type")) + assertEquals("alice", field(rosterForBob, "senderPeerId")) + assertEquals("room-a", field(Json.parseToJsonElement(field(rosterForBob, "payloadJson")).jsonObject, "roomId")) + + val peerJoinedForAlice = alice.awaitMessage() + assertEquals("peer-joined", field(peerJoinedForAlice, "type")) + assertEquals("bob", field(peerJoinedForAlice, "senderPeerId")) + + carol.send("note", "carol") + assertTrue(carol.expectSilence(), "carol in empty room must not be echoed") + assertTrue(alice.expectSilence(), "alice must not receive broadcasts from other rooms") + assertTrue(bob.expectSilence(), "bob must not receive broadcasts from other rooms") + + bob.send("note", "bob") + val broadcast = alice.awaitMessage() + assertEquals("note", field(broadcast, "type")) + assertEquals("bob", field(broadcast, "senderPeerId")) + assertTrue(carol.expectSilence(), "carol must not receive broadcasts from other rooms") + + alice.send("direct", "alice", targetPeerId = "carol") + val targeted = carol.awaitMessage() + assertEquals("direct", field(targeted, "type")) + assertEquals("alice", field(targeted, "senderPeerId")) + assertTrue(bob.expectSilence(), "targeted relay must not be delivered to third parties") + + alice.abort() + val leftForBob = bob.awaitMessage() + assertEquals("peer-left", field(leftForBob, "type")) + assertEquals("alice", field(leftForBob, "senderPeerId")) + assertTrue(carol.expectSilence(), "carol must not receive peer-left from other rooms") + } finally { + server.stop() + } + } + + @Test + fun joinWithoutRoomIdFallsBackToDefaultRoom() { + val server = WsSignalingServer(port = 0) + server.start() + val endpoint = "ws://127.0.0.1:${server.actualPort()}/ws" + try { + val dave = TestClient(endpoint) + dave.joinLegacy("dave") + val eve = TestClient(endpoint) + eve.joinLegacy("eve") + + val peerJoinedForDave = dave.awaitMessage() + assertEquals("peer-joined", field(peerJoinedForDave, "type")) + assertEquals("eve", field(peerJoinedForDave, "senderPeerId")) + } finally { + server.stop() + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt index 15ff360..b84abaa 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt @@ -16,11 +16,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import jp.orgflow.ui.runtime.OrgFlowRuntime +import jp.orgflow.ui.store.OrgFlowAppStore @Composable fun OrgFlowApp( runtime: OrgFlowRuntime = OrgFlowRuntime.UNAVAILABLE, navigation: OrgFlowNavigation = remember { OrgFlowNavigation() }, + store: OrgFlowAppStore = remember { OrgFlowAppStore() }, ) { OrgFlowTheme { Scaffold { padding -> @@ -40,10 +42,11 @@ fun OrgFlowApp( Column(modifier = Modifier.fillMaxSize()) { when (navigation.current) { OrgFlowRoute.Onboarding -> jp.orgflow.ui.onboarding.OnboardingScreen() - OrgFlowRoute.Home -> jp.orgflow.ui.home.HomeScreen() - OrgFlowRoute.Notes -> jp.orgflow.ui.notes.NotesScreen() - OrgFlowRoute.Capture -> jp.orgflow.ui.capture.CaptureScreen() - OrgFlowRoute.Agenda -> jp.orgflow.ui.agenda.AgendaScreen() + OrgFlowRoute.Home -> jp.orgflow.ui.home.HomeScreen(store) + OrgFlowRoute.Notes -> jp.orgflow.ui.notes.NotesScreen(store) + OrgFlowRoute.Capture -> jp.orgflow.ui.capture.CaptureScreen(store) + OrgFlowRoute.Agenda -> jp.orgflow.ui.agenda.AgendaScreen(store) + OrgFlowRoute.Calendar -> jp.orgflow.ui.calendar.CalendarScreen(store) OrgFlowRoute.Experiment -> jp.orgflow.ui.experiment.ExperimentTableScreen() OrgFlowRoute.Presentation -> jp.orgflow.ui.presentation.PresentationBuilderScreen() OrgFlowRoute.Distribution -> jp.orgflow.ui.distribution.DistributionScreen(runtime.distribution) diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt index 383684e..076fe54 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt @@ -6,6 +6,7 @@ sealed class OrgFlowRoute(val id: String) { data object Notes : OrgFlowRoute("notes") data object Capture : OrgFlowRoute("capture") data object Agenda : OrgFlowRoute("agenda") + data object Calendar : OrgFlowRoute("calendar") data object Experiment : OrgFlowRoute("experiment") data object Presentation : OrgFlowRoute("presentation") data object Distribution : OrgFlowRoute("distribution") @@ -14,7 +15,7 @@ sealed class OrgFlowRoute(val id: String) { companion object { val all: List<OrgFlowRoute> by lazy { - listOf(Home, Notes, Capture, Agenda, Experiment, Presentation, Distribution, Workspace, Config) + listOf(Home, Notes, Capture, Agenda, Calendar, Experiment, Presentation, Distribution, Workspace, Config) } } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaScreen.kt index 98964e3..a3b1611 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaScreen.kt @@ -15,9 +15,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import jp.orgflow.ui.component.EmptyContent +import jp.orgflow.ui.store.OrgFlowAppStore @Composable -fun AgendaScreen(viewModel: AgendaViewModel = remember { AgendaViewModel() }) { +fun AgendaScreen( + store: OrgFlowAppStore = remember { OrgFlowAppStore() }, + viewModel: AgendaViewModel = remember(store) { AgendaViewModel(store) }, +) { Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { Text("Agenda", fontSize = 17.sp) if (viewModel.items.isEmpty()) { diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaViewModel.kt index f83df47..c279b1e 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaViewModel.kt @@ -1,28 +1,20 @@ package jp.orgflow.ui.agenda -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import jp.orgflow.domain.calendar.AgendaItem +import jp.orgflow.ui.store.OrgFlowAppStore import kotlinx.datetime.LocalDateTime -class AgendaViewModel { - var items: List<AgendaItem> by mutableStateOf(sampleItems()) - private set +class AgendaViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()) { + val items: List<AgendaItem> + get() = store.agendaItems fun toggleDone(id: String) { - items = items.map { if (it.id == id) it.copy(done = !it.done) else it } + store.toggleAgendaDone(id) } fun forDate(date: LocalDateTime): List<AgendaItem> = - items.filter { it.scheduledAt.date == date.date }.sortedBy { it.scheduledAt.toString() } + store.agendaItems.filter { it.scheduledAt.date == date.date }.sortedBy { it.scheduledAt.toString() } fun overdue(now: LocalDateTime): List<AgendaItem> = - items.filter { !it.done && it.scheduledAt < now } - - private fun sampleItems(): List<AgendaItem> = listOf( - AgendaItem("a1", "Team sync", LocalDateTime(2026, 8, 27, 10, 0)), - AgendaItem("a2", "Write experiment report", LocalDateTime(2026, 8, 27, 15, 0)), - AgendaItem("a3", "Pack rehearsal", LocalDateTime(2026, 8, 28, 9, 30)), - ) + store.agendaItems.filter { !it.done && it.scheduledAt < now } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarEvent.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarEvent.kt new file mode 100644 index 0000000..5120b89 --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarEvent.kt @@ -0,0 +1,77 @@ +package jp.orgflow.ui.calendar + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime + +enum class EventSource(val label: String) { + Personal("Personal"), + Room("Room"), + AllRooms("All rooms"), +} + +enum class EventColor { + Personal, + Room, + AllRooms, +} + +private fun colorOf(source: EventSource): EventColor = when (source) { + EventSource.Personal -> EventColor.Personal + EventSource.Room -> EventColor.Room + EventSource.AllRooms -> EventColor.AllRooms +} + +data class CalendarEvent( + val id: String, + val title: String, + val date: LocalDate, + val source: EventSource, + val color: EventColor = colorOf(source), + val roomName: String? = null, + val time: LocalTime? = null, +) { + fun startsAt(): LocalDateTime = LocalDateTime(date, time ?: defaultStartTime) + + fun sourceLabel(): String = when (source) { + EventSource.Personal -> EventSource.Personal.label + EventSource.Room -> "${EventSource.Room.label}: ${roomName ?: "unknown"}" + EventSource.AllRooms -> EventSource.AllRooms.label + } + + companion object { + val defaultStartTime: LocalTime = LocalTime(9, 0) + } +} + +enum class CalendarFilter(val label: String) { + Personal("Personal"), + Room("Room"), + All("All"); + + fun visible(event: CalendarEvent): Boolean = when (this) { + Personal -> event.source == EventSource.Personal + Room -> event.source != EventSource.Personal + All -> true + } +} + +data class NotificationSettings( + val notifyEnabled: Boolean = true, + val minutesBefore: Int = 30, + val targets: Set<EventSource> = EventSource.entries.toSet(), +) + +// TODO(W4): classroom chat / room sync supplies shared room events here (union view). +fun interface RoomEventProvider { + suspend fun roomEvents(): List<CalendarEvent> + + companion object { + val Empty: RoomEventProvider = RoomEventProvider { emptyList() } + } +} + +fun mergeCalendarEvents(personal: List<CalendarEvent>, room: List<CalendarEvent>): List<CalendarEvent> = + (personal + room) + .distinctBy { it.id } + .sortedWith(compareBy({ it.date }, { it.time ?: CalendarEvent.defaultStartTime }, { it.title })) diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarScreen.kt new file mode 100644 index 0000000..6238908 --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarScreen.kt @@ -0,0 +1,312 @@ +package jp.orgflow.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.AlertDialog +import androidx.compose.material.Checkbox +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import jp.orgflow.ui.store.OrgFlowAppStore +import kotlin.math.abs +import kotlinx.datetime.LocalDate + +@Composable +fun CalendarScreen( + store: OrgFlowAppStore = remember { OrgFlowAppStore() }, + roomEventProvider: RoomEventProvider = RoomEventProvider.Empty, + viewModel: CalendarViewModel = remember(store) { CalendarViewModel(store, roomEventProvider) }, +) { + var showAddDialog by remember { mutableStateOf(false) } + var showSettingsDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.refreshRoomEvents() + viewModel.checkDueNotifications() + } + + Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Calendar", fontSize = 17.sp, modifier = Modifier.weight(1f)) + TextButton(onClick = { showSettingsDialog = true }) { Text("Settings") } + } + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { viewModel.previous() }) { Text("<") } + Text(viewModel.headerLabel(), fontSize = 14.sp, modifier = Modifier.weight(1f)) + TextButton(onClick = { viewModel.next() }) { Text(">") } + TextButton(onClick = { viewModel.toggleViewMode() }) { + Text(if (viewModel.viewMode == CalendarViewMode.Month) "Week" else "Month") + } + TextButton(onClick = { showAddDialog = true }) { Text("+ Add") } + } + Row(modifier = Modifier.padding(vertical = 4.dp)) { + CalendarFilter.entries.forEach { candidate -> + val selected = viewModel.filter == candidate + Text( + text = candidate.label, + fontSize = 12.sp, + fontWeight = if (selected) FontWeight.Bold else null, + color = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(end = 8.dp).clickable { viewModel.filter = candidate }, + ) + } + } + when (viewModel.viewMode) { + CalendarViewMode.Month -> MonthGrid(viewModel) + CalendarViewMode.Week -> WeekList(viewModel) + } + Spacer(modifier = Modifier.height(8.dp)) + DayEventList(viewModel) + } + + if (showAddDialog) { + AddEventDialog( + defaultDate = viewModel.selectedDate.toString(), + onAdd = { title, date, time -> viewModel.addEvent(title, date, time) }, + onDismiss = { showAddDialog = false }, + ) + } + if (showSettingsDialog) { + NotificationSettingsDialog(viewModel, onDismiss = { showSettingsDialog = false }) + } +} + +@Composable +private fun MonthGrid(viewModel: CalendarViewModel) { + Column(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth()) { + listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun").forEach { label -> + Text( + label, + fontSize = 11.sp, + modifier = Modifier.weight(1f), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + ) + } + } + viewModel.monthDays().chunked(7).forEach { week -> + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) { + week.forEach { day -> DayCell(viewModel, day, Modifier.weight(1f)) } + } + } + } +} + +@Composable +private fun DayCell(viewModel: CalendarViewModel, day: LocalDate?, modifier: Modifier) { + val events = day?.let { viewModel.eventsOn(it) } ?: emptyList() + Box( + modifier = modifier + .padding(1.dp) + .background( + color = if (day != null && day == viewModel.selectedDate) { + MaterialTheme.colors.primary.copy(alpha = 0.15f) + } else { + Color.Transparent + }, + shape = RoundedCornerShape(4.dp), + ) + .clickable(enabled = day != null) { day?.let(viewModel::selectDay) } + .padding(2.dp), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { + Text( + text = day?.dayOfMonth?.toString() ?: "", + fontSize = 12.sp, + fontWeight = if (day != null && day == viewModel.today) FontWeight.Bold else null, + color = if (day != null && day == viewModel.today) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface, + ) + Row { + events.take(3).forEach { event -> + Box( + modifier = Modifier + .padding(end = 2.dp) + .size(5.dp) + .background(eventColor(event), CircleShape), + ) + } + } + } + } +} + +@Composable +private fun WeekList(viewModel: CalendarViewModel) { + Column(modifier = Modifier.fillMaxWidth()) { + viewModel.weekDays().forEach { day -> + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text( + day.toString(), + fontSize = 12.sp, + modifier = Modifier.width(96.dp).clickable { viewModel.selectDay(day) }, + ) + Column(modifier = Modifier.weight(1f)) { + viewModel.eventsOn(day).forEach { event -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .padding(end = 4.dp) + .size(6.dp) + .background(eventColor(event), CircleShape), + ) + Text( + if (event.time == null) event.title else "${event.title} ${event.time}", + fontSize = 12.sp, + ) + } + } + } + } + } + } +} + +@Composable +private fun DayEventList(viewModel: CalendarViewModel) { + val events = viewModel.eventsOn(viewModel.selectedDate) + Text("Events on ${viewModel.selectedDate}", fontSize = 14.sp) + if (events.isEmpty()) { + Text( + "No events", + fontSize = 12.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(top = 4.dp), + ) + } else { + Column { + events.forEach { event -> + Row(modifier = Modifier.padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .padding(end = 6.dp) + .size(8.dp) + .background(eventColor(event), CircleShape), + ) + Column { + Text(event.title, fontSize = 13.sp) + Text( + "${event.startsAt()} · ${event.sourceLabel()}", + fontSize = 11.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + ) + } + } + } + } + } +} + +@Composable +private fun AddEventDialog( + defaultDate: String, + onAdd: (title: String, date: String, time: String) -> Boolean, + onDismiss: () -> Unit, +) { + var title by remember { mutableStateOf("") } + var date by remember { mutableStateOf(defaultDate) } + var time by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf<String?>(null) } + AlertDialog( + title = { Text("Add event") }, + text = { + Column { + OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Title") }, singleLine = true) + OutlinedTextField( + value = date, + onValueChange = { date = it }, + label = { Text("Date (YYYY-MM-DD, today, tomorrow)") }, + singleLine = true, + ) + OutlinedTextField(value = time, onValueChange = { time = it }, label = { Text("Time (HH:MM, optional)") }, singleLine = true) + errorMessage?.let { + Text(it, fontSize = 11.sp, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 4.dp)) + } + } + }, + confirmButton = { + TextButton(onClick = { + if (onAdd(title, date, time)) { + onDismiss() + } else { + errorMessage = "Enter a valid date (YYYY-MM-DD) and optional time (HH:MM)" + } + }) { Text("Add") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + onDismissRequest = onDismiss, + ) +} + +@Composable +private fun NotificationSettingsDialog(viewModel: CalendarViewModel, onDismiss: () -> Unit) { + val settings = viewModel.notificationSettings + var minutes by remember { mutableStateOf(settings.minutesBefore.toString()) } + AlertDialog( + title = { Text("Notification settings") }, + text = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = settings.notifyEnabled, onCheckedChange = { viewModel.setNotifyEnabled(it) }) + Text("Notify about upcoming events", fontSize = 13.sp) + } + OutlinedTextField( + value = minutes, + onValueChange = { + minutes = it + it.toIntOrNull()?.let(viewModel::setMinutesBefore) + }, + label = { Text("Minutes before") }, + singleLine = true, + ) + Text("Targets", fontSize = 13.sp, modifier = Modifier.padding(top = 8.dp)) + EventSource.entries.forEach { source -> + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = source in settings.targets, onCheckedChange = { viewModel.toggleTarget(source) }) + Text(source.label, fontSize = 13.sp) + } + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } }, + onDismissRequest = onDismiss, + ) +} + +private val roomPalette = listOf( + Color(0xFF2E7D32), + Color(0xFF00838F), + Color(0xFF6D4C41), + Color(0xFFAD1457), + Color(0xFFEF6C00), +) + +private fun eventColor(event: CalendarEvent): Color = when (event.color) { + EventColor.Personal -> Color(0xFF1565C0) + EventColor.Room -> roomPalette[abs(event.roomName?.hashCode() ?: 0) % roomPalette.size] + EventColor.AllRooms -> Color(0xFF6A1B9A) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarViewModel.kt new file mode 100644 index 0000000..92b51d7 --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarViewModel.kt @@ -0,0 +1,200 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package jp.orgflow.ui.calendar + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import jp.orgflow.domain.calendar.AgendaItem +import jp.orgflow.ui.capture.FiveW1HField +import jp.orgflow.ui.notification.Notifier +import jp.orgflow.ui.notification.defaultNotifier +import jp.orgflow.ui.store.CaptureEntry +import jp.orgflow.ui.store.OrgFlowAppStore +import kotlin.time.Clock +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.isoDayNumber +import kotlinx.datetime.toLocalDateTime + +enum class CalendarViewMode(val label: String) { + Month("Month"), + Week("Week"), +} + +class CalendarViewModel( + private val store: OrgFlowAppStore = OrgFlowAppStore(), + private val roomEventProvider: RoomEventProvider = RoomEventProvider.Empty, + private val notifier: Notifier = defaultNotifier(), + private val now: () -> LocalDateTime = { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) }, +) { + val today: LocalDate = now().date + + var viewMode: CalendarViewMode by mutableStateOf(CalendarViewMode.Month) + var filter: CalendarFilter by mutableStateOf(CalendarFilter.All) + var month: LocalDate by mutableStateOf(today.withDay(1)) + var selectedDate: LocalDate by mutableStateOf(today) + var roomEvents: List<CalendarEvent> by mutableStateOf(emptyList()) + private set + + val notificationSettings: NotificationSettings + get() = store.notificationSettings + + val notifiedEventIds: Set<String> + get() = store.notifiedEventIds + + val events: List<CalendarEvent> + get() = mergeCalendarEvents(personalEvents(), roomEvents) + + val visibleEvents: List<CalendarEvent> + get() = events.filter { filter.visible(it) } + + fun eventsOn(date: LocalDate): List<CalendarEvent> = visibleEvents.filter { it.date == date } + + fun personalEvents(): List<CalendarEvent> = buildList { + addAll(store.calendarEvents) + store.agendaItems.forEach { add(agendaEvent(it)) } + store.activities.forEach { entry -> captureEvent(entry)?.let { add(it) } } + } + + fun selectDay(date: LocalDate) { + selectedDate = date + } + + fun toggleViewMode() { + viewMode = if (viewMode == CalendarViewMode.Month) CalendarViewMode.Week else CalendarViewMode.Month + } + + fun previous() { + if (viewMode == CalendarViewMode.Month) month = shiftMonth(month, -1) else shiftSelected(-7) + } + + fun next() { + if (viewMode == CalendarViewMode.Month) month = shiftMonth(month, 1) else shiftSelected(7) + } + + fun monthDays(): List<LocalDate?> { + val leading = month.dayOfWeek.isoDayNumber - 1 + val cells: List<LocalDate?> = List(leading) { null } + + (1..daysInMonth(month.year, month.monthNumber)).map { LocalDate(month.year, month.monthNumber, it) } + return cells + List((7 - cells.size % 7) % 7) { null } + } + + fun weekDays(): List<LocalDate> { + val start = LocalDate.fromEpochDays(selectedDate.toEpochDays() - (selectedDate.dayOfWeek.isoDayNumber - 1)) + return (0..6).map { LocalDate.fromEpochDays(start.toEpochDays() + it) } + } + + fun headerLabel(): String = if (viewMode == CalendarViewMode.Month) { + "${month.year}-${month.monthNumber.toString().padStart(2, '0')}" + } else { + val week = weekDays() + "${week.first()} - ${week.last()}" + } + + fun addEvent(title: String, dateText: String, timeText: String): Boolean { + val parsed = WhenTextParser.parse(dateText, today) ?: return false + val explicitTime = parseTime(timeText) + if (timeText.isNotBlank() && explicitTime == null) return false + store.addCalendarEvent(title, parsed.first, explicitTime ?: parsed.second) + return true + } + + suspend fun refreshRoomEvents() { + roomEvents = runCatching { roomEventProvider.roomEvents() }.getOrDefault(emptyList()) + } + + fun checkDueNotifications(): List<CalendarEvent> { + val due = dueSoon(events, now()) + due.forEach { notifier.notify(it.title, "starts at ${it.startsAt()}") } + if (due.isNotEmpty()) store.markNotified(due.map { it.id }) + return due + } + + fun dueSoon( + events: List<CalendarEvent>, + now: LocalDateTime, + settings: NotificationSettings = store.notificationSettings, + notified: Set<String> = store.notifiedEventIds, + ): List<CalendarEvent> { + if (!settings.notifyEnabled) return emptyList() + return events.filter { event -> + event.id !in notified && + event.source in settings.targets && + minutesUntil(event, now) in 0..settings.minutesBefore.toLong() + } + } + + fun setNotifyEnabled(enabled: Boolean) { + store.updateNotificationSettings(notificationSettings.copy(notifyEnabled = enabled)) + } + + fun setMinutesBefore(minutes: Int) { + if (minutes >= 0) store.updateNotificationSettings(notificationSettings.copy(minutesBefore = minutes)) + } + + fun toggleTarget(source: EventSource) { + val targets = notificationSettings.targets + val updated = if (source in targets) targets - source else targets + source + store.updateNotificationSettings(notificationSettings.copy(targets = updated)) + } + + private fun agendaEvent(item: AgendaItem): CalendarEvent = CalendarEvent( + id = "agenda-${item.id}", + title = item.title, + date = item.scheduledAt.date, + source = EventSource.Personal, + time = item.scheduledAt.time, + ) + + private fun captureEvent(entry: CaptureEntry): CalendarEvent? { + val parsed = WhenTextParser.parse(entry.fields[FiveW1HField.WHEN] ?: "", today) ?: return null + val title = entry.fields[FiveW1HField.WHAT]?.takeIf { it.isNotBlank() } ?: entry.templateType + return CalendarEvent( + id = "capture-${entry.id.value}", + title = title, + date = parsed.first, + source = EventSource.Personal, + time = parsed.second, + ) + } + + private fun shiftSelected(days: Int) { + selectedDate = LocalDate.fromEpochDays(selectedDate.toEpochDays() + days) + month = selectedDate.withDay(1) + } + + private fun parseTime(text: String): LocalTime? { + val match = TIME_RX.find(text.trim()) ?: return null + val hour = match.groupValues[1].toInt() + val minute = match.groupValues[2].toInt() + return if (hour in 0..23 && minute in 0..59) LocalTime(hour, minute) else null + } + + private fun minutesUntil(event: CalendarEvent, now: LocalDateTime): Long { + val at = event.startsAt() + val dayMinutes = (at.date.toEpochDays() - now.date.toEpochDays()) * 24L * 60 + return dayMinutes + at.hour * 60L + at.minute - (now.hour * 60L + now.minute) + } + + companion object { + private val TIME_RX = Regex("(\\d{1,2}):(\\d{2})") + + private fun shiftMonth(date: LocalDate, delta: Int): LocalDate { + val total = date.year * 12 + (date.monthNumber - 1) + delta + return LocalDate(total / 12, total % 12 + 1, 1) + } + + private fun daysInMonth(year: Int, monthNumber: Int): Int = when (monthNumber) { + 1, 3, 5, 7, 8, 10, 12 -> 31 + 4, 6, 9, 11 -> 30 + else -> if (isLeapYear(year)) 29 else 28 + } + + private fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 + } +} + +private fun LocalDate.withDay(day: Int): LocalDate = LocalDate(year, monthNumber, day) diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/WhenTextParser.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/WhenTextParser.kt new file mode 100644 index 0000000..374a16d --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/WhenTextParser.kt @@ -0,0 +1,35 @@ +package jp.orgflow.ui.calendar + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime + +// helper - parses capture When fields such as "today 14:00" or "<2026-08-27 Tue 09:00>" into calendar dates. +object WhenTextParser { + + private val DATE_RX = Regex("(\\d{4})-(\\d{2})-(\\d{2})") + private val TIME_RX = Regex("(\\d{1,2}):(\\d{2})") + + fun parse(input: String, today: LocalDate): Pair<LocalDate, LocalTime?>? { + val trimmed = input.trim() + if (trimmed.isEmpty()) return null + val date: LocalDate = when { + trimmed.startsWith("today", ignoreCase = true) -> today + trimmed.startsWith("tomorrow", ignoreCase = true) -> LocalDate.fromEpochDays(today.toEpochDays() + 1) + else -> DATE_RX.find(trimmed)?.let { match -> + runCatching { + LocalDate( + match.groupValues[1].toInt(), + match.groupValues[2].toInt(), + match.groupValues[3].toInt(), + ) + }.getOrNull() + } + } ?: return null + val time: LocalTime? = TIME_RX.find(trimmed)?.let { match -> + val hour = match.groupValues[1].toInt() + val minute = match.groupValues[2].toInt() + if (hour in 0..23 && minute in 0..59) LocalTime(hour, minute) else null + } + return date to time + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt index ffd427d..d7b4f05 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt @@ -13,10 +13,14 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import jp.orgflow.ui.store.OrgFlowAppStore @OptIn(ExperimentalMaterialApi::class) @Composable -fun CaptureScreen(viewModel: CaptureViewModel = remember { CaptureViewModel() }) { +fun CaptureScreen( + store: OrgFlowAppStore = remember { OrgFlowAppStore() }, + viewModel: CaptureViewModel = remember(store) { CaptureViewModel(store) }, +) { Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { Text("Capture", fontSize = 17.sp) Row(modifier = Modifier.padding(vertical = 6.dp)) { @@ -43,6 +47,7 @@ fun CaptureScreen(viewModel: CaptureViewModel = remember { CaptureViewModel() }) viewModel.lastResult?.let { result -> Text("org preview:", fontSize = 12.sp, modifier = Modifier.padding(top = 12.dp)) Text(result.orgSnippet, fontSize = 12.sp) + result.activityId?.let { Text("saved as ${it.value}", fontSize = 11.sp) } } } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt index b5a065c..329720b 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt @@ -3,13 +3,16 @@ package jp.orgflow.ui.capture import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import jp.orgflow.domain.identity.ActivityId +import jp.orgflow.ui.store.OrgFlowAppStore data class CaptureUiResult( val orgSnippet: String, val templateType: String, + val activityId: ActivityId? = null, ) -class CaptureViewModel { +class CaptureViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()) { var form: CaptureFormState by mutableStateOf(CaptureFormState(templateType = QuickCaptureShortcuts.defaultType())) private set @@ -30,6 +33,7 @@ class CaptureViewModel { fun submit(): Boolean { if (!form.isSubmittable()) return false + val activityId = store.addCapture(form.templateType, form.fields, form.attachments) val whenText = form.fields[FiveW1HField.WHAT] ?: "" val snippet = buildString { append("* ").append(form.templateType).append(": ").append(whenText.take(40)).appendLine() @@ -37,7 +41,7 @@ class CaptureViewModel { form.fields[FiveW1HField.WHY]?.takeIf { it.isNotBlank() }?.let { append(" :WHY: ").append(it).appendLine(" :END:") } if (form.attachments.isNotEmpty()) append(" attachments: ").append(form.attachments.joinToString { it.value }).appendLine() } - lastResult = CaptureUiResult(snippet, form.templateType) + lastResult = CaptureUiResult(snippet, form.templateType, activityId) return true } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableScreen.kt index c38c835..5e33486 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableScreen.kt @@ -1,8 +1,13 @@ package jp.orgflow.ui.experiment +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Tab import androidx.compose.material.TabRow @@ -15,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -29,17 +35,34 @@ fun ExperimentTableScreen(viewModel: ExperimentTableViewModel = remember { Exper Tab(selected = tab == 2, onClick = { tab = 2 }) { Text("GUI-Lisp", modifier = Modifier.padding(8.dp)) } } when (tab) { - 0 -> { - viewModel.rows.forEach { row -> + 0 -> Column(modifier = Modifier.padding(top = 4.dp)) { + val sheet = remember(viewModel.rows, viewModel.columns) { viewModel.formulaSheet() } + viewModel.rows.forEachIndexed { rowIndex, row -> Row(modifier = Modifier.padding(vertical = 2.dp)) { - viewModel.columns.forEach { column -> - OutlinedTextField( - value = row.cells[column.name] ?: "", - onValueChange = { viewModel.updateCell(row.id, column.name, it) }, - label = { Text(column.name, fontSize = 10.sp) }, - modifier = Modifier.padding(end = 6.dp).padding(vertical = 1.dp), - textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp), - ) + viewModel.columns.forEachIndexed { columnIndex, column -> + Column { + OutlinedTextField( + value = row.cells[column.name] ?: "", + onValueChange = { viewModel.updateCell(row.id, column.name, it) }, + label = { Text(column.name, fontSize = 10.sp) }, + modifier = Modifier.padding(end = 6.dp).padding(vertical = 1.dp), + textStyle = androidx.compose.ui.text.TextStyle(fontSize = 12.sp), + ) + val raw = row.cells[column.name] ?: "" + if (raw.trim().startsWith("=")) { + val result = sheet.display(rowIndex, columnIndex) + Text( + text = result, + fontSize = 10.sp, + color = if (result.startsWith("#")) { + MaterialTheme.colors.error + } else { + MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + }, + modifier = Modifier.padding(start = 4.dp), + ) + } + } } } } @@ -50,14 +73,33 @@ fun ExperimentTableScreen(viewModel: ExperimentTableViewModel = remember { Exper } 1 -> GraphTab(viewModel) 2 -> Column(modifier = Modifier.padding(top = 8.dp)) { - val editorState = remember { androidx.compose.ui.text.input.TextFieldValue("") } var text by remember { mutableStateOf("(avg (column \"yield\"))") } + Text("Insert demo:", fontSize = 11.sp) + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + GuiLispDemos.all.forEach { demo -> + TextButton(onClick = { text = GuiLispDemos.insertExpression(demo.name) }) { + Text(demo.label, fontSize = 11.sp) + } + } + } GuiLispEditor( code = text, onCodeChange = { text = it }, ) GuiLispErrorHint(text) GuiLispLivePreview(text) + GuiLispDemos.demoNameIn(text)?.let { name -> + GuiLispDemos.chartSpec(name)?.let { spec -> KoalaChartRenderer(spec) } + Text( + text = GuiLispDemos.expand(text) ?: "", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(vertical = 2.dp), + ) + } } } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableViewModel.kt index 61f1ff9..9147af3 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableViewModel.kt @@ -38,8 +38,24 @@ class ExperimentTableViewModel { return id } - fun numericColumn(name: String): List<Double> = - rows.mapNotNull { it.cells[name]?.toDoubleOrNull() } + fun formulaGrid(): List<List<String>> = + rows.map { row -> columns.map { column -> row.cells[column.name] ?: "" } } + + fun formulaSheet(): FormulaSheet = FormulaSheet(formulaGrid()) + + fun numericColumn(name: String): List<Double> { + val columnIndex = columns.indexOfFirst { it.name == name } + if (columnIndex < 0) return emptyList() + val sheet = formulaSheet() + return rows.mapIndexed { rowIndex, row -> + val raw = row.cells[name] ?: "" + if (raw.trim().startsWith("=")) { + (sheet.valueAt(FormulaRef(columnIndex, rowIndex)) as? FormulaValue.Num)?.value + } else { + raw.toDoubleOrNull() + } + }.filterNotNull() + } fun mean(name: String): Double? { val values = numericColumn(name) diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/FormulaEvaluator.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/FormulaEvaluator.kt new file mode 100644 index 0000000..dba8b26 --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/FormulaEvaluator.kt @@ -0,0 +1,341 @@ +package jp.orgflow.ui.experiment + +import kotlin.math.abs + +data class FormulaRef(val column: Int, val row: Int) + +sealed class FormulaValue { + data class Num(val value: Double) : FormulaValue() + data class Str(val value: String) : FormulaValue() + data class Err(val code: String) : FormulaValue() + object Empty : FormulaValue() +} + +class FormulaSheet(private val cells: List<List<String>>) { + + private val cache = HashMap<FormulaRef, FormulaValue>() + private val visiting = HashSet<FormulaRef>() + + fun textAt(ref: FormulaRef): String? = cells.getOrNull(ref.row)?.getOrNull(ref.column) + + fun contains(ref: FormulaRef): Boolean = textAt(ref) != null + + fun valueAt(ref: FormulaRef): FormulaValue { + cache[ref]?.let { return it } + if (!visiting.add(ref)) return FormulaValue.Err(FormulaEvaluator.ERR_CYCLE) + val raw = textAt(ref) ?: "" + val trimmed = raw.trim() + val value = when { + trimmed.startsWith("=") -> FormulaEvaluator.evaluate(trimmed.substring(1), this) + trimmed.isEmpty() -> FormulaValue.Empty + else -> trimmed.toDoubleOrNull()?.let { FormulaValue.Num(it) } ?: FormulaValue.Str(trimmed) + } + visiting.remove(ref) + cache[ref] = value + return value + } + + fun display(row: Int, column: Int): String { + val raw = textAt(FormulaRef(column, row)) ?: return "" + if (!raw.trim().startsWith("=")) return raw + return format(valueAt(FormulaRef(column, row))) + } + + fun format(value: FormulaValue): String = when (value) { + is FormulaValue.Num -> formatNumber(value.value) + is FormulaValue.Str -> value.value + is FormulaValue.Err -> value.code + FormulaValue.Empty -> "" + } + + private fun formatNumber(value: Double): String { + if (abs(value) < 1e15 && value % 1.0 == 0.0) return value.toLong().toString() + return value.toString() + } +} + +object FormulaEvaluator { + const val ERR_CYCLE = "#CYCLE!" + const val ERR_REF = "#REF!" + const val ERR_DIV0 = "#DIV0!" + const val ERR_NAME = "#NAME!" + const val ERR_FORMULA = "#ERROR!" + + fun evaluate(source: String, sheet: FormulaSheet): FormulaValue = try { + val parser = Parser(lex(source), sheet) + val value = parser.parseExpression() + parser.expectEnd() + value + } catch (_: FormulaParseException) { + FormulaValue.Err(ERR_FORMULA) + } +} + +private const val RANGE_CELL_LIMIT = 10_000L + +private class FormulaParseException : Exception() + +private sealed interface FormulaToken +private data class TokNumber(val value: Double) : FormulaToken +private data class TokRef(val ref: FormulaRef) : FormulaToken +private data class TokRange(val from: FormulaRef, val to: FormulaRef) : FormulaToken +private data class TokName(val name: String) : FormulaToken +private data class TokOp(val op: Char) : FormulaToken + +private sealed interface FormulaArg +private data class ScalarArg(val value: FormulaValue) : FormulaArg +private data class RangeArg(val values: List<FormulaValue>) : FormulaArg + +private fun lex(source: String): List<FormulaToken> { + val tokens = mutableListOf<FormulaToken>() + var index = 0 + while (index < source.length) { + val c = source[index] + when { + c.isWhitespace() -> index++ + c.isDigit() || (c == '.' && index + 1 < source.length && source[index + 1].isDigit()) -> { + val start = index + while (index < source.length && (source[index].isDigit() || source[index] == '.')) index++ + val value = source.substring(start, index).toDoubleOrNull() ?: throw FormulaParseException() + tokens += TokNumber(value) + } + c.isLetter() -> { + val letterStart = index + while (index < source.length && source[index].isLetter()) index++ + val letters = source.substring(letterStart, index).uppercase() + val digitStart = index + while (index < source.length && source[index].isDigit()) index++ + val digits = source.substring(digitStart, index) + if (digits.isEmpty()) { + tokens += TokName(letters) + } else { + val from = FormulaRef(columnIndex(letters), rowNumber(digits)) + if (index < source.length && source[index] == ':') { + index++ + val secondLetterStart = index + while (index < source.length && source[index].isLetter()) index++ + val secondLetters = source.substring(secondLetterStart, index).uppercase() + val secondDigitStart = index + while (index < source.length && source[index].isDigit()) index++ + val secondDigits = source.substring(secondDigitStart, index) + if (secondLetters.isEmpty() || secondDigits.isEmpty()) throw FormulaParseException() + tokens += TokRange(from, FormulaRef(columnIndex(secondLetters), rowNumber(secondDigits))) + } else { + tokens += TokRef(from) + } + } + } + c in "+-*/()," -> { + tokens += TokOp(c) + index++ + } + else -> throw FormulaParseException() + } + } + return tokens +} + +private fun columnIndex(letters: String): Int = + letters.fold(0) { acc, letter -> acc * 26 + (letter - 'A' + 1) } - 1 + +private fun rowNumber(digits: String): Int = (digits.toIntOrNull() ?: throw FormulaParseException()) - 1 + +private class Parser( + private val tokens: List<FormulaToken>, + private val sheet: FormulaSheet, +) { + private var pos = 0 + + fun expectEnd() { + if (pos != tokens.size) throw FormulaParseException() + } + + fun parseExpression(): FormulaValue { + var left = parseTerm() + while (peekOp('+') || peekOp('-')) { + val op = (consume() as TokOp).op + left = arithmetic(op, left, parseTerm()) + } + return left + } + + private fun parseTerm(): FormulaValue { + var left = parseUnary() + while (peekOp('*') || peekOp('/')) { + val op = (consume() as TokOp).op + left = arithmetic(op, left, parseUnary()) + } + return left + } + + private fun parseUnary(): FormulaValue = when { + peekOp('-') -> { + consume() + negate(parseUnary()) + } + peekOp('+') -> { + consume() + parseUnary() + } + else -> parsePrimary() + } + + private fun parsePrimary(): FormulaValue { + val token = consume() + return when { + token is TokNumber -> FormulaValue.Num(token.value) + token is TokRef -> resolveRef(token.ref) + token is TokName -> callFunction(token.name, parseArgs()) + token is TokOp && token.op == '(' -> { + val value = parseExpression() + expectOp(')') + value + } + else -> throw FormulaParseException() + } + } + + private fun parseArgs(): List<FormulaArg> { + expectOp('(') + if (peekOp(')')) { + consume() + return emptyList() + } + val args = mutableListOf<FormulaArg>() + while (true) { + val token = tokens.getOrNull(pos) + args += if (token is TokRange) { + consume() + rangeArg(token) + } else { + ScalarArg(parseExpression()) + } + if (peekOp(',')) { + consume() + continue + } + expectOp(')') + return args + } + } + + private fun rangeArg(range: TokRange): FormulaArg { + val colStart = minOf(range.from.column, range.to.column) + val colEnd = maxOf(range.from.column, range.to.column) + val rowStart = minOf(range.from.row, range.to.row) + val rowEnd = maxOf(range.from.row, range.to.row) + if ((colEnd - colStart + 1).toLong() * (rowEnd - rowStart + 1) > RANGE_CELL_LIMIT) { + return ScalarArg(FormulaValue.Err(FormulaEvaluator.ERR_REF)) + } + val values = mutableListOf<FormulaValue>() + for (row in rowStart..rowEnd) { + for (column in colStart..colEnd) { + val ref = FormulaRef(column, row) + values += if (sheet.contains(ref)) sheet.valueAt(ref) else FormulaValue.Err(FormulaEvaluator.ERR_REF) + } + } + return RangeArg(values) + } + + private fun resolveRef(ref: FormulaRef): FormulaValue = + if (sheet.contains(ref)) sheet.valueAt(ref) else FormulaValue.Err(FormulaEvaluator.ERR_REF) + + private fun callFunction(rawName: String, args: List<FormulaArg>): FormulaValue = when (rawName.uppercase()) { + "SUM" -> aggregate(args) { FormulaValue.Num(it.sum()) } + "AVG" -> aggregate(args) { values -> + if (values.isEmpty()) FormulaValue.Err(FormulaEvaluator.ERR_DIV0) else FormulaValue.Num(values.sum() / values.size) + } + "MIN" -> aggregate(args) { FormulaValue.Num(it.minOrNull() ?: 0.0) } + "MAX" -> aggregate(args) { FormulaValue.Num(it.maxOrNull() ?: 0.0) } + "COUNT" -> countOf(args) + else -> FormulaValue.Err(FormulaEvaluator.ERR_NAME) + } + + private fun aggregate(args: List<FormulaArg>, combine: (List<Double>) -> FormulaValue): FormulaValue { + val numbers = mutableListOf<Double>() + for (arg in args) { + when (arg) { + is RangeArg -> for (value in arg.values) { + when (value) { + is FormulaValue.Num -> numbers += value.value + is FormulaValue.Err -> return value + is FormulaValue.Empty, is FormulaValue.Str -> {} + } + } + is ScalarArg -> when (val value = arg.value) { + is FormulaValue.Num -> numbers += value.value + is FormulaValue.Err -> return value + is FormulaValue.Empty -> {} + is FormulaValue.Str -> return FormulaValue.Err(FormulaEvaluator.ERR_REF) + } + } + } + return combine(numbers) + } + + private fun countOf(args: List<FormulaArg>): FormulaValue { + var count = 0 + for (arg in args) { + when (arg) { + is RangeArg -> for (value in arg.values) { + when (value) { + is FormulaValue.Num -> count++ + is FormulaValue.Err -> return value + is FormulaValue.Empty, is FormulaValue.Str -> {} + } + } + is ScalarArg -> when (val value = arg.value) { + is FormulaValue.Num -> count++ + is FormulaValue.Err -> return value + is FormulaValue.Empty, is FormulaValue.Str -> {} + } + } + } + return FormulaValue.Num(count.toDouble()) + } + + private fun arithmetic(op: Char, left: FormulaValue, right: FormulaValue): FormulaValue { + val a = when (left) { + is FormulaValue.Num -> left.value + is FormulaValue.Empty -> 0.0 + is FormulaValue.Err -> return left + is FormulaValue.Str -> return FormulaValue.Err(FormulaEvaluator.ERR_REF) + } + val b = when (right) { + is FormulaValue.Num -> right.value + is FormulaValue.Empty -> 0.0 + is FormulaValue.Err -> return right + is FormulaValue.Str -> return FormulaValue.Err(FormulaEvaluator.ERR_REF) + } + return when (op) { + '+' -> FormulaValue.Num(a + b) + '-' -> FormulaValue.Num(a - b) + '*' -> FormulaValue.Num(a * b) + '/' -> if (b == 0.0) FormulaValue.Err(FormulaEvaluator.ERR_DIV0) else FormulaValue.Num(a / b) + else -> throw FormulaParseException() + } + } + + private fun negate(value: FormulaValue): FormulaValue = when (value) { + is FormulaValue.Num -> FormulaValue.Num(-value.value) + is FormulaValue.Empty -> FormulaValue.Num(0.0) + is FormulaValue.Err -> value + is FormulaValue.Str -> FormulaValue.Err(FormulaEvaluator.ERR_REF) + } + + private fun peekOp(op: Char): Boolean { + val token = tokens.getOrNull(pos) ?: return false + return token is TokOp && token.op == op + } + + private fun consume(): FormulaToken { + val token = tokens.getOrNull(pos) ?: throw FormulaParseException() + pos++ + return token + } + + private fun expectOp(op: Char) { + val token = consume() + if (!(token is TokOp && token.op == op)) throw FormulaParseException() + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispAutocomplete.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispAutocomplete.kt index 8da197f..4e83b5a 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispAutocomplete.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispAutocomplete.kt @@ -10,10 +10,12 @@ object GuiLispAutocomplete { "if", "cond", "let", "defun", "and", "or", ) + val demoSymbols: List<String> = listOf("demos") + GuiLispDemos.all.map { it.name } + fun suggestions(prefix: String): List<String> { if (prefix.isBlank()) return emptyList() val token = prefix.takeLastWhile { it.isLetterOrDigit() || it == '-' } if (token.length < 2) return emptyList() - return builtinSymbols.filter { it.startsWith(token) && it != token }.take(8) + return (builtinSymbols + demoSymbols).filter { it.startsWith(token) && it != token }.take(8) } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispDemos.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispDemos.kt new file mode 100644 index 0000000..2b00dde --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispDemos.kt @@ -0,0 +1,121 @@ +package jp.orgflow.ui.experiment + +import jp.orgflow.ui.chart.UiChartKind +import jp.orgflow.ui.chart.UiChartPoint +import jp.orgflow.ui.chart.UiChartSeries +import jp.orgflow.ui.chart.UiChartSpec +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlin.random.Random + +data class GuiLispDemo( + val name: String, + val label: String, + val samples: Int, + val kind: UiChartKind, + val generate: (Int) -> List<UiChartPoint>, +) + +object GuiLispDemos { + + private const val DEFAULT_SAMPLES = 64 + private const val NOISE_SEED = 20260829 + private val WHITESPACE = Regex("\\s+") + + val all: List<GuiLispDemo> = listOf( + GuiLispDemo("sine-wave", "Sine wave", DEFAULT_SAMPLES, UiChartKind.LINE) { samples -> + linspace(samples, 0.0, 4.0 * PI).mapIndexed { index, x -> UiChartPoint(index.toString(), x, sin(x)) } + }, + GuiLispDemo("cosine", "Cosine", DEFAULT_SAMPLES, UiChartKind.LINE) { samples -> + linspace(samples, 0.0, 2.0 * PI).mapIndexed { index, x -> UiChartPoint(index.toString(), x, cos(x)) } + }, + GuiLispDemo("noise", "Noise", DEFAULT_SAMPLES, UiChartKind.LINE) { samples -> + val random = Random(NOISE_SEED) + List(samples.coerceAtLeast(2)) { index -> + UiChartPoint(index.toString(), index.toDouble(), random.nextDouble(-1.0, 1.0)) + } + }, + GuiLispDemo("plasma", "Plasma", DEFAULT_SAMPLES, UiChartKind.LINE) { samples -> + linspace(samples, 0.0, 1.0).mapIndexed { index, x -> + UiChartPoint( + index.toString(), + x, + 0.55 * sin(2.0 * PI * x) + 0.30 * sin(4.0 * PI * x + 1.3) + 0.15 * sin(6.0 * PI * x + 2.7), + ) + } + }, + GuiLispDemo("mandel", "Mandel", DEFAULT_SAMPLES, UiChartKind.LINE) { samples -> + linspace(samples, -2.2, 0.8).mapIndexed { index, x -> + UiChartPoint(index.toString(), x, mandelbrotEscape(x).toDouble()) + } + }, + GuiLispDemo("heart", "Heart", DEFAULT_SAMPLES, UiChartKind.SCATTER) { samples -> + linspace(samples, 0.0, 2.0 * PI).mapIndexed { index, t -> + UiChartPoint( + index.toString(), + 16.0 * sin(t) * sin(t) * sin(t), + 13.0 * cos(t) - 5.0 * cos(2.0 * t) - 2.0 * cos(3.0 * t) - cos(4.0 * t), + ) + } + }, + ) + + val names: List<String> = all.map { it.name } + + fun find(name: String): GuiLispDemo? = all.firstOrNull { it.name == name } + + fun insertExpression(name: String): String = "(demos $name)" + + fun series(name: String, samples: Int = DEFAULT_SAMPLES): List<UiChartPoint> = + find(name)?.generate(samples) ?: emptyList() + + fun demoNameIn(code: String): String? { + val trimmed = code.trim() + if (!trimmed.startsWith("(") || !trimmed.endsWith(")")) return null + val parts = trimmed.substring(1, trimmed.length - 1).trim().split(WHITESPACE) + if (parts.firstOrNull() != "demos") return null + val name = parts.getOrNull(1) ?: return null + return name.takeIf { find(it) != null } + } + + fun expand(code: String): String? { + val demo = demoNameIn(code)?.let { find(it) } ?: return null + val chartCall = when (demo.kind) { + UiChartKind.LINE -> "line-chart" + UiChartKind.SCATTER -> "scatter-chart" + UiChartKind.BAR -> "bar-chart" + UiChartKind.PIE -> "pie-chart" + } + return "($chartCall (demos-series \"${demo.name}\" ${demo.samples}))" + } + + fun chartSpec(name: String, samples: Int = DEFAULT_SAMPLES): UiChartSpec? { + val demo = find(name) ?: return null + return UiChartSpec( + title = demo.label, + kind = demo.kind, + xLabel = if (demo.kind == UiChartKind.SCATTER) "x" else "sample", + yLabel = "value", + series = listOf(UiChartSeries(demo.name, demo.generate(samples))), + ) + } + + private fun linspace(samples: Int, from: Double, to: Double): List<Double> { + val count = samples.coerceAtLeast(2) + return List(count) { index -> from + (to - from) * index / (count - 1) } + } + + private fun mandelbrotEscape(real: Double, maxIterations: Int = 40): Int { + var zr = 0.0 + var zi = 0.0 + var iterations = 0 + while (iterations < maxIterations && zr * zr + zi * zi <= 4.0) { + val nextZr = zr * zr - zi * zi + real + zi = 2.0 * zr * zi + 0.3 + zr = nextZr + iterations++ + } + return iterations + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt index 7fb2224..f959875 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt @@ -11,10 +11,14 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import jp.orgflow.ui.store.OrgFlowAppStore @Composable -fun HomeScreen(viewModel: HomeViewModel = remember { HomeViewModel() }) { - viewModel.refresh(activities = 3, notes = 12, openTasks = 4) +fun HomeScreen( + store: OrgFlowAppStore = remember { OrgFlowAppStore() }, + viewModel: HomeViewModel = remember(store) { HomeViewModel(store) }, +) { + viewModel.refresh() Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { Text(viewModel.quickGreeting(), fontSize = 20.sp) Row(modifier = Modifier.padding(vertical = 8.dp)) { @@ -22,6 +26,9 @@ fun HomeScreen(viewModel: HomeViewModel = remember { HomeViewModel() }) { StatCard("Notes", viewModel.summary.noteCount) StatCard("Open tasks", viewModel.summary.taskOpen) } + viewModel.lastActivityId?.let { + Text("Last activity: ${it.value}", fontSize = 12.sp) + } Text("Quick actions: capture a note, review today's agenda, open the experiment table.", fontSize = 13.sp) } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt index c6e115f..f5588b7 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import jp.orgflow.domain.identity.ActivityId +import jp.orgflow.ui.store.OrgFlowAppStore data class HomeSummary( val activityCount: Int, @@ -11,13 +12,19 @@ data class HomeSummary( val taskOpen: Int, ) -class HomeViewModel { +class HomeViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()) { var summary: HomeSummary by mutableStateOf(HomeSummary(0, 0, 0)) private set - var lastActivityId: ActivityId? = null + var lastActivityId: ActivityId? by mutableStateOf(null) + private set - fun refresh(activities: Int, notes: Int, openTasks: Int) { - summary = HomeSummary(activities, notes, openTasks) + fun refresh(activities: Int? = null, notes: Int? = null, openTasks: Int? = null) { + summary = HomeSummary( + activities ?: store.activities.size, + notes ?: store.notes.size, + openTasks ?: store.agendaItems.count { !it.done }, + ) + lastActivityId = store.activities.lastOrNull()?.id } fun quickGreeting(): String = "Welcome to OrgFlow" diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt index e1f0569..0595766 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import jp.orgflow.ui.component.SaveableEditorState +import jp.orgflow.ui.store.OrgFlowAppStore data class NoteEntry( val id: String, @@ -11,11 +12,9 @@ data class NoteEntry( val body: String, ) -class NoteEditorViewModel { - var notes: List<NoteEntry> by mutableStateOf( - listOf(NoteEntry("n1", "Research log", "* observation A\n* observation B")), - ) - private set +class NoteEditorViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()) { + val notes: List<NoteEntry> + get() = store.notes val editor = SaveableEditorState() @@ -23,18 +22,17 @@ class NoteEditorViewModel { fun select(id: String) { selectedId = id - notes.firstOrNull { it.id == id }?.let { editor.edit(it.body) } + store.notes.firstOrNull { it.id == id }?.let { editor.edit(it.body) } } fun saveCurrent(): Boolean { val id = selectedId ?: return false - notes = notes.map { if (it.id == id) it.copy(body = editor.text) else it } - return true + return store.updateNote(id, editor.text) } fun addNote(title: String): String { - val id = "n${notes.size + 1}" - notes = notes + NoteEntry(id, title.ifBlank { "Untitled" }, "") - return id + val entry = store.addNote(title) + select(entry.id) + return entry.id } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt index 77569c6..c22c458 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt @@ -1,6 +1,8 @@ package jp.orgflow.ui.notes import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -14,30 +16,39 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import jp.orgflow.ui.component.EmptyContent +import jp.orgflow.ui.store.OrgFlowAppStore @Composable -fun NotesScreen(viewModel: NoteEditorViewModel = remember { NoteEditorViewModel() }) { - Column(modifier = Modifier.padding(12.dp)) { - Text("Notes", fontSize = 17.sp) - TextButton(onClick = { viewModel.addNote("New note") }) { Text("+ New note") } - if (viewModel.notes.isEmpty()) { - EmptyContent("No notes yet — capture your first observation") - } else { - LazyColumn { - items(viewModel.notes, key = { it.id }) { note -> - Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) { - Column(modifier = Modifier.padding(10.dp)) { - Text(note.title, fontSize = 14.sp) - Text( - note.body.lineSequence().firstOrNull() ?: "(empty)", - fontSize = 11.sp, - maxLines = 1, - ) - TextButton(onClick = { viewModel.select(note.id) }) { Text("Open") } +fun NotesScreen( + store: OrgFlowAppStore = remember { OrgFlowAppStore() }, + viewModel: NoteEditorViewModel = remember(store) { NoteEditorViewModel(store) }, +) { + Row(modifier = Modifier.fillMaxSize().padding(12.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text("Notes", fontSize = 17.sp) + TextButton(onClick = { viewModel.addNote("New note") }) { Text("+ New note") } + if (viewModel.notes.isEmpty()) { + EmptyContent("No notes yet — capture your first observation") + } else { + LazyColumn { + items(viewModel.notes, key = { it.id }) { note -> + Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) { + Column(modifier = Modifier.padding(10.dp)) { + Text(note.title, fontSize = 14.sp) + Text( + note.body.lineSequence().firstOrNull() ?: "(empty)", + fontSize = 11.sp, + maxLines = 1, + ) + TextButton(onClick = { viewModel.select(note.id) }) { Text("Open") } + } } } } } } + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + NoteEditorScreen(viewModel) + } } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notification/Notifier.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notification/Notifier.kt new file mode 100644 index 0000000..fb14537 --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notification/Notifier.kt @@ -0,0 +1,8 @@ +package jp.orgflow.ui.notification + +// W7 practical notifications - platform dispatch boundary (desktop SystemTray / wasm Notification API). +fun interface Notifier { + fun notify(title: String, message: String) +} + +expect fun defaultNotifier(): Notifier diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingScreen.kt index c84464c..1c86c40 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingScreen.kt @@ -28,7 +28,7 @@ fun OnboardingScreen(viewModel: OnboardingViewModel = remember { OnboardingViewM onNameChange = { viewModel.workspaceName = it }, ) 1 -> WorkspaceJoinByQrStep( - onJoined = { viewModel.joinedViaQr = true }, + onJoined = { roomId -> viewModel.joinRoom(roomId) }, ) else -> FirstCaptureStep( onCaptured = { viewModel.firstCaptureDone = true }, diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingViewModel.kt index f8f4a9b..0ee7cca 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingViewModel.kt @@ -10,10 +10,18 @@ class OnboardingViewModel { private set var workspaceName: String by mutableStateOf("") var joinedViaQr: Boolean by mutableStateOf(false) + private set + var joinedRoomId: String? by mutableStateOf<String?>(null) + private set var firstCaptureDone: Boolean by mutableStateOf(false) val stepCount: Int get() = 3 + fun joinRoom(roomId: String) { + joinedViaQr = true + joinedRoomId = roomId + } + fun next() { if (step < stepCount - 1) step++ } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceJoinByQrStep.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceJoinByQrStep.kt index 05615ed..3ae8fae 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceJoinByQrStep.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceJoinByQrStep.kt @@ -1,7 +1,10 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + package jp.orgflow.ui.onboarding import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.material.TextButton @@ -13,10 +16,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import jp.orgflow.transport.qr.QrBootstrapEncoder +import kotlin.time.Clock @Composable -fun WorkspaceJoinByQrStep(onJoined: () -> Unit) { +fun WorkspaceJoinByQrStep(onJoined: (String) -> Unit) { var scanned by remember { mutableStateOf("") } + var error by remember { mutableStateOf<String?>(null) } Column { Text("Join by QR", fontSize = 15.sp) Text("Paste the FSMP1 invite payload you scanned.", fontSize = 12.sp) @@ -26,6 +32,26 @@ fun WorkspaceJoinByQrStep(onJoined: () -> Unit) { label = { Text("FSMP1:...") }, modifier = Modifier.padding(vertical = 8.dp), ) - TextButton(onClick = onJoined, enabled = scanned.startsWith("FSMP1")) { Text("join") } + error?.let { + Text( + it, + color = MaterialTheme.colors.error, + fontSize = 12.sp, + ) + } + TextButton( + onClick = { + val payload = QrBootstrapEncoder.decode(scanned.trim()) + if (payload == null) { + error = "invalid payload" + } else if (payload.isExpired(Clock.System.now().toEpochMilliseconds())) { + error = "invite expired" + } else { + error = null + onJoined(payload.workspaceId) + } + }, + enabled = scanned.startsWith("FSMP1"), + ) { Text("join") } } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/qr/QrMatrixEncoder.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/qr/QrMatrixEncoder.kt new file mode 100644 index 0000000..eef560e --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/qr/QrMatrixEncoder.kt @@ -0,0 +1,396 @@ +package jp.orgflow.ui.qr + +import kotlin.math.abs + +object QrMatrixEncoder { + + const val MAX_PAYLOAD_BYTES = 271 + + private const val ECC_FORMAT_BITS = 0b01 + private const val FORMAT_MASK = 0x5412 + private const val VERSION_MASK = 0x1F25 + private const val FORMAT_GENERATOR = 0x537 + + private val GF_EXP = IntArray(512) + private val GF_LOG = IntArray(256) + + init { + var x = 1 + for (i in 0 until 255) { + GF_EXP[i] = x + GF_LOG[x] = i + x = x shl 1 + if (x and 0x100 != 0) x = x xor 0x11D + } + for (i in 255 until 512) GF_EXP[i] = GF_EXP[i - 255] + } + + private data class BlockLayout( + val blockCount: Int, + val shortData: Int, + val longData: Int, + val longBlockCount: Int, + val ecPerBlock: Int, + ) + + private fun layoutFor(version: Int): BlockLayout = when (version) { + 1 -> BlockLayout(1, 19, 19, 0, 7) + 2 -> BlockLayout(1, 34, 34, 0, 10) + 3 -> BlockLayout(1, 55, 55, 0, 15) + 4 -> BlockLayout(1, 80, 80, 0, 20) + 5 -> BlockLayout(1, 108, 108, 0, 26) + 6 -> BlockLayout(2, 68, 68, 0, 18) + 7 -> BlockLayout(2, 78, 78, 0, 20) + 8 -> BlockLayout(2, 97, 97, 0, 24) + 9 -> BlockLayout(2, 116, 116, 0, 30) + else -> BlockLayout(4, 68, 69, 2, 18) + } + + private fun alignmentCoords(version: Int): IntArray = when (version) { + 1 -> IntArray(0) + 2 -> intArrayOf(6, 18) + 3 -> intArrayOf(6, 22) + 4 -> intArrayOf(6, 26) + 5 -> intArrayOf(6, 30) + 6 -> intArrayOf(6, 34) + 7 -> intArrayOf(6, 22, 38) + 8 -> intArrayOf(6, 24, 42) + 9 -> intArrayOf(6, 26, 46) + else -> intArrayOf(6, 28, 50) + } + + private fun dataCodewordCount(layout: BlockLayout): Int = + (layout.blockCount - layout.longBlockCount) * layout.shortData + layout.longBlockCount * layout.longData + + private fun capacityBytes(version: Int): Int { + val layout = layoutFor(version) + val countBits = if (version >= 10) 16 else 8 + return (dataCodewordCount(layout) * 8 - 4 - countBits) / 8 + } + + fun encode(text: String): List<List<Boolean>>? { + val bytes = text.encodeToByteArray() + if (bytes.size > MAX_PAYLOAD_BYTES) return null + val version = (1..10).firstOrNull { capacityBytes(it) >= bytes.size } ?: return null + val codewords = addErrorCorrection(buildCodewords(bytes, version), version) + return MatrixBuilder(version).build(codewords) + } + + private fun gfMul(a: Int, b: Int): Int = + if (a == 0 || b == 0) 0 else GF_EXP[GF_LOG[a] + GF_LOG[b]] + + private fun generatorPoly(degree: Int): IntArray { + var result = intArrayOf(1) + repeat(degree) { i -> + val next = IntArray(result.size + 1) + for (j in result.indices) { + next[j] = next[j] xor result[j] + next[j + 1] = next[j + 1] xor gfMul(result[j], GF_EXP[i]) + } + result = next + } + return result + } + + private fun rsRemainder(data: ByteArray, generator: IntArray): ByteArray { + val ecLength = generator.size - 1 + val buffer = IntArray(data.size + ecLength) + for (i in data.indices) buffer[i] = data[i].toInt() and 0xFF + for (i in data.indices) { + val factor = buffer[i] + if (factor == 0) continue + for (j in 1 until generator.size) { + buffer[i + j] = buffer[i + j] xor gfMul(generator[j], factor) + } + } + return ByteArray(ecLength) { buffer[data.size + it].toByte() } + } + + private class BitBuffer { + private val bits = mutableListOf<Int>() + val length: Int get() = bits.size + + fun append(value: Int, count: Int) { + for (shift in count - 1 downTo 0) bits.add((value shr shift) and 1) + } + + fun padToByte() { + while (bits.size % 8 != 0) bits.add(0) + } + + fun toBytes(): ByteArray { + val out = ByteArray(bits.size / 8) + for (i in out.indices) { + var b = 0 + for (j in 0 until 8) b = (b shl 1) or bits[i * 8 + j] + out[i] = b.toByte() + } + return out + } + } + + private fun buildCodewords(bytes: ByteArray, version: Int): ByteArray { + val layout = layoutFor(version) + val capacityBits = dataCodewordCount(layout) * 8 + val bits = BitBuffer() + bits.append(0b0100, 4) + bits.append(bytes.size, if (version >= 10) 16 else 8) + for (b in bytes) bits.append(b.toInt() and 0xFF, 8) + bits.append(0, (capacityBits - bits.length).coerceAtMost(4)) + bits.padToByte() + val pads = intArrayOf(0xEC, 0x11) + var padIndex = 0 + while (bits.length < capacityBits) { + bits.append(pads[padIndex % 2], 8) + padIndex++ + } + return bits.toBytes() + } + + private fun splitBlocks(data: ByteArray, layout: BlockLayout): List<ByteArray> { + val shortCount = layout.blockCount - layout.longBlockCount + val blocks = mutableListOf<ByteArray>() + var offset = 0 + repeat(layout.blockCount) { i -> + val size = if (i < shortCount) layout.shortData else layout.longData + blocks.add(data.copyOfRange(offset, offset + size)) + offset += size + } + return blocks + } + + private fun addErrorCorrection(data: ByteArray, version: Int): ByteArray { + val layout = layoutFor(version) + val blocks = splitBlocks(data, layout) + val ecBlocks = blocks.map { block -> rsRemainder(block, generatorPoly(layout.ecPerBlock)) } + val out = ByteArray(data.size + layout.blockCount * layout.ecPerBlock) + var offset = 0 + val maxData = blocks.maxOfOrNull { it.size } ?: 0 + for (col in 0 until maxData) { + for (block in blocks) { + if (col < block.size) { + out[offset] = block[col] + offset++ + } + } + } + for (col in 0 until layout.ecPerBlock) { + for (ec in ecBlocks) { + out[offset] = ec[col] + offset++ + } + } + return out + } + + private fun maskDark(mask: Int, x: Int, y: Int): Boolean = when (mask) { + 0 -> (x + y) % 2 == 0 + 1 -> y % 2 == 0 + 2 -> x % 3 == 0 + 3 -> (x + y) % 3 == 0 + 4 -> (y / 2 + x / 3) % 2 == 0 + 5 -> (x * y) % 2 + (x * y) % 3 == 0 + 6 -> ((x * y) % 2 + (x * y) % 3) % 2 == 0 + else -> ((x + y) % 2 + (x * y) % 3) % 2 == 0 + } + + private val penaltyPatternA = booleanArrayOf(true, false, true, true, true, false, true, false, false, false, false) + private val penaltyPatternB = booleanArrayOf(false, false, false, false, true, false, true, true, true, false, true) + + private class MatrixBuilder(private val version: Int) { + val size = 17 + version * 4 + val modules = Array(size) { BooleanArray(size) } + val isFunction = Array(size) { BooleanArray(size) } + + fun setFunction(row: Int, col: Int, dark: Boolean) { + modules[row][col] = dark + isFunction[row][col] = true + } + + fun drawFinder(cx: Int, cy: Int) { + for (dy in -4..4) { + for (dx in -4..4) { + val x = cx + dx + val y = cy + dy + if (x < 0 || x >= size || y < 0 || y >= size) continue + val dist = maxOf(abs(dx), abs(dy)) + setFunction(y, x, dist != 2 && dist != 4) + } + } + } + + fun drawAlignment(cx: Int, cy: Int) { + for (dy in -2..2) { + for (dx in -2..2) { + setFunction(cy + dy, cx + dx, maxOf(abs(dx), abs(dy)) != 1) + } + } + } + + fun drawFormatBits(mask: Int) { + val data = (ECC_FORMAT_BITS shl 3) or mask + var rem = data + repeat(10) { rem = (rem shl 1) xor ((rem ushr 9) * FORMAT_GENERATOR) } + val bits = ((data shl 10) or rem) xor FORMAT_MASK + for (i in 0 until 6) setFunction(i, 8, bitAt(bits, i)) + setFunction(7, 8, bitAt(bits, 6)) + setFunction(8, 8, bitAt(bits, 7)) + setFunction(8, 7, bitAt(bits, 8)) + for (i in 9 until 15) setFunction(8, 14 - i, bitAt(bits, i)) + for (i in 0 until 8) setFunction(8, size - 1 - i, bitAt(bits, i)) + for (i in 8 until 15) setFunction(size - 15 + i, 8, bitAt(bits, i)) + setFunction(size - 8, 8, true) + } + + fun drawVersionInfo() { + if (version < 7) return + var rem = version + repeat(12) { rem = (rem shl 1) xor ((rem ushr 11) * VERSION_MASK) } + val bits = (version shl 12) or rem + for (i in 0 until 18) { + val bit = bitAt(bits, i) + val a = size - 11 + i % 3 + val b = i / 3 + setFunction(b, a, bit) + setFunction(a, b, bit) + } + } + + fun drawFunctionPatterns() { + for (i in 8 until size - 8) { + setFunction(6, i, i % 2 == 0) + setFunction(i, 6, i % 2 == 0) + } + drawFinder(3, 3) + drawFinder(size - 4, 3) + drawFinder(3, size - 4) + val finderCenters = listOf(3 to 3, size - 4 to 3, 3 to size - 4) + val coords = alignmentCoords(version) + for (cy in coords) { + for (cx in coords) { + val overlapsFinder = finderCenters.any { (fy, fx) -> + maxOf(abs(cy - fy), abs(cx - fx)) <= 4 + } + if (overlapsFinder) continue + drawAlignment(cx, cy) + } + } + drawFormatBits(0) + drawVersionInfo() + setFunction(size - 8, 8, true) + } + + fun placeData(codewords: ByteArray) { + var bitIndex = 0 + var right = size - 1 + while (right >= 1) { + if (right == 6) right = 5 + for (vert in 0 until size) { + for (j in 0 until 2) { + val x = right - j + val upward = ((right + 1) and 2) == 0 + val y = if (upward) size - 1 - vert else vert + if (!isFunction[y][x]) { + val dark = bitIndex < codewords.size * 8 && + (codewords[bitIndex / 8].toInt() and (0x80 shr (bitIndex and 7))) != 0 + modules[y][x] = dark + bitIndex++ + } + } + } + right -= 2 + } + } + + fun applyMask(mask: Int) { + for (y in 0 until size) { + for (x in 0 until size) { + if (!isFunction[y][x] && maskDark(mask, x, y)) modules[y][x] = !modules[y][x] + } + } + } + + fun windowMatches(y: Int, x: Int, dy: Int, dx: Int, pattern: BooleanArray): Boolean { + for (i in pattern.indices) { + val ry = y + dy * i + val rx = x + dx * i + if (ry < 0 || ry >= size || rx < 0 || rx >= size) return false + if (modules[ry][rx] != pattern[i]) return false + } + return true + } + + fun penaltyScore(): Int { + var score = 0 + for (y in 0 until size) { + var run = 1 + for (x in 1 until size) { + if (modules[y][x] == modules[y][x - 1]) { + run++ + } else { + if (run >= 5) score += 3 + run - 5 + run = 1 + } + } + if (run >= 5) score += 3 + run - 5 + } + for (x in 0 until size) { + var run = 1 + for (y in 1 until size) { + if (modules[y][x] == modules[y - 1][x]) { + run++ + } else { + if (run >= 5) score += 3 + run - 5 + run = 1 + } + } + if (run >= 5) score += 3 + run - 5 + } + for (y in 0 until size - 1) { + for (x in 0 until size - 1) { + val dark = modules[y][x] + if (dark == modules[y][x + 1] && dark == modules[y + 1][x] && dark == modules[y + 1][x + 1]) score += 3 + } + } + for (y in 0 until size) { + for (x in 0 until size - 10) { + if (windowMatches(y, x, 0, 1, penaltyPatternA) || windowMatches(y, x, 0, 1, penaltyPatternB)) score += 40 + } + } + for (x in 0 until size) { + for (y in 0 until size - 10) { + if (windowMatches(y, x, 1, 0, penaltyPatternA) || windowMatches(y, x, 1, 0, penaltyPatternB)) score += 40 + } + } + var darkCount = 0 + for (row in modules) for (module in row) if (module) darkCount++ + val total = size * size + val percent = darkCount * 100 / total + score += (abs(percent - 50) / 5) * 10 + return score + } + + fun build(codewords: ByteArray): List<List<Boolean>> { + drawFunctionPatterns() + placeData(codewords) + var bestMask = 0 + var bestScore = Int.MAX_VALUE + var bestModules = modules.map { it.copyOf() } + for (mask in 0 until 8) { + applyMask(mask) + drawFormatBits(mask) + val score = penaltyScore() + if (score < bestScore) { + bestScore = score + bestMask = mask + bestModules = modules.map { it.copyOf() } + } + applyMask(mask) + } + drawFormatBits(bestMask) + return bestModules.map { it.toList() } + } + + private fun bitAt(bits: Int, index: Int): Boolean = (bits ushr index) and 1 != 0 + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/store/OrgFlowAppStore.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/store/OrgFlowAppStore.kt new file mode 100644 index 0000000..1ebb309 --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/store/OrgFlowAppStore.kt @@ -0,0 +1,105 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package jp.orgflow.ui.store + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import jp.orgflow.domain.calendar.AgendaItem +import jp.orgflow.domain.identity.ActivityId +import jp.orgflow.domain.identity.CardId +import jp.orgflow.ui.calendar.CalendarEvent +import jp.orgflow.ui.calendar.EventSource +import jp.orgflow.ui.calendar.NotificationSettings +import jp.orgflow.ui.capture.FiveW1HField +import jp.orgflow.ui.notes.NoteEntry +import kotlin.time.Clock +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime + +data class CaptureEntry( + val id: ActivityId, + val templateType: String, + val fields: Map<FiveW1HField, String>, + val attachments: List<CardId> = emptyList(), + val capturedAtMs: Long, +) + +class OrgFlowAppStore { + var activities: List<CaptureEntry> by mutableStateOf(emptyList()) + private set + var notes: List<NoteEntry> by mutableStateOf(emptyList()) + private set + var agendaItems: List<AgendaItem> by mutableStateOf(sampleItems()) + private set + var calendarEvents: List<CalendarEvent> by mutableStateOf(emptyList()) + private set + var notificationSettings: NotificationSettings by mutableStateOf(NotificationSettings()) + private set + var notifiedEventIds: Set<String> by mutableStateOf(emptySet()) + private set + + fun addCapture( + templateType: String, + fields: Map<FiveW1HField, String>, + attachments: List<CardId> = emptyList(), + capturedAtMs: Long = defaultCapturedAtMs(), + ): ActivityId { + val entry = CaptureEntry( + id = ActivityId("act-${activities.size + 1}"), + templateType = templateType, + fields = fields, + attachments = attachments, + capturedAtMs = capturedAtMs, + ) + activities = activities + entry + return entry.id + } + + fun addNote(title: String): NoteEntry { + val entry = NoteEntry("n${notes.size + 1}", title.ifBlank { "Untitled" }, "") + notes = notes + entry + return entry + } + + fun updateNote(id: String, body: String): Boolean { + if (notes.none { it.id == id }) return false + notes = notes.map { if (it.id == id) it.copy(body = body) else it } + return true + } + + fun toggleAgendaDone(id: String) { + agendaItems = agendaItems.map { if (it.id == id) it.copy(done = !it.done) else it } + } + + fun addCalendarEvent(title: String, date: LocalDate, time: LocalTime? = null): CalendarEvent { + val entry = CalendarEvent( + id = "cal-${calendarEvents.size + 1}", + title = title.ifBlank { "Untitled" }, + date = date, + source = EventSource.Personal, + time = time, + ) + calendarEvents = calendarEvents + entry + return entry + } + + fun markNotified(ids: Collection<String>) { + notifiedEventIds = notifiedEventIds + ids + } + + fun updateNotificationSettings(settings: NotificationSettings) { + notificationSettings = settings + } + + companion object { + private fun sampleItems(): List<AgendaItem> = listOf( + AgendaItem("a1", "Team sync", LocalDateTime(2026, 8, 27, 10, 0)), + AgendaItem("a2", "Write experiment report", LocalDateTime(2026, 8, 27, 15, 0)), + AgendaItem("a3", "Pack rehearsal", LocalDateTime(2026, 8, 28, 9, 30)), + ) + } +} + +private fun defaultCapturedAtMs(): Long = Clock.System.now().toEpochMilliseconds() diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceQrInviteCard.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceQrInviteCard.kt index d52dd25..5c62c5a 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceQrInviteCard.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceQrInviteCard.kt @@ -1,13 +1,21 @@ package jp.orgflow.ui.workspace +import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.Card import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import jp.orgflow.ui.qr.QrMatrixEncoder @Composable fun WorkspaceQrInviteCard( @@ -18,6 +26,15 @@ fun WorkspaceQrInviteCard( Column(modifier = Modifier.padding(12.dp)) { Text("Workspace invite", fontSize = 14.sp) Text("scan to join:", fontSize = 11.sp) + val matrix = remember(payload) { QrMatrixEncoder.encode(payload) } + if (matrix == null) { + Text("payload too long for QR", fontSize = 12.sp) + } else { + QrCanvas( + matrix = matrix, + modifier = Modifier.fillMaxWidth().height(220.dp).padding(vertical = 8.dp), + ) + } Text( payload, fontSize = 12.sp, @@ -26,3 +43,30 @@ fun WorkspaceQrInviteCard( } } } + +@Composable +private fun QrCanvas( + matrix: List<List<Boolean>>, + modifier: Modifier = Modifier, +) { + Canvas(modifier) { + val quietZone = 4 + val count = matrix.size + quietZone * 2 + val cell = minOf(size.width, size.height) / count + val board = cell * count + val originX = (size.width - board) / 2 + val originY = (size.height - board) / 2 + drawRect(color = Color.White, topLeft = Offset(originX, originY), size = Size(board, board)) + for (y in matrix.indices) { + for (x in matrix[y].indices) { + if (matrix[y][x]) { + drawRect( + color = Color.Black, + topLeft = Offset(originX + (x + quietZone) * cell, originY + (y + quietZone) * cell), + size = Size(cell, cell), + ) + } + } + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt index 6649ee0..78990d1 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt @@ -1,21 +1,46 @@ package jp.orgflow.ui.workspace import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material.AlertDialog +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.RadioButton import androidx.compose.material.Text +import androidx.compose.material.TextButton import androidx.compose.runtime.Composable +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.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable fun WorkspaceScreen(viewModel: WorkspaceViewModel = remember { WorkspaceViewModel() }) { + var showAddRoom by remember { mutableStateOf(false) } Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { Text("Workspace", fontSize = 17.sp) - Text("id: ${viewModel.workspace.id.value}", fontSize = 12.sp) - Text("members", fontSize = 14.sp, modifier = Modifier.padding(top = 8.dp)) + Text("rooms", fontSize = 14.sp, modifier = Modifier.padding(top = 8.dp)) + viewModel.rooms.forEach { room -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = room.id.value == viewModel.currentRoomId, + onClick = { viewModel.selectRoom(room.id.value) }, + ) + Text( + "${room.settings.name} (${room.id.value})" + + if (room.id.value == viewModel.currentRoomId) " — current" else "", + fontSize = 12.sp, + ) + } + } + TextButton(onClick = { showAddRoom = true }) { Text("+ Add Room") } + Text("current room members", fontSize = 14.sp, modifier = Modifier.padding(top = 8.dp)) viewModel.membersUi().forEach { member -> Text("${member.displayName} — ${member.role}", fontSize = 12.sp) } @@ -23,4 +48,59 @@ fun WorkspaceScreen(viewModel: WorkspaceViewModel = remember { WorkspaceViewMode payload = viewModel.invitePayload("ws://192.168.2.114:8091/ws"), ) } + if (showAddRoom) { + AddRoomDialog( + onCreate = { name -> + viewModel.createRoom(name) + showAddRoom = false + }, + onJoin = { payload -> viewModel.joinByPayload(payload) }, + onDismiss = { showAddRoom = false }, + ) + } +} + +@Composable +private fun AddRoomDialog( + onCreate: (String) -> Unit, + onJoin: (String) -> String?, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf("") } + var payload by remember { mutableStateOf("") } + var error by remember { mutableStateOf<String?>(null) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Room") }, + text = { + Column { + Text("create a new room", fontSize = 12.sp) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("room name") }, + ) + TextButton(onClick = { onCreate(name) }, enabled = name.isNotBlank()) { Text("create") } + Text("or join by invite payload", fontSize = 12.sp, modifier = Modifier.padding(top = 8.dp)) + OutlinedTextField( + value = payload, + onValueChange = { payload = it }, + label = { Text("FSMP1:...") }, + ) + error?.let { + Text( + it, + color = MaterialTheme.colors.error, + fontSize = 12.sp, + modifier = Modifier.padding(top = 4.dp), + ) + } + TextButton( + onClick = { error = onJoin(payload) }, + enabled = payload.startsWith("FSMP1"), + ) { Text("join") } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("close") } }, + ) } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceViewModel.kt index 0cefeba..a4f5b36 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceViewModel.kt @@ -7,10 +7,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import jp.orgflow.domain.identity.WorkspaceId import jp.orgflow.domain.workspace.OrgFlowWorkspace +import jp.orgflow.domain.workspace.WorkspaceMember import jp.orgflow.domain.workspace.WorkspaceRole import jp.orgflow.domain.workspace.WorkspaceSettings import jp.orgflow.transport.qr.QrBootstrapEncoder import jp.orgflow.transport.qr.QrBootstrapPayload +import kotlin.random.Random import kotlin.time.Clock import kotlin.time.Instant @@ -21,21 +23,49 @@ data class WorkspaceMemberUi( ) class WorkspaceViewModel { - var workspace: OrgFlowWorkspace by mutableStateOf( - OrgFlowWorkspace( - id = WorkspaceId("ws-demo"), - settings = WorkspaceSettings(name = "kukuri demo"), - members = listOf( - jp.orgflow.domain.workspace.WorkspaceMember("m1", WorkspaceRole.OWNER, Instant.fromEpochMilliseconds(0)), - jp.orgflow.domain.workspace.WorkspaceMember("m2", WorkspaceRole.MEMBER, Instant.fromEpochMilliseconds(0)), - ), - ), - ) + var rooms: List<OrgFlowWorkspace> by mutableStateOf(listOf(seedRoom())) + private set + + var currentRoomId: String by mutableStateOf(DEFAULT_ROOM_ID) private set + val workspace: OrgFlowWorkspace + get() = rooms.first { it.id.value == currentRoomId } + fun membersUi(): List<WorkspaceMemberUi> = workspace.members.map { WorkspaceMemberUi(it.memberId, it.memberId, it.role) } + fun selectRoom(id: String) { + if (rooms.any { it.id.value == id }) currentRoomId = id + } + + fun createRoom(name: String) { + val id = "ws-" + randomHex(8) + rooms = rooms + OrgFlowWorkspace( + id = WorkspaceId(id), + settings = WorkspaceSettings(name = name.ifBlank { id }), + members = listOf(WorkspaceMember("m1", WorkspaceRole.OWNER, Clock.System.now())), + ) + currentRoomId = id + } + + fun joinByPayload(payloadString: String): String? { + val payload = QrBootstrapEncoder.decode(payloadString.trim()) + ?: return "invalid payload" + if (payload.isExpired(Clock.System.now().toEpochMilliseconds())) return "payload expired" + val id = payload.workspaceId + if (id.isBlank()) return "invalid payload" + if (rooms.none { it.id.value == id }) { + rooms = rooms + OrgFlowWorkspace( + id = WorkspaceId(id), + settings = WorkspaceSettings(name = id), + members = listOf(WorkspaceMember("m1", WorkspaceRole.MEMBER, Clock.System.now())), + ) + } + currentRoomId = id + return null + } + fun invitePayload(signalingEndpoint: String): String = QrBootstrapEncoder.encode( QrBootstrapPayload( workspaceId = workspace.id.value, @@ -44,4 +74,20 @@ class WorkspaceViewModel { expiresAtMs = Clock.System.now().toEpochMilliseconds() + 24 * 60 * 60 * 1000, ), ) + + private fun seedRoom(): OrgFlowWorkspace = OrgFlowWorkspace( + id = WorkspaceId(DEFAULT_ROOM_ID), + settings = WorkspaceSettings(name = "kukuri demo"), + members = listOf( + WorkspaceMember("m1", WorkspaceRole.OWNER, Instant.fromEpochMilliseconds(0)), + WorkspaceMember("m2", WorkspaceRole.MEMBER, Instant.fromEpochMilliseconds(0)), + ), + ) + + companion object { + const val DEFAULT_ROOM_ID = "ws-demo" + + private fun randomHex(length: Int): String = + (1..length).map { "0123456789abcdef"[Random.nextInt(16)] }.joinToString("") + } } diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/AppStoreFlowTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/AppStoreFlowTest.kt new file mode 100644 index 0000000..f8fc370 --- /dev/null +++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/AppStoreFlowTest.kt @@ -0,0 +1,86 @@ +package jp.orgflow.ui + +import jp.orgflow.ui.agenda.AgendaViewModel +import jp.orgflow.ui.capture.CaptureViewModel +import jp.orgflow.ui.capture.FiveW1HField +import jp.orgflow.ui.home.HomeViewModel +import jp.orgflow.ui.notes.NoteEditorViewModel +import jp.orgflow.ui.store.OrgFlowAppStore +import kotlinx.datetime.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AppStoreFlowTest { + + @Test + fun storeSeedsAgendaFixtureOnce() { + val store = OrgFlowAppStore() + assertEquals(listOf("a1", "a2", "a3"), store.agendaItems.map { it.id }) + assertEquals(emptyList(), store.activities) + assertEquals(emptyList(), store.notes) + } + + @Test + fun captureSubmitReachesStoreAndHome() { + val store = OrgFlowAppStore() + val capture = CaptureViewModel(store) + capture.updateField(FiveW1HField.WHAT, "observed X") + capture.updateField(FiveW1HField.WHEN, "today") + assertTrue(capture.submit()) + assertEquals(1, store.activities.size) + val entry = store.activities.single() + assertEquals("observed X", entry.fields[FiveW1HField.WHAT]) + assertEquals("today", entry.fields[FiveW1HField.WHEN]) + assertEquals("Note", entry.templateType) + + val home = HomeViewModel(store) + home.refresh() + assertEquals(1, home.summary.activityCount) + assertEquals(entry.id, home.lastActivityId) + } + + @Test + fun homeRefreshOverridesStillSupported() { + val store = OrgFlowAppStore() + val home = HomeViewModel(store) + home.refresh(activities = 3, notes = 12, openTasks = 4) + assertEquals(3, home.summary.activityCount) + assertEquals(12, home.summary.noteCount) + assertEquals(4, home.summary.taskOpen) + assertEquals(null, home.lastActivityId) + } + + @Test + fun notesOpenEditsAndSavesThroughStore() { + val store = OrgFlowAppStore() + val notes = NoteEditorViewModel(store) + assertTrue(notes.notes.isEmpty()) + val id = notes.addNote("New note") + assertEquals(1, store.notes.size) + assertEquals(id, notes.selectedId) + notes.editor.edit("* observation A") + assertTrue(notes.saveCurrent()) + assertEquals("* observation A", store.notes.single().body) + + val reopened = NoteEditorViewModel(store) + reopened.select(id) + assertEquals("* observation A", reopened.editor.text) + } + + @Test + fun agendaTogglePersistsInStore() { + val store = OrgFlowAppStore() + val agenda = AgendaViewModel(store) + assertTrue(agenda.items.isNotEmpty()) + + agenda.toggleDone("a1") + assertTrue(store.agendaItems.single { it.id == "a1" }.done) + assertEquals(AgendaViewModel(store).items, store.agendaItems) + + val now = LocalDateTime(2026, 8, 27, 11, 0) + assertFalse(agenda.overdue(now).any { it.id == "a1" }) + assertEquals(2, agenda.forDate(LocalDateTime(2026, 8, 27, 0, 0)).size) + } +} diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/CalendarTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/CalendarTest.kt new file mode 100644 index 0000000..2550a7e --- /dev/null +++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/CalendarTest.kt @@ -0,0 +1,159 @@ +package jp.orgflow.ui + +import jp.orgflow.ui.calendar.CalendarEvent +import jp.orgflow.ui.calendar.CalendarFilter +import jp.orgflow.ui.calendar.CalendarViewModel +import jp.orgflow.ui.calendar.EventColor +import jp.orgflow.ui.calendar.EventSource +import jp.orgflow.ui.calendar.NotificationSettings +import jp.orgflow.ui.calendar.WhenTextParser +import jp.orgflow.ui.calendar.mergeCalendarEvents +import jp.orgflow.ui.capture.CaptureViewModel +import jp.orgflow.ui.capture.FiveW1HField +import jp.orgflow.ui.notification.Notifier +import jp.orgflow.ui.store.OrgFlowAppStore +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.isoDayNumber +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CalendarTest { + + private class RecordingNotifier : Notifier { + val titles = mutableListOf<String>() + + override fun notify(title: String, message: String) { + titles.add(title) + } + } + + private fun event(id: String, date: String, source: EventSource, time: LocalTime? = null) = + CalendarEvent(id = id, title = id, date = LocalDate.parse(date), source = source, time = time) + + @Test + fun unionMergesPersonalAndRoomEventsSortedAndDeduped() { + val personal = listOf(event("p1", "2026-08-27", EventSource.Personal)) + val room = listOf( + event("r1", "2026-08-26", EventSource.Room), + event("all1", "2026-08-28", EventSource.AllRooms), + ) + assertEquals(listOf("r1", "p1", "all1"), mergeCalendarEvents(personal, room).map { it.id }) + assertEquals(1, mergeCalendarEvents(personal, personal).size) + } + + @Test + fun filterChipsToggleVisibility() { + val events = listOf( + event("p1", "2026-08-27", EventSource.Personal), + event("r1", "2026-08-27", EventSource.Room), + event("all1", "2026-08-27", EventSource.AllRooms), + ) + assertEquals(listOf("p1"), events.filter { CalendarFilter.Personal.visible(it) }.map { it.id }) + assertEquals(listOf("r1", "all1"), events.filter { CalendarFilter.Room.visible(it) }.map { it.id }) + assertEquals(3, events.filter { CalendarFilter.All.visible(it) }.size) + } + + @Test + fun dueSoonUsesInjectedClockAndNotificationSettings() { + val now = LocalDateTime(2026, 8, 27, 8, 45) + val settings = NotificationSettings(notifyEnabled = true, minutesBefore = 30, targets = setOf(EventSource.Personal)) + val events = listOf( + event("soon", "2026-08-27", EventSource.Personal, LocalTime(9, 5)), + event("later", "2026-08-27", EventSource.Personal, LocalTime(9, 30)), + event("past", "2026-08-27", EventSource.Personal, LocalTime(8, 0)), + event("room", "2026-08-27", EventSource.Room, LocalTime(8, 55)), + event("all-day", "2026-08-27", EventSource.Personal, null), + ) + val vm = CalendarViewModel(OrgFlowAppStore(), now = { LocalDateTime(2026, 8, 27, 8, 45) }) + + assertEquals(listOf("soon", "all-day"), vm.dueSoon(events, now, settings, emptySet()).map { it.id }) + assertEquals( + listOf("soon", "room", "all-day"), + vm.dueSoon(events, now, settings.copy(targets = EventSource.entries.toSet()), emptySet()).map { it.id }, + ) + assertTrue(vm.dueSoon(events, now, settings, setOf("soon", "all-day")).isEmpty()) + assertTrue(vm.dueSoon(events, now, settings.copy(notifyEnabled = false), emptySet()).isEmpty()) + } + + @Test + fun notificationsDispatchOncePerEvent() { + val store = OrgFlowAppStore() + val notifier = RecordingNotifier() + val vm = CalendarViewModel(store, notifier = notifier, now = { LocalDateTime(2026, 8, 27, 8, 45) }) + store.addCalendarEvent("lab cleanup", LocalDate.parse("2026-08-27"), LocalTime(9, 0)) + + val due = vm.checkDueNotifications() + assertEquals(listOf("lab cleanup"), due.map { it.title }) + assertEquals(listOf("lab cleanup"), notifier.titles) + assertTrue(vm.checkDueNotifications().isEmpty()) + assertEquals(setOf("cal-1"), store.notifiedEventIds) + } + + @Test + fun captureWhenFieldBecomesPersonalEvent() { + val store = OrgFlowAppStore() + val capture = CaptureViewModel(store) + capture.updateField(FiveW1HField.WHAT, "observe plasma") + capture.updateField(FiveW1HField.WHEN, "2026-08-27 14:30") + assertTrue(capture.submit()) + + val vm = CalendarViewModel(store, now = { LocalDateTime(2026, 8, 27, 8, 45) }) + val fromCapture = vm.personalEvents().single { it.id == "capture-${store.activities.single().id.value}" } + assertEquals(LocalDate.parse("2026-08-27"), fromCapture.date) + assertEquals(LocalTime(14, 30), fromCapture.time) + assertEquals(EventSource.Personal, fromCapture.source) + assertEquals(EventColor.Personal, fromCapture.color) + } + + @Test + fun whenTextParserSupportsIsoAndRelativeDates() { + val today = LocalDate.parse("2026-08-27") + assertEquals(LocalDate.parse("2026-08-27") to LocalTime(14, 30), WhenTextParser.parse("2026-08-27 14:30", today)) + assertEquals(today to LocalTime(9, 0), WhenTextParser.parse("today 9:00", today)) + assertEquals(LocalDate.parse("2026-08-28") to null, WhenTextParser.parse("tomorrow", today)) + assertNull(WhenTextParser.parse("someday", today)) + assertNull(WhenTextParser.parse("", today)) + } + + @Test + fun storeHoldsCalendarEventsAndNotificationSettings() { + val store = OrgFlowAppStore() + val vm = CalendarViewModel(store, now = { LocalDateTime(2026, 8, 27, 8, 45) }) + assertTrue(vm.addEvent("kickoff", "2026-09-01", "10:00")) + assertEquals(1, store.calendarEvents.size) + assertEquals(EventSource.Personal, store.calendarEvents.single().source) + assertFalse(vm.addEvent("bad", "not-a-date", "")) + + vm.setNotifyEnabled(false) + vm.setMinutesBefore(15) + vm.toggleTarget(EventSource.Room) + assertFalse(store.notificationSettings.notifyEnabled) + assertEquals(15, store.notificationSettings.minutesBefore) + assertEquals(setOf(EventSource.Personal, EventSource.AllRooms), store.notificationSettings.targets) + } + + @Test + fun monthAndWeekNavigationStaysWithinBounds() { + val vm = CalendarViewModel(OrgFlowAppStore(), now = { LocalDateTime(2026, 1, 15, 9, 0) }) + assertEquals("2026-01", vm.headerLabel()) + assertEquals(31, vm.monthDays().filterNotNull().size) + vm.previous() + assertEquals("2025-12", vm.headerLabel()) + vm.next() + vm.next() + assertEquals("2026-02", vm.headerLabel()) + assertEquals(28, vm.monthDays().filterNotNull().size) + + vm.toggleViewMode() + val week = vm.weekDays() + assertEquals(7, week.size) + assertEquals(1, week.first().dayOfWeek.isoDayNumber) + vm.next() + assertEquals(week.first().toEpochDays() + 7, vm.weekDays().first().toEpochDays()) + } +} diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/FormulaEvaluatorTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/FormulaEvaluatorTest.kt new file mode 100644 index 0000000..8ee3d6a --- /dev/null +++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/FormulaEvaluatorTest.kt @@ -0,0 +1,96 @@ +package jp.orgflow.ui + +import jp.orgflow.ui.experiment.ExperimentTableViewModel +import jp.orgflow.ui.experiment.FormulaRef +import jp.orgflow.ui.experiment.FormulaSheet +import jp.orgflow.ui.experiment.FormulaValue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class FormulaEvaluatorTest { + + private fun sheet(vararg rows: String): FormulaSheet = + FormulaSheet(rows.map { row -> row.split('|').map { it.trim() } }) + + @Test + fun nonFormulaCellsStayLiteral() { + val grid = sheet("|21.0|abc") + assertEquals("", grid.display(0, 0)) + assertEquals("21.0", grid.display(0, 1)) + assertEquals(21.0, (grid.valueAt(FormulaRef(1, 0)) as FormulaValue.Num).value) + assertEquals("abc", grid.display(0, 2)) + assertIs<FormulaValue.Str>(grid.valueAt(FormulaRef(2, 0))) + } + + @Test + fun cellRefsAndArithmetic() { + val grid = sheet("21|=A1*2|=B1+A1|=A1/4") + assertEquals("42", grid.display(0, 1)) + assertEquals("63", grid.display(0, 2)) + assertEquals("5.25", grid.display(0, 3)) + } + + @Test + fun functionsOverRanges() { + val grid = sheet( + "1|10|=SUM(A1:A3)", + "2|20|=AVG(B1:B3)", + "3|30|=MIN(A1:A3)", + "4|40|=MAX(B1:B3)", + "=COUNT(A1:A3)|=SUM(A1:A3, 10)|=AVG(A1:A3, 10)", + ) + assertEquals("6", grid.display(0, 2)) + assertEquals("20", grid.display(1, 2)) + assertEquals("1", grid.display(2, 2)) + assertEquals("30", grid.display(3, 2)) + assertEquals("3", grid.display(4, 0)) + assertEquals("16", grid.display(4, 1)) + assertEquals("4", grid.display(4, 2)) + } + + @Test + fun errorValues() { + val grid = sheet( + "=1/0|=Z99|=SUM(A1:A2)+X", + "abc|=A2+1|=FOO(1)", + ) + assertEquals("#DIV0!", grid.display(0, 0)) + assertEquals("#REF!", grid.display(0, 1)) + assertEquals("#ERROR!", grid.display(0, 2)) + assertEquals("#REF!", grid.display(1, 1)) + assertEquals("#NAME!", grid.display(1, 2)) + } + + @Test + fun circularReferencesDetectCycle() { + val grid = sheet("=B1|=A1") + assertEquals("#CYCLE!", grid.display(0, 0)) + assertEquals("#CYCLE!", grid.display(0, 1)) + val self = sheet("=A1") + assertEquals("#CYCLE!", self.display(0, 0)) + } + + @Test + fun formulaCellsChainThroughSheet() { + val grid = sheet( + "2", + "=A1*3", + "=A2+1", + ) + assertEquals("6", grid.display(1, 0)) + assertEquals("7", grid.display(2, 0)) + } + + @Test + fun viewModelRecalculatesFormulaCells() { + val viewModel = ExperimentTableViewModel() + viewModel.updateCell("r2", "yield", "=B1+B2") + val sheet = viewModel.formulaSheet() + assertEquals("43.5", sheet.display(1, 2)) + val values = viewModel.numericColumn("yield") + assertEquals(2, values.size) + assertEquals(43.5, values[1], 1e-9) + assertEquals((0.62 + 43.5) / 2, viewModel.mean("yield")!!, 1e-9) + } +} diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/GuiLispDemosTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/GuiLispDemosTest.kt new file mode 100644 index 0000000..4a98fe6 --- /dev/null +++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/GuiLispDemosTest.kt @@ -0,0 +1,76 @@ +package jp.orgflow.ui + +import jp.orgflow.ui.chart.UiChartKind +import jp.orgflow.ui.experiment.GuiLispAutocomplete +import jp.orgflow.ui.experiment.GuiLispDemos +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class GuiLispDemosTest { + + @Test + fun everyDemoProducesNonEmptySeries() { + GuiLispDemos.all.forEach { demo -> + val points = GuiLispDemos.series(demo.name) + assertTrue(points.isNotEmpty(), "demo ${demo.name} produced an empty series") + assertEquals(demo.samples, points.size) + points.forEach { point -> + assertFalse(point.x.isNaN()) + assertFalse(point.y.isNaN()) + } + } + } + + @Test + fun insertExpressionRoundTripsThroughDetection() { + GuiLispDemos.names.forEach { name -> + val expression = GuiLispDemos.insertExpression(name) + assertEquals("(demos $name)", expression) + assertEquals(name, GuiLispDemos.demoNameIn(expression)) + } + } + + @Test + fun detectionRejectsNonDemoExpressions() { + assertNull(GuiLispDemos.demoNameIn("(avg (column \"yield\"))")) + assertNull(GuiLispDemos.demoNameIn("(demos nope)")) + assertNull(GuiLispDemos.demoNameIn("(demos")) + assertNull(GuiLispDemos.demoNameIn("plain text")) + } + + @Test + fun expandProducesChartCall() { + val expanded = GuiLispDemos.expand("(demos sine-wave)") + assertNotNull(expanded) + assertTrue(expanded.contains("line-chart")) + assertTrue(expanded.contains("sine-wave")) + assertNull(GuiLispDemos.expand("(avg (column \"yield\"))")) + } + + @Test + fun chartSpecMatchesDemoKind() { + val line = GuiLispDemos.chartSpec("plasma") + assertNotNull(line) + assertEquals(UiChartKind.LINE, line.kind) + assertEquals(1, line.series.size) + assertFalse(line.series.first().points.isEmpty()) + + val heart = GuiLispDemos.chartSpec("heart") + assertNotNull(heart) + assertEquals(UiChartKind.SCATTER, heart.kind) + assertTrue(heart.series.first().points.isNotEmpty()) + assertNull(GuiLispDemos.chartSpec("nope")) + } + + @Test + fun autocompleteOffersDemosCompletions() { + assertTrue(GuiLispAutocomplete.suggestions("(demos sine-").contains("sine-wave")) + assertTrue(GuiLispAutocomplete.suggestions("dem").contains("demos")) + assertTrue(GuiLispAutocomplete.suggestions("hea").contains("heart")) + assertTrue(GuiLispAutocomplete.demoSymbols.containsAll(GuiLispDemos.names)) + } +} diff --git a/modules/orgflow-ui/src/jvmMain/kotlin/jp/orgflow/ui/notification/Notifier.jvm.kt b/modules/orgflow-ui/src/jvmMain/kotlin/jp/orgflow/ui/notification/Notifier.jvm.kt new file mode 100644 index 0000000..c459b1b --- /dev/null +++ b/modules/orgflow-ui/src/jvmMain/kotlin/jp/orgflow/ui/notification/Notifier.jvm.kt @@ -0,0 +1,40 @@ +package jp.orgflow.ui.notification + +import java.awt.Color +import java.awt.EventQueue +import java.awt.GraphicsEnvironment +import java.awt.SystemTray +import java.awt.TrayIcon +import java.awt.image.BufferedImage + +actual fun defaultNotifier(): Notifier = SystemTrayNotifier + +object SystemTrayNotifier : Notifier { + private val available: Boolean by lazy { + runCatching { !GraphicsEnvironment.isHeadless() && SystemTray.isSupported() }.getOrDefault(false) + } + + override fun notify(title: String, message: String) { + if (!available) return + EventQueue.invokeLater { + runCatching { + val tray = SystemTray.getSystemTray() + val icon = TrayIcon(defaultImage(), "kukuri OrgFlow") + tray.add(icon) + icon.displayMessage(title, message, TrayIcon.MessageType.INFO) + Thread { + Thread.sleep(5_000) + tray.remove(icon) + }.apply { isDaemon = true }.start() + } + } + } + + private fun defaultImage(): BufferedImage = BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB).apply { + createGraphics().apply { + color = Color(21, 101, 192) + fillOval(0, 0, 16, 16) + dispose() + } + } +} diff --git a/modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/notification/Notifier.wasmJs.kt b/modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/notification/Notifier.wasmJs.kt new file mode 100644 index 0000000..ec531b1 --- /dev/null +++ b/modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/notification/Notifier.wasmJs.kt @@ -0,0 +1,13 @@ +package jp.orgflow.ui.notification + +actual fun defaultNotifier(): Notifier = BrowserNotifier + +object BrowserNotifier : Notifier { + override fun notify(title: String, message: String) { + runCatching { showBrowserNotification(title, message) } + } +} + +private fun showBrowserNotification(title: String, message: String): Boolean = js( + "(() => { try { if (typeof Notification === 'undefined') { return false } if (Notification.permission === 'granted') { new Notification(title, { body: message }); return true } if (Notification.permission === 'default') { Notification.requestPermission() } return false } catch (e) { return false } })()", +) |
