summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorketsuban <ketsuban@kukuri.dev>2026-08-29 07:14:23 +0000
committerketsuban <ketsuban@kukuri.dev>2026-08-29 07:14:23 +0000
commit9969ad9e7669dbae5027574d34da72306f4c6958 (patch)
treeba7170b61c0d0ad3b8e71616615ba15596390998
parent695fc4ed3fa3e4e86affb3c558f7c04a428342d9 (diff)
downloadkukuri-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
-rw-r--r--apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/Main.kt2
-rw-r--r--apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/p2p/P2pDemoMain.kt12
-rw-r--r--apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/runtime/DesktopDistributionRuntime.kt5
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/mesh/WebRtcMeshTransport.kt2
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/KtorSignalingClient.kt1
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt1
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingClient.kt2
-rw-r--r--modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/PeerJoinSignalSerializationTest.kt29
-rw-r--r--modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt1
-rw-r--r--modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServer.kt53
-rw-r--r--modules/fsmp-transport/src/jvmTest/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServerRoomRoutingTest.kt149
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt11
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt3
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaScreen.kt6
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/agenda/AgendaViewModel.kt22
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarEvent.kt77
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarScreen.kt312
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarViewModel.kt200
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/WhenTextParser.kt35
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt7
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt8
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableScreen.kt64
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/ExperimentTableViewModel.kt20
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/FormulaEvaluator.kt341
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispAutocomplete.kt4
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/experiment/GuiLispDemos.kt121
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt11
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt15
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt20
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt47
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notification/Notifier.kt8
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingScreen.kt2
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/OnboardingViewModel.kt8
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/onboarding/WorkspaceJoinByQrStep.kt30
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/qr/QrMatrixEncoder.kt396
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/store/OrgFlowAppStore.kt105
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceQrInviteCard.kt44
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt84
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceViewModel.kt66
-rw-r--r--modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/AppStoreFlowTest.kt86
-rw-r--r--modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/CalendarTest.kt159
-rw-r--r--modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/FormulaEvaluatorTest.kt96
-rw-r--r--modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/GuiLispDemosTest.kt76
-rw-r--r--modules/orgflow-ui/src/jvmMain/kotlin/jp/orgflow/ui/notification/Notifier.jvm.kt40
-rw-r--r--modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/notification/Notifier.wasmJs.kt13
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 } })()",
+)