summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/p2p/P2pDemoMain.kt35
-rw-r--r--apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/runtime/DesktopDistributionRuntime.kt69
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt1
-rw-r--r--modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypes.kt58
-rw-r--r--modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypesTest.kt98
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/mesh/WebRtcMeshTransport.kt16
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/net/ContentTransferService.kt50
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/ChatMessageSignal.kt23
-rw-r--r--modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt7
-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.kt30
-rw-r--r--modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/net/ContentTransferServiceWireTest.kt88
-rw-r--r--modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt13
-rw-r--r--modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServer.kt133
-rw-r--r--modules/fsmp-transport/src/jvmTest/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServerRoomRoutingTest.kt214
-rw-r--r--modules/fsmp-transport/src/wasmJsMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt132
-rw-r--r--modules/orgflow-content-store/build.gradle.kts5
-rw-r--r--modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/BlockStore.kt53
-rw-r--r--modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/Cid.kt3
-rw-r--r--modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/ContentAddresser.kt14
-rw-r--r--modules/orgflow-content-store/src/commonTest/kotlin/jp/orgflow/contentstore/ContentStoreTest.kt108
-rw-r--r--modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/FileBlockStore.kt57
-rw-r--r--modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/NabuBlockStoreAdapter.kt37
-rw-r--r--modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/OkioBlockStore.kt38
-rw-r--r--modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/FileBlockStoreTest.kt55
-rw-r--r--modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/NabuAdapterTest.kt19
-rw-r--r--modules/orgflow-content-store/src/wasmJsMain/kotlin/jp/orgflow/contentstore/wasm/OpfsBlockStore.kt207
-rw-r--r--modules/orgflow-content-store/src/wasmJsTest/kotlin/jp/orgflow/contentstore/OpfsBlockStoreTest.kt50
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt193
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt28
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/breadcrumb/BreadcrumbBar.kt48
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarEvent.kt1
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarScreen.kt107
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarViewModel.kt22
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt19
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt55
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigScreen.kt38
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigViewModel.kt4
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt183
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt66
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/live/LiveScreen.kt54
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/DynamicGroup.kt32
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Group.kt14
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupColor.kt31
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupDirectory.kt50
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupType.kt9
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/JoinButtons.kt26
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Membership.kt8
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipIcons.kt61
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipStatus.kt8
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/SetFilterState.kt56
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/User.kt7
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteCards.kt292
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt209
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt155
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt53
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/pie/PieMenu.kt199
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/runtime/OrgFlowRuntime.kt34
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/store/OrgFlowAppStore.kt67
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt8
-rw-r--r--modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/AppStoreFlowTest.kt102
-rw-r--r--modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/CalendarTest.kt30
-rw-r--r--modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/ConnectionConfigTest.kt89
-rw-r--r--modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/NoteCardBoardTest.kt221
-rw-r--r--modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/GroupModelTest.kt84
-rw-r--r--modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/SetFilterStateTest.kt64
-rw-r--r--modules/orgflow-ui/src/jvmMain/kotlin/jp/orgflow/ui/runtime/ProvideRuntime.jvm.kt3
-rw-r--r--modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/runtime/WasmDistributionRuntime.kt183
68 files changed, 4206 insertions, 322 deletions
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 65b07e7..c7eb57b 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
@@ -12,8 +12,11 @@ import jp.orgflow.pack.model.PackIntegrity
import jp.orgflow.pack.model.PackType
import kotlin.random.Random
import kotlin.system.exitProcess
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
+import java.util.concurrent.atomic.AtomicBoolean
fun main(args: Array<String>) {
val role = args.firstOrNull { it.startsWith("--role=") }?.removePrefix("--role=") ?: "help"
@@ -26,8 +29,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 --room=demo-room")
- println(" receiver --endpoint=127.0.0.1:8091 --name=bob --out=/tmp/received.kzip --room=demo-room")
+ println(" sender --endpoint=127.0.0.1:8091 --name=alice --pack=/tmp/pack.kzip --room=demo-room --chat")
+ println(" receiver --endpoint=127.0.0.1:8091 --name=bob --out=/tmp/received.kzip --room=demo-room --chat")
}
}
}
@@ -81,30 +84,44 @@ private fun runMakePack(args: Array<String>) {
println("[makepack] wrote $out (${Files.size(Paths.get(out))} bytes)")
}
+private fun chatEnabled(args: Array<String>): Boolean = args.contains("--chat") || args.any { it.startsWith("--chat=") }
+
+private fun logChat(name: String): (jp.orgflow.transport.signaling.ChatMessageSignal) -> Unit = { chat ->
+ println("[chat:$name] from=${chat.senderPeerId} name=${chat.displayName} body=${chat.body}")
+}
+
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 withChat = chatEnabled(args)
+ val chatSent = AtomicBoolean(false)
val packBytes = Files.readAllBytes(Paths.get(packPath))
val runtime = DesktopDistributionRuntime(
selfPeerId = name,
signalingEndpoint = "ws://$endpoint/ws",
roomId = room,
)
+ if (withChat) runtime.onChatReceived(logChat(name))
runtime.offerPack("demo-pack-1", packBytes)
runBlocking {
runtime.start()
println("[sender:$name] started room=$room, pack=${packBytes.size} bytes, waiting for peers...")
+ var expectedPeers = 0
val deadline = System.currentTimeMillis() + 120_000
while (System.currentTimeMillis() < deadline) {
val snap = runtime.snapshot.value
- if (snap.completedPeers.isNotEmpty() || (snap.peers.isNotEmpty() && snap.peers.all { it.ratio >= 0.999 })) break
+ if (snap.peers.size > expectedPeers) expectedPeers = snap.peers.size
+ if (expectedPeers in 1..snap.completedPeers.size) break
Thread.sleep(500)
}
+ if (withChat) delay(6_000)
+ if (withChat && chatSent.compareAndSet(false, true)) runtime.sendChat("hello from $name")
val snap = runtime.snapshot.value
println("[sender:$name] final peer ratios: ${snap.peers.associate { it.peerId to it.ratio }} sentChunks=${snap.sentChunks}")
- println(if (snap.peers.isNotEmpty() && snap.peers.all { it.ratio >= 0.999 }) "RESULT: OK" else "RESULT: INCOMPLETE")
+ println("[sender:$name] completed peers: ${snap.completedPeers} (expected $expectedPeers)")
+ println(if (expectedPeers > 0 && snap.completedPeers.size >= expectedPeers) "RESULT: OK" else "RESULT: INCOMPLETE")
}
runtime.stop()
exitProcess(0)
@@ -115,6 +132,7 @@ private fun runReceiver(args: Array<String>) {
val name = arg(args, "name", "bob")
val room = arg(args, "room", "demo-room")
val outPath = arg(args, "out", "/tmp/fsmp-demo-received.kzip")
+ val withChat = chatEnabled(args)
var verifiedResult = false
val done = kotlinx.coroutines.CompletableDeferred<Unit>()
val runtime = DesktopDistributionRuntime(
@@ -122,6 +140,7 @@ private fun runReceiver(args: Array<String>) {
signalingEndpoint = "ws://$endpoint/ws",
roomId = room,
)
+ if (withChat) runtime.onChatReceived(logChat(name))
runtime.onPackReceived { packId, bytes, verified ->
verifiedResult = verified
Files.write(Paths.get(outPath), bytes)
@@ -131,7 +150,15 @@ private fun runReceiver(args: Array<String>) {
runBlocking {
runtime.start()
println("[receiver:$name] started, waiting for pack...")
+ if (withChat) {
+ launch {
+ delay(8_000)
+ runtime.sendChat("hello from $name")
+ println("[chat:$name] sent body=hello from $name")
+ }
+ }
withTimeoutOrNull(120_000) { done.await() }
+ if (withChat) delay(15_000)
println(if (done.isCompleted && verifiedResult) "RESULT: OK" else "RESULT: TIMEOUT")
}
runtime.stop()
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 4f36967..0bc5bbc 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
@@ -8,6 +8,8 @@ import jp.orgflow.fsmp.pipeline.DecisionContext
import jp.orgflow.fsmp.waterline.WaterlineState
import jp.orgflow.transport.mesh.WebRtcMeshTransport
import jp.orgflow.transport.net.ContentTransferService
+import jp.orgflow.transport.signaling.ChatMessageSignal
+import jp.orgflow.transport.signaling.SignalingEnvelope
import jp.orgflow.transport.signaling.WsSignalingClient
import jp.orgflow.transport.signaling.server.WsSignalingServer
import jp.orgflow.transport.webrtc.jvm.OnVoidRtcPlatform
@@ -19,6 +21,7 @@ import jp.orgflow.ui.runtime.DistributionSnapshot
import jp.orgflow.ui.runtime.UiConfigField
import jp.orgflow.ui.runtime.UiPackSync
import jp.orgflow.ui.runtime.UiPeerSync
+import jp.orgflow.ui.store.OrgFlowAppStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -41,6 +44,12 @@ class DesktopDistributionRuntime(
private val configStore = DesktopConfigStore()
@Volatile
+ private var configSignalingEndpoint: String = signalingEndpoint
+
+ @Volatile
+ private var configIceServers: List<String> = OrgFlowAppStore.DEFAULT_ICE_SERVERS
+
+ @Volatile
var configuration: FsmpConfiguration = FsmpConfiguration.DEFAULT
private set
@@ -58,6 +67,9 @@ class DesktopDistributionRuntime(
@Volatile
private var signalingServer: WsSignalingServer? = null
+ @Volatile
+ private var signalingClient: WsSignalingClient? = null
+
private val _snapshot = MutableStateFlow(DistributionSnapshot())
override val snapshot: StateFlow<DistributionSnapshot> = _snapshot
@@ -68,6 +80,7 @@ class DesktopDistributionRuntime(
private var receivedPacks: Int = 0
private var lastEvent: String = "idle"
private var packReceiver: (suspend (String, ByteArray, Boolean) -> Unit)? = null
+ private var chatHandler: ((ChatMessageSignal) -> Unit)? = null
private var advertised = false
private val completedPeers = mutableSetOf<String>()
@@ -75,6 +88,34 @@ class DesktopDistributionRuntime(
packReceiver = handler
}
+ fun onChatReceived(handler: (ChatMessageSignal) -> Unit) {
+ chatHandler = handler
+ }
+
+ fun sendChat(body: String) {
+ val client = signalingClient ?: return
+ val chat = ChatMessageSignal(
+ gid = roomId,
+ senderPeerId = selfPeerId,
+ displayName = selfPeerId,
+ body = body,
+ sentAtMs = System.currentTimeMillis(),
+ )
+ scope.launch {
+ runCatching {
+ client.send(
+ SignalingEnvelope(
+ type = ChatMessageSignal.TYPE,
+ senderPeerId = selfPeerId,
+ nonce = chat.sentAtMs,
+ timestampMs = chat.sentAtMs,
+ payloadJson = kotlinx.serialization.json.Json.encodeToString(ChatMessageSignal.serializer(), chat),
+ ),
+ )
+ }
+ }
+ }
+
fun reloadConfig(): FsmpConfiguration {
configuration = configStore.load()
currentEngine = FsmpEngine(configuration)
@@ -94,6 +135,11 @@ class DesktopDistributionRuntime(
}
lastEvent = "starting $endpoint room=$roomId"
val client = WsSignalingClient(endpoint, selfPeerId, roomId = roomId)
+ signalingClient = client
+ client.onChat { chat ->
+ lastEvent = "chat from ${chat.senderPeerId}"
+ chatHandler?.invoke(chat)
+ }
val mesh = WebRtcMeshTransport(
peerId = PeerId(selfPeerId),
signaling = client,
@@ -381,6 +427,21 @@ class DesktopDistributionRuntime(
refreshConfigSnapshot(System.currentTimeMillis())
}
+ override fun updateEndpoint(endpoint: String) {
+ val next = endpoint.trim()
+ if (next.isBlank()) return
+ configSignalingEndpoint = next
+ lastEvent = "config saved: signaling endpoint $next (restart applies)"
+ refreshConfigSnapshot(System.currentTimeMillis())
+ }
+
+ override fun updateIceServers(servers: List<String>) {
+ val cleaned = servers.map { it.trim() }.filter { it.isNotBlank() }
+ configIceServers = cleaned
+ lastEvent = "config saved: ${cleaned.size} ice servers (restart applies)"
+ refreshConfigSnapshot(System.currentTimeMillis())
+ }
+
private fun refreshConfigSnapshot(savedAt: Long) {
val fields = listOf(
UiConfigField("frame_size_bytes", "frame size (bytes)", configuration.frameSizeBytes.toDouble(), 4096.0, 65536.0),
@@ -390,7 +451,13 @@ class DesktopDistributionRuntime(
UiConfigField("w_focus_card", "OB-SCA-V w_focus_card", configuration.obScaWFocusCard, 0.0, 1.0),
UiConfigField("evaporation_rate", "ACO evaporation rate", configuration.acoEvaporationRate, 0.0, 1.0),
)
- _config.value = ConfigSnapshot(configDir = configStore.summary(), fields = fields, lastSavedAtMs = savedAt)
+ _config.value = ConfigSnapshot(
+ configDir = configStore.summary(),
+ fields = fields,
+ lastSavedAtMs = savedAt,
+ signalingEndpoint = configSignalingEndpoint,
+ iceServers = configIceServers,
+ )
}
private fun levelFor(ratio: Double): UiWaterline = when {
diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt
index 74fa7c2..e1f7924 100644
--- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt
+++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt
@@ -8,6 +8,7 @@ sealed interface FsmpMessage {
val streamId: String
val flags: Int
val bodyHash: String
+ val protocolVersion: Int get() = FsmpMessageTypes.PROTOCOL_VERSION
fun validate(): Boolean = msgType.isNotBlank() && streamId.isNotBlank()
}
diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypes.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypes.kt
new file mode 100644
index 0000000..f43c0cb
--- /dev/null
+++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypes.kt
@@ -0,0 +1,58 @@
+package jp.orgflow.fsmp.message
+
+object FsmpMessageTypes {
+ const val PROTOCOL_VERSION = 1
+
+ const val RESERVED_FRAME = 1
+ const val RESERVED_MESSAGE = 2
+ const val PEER_HELLO = 3
+ const val PEER_BYE = 4
+ const val MANIFEST_REQUEST = 5
+ const val MANIFEST_RESPONSE = 6
+ const val CHUNK = 7
+ const val ACK_BITMAP = 8
+ const val REPAIR_REQUEST = 9
+ const val REPAIR_RESPONSE = 10
+ const val CACHE_ADVERTISE = 11
+ const val STATUS_REPORT = 12
+ const val ROUTE_FEEDBACK = 13
+ const val PACK_ADVERTISE = 14
+ const val GIT_POA_METADATA = 15
+ const val COMMIT_OBSERVATION_NOTE = 16
+ const val SESSION_CONTENT_ADVERTISE = 32
+ const val SESSION_CONTENT_CHUNK = 33
+ const val SESSION_CONTENT_ACK = 34
+ const val SESSION_CONTENT_COMPLETE = 35
+
+ private val numberByMsgType: Map<String, Int> = linkedMapOf(
+ "peer.hello" to PEER_HELLO,
+ "peer.bye" to PEER_BYE,
+ "content.manifest.request" to MANIFEST_REQUEST,
+ "content.manifest.response" to MANIFEST_RESPONSE,
+ "content.chunk" to CHUNK,
+ "content.ack.bitmap" to ACK_BITMAP,
+ "repair.request" to REPAIR_REQUEST,
+ "repair.response" to REPAIR_RESPONSE,
+ "repair.cache.advertise" to CACHE_ADVERTISE,
+ "peer.status" to STATUS_REPORT,
+ "route.feedback" to ROUTE_FEEDBACK,
+ "content.pack.advertise" to PACK_ADVERTISE,
+ "gitpoa.metadata" to GIT_POA_METADATA,
+ "gitpoa.commit.observation" to COMMIT_OBSERVATION_NOTE,
+ "session.content.advertise" to SESSION_CONTENT_ADVERTISE,
+ "session.content.chunk" to SESSION_CONTENT_CHUNK,
+ "session.content.ack" to SESSION_CONTENT_ACK,
+ "session.content.complete" to SESSION_CONTENT_COMPLETE,
+ )
+
+ private val msgTypeByNumber: Map<Int, String> =
+ numberByMsgType.entries.associate { (msgType, number) -> number to msgType }
+
+ fun numberFor(msgType: String): Int? = numberByMsgType[msgType]
+
+ fun stringFor(number: Int): String? = msgTypeByNumber[number]
+
+ fun allMsgTypes(): Set<String> = numberByMsgType.keys
+
+ fun allNumbers(): Set<Int> = msgTypeByNumber.keys
+}
diff --git a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypesTest.kt b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypesTest.kt
new file mode 100644
index 0000000..8d06d54
--- /dev/null
+++ b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypesTest.kt
@@ -0,0 +1,98 @@
+package jp.orgflow.fsmp.message
+
+import jp.orgflow.fsmp.waterline.Waterline
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class FsmpMessageTypesTest {
+
+ private fun allMessages(): List<FsmpMessage> = listOf(
+ PeerHello(streamId = "s", peerId = "p1"),
+ PeerBye(streamId = "s", peerId = "p1"),
+ StatusReport(streamId = "s", peerId = "p1", waterline = Waterline.USABLE, missingChunkCount = 0),
+ ManifestRequest(streamId = "s", packId = "p1"),
+ ManifestResponse(streamId = "s", packId = "p1", manifestHash = "hash", totalChunks = 1, chunkHashes = listOf("hash")),
+ ChunkMessage(streamId = "s", packId = "p1", chunkIndex = 0, chunkHash = "hash", payloadSizeBytes = 1),
+ AckBitmapMessage(streamId = "s", packId = "p1", peerId = "p1", receivedBitmap = listOf(true)),
+ PackAdvertise(streamId = "s", packId = "p1", manifestHash = "hash", totalChunks = 1),
+ RepairRequest(streamId = "s", packId = "p1", missingChunkIndexes = listOf(0)),
+ RepairResponse(streamId = "s", packId = "p1", chunkIndex = 0, chunkHash = "hash"),
+ CacheAdvertise(streamId = "s", packId = "p1", cachedChunkIndexes = listOf(0)),
+ RouteFeedback(streamId = "s", fromPeerId = "a", toPeerId = "b", success = true, rttMs = 1.0),
+ GitPoaMetadata(streamId = "s", commitHash = "commit", manifestHashes = listOf("hash"), committedAtMs = 0L),
+ CommitObservationNote(streamId = "s", commitHash = "commit", observedByPeerId = "p1", observedAtMs = 0L, consistent = true),
+ )
+
+ @Test
+ fun protocolVersionIsOne() {
+ assertEquals(1, FsmpMessageTypes.PROTOCOL_VERSION)
+ }
+
+ @Test
+ fun everyMessageInstanceCarriesProtocolVersionAndRegisteredType() {
+ allMessages().forEach { message ->
+ assertEquals(FsmpMessageTypes.PROTOCOL_VERSION, message.protocolVersion)
+ assertTrue(message.validate())
+ assertNotNull(FsmpMessageTypes.numberFor(message.msgType))
+ }
+ }
+
+ @Test
+ fun bidiRoundTripForAllTypes() {
+ FsmpMessageTypes.allMsgTypes().forEach { msgType ->
+ val number = assertNotNull(FsmpMessageTypes.numberFor(msgType))
+ assertEquals(msgType, FsmpMessageTypes.stringFor(number))
+ }
+ FsmpMessageTypes.allNumbers().forEach { number ->
+ val msgType = assertNotNull(FsmpMessageTypes.stringFor(number))
+ assertEquals(number, FsmpMessageTypes.numberFor(msgType))
+ }
+ }
+
+ @Test
+ fun noDuplicateNumbers() {
+ assertEquals(FsmpMessageTypes.allMsgTypes().size, FsmpMessageTypes.allNumbers().size)
+ }
+
+ @Test
+ fun allNumbersFitU16() {
+ FsmpMessageTypes.allNumbers().forEach { number ->
+ assertTrue(number in 0..0xFFFF)
+ }
+ }
+
+ @Test
+ fun stableNumberingContract() {
+ assertEquals(1, FsmpMessageTypes.RESERVED_FRAME)
+ assertEquals(2, FsmpMessageTypes.RESERVED_MESSAGE)
+ assertEquals(3, FsmpMessageTypes.numberFor("peer.hello"))
+ assertEquals(4, FsmpMessageTypes.numberFor("peer.bye"))
+ assertEquals(5, FsmpMessageTypes.numberFor("content.manifest.request"))
+ assertEquals(6, FsmpMessageTypes.numberFor("content.manifest.response"))
+ assertEquals(7, FsmpMessageTypes.numberFor("content.chunk"))
+ assertEquals(8, FsmpMessageTypes.numberFor("content.ack.bitmap"))
+ assertEquals(9, FsmpMessageTypes.numberFor("repair.request"))
+ assertEquals(10, FsmpMessageTypes.numberFor("repair.response"))
+ assertEquals(11, FsmpMessageTypes.numberFor("repair.cache.advertise"))
+ assertEquals(12, FsmpMessageTypes.numberFor("peer.status"))
+ assertEquals(13, FsmpMessageTypes.numberFor("route.feedback"))
+ assertEquals(14, FsmpMessageTypes.numberFor("content.pack.advertise"))
+ assertEquals(15, FsmpMessageTypes.numberFor("gitpoa.metadata"))
+ assertEquals(16, FsmpMessageTypes.numberFor("gitpoa.commit.observation"))
+ assertEquals(32, FsmpMessageTypes.numberFor("session.content.advertise"))
+ assertEquals(33, FsmpMessageTypes.numberFor("session.content.chunk"))
+ assertEquals(34, FsmpMessageTypes.numberFor("session.content.ack"))
+ assertEquals(35, FsmpMessageTypes.numberFor("session.content.complete"))
+ }
+
+ @Test
+ fun unknownLookupsReturnNull() {
+ assertNull(FsmpMessageTypes.numberFor("unknown.type"))
+ assertNull(FsmpMessageTypes.stringFor(0))
+ assertNull(FsmpMessageTypes.stringFor(17))
+ assertNull(FsmpMessageTypes.stringFor(0xFFFF))
+ }
+}
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 7be5737..19eb059 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
@@ -48,6 +48,7 @@ class WebRtcMeshTransport(
private val sessions = mutableMapOf<PeerId, MeshSession>()
private val mutex = Mutex()
private val knownPeers = mutableSetOf<PeerId>()
+ private val earlyIce = mutableMapOf<PeerId, MutableList<IceCandidateSignal>>()
private val _incoming = kotlinx.coroutines.flow.MutableSharedFlow<Pair<PeerId, ByteArray>>(extraBufferCapacity = 256)
private val _events = kotlinx.coroutines.flow.MutableSharedFlow<FsmpTransportEvent>(extraBufferCapacity = 128)
private val envelopes = kotlinx.coroutines.flow.MutableSharedFlow<SignalingEnvelope>(extraBufferCapacity = 128)
@@ -107,11 +108,16 @@ class WebRtcMeshTransport(
session.peer.acceptAnswer(answer.sdp)
session.remoteDescriptionSet = true
flushPendingIce(session)
+ flushEarlyIce(sender, session)
session.pending?.complete(Unit)
}
TYPE_ICE -> {
val ice = json.decodeFromString(IceCandidateSignal.serializer(), envelope.payloadJson)
- val session = session(sender) ?: return
+ val session = session(sender)
+ if (session == null) {
+ earlyIce.getOrPut(sender) { mutableListOf() }.add(ice)
+ return
+ }
if (session.remoteDescriptionSet) {
runCatching { session.peer.addIceCandidate(ice.candidate, ice.sdpMid, ice.sdpMLineIndex) }
} else {
@@ -129,6 +135,13 @@ class WebRtcMeshTransport(
}
}
+ private fun flushEarlyIce(sender: PeerId, session: MeshSession) {
+ val buffered = earlyIce.remove(sender) ?: return
+ buffered.forEach { ice ->
+ runCatching { session.peer.addIceCandidate(ice.candidate, ice.sdpMid, ice.sdpMLineIndex) }
+ }
+ }
+
private fun initiatorBetween(self: PeerId, remote: PeerId): Boolean = self.value < remote.value
private suspend fun session(remote: PeerId): MeshSession? = mutex.withLock { sessions[remote] }
@@ -178,6 +191,7 @@ class WebRtcMeshTransport(
val answerSdp = session.peer.acceptOffer(offer.sdp)
session.remoteDescriptionSet = true
flushPendingIce(session)
+ flushEarlyIce(sender, session)
sendSignal(
sender,
TYPE_ANSWER,
diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/net/ContentTransferService.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/net/ContentTransferService.kt
index 58c4a9f..14cfa4c 100644
--- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/net/ContentTransferService.kt
+++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/net/ContentTransferService.kt
@@ -9,6 +9,7 @@ import jp.orgflow.fsmp.session.ContentComplete
import jp.orgflow.fsmp.session.ContentReceiverSession
import jp.orgflow.fsmp.session.ContentSenderSession
import jp.orgflow.fsmp.session.ContentTransferMessage
+import jp.orgflow.fsmp.message.FsmpMessageTypes
import jp.orgflow.transport.api.FsmpTransport
import kotlinx.serialization.Serializable
import kotlinx.coroutines.CoroutineScope
@@ -62,9 +63,9 @@ class ContentTransferService(
while (remaining > 0 || sentToPeer == 0) {
val chunk = sender.nextChunkFor(target.value, packId) ?: break
val encoded = encode(chunk)
- if (sentToPeer > 0 && encoded.size > remaining) return sent
+ if (sentToPeer > 0 && encoded.size > remaining) break
val okSend = transport.send(target, encoded)
- if (!okSend) return sent
+ if (!okSend) break
sentAtLock.lock()
try {
chunkSentAt[Triple(packId, target.value, chunk.index)] = nowMs()
@@ -149,23 +150,47 @@ class ContentTransferService(
}
}
- private fun encode(message: ContentTransferMessage): ByteArray = when (message) {
- is ContentChunk -> {
- @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
- val b64 = kotlin.io.encoding.Base64.encode(message.payload)
- json.encodeToString(
- ChunkWire.serializer(),
- ChunkWire(message.packId, message.index, b64, message.sha256),
- ).encodeToByteArray()
+ private fun wireTypeName(message: ContentTransferMessage): String = when (message) {
+ is ContentAdvertise -> "session.content.advertise"
+ is ContentChunk -> "session.content.chunk"
+ is ContentAck -> "session.content.ack"
+ is ContentComplete -> "session.content.complete"
+ }
+
+ private fun encode(message: ContentTransferMessage): ByteArray {
+ val text = when (message) {
+ is ContentChunk -> {
+ @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
+ val b64 = kotlin.io.encoding.Base64.encode(message.payload)
+ json.encodeToString(
+ ChunkWire.serializer(),
+ ChunkWire(message.packId, message.index, b64, message.sha256),
+ )
+ }
+ else -> json.encodeToString(ContentTransferMessage.serializer(), message)
+ }
+ val msgTypeNumber = FsmpMessageTypes.numberFor(wireTypeName(message))
+ val framed = if (msgTypeNumber == null) {
+ text
+ } else {
+ val obj = json.parseToJsonElement(text) as? kotlinx.serialization.json.JsonObject
+ if (obj == null) {
+ text
+ } else {
+ val withNumber = obj + ("msgTypeNumber" to kotlinx.serialization.json.JsonPrimitive(msgTypeNumber))
+ kotlinx.serialization.json.JsonObject(withNumber).toString()
+ }
}
- else -> json.encodeToString(ContentTransferMessage.serializer(), message).encodeToByteArray()
+ return framed.encodeToByteArray()
}
private fun decode(bytes: ByteArray): ContentTransferMessage? = try {
val text = bytes.decodeToString()
val element = json.parseToJsonElement(text)
val obj = element as? kotlinx.serialization.json.JsonObject
- if (obj != null && obj.containsKey("payloadB64")) {
+ val msgTypeNumber =
+ (obj?.get("msgTypeNumber") as? kotlinx.serialization.json.JsonPrimitive)?.content?.toIntOrNull()
+ val message = if (obj != null && obj.containsKey("payloadB64")) {
val wire = json.decodeFromString(ChunkWire.serializer(), text)
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
val payload = kotlin.io.encoding.Base64.decode(wire.payloadB64)
@@ -173,6 +198,7 @@ class ContentTransferService(
} else {
json.decodeFromString(ContentTransferMessage.serializer(), text)
}
+ if (msgTypeNumber != null && msgTypeNumber != FsmpMessageTypes.numberFor(wireTypeName(message))) null else message
} catch (e: Exception) {
null
}
diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/ChatMessageSignal.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/ChatMessageSignal.kt
new file mode 100644
index 0000000..6853b23
--- /dev/null
+++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/ChatMessageSignal.kt
@@ -0,0 +1,23 @@
+package jp.orgflow.transport.signaling
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Classroom chat relayed through signaling. [gid] is the set id (room) the message
+ * belongs to; the server relays the envelope to peers in that gid, excluding the sender.
+ * [targetPeerIds] limits delivery to those peers within the gid (empty = all in gid),
+ * enabling pie-menu 送信先絞り込み (W16).
+ */
+@Serializable
+data class ChatMessageSignal(
+ val gid: String,
+ val senderPeerId: String,
+ val displayName: String = "",
+ val body: String = "",
+ val sentAtMs: Long = 0L,
+ val targetPeerIds: List<String> = emptyList(),
+) {
+ companion object {
+ const val TYPE = "chat"
+ }
+}
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 b0d11f0..25f3e90 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
@@ -2,10 +2,17 @@ package jp.orgflow.transport.signaling
import kotlinx.serialization.Serializable
+/**
+ * roomId is kept as the wire field name for compatibility, but semantically it is a gid:
+ * an identifier of a set/group (W16 集合モデル) the peer belongs to.
+ * A peer may belong to multiple sets at once; [roomIds] lists the additional gids
+ * (the server merges [roomId] + [roomIds], deduped).
+ */
@Serializable
data class PeerJoinSignal(
val peerId: String,
val displayName: String = "",
val capabilitiesJson: String = "{}",
val roomId: String = "default",
+ val roomIds: List<String> = emptyList(),
)
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 b5dd5d1..29c522b 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
@@ -14,4 +14,6 @@ interface SignalingClient {
suspend fun close()
fun outgoing(): Flow<SignalingEnvelope>
+
+ fun onChat(handler: (ChatMessageSignal) -> Unit) = Unit
}
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
index 1811ad8..f6f975e 100644
--- a/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/PeerJoinSignalSerializationTest.kt
+++ b/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/PeerJoinSignalSerializationTest.kt
@@ -2,8 +2,11 @@ package jp.orgflow.transport
import jp.orgflow.transport.signaling.PeerJoinSignal
import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
class PeerJoinSignalSerializationTest {
@@ -26,4 +29,31 @@ class PeerJoinSignalSerializationTest {
fun defaultRoomIdIsDefault() {
assertEquals("default", PeerJoinSignal(peerId = "peer-1").roomId)
}
+
+ @Test
+ fun roomIdsRoundTrip() {
+ val signal = PeerJoinSignal(peerId = "peer-1", roomId = "room-a", roomIds = listOf("room-a", "room-b"))
+ val encoded = Json.encodeToString(PeerJoinSignal.serializer(), signal)
+ val decoded = Json.decodeFromString(PeerJoinSignal.serializer(), encoded)
+ assertEquals("room-a", decoded.roomId)
+ assertEquals(listOf("room-a", "room-b"), decoded.roomIds)
+ }
+
+ @Test
+ fun legacyPayloadWithoutRoomIdsDecodesToEmpty() {
+ val decoded = Json.decodeFromString(PeerJoinSignal.serializer(), "{\"peerId\":\"peer-1\",\"roomId\":\"room-a\"}")
+ assertEquals(emptyList(), decoded.roomIds)
+ }
+
+ @Test
+ fun wireFieldNamesUnchanged() {
+ val encoded = Json.encodeToString(
+ PeerJoinSignal.serializer(),
+ PeerJoinSignal(peerId = "peer-1", roomId = "room-a", roomIds = listOf("room-b")),
+ )
+ val obj = Json.parseToJsonElement(encoded).jsonObject
+ assertEquals("peer-1", obj["peerId"]!!.jsonPrimitive.content)
+ assertEquals("room-a", obj["roomId"]!!.jsonPrimitive.content)
+ assertNotNull(obj["roomIds"])
+ }
}
diff --git a/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/net/ContentTransferServiceWireTest.kt b/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/net/ContentTransferServiceWireTest.kt
new file mode 100644
index 0000000..ecf0551
--- /dev/null
+++ b/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/net/ContentTransferServiceWireTest.kt
@@ -0,0 +1,88 @@
+package jp.orgflow.transport.net
+
+import jp.orgflow.domain.identity.PeerId
+import jp.orgflow.transport.api.FsmpTransport
+import jp.orgflow.transport.api.FsmpTransportEvent
+import jp.orgflow.transport.api.FsmpTransportState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class ContentTransferServiceWireTest {
+
+ private class FakeTransport(override val incoming: Flow<Pair<PeerId, ByteArray>>) : FsmpTransport {
+ override val peerId = PeerId("self")
+ override val state = FsmpTransportState.CONNECTED
+ override val events: Flow<FsmpTransportEvent> = emptyFlow()
+ val sent = mutableListOf<Pair<PeerId, ByteArray>>()
+
+ override suspend fun connect(target: PeerId) {}
+ override suspend fun disconnect(target: PeerId) {}
+ override suspend fun send(target: PeerId, bytes: ByteArray): Boolean {
+ sent.add(target to bytes)
+ return true
+ }
+ override suspend fun close() {}
+ }
+
+ private fun newService(transport: FakeTransport, scope: CoroutineScope) =
+ ContentTransferService(transport, scope, nowMs = { 0L })
+
+ private fun TestScope.newServiceWithEagerCollector(transport: FakeTransport) =
+ newService(transport, CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler)))
+
+ @Test
+ fun advertiseWireCarriesRegistryNumberAndRoundTrips() = runTest {
+ val incoming = MutableSharedFlow<Pair<PeerId, ByteArray>>(replay = 16, extraBufferCapacity = 64)
+ val transport = FakeTransport(incoming)
+ val service = newServiceWithEagerCollector(transport)
+ service.start()
+ val bytes = ByteArray(2048) { (it % 7).toByte() }
+ assertTrue(service.distribute("pack-w1", bytes, listOf(PeerId("self"))))
+ val advertiseJson = transport.sent.single().second.decodeToString()
+ assertTrue(advertiseJson.contains("\"msgTypeNumber\":32"))
+
+ incoming.tryEmit(PeerId("self") to transport.sent.single().second)
+ testScheduler.advanceUntilIdle()
+ assertEquals(2, transport.sent.size)
+ val ackJson = transport.sent.last().second.decodeToString()
+ assertTrue(ackJson.contains("\"msgTypeNumber\":34"))
+ assertTrue(ackJson.contains("ContentAck"))
+ }
+
+ @Test
+ fun legacyWireWithoutNumberIsAccepted() = runTest {
+ val incoming = MutableSharedFlow<Pair<PeerId, ByteArray>>(replay = 16, extraBufferCapacity = 64)
+ val transport = FakeTransport(incoming)
+ val service = newServiceWithEagerCollector(transport)
+ service.start()
+ val legacy =
+ """{"type":"jp.orgflow.fsmp.session.ContentAdvertise","packId":"pack-w2","manifestSha256":"legacy",""" +
+ """"totalChunks":1,"chunkSizeBytes":1024,"totalBytes":1024,"chunkSha256List":["legacy"]}"""
+ incoming.tryEmit(PeerId("self") to legacy.encodeToByteArray())
+ testScheduler.advanceUntilIdle()
+ assertEquals(1, transport.sent.size)
+ assertTrue(transport.sent.single().second.decodeToString().contains("\"msgTypeNumber\":34"))
+ }
+
+ @Test
+ fun mismatchedMsgTypeNumberIsDropped() = runTest {
+ val incoming = MutableSharedFlow<Pair<PeerId, ByteArray>>(replay = 16, extraBufferCapacity = 64)
+ val transport = FakeTransport(incoming)
+ val service = newServiceWithEagerCollector(transport)
+ service.start()
+ val forged =
+ """{"type":"jp.orgflow.fsmp.session.ContentAdvertise","packId":"pack-w3","manifestSha256":"forged",""" +
+ """"totalChunks":1,"chunkSizeBytes":1024,"totalBytes":1024,"chunkSha256List":["forged"],"msgTypeNumber":999}"""
+ incoming.tryEmit(PeerId("self") to forged.encodeToByteArray())
+ testScheduler.advanceUntilIdle()
+ assertEquals(0, transport.sent.size)
+ }
+}
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 8994e2b..0c78b71 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
@@ -31,6 +31,9 @@ class WsSignalingClient(
)
private val _outgoing = MutableSharedFlow<SignalingEnvelope>(extraBufferCapacity = 128)
+ @Volatile
+ private var chatHandler: ((ChatMessageSignal) -> Unit)? = null
+
private var http: HttpClient? = null
private var socket: WebSocket? = null
private val sendMutex = kotlinx.coroutines.sync.Mutex()
@@ -41,6 +44,15 @@ class WsSignalingClient(
override fun outgoing(): Flow<SignalingEnvelope> = _outgoing
+ override fun onChat(handler: (ChatMessageSignal) -> Unit) {
+ chatHandler = handler
+ }
+
+ private fun dispatchChat(envelope: SignalingEnvelope) {
+ val chat = runCatching { json.decodeFromString(ChatMessageSignal.serializer(), envelope.payloadJson) }.getOrNull() ?: return
+ chatHandler?.invoke(chat)
+ }
+
override suspend fun connect() {
val uri = normalize(endpoint)
val client = HttpClient.newHttpClient()
@@ -57,6 +69,7 @@ class WsSignalingClient(
val envelope = json.decodeFromString(SignalingEnvelope.serializer(), text)
if (envelope.isFor(selfPeerId)) {
_incoming.tryEmit(envelope)
+ if (envelope.type == ChatMessageSignal.TYPE) dispatchChat(envelope)
}
}
return null
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 7342efa..06bd597 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
@@ -13,6 +13,7 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import kotlin.concurrent.thread
import kotlinx.serialization.json.Json
+import jp.orgflow.transport.signaling.ChatMessageSignal
import jp.orgflow.transport.signaling.PeerJoinSignal
import jp.orgflow.transport.signaling.SignalingEnvelope
@@ -21,7 +22,9 @@ class WsSignalingServer(
) {
private val json = Json { ignoreUnknownKeys = true }
private val rooms = ConcurrentHashMap<String, ConcurrentHashMap<String, OutputStream>>()
+ private val peerGids = ConcurrentHashMap<String, List<String>>()
private val writeLocks = ConcurrentHashMap<String, Any>()
+ private val membershipLock = Any()
private val nonce = AtomicLong(0)
@Volatile
@@ -87,70 +90,93 @@ class WsSignalingServer(
val joinEnvelope = parse(firstMessage) ?: return
if (joinEnvelope.type != TYPE_JOIN) return
val peerId = joinEnvelope.senderPeerId
- val roomId = joinRoomId(joinEnvelope)
- val room = roomFor(roomId)
- val roster: List<String>
- val joinTargets: List<String>
- synchronized(room) {
- room[peerId] = output
+ val gids = joinGids(joinEnvelope)
+ synchronized(membershipLock) {
+ gids.forEach { gid -> roomFor(gid)[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}")
-
- roster.forEach { payload -> sendText(peerId, payload) }
- joinTargets.forEach { other ->
- sendText(
- other,
- json.encodeToString(
- SignalingEnvelope.serializer(),
- SignalingEnvelope(
- type = TYPE_PEER_JOINED,
- senderPeerId = peerId,
- nonce = nonce.incrementAndGet(),
- timestampMs = nowMs(),
- payloadJson = joinEnvelope.payloadJson,
- ),
- ),
- )
+ peerGids[peerId] = gids
+ gids.flatMap { gid -> rosterPayload(roomFor(gid), gid, peerId) }
+ .forEach { payload -> sendText(peerId, payload) }
+ gids.flatMap { gid -> roomFor(gid).keys.filter { it != peerId } }
+ .distinct()
+ .forEach { other ->
+ sendText(
+ other,
+ json.encodeToString(
+ SignalingEnvelope.serializer(),
+ SignalingEnvelope(
+ type = TYPE_PEER_JOINED,
+ senderPeerId = peerId,
+ nonce = nonce.incrementAndGet(),
+ timestampMs = nowMs(),
+ payloadJson = joinEnvelope.payloadJson,
+ ),
+ ),
+ )
+ }
}
+ System.err.println("[signaling] registered $peerId in rooms ${gids.joinToString(",")} from ${socket.remoteSocketAddress}")
try {
while (true) {
val text = readTextFrame(input) ?: break
val envelope = parse(text) ?: continue
- val target = envelope.targetPeerId
val payload = json.encodeToString(SignalingEnvelope.serializer(), envelope)
- if (target == null) {
- room.keys.filter { it != peerId }.forEach { other -> sendText(other, payload) }
- } else {
- System.err.println("[signaling] relay ${envelope.type} $peerId -> $target (${writerFor(target) != null})")
- sendText(target, payload)
+ val target = envelope.targetPeerId
+ when {
+ envelope.type == ChatMessageSignal.TYPE -> relayChat(peerId, envelope, payload)
+ target != null -> {
+ System.err.println("[signaling] relay ${envelope.type} $peerId -> $target (${writerFor(target) != null})")
+ sendText(target, payload)
+ }
+ else -> broadcastFrom(peerId, payload)
}
}
} finally {
- room.remove(peerId)
- writeLocks.remove(peerId)
- room.keys.forEach { other ->
- sendText(
- other,
- json.encodeToString(
- SignalingEnvelope.serializer(),
- SignalingEnvelope(
- type = TYPE_PEER_LEFT,
- senderPeerId = peerId,
- nonce = nonce.incrementAndGet(),
- timestampMs = nowMs(),
- payloadJson = "{}",
- ),
- ),
- )
- }
+ leave(peerId)
runCatching { socket.close() }
}
}
+ private fun broadcastFrom(peerId: String, payload: String) {
+ val targets = LinkedHashSet<String>()
+ peerGids[peerId]?.forEach { gid -> rooms[gid]?.keys?.let { targets.addAll(it) } }
+ targets.remove(peerId)
+ targets.forEach { other -> sendText(other, payload) }
+ }
+
+ private fun relayChat(peerId: String, envelope: SignalingEnvelope, payload: String) {
+ val chat = runCatching { json.decodeFromString(ChatMessageSignal.serializer(), envelope.payloadJson) }.getOrNull() ?: return
+ val room = rooms[chat.gid] ?: return
+ val targets = if (chat.targetPeerIds.isEmpty()) room.keys else chat.targetPeerIds
+ targets.filter { it != peerId && room.containsKey(it) }.forEach { other -> sendText(other, payload) }
+ }
+
+ private fun leave(peerId: String) {
+ synchronized(membershipLock) {
+ val notified = LinkedHashSet<String>()
+ val formerGids = peerGids.remove(peerId) ?: emptyList()
+ formerGids.forEach { gid ->
+ val room = rooms[gid]
+ room?.remove(peerId)
+ room?.keys?.let { notified.addAll(it) }
+ }
+ writeLocks.remove(peerId)
+ if (notified.isEmpty()) return
+ val payload = json.encodeToString(
+ SignalingEnvelope.serializer(),
+ SignalingEnvelope(
+ type = TYPE_PEER_LEFT,
+ senderPeerId = peerId,
+ nonce = nonce.incrementAndGet(),
+ timestampMs = nowMs(),
+ payloadJson = "{}",
+ ),
+ )
+ notified.forEach { other -> sendText(other, payload) }
+ }
+ }
+
private fun sendText(peerId: String, text: String) {
val output = writerFor(peerId) ?: return
val lock = writeLocks[peerId] ?: return
@@ -182,10 +208,13 @@ class WsSignalingServer(
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 }
+ private fun joinGids(envelope: SignalingEnvelope): List<String> = try {
+ val signal = json.decodeFromString(PeerJoinSignal.serializer(), envelope.payloadJson)
+ (listOf(signal.roomId) + signal.roomIds)
+ .map { gid -> gid.ifBlank { DEFAULT_ROOM } }
+ .distinct()
} catch (e: Exception) {
- DEFAULT_ROOM
+ listOf(DEFAULT_ROOM)
}
private fun writerFor(peerId: String): OutputStream? {
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
index c16c799..3c7468c 100644
--- 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
@@ -11,6 +11,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.fail
+import jp.orgflow.transport.signaling.ChatMessageSignal
import jp.orgflow.transport.signaling.PeerJoinSignal
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
@@ -20,6 +21,10 @@ import kotlinx.serialization.json.put
class WsSignalingServerRoomRoutingTest {
+ private companion object {
+ const val JOIN_BARRIER_MS = 200L
+ }
+
private class TestClient(endpoint: String) {
private val received = LinkedBlockingQueue<String>()
private val connected = CompletableFuture<Unit>()
@@ -40,8 +45,15 @@ class WsSignalingServerRoomRoutingTest {
},
).get()
- fun join(peerId: String, roomId: String) {
- socket.sendText(envelope("join", peerId, payloadJson = json.encodeToString(PeerJoinSignal.serializer(), PeerJoinSignal(peerId, roomId = roomId))), true).get()
+ fun join(peerId: String, roomId: String, roomIds: List<String> = emptyList()) {
+ socket.sendText(
+ envelope(
+ "join",
+ peerId,
+ payloadJson = json.encodeToString(PeerJoinSignal.serializer(), PeerJoinSignal(peerId, roomId = roomId, roomIds = roomIds)),
+ ),
+ true,
+ ).get()
}
fun joinLegacy(peerId: String) {
@@ -52,6 +64,13 @@ class WsSignalingServerRoomRoutingTest {
socket.sendText(envelope(type, senderPeerId, targetPeerId, payloadJson = "{}"), true).get()
}
+ fun sendChat(senderPeerId: String, chat: ChatMessageSignal) {
+ socket.sendText(
+ envelope(ChatMessageSignal.TYPE, senderPeerId, payloadJson = json.encodeToString(ChatMessageSignal.serializer(), chat)),
+ true,
+ ).get()
+ }
+
fun abort() {
socket.abort()
}
@@ -79,6 +98,9 @@ class WsSignalingServerRoomRoutingTest {
private fun field(envelope: JsonObject, key: String): String = envelope[key]!!.jsonPrimitive.content
+ private fun payloadField(envelope: JsonObject, key: String): String =
+ field(Json.parseToJsonElement(field(envelope, "payloadJson")).jsonObject, key)
+
@Test
fun peersInSameRoomSeeEachOtherButOtherRoomsDoNot() {
val server = WsSignalingServer(port = 0)
@@ -87,15 +109,15 @@ class WsSignalingServerRoomRoutingTest {
try {
val alice = TestClient(endpoint)
alice.join("alice", "room-a")
+ Thread.sleep(JOIN_BARRIER_MS)
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 carol = TestClient(endpoint)
+ carol.join("carol", "room-b")
val peerJoinedForAlice = alice.awaitMessage()
assertEquals("peer-joined", field(peerJoinedForAlice, "type"))
@@ -136,6 +158,7 @@ class WsSignalingServerRoomRoutingTest {
try {
val dave = TestClient(endpoint)
dave.joinLegacy("dave")
+ Thread.sleep(JOIN_BARRIER_MS)
val eve = TestClient(endpoint)
eve.joinLegacy("eve")
@@ -146,4 +169,185 @@ class WsSignalingServerRoomRoutingTest {
server.stop()
}
}
+
+ @Test
+ fun peerInMultipleGidsReceivesEveryGidEvents() {
+ 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", roomIds = listOf("room-a", "room-b"))
+ Thread.sleep(JOIN_BARRIER_MS)
+ val bob = TestClient(endpoint)
+ bob.join("bob", "room-a")
+
+ val rosterForBob = bob.awaitMessage()
+ assertEquals("peer-joined", field(rosterForBob, "type"))
+ assertEquals("alice", field(rosterForBob, "senderPeerId"))
+ assertEquals("room-a", payloadField(rosterForBob, "roomId"))
+
+ val carol = TestClient(endpoint)
+ carol.join("carol", "room-b")
+
+ val bobJoinedForAlice = alice.awaitMessage()
+ assertEquals("bob", field(bobJoinedForAlice, "senderPeerId"))
+
+ val rosterForCarol = carol.awaitMessage()
+ assertEquals("peer-joined", field(rosterForCarol, "type"))
+ assertEquals("alice", field(rosterForCarol, "senderPeerId"))
+ assertEquals("room-b", payloadField(rosterForCarol, "roomId"))
+
+ val carolJoinedForAlice = alice.awaitMessage()
+ assertEquals("carol", field(carolJoinedForAlice, "senderPeerId"))
+
+ carol.send("note", "carol")
+ val carolNoteForAlice = alice.awaitMessage()
+ assertEquals("note", field(carolNoteForAlice, "type"))
+ assertEquals("carol", field(carolNoteForAlice, "senderPeerId"))
+ assertTrue(bob.expectSilence(), "bob in room-a must not receive room-b broadcasts")
+
+ bob.send("note", "bob")
+ val bobNoteForAlice = alice.awaitMessage()
+ assertEquals("note", field(bobNoteForAlice, "type"))
+ assertEquals("bob", field(bobNoteForAlice, "senderPeerId"))
+ assertTrue(carol.expectSilence(), "carol in room-b must not receive room-a broadcasts")
+ } finally {
+ server.stop()
+ }
+ }
+
+ @Test
+ fun broadcastReachesPeersSharingAnyGidExactlyOnce() {
+ 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", roomIds = listOf("room-a", "room-b"))
+ Thread.sleep(JOIN_BARRIER_MS)
+ val bob = TestClient(endpoint)
+ bob.join("bob", "room-a", roomIds = listOf("room-a", "room-b"))
+
+ assertEquals("room-a", payloadField(bob.awaitMessage(), "roomId"))
+ assertEquals("room-b", payloadField(bob.awaitMessage(), "roomId"))
+ assertEquals("bob", field(alice.awaitMessage(), "senderPeerId"))
+
+ val carol = TestClient(endpoint)
+ carol.join("carol", "room-b")
+
+ assertEquals("room-b", payloadField(carol.awaitMessage(), "roomId"))
+ assertEquals("room-b", payloadField(carol.awaitMessage(), "roomId"))
+ assertEquals("carol", field(alice.awaitMessage(), "senderPeerId"))
+ assertEquals("carol", field(bob.awaitMessage(), "senderPeerId"))
+
+ alice.send("note", "alice")
+ val noteForBob = bob.awaitMessage()
+ assertEquals("note", field(noteForBob, "type"))
+ assertEquals("alice", field(noteForBob, "senderPeerId"))
+ assertTrue(bob.expectSilence(), "bob shares two gids with alice but must receive the broadcast once")
+ val noteForCarol = carol.awaitMessage()
+ assertEquals("note", field(noteForCarol, "type"))
+ assertTrue(alice.expectSilence(), "sender must not receive its own broadcast")
+ } finally {
+ server.stop()
+ }
+ }
+
+ @Test
+ fun chatRelaysWithinGidAndExcludesSender() {
+ 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")
+ Thread.sleep(JOIN_BARRIER_MS)
+ val bob = TestClient(endpoint)
+ bob.join("bob", "room-a")
+ val carol = TestClient(endpoint)
+ carol.join("carol", "room-b")
+
+ assertEquals("alice", field(bob.awaitMessage(), "senderPeerId"))
+ assertEquals("bob", field(alice.awaitMessage(), "senderPeerId"))
+
+ alice.sendChat(
+ "alice",
+ ChatMessageSignal(gid = "room-a", senderPeerId = "alice", displayName = "Alice", body = "hello", sentAtMs = 42L),
+ )
+ val chatForBob = bob.awaitMessage()
+ assertEquals("chat", field(chatForBob, "type"))
+ assertEquals("alice", field(chatForBob, "senderPeerId"))
+ assertEquals("hello", payloadField(chatForBob, "body"))
+ assertEquals("room-a", payloadField(chatForBob, "gid"))
+ assertEquals("Alice", payloadField(chatForBob, "displayName"))
+ assertEquals("42", payloadField(chatForBob, "sentAtMs"))
+ assertTrue(alice.expectSilence(), "sender must not receive its own chat")
+ assertTrue(carol.expectSilence(), "chat must not leak into other gids")
+
+ alice.sendChat(
+ "alice",
+ ChatMessageSignal(gid = "room-z", senderPeerId = "alice", body = "ghost", sentAtMs = 43L),
+ )
+ assertTrue(bob.expectSilence(), "chat to unknown gid must be dropped")
+ assertTrue(carol.expectSilence(), "chat to unknown gid must be dropped")
+ } finally {
+ server.stop()
+ }
+ }
+
+ @Test
+ fun targetedChatRelaysOnlyToTargetsInGid() {
+ 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")
+ Thread.sleep(JOIN_BARRIER_MS)
+ val bob = TestClient(endpoint)
+ bob.join("bob", "room-a")
+ assertEquals("bob", field(alice.awaitMessage(), "senderPeerId"))
+ val carol = TestClient(endpoint)
+ carol.join("carol", "room-a")
+ val dave = TestClient(endpoint)
+ dave.join("dave", "room-b")
+
+ assertEquals("room-a", payloadField(bob.awaitMessage(), "roomId"))
+ assertEquals("room-a", payloadField(carol.awaitMessage(), "roomId"))
+ assertEquals("room-a", payloadField(carol.awaitMessage(), "roomId"))
+ assertEquals("carol", field(alice.awaitMessage(), "senderPeerId"))
+ assertEquals("carol", field(bob.awaitMessage(), "senderPeerId"))
+
+ alice.sendChat(
+ "alice",
+ ChatMessageSignal(gid = "room-a", senderPeerId = "alice", body = "for-bob-and-carol", sentAtMs = 44L, targetPeerIds = listOf("bob", "carol")),
+ )
+ val chatForBob = bob.awaitMessage()
+ assertEquals("chat", field(chatForBob, "type"))
+ assertEquals("for-bob-and-carol", payloadField(chatForBob, "body"))
+ val chatForCarol = carol.awaitMessage()
+ assertEquals("for-bob-and-carol", payloadField(chatForCarol, "body"))
+ assertTrue(dave.expectSilence(), "targeted chat must not reach other gids")
+ assertTrue(alice.expectSilence(), "sender must not receive its own targeted chat")
+
+ alice.sendChat(
+ "alice",
+ ChatMessageSignal(gid = "room-a", senderPeerId = "alice", body = "only-bob", sentAtMs = 45L, targetPeerIds = listOf("bob")),
+ )
+ val onlyBob = bob.awaitMessage()
+ assertEquals("only-bob", payloadField(onlyBob, "body"))
+ assertTrue(carol.expectSilence(), "untargeted gid member must not receive targeted chat")
+
+ alice.sendChat(
+ "alice",
+ ChatMessageSignal(gid = "room-a", senderPeerId = "alice", body = "outside-gid", sentAtMs = 46L, targetPeerIds = listOf("dave")),
+ )
+ assertTrue(dave.expectSilence(), "targets outside the gid must not receive chat")
+ assertTrue(bob.expectSilence(), "non-targeted members must stay silent")
+ assertTrue(carol.expectSilence(), "non-targeted members must stay silent")
+ } finally {
+ server.stop()
+ }
+ }
}
diff --git a/modules/fsmp-transport/src/wasmJsMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt b/modules/fsmp-transport/src/wasmJsMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt
new file mode 100644
index 0000000..4fe6cca
--- /dev/null
+++ b/modules/fsmp-transport/src/wasmJsMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt
@@ -0,0 +1,132 @@
+package jp.orgflow.transport.signaling
+
+import jp.orgflow.transport.mesh.WebRtcMeshTransport
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.serialization.json.Json
+
+class WsSignalingClient(
+ private val endpoint: String,
+ private val selfPeerId: String,
+ override val roomId: String = "default",
+) : SignalingClient {
+
+ private val json = Json { ignoreUnknownKeys = true }
+ private val socketId = "orgflow-ws-" + nextSocketId()
+ private val _incoming = MutableSharedFlow<SignalingEnvelope>(
+ replay = 32,
+ extraBufferCapacity = 128,
+ onBufferOverflow = BufferOverflow.SUSPEND,
+ )
+
+ override val incoming: SharedFlow<SignalingEnvelope> = _incoming
+ override var connected: Boolean = false
+ private set
+
+ private var opened = false
+ private var chatHandler: ((ChatMessageSignal) -> Unit)? = null
+
+ override fun onChat(handler: (ChatMessageSignal) -> Unit) {
+ chatHandler = handler
+ }
+
+ override suspend fun connect() {
+ wsOpen(socketId, normalize(endpoint))
+ opened = true
+ }
+
+ override suspend fun send(envelope: SignalingEnvelope) {
+ wsSend(socketId, json.encodeToString(SignalingEnvelope.serializer(), envelope))
+ }
+
+ override suspend fun close() {
+ connected = false
+ if (opened) wsClose(socketId)
+ opened = false
+ }
+
+ override fun outgoing(): Flow<SignalingEnvelope> = emptyFlow()
+
+ fun poll() {
+ if (!opened) return
+ while (true) {
+ val event = wsPollEvent()
+ if (event.isEmpty()) return
+ val separator = event.indexOf('|')
+ if (separator < 0) continue
+ if (event.substring(0, separator) != socketId) continue
+ val payload = event.substring(separator + 1)
+ when {
+ payload == EVENT_OPEN -> {
+ connected = true
+ sendJoin()
+ }
+ payload == EVENT_CLOSE -> connected = false
+ payload == EVENT_ERROR -> connected = false
+ else -> onMessage(payload)
+ }
+ }
+ }
+
+ private fun onMessage(text: String) {
+ val envelope = runCatching { json.decodeFromString(SignalingEnvelope.serializer(), text) }.getOrNull() ?: return
+ if (!envelope.isFor(selfPeerId)) return
+ _incoming.tryEmit(envelope)
+ if (envelope.type == ChatMessageSignal.TYPE) {
+ val chat = runCatching { json.decodeFromString(ChatMessageSignal.serializer(), envelope.payloadJson) }.getOrNull() ?: return
+ chatHandler?.invoke(chat)
+ }
+ }
+
+ private fun sendJoin() {
+ val join = PeerJoinSignal(selfPeerId, roomId = roomId)
+ wsSend(
+ socketId,
+ json.encodeToString(
+ SignalingEnvelope.serializer(),
+ SignalingEnvelope(
+ type = WebRtcMeshTransport.TYPE_JOIN,
+ senderPeerId = selfPeerId,
+ nonce = 0L,
+ timestampMs = 0L,
+ payloadJson = json.encodeToString(PeerJoinSignal.serializer(), join),
+ ),
+ ),
+ )
+ }
+
+ private fun normalize(endpoint: String): String {
+ val raw = if (endpoint.startsWith("ws://") || endpoint.startsWith("wss://")) endpoint else "ws://$endpoint"
+ val withoutScheme = raw.substringAfter("://")
+ return if (withoutScheme.contains('/')) raw else "$raw/ws"
+ }
+
+ private companion object {
+ const val EVENT_OPEN = "@open"
+ const val EVENT_CLOSE = "@close"
+ const val EVENT_ERROR = "@error"
+ }
+}
+
+private var socketCounter = 0
+
+private fun nextSocketId(): Int = ++socketCounter
+
+private fun wsOpen(id: String, url: String): Boolean = js(
+ "(() => { try { const reg = (globalThis.__orgflowWsSockets = globalThis.__orgflowWsSockets || {}); const q = (globalThis.__orgflowWsQueue = globalThis.__orgflowWsQueue || []); const ws = new WebSocket(url); reg[id] = ws; ws.onopen = () => q.push(id + '|@open'); ws.onmessage = (e) => q.push(id + '|' + e.data); ws.onclose = () => q.push(id + '|@close'); ws.onerror = () => q.push(id + '|@error'); return true; } catch (e) { (globalThis.__orgflowWsQueue = globalThis.__orgflowWsQueue || []).push(id + '|@error'); return false; } })()",
+)
+
+private fun wsPollEvent(): String = js(
+ "(() => { const q = globalThis.__orgflowWsQueue; if (q && q.length > 0) { return '' + q.shift(); } return ''; })()",
+)
+
+private fun wsSend(id: String, text: String): Boolean = js(
+ "(() => { try { const ws = (globalThis.__orgflowWsSockets || {})[id]; if (ws && ws.readyState === 1) { ws.send(text); return true; } } catch (e) { return false; } return false; })()",
+)
+
+private fun wsClose(id: String): Boolean = js(
+ "(() => { try { const reg = (globalThis.__orgflowWsSockets || {}); const ws = reg[id]; if (ws) { ws.onclose = null; ws.onerror = null; ws.close(); delete reg[id]; } } catch (e) { return false; } return true; })()",
+)
diff --git a/modules/orgflow-content-store/build.gradle.kts b/modules/orgflow-content-store/build.gradle.kts
index a6a9fdd..0669b22 100644
--- a/modules/orgflow-content-store/build.gradle.kts
+++ b/modules/orgflow-content-store/build.gradle.kts
@@ -16,9 +16,10 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.okio)
+ implementation(project(":modules:orgflow-crypto"))
}
- jvmMain.dependencies {
- implementation(project(":modules:vendor-nabu"))
+ wasmJsMain.dependencies {
+ implementation(libs.coroutines.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
diff --git a/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/BlockStore.kt b/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/BlockStore.kt
index 7d1e984..12d2053 100644
--- a/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/BlockStore.kt
+++ b/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/BlockStore.kt
@@ -1,18 +1,57 @@
package jp.orgflow.contentstore
interface BlockStore {
- suspend fun put(block: ContentBlock)
- suspend fun get(cid: Cid): ContentBlock?
+ suspend fun put(bytes: ByteArray): Cid
+
+ suspend fun put(block: ContentBlock): Cid {
+ check(block.cid == ContentAddresser.cidOf(block.bytes)) {
+ "block content does not match cid: ${block.cid}"
+ }
+ return put(block.bytes)
+ }
+
+ suspend fun get(cid: Cid): ByteArray?
+
+ suspend fun getBlock(cid: Cid): ContentBlock? = get(cid)?.let { ContentBlock(cid, it) }
+
+ suspend fun contains(cid: Cid): Boolean
+
+ suspend fun clear()
+
+ fun size(): Int
+
+ fun stats(): BlockStoreStats
+
+ fun verify(bytes: ByteArray, cid: Cid): Boolean = ContentAddresser.verify(bytes, cid)
}
+data class BlockStoreStats(val blockCount: Int, val totalBytes: Long)
+
class MemoryBlockStore : BlockStore {
- private val blocks = mutableMapOf<Cid, ContentBlock>()
+ private val blocks = LinkedHashMap<Cid, ByteArray>()
+
+ override suspend fun put(bytes: ByteArray): Cid {
+ val cid = ContentAddresser.cidOf(bytes)
+ if (cid !in blocks) blocks[cid] = bytes.copyOf()
+ return cid
+ }
+
+ override suspend fun get(cid: Cid): ByteArray? = blocks[cid]?.copyOf()
- override suspend fun put(block: ContentBlock) {
- blocks[block.cid] = block
+ override suspend fun contains(cid: Cid): Boolean = blocks.containsKey(cid)
+
+ override suspend fun clear() {
+ blocks.clear()
}
- override suspend fun get(cid: Cid): ContentBlock? = blocks[cid]
+ override fun size(): Int = blocks.size
- fun size(): Int = blocks.size
+ override fun stats(): BlockStoreStats =
+ BlockStoreStats(blocks.size, blocks.values.sumOf { it.size.toLong() })
}
+
+suspend fun BlockStore.store(bytes: ByteArray): Cid = put(bytes)
+
+fun BlockStore.linkFor(cid: Cid): String = "attachment:$cid"
+
+suspend fun BlockStore.linkFor(bytes: ByteArray): String = linkFor(put(bytes))
diff --git a/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/Cid.kt b/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/Cid.kt
index 2b12829..6e8ac0a 100644
--- a/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/Cid.kt
+++ b/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/Cid.kt
@@ -1,5 +1,8 @@
package jp.orgflow.contentstore
+// CID string form: "<codec>:<hashHex>" (lowercase hex), e.g. sha256:2cf24dba...
+// This is the token recorded in org-mode attachment properties (":ATTACHMENTS:").
+
data class Cid(val codec: String, val hashHex: String) {
override fun toString(): String = "$codec:$hashHex"
diff --git a/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/ContentAddresser.kt b/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/ContentAddresser.kt
index 8cb3733..de1f641 100644
--- a/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/ContentAddresser.kt
+++ b/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/ContentAddresser.kt
@@ -1,14 +1,10 @@
package jp.orgflow.contentstore
-import okio.Buffer
-import okio.HashingSink
-import okio.blackholeSink
+import jp.orgflow.crypto.HashingService
object ContentAddresser {
- fun cidOf(bytes: ByteArray): Cid {
- val source = Buffer().apply { write(bytes) }
- val sink = HashingSink.sha256(blackholeSink())
- source.readAll(sink)
- return Cid.fromSha256(sink.hash.toByteArray())
- }
+ fun cidOf(bytes: ByteArray): Cid =
+ Cid.fromSha256(HashingService.sha256(bytes).toByteArray())
+
+ fun verify(bytes: ByteArray, cid: Cid): Boolean = cidOf(bytes) == cid
}
diff --git a/modules/orgflow-content-store/src/commonTest/kotlin/jp/orgflow/contentstore/ContentStoreTest.kt b/modules/orgflow-content-store/src/commonTest/kotlin/jp/orgflow/contentstore/ContentStoreTest.kt
index d3e1fd3..429612b 100644
--- a/modules/orgflow-content-store/src/commonTest/kotlin/jp/orgflow/contentstore/ContentStoreTest.kt
+++ b/modules/orgflow-content-store/src/commonTest/kotlin/jp/orgflow/contentstore/ContentStoreTest.kt
@@ -2,9 +2,18 @@ package jp.orgflow.contentstore
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlinx.coroutines.test.runTest
class ContentStoreTest {
+ private fun pseudoRandom(size: Int, seed: Long = 42): ByteArray {
+ var s = seed
+ return ByteArray(size) { s = s * 6364136223846793005L + 1442695040888963407L; (s shr 33).toByte() }
+ }
+
@Test
fun cidMatchesSha256Vector() {
val cid = ContentAddresser.cidOf("hello".encodeToByteArray())
@@ -15,17 +24,100 @@ class ContentStoreTest {
}
@Test
- fun blockStoreDeduplicatesByContent() = kotlinx.coroutines.test.runTest {
+ fun cidParseRoundTripsToString() {
+ val raw = "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
+ assertEquals(raw, Cid.parse(raw).toString())
+ }
+
+ @Test
+ fun putThenGetRoundTrips() = runTest {
+ val store = MemoryBlockStore()
+ val bytes = "round-trip".encodeToByteArray()
+ val cid = store.put(bytes)
+ assertTrue(store.contains(cid))
+ assertTrue(bytes.contentEquals(store.get(cid)!!))
+ assertEquals(bytes.toList(), store.getBlock(cid)!!.bytes.toList())
+ }
+
+ @Test
+ fun sameBytesDedupeIntoOneBlock() = runTest {
+ val store = MemoryBlockStore()
+ val bytes = ByteArray(4096) { (it % 251).toByte() }
+ val first = store.put(bytes)
+ val second = store.put(bytes.copyOf())
+ assertEquals(first, second)
+ assertEquals(1, store.size())
+ }
+
+ @Test
+ fun differentBytesYieldDifferentCids() = runTest {
val store = MemoryBlockStore()
- val bytes = "same".encodeToByteArray()
- val b1 = ContentBlock(ContentAddresser.cidOf(bytes), bytes)
- val b2 = ContentBlock(ContentAddresser.cidOf(bytes), bytes)
+ val a = store.put("alpha".encodeToByteArray())
+ val b = store.put("beta".encodeToByteArray())
+ assertFalse(a == b)
+ assertEquals(2, store.size())
+ }
- store.put(b1)
- store.put(b2)
+ @Test
+ fun verifyAcceptsOnlyMatchingContent() = runTest {
+ val store = MemoryBlockStore()
+ val bytes = "verify me".encodeToByteArray()
+ val cid = store.put(bytes)
+ assertTrue(store.verify(bytes, cid))
+ assertFalse(store.verify("verify me!".encodeToByteArray(), cid))
+ assertFalse(ContentAddresser.verify(ByteArray(1), cid))
+ }
+ @Test
+ fun emptyBytesRoundTrip() = runTest {
+ val store = MemoryBlockStore()
+ val cid = store.put(ByteArray(0))
+ assertEquals(
+ "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ cid.toString()
+ )
+ assertEquals(0, store.get(cid)!!.size)
+ }
+
+ @Test
+ fun largeBinaryRoundTripsAndDedupes() = runTest {
+ val data = pseudoRandom(256 * 1024)
+ val store = MemoryBlockStore()
+ val cid = store.put(data)
+ assertTrue(data.contentEquals(store.get(cid)!!))
+ assertEquals(cid, store.put(data.copyOf()))
assertEquals(1, store.size())
- assertEquals(b1, store.get(b1.cid))
- assertNull(store.get(Cid.parse("sha256:00")))
+ assertEquals(256 * 1024L, store.stats().totalBytes)
+ }
+
+ @Test
+ fun statsAndClearReportStoreState() = runTest {
+ val store = MemoryBlockStore()
+ store.put("one".encodeToByteArray())
+ store.put("two!".encodeToByteArray())
+ assertEquals(2, store.stats().blockCount)
+ assertEquals(("one".length + "two!".length).toLong(), store.stats().totalBytes)
+ store.clear()
+ assertEquals(0, store.size())
+ assertEquals(0L, store.stats().totalBytes)
+ assertNull(store.get(ContentAddresser.cidOf("one".encodeToByteArray())))
+ }
+
+ @Test
+ fun putRejectsBlockWhoseCidDoesNotMatchContent() = runTest {
+ val store = MemoryBlockStore()
+ val mismatch = ContentBlock(Cid.parse("sha256:00"), "content".encodeToByteArray())
+ assertFailsWith<IllegalStateException> { store.put(mismatch) }
+ }
+
+ @Test
+ fun linkForRecordsCidForOrgAttachments() = runTest {
+ val store = MemoryBlockStore()
+ val bytes = "attachment bytes".encodeToByteArray()
+ val cid = store.store(bytes)
+ val link = store.linkFor(bytes)
+ assertEquals("attachment:$cid", link)
+ assertEquals(cid, Cid.parse(link.removePrefix("attachment:")))
+ assertEquals("attachment:$cid", store.linkFor(cid))
}
}
diff --git a/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/FileBlockStore.kt b/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/FileBlockStore.kt
new file mode 100644
index 0000000..bff916a
--- /dev/null
+++ b/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/FileBlockStore.kt
@@ -0,0 +1,57 @@
+package jp.orgflow.contentstore.jvm
+
+import jp.orgflow.contentstore.BlockStore
+import jp.orgflow.contentstore.BlockStoreStats
+import jp.orgflow.contentstore.Cid
+import jp.orgflow.contentstore.ContentAddresser
+import okio.FileSystem
+import okio.Path
+import okio.Path.Companion.toPath
+
+// ch.20a realization: directory-backed store, one block per file named by CID hash hex
+// with a 2-char fanout (git object layout): <root>/<first2>/<hashHex>.
+// 経路を信頼せず、読み出し時に内容をCID検証する (ch.14 principle applied to storage).
+
+class FileBlockStore(
+ private val root: String,
+ private val fs: FileSystem = FileSystem.SYSTEM,
+) : BlockStore {
+
+ private fun pathFor(cid: Cid): Path =
+ "$root/${cid.hashHex.take(2)}/${cid.hashHex}".toPath()
+
+ override suspend fun put(bytes: ByteArray): Cid {
+ val cid = ContentAddresser.cidOf(bytes)
+ val p = pathFor(cid)
+ fs.createDirectories(p.parent!!)
+ if (!fs.exists(p)) fs.write(p) { write(bytes) }
+ return cid
+ }
+
+ override suspend fun get(cid: Cid): ByteArray? {
+ val p = pathFor(cid)
+ if (!fs.exists(p)) return null
+ val bytes = fs.read(p) { readByteArray() }
+ check(ContentAddresser.cidOf(bytes) == cid) { "stored block failed verification: $cid" }
+ return bytes
+ }
+
+ override suspend fun contains(cid: Cid): Boolean = fs.exists(pathFor(cid))
+
+ override suspend fun clear() {
+ fs.deleteRecursively(root.toPath(), mustExist = false)
+ }
+
+ override fun size(): Int = blockFiles().size
+
+ override fun stats(): BlockStoreStats {
+ val files = blockFiles()
+ return BlockStoreStats(files.size, files.sumOf { fs.metadata(it).size ?: 0L })
+ }
+
+ private fun blockFiles(): List<Path> {
+ val rootPath = root.toPath()
+ if (!fs.exists(rootPath)) return emptyList()
+ return fs.listRecursively(rootPath).filter { fs.metadata(it).isRegularFile }.toList()
+ }
+}
diff --git a/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/NabuBlockStoreAdapter.kt b/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/NabuBlockStoreAdapter.kt
deleted file mode 100644
index dcec946..0000000
--- a/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/NabuBlockStoreAdapter.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package jp.orgflow.contentstore.jvm
-
-import io.ipfs.multihash.Multihash
-import jp.orgflow.contentstore.BlockStore
-import jp.orgflow.contentstore.ContentBlock
-import io.ipfs.cid.Cid as NabuCid
-import org.peergos.blockstore.RamBlockstore
-import java.util.concurrent.TimeUnit
-
-// ch.20a: adapter exposing the vendored nabu blockstore as our BlockStore.
-// Nabu is JVM-only, hence this adapter lives in jvmMain.
-
-class NabuBlockStoreAdapter(
- private val backing: RamBlockstore = RamBlockstore(),
-) : BlockStore {
-
- private fun nabuMultihash(hex: String): Multihash =
- Multihash(Multihash.Type.sha2_256, hexToBytes(hex))
-
- private fun nabuCid(hex: String): NabuCid =
- NabuCid.build(1, NabuCid.Codec.Raw, nabuMultihash(hex))
-
- override suspend fun put(block: ContentBlock) {
- val stored = backing.put(block.bytes, NabuCid.Codec.Raw).get(30, TimeUnit.SECONDS)
- check(stored.bareMultihash() == nabuMultihash(block.cid.hashHex)) { "cid mismatch after put" }
- }
-
- override suspend fun get(cid: jp.orgflow.contentstore.Cid): ContentBlock? {
- val bytes = backing.get(nabuCid(cid.hashHex)).get(30, TimeUnit.SECONDS).orElse(null)
- ?: return null
- return ContentBlock(cid, bytes)
- }
-}
-
-private fun hexToBytes(hex: String): ByteArray = ByteArray(hex.length / 2) { i ->
- ((Character.digit(hex[i * 2], 16) shl 4) + Character.digit(hex[i * 2 + 1], 16)).toByte()
-}
diff --git a/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/OkioBlockStore.kt b/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/OkioBlockStore.kt
deleted file mode 100644
index df0928d..0000000
--- a/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/OkioBlockStore.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package jp.orgflow.contentstore.jvm
-
-import jp.orgflow.contentstore.BlockStore
-import jp.orgflow.contentstore.Cid
-import jp.orgflow.contentstore.ContentAddresser
-import jp.orgflow.contentstore.ContentBlock
-import okio.FileSystem
-import okio.Path
-import okio.Path.Companion.toPath
-
-// ch.20a realization: directory-backed block store with on-read CID verification.
-// Layout: <root>/<first2>/<rest of hex>
-
-class OkioBlockStore(
- private val root: String,
- private val fs: FileSystem = FileSystem.SYSTEM,
-) : BlockStore {
-
- private fun pathFor(cid: Cid): Path =
- "$root/${cid.hashHex.take(2)}/${cid.hashHex}".toPath()
-
- override suspend fun put(block: ContentBlock) {
- val recomputed = ContentAddresser.cidOf(block.bytes)
- check(recomputed == block.cid) { "block content does not match cid" }
- val p = pathFor(block.cid)
- fs.createDirectories(p.parent!!)
- if (!fs.exists(p)) fs.write(p) { write(block.bytes) }
- }
-
- override suspend fun get(cid: Cid): ContentBlock? {
- val p = pathFor(cid)
- if (!fs.exists(p)) return null
- val bytes = fs.read(p) { readByteArray() }
- // 経路を信頼せず内容を検証する (ch.14 principle applied to storage)
- check(ContentAddresser.cidOf(bytes) == cid) { "stored block failed verification: $cid" }
- return ContentBlock(cid, bytes)
- }
-}
diff --git a/modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/FileBlockStoreTest.kt b/modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/FileBlockStoreTest.kt
new file mode 100644
index 0000000..277eac3
--- /dev/null
+++ b/modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/FileBlockStoreTest.kt
@@ -0,0 +1,55 @@
+package jp.orgflow.contentstore
+
+import jp.orgflow.contentstore.jvm.FileBlockStore
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class FileBlockStoreTest {
+ @Test
+ fun blocksPersistAcrossStoreInstances() = runTest {
+ val root = kotlin.io.path.createTempDirectory("content-store").toFile().absolutePath
+ val bytes = "persisted attachment".encodeToByteArray()
+ val cid = FileBlockStore(root).put(bytes)
+
+ val reopened = FileBlockStore(root)
+ assertTrue(reopened.contains(cid))
+ assertTrue(bytes.contentEquals(reopened.get(cid)!!))
+ assertEquals(1, reopened.size())
+ }
+
+ @Test
+ fun dedupesOnDiskAndReportsStats() = runTest {
+ val root = kotlin.io.path.createTempDirectory("content-store").toFile().absolutePath
+ val store = FileBlockStore(root)
+ val a = store.put("duplicate".encodeToByteArray())
+ val b = store.put("duplicate".encodeToByteArray())
+ val c = store.put("other".encodeToByteArray())
+ assertEquals(a, b)
+ assertFalse(a == c)
+ assertEquals(2, store.size())
+ assertEquals(2, store.stats().blockCount)
+ assertEquals(("duplicate".length + "other".length).toLong(), store.stats().totalBytes)
+ }
+
+ @Test
+ fun getReturnsNullForUnknownCid() = runTest {
+ val root = kotlin.io.path.createTempDirectory("content-store").toFile().absolutePath
+ val store = FileBlockStore(root)
+ assertNull(store.get(ContentAddresser.cidOf("missing".encodeToByteArray())))
+ assertFalse(store.contains(ContentAddresser.cidOf("missing".encodeToByteArray())))
+ }
+
+ @Test
+ fun clearRemovesAllBlocks() = runTest {
+ val root = kotlin.io.path.createTempDirectory("content-store").toFile().absolutePath
+ val store = FileBlockStore(root)
+ store.put("gone".encodeToByteArray())
+ store.clear()
+ assertEquals(0, store.size())
+ assertNull(store.get(ContentAddresser.cidOf("gone".encodeToByteArray())))
+ }
+}
diff --git a/modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/NabuAdapterTest.kt b/modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/NabuAdapterTest.kt
deleted file mode 100644
index 957612b..0000000
--- a/modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/NabuAdapterTest.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package jp.orgflow.contentstore
-
-import jp.orgflow.contentstore.jvm.NabuBlockStoreAdapter
-import kotlinx.coroutines.test.runTest
-import kotlin.test.Test
-import kotlin.test.assertEquals
-
-class NabuAdapterTest {
- @Test
- fun putGetRoundTripThroughNabu() = runTest {
- val adapter = NabuBlockStoreAdapter()
- val bytes = "nabu vendored core works".encodeToByteArray()
- val cid = ContentAddresser.cidOf(bytes)
- val block = ContentBlock(cid, bytes)
-
- adapter.put(block)
- assertEquals(block, adapter.get(cid))
- }
-}
diff --git a/modules/orgflow-content-store/src/wasmJsMain/kotlin/jp/orgflow/contentstore/wasm/OpfsBlockStore.kt b/modules/orgflow-content-store/src/wasmJsMain/kotlin/jp/orgflow/contentstore/wasm/OpfsBlockStore.kt
index 19ecb5a..6cd935b 100644
--- a/modules/orgflow-content-store/src/wasmJsMain/kotlin/jp/orgflow/contentstore/wasm/OpfsBlockStore.kt
+++ b/modules/orgflow-content-store/src/wasmJsMain/kotlin/jp/orgflow/contentstore/wasm/OpfsBlockStore.kt
@@ -1,16 +1,209 @@
package jp.orgflow.contentstore.wasm
import jp.orgflow.contentstore.BlockStore
+import jp.orgflow.contentstore.BlockStoreStats
import jp.orgflow.contentstore.Cid
-import jp.orgflow.contentstore.ContentBlock
+import jp.orgflow.contentstore.ContentAddresser
import jp.orgflow.contentstore.MemoryBlockStore
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlinx.coroutines.suspendCancellableCoroutine
-// ch.19 stub policy (submission scope): web persistence is in-memory; full OPFS adapter
-// is out of scope. Interface stays identical so the real OPFS store can drop in later.
+// ch.20a wasm realization: blocks persist in OPFS with the same git object layout as the
+// JVM store (<root>/<first2>/<hashHex>) and are CID-verified on read.
+// navigator.storage.getDirectory() が使えない環境 (insecure context / node等) では
+// MemoryBlockStore に縮退する. size()/stats() は初回使用時に OPFS から構築する index に基づく.
-class OpfsBlockStore : BlockStore {
- private val delegate = MemoryBlockStore()
+internal fun bytesToBinaryString(bytes: ByteArray): String {
+ val out = StringBuilder(bytes.size)
+ for (b in bytes) out.append((b.toInt() and 0xff).toChar())
+ return out.toString()
+}
+
+internal fun binaryStringToBytes(binary: String): ByteArray =
+ ByteArray(binary.length) { binary[it].code.toByte() }
+
+class OpfsBlockStore(private val root: String = DEFAULT_ROOT) : BlockStore {
+
+ private val memory = MemoryBlockStore()
+ private val index = HashMap<String, Int>()
+ private var probePromise: JsAny? = null
+ private var usingOpfs = false
+ private var indexed = false
+
+ override suspend fun put(bytes: ByteArray): Cid {
+ val cid = ContentAddresser.cidOf(bytes)
+ val dir = backend() ?: return memory.put(bytes)
+ val fan = awaitAny(getDirectoryHandlePromise(dir, cid.hashHex.take(2), true))
+ ?: throw IllegalStateException("opfs: cannot open fanout dir ${cid.hashHex.take(2)} for $cid")
+ if (awaitAny(getFileHandlePromise(fan, cid.hashHex, false)) != null) return cid
+ val handle = awaitAny(getFileHandlePromise(fan, cid.hashHex, true))
+ ?: throw IllegalStateException("opfs: cannot create block file for $cid")
+ awaitUnit(writeBlockPromise(handle, bytesToBinaryString(bytes)))
+ index[cid.hashHex] = bytes.size
+ return cid
+ }
+
+ override suspend fun get(cid: Cid): ByteArray? {
+ val dir = backend() ?: return memory.get(cid)
+ val fan = awaitAny(getDirectoryHandlePromise(dir, cid.hashHex.take(2), false)) ?: return null
+ val handle = awaitAny(getFileHandlePromise(fan, cid.hashHex, false)) ?: return null
+ val file = awaitAny(filePromise(handle)) ?: return null
+ val binary = awaitString(fileBinaryStringPromise(file)) ?: return null
+ val bytes = binaryStringToBytes(binary)
+ check(ContentAddresser.cidOf(bytes) == cid) { "stored block failed verification: $cid" }
+ return bytes
+ }
+
+ override suspend fun contains(cid: Cid): Boolean {
+ val dir = backend() ?: return memory.contains(cid)
+ val fan = awaitAny(getDirectoryHandlePromise(dir, cid.hashHex.take(2), false)) ?: return false
+ return awaitAny(getFileHandlePromise(fan, cid.hashHex, false)) != null
+ }
+
+ override suspend fun clear() {
+ val dir = backend()
+ if (dir == null) {
+ memory.clear()
+ return
+ }
+ for (name in awaitStringList(listEntriesPromise(dir))) {
+ awaitAny(removeEntryPromise(dir, name))
+ }
+ index.clear()
+ }
+
+ override fun size(): Int = if (usingOpfs) index.size else memory.size()
+
+ override fun stats(): BlockStoreStats =
+ if (usingOpfs) BlockStoreStats(index.size, index.values.sumOf { it.toLong() }) else memory.stats()
- override suspend fun put(block: ContentBlock) = delegate.put(block)
- override suspend fun get(cid: Cid): ContentBlock? = delegate.get(cid)
+ private suspend fun backend(): JsAny? {
+ val promise = probePromise ?: probe().also { probePromise = it }
+ val dir = awaitAny(promise)
+ usingOpfs = dir != null
+ if (dir != null && !indexed) {
+ indexed = true
+ loadIndex(dir)
+ }
+ return dir
+ }
+
+ private fun probe(): JsAny = if (opfsSupported()) openRootPromise(root) else resolvedNullPromise()
+
+ private suspend fun loadIndex(dir: JsAny) {
+ index.clear()
+ val entries = awaitAny(collectSizesPromise(dir)) ?: return
+ repeat(jsArrayLength(entries)) { i ->
+ val item = jsArrayItemAt(entries, i) ?: return@repeat
+ val hashHex = jsItemName(item) ?: return@repeat
+ index[hashHex] = jsItemSize(item)
+ }
+ }
+
+ private companion object {
+ const val DEFAULT_ROOT = "orgflow-content"
+ }
+}
+
+private suspend fun awaitAny(promise: JsAny): JsAny? =
+ suspendCancellableCoroutine { cont ->
+ bridgeThen(promise, { value -> cont.resume(value) }, { cont.resume(null) })
+ }
+
+private suspend fun awaitUnit(promise: JsAny) {
+ suspendCancellableCoroutine<Unit> { cont ->
+ bridgeThen(promise, { cont.resume(Unit) }, { error -> cont.resumeWithException(opfsFailure(error)) })
+ }
}
+
+private suspend fun awaitString(promise: JsAny): String? =
+ suspendCancellableCoroutine { cont ->
+ bridgeThen(promise, { value -> cont.resume(jsValueToString(value)) }, { cont.resume(null) })
+ }
+
+private suspend fun awaitStringList(promise: JsAny): List<String> {
+ val array = awaitAny(promise) ?: return emptyList()
+ val out = ArrayList<String>(jsArrayLength(array))
+ repeat(jsArrayLength(array)) { i -> jsArrayStringAt(array, i)?.let { out += it } }
+ return out
+}
+
+private fun opfsFailure(error: JsAny): IllegalStateException =
+ IllegalStateException("opfs failure: ${jsErrorMessage(error)}")
+
+private fun opfsSupported(): Boolean = js(
+ "(() => { try { const s = globalThis.navigator && globalThis.navigator.storage; const fh = globalThis.FileSystemFileHandle; const dh = globalThis.FileSystemDirectoryHandle; return !!(s && typeof s.getDirectory === 'function' && fh && fh.prototype && typeof fh.prototype.createWritable === 'function' && dh && dh.prototype && typeof dh.prototype.getDirectoryHandle === 'function' && typeof dh.prototype.getFileHandle === 'function'); } catch (e) { return false; } })()",
+)
+
+private fun openRootPromise(name: String): JsAny = js(
+ "(() => { return globalThis.navigator.storage.getDirectory().then((base) => base.getDirectoryHandle(name, { create: true })); })()",
+)
+
+private fun getDirectoryHandlePromise(parent: JsAny, name: String, create: Boolean): JsAny = js(
+ "(() => { return parent.getDirectoryHandle(name, { create: create }).then((handle) => handle, () => null); })()",
+)
+
+private fun getFileHandlePromise(parent: JsAny, name: String, create: Boolean): JsAny = js(
+ "(() => { return parent.getFileHandle(name, { create: create }).then((handle) => handle, () => null); })()",
+)
+
+private fun filePromise(handle: JsAny): JsAny = js(
+ "(() => { return handle.getFile(); })()",
+)
+
+private fun fileBinaryStringPromise(file: JsAny): JsAny = js(
+ "(() => { return file.arrayBuffer().then((buffer) => { const u8 = new Uint8Array(buffer); let out = ''; const chunk = 0x8000; for (let i = 0; i < u8.length; i += chunk) { out += String.fromCharCode.apply(null, u8.subarray(i, i + chunk)); } return out; }); })()",
+)
+
+private fun writeBlockPromise(handle: JsAny, binary: String): JsAny = js(
+ "(() => { const u8 = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { u8[i] = binary.charCodeAt(i); } return handle.createWritable().then((writable) => writable.write(u8).then(() => writable.close())); })()",
+)
+
+private fun listEntriesPromise(parent: JsAny): JsAny = js(
+ "(() => { try { if (!parent || typeof parent.entries !== 'function') { return Promise.resolve([]); } return new Promise((resolve, reject) => { const names = []; const it = parent.entries(); const step = () => { it.next().then((r) => { if (r.done) { resolve(names); } else { names.push(String(r.value[0])); step(); } }, (e) => reject(e)); }; step(); }); } catch (e) { return Promise.resolve([]); } })()",
+)
+
+private fun collectSizesPromise(parent: JsAny): JsAny = js(
+ "(() => { try { if (!parent || typeof parent.entries !== 'function') { return Promise.resolve([]); } return new Promise((resolve, reject) => { const out = []; const outer = parent.entries(); const stepOuter = () => { outer.next().then((r) => { if (r.done) { resolve(out); return; } const fan = r.value[1]; if (!fan || fan.kind !== 'directory') { stepOuter(); return; } const inner = fan.entries(); const stepInner = () => { inner.next().then((r2) => { if (r2.done) { stepOuter(); return; } const fh = r2.value[1]; if (!fh || fh.kind !== 'file') { stepInner(); return; } fh.getFile().then((f) => { out.push([r2.value[0], f.size | 0]); stepInner(); }, () => stepInner()); }, (e) => reject(e)); }; stepInner(); }, (e) => reject(e)); }; stepOuter(); }); } catch (e) { return Promise.resolve([]); } })()",
+)
+
+private fun removeEntryPromise(parent: JsAny, name: String): JsAny = js(
+ "(() => { try { return parent.removeEntry(name, { recursive: true }).then(() => true, () => false); } catch (e) { return Promise.resolve(false); } })()",
+)
+
+private fun bridgeThen(promise: JsAny, onOk: (JsAny?) -> Unit, onErr: (JsAny) -> Unit): Unit = js(
+ "(() => { try { promise.then(onOk, onErr); } catch (e) { onErr(e); } })()",
+)
+
+private fun resolvedNullPromise(): JsAny = js(
+ "(() => { return Promise.resolve(null); })()",
+)
+
+private fun jsArrayLength(array: JsAny): Int = js(
+ "(() => { return array.length | 0; })()",
+)
+
+private fun jsArrayItemAt(array: JsAny, index: Int): JsAny? = js(
+ "(() => { const v = array[index]; return (v === undefined || v === null) ? null : v; })()",
+)
+
+private fun jsArrayStringAt(array: JsAny, index: Int): String? = js(
+ "(() => { const v = array[index]; return (typeof v === 'string') ? v : null; })()",
+)
+
+private fun jsItemName(item: JsAny): String? = js(
+ "(() => { return (item && item.length > 0 && typeof item[0] === 'string') ? item[0] : null; })()",
+)
+
+private fun jsItemSize(item: JsAny): Int = js(
+ "(() => { return (item && item.length > 1) ? (item[1] | 0) : 0; })()",
+)
+
+private fun jsValueToString(value: JsAny?): String? = js(
+ "(() => { return (value === null || value === undefined) ? null : String(value); })()",
+)
+
+private fun jsErrorMessage(error: JsAny): String = js(
+ "(() => { try { return (error && typeof error.message === 'string') ? error.message : String(error); } catch (e) { return 'unknown'; } })()",
+)
diff --git a/modules/orgflow-content-store/src/wasmJsTest/kotlin/jp/orgflow/contentstore/OpfsBlockStoreTest.kt b/modules/orgflow-content-store/src/wasmJsTest/kotlin/jp/orgflow/contentstore/OpfsBlockStoreTest.kt
new file mode 100644
index 0000000..43b6a1a
--- /dev/null
+++ b/modules/orgflow-content-store/src/wasmJsTest/kotlin/jp/orgflow/contentstore/OpfsBlockStoreTest.kt
@@ -0,0 +1,50 @@
+package jp.orgflow.contentstore
+
+import jp.orgflow.contentstore.wasm.OpfsBlockStore
+import jp.orgflow.contentstore.wasm.binaryStringToBytes
+import jp.orgflow.contentstore.wasm.bytesToBinaryString
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+// wasmJsNodeTest は nodejs で走るため OPFS が無く、ここでは MemoryBlockStore 縮退経路を検証する.
+// アサーションは backend 非依存なので browser + OPFS でも同じ結果になる.
+
+class OpfsBlockStoreTest {
+ @Test
+ fun storeRoundTripsAndDedupes() = runTest {
+ val store = OpfsBlockStore()
+ val bytes = "wasm block".encodeToByteArray()
+ val cid = store.put(bytes)
+ assertTrue(store.contains(cid))
+ assertTrue(bytes.contentEquals(store.get(cid)!!))
+ assertEquals(cid, store.put(bytes.copyOf()))
+ assertEquals(1, store.size())
+ assertEquals(1, store.stats().blockCount)
+ assertEquals(bytes.size.toLong(), store.stats().totalBytes)
+ assertNull(store.get(ContentAddresser.cidOf("missing".encodeToByteArray())))
+ assertFalse(store.contains(ContentAddresser.cidOf("missing".encodeToByteArray())))
+ }
+
+ @Test
+ fun clearRemovesAllBlocks() = runTest {
+ val store = OpfsBlockStore()
+ store.put("one".encodeToByteArray())
+ store.put("two!".encodeToByteArray())
+ store.clear()
+ assertEquals(0, store.size())
+ assertEquals(0L, store.stats().totalBytes)
+ assertNull(store.get(ContentAddresser.cidOf("one".encodeToByteArray())))
+ }
+
+ @Test
+ fun binaryStringCodecIsLosslessForAllByteValues() {
+ val bytes = ByteArray(256) { it.toByte() } + "orgflow".encodeToByteArray()
+ assertTrue(bytes.contentEquals(binaryStringToBytes(bytesToBinaryString(bytes))))
+ assertEquals(0, binaryStringToBytes(bytesToBinaryString(ByteArray(0))).size)
+ assertEquals(bytes.size, bytesToBinaryString(bytes).length)
+ }
+}
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 b84abaa..36299a8 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
@@ -1,59 +1,216 @@
package jp.orgflow.ui
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.RoundedCornerShape
+import androidx.compose.material.Button
+import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
+import androidx.compose.material.Surface
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.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import jp.orgflow.ui.breadcrumb.BreadcrumbBar
+import jp.orgflow.ui.model.SetFilterState
+import jp.orgflow.ui.pie.PieMenu
import jp.orgflow.ui.runtime.OrgFlowRuntime
+import jp.orgflow.ui.runtime.provideRuntime
import jp.orgflow.ui.store.OrgFlowAppStore
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
@Composable
fun OrgFlowApp(
runtime: OrgFlowRuntime = OrgFlowRuntime.UNAVAILABLE,
navigation: OrgFlowNavigation = remember { OrgFlowNavigation() },
store: OrgFlowAppStore = remember { OrgFlowAppStore() },
+ filter: SetFilterState = remember { SetFilterState() },
) {
+ val effectiveRuntime = if (runtime === OrgFlowRuntime.UNAVAILABLE) remember { provideRuntime() } else runtime
+ LaunchedEffect(effectiveRuntime) {
+ if (runtime === OrgFlowRuntime.UNAVAILABLE) effectiveRuntime.distribution.start()
+ effectiveRuntime.config.config.collect { snapshot ->
+ store.updateConnectionConfig(snapshot.signalingEndpoint, snapshot.iceServers)
+ }
+ }
+ var showRoomDialog by remember { mutableStateOf(false) }
OrgFlowTheme {
Scaffold { padding ->
Row(modifier = Modifier.fillMaxSize().padding(padding)) {
- Column(modifier = Modifier.fillMaxHeight().width(160.dp)) {
- Text("kukuri", fontSize = 18.sp, modifier = Modifier.padding(12.dp))
- OrgFlowRoute.all.forEach { route ->
- Text(
- text = route.id,
- fontSize = 13.sp,
- modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
- .clickable { navigation.navigate(route) },
- color = if (route == navigation.current) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface,
- )
- }
- }
- Column(modifier = Modifier.fillMaxSize()) {
+ LeftPanel(
+ navigation = navigation,
+ filter = filter,
+ onAddRoom = { showRoomDialog = true },
+ modifier = Modifier.fillMaxHeight().width(140.dp).padding(8.dp),
+ )
+ Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
+ BreadcrumbBar(filter)
when (navigation.current) {
OrgFlowRoute.Onboarding -> jp.orgflow.ui.onboarding.OnboardingScreen()
OrgFlowRoute.Home -> jp.orgflow.ui.home.HomeScreen(store)
+ OrgFlowRoute.Create -> jp.orgflow.ui.capture.CaptureScreen(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.Live -> jp.orgflow.ui.live.LiveScreen(effectiveRuntime.distribution, filter)
OrgFlowRoute.Experiment -> jp.orgflow.ui.experiment.ExperimentTableScreen()
OrgFlowRoute.Presentation -> jp.orgflow.ui.presentation.PresentationBuilderScreen()
- OrgFlowRoute.Distribution -> jp.orgflow.ui.distribution.DistributionScreen(runtime.distribution)
- OrgFlowRoute.Workspace -> jp.orgflow.ui.workspace.WorkspaceScreen()
- OrgFlowRoute.Config -> jp.orgflow.ui.config.ConfigScreen(runtime.config)
+ OrgFlowRoute.Config -> jp.orgflow.ui.config.ConfigScreen(effectiveRuntime.config)
}
}
+ RightNav(modifier = Modifier.fillMaxHeight().width(140.dp), navigation = navigation)
+ }
+ }
+ }
+ if (showRoomDialog) {
+ RoomEntryDialog(onDismiss = { showRoomDialog = false })
+ }
+}
+
+@Composable
+private fun LeftPanel(
+ navigation: OrgFlowNavigation,
+ filter: SetFilterState,
+ onAddRoom: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
+ PieMenu(filter = filter, modifier = Modifier.fillMaxWidth())
+ Spacer(modifier = Modifier.weight(1f))
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
+ ConfigIconButton(navigation = navigation)
+ AddRoomButton(onClick = onAddRoom)
+ }
+ }
+}
+
+@Composable
+private fun ConfigIconButton(navigation: OrgFlowNavigation) {
+ IconButton(onClick = { navigation.navigate(OrgFlowRoute.Config) }) {
+ val tint = MaterialTheme.colors.onSurface
+ Canvas(modifier = Modifier.size(20.dp)) {
+ val w = this.size.width
+ val h = this.size.height
+ val cx = w / 2f
+ val cy = h / 2f
+ drawCircle(color = tint, radius = w * 0.26f, style = Stroke(width = 1.5.dp.toPx()))
+ drawCircle(color = tint, radius = w * 0.1f, style = Stroke(width = 1.5.dp.toPx()))
+ repeat(8) { i ->
+ val angle = (i * 45f) * PI.toFloat() / 180f
+ val cosA = cos(angle)
+ val sinA = sin(angle)
+ drawLine(
+ color = tint,
+ start = Offset(cx + cosA * w * 0.3f, cy + sinA * h * 0.3f),
+ end = Offset(cx + cosA * w * 0.42f, cy + sinA * h * 0.42f),
+ strokeWidth = 1.5.dp.toPx(),
+ cap = StrokeCap.Round,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun AddRoomButton(onClick: () -> Unit) {
+ IconButton(onClick = onClick) {
+ val tint = MaterialTheme.colors.onSurface
+ Canvas(modifier = Modifier.size(20.dp)) {
+ val w = this.size.width
+ val h = this.size.height
+ drawLine(tint, Offset(w * 0.5f, h * 0.15f), Offset(w * 0.5f, h * 0.85f), strokeWidth = 2.dp.toPx(), cap = StrokeCap.Round)
+ drawLine(tint, Offset(w * 0.15f, h * 0.5f), Offset(w * 0.85f, h * 0.5f), strokeWidth = 2.dp.toPx(), cap = StrokeCap.Round)
+ }
+ }
+}
+
+@Composable
+private fun RightNav(navigation: OrgFlowNavigation, modifier: Modifier = Modifier) {
+ Column(modifier = modifier.padding(vertical = 12.dp)) {
+ Text("kukuri", fontSize = 18.sp, modifier = Modifier.padding(horizontal = 12.dp))
+ Spacer(modifier = Modifier.height(8.dp))
+ OrgFlowRoute.primary.forEach { route -> NavItem(navigation, route, compact = false) }
+ Spacer(modifier = Modifier.height(16.dp))
+ OrgFlowRoute.secondary.forEach { route -> NavItem(navigation, route, compact = true) }
+ }
+}
+
+@Composable
+private fun NavItem(navigation: OrgFlowNavigation, route: OrgFlowRoute, compact: Boolean) {
+ val selected = navigation.current == route
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ color = if (selected) MaterialTheme.colors.primary.copy(alpha = 0.16f) else Color.Transparent,
+ )
+ .clickable { navigation.navigate(route) }
+ .padding(horizontal = 12.dp, vertical = if (compact) 4.dp else 8.dp),
+ ) {
+ Text(
+ text = route.label,
+ fontSize = if (compact) 11.sp else 14.sp,
+ color = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface,
+ )
+ }
+}
+
+@Composable
+private fun RoomEntryDialog(onDismiss: () -> Unit) {
+ var name by remember { mutableStateOf("") }
+ var payload by remember { mutableStateOf("") }
+ Dialog(onDismissRequest = onDismiss) {
+ Surface(shape = RoundedCornerShape(12.dp), elevation = 8.dp) {
+ Column(modifier = Modifier.padding(16.dp).width(320.dp)) {
+ Text("ルーム作成 / QR参加", fontSize = 15.sp)
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ label = { Text("room name") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+ )
+ OutlinedTextField(
+ value = payload,
+ onValueChange = { payload = it },
+ label = { Text("FSMP1 payload") },
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ TextButton(onClick = onDismiss) { Text("cancel") }
+ Button(onClick = onDismiss) { Text("confirm") }
+ }
}
}
}
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 076fe54..cb706ab 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
@@ -1,21 +1,19 @@
package jp.orgflow.ui
-sealed class OrgFlowRoute(val id: String) {
- data object Onboarding : OrgFlowRoute("onboarding")
- data object Home : OrgFlowRoute("home")
- 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")
- data object Workspace : OrgFlowRoute("workspace")
- data object Config : OrgFlowRoute("config")
+sealed class OrgFlowRoute(val id: String, val label: String = id) {
+ data object Onboarding : OrgFlowRoute("onboarding", "Onboarding")
+ data object Home : OrgFlowRoute("home", "Home")
+ data object Create : OrgFlowRoute("create", "Create")
+ data object Notes : OrgFlowRoute("note", "Note")
+ data object Calendar : OrgFlowRoute("calendar", "Calendar")
+ data object Live : OrgFlowRoute("live", "Live")
+ data object Experiment : OrgFlowRoute("experiment", "Experiment")
+ data object Presentation : OrgFlowRoute("presentation", "Presentation")
+ data object Config : OrgFlowRoute("config", "Config")
companion object {
- val all: List<OrgFlowRoute> by lazy {
- listOf(Home, Notes, Capture, Agenda, Calendar, Experiment, Presentation, Distribution, Workspace, Config)
- }
+ val primary: List<OrgFlowRoute> = listOf(Home, Create, Notes, Calendar, Live)
+ val secondary: List<OrgFlowRoute> = listOf(Experiment, Presentation)
+ val all: List<OrgFlowRoute> = primary + secondary
}
}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/breadcrumb/BreadcrumbBar.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/breadcrumb/BreadcrumbBar.kt
new file mode 100644
index 0000000..0cbcdb8
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/breadcrumb/BreadcrumbBar.kt
@@ -0,0 +1,48 @@
+package jp.orgflow.ui.breadcrumb
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import jp.orgflow.ui.model.SetFilterState
+
+@Composable
+fun BreadcrumbBar(filter: SetFilterState, modifier: Modifier = Modifier) {
+ val segments = filter.segments()
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colors.surface)
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ segments.forEachIndexed { index, segment ->
+ if (index > 0) {
+ Text(
+ text = " > ",
+ fontSize = 12.sp,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f),
+ )
+ }
+ val last = index == segments.lastIndex
+ Text(
+ text = segment.label,
+ fontSize = 13.sp,
+ fontWeight = if (last) FontWeight.Bold else FontWeight.Normal,
+ color = if (last) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface,
+ modifier = Modifier
+ .clickable { filter.select(segment.filter) }
+ .padding(horizontal = 2.dp),
+ )
+ }
+ }
+}
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
index 5120b89..f3a72b9 100644
--- 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
@@ -30,6 +30,7 @@ data class CalendarEvent(
val color: EventColor = colorOf(source),
val roomName: String? = null,
val time: LocalTime? = null,
+ val linkedNoteId: String? = null,
) {
fun startsAt(): LocalDateTime = LocalDateTime(date, time ?: defaultStartTime)
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
index 6238908..76f4546 100644
--- 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
@@ -12,6 +12,8 @@ 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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.AlertDialog
@@ -32,6 +34,7 @@ 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.component.EmptyContent
import jp.orgflow.ui.store.OrgFlowAppStore
import kotlin.math.abs
import kotlinx.datetime.LocalDate
@@ -42,7 +45,6 @@ fun CalendarScreen(
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) {
@@ -56,13 +58,42 @@ fun CalendarScreen(
TextButton(onClick = { showSettingsDialog = true }) { Text("Settings") }
}
Row(verticalAlignment = Alignment.CenterVertically) {
+ CalendarTab.entries.forEach { tab ->
+ val selected = viewModel.calendarTab == tab
+ Text(
+ text = if (tab == CalendarTab.Tasks && viewModel.openTaskCount() > 0) {
+ "${tab.label} (${viewModel.openTaskCount()})"
+ } else {
+ tab.label
+ },
+ fontSize = 14.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 = 12.dp).clickable { viewModel.calendarTab = tab },
+ )
+ }
+ }
+ when (viewModel.calendarTab) {
+ CalendarTab.Calendar -> CalendarTabContent(viewModel)
+ CalendarTab.Tasks -> TaskList(viewModel)
+ }
+ }
+
+ if (showSettingsDialog) {
+ NotificationSettingsDialog(viewModel, onDismiss = { showSettingsDialog = false })
+ }
+}
+
+@Composable
+private fun CalendarTabContent(viewModel: CalendarViewModel) {
+ Column {
+ 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 ->
@@ -83,16 +114,27 @@ fun CalendarScreen(
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 TaskList(viewModel: CalendarViewModel) {
+ if (viewModel.tasks.isEmpty()) {
+ EmptyContent("No tasks")
+ } else {
+ LazyColumn {
+ items(viewModel.tasks, key = { it.id }) { item ->
+ Row(
+ modifier = Modifier.padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(checked = item.done, onCheckedChange = { viewModel.toggleTaskDone(item.id) })
+ Column {
+ Text(item.title, fontSize = 14.sp)
+ Text(item.scheduledAt.toString(), fontSize = 11.sp)
+ }
+ }
+ }
+ }
}
}
@@ -192,7 +234,7 @@ private fun DayEventList(viewModel: CalendarViewModel) {
Text("Events on ${viewModel.selectedDate}", fontSize = 14.sp)
if (events.isEmpty()) {
Text(
- "No events",
+ "No events — create them in the Create screen",
fontSize = 12.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(top = 4.dp),
@@ -222,47 +264,6 @@ private fun DayEventList(viewModel: CalendarViewModel) {
}
@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()) }
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
index 92b51d7..058825e 100644
--- 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
@@ -24,6 +24,11 @@ enum class CalendarViewMode(val label: String) {
Week("Week"),
}
+enum class CalendarTab(val label: String) {
+ Calendar("Calendar"),
+ Tasks("Tasks"),
+}
+
class CalendarViewModel(
private val store: OrgFlowAppStore = OrgFlowAppStore(),
private val roomEventProvider: RoomEventProvider = RoomEventProvider.Empty,
@@ -33,6 +38,7 @@ class CalendarViewModel(
val today: LocalDate = now().date
var viewMode: CalendarViewMode by mutableStateOf(CalendarViewMode.Month)
+ var calendarTab: CalendarTab by mutableStateOf(CalendarTab.Calendar)
var filter: CalendarFilter by mutableStateOf(CalendarFilter.All)
var month: LocalDate by mutableStateOf(today.withDay(1))
var selectedDate: LocalDate by mutableStateOf(today)
@@ -51,6 +57,20 @@ class CalendarViewModel(
val visibleEvents: List<CalendarEvent>
get() = events.filter { filter.visible(it) }
+ val tasks: List<AgendaItem>
+ get() = store.agendaItems
+
+ fun tasksOn(date: LocalDate): List<AgendaItem> =
+ store.agendaItems.filter { it.scheduledAt.date == date }.sortedBy { it.scheduledAt.toString() }
+
+ fun overdueTasks(): List<AgendaItem> = store.agendaItems.filter { !it.done && it.scheduledAt < now() }
+
+ fun openTaskCount(): Int = store.agendaItems.count { !it.done }
+
+ fun toggleTaskDone(id: String) {
+ store.toggleAgendaDone(id)
+ }
+
fun eventsOn(date: LocalDate): List<CalendarEvent> = visibleEvents.filter { it.date == date }
fun personalEvents(): List<CalendarEvent> = buildList {
@@ -141,7 +161,7 @@ class CalendarViewModel(
store.updateNotificationSettings(notificationSettings.copy(targets = updated))
}
- private fun agendaEvent(item: AgendaItem): CalendarEvent = CalendarEvent(
+ fun agendaEvent(item: AgendaItem): CalendarEvent = CalendarEvent(
id = "agenda-${item.id}",
title = item.title,
date = item.scheduledAt.date,
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 d7b4f05..13f4c48 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
@@ -10,6 +10,7 @@ import androidx.compose.material.FilterChip
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -22,7 +23,7 @@ fun CaptureScreen(
viewModel: CaptureViewModel = remember(store) { CaptureViewModel(store) },
) {
Column(modifier = Modifier.fillMaxSize().padding(12.dp)) {
- Text("Capture", fontSize = 17.sp)
+ Text("Create", fontSize = 17.sp)
Row(modifier = Modifier.padding(vertical = 6.dp)) {
QuickCaptureShortcuts.options.forEach { option ->
FilterChip(
@@ -38,6 +39,18 @@ fun CaptureScreen(
state = viewModel.form,
onFieldChange = { field, value -> viewModel.updateField(field, value) },
)
+ Row(modifier = Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text("Post to:", fontSize = 13.sp, modifier = Modifier.padding(end = 6.dp))
+ PostTarget.entries.forEach { target ->
+ FilterChip(
+ selected = viewModel.postTarget == target,
+ onClick = { viewModel.selectPostTarget(target) },
+ modifier = Modifier.padding(end = 6.dp),
+ ) {
+ Text(target.label, fontSize = 12.sp)
+ }
+ }
+ }
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
onClick = { viewModel.submit() },
@@ -48,6 +61,10 @@ fun CaptureScreen(
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) }
+ Row(modifier = Modifier.padding(top = 4.dp)) {
+ if (result.noteId != null) Text("Note: ${result.noteId} ", fontSize = 11.sp)
+ if (result.eventId != null) Text("Calendar: ${result.eventId}", 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 329720b..7a9a123 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
@@ -1,21 +1,43 @@
+@file:OptIn(kotlin.time.ExperimentalTime::class)
+
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.calendar.WhenTextParser
import jp.orgflow.ui.store.OrgFlowAppStore
+import kotlin.time.Clock
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+
+enum class PostTarget(val label: String) {
+ Note("Note"),
+ Calendar("Calendar"),
+ Both("Both"),
+}
data class CaptureUiResult(
val orgSnippet: String,
val templateType: String,
val activityId: ActivityId? = null,
+ val target: PostTarget? = null,
+ val noteId: String? = null,
+ val eventId: String? = null,
)
-class CaptureViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()) {
+class CaptureViewModel(
+ private val store: OrgFlowAppStore = OrgFlowAppStore(),
+ private val today: () -> LocalDate = { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date },
+) {
var form: CaptureFormState by mutableStateOf(CaptureFormState(templateType = QuickCaptureShortcuts.defaultType()))
private set
+ var postTarget: PostTarget by mutableStateOf(PostTarget.Note)
+ private set
+
var lastResult: CaptureUiResult? by mutableStateOf(null)
private set
@@ -23,6 +45,10 @@ class CaptureViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()) {
form = form.copy(templateType = type)
}
+ fun selectPostTarget(target: PostTarget) {
+ postTarget = target
+ }
+
fun updateField(field: FiveW1HField, value: String) {
form = form.copy(fields = form.fields + (field to value))
}
@@ -31,17 +57,36 @@ class CaptureViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()) {
form = form.copy(attachments = form.attachments + cardId)
}
- fun submit(): Boolean {
+ fun submit(): Boolean = submit(postTarget)
+
+ fun submit(target: PostTarget): Boolean {
if (!form.isSubmittable()) return false
+ val whenText = form.fields[FiveW1HField.WHEN] ?: ""
+ val schedule = if (target == PostTarget.Note) null else WhenTextParser.parse(whenText, today())
+ if (target != PostTarget.Note && schedule == null) return false
+ val what = form.fields[FiveW1HField.WHAT] ?: ""
val activityId = store.addCapture(form.templateType, form.fields, form.attachments)
- val whenText = form.fields[FiveW1HField.WHAT] ?: ""
+ var noteId: String? = null
+ var eventId: String? = null
+ when (target) {
+ PostTarget.Note -> noteId = store.addNote(what, what).id
+ PostTarget.Calendar -> schedule?.let { eventId = store.addCalendarEvent(what, it.first, it.second).id }
+ PostTarget.Both -> {
+ val note = store.addNote(what, what)
+ val event = schedule?.let { store.addCalendarEvent(what, it.first, it.second, linkedNoteId = note.id) }
+ if (event != null) store.linkNoteToEvent(note.id, event.id)
+ noteId = note.id
+ eventId = event?.id
+ }
+ }
val snippet = buildString {
- append("* ").append(form.templateType).append(": ").append(whenText.take(40)).appendLine()
+ append("* ").append(form.templateType).append(": ").append(what.take(40)).appendLine()
form.fields[FiveW1HField.WHEN]?.takeIf { it.isNotBlank() }?.let { append(" SCHEDULED: <").append(it).appendLine(">") }
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()
+ append(" posted to: ").append(target.label).appendLine()
}
- lastResult = CaptureUiResult(snippet, form.templateType, activityId)
+ lastResult = CaptureUiResult(snippet, form.templateType, activityId, target, noteId, eventId)
return true
}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigScreen.kt
index 8f36191..c370d90 100644
--- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigScreen.kt
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigScreen.kt
@@ -7,13 +7,16 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Button
+import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Slider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -26,10 +29,45 @@ fun ConfigScreen(
) {
val snapshot by runtime.config.collectAsState()
val drafts = remember { mutableStateMapOf<String, Double>() }
+ var endpointDraft by remember { mutableStateOf<String?>(null) }
+ var iceDraft by remember { mutableStateOf<String?>(null) }
Column(modifier = Modifier.fillMaxSize().padding(12.dp)) {
Text("Config (FsmpConfiguration)", fontSize = 17.sp)
Text("config dir: ${snapshot.configDir}", fontSize = 12.sp)
Text("saved at: ${snapshot.lastSavedAtMs}", fontSize = 11.sp)
+ Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
+ Text("signaling endpoint: ${snapshot.signalingEndpoint}", fontSize = 12.sp)
+ OutlinedTextField(
+ value = endpointDraft ?: snapshot.signalingEndpoint,
+ onValueChange = { endpointDraft = it },
+ label = { Text("ws://host:port/ws") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Button(
+ onClick = {
+ viewModel.updateEndpoint(endpointDraft ?: snapshot.signalingEndpoint)
+ endpointDraft = null
+ },
+ modifier = Modifier.padding(top = 4.dp),
+ ) { Text("save endpoint") }
+ }
+ Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
+ Text("ice servers: ${snapshot.iceServers.joinToString()}", fontSize = 12.sp)
+ OutlinedTextField(
+ value = iceDraft ?: snapshot.iceServers.joinToString("\n"),
+ onValueChange = { iceDraft = it },
+ label = { Text("one STUN/TURN url per line") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Button(
+ onClick = {
+ viewModel.updateIceServers((iceDraft ?: snapshot.iceServers.joinToString("\n")).lines())
+ iceDraft = null
+ },
+ modifier = Modifier.padding(top = 4.dp),
+ ) { Text("save ice servers") }
+ }
snapshot.fields.forEach { field ->
val draft = drafts[field.key] ?: field.value
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigViewModel.kt
index 8e7f806..2ca5cd4 100644
--- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigViewModel.kt
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigViewModel.kt
@@ -9,4 +9,8 @@ class ConfigViewModel(private val runtime: ConfigRuntime) {
private set
fun update(fieldKey: String, value: Double) = runtime.update(fieldKey, value)
+
+ fun updateEndpoint(endpoint: String) = runtime.updateEndpoint(endpoint)
+
+ fun updateIceServers(servers: List<String>) = runtime.updateIceServers(servers)
}
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 f959875..9a49033 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
@@ -1,25 +1,47 @@
package jp.orgflow.ui.home
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Card
+import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
+import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
+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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import jp.orgflow.ui.store.Affiliation
+import jp.orgflow.ui.store.JoinableRoom
import jp.orgflow.ui.store.OrgFlowAppStore
@Composable
fun HomeScreen(
store: OrgFlowAppStore = remember { OrgFlowAppStore() },
+ onOpenCalendar: () -> Unit = {},
viewModel: HomeViewModel = remember(store) { HomeViewModel(store) },
) {
viewModel.refresh()
- Column(modifier = Modifier.fillMaxSize().padding(12.dp)) {
+ LaunchedEffect(Unit) {
+ viewModel.checkDueNotifications()
+ }
+ Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp)) {
Text(viewModel.quickGreeting(), fontSize = 20.sp)
Row(modifier = Modifier.padding(vertical = 8.dp)) {
StatCard("Activities", viewModel.summary.activityCount)
@@ -29,7 +51,10 @@ fun HomeScreen(
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)
+ AffiliationsSection(viewModel)
+ DueSoonSection(viewModel, onOpenCalendar)
+ WeekStripSection(viewModel)
+ RoomsSection(viewModel)
}
}
@@ -42,3 +67,157 @@ private fun StatCard(label: String, count: Int) {
}
}
}
+
+@Composable
+private fun SectionHeader(title: String) {
+ Text(
+ title,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
+ )
+}
+
+@Composable
+private fun AffiliationsSection(viewModel: HomeViewModel) {
+ SectionHeader("Affiliations")
+ if (viewModel.affiliations.isEmpty()) {
+ Text(
+ "No affiliations yet",
+ fontSize = 12.sp,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ )
+ } else {
+ Row {
+ viewModel.affiliations.forEach { affiliation -> AffiliationChip(affiliation) }
+ }
+ }
+}
+
+@Composable
+private fun AffiliationChip(affiliation: Affiliation) {
+ Box(
+ modifier = Modifier
+ .padding(end = 6.dp)
+ .background(Color(affiliation.colorArgb), RoundedCornerShape(10.dp))
+ .padding(horizontal = 10.dp, vertical = 4.dp),
+ ) {
+ Text(affiliation.name, fontSize = 12.sp, color = Color.White)
+ }
+}
+
+@Composable
+private fun DueSoonSection(viewModel: HomeViewModel, onOpenCalendar: () -> Unit) {
+ SectionHeader("Due soon")
+ val items = viewModel.dueSoonItems()
+ if (items.isEmpty()) {
+ Text(
+ "Nothing due",
+ fontSize = 12.sp,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ )
+ } else {
+ Column {
+ items.forEach { item -> DueSoonRow(item, onOpenCalendar) }
+ }
+ }
+}
+
+@Composable
+private fun DueSoonRow(item: DueSoonItem, onOpenCalendar: () -> Unit) {
+ Row(
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onOpenCalendar).padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .padding(end = 6.dp)
+ .size(8.dp)
+ .background(MaterialTheme.colors.primary, CircleShape),
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(item.title, fontSize = 14.sp)
+ Text(
+ "${item.at} · ${dueLabel(item.daysUntil)}",
+ fontSize = 11.sp,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ )
+ }
+ }
+}
+
+@Composable
+private fun WeekStripSection(viewModel: HomeViewModel) {
+ SectionHeader("Next 7 days")
+ Row(modifier = Modifier.fillMaxWidth()) {
+ viewModel.weekDays().forEach { day ->
+ Column(
+ modifier = Modifier.weight(1f).padding(horizontal = 1.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ day.dayOfWeek.name.take(3),
+ fontSize = 10.sp,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ )
+ Text(
+ day.dayOfMonth.toString(),
+ fontSize = 13.sp,
+ fontWeight = if (day == viewModel.today()) FontWeight.Bold else null,
+ color = if (day == viewModel.today()) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface,
+ )
+ viewModel.entriesOn(day).take(2).forEach { event ->
+ Text(
+ event.title,
+ fontSize = 10.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.8f),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RoomsSection(viewModel: HomeViewModel) {
+ SectionHeader("Rooms to join")
+ if (viewModel.joinableRooms.isEmpty()) {
+ Text(
+ "No rooms available",
+ fontSize = 12.sp,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ )
+ return
+ }
+ viewModel.joinableRooms.forEach { room -> RoomCard(viewModel, room) }
+}
+
+@Composable
+private fun RoomCard(viewModel: HomeViewModel, room: JoinableRoom) {
+ Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 2.dp) {
+ Row(modifier = Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(room.name, fontSize = 14.sp)
+ Text(
+ "${room.memberCount} members",
+ fontSize = 11.sp,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ )
+ }
+ if (room.id in viewModel.joinedRoomIds) {
+ Text("Joined", fontSize = 12.sp, color = MaterialTheme.colors.primary)
+ } else {
+ TextButton(onClick = { viewModel.joinRoom(room.id) }) { Text("参加") }
+ }
+ TextButton(onClick = {}) { Text("見る") }
+ }
+ }
+}
+
+private fun dueLabel(daysUntil: Int): String = when (daysUntil) {
+ 0 -> "Today"
+ 1 -> "Tomorrow"
+ else -> "in $daysUntil days"
+}
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 f5588b7..a90497a 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
@@ -1,10 +1,23 @@
+@file:OptIn(kotlin.time.ExperimentalTime::class)
+
package jp.orgflow.ui.home
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.calendar.CalendarEvent
+import jp.orgflow.ui.calendar.CalendarViewModel
+import jp.orgflow.ui.notification.Notifier
+import jp.orgflow.ui.notification.defaultNotifier
+import jp.orgflow.ui.store.Affiliation
+import jp.orgflow.ui.store.JoinableRoom
import jp.orgflow.ui.store.OrgFlowAppStore
+import kotlin.time.Clock
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
data class HomeSummary(
val activityCount: Int,
@@ -12,12 +25,25 @@ data class HomeSummary(
val taskOpen: Int,
)
-class HomeViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()) {
+data class DueSoonItem(
+ val id: String,
+ val title: String,
+ val at: LocalDateTime,
+ val daysUntil: Int,
+)
+
+class HomeViewModel(
+ private val store: OrgFlowAppStore = OrgFlowAppStore(),
+ private val notifier: Notifier = defaultNotifier(),
+ private val now: () -> LocalDateTime = { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) },
+) {
var summary: HomeSummary by mutableStateOf(HomeSummary(0, 0, 0))
private set
var lastActivityId: ActivityId? by mutableStateOf(null)
private set
+ private val calendar = CalendarViewModel(store, notifier = notifier, now = now)
+
fun refresh(activities: Int? = null, notes: Int? = null, openTasks: Int? = null) {
summary = HomeSummary(
activities ?: store.activities.size,
@@ -28,4 +54,42 @@ class HomeViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()) {
}
fun quickGreeting(): String = "Welcome to OrgFlow"
+
+ fun today(): LocalDate = now().date
+
+ fun dueSoonItems(): List<DueSoonItem> {
+ val todayDays = today().toEpochDays()
+ val items = buildList {
+ store.agendaItems.filter { !it.done }.forEach { item ->
+ add(DueSoonItem("agenda-${item.id}", item.title, item.scheduledAt, (item.scheduledAt.date.toEpochDays() - todayDays).toInt()))
+ }
+ store.calendarEvents.forEach { event ->
+ val at = event.startsAt()
+ add(DueSoonItem(event.id, event.title, at, (at.date.toEpochDays() - todayDays).toInt()))
+ }
+ }
+ return items.filter { it.daysUntil >= 0 }.sortedWith(compareBy({ it.at }, { it.title }))
+ }
+
+ fun weekDays(): List<LocalDate> = (0..6).map { LocalDate.fromEpochDays(today().toEpochDays() + it) }
+
+ fun entriesOn(date: LocalDate): List<CalendarEvent> {
+ val fromAgenda = store.agendaItems.filter { it.scheduledAt.date == date }.map(calendar::agendaEvent)
+ return (store.calendarEvents.filter { it.date == date } + fromAgenda).sortedBy { it.startsAt() }
+ }
+
+ fun checkDueNotifications(): List<CalendarEvent> = calendar.checkDueNotifications()
+
+ val joinableRooms: List<JoinableRoom>
+ get() = store.joinableRooms
+
+ val joinedRoomIds: Set<String>
+ get() = store.joinedRoomIds
+
+ val affiliations: List<Affiliation>
+ get() = store.affiliations
+
+ fun joinRoom(id: String) {
+ store.joinRoom(id)
+ }
}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/live/LiveScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/live/LiveScreen.kt
new file mode 100644
index 0000000..86789ea
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/live/LiveScreen.kt
@@ -0,0 +1,54 @@
+package jp.orgflow.ui.live
+
+import androidx.compose.foundation.layout.Arrangement
+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.Checkbox
+import androidx.compose.material.Text
+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
+import jp.orgflow.ui.distribution.DistributionScreen
+import jp.orgflow.ui.distribution.DistributionViewModel
+import jp.orgflow.ui.model.SetFilterState
+import jp.orgflow.ui.runtime.DistributionRuntime
+
+@Composable
+fun LiveScreen(
+ runtime: DistributionRuntime,
+ filter: SetFilterState,
+ viewModel: DistributionViewModel = remember(runtime) { DistributionViewModel(runtime) },
+) {
+ var screenShare by remember { mutableStateOf(false) }
+ var camera by remember { mutableStateOf(false) }
+ Column(modifier = Modifier.fillMaxSize().padding(12.dp)) {
+ Text("Live", fontSize = 17.sp)
+ Row(
+ modifier = Modifier.padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text("source:", fontSize = 12.sp)
+ SourceToggle("画面共有", screenShare) { screenShare = it }
+ SourceToggle("カメラ", camera) { camera = it }
+ Text("配信先: ${filter.label()}", fontSize = 12.sp)
+ }
+ DistributionScreen(runtime = runtime, viewModel = viewModel)
+ }
+}
+
+@Composable
+private fun SourceToggle(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = checked, onCheckedChange = onCheckedChange)
+ Text(label, fontSize = 13.sp)
+ }
+}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/DynamicGroup.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/DynamicGroup.kt
new file mode 100644
index 0000000..f19925d
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/DynamicGroup.kt
@@ -0,0 +1,32 @@
+package jp.orgflow.ui.model
+
+sealed interface MembershipRule {
+ data object All : MembershipRule
+ data class Grade(val grade: String) : MembershipRule
+ data class Classroom(val grade: String, val className: String) : MembershipRule
+}
+
+data class DynamicGroup(
+ val gid: String,
+ val rule: MembershipRule,
+)
+
+object DynamicMembership {
+ const val SEPARATOR = "-"
+
+ fun gradeOf(user: User): String? = segmentOf(user, 0)
+
+ fun classOf(user: User): String? = segmentOf(user, 1)
+
+ fun resolve(group: DynamicGroup, user: User): Membership? {
+ val matched = when (val rule = group.rule) {
+ MembershipRule.All -> true
+ is MembershipRule.Grade -> gradeOf(user) == rule.grade
+ is MembershipRule.Classroom -> gradeOf(user) == rule.grade && classOf(user) == rule.className
+ }
+ return if (matched) Membership(user.uid, group.gid, "member", MembershipStatus.AUTO_JOIN) else null
+ }
+
+ private fun segmentOf(user: User, index: Int): String? =
+ user.schoolId.split(SEPARATOR).getOrNull(index)?.takeIf { it.isNotBlank() }
+}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Group.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Group.kt
new file mode 100644
index 0000000..a143614
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Group.kt
@@ -0,0 +1,14 @@
+package jp.orgflow.ui.model
+
+enum class GroupVisibility(val label: String) {
+ OPEN("公開"),
+ CLOSED("非公開"),
+}
+
+data class Group(
+ val gid: String,
+ val name: String,
+ val type: GroupType,
+ val parent: String? = null,
+ val visibility: GroupVisibility = GroupVisibility.OPEN,
+)
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupColor.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupColor.kt
new file mode 100644
index 0000000..7a0208d
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupColor.kt
@@ -0,0 +1,31 @@
+package jp.orgflow.ui.model
+
+import androidx.compose.ui.graphics.Color
+
+object GroupColor {
+ val paletteSize: Int get() = palette.size
+
+ fun colorOf(gid: String): Color = palette[indexFor(gid)]
+
+ fun indexFor(gid: String): Int {
+ var hash = FNV_OFFSET_BASIS
+ gid.forEach { char ->
+ hash = (hash xor char.code.toLong()) * FNV_PRIME
+ }
+ return hash.mod(palette.size)
+ }
+
+ private val palette = listOf(
+ Color(0xFF3F51B5),
+ Color(0xFF00897B),
+ Color(0xFFF57C00),
+ Color(0xFF7B1FA2),
+ Color(0xFFC62828),
+ Color(0xFF1976D2),
+ Color(0xFF388E3C),
+ Color(0xFF5D4037),
+ )
+
+ private const val FNV_OFFSET_BASIS = -0x340d631b7bdddcdbL
+ private const val FNV_PRIME = 0x100000001b3L
+}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupDirectory.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupDirectory.kt
new file mode 100644
index 0000000..ccda75c
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupDirectory.kt
@@ -0,0 +1,50 @@
+package jp.orgflow.ui.model
+
+object GroupDirectory {
+ val currentUser = User("u-self", "自分", "2-A-15")
+
+ val users = listOf(
+ currentUser,
+ User("u-tanaka", "田中", "2-A-03"),
+ User("u-suzuki", "鈴木", "2-B-11"),
+ User("u-sato", "佐藤", "2-A-07"),
+ User("u-yamada", "山田", "3-C-02"),
+ )
+
+ val groups = listOf(
+ Group("g-whole", "全体", GroupType.WHOLE),
+ Group("g-class-2a", "クラス2-A", GroupType.CLASS, parent = "g-whole"),
+ Group("g-class-2b", "クラス2-B", GroupType.CLASS, parent = "g-whole"),
+ Group("g-club-art", "美術部", GroupType.CLUB, parent = "g-whole"),
+ Group("g-club-jazz", "軽音部", GroupType.CLUB, parent = "g-whole"),
+ Group("g-committee-culture", "文化実行委員会", GroupType.COMMITTEE, parent = "g-whole", visibility = GroupVisibility.CLOSED),
+ )
+
+ val dynamicGroups = listOf(
+ DynamicGroup("g-dyn-grade2", MembershipRule.Grade("2")),
+ DynamicGroup("g-dyn-class-2a", MembershipRule.Classroom("2", "A")),
+ )
+
+ val memberships = listOf(
+ Membership(currentUser.uid, "g-whole", "member", MembershipStatus.AUTO_JOIN),
+ Membership(currentUser.uid, "g-class-2a", "member", MembershipStatus.AUTO_JOIN),
+ Membership(currentUser.uid, "g-club-art", "member", MembershipStatus.JOIN),
+ Membership(currentUser.uid, "g-committee-culture", "member", MembershipStatus.VIEW_ONLY),
+ Membership("u-tanaka", "g-class-2a", "member", MembershipStatus.AUTO_JOIN),
+ Membership("u-suzuki", "g-class-2b", "leader", MembershipStatus.AUTO_JOIN),
+ Membership("u-sato", "g-class-2a", "member", MembershipStatus.AUTO_JOIN),
+ Membership("u-yamada", "g-club-jazz", "member", MembershipStatus.JOIN),
+ )
+
+ fun groupOf(gid: String): Group? = groups.firstOrNull { it.gid == gid }
+
+ fun groupsOf(type: GroupType): List<Group> = groups.filter { it.type == type }
+
+ fun membershipOf(uid: String, gid: String): Membership? =
+ memberships.firstOrNull { it.uid == uid && it.gid == gid }
+
+ fun statusOf(uid: String, gid: String): MembershipStatus? = membershipOf(uid, gid)?.status
+
+ fun dynamicMemberships(user: User): List<Membership> =
+ dynamicGroups.mapNotNull { DynamicMembership.resolve(it, user) }
+}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupType.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupType.kt
new file mode 100644
index 0000000..06eae35
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupType.kt
@@ -0,0 +1,9 @@
+package jp.orgflow.ui.model
+
+enum class GroupType(val label: String) {
+ WHOLE("全体"),
+ CLASS("クラス"),
+ CLUB("部活動"),
+ COMMITTEE("委員会"),
+ PERSONAL("自分"),
+}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/JoinButtons.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/JoinButtons.kt
new file mode 100644
index 0000000..e6068e5
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/JoinButtons.kt
@@ -0,0 +1,26 @@
+package jp.orgflow.ui.model
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.material.Button
+import androidx.compose.material.OutlinedButton
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun JoinButtons(
+ joined: Boolean,
+ onView: () -> Unit,
+ onJoin: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ OutlinedButton(onClick = onView) { Text("見る", fontSize = 12.sp) }
+ Button(onClick = onJoin, enabled = !joined) {
+ Text(if (joined) "参加済み" else "参加", fontSize = 12.sp)
+ }
+ }
+}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Membership.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Membership.kt
new file mode 100644
index 0000000..b8365d3
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Membership.kt
@@ -0,0 +1,8 @@
+package jp.orgflow.ui.model
+
+data class Membership(
+ val uid: String,
+ val gid: String,
+ val role: String,
+ val status: MembershipStatus,
+)
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipIcons.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipIcons.kt
new file mode 100644
index 0000000..1ed3e3e
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipIcons.kt
@@ -0,0 +1,61 @@
+package jp.orgflow.ui.model
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+enum class MembershipIcon { FULL_MOON, HALF_MOON, CIRCLE, KEY, CHECK }
+
+fun MembershipStatus.toIcon(): MembershipIcon = when (this) {
+ MembershipStatus.JOIN -> MembershipIcon.CIRCLE
+ MembershipStatus.AUTO_JOIN -> MembershipIcon.FULL_MOON
+ MembershipStatus.VIEW_ONLY -> MembershipIcon.HALF_MOON
+ MembershipStatus.HIDDEN -> MembershipIcon.KEY
+}
+
+fun iconFor(status: MembershipStatus?): MembershipIcon =
+ status?.toIcon() ?: MembershipIcon.CIRCLE
+
+fun Group.iconForVisibility(): MembershipIcon? =
+ if (visibility == GroupVisibility.CLOSED) MembershipIcon.KEY else null
+
+@Composable
+fun MembershipIconView(icon: MembershipIcon, color: Color, iconSize: Dp = 16.dp) {
+ Canvas(modifier = Modifier.size(iconSize)) {
+ val stroke = Stroke(width = 1.5.dp.toPx(), cap = StrokeCap.Round)
+ when (icon) {
+ MembershipIcon.FULL_MOON -> drawCircle(color)
+ MembershipIcon.HALF_MOON -> {
+ drawCircle(color, style = stroke)
+ drawArc(color, startAngle = -90f, sweepAngle = 180f, useCenter = true)
+ }
+ MembershipIcon.CIRCLE -> drawCircle(color, style = stroke)
+ MembershipIcon.KEY -> {
+ val w = this.size.width
+ val h = this.size.height
+ drawCircle(
+ color = color,
+ radius = w * 0.18f,
+ center = Offset(w * 0.32f, h * 0.5f),
+ style = stroke,
+ )
+ drawLine(color, Offset(w * 0.5f, h * 0.5f), Offset(w * 0.85f, h * 0.5f), strokeWidth = stroke.width, cap = StrokeCap.Round)
+ drawLine(color, Offset(w * 0.68f, h * 0.5f), Offset(w * 0.68f, h * 0.7f), strokeWidth = stroke.width, cap = StrokeCap.Round)
+ drawLine(color, Offset(w * 0.85f, h * 0.5f), Offset(w * 0.85f, h * 0.7f), strokeWidth = stroke.width, cap = StrokeCap.Round)
+ }
+ MembershipIcon.CHECK -> {
+ val w = this.size.width
+ val h = this.size.height
+ drawLine(color, Offset(w * 0.2f, h * 0.52f), Offset(w * 0.42f, h * 0.74f), strokeWidth = stroke.width, cap = StrokeCap.Round)
+ drawLine(color, Offset(w * 0.42f, h * 0.74f), Offset(w * 0.8f, h * 0.26f), strokeWidth = stroke.width, cap = StrokeCap.Round)
+ }
+ }
+ }
+}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipStatus.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipStatus.kt
new file mode 100644
index 0000000..2a60d9c
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipStatus.kt
@@ -0,0 +1,8 @@
+package jp.orgflow.ui.model
+
+enum class MembershipStatus(val label: String) {
+ JOIN("参加"),
+ AUTO_JOIN("自動参加"),
+ VIEW_ONLY("閲覧のみ"),
+ HIDDEN("非表示"),
+}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/SetFilterState.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/SetFilterState.kt
new file mode 100644
index 0000000..0325f63
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/SetFilterState.kt
@@ -0,0 +1,56 @@
+package jp.orgflow.ui.model
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+
+sealed interface SetFilter {
+ data object Whole : SetFilter
+ data object Self : SetFilter
+ data class Type(val type: GroupType) : SetFilter
+ data class Group(val gid: String) : SetFilter
+}
+
+data class FilterSegment(val label: String, val filter: SetFilter)
+
+class SetFilterState(initial: SetFilter = SetFilter.Whole) {
+ var current: SetFilter by mutableStateOf(initial)
+ private set
+
+ fun select(filter: SetFilter) {
+ current = filter
+ }
+
+ fun reset() {
+ current = SetFilter.Whole
+ }
+
+ fun groupId(): String? = (current as? SetFilter.Group)?.gid
+
+ fun label(): String = when (val filter = current) {
+ SetFilter.Whole -> GroupType.WHOLE.label
+ SetFilter.Self -> GroupType.PERSONAL.label
+ is SetFilter.Type -> filter.type.label
+ is SetFilter.Group -> GroupDirectory.groupOf(filter.gid)?.name ?: filter.gid
+ }
+
+ fun segments(): List<FilterSegment> = when (val filter = current) {
+ SetFilter.Whole -> listOf(FilterSegment(GroupType.WHOLE.label, SetFilter.Whole))
+ SetFilter.Self -> listOf(
+ FilterSegment(GroupType.WHOLE.label, SetFilter.Whole),
+ FilterSegment(GroupType.PERSONAL.label, SetFilter.Self),
+ )
+ is SetFilter.Type -> listOf(
+ FilterSegment(GroupType.WHOLE.label, SetFilter.Whole),
+ FilterSegment(filter.type.label, filter),
+ )
+ is SetFilter.Group -> {
+ val group = GroupDirectory.groupOf(filter.gid)
+ listOf(
+ FilterSegment(GroupType.WHOLE.label, SetFilter.Whole),
+ FilterSegment(group?.type?.label ?: "", SetFilter.Type(group?.type ?: GroupType.WHOLE)),
+ FilterSegment(group?.name ?: filter.gid, filter),
+ )
+ }
+ }
+}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/User.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/User.kt
new file mode 100644
index 0000000..063488c
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/User.kt
@@ -0,0 +1,7 @@
+package jp.orgflow.ui.model
+
+data class User(
+ val uid: String,
+ val displayName: String,
+ val schoolId: String,
+)
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteCards.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteCards.kt
new file mode 100644
index 0000000..efe044a
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteCards.kt
@@ -0,0 +1,292 @@
+package jp.orgflow.ui.notes
+
+enum class CardType(val id: String) {
+ TEXT("text"),
+ INK("ink"),
+ GRAPH("graph");
+
+ companion object {
+ fun fromId(id: String?): CardType = entries.firstOrNull { it.id == id } ?: TEXT
+ }
+}
+
+data class CardModel(
+ val id: String,
+ val type: CardType,
+ val x: Int,
+ val y: Int,
+ val w: Int,
+ val h: Int,
+ val text: String,
+ val color: String,
+)
+
+enum class NoteScope(val label: String, val colorHex: String) {
+ SELF("自分", "#7E57C2"),
+ EVERYONE("全体", "#43A047"),
+ CLASS("クラス", "#1E88E5"),
+ CLUB("部活動", "#FB8C00"),
+ COMMITTEE("委員会", "#D81B60");
+
+ companion object {
+ fun fromLabel(label: String?): NoteScope = entries.firstOrNull { it.label == label } ?: SELF
+ }
+}
+
+data class NoteDocument(
+ val scope: NoteScope = NoteScope.SELF,
+ val collaborationActive: Boolean = false,
+ val cards: List<CardModel> = emptyList(),
+) {
+ fun card(id: String): CardModel? = cards.firstOrNull { it.id == id }
+
+ companion object {
+ const val DEFAULT_CARD_WIDTH = 170
+ const val DEFAULT_CARD_HEIGHT = 100
+ const val DEFAULT_CARD_COLOR = "#FFF59D"
+ const val CARD_ORIGIN = 16
+
+ fun fromPlainBody(body: String): NoteDocument = NoteDocument(
+ cards = listOf(
+ CardModel(
+ id = "card-1",
+ type = CardType.TEXT,
+ x = CARD_ORIGIN,
+ y = CARD_ORIGIN,
+ w = DEFAULT_CARD_WIDTH,
+ h = DEFAULT_CARD_HEIGHT,
+ text = body,
+ color = DEFAULT_CARD_COLOR,
+ ),
+ ),
+ )
+ }
+}
+
+data class NoteBundle(
+ val title: String,
+ val document: NoteDocument,
+ val remaining: NoteDocument,
+)
+
+fun NoteDocument.bundleSelected(selectedIds: Set<String>): NoteBundle? {
+ if (selectedIds.size < 2) return null
+ val selected = cards.filter { it.id in selectedIds }
+ if (selected.size < 2) return null
+ val title = selected.first().text.lineSequence().firstOrNull { it.isNotBlank() }?.take(24)?.trim().orEmpty()
+ return NoteBundle(
+ title = title.ifBlank { "クリップノート" },
+ document = copy(cards = selected, collaborationActive = false),
+ remaining = copy(cards = cards - selected.toSet()),
+ )
+}
+
+object NoteDocumentJson {
+ fun encode(document: NoteDocument): String = buildString {
+ append("{\"v\":1,\"scope\":")
+ appendQuoted(document.scope.label)
+ append(",\"collab\":")
+ append(document.collaborationActive)
+ append(",\"cards\":[")
+ document.cards.forEachIndexed { index, card ->
+ if (index > 0) append(",")
+ appendCard(card)
+ }
+ append("]}")
+ }
+
+ fun decode(body: String): NoteDocument? {
+ val trimmed = body.trim()
+ if (!trimmed.startsWith("{")) return null
+ return try {
+ val root = NoteJsonParser(trimmed).parse() as? Map<*, *> ?: return null
+ val cardList = root["cards"] as? List<*> ?: return null
+ NoteDocument(
+ scope = NoteScope.fromLabel(root["scope"] as? String),
+ collaborationActive = root["collab"] as? Boolean ?: false,
+ cards = cardList.mapNotNull { entry ->
+ val map = entry as? Map<*, *> ?: return@mapNotNull null
+ val id = map["id"] as? String ?: return@mapNotNull null
+ CardModel(
+ id = id,
+ type = CardType.fromId(map["type"] as? String),
+ x = (map["x"] as? Number)?.toInt() ?: 0,
+ y = (map["y"] as? Number)?.toInt() ?: 0,
+ w = (map["w"] as? Number)?.toInt() ?: NoteDocument.DEFAULT_CARD_WIDTH,
+ h = (map["h"] as? Number)?.toInt() ?: NoteDocument.DEFAULT_CARD_HEIGHT,
+ text = map["text"] as? String ?: "",
+ color = map["color"] as? String ?: NoteDocument.DEFAULT_CARD_COLOR,
+ )
+ },
+ )
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun StringBuilder.appendCard(card: CardModel) {
+ append("{\"id\":")
+ appendQuoted(card.id)
+ append(",\"type\":")
+ appendQuoted(card.type.id)
+ append(",\"x\":")
+ append(card.x)
+ append(",\"y\":")
+ append(card.y)
+ append(",\"w\":")
+ append(card.w)
+ append(",\"h\":")
+ append(card.h)
+ append(",\"text\":")
+ appendQuoted(card.text)
+ append(",\"color\":")
+ appendQuoted(card.color)
+ append("}")
+ }
+
+ private fun StringBuilder.appendQuoted(value: String) {
+ append('"')
+ value.forEach { c ->
+ when {
+ c == '"' -> append("\\\"")
+ c == '\\' -> append("\\\\")
+ c == '\n' -> append("\\n")
+ c == '\r' -> append("\\r")
+ c == '\t' -> append("\\t")
+ c == '\b' -> append("\\b")
+ c == '\u000C' -> append("\\f")
+ c < ' ' -> {
+ append("\\u")
+ append(c.code.toString(16).padStart(4, '0'))
+ }
+ else -> append(c)
+ }
+ }
+ append('"')
+ }
+}
+
+private class NoteJsonException : Exception()
+
+private class NoteJsonParser(private val text: String) {
+ private var index = 0
+
+ fun parse(): Any? {
+ val value = parseValue()
+ skipWhitespace()
+ if (index != text.length) throw NoteJsonException()
+ return value
+ }
+
+ private fun parseValue(): Any? {
+ skipWhitespace()
+ return when (text.getOrNull(index)) {
+ '{' -> parseObject()
+ '[' -> parseArray()
+ '"' -> parseString()
+ 't' -> parseLiteral("true", true)
+ 'f' -> parseLiteral("false", false)
+ 'n' -> parseLiteral("null", null)
+ else -> parseNumber()
+ }
+ }
+
+ private fun parseObject(): Map<String, Any?> {
+ index++
+ val result = mutableMapOf<String, Any?>()
+ skipWhitespace()
+ if (consumeIf('}')) return result
+ while (true) {
+ skipWhitespace()
+ val key = parseString()
+ skipWhitespace()
+ expect(':')
+ result[key] = parseValue()
+ skipWhitespace()
+ if (consumeIf(',')) continue
+ expect('}')
+ return result
+ }
+ }
+
+ private fun parseArray(): List<Any?> {
+ index++
+ val result = mutableListOf<Any?>()
+ skipWhitespace()
+ if (consumeIf(']')) return result
+ while (true) {
+ result.add(parseValue())
+ skipWhitespace()
+ if (consumeIf(',')) continue
+ expect(']')
+ return result
+ }
+ }
+
+ private fun parseString(): String {
+ expect('"')
+ val builder = StringBuilder()
+ while (true) {
+ val c = text.getOrNull(index) ?: throw NoteJsonException()
+ index++
+ when (c) {
+ '"' -> return builder.toString()
+ '\\' -> builder.append(parseEscape())
+ else -> builder.append(c)
+ }
+ }
+ }
+
+ private fun parseEscape(): Char {
+ val c = text.getOrNull(index) ?: throw NoteJsonException()
+ index++
+ return when (c) {
+ '"' -> '"'
+ '\\' -> '\\'
+ '/' -> '/'
+ 'n' -> '\n'
+ 't' -> '\t'
+ 'r' -> '\r'
+ 'b' -> '\b'
+ 'f' -> '\u000C'
+ 'u' -> {
+ if (index + 4 > text.length) throw NoteJsonException()
+ val token = text.substring(index, index + 4)
+ index += 4
+ token.toIntOrNull(16)?.toChar() ?: throw NoteJsonException()
+ }
+ else -> throw NoteJsonException()
+ }
+ }
+
+ private fun parseNumber(): Any {
+ val start = index
+ if (text.getOrNull(index) == '-') index++
+ while (index < text.length && (text[index].isDigit() || text[index] in ".eE+-")) index++
+ val token = text.substring(start, index)
+ return token.toLongOrNull() ?: token.toDoubleOrNull() ?: throw NoteJsonException()
+ }
+
+ private fun parseLiteral(literal: String, value: Any?): Any? {
+ if (!text.startsWith(literal, index)) throw NoteJsonException()
+ index += literal.length
+ return value
+ }
+
+ private fun skipWhitespace() {
+ while (index < text.length && text[index] in " \t\n\r") index++
+ }
+
+ private fun consumeIf(expected: Char): Boolean {
+ if (text.getOrNull(index) == expected) {
+ index++
+ return true
+ }
+ return false
+ }
+
+ private fun expect(expected: Char) {
+ if (text.getOrNull(index) != expected) throw NoteJsonException()
+ index++
+ }
+}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt
index 5e88413..ae53cb7 100644
--- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt
@@ -1,17 +1,45 @@
package jp.orgflow.ui.notes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.DropdownMenu
+import androidx.compose.material.DropdownMenuItem
+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.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.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import jp.orgflow.ui.component.SourceCommitBadge
+import kotlin.math.roundToInt
@Composable
fun NoteEditorScreen(viewModel: NoteEditorViewModel = remember { NoteEditorViewModel() }) {
@@ -22,13 +50,184 @@ fun NoteEditorScreen(viewModel: NoteEditorViewModel = remember { NoteEditorViewM
SourceCommitBadge(null)
}
if (note != null) {
- OrgRichTextEditor(
- state = viewModel.editor,
- onContentChanged = { },
- )
- TextButton(onClick = { viewModel.saveCurrent() }) { Text("Save (commit candidate)") }
+ NoteDetailHeader(viewModel)
+ NoteCardBoard(viewModel)
} else {
Text("Select a note from the list first.", fontSize = 13.sp)
}
}
}
+
+@Composable
+private fun NoteDetailHeader(viewModel: NoteEditorViewModel) {
+ var scopeMenuOpen by remember { mutableStateOf(false) }
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Box {
+ ScopeBadge(scope = viewModel.document.scope, onClick = { if (viewModel.editable) scopeMenuOpen = true })
+ DropdownMenu(expanded = scopeMenuOpen, onDismissRequest = { scopeMenuOpen = false }) {
+ NoteScope.entries.forEach { scope ->
+ DropdownMenuItem(onClick = {
+ viewModel.setScope(scope)
+ scopeMenuOpen = false
+ }) {
+ ScopeBadge(scope)
+ }
+ }
+ }
+ }
+ if (viewModel.document.collaborationActive) {
+ CollabBadge()
+ } else {
+ TextButton(onClick = { viewModel.startCollaboration() }) { Text("共同編集を開始") }
+ }
+ TextButton(onClick = { viewModel.toggleEditable() }) {
+ Text(if (viewModel.editable) "編集中" else "閲覧")
+ }
+ TextButton(onClick = { viewModel.toggleSelectMode() }, enabled = viewModel.editable) {
+ Text(if (viewModel.selectMode) "選択中" else "選択", color = if (viewModel.selectMode) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface)
+ }
+ TextButton(onClick = { viewModel.saveCurrent() }) { Text("Save (commit candidate)") }
+ }
+}
+
+@Composable
+private fun NoteCardBoard(viewModel: NoteEditorViewModel) {
+ var boardSize by remember { mutableStateOf(IntSize.Zero) }
+ Column(modifier = Modifier.fillMaxSize().padding(top = 4.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ TextButton(onClick = { viewModel.addCard() }, enabled = viewModel.editable) {
+ Text("+ テキストカード")
+ }
+ if (viewModel.selectedCardIds.size >= 2) {
+ TextButton(onClick = { viewModel.bundleSelectedCards() }, enabled = viewModel.editable) {
+ Text("クリップ (${viewModel.selectedCardIds.size})")
+ }
+ TextButton(onClick = { viewModel.clearCardSelection() }) { Text("解除") }
+ }
+ }
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .onSizeChanged { boardSize = it }
+ .background(Color(0xFFF6F4EE))
+ .pointerInput(Unit) {
+ detectTapGestures(onTap = {
+ viewModel.endCardEdit()
+ viewModel.clearCardSelection()
+ })
+ },
+ ) {
+ if (viewModel.document.cards.isEmpty()) {
+ Text(
+ "カードがありません — 編集モードで「+ テキストカード」を追加",
+ fontSize = 12.sp,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ modifier = Modifier.align(Alignment.Center).padding(12.dp),
+ )
+ }
+ viewModel.document.cards.forEach { card ->
+ NoteCardView(viewModel, card, boardSize)
+ }
+ }
+ }
+}
+
+@Composable
+private fun NoteCardView(viewModel: NoteEditorViewModel, card: CardModel, boardSize: IntSize) {
+ val selected = card.id in viewModel.selectedCardIds
+ val editing = viewModel.editingCardId == card.id
+ Box(
+ modifier = Modifier
+ .offset { IntOffset(card.x, card.y) }
+ .size(card.w.dp, card.h.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(parseHexColor(card.color))
+ .border(
+ width = if (selected) 2.dp else 1.dp,
+ color = if (selected) MaterialTheme.colors.primary else Color(0xFF9E9E9E),
+ shape = RoundedCornerShape(6.dp),
+ )
+ .pointerInput(card.id, viewModel.selectMode, viewModel.editable) {
+ detectTapGestures(
+ onTap = {
+ when {
+ viewModel.selectMode -> viewModel.toggleCardSelection(card.id)
+ viewModel.editable -> viewModel.beginCardEdit(card.id)
+ else -> Unit
+ }
+ },
+ onLongPress = { viewModel.toggleCardSelection(card.id) },
+ )
+ }
+ .pointerInput(card.id, viewModel.editable) {
+ if (viewModel.editable) {
+ detectDragGestures(
+ onDragStart = { viewModel.endCardEdit() },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ viewModel.dragCard(
+ cardId = card.id,
+ dx = dragAmount.x.roundToInt(),
+ dy = dragAmount.y.roundToInt(),
+ boardWidth = boardSize.width,
+ boardHeight = boardSize.height,
+ )
+ },
+ onDragEnd = { viewModel.endDrag() },
+ onDragCancel = { viewModel.endDrag() },
+ )
+ }
+ }
+ .padding(6.dp),
+ ) {
+ if (editing && card.type == CardType.TEXT) {
+ OutlinedTextField(
+ value = viewModel.editor.text,
+ onValueChange = { viewModel.updateEditingCardText(it) },
+ modifier = Modifier.fillMaxSize(),
+ textStyle = TextStyle(fontSize = 12.sp),
+ )
+ } else {
+ Text(
+ text = card.text.ifBlank { "(空のカード)" },
+ fontSize = 12.sp,
+ maxLines = 6,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Composable
+internal fun ScopeBadge(scope: NoteScope, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null) {
+ Text(
+ text = scope.label,
+ fontSize = 11.sp,
+ color = Color.White,
+ modifier = modifier
+ .background(parseHexColor(scope.colorHex))
+ .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
+ .padding(horizontal = 6.dp, vertical = 2.dp),
+ )
+}
+
+@Composable
+internal fun CollabBadge() {
+ Text(
+ text = "共同編集中",
+ fontSize = 11.sp,
+ color = Color.White,
+ modifier = Modifier
+ .background(Color(0xFF546E7A))
+ .padding(horizontal = 6.dp, vertical = 2.dp),
+ )
+}
+
+internal fun parseHexColor(hex: String): Color {
+ val rgb = hex.removePrefix("#").toLongOrNull(16) ?: 0L
+ return Color(0xFF000000L or rgb)
+}
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 0595766..34fad49 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
@@ -19,15 +19,46 @@ class NoteEditorViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()
val editor = SaveableEditorState()
var selectedId: String? by mutableStateOf(null)
+ private set
+ var document: NoteDocument by mutableStateOf(NoteDocument())
+ private set
+ var editable: Boolean by mutableStateOf(false)
+ private set
+ var selectMode: Boolean by mutableStateOf(false)
+ private set
+ var selectedCardIds: Set<String> by mutableStateOf(emptySet())
+ private set
+ var editingCardId: String? by mutableStateOf(null)
+ private set
+
+ private var isCardDocument: Boolean = false
fun select(id: String) {
selectedId = id
- store.notes.firstOrNull { it.id == id }?.let { editor.edit(it.body) }
+ editable = false
+ selectMode = false
+ selectedCardIds = emptySet()
+ editingCardId = null
+ val note = store.notes.firstOrNull { it.id == id } ?: return
+ val decoded = NoteDocumentJson.decode(note.body)
+ if (decoded == null) {
+ isCardDocument = false
+ document = NoteDocument.fromPlainBody(note.body)
+ editor.edit(note.body)
+ } else {
+ isCardDocument = true
+ document = decoded
+ editor.edit("")
+ }
}
fun saveCurrent(): Boolean {
val id = selectedId ?: return false
- return store.updateNote(id, editor.text)
+ return if (isCardDocument) {
+ store.updateNote(id, NoteDocumentJson.encode(document))
+ } else {
+ store.updateNote(id, editor.text)
+ }
}
fun addNote(title: String): String {
@@ -35,4 +66,124 @@ class NoteEditorViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()
select(entry.id)
return entry.id
}
+
+ fun toggleEditable() {
+ editable = !editable
+ if (!editable) {
+ editingCardId = null
+ selectMode = false
+ selectedCardIds = emptySet()
+ }
+ }
+
+ fun toggleSelectMode() {
+ selectMode = !selectMode
+ if (!selectMode) selectedCardIds = emptySet()
+ }
+
+ fun toggleCardSelection(cardId: String) {
+ selectedCardIds = if (cardId in selectedCardIds) selectedCardIds - cardId else selectedCardIds + cardId
+ }
+
+ fun clearCardSelection() {
+ selectedCardIds = emptySet()
+ }
+
+ fun addCard(): String? {
+ if (!editable || selectedId == null) return null
+ val nextNumber = (document.cards.maxOfOrNull { cardNumber(it.id) } ?: 0) + 1
+ val card = CardModel(
+ id = "card-$nextNumber",
+ type = CardType.TEXT,
+ x = NoteDocument.CARD_ORIGIN + (nextNumber % 6) * 16,
+ y = NoteDocument.CARD_ORIGIN + (nextNumber % 6) * 16,
+ w = NoteDocument.DEFAULT_CARD_WIDTH,
+ h = NoteDocument.DEFAULT_CARD_HEIGHT,
+ text = "",
+ color = NoteDocument.DEFAULT_CARD_COLOR,
+ )
+ mutate { it.copy(cards = it.cards + card) }
+ editingCardId = card.id
+ selectMode = false
+ selectedCardIds = emptySet()
+ editor.edit("")
+ return card.id
+ }
+
+ fun beginCardEdit(cardId: String) {
+ if (!editable) return
+ val card = document.card(cardId) ?: return
+ if (card.type != CardType.TEXT) return
+ editingCardId = cardId
+ selectedCardIds = emptySet()
+ selectMode = false
+ editor.edit(card.text)
+ }
+
+ fun updateEditingCardText(text: String) {
+ val id = editingCardId ?: return
+ editor.edit(text)
+ mutate { doc -> doc.copy(cards = doc.cards.map { if (it.id == id) it.copy(text = text) else it }) }
+ }
+
+ fun endCardEdit() {
+ editingCardId = null
+ }
+
+ fun dragCard(cardId: String, dx: Int, dy: Int, boardWidth: Int, boardHeight: Int) {
+ if (!editable) return
+ if (document.card(cardId) == null) return
+ document = document.copy(
+ cards = document.cards.map { card ->
+ if (card.id != cardId) {
+ card
+ } else {
+ card.copy(
+ x = (card.x + dx).coerceIn(0, (boardWidth - card.w).coerceAtLeast(0)),
+ y = (card.y + dy).coerceIn(0, (boardHeight - card.h).coerceAtLeast(0)),
+ )
+ }
+ },
+ )
+ isCardDocument = true
+ }
+
+ fun endDrag(): Boolean {
+ return persist()
+ }
+
+ fun setScope(scope: NoteScope) {
+ if (selectedId == null) return
+ mutate { it.copy(scope = scope) }
+ }
+
+ fun startCollaboration() {
+ if (selectedId == null) return
+ mutate { it.copy(collaborationActive = true) }
+ }
+
+ fun bundleSelectedCards(): Boolean {
+ if (!editable) return false
+ val bundle = document.bundleSelected(selectedCardIds) ?: return false
+ val sourceId = selectedId ?: return false
+ if (!store.updateNote(sourceId, NoteDocumentJson.encode(bundle.remaining))) return false
+ val created = store.addNote(bundle.title)
+ store.updateNote(created.id, NoteDocumentJson.encode(bundle.document))
+ select(created.id)
+ return true
+ }
+
+ private fun cardNumber(id: String): Int = id.removePrefix("card-").toIntOrNull() ?: 0
+
+ private fun mutate(transform: (NoteDocument) -> NoteDocument) {
+ document = transform(document)
+ isCardDocument = true
+ persist()
+ }
+
+ private fun persist(): Boolean {
+ val id = selectedId ?: return false
+ if (!isCardDocument) return false
+ return store.updateNote(id, NoteDocumentJson.encode(document))
+ }
}
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 c22c458..18ea42d 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
@@ -8,10 +8,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Card
+import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -25,24 +27,14 @@ fun NotesScreen(
) {
Row(modifier = Modifier.fillMaxSize().padding(12.dp)) {
Column(modifier = Modifier.weight(1f)) {
- Text("Notes", fontSize = 17.sp)
+ Text("Note", 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") }
- }
- }
+ NoteListCard(viewModel, note)
}
}
}
@@ -52,3 +44,40 @@ fun NotesScreen(
}
}
}
+
+@Composable
+private fun NoteListCard(viewModel: NoteEditorViewModel, note: NoteEntry) {
+ val document = NoteDocumentJson.decode(note.body)
+ Card(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ elevation = 1.dp,
+ backgroundColor = if (note.id == viewModel.selectedId) {
+ MaterialTheme.colors.primary.copy(alpha = 0.08f)
+ } else {
+ MaterialTheme.colors.surface
+ },
+ ) {
+ Column(modifier = Modifier.padding(10.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ ScopeBadge(scope = document?.scope ?: NoteScope.SELF, modifier = Modifier.padding(end = 6.dp))
+ if (document?.collaborationActive == true) CollabBadge()
+ }
+ Text(note.title, fontSize = 14.sp)
+ Text(
+ text = previewText(note, document),
+ fontSize = 11.sp,
+ maxLines = 1,
+ )
+ TextButton(onClick = { viewModel.select(note.id) }) { Text("Open") }
+ }
+ }
+}
+
+private fun previewText(note: NoteEntry, document: NoteDocument?): String =
+ if (document == null) {
+ note.body.lineSequence().firstOrNull { it.isNotBlank() } ?: "(empty)"
+ } else {
+ val firstText = document.cards
+ .firstNotNullOfOrNull { card -> card.text.lineSequence().firstOrNull { it.isNotBlank() } }
+ if (firstText == null) "カード ${document.cards.size}枚" else "カード ${document.cards.size}枚 · $firstText"
+ }
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/pie/PieMenu.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/pie/PieMenu.kt
new file mode 100644
index 0000000..145f318
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/pie/PieMenu.kt
@@ -0,0 +1,199 @@
+package jp.orgflow.ui.pie
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+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.graphics.drawscope.Stroke
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import jp.orgflow.ui.model.GroupDirectory
+import jp.orgflow.ui.model.GroupType
+import jp.orgflow.ui.model.SetFilter
+import jp.orgflow.ui.model.SetFilterState
+import kotlin.math.PI
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.roundToInt
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+private data class PieEntry(
+ val label: String,
+ val filter: SetFilter,
+ val nextStage: PieStage? = null,
+)
+
+private sealed interface PieStage {
+ data object Root : PieStage
+ data class Groups(val type: GroupType) : PieStage
+}
+
+private val pieDiameter = 280.dp
+
+@Composable
+fun PieMenu(filter: SetFilterState, modifier: Modifier = Modifier) {
+ var open by remember { mutableStateOf(false) }
+ var stage by remember { mutableStateOf<PieStage>(PieStage.Root) }
+ Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
+ PieTrigger(filter = filter, onClick = {
+ stage = PieStage.Root
+ open = true
+ })
+ }
+ if (open) {
+ Dialog(onDismissRequest = { open = false }) {
+ Surface(shape = CircleShape, color = MaterialTheme.colors.surface, elevation = 8.dp) {
+ PieRadial(
+ stage = stage,
+ filter = filter,
+ onPick = { entry ->
+ filter.select(entry.filter)
+ val next = entry.nextStage
+ if (next == null) open = false else stage = next
+ },
+ onCenter = {
+ when (stage) {
+ PieStage.Root -> open = false
+ is PieStage.Groups -> stage = PieStage.Root
+ }
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PieTrigger(filter: SetFilterState, onClick: () -> Unit) {
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .background(MaterialTheme.colors.primary.copy(alpha = 0.12f), CircleShape)
+ .clickable(onClick = onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = filter.label(),
+ fontSize = 10.sp,
+ color = MaterialTheme.colors.primary,
+ maxLines = 2,
+ )
+ }
+}
+
+@Composable
+private fun PieRadial(
+ stage: PieStage,
+ filter: SetFilterState,
+ onPick: (PieEntry) -> Unit,
+ onCenter: () -> Unit,
+) {
+ val entries = remember(stage) { pieEntries(stage) }
+ val onSurface = MaterialTheme.colors.onSurface
+ val primary = MaterialTheme.colors.primary
+ val surface = MaterialTheme.colors.surface
+ Box(modifier = Modifier.size(pieDiameter), contentAlignment = Alignment.Center) {
+ Canvas(
+ modifier = Modifier
+ .size(pieDiameter)
+ .pointerInput(entries) {
+ detectTapGestures { offset ->
+ val cx = size.width / 2f
+ val cy = size.height / 2f
+ val dx = offset.x - cx
+ val dy = offset.y - cy
+ val radius = sqrt(dx * dx + dy * dy)
+ val outer = size.width / 2f
+ val inner = outer * 0.3f
+ if (radius <= inner) {
+ onCenter()
+ } else if (radius <= outer && entries.isNotEmpty()) {
+ val sweep = 360f / entries.size
+ val degrees = ((atan2(dy, dx) * 180f / PI.toFloat()) + 450f) % 360f
+ val index = (degrees / sweep).toInt().coerceIn(0, entries.size - 1)
+ onPick(entries[index])
+ }
+ }
+ },
+ ) {
+ val sweep = 360f / entries.size
+ entries.forEachIndexed { index, entry ->
+ val start = -90f + index * sweep
+ drawArc(
+ color = if (entry.filter == filter.current) primary.copy(alpha = 0.45f) else onSurface.copy(alpha = 0.08f),
+ startAngle = start,
+ sweepAngle = sweep,
+ useCenter = true,
+ )
+ drawArc(
+ color = onSurface.copy(alpha = 0.25f),
+ startAngle = start,
+ sweepAngle = sweep,
+ useCenter = false,
+ style = Stroke(width = 1.dp.toPx()),
+ )
+ }
+ val innerRadius = this.size.minDimension / 2f * 0.3f
+ drawCircle(color = surface, radius = innerRadius)
+ drawCircle(color = onSurface.copy(alpha = 0.25f), radius = innerRadius, style = Stroke(width = 1.dp.toPx()))
+ }
+ entries.forEachIndexed { index, entry ->
+ val sweep = 360f / entries.size
+ val mid = (-90f + (index + 0.5f) * sweep) * PI.toFloat() / 180f
+ val labelRadius = (pieDiameter.value / 2f) * 0.64f
+ Text(
+ text = entry.label,
+ fontSize = 11.sp,
+ maxLines = 1,
+ color = if (entry.filter == filter.current) primary else MaterialTheme.colors.onSurface,
+ modifier = Modifier.offset {
+ IntOffset(
+ (cos(mid) * labelRadius * density).roundToInt(),
+ (sin(mid) * labelRadius * density).roundToInt(),
+ )
+ },
+ )
+ }
+ Text(
+ text = when (stage) {
+ PieStage.Root -> "閉じる"
+ is PieStage.Groups -> "戻る"
+ },
+ fontSize = 11.sp,
+ color = primary,
+ modifier = Modifier.clickable(onClick = onCenter),
+ )
+ }
+}
+
+private fun pieEntries(stage: PieStage): List<PieEntry> = when (stage) {
+ PieStage.Root -> listOf(
+ PieEntry(GroupType.WHOLE.label, SetFilter.Whole),
+ PieEntry(GroupType.CLASS.label, SetFilter.Type(GroupType.CLASS), PieStage.Groups(GroupType.CLASS)),
+ PieEntry(GroupType.CLUB.label, SetFilter.Type(GroupType.CLUB), PieStage.Groups(GroupType.CLUB)),
+ PieEntry(GroupType.COMMITTEE.label, SetFilter.Type(GroupType.COMMITTEE), PieStage.Groups(GroupType.COMMITTEE)),
+ PieEntry(GroupType.PERSONAL.label, SetFilter.Self),
+ )
+ is PieStage.Groups -> GroupDirectory.groupsOf(stage.type).map { group ->
+ PieEntry(group.name, SetFilter.Group(group.gid))
+ }
+}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/runtime/OrgFlowRuntime.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/runtime/OrgFlowRuntime.kt
index d0b49e1..af7258b 100644
--- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/runtime/OrgFlowRuntime.kt
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/runtime/OrgFlowRuntime.kt
@@ -3,6 +3,8 @@ package jp.orgflow.ui.runtime
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
+import jp.orgflow.transport.mesh.WebRtcMeshTransport
+import jp.orgflow.transport.signaling.SignalingEnvelope
import jp.orgflow.ui.component.UiWaterline
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -51,6 +53,8 @@ data class ConfigSnapshot(
val configDir: String = "",
val fields: List<UiConfigField> = emptyList(),
val lastSavedAtMs: Long = 0,
+ val signalingEndpoint: String = "",
+ val iceServers: List<String> = emptyList(),
)
interface DistributionRuntime {
@@ -67,6 +71,10 @@ interface ConfigRuntime {
val config: StateFlow<ConfigSnapshot>
fun update(fieldKey: String, value: Double)
+
+ fun updateEndpoint(endpoint: String)
+
+ fun updateIceServers(servers: List<String>)
}
object UnavailableDistributionRuntime : DistributionRuntime {
@@ -87,8 +95,34 @@ object UnavailableConfigRuntime : ConfigRuntime {
)
override fun update(fieldKey: String, value: Double) {}
+
+ override fun updateEndpoint(endpoint: String) {}
+
+ override fun updateIceServers(servers: List<String>) {}
+}
+
+class SignalingPeerRoster(private val selfPeerId: String) {
+
+ private val joined = LinkedHashSet<String>()
+
+ val peers: List<String>
+ get() = joined.toList()
+
+ fun onEnvelope(envelope: SignalingEnvelope) {
+ when (envelope.type) {
+ WebRtcMeshTransport.TYPE_PEER_JOINED -> if (envelope.senderPeerId != selfPeerId) joined.add(envelope.senderPeerId)
+ WebRtcMeshTransport.TYPE_PEER_LEFT -> joined.remove(envelope.senderPeerId)
+ else -> Unit
+ }
+ }
+
+ fun clear() {
+ joined.clear()
+ }
}
+expect fun provideRuntime(): OrgFlowRuntime
+
data class OrgFlowRuntime(
val distribution: DistributionRuntime,
val config: ConfigRuntime,
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
index 1ebb309..4b730f7 100644
--- 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
@@ -26,6 +26,17 @@ data class CaptureEntry(
val capturedAtMs: Long,
)
+data class JoinableRoom(
+ val id: String,
+ val name: String,
+ val memberCount: Int = 0,
+)
+
+data class Affiliation(
+ val name: String,
+ val colorArgb: Long,
+)
+
class OrgFlowAppStore {
var activities: List<CaptureEntry> by mutableStateOf(emptyList())
private set
@@ -39,6 +50,18 @@ class OrgFlowAppStore {
private set
var notifiedEventIds: Set<String> by mutableStateOf(emptySet())
private set
+ var signalingEndpoint: String by mutableStateOf(DEFAULT_SIGNALING_ENDPOINT)
+ private set
+ var iceServers: List<String> by mutableStateOf(DEFAULT_ICE_SERVERS)
+ private set
+ var joinableRooms: List<JoinableRoom> by mutableStateOf(sampleJoinableRooms())
+ private set
+ var joinedRoomIds: Set<String> by mutableStateOf(emptySet())
+ private set
+ var affiliations: List<Affiliation> by mutableStateOf(emptyList())
+ private set
+ var noteEventLinks: Map<String, String> by mutableStateOf(emptyMap())
+ private set
fun addCapture(
templateType: String,
@@ -57,8 +80,8 @@ class OrgFlowAppStore {
return entry.id
}
- fun addNote(title: String): NoteEntry {
- val entry = NoteEntry("n${notes.size + 1}", title.ifBlank { "Untitled" }, "")
+ fun addNote(title: String, body: String = ""): NoteEntry {
+ val entry = NoteEntry("n${notes.size + 1}", title.ifBlank { "Untitled" }, body)
notes = notes + entry
return entry
}
@@ -69,22 +92,44 @@ class OrgFlowAppStore {
return true
}
+ fun linkNoteToEvent(noteId: String, eventId: String) {
+ noteEventLinks = noteEventLinks + (noteId to eventId)
+ }
+
+ fun linkedEventId(noteId: String): String? = noteEventLinks[noteId]
+
+ fun addAgendaItem(title: String, scheduledAt: LocalDateTime): AgendaItem {
+ val entry = AgendaItem("a${agendaItems.size + 1}", title, scheduledAt)
+ agendaItems = agendaItems + entry
+ return entry
+ }
+
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 {
+ fun addCalendarEvent(
+ title: String,
+ date: LocalDate,
+ time: LocalTime? = null,
+ linkedNoteId: String? = null,
+ ): CalendarEvent {
val entry = CalendarEvent(
id = "cal-${calendarEvents.size + 1}",
title = title.ifBlank { "Untitled" },
date = date,
source = EventSource.Personal,
time = time,
+ linkedNoteId = linkedNoteId,
)
calendarEvents = calendarEvents + entry
return entry
}
+ fun joinRoom(id: String) {
+ joinedRoomIds = joinedRoomIds + id
+ }
+
fun markNotified(ids: Collection<String>) {
notifiedEventIds = notifiedEventIds + ids
}
@@ -93,12 +138,28 @@ class OrgFlowAppStore {
notificationSettings = settings
}
+ fun updateConnectionConfig(endpoint: String, servers: List<String>) {
+ val trimmedEndpoint = endpoint.trim()
+ if (trimmedEndpoint.isNotBlank()) signalingEndpoint = trimmedEndpoint
+ val cleanedServers = servers.map { it.trim() }.filter { it.isNotBlank() }
+ if (cleanedServers.isNotEmpty()) iceServers = cleanedServers
+ }
+
companion object {
+ const val DEFAULT_SIGNALING_ENDPOINT = "ws://192.168.2.114:8091/ws"
+ val DEFAULT_ICE_SERVERS = listOf("stun:stun.l.google.com:19302")
+
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 sampleJoinableRooms(): List<JoinableRoom> = listOf(
+ JoinableRoom("ws-classroom", "Classroom", 4),
+ JoinableRoom("ws-club", "Club activity", 3),
+ JoinableRoom("ws-committee", "Committee", 2),
+ )
}
}
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 78990d1..2fb2725 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
@@ -19,9 +19,13 @@ import androidx.compose.ui.Alignment
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 WorkspaceScreen(viewModel: WorkspaceViewModel = remember { WorkspaceViewModel() }) {
+fun WorkspaceScreen(
+ store: OrgFlowAppStore,
+ viewModel: WorkspaceViewModel = remember { WorkspaceViewModel() },
+) {
var showAddRoom by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize().padding(12.dp)) {
Text("Workspace", fontSize = 17.sp)
@@ -45,7 +49,7 @@ fun WorkspaceScreen(viewModel: WorkspaceViewModel = remember { WorkspaceViewMode
Text("${member.displayName} — ${member.role}", fontSize = 12.sp)
}
WorkspaceQrInviteCard(
- payload = viewModel.invitePayload("ws://192.168.2.114:8091/ws"),
+ payload = viewModel.invitePayload(store.signalingEndpoint),
)
}
if (showAddRoom) {
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
index f8fc370..19ce5d6 100644
--- a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/AppStoreFlowTest.kt
+++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/AppStoreFlowTest.kt
@@ -3,10 +3,13 @@ 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.capture.PostTarget
import jp.orgflow.ui.home.HomeViewModel
import jp.orgflow.ui.notes.NoteEditorViewModel
import jp.orgflow.ui.store.OrgFlowAppStore
+import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.LocalTime
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -83,4 +86,103 @@ class AppStoreFlowTest {
assertFalse(agenda.overdue(now).any { it.id == "a1" })
assertEquals(2, agenda.forDate(LocalDateTime(2026, 8, 27, 0, 0)).size)
}
+
+ @Test
+ fun postTargetNoteCreatesNoteOnly() {
+ val store = OrgFlowAppStore()
+ val capture = CaptureViewModel(store, today = { LocalDate.parse("2026-08-27") })
+ capture.updateField(FiveW1HField.WHAT, "observed X")
+ capture.updateField(FiveW1HField.WHEN, "2026-08-27 14:30")
+ assertTrue(capture.submit(PostTarget.Note))
+
+ assertEquals(1, store.activities.size)
+ assertEquals(1, store.notes.size)
+ assertEquals("observed X", store.notes.single().body)
+ assertEquals(emptyList(), store.calendarEvents)
+ assertEquals(PostTarget.Note, capture.lastResult?.target)
+ assertEquals(store.notes.single().id, capture.lastResult?.noteId)
+ }
+
+ @Test
+ fun postTargetCalendarCreatesEventOnly() {
+ val store = OrgFlowAppStore()
+ val capture = CaptureViewModel(store, today = { LocalDate.parse("2026-08-27") })
+ capture.updateField(FiveW1HField.WHAT, "observed X")
+ capture.updateField(FiveW1HField.WHEN, "2026-08-27 14:30")
+ assertTrue(capture.submit(PostTarget.Calendar))
+
+ assertEquals(1, store.activities.size)
+ assertEquals(1, store.calendarEvents.size)
+ val event = store.calendarEvents.single()
+ assertEquals("observed X", event.title)
+ assertEquals(LocalDate.parse("2026-08-27"), event.date)
+ assertEquals(LocalTime(14, 30), event.time)
+ assertEquals(emptyList(), store.notes)
+ assertEquals(event.id, capture.lastResult?.eventId)
+ }
+
+ @Test
+ fun postTargetBothCreatesLinkedPair() {
+ val store = OrgFlowAppStore()
+ val capture = CaptureViewModel(store, today = { LocalDate.parse("2026-08-27") })
+ capture.updateField(FiveW1HField.WHAT, "observed X")
+ capture.updateField(FiveW1HField.WHEN, "tomorrow 9:00")
+ assertTrue(capture.submit(PostTarget.Both))
+
+ assertEquals(1, store.notes.size)
+ assertEquals(1, store.calendarEvents.size)
+ val note = store.notes.single()
+ val event = store.calendarEvents.single()
+ assertEquals("observed X", note.body)
+ assertEquals("observed X", event.title)
+ assertEquals(LocalDate.parse("2026-08-28"), event.date)
+ assertEquals(note.id, event.linkedNoteId)
+ assertEquals(event.id, store.linkedEventId(note.id))
+ assertEquals(mapOf(note.id to event.id), store.noteEventLinks)
+ }
+
+ @Test
+ fun calendarTargetRequiresParseableWhen() {
+ val store = OrgFlowAppStore()
+ val capture = CaptureViewModel(store, today = { LocalDate.parse("2026-08-27") })
+ capture.updateField(FiveW1HField.WHAT, "someday task")
+ capture.updateField(FiveW1HField.WHEN, "someday")
+ assertFalse(capture.submit(PostTarget.Calendar))
+ assertFalse(capture.submit(PostTarget.Both))
+ assertTrue(store.activities.isEmpty())
+ assertTrue(store.notes.isEmpty())
+ assertTrue(store.calendarEvents.isEmpty())
+ assertTrue(capture.submit(PostTarget.Note))
+ assertEquals(1, store.activities.size)
+ }
+
+ @Test
+ fun homeDueSoonListsAgendaAndEventsNearestFirst() {
+ val store = OrgFlowAppStore()
+ store.addCalendarEvent("lab visit", LocalDate.parse("2026-08-28"), LocalTime(10, 0))
+ store.addAgendaItem("room cleanup", LocalDateTime(2026, 8, 27, 9, 0))
+ val home = HomeViewModel(store, now = { LocalDateTime(2026, 8, 27, 8, 0) })
+
+ val items = home.dueSoonItems()
+ assertEquals(
+ listOf("room cleanup", "Team sync", "Write experiment report", "Pack rehearsal", "lab visit"),
+ items.map { it.title },
+ )
+ assertEquals(listOf("agenda-a4", "agenda-a1", "agenda-a2", "agenda-a3", "cal-1"), items.map { it.id })
+ assertEquals(0, items.first().daysUntil)
+ assertEquals(1, items.last().daysUntil)
+ assertEquals(listOf<LocalDate>(LocalDate.parse("2026-08-27"), LocalDate.parse("2026-08-28")), home.weekDays().take(2))
+ assertEquals(listOf("room cleanup", "Team sync", "Write experiment report"), home.entriesOn(LocalDate.parse("2026-08-27")).map { it.title })
+ }
+
+ @Test
+ fun joinRoomRecordsMembership() {
+ val store = OrgFlowAppStore()
+ val home = HomeViewModel(store)
+ assertTrue(store.joinableRooms.isNotEmpty())
+ val room = store.joinableRooms.first()
+ assertTrue(room.id !in home.joinedRoomIds)
+ home.joinRoom(room.id)
+ assertEquals(setOf(room.id), store.joinedRoomIds)
+ }
}
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
index 2550a7e..bc03df9 100644
--- a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/CalendarTest.kt
+++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/CalendarTest.kt
@@ -95,6 +95,36 @@ class CalendarTest {
}
@Test
+ fun agendaDueSoonNotifiesOncePerId() {
+ val store = OrgFlowAppStore()
+ val notifier = RecordingNotifier()
+ val vm = CalendarViewModel(store, notifier = notifier, now = { LocalDateTime(2026, 8, 27, 8, 45) })
+ store.addAgendaItem("room cleanup", LocalDateTime(2026, 8, 27, 9, 0))
+
+ val due = vm.checkDueNotifications()
+ assertEquals(listOf("room cleanup"), due.map { it.title })
+ assertEquals(listOf("room cleanup"), notifier.titles)
+ assertTrue(vm.checkDueNotifications().isEmpty())
+ assertEquals(setOf("agenda-a4"), store.notifiedEventIds)
+ }
+
+ @Test
+ fun calendarScreenMergesTasksAndTogglePersists() {
+ val store = OrgFlowAppStore()
+ val vm = CalendarViewModel(store, now = { LocalDateTime(2026, 8, 27, 8, 45) })
+ assertEquals(listOf("a1", "a2", "a3"), vm.tasks.map { it.id })
+ assertEquals(3, vm.openTaskCount())
+ assertEquals(listOf("a1", "a2"), vm.tasksOn(LocalDate.parse("2026-08-27")).map { it.id })
+
+ store.addAgendaItem("past task", LocalDateTime(2026, 8, 27, 7, 0))
+ assertEquals(listOf("a4"), vm.overdueTasks().map { it.id })
+ vm.toggleTaskDone("a4")
+ assertTrue(store.agendaItems.single { it.id == "a4" }.done)
+ assertTrue(vm.overdueTasks().isEmpty())
+ assertEquals(3, vm.openTaskCount())
+ }
+
+ @Test
fun captureWhenFieldBecomesPersonalEvent() {
val store = OrgFlowAppStore()
val capture = CaptureViewModel(store)
diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/ConnectionConfigTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/ConnectionConfigTest.kt
new file mode 100644
index 0000000..eef1b6c
--- /dev/null
+++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/ConnectionConfigTest.kt
@@ -0,0 +1,89 @@
+package jp.orgflow.ui
+
+import jp.orgflow.ui.store.OrgFlowAppStore
+import jp.orgflow.transport.mesh.WebRtcMeshTransport
+import jp.orgflow.transport.qr.QrBootstrapEncoder
+import jp.orgflow.transport.signaling.SignalingEnvelope
+import jp.orgflow.ui.runtime.SignalingPeerRoster
+import jp.orgflow.ui.workspace.WorkspaceViewModel
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class ConnectionConfigTest {
+
+ @Test
+ fun storePersistsSignalingEndpointAndIceServers() {
+ val store = OrgFlowAppStore()
+ assertEquals(OrgFlowAppStore.DEFAULT_SIGNALING_ENDPOINT, store.signalingEndpoint)
+ assertEquals(OrgFlowAppStore.DEFAULT_ICE_SERVERS, store.iceServers)
+
+ store.updateConnectionConfig(
+ "ws://10.0.0.5:8091/ws",
+ listOf("stun:stun.example.com:3478", " ", "turn:turn.example.com:3478"),
+ )
+ assertEquals("ws://10.0.0.5:8091/ws", store.signalingEndpoint)
+ assertEquals(listOf("stun:stun.example.com:3478", "turn:turn.example.com:3478"), store.iceServers)
+ }
+
+ @Test
+ fun storeKeepsPreviousConfigWhenUpdateIsBlank() {
+ val store = OrgFlowAppStore()
+ store.updateConnectionConfig("ws://10.0.0.5:8091/ws", listOf("stun:stun.example.com:3478"))
+ store.updateConnectionConfig(" ", emptyList())
+ assertEquals("ws://10.0.0.5:8091/ws", store.signalingEndpoint)
+ assertEquals(listOf("stun:stun.example.com:3478"), store.iceServers)
+ }
+
+ @Test
+ fun workspaceInvitePayloadCarriesConfiguredEndpoint() {
+ val store = OrgFlowAppStore()
+ store.updateConnectionConfig("ws://10.0.0.5:8091/ws", emptyList())
+ val viewModel = WorkspaceViewModel()
+ viewModel.createRoom("study group")
+
+ val payload = viewModel.invitePayload(store.signalingEndpoint)
+ val decoded = QrBootstrapEncoder.decode(payload)
+ assertNotNull(decoded)
+ assertEquals("ws://10.0.0.5:8091/ws", decoded.signalingEndpoint)
+ assertEquals(viewModel.currentRoomId, decoded.workspaceId)
+ }
+
+ @Test
+ fun rosterTracksPeerJoinAndLeaveSignals() {
+ val roster = SignalingPeerRoster(selfPeerId = "self")
+ roster.onEnvelope(envelope(WebRtcMeshTransport.TYPE_PEER_JOINED, "alpha"))
+ roster.onEnvelope(envelope(WebRtcMeshTransport.TYPE_PEER_JOINED, "beta"))
+ roster.onEnvelope(envelope(WebRtcMeshTransport.TYPE_PEER_JOINED, "self"))
+ assertEquals(listOf("alpha", "beta"), roster.peers)
+
+ roster.onEnvelope(envelope(WebRtcMeshTransport.TYPE_PEER_LEFT, "alpha"))
+ assertEquals(listOf("beta"), roster.peers)
+
+ roster.onEnvelope(envelope(WebRtcMeshTransport.TYPE_PEER_LEFT, "unknown"))
+ assertEquals(listOf("beta"), roster.peers)
+ }
+
+ @Test
+ fun rosterIgnoresOtherSignalTypesAndDuplicates() {
+ val roster = SignalingPeerRoster(selfPeerId = "self")
+ roster.onEnvelope(envelope("offer", "alpha"))
+ assertTrue(roster.peers.isEmpty())
+
+ roster.onEnvelope(envelope(WebRtcMeshTransport.TYPE_PEER_JOINED, "alpha"))
+ roster.onEnvelope(envelope(WebRtcMeshTransport.TYPE_PEER_JOINED, "alpha"))
+ assertEquals(listOf("alpha"), roster.peers)
+
+ roster.clear()
+ assertTrue(roster.peers.isEmpty())
+ }
+
+ private fun envelope(type: String, sender: String): SignalingEnvelope = SignalingEnvelope(
+ type = type,
+ senderPeerId = sender,
+ nonce = 0L,
+ timestampMs = 0L,
+ payloadJson = "{}",
+ )
+}
diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/NoteCardBoardTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/NoteCardBoardTest.kt
new file mode 100644
index 0000000..ff428b9
--- /dev/null
+++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/NoteCardBoardTest.kt
@@ -0,0 +1,221 @@
+package jp.orgflow.ui
+
+import jp.orgflow.ui.notes.CardModel
+import jp.orgflow.ui.notes.CardType
+import jp.orgflow.ui.notes.NoteDocument
+import jp.orgflow.ui.notes.NoteDocumentJson
+import jp.orgflow.ui.notes.NoteEditorViewModel
+import jp.orgflow.ui.notes.NoteScope
+import jp.orgflow.ui.notes.bundleSelected
+import jp.orgflow.ui.store.OrgFlowAppStore
+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 NoteCardBoardTest {
+
+ private fun card(
+ id: String,
+ x: Int = 0,
+ y: Int = 0,
+ text: String = "",
+ color: String = "#FFF59D",
+ type: CardType = CardType.TEXT,
+ ) = CardModel(id = id, type = type, x = x, y = y, w = 120, h = 80, text = text, color = color)
+
+ @Test
+ fun cardJsonRoundTrip() {
+ val document = NoteDocument(
+ scope = NoteScope.CLASS,
+ collaborationActive = true,
+ cards = listOf(
+ card("card-1", x = 12, y = 34, text = "観察メモ\n\"引用\" \\ 改行\tタブ"),
+ card("card-2", x = 200, y = 80, text = "second", color = "#81D4FA"),
+ ),
+ )
+ val encoded = NoteDocumentJson.encode(document)
+ assertEquals(
+ "{\"v\":1,\"scope\":\"クラス\",\"collab\":true,\"cards\":[" +
+ "{\"id\":\"card-1\",\"type\":\"text\",\"x\":12,\"y\":34,\"w\":120,\"h\":80," +
+ "\"text\":\"観察メモ\\n\\\"引用\\\" \\\\ 改行\\tタブ\",\"color\":\"#FFF59D\"}," +
+ "{\"id\":\"card-2\",\"type\":\"text\",\"x\":200,\"y\":80,\"w\":120,\"h\":80," +
+ "\"text\":\"second\",\"color\":\"#81D4FA\"}]}",
+ encoded,
+ )
+ assertEquals(document, NoteDocumentJson.decode(encoded))
+ assertEquals(document, NoteDocumentJson.decode(NoteDocumentJson.encode(NoteDocumentJson.decode(encoded)!!)))
+ }
+
+ @Test
+ fun decodeIgnoresUnknownKeysAndTypes() {
+ val decoded = NoteDocumentJson.decode(
+ "{\"v\":1,\"scope\":\"部活動\",\"collab\":false,\"future\":123," +
+ "\"cards\":[{\"id\":\"c9\",\"type\":\"ink\",\"x\":1,\"y\":2,\"w\":3,\"h\":4,\"text\":\"t\",\"color\":\"#FFFFFF\",\"extra\":true}]}",
+ )
+ assertNotNull(decoded)
+ assertEquals(NoteScope.CLUB, decoded.scope)
+ assertEquals(CardType.INK, decoded.cards.single().type)
+ assertEquals(1, decoded.cards.single().x)
+ }
+
+ @Test
+ fun malformedAndPlainBodiesDecodeToNull() {
+ assertNull(NoteDocumentJson.decode("plain memo"))
+ assertNull(NoteDocumentJson.decode(""))
+ assertNull(NoteDocumentJson.decode("{\"cards\":["))
+ assertNull(NoteDocumentJson.decode("{\"scope\":\"自分\"}"))
+ }
+
+ @Test
+ fun plainBodyMigratesToSingleTextCard() {
+ val body = "* observation A\n- detail"
+ val document = NoteDocument.fromPlainBody(body)
+ assertEquals(NoteScope.SELF, document.scope)
+ assertFalse(document.collaborationActive)
+ val migrated = document.cards.single()
+ assertEquals("card-1", migrated.id)
+ assertEquals(CardType.TEXT, migrated.type)
+ assertEquals(body, migrated.text)
+ assertEquals(document, NoteDocumentJson.decode(NoteDocumentJson.encode(document)))
+ }
+
+ @Test
+ fun bundleSelectionMovesCardsAndKeepsRemaining() {
+ val document = NoteDocument(
+ scope = NoteScope.CLUB,
+ collaborationActive = true,
+ cards = listOf(
+ card("card-1", text = "-first"),
+ card("card-2", text = "second card"),
+ card("card-3", text = "third"),
+ ),
+ )
+ val bundle = assertNotNull(document.bundleSelected(setOf("card-2", "card-3")))
+ assertEquals("second card", bundle.title)
+ assertEquals(listOf("card-2", "card-3"), bundle.document.cards.map { it.id })
+ assertEquals(NoteScope.CLUB, bundle.document.scope)
+ assertFalse(bundle.document.collaborationActive)
+ assertEquals(listOf("card-1"), bundle.remaining.cards.map { it.id })
+ assertNull(document.bundleSelected(setOf("card-1")))
+ }
+
+ @Test
+ fun bundlingThroughViewModelUsesStore() {
+ val store = OrgFlowAppStore()
+ val viewModel = NoteEditorViewModel(store)
+ val sourceId = viewModel.addNote("探究ボード")
+ viewModel.toggleEditable()
+ val first = assertNotNull(viewModel.addCard())
+ val second = assertNotNull(viewModel.addCard())
+ val third = assertNotNull(viewModel.addCard())
+ viewModel.beginCardEdit(first)
+ viewModel.updateEditingCardText("観察A")
+ viewModel.endCardEdit()
+ viewModel.beginCardEdit(second)
+ viewModel.updateEditingCardText("観察B")
+ viewModel.endCardEdit()
+ viewModel.beginCardEdit(third)
+ viewModel.updateEditingCardText("課題C")
+ viewModel.endCardEdit()
+
+ viewModel.toggleCardSelection(first)
+ viewModel.toggleCardSelection(second)
+ viewModel.toggleCardSelection(third)
+ assertTrue(viewModel.bundleSelectedCards())
+
+ assertEquals(2, store.notes.size)
+ val bundled = store.notes.last()
+ assertEquals("観察A", bundled.title)
+ assertEquals(viewModel.selectedId, bundled.id)
+ val bundledDoc = assertNotNull(NoteDocumentJson.decode(bundled.body))
+ assertEquals(listOf("観察A", "観察B", "課題C"), bundledDoc.cards.map { it.text })
+ assertFalse(bundledDoc.collaborationActive)
+
+ val sourceDoc = assertNotNull(NoteDocumentJson.decode(store.notes.single { it.id == sourceId }.body))
+ assertEquals(listOf("card-1"), sourceDoc.cards.map { it.id })
+ }
+
+ @Test
+ fun bundlingRequiresTwoOrMoreSelected() {
+ val store = OrgFlowAppStore()
+ val viewModel = NoteEditorViewModel(store)
+ viewModel.addNote("board")
+ viewModel.toggleEditable()
+ viewModel.addCard()
+ viewModel.addCard()
+ viewModel.toggleCardSelection("card-2")
+ assertFalse(viewModel.bundleSelectedCards())
+ assertEquals(1, store.notes.size)
+
+ viewModel.toggleCardSelection("card-1")
+ assertTrue(viewModel.bundleSelectedCards())
+ assertEquals(2, store.notes.size)
+ assertEquals("クリップノート", store.notes.last().title)
+ }
+
+ @Test
+ fun scopeAndCollaborationPersistInBody() {
+ val store = OrgFlowAppStore()
+ val viewModel = NoteEditorViewModel(store)
+ viewModel.addNote("note")
+ viewModel.setScope(NoteScope.COMMITTEE)
+ viewModel.startCollaboration()
+ val decoded = assertNotNull(NoteDocumentJson.decode(store.notes.single().body))
+ assertEquals(NoteScope.COMMITTEE, decoded.scope)
+ assertTrue(decoded.collaborationActive)
+
+ val reopened = NoteEditorViewModel(store)
+ reopened.select(store.notes.single().id)
+ assertEquals(NoteScope.COMMITTEE, reopened.document.scope)
+ assertTrue(reopened.document.collaborationActive)
+ }
+
+ @Test
+ fun plainNoteSaveStaysPlainTextAndReopenMigrates() {
+ val store = OrgFlowAppStore()
+ val viewModel = NoteEditorViewModel(store)
+ val id = viewModel.addNote("legacy")
+ viewModel.editor.edit("* observation A")
+ assertTrue(viewModel.saveCurrent())
+ assertEquals("* observation A", store.notes.single().body)
+
+ val reopened = NoteEditorViewModel(store)
+ reopened.select(id)
+ assertEquals("* observation A", reopened.editor.text)
+ assertEquals("* observation A", reopened.document.cards.single().text)
+ }
+
+ @Test
+ fun dragCardClampsWithinBoard() {
+ val store = OrgFlowAppStore()
+ val viewModel = NoteEditorViewModel(store)
+ viewModel.addNote("board")
+ viewModel.toggleEditable()
+ val cardId = assertNotNull(viewModel.addCard())
+ viewModel.endCardEdit()
+
+ val before = assertNotNull(viewModel.document.card(cardId))
+ viewModel.dragCard(cardId, dx = 50, dy = 40, boardWidth = 800, boardHeight = 600)
+ val moved = assertNotNull(viewModel.document.card(cardId))
+ assertEquals(before.x + 50, moved.x)
+ assertEquals(before.y + 40, moved.y)
+
+ viewModel.dragCard(cardId, dx = 5000, dy = 5000, boardWidth = 800, boardHeight = 600)
+ val clamped = assertNotNull(viewModel.document.card(cardId))
+ assertEquals(800 - clamped.w, clamped.x)
+ assertEquals(600 - clamped.h, clamped.y)
+
+ viewModel.dragCard(cardId, dx = -5000, dy = -5000, boardWidth = 800, boardHeight = 600)
+ val floor = assertNotNull(viewModel.document.card(cardId))
+ assertEquals(0, floor.x)
+ assertEquals(0, floor.y)
+
+ assertTrue(viewModel.endDrag())
+ val persisted = assertNotNull(NoteDocumentJson.decode(store.notes.single().body))
+ assertEquals(0, assertNotNull(persisted.card(cardId)).x)
+ assertEquals(0, assertNotNull(persisted.card(cardId)).y)
+ }
+}
diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/GroupModelTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/GroupModelTest.kt
new file mode 100644
index 0000000..3240460
--- /dev/null
+++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/GroupModelTest.kt
@@ -0,0 +1,84 @@
+package jp.orgflow.ui.model
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class GroupModelTest {
+
+ @Test
+ fun membershipStatusMapsToSquareIcons() {
+ assertEquals(MembershipIcon.CIRCLE, MembershipStatus.JOIN.toIcon())
+ assertEquals(MembershipIcon.FULL_MOON, MembershipStatus.AUTO_JOIN.toIcon())
+ assertEquals(MembershipIcon.HALF_MOON, MembershipStatus.VIEW_ONLY.toIcon())
+ assertEquals(MembershipIcon.KEY, MembershipStatus.HIDDEN.toIcon())
+ assertEquals(MembershipIcon.CIRCLE, iconFor(null))
+ }
+
+ @Test
+ fun closedGroupShowsKeyIcon() {
+ val closed = GroupDirectory.groupOf("g-committee-culture")!!
+ assertEquals(MembershipIcon.KEY, closed.iconForVisibility())
+ val open = GroupDirectory.groupOf("g-club-art")!!
+ assertNull(open.iconForVisibility())
+ }
+
+ @Test
+ fun directoryMembershipLookups() {
+ val self = GroupDirectory.currentUser
+ assertEquals(MembershipStatus.AUTO_JOIN, GroupDirectory.statusOf(self.uid, "g-class-2a"))
+ assertEquals(MembershipStatus.JOIN, GroupDirectory.statusOf(self.uid, "g-club-art"))
+ assertEquals(MembershipStatus.VIEW_ONLY, GroupDirectory.statusOf(self.uid, "g-committee-culture"))
+ assertNull(GroupDirectory.statusOf(self.uid, "g-class-2b"))
+ assertEquals(listOf("美術部", "軽音部"), GroupDirectory.groupsOf(GroupType.CLUB).map { it.name })
+ }
+
+ @Test
+ fun dynamicRuleResolvesGradeAndClassFromSchoolId() {
+ val user = User("u9", "テスト", "2-A-15")
+ assertEquals("2", DynamicMembership.gradeOf(user))
+ assertEquals("A", DynamicMembership.classOf(user))
+ val grade = DynamicGroup("g-dyn-grade2", MembershipRule.Grade("2"))
+ assertEquals(MembershipStatus.AUTO_JOIN, DynamicMembership.resolve(grade, user)?.status)
+ assertEquals(user.uid, DynamicMembership.resolve(grade, user)?.uid)
+ val classroom = DynamicGroup("g-dyn-2a", MembershipRule.Classroom("2", "A"))
+ assertEquals(MembershipStatus.AUTO_JOIN, DynamicMembership.resolve(classroom, user)?.status)
+ assertNull(DynamicMembership.resolve(DynamicGroup("x", MembershipRule.Classroom("2", "B")), user))
+ assertNull(DynamicMembership.resolve(DynamicGroup("x", MembershipRule.Grade("3")), user))
+ val all = DynamicGroup("g-dyn-all", MembershipRule.All)
+ assertEquals(MembershipStatus.AUTO_JOIN, DynamicMembership.resolve(all, user)?.status)
+ }
+
+ @Test
+ fun dynamicRuleIgnoresMalformedSchoolId() {
+ val user = User("u9", "テスト", "")
+ assertNull(DynamicMembership.gradeOf(user))
+ assertNull(DynamicMembership.classOf(user))
+ assertNull(DynamicMembership.resolve(DynamicGroup("x", MembershipRule.Grade("2")), user))
+ val all = DynamicGroup("x", MembershipRule.All)
+ assertEquals(MembershipStatus.AUTO_JOIN, DynamicMembership.resolve(all, user)?.status)
+ }
+
+ @Test
+ fun dynamicMembershipsDerivedForDirectoryUser() {
+ val self = GroupDirectory.currentUser
+ val gids = GroupDirectory.dynamicMemberships(self).map { it.gid }
+ assertTrue("g-dyn-grade2" in gids)
+ assertTrue("g-dyn-class-2a" in gids)
+ val other = User("u-x", "外部", "3-C-01")
+ assertTrue(GroupDirectory.dynamicMemberships(other).isEmpty())
+ }
+
+ @Test
+ fun colorOfIsStablePerGid() {
+ val first = GroupColor.indexFor("g-class-2a")
+ assertEquals(first, GroupColor.indexFor("g-class-2a"))
+ assertEquals(first, GroupColor.indexFor("g-class-2a"))
+ assertTrue(first in 0 until GroupColor.paletteSize)
+ assertEquals(GroupColor.colorOf("g-club-art"), GroupColor.colorOf("g-club-art"))
+ GroupDirectory.groups.forEach { group ->
+ assertTrue(GroupColor.indexFor(group.gid) in 0 until GroupColor.paletteSize)
+ }
+ }
+}
diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/SetFilterStateTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/SetFilterStateTest.kt
new file mode 100644
index 0000000..affc951
--- /dev/null
+++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/SetFilterStateTest.kt
@@ -0,0 +1,64 @@
+package jp.orgflow.ui.model
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class SetFilterStateTest {
+
+ @Test
+ fun initialFilterIsWhole() {
+ val state = SetFilterState()
+ assertEquals(SetFilter.Whole, state.current)
+ assertEquals("全体", state.label())
+ assertEquals(listOf("全体"), state.segments().map { it.label })
+ assertNull(state.groupId())
+ }
+
+ @Test
+ fun groupFilterBuildsBreadcrumbPath() {
+ val state = SetFilterState()
+ state.select(SetFilter.Group("g-class-2a"))
+ assertEquals(listOf("全体", "クラス", "クラス2-A"), state.segments().map { it.label })
+ assertEquals("g-class-2a", state.groupId())
+ assertEquals("クラス2-A", state.label())
+ }
+
+ @Test
+ fun breadcrumbSegmentClickPopsFilter() {
+ val state = SetFilterState()
+ state.select(SetFilter.Group("g-class-2a"))
+ val segments = state.segments()
+ state.select(segments[1].filter)
+ assertEquals(SetFilter.Type(GroupType.CLASS), state.current)
+ state.select(state.segments()[0].filter)
+ assertEquals(SetFilter.Whole, state.current)
+ }
+
+ @Test
+ fun selfAndTypeFilters() {
+ val state = SetFilterState()
+ state.select(SetFilter.Self)
+ assertEquals(listOf("全体", "自分"), state.segments().map { it.label })
+ assertEquals("自分", state.label())
+ state.select(SetFilter.Type(GroupType.CLUB))
+ assertEquals(listOf("全体", "部活動"), state.segments().map { it.label })
+ }
+
+ @Test
+ fun unknownGroupFallsBackToGid() {
+ val state = SetFilterState()
+ state.select(SetFilter.Group("g-unknown"))
+ assertEquals("g-unknown", state.label())
+ assertEquals(listOf("全体", "", "g-unknown"), state.segments().map { it.label })
+ }
+
+ @Test
+ fun resetReturnsToWhole() {
+ val state = SetFilterState()
+ state.select(SetFilter.Group("g-club-art"))
+ state.reset()
+ assertEquals(SetFilter.Whole, state.current)
+ assertNull(state.groupId())
+ }
+}
diff --git a/modules/orgflow-ui/src/jvmMain/kotlin/jp/orgflow/ui/runtime/ProvideRuntime.jvm.kt b/modules/orgflow-ui/src/jvmMain/kotlin/jp/orgflow/ui/runtime/ProvideRuntime.jvm.kt
new file mode 100644
index 0000000..75af22f
--- /dev/null
+++ b/modules/orgflow-ui/src/jvmMain/kotlin/jp/orgflow/ui/runtime/ProvideRuntime.jvm.kt
@@ -0,0 +1,3 @@
+package jp.orgflow.ui.runtime
+
+actual fun provideRuntime(): OrgFlowRuntime = OrgFlowRuntime.UNAVAILABLE
diff --git a/modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/runtime/WasmDistributionRuntime.kt b/modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/runtime/WasmDistributionRuntime.kt
new file mode 100644
index 0000000..1f8812f
--- /dev/null
+++ b/modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/runtime/WasmDistributionRuntime.kt
@@ -0,0 +1,183 @@
+@file:OptIn(kotlin.time.ExperimentalTime::class)
+
+package jp.orgflow.ui.runtime
+
+import jp.orgflow.ui.store.OrgFlowAppStore
+import jp.orgflow.transport.mesh.WebRtcMeshTransport
+import jp.orgflow.transport.signaling.SignalingEnvelope
+import jp.orgflow.transport.signaling.WsSignalingClient
+import jp.orgflow.ui.component.UiWaterline
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlin.time.Clock
+
+class WasmDistributionRuntime(
+ private val selfPeerId: String = "wasm-${Clock.System.now().toEpochMilliseconds() % 100000}",
+ private var signalingEndpoint: String = OrgFlowAppStore.DEFAULT_SIGNALING_ENDPOINT,
+ private val roomId: String = "default",
+ private var iceServers: List<String> = OrgFlowAppStore.DEFAULT_ICE_SERVERS,
+) : DistributionRuntime, ConfigRuntime {
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private val roster = SignalingPeerRoster(selfPeerId)
+
+ private var signaling: WsSignalingClient? = null
+ private var polledClient: WsSignalingClient? = null
+ private var collector: Job? = null
+ private var knownConnected = false
+ private var started = false
+ private var lastEvent = "idle"
+
+ private val _snapshot = MutableStateFlow(DistributionSnapshot(selfPeerId = selfPeerId))
+ override val snapshot: StateFlow<DistributionSnapshot> = _snapshot
+
+ private val _config = MutableStateFlow(
+ ConfigSnapshot(signalingEndpoint = signalingEndpoint, iceServers = iceServers),
+ )
+ override val config: StateFlow<ConfigSnapshot> = _config
+
+ override suspend fun start() {
+ if (started) return
+ started = true
+ connectSignaling(signalingEndpoint)
+ scope.launch {
+ while (scope.isActive) {
+ val client = signaling
+ if (client != null) {
+ if (client !== polledClient) {
+ polledClient = client
+ runCatching { client.connect() }
+ }
+ runCatching { client.poll() }
+ if (client.connected != knownConnected) {
+ knownConnected = client.connected
+ lastEvent = if (client.connected) {
+ "connected to signaling $signalingEndpoint room=$roomId"
+ } else {
+ "signaling disconnected"
+ }
+ refreshSnapshot()
+ }
+ }
+ delay(POLL_INTERVAL_MS)
+ }
+ }
+ lastEvent = "connecting to signaling $signalingEndpoint room=$roomId"
+ refreshSnapshot()
+ }
+
+ override fun stop() {
+ started = false
+ val client = signaling
+ signaling = null
+ polledClient = null
+ collector?.cancel()
+ collector = null
+ if (client != null) scope.launch { runCatching { client.close() } }
+ lastEvent = "stopped"
+ refreshSnapshot()
+ }
+
+ override fun startDistribution() {
+ lastEvent = "no datachannel transfer on wasm yet (signaling roster only)"
+ refreshSnapshot()
+ }
+
+ override fun update(fieldKey: String, value: Double) {
+ lastEvent = "fsmp numeric config not applied on wasm: $fieldKey=$value"
+ refreshConfigSnapshot(nowMs())
+ }
+
+ override fun updateEndpoint(endpoint: String) {
+ val next = endpoint.trim()
+ if (next.isBlank()) return
+ signalingEndpoint = next
+ lastEvent = "signaling endpoint updated: $next"
+ if (started) connectSignaling(next)
+ refreshConfigSnapshot(nowMs())
+ refreshSnapshot()
+ }
+
+ override fun updateIceServers(servers: List<String>) {
+ val cleaned = servers.map { it.trim() }.filter { it.isNotBlank() }
+ if (cleaned.isEmpty()) return
+ iceServers = cleaned
+ lastEvent = "ice servers updated: ${cleaned.size} entries (applied with datachannel transfer)"
+ refreshConfigSnapshot(nowMs())
+ }
+
+ private fun connectSignaling(endpoint: String) {
+ val previous = signaling
+ val client = WsSignalingClient(endpoint = endpoint, selfPeerId = selfPeerId, roomId = roomId)
+ signaling = client
+ polledClient = null
+ knownConnected = false
+ roster.clear()
+ collector?.cancel()
+ collector = scope.launch {
+ client.incoming.collect { envelope -> onEnvelope(envelope) }
+ }
+ if (previous != null) scope.launch { runCatching { previous.close() } }
+ }
+
+ private fun onEnvelope(envelope: SignalingEnvelope) {
+ val before = roster.peers.size
+ roster.onEnvelope(envelope)
+ when (envelope.type) {
+ WebRtcMeshTransport.TYPE_PEER_JOINED -> if (roster.peers.size != before) {
+ lastEvent = "peer ${envelope.senderPeerId} joined room $roomId"
+ }
+ WebRtcMeshTransport.TYPE_PEER_LEFT -> if (roster.peers.size != before) {
+ lastEvent = "peer ${envelope.senderPeerId} left room $roomId"
+ }
+ }
+ refreshSnapshot()
+ }
+
+ private fun refreshSnapshot() {
+ val client = signaling
+ val connected = client?.connected == true
+ _snapshot.value = DistributionSnapshot(
+ selfPeerId = selfPeerId,
+ signalingEndpoint = signalingEndpoint,
+ connected = connected,
+ configDir = "browser (in-session)",
+ engineReady = connected,
+ peers = roster.peers.map { peerId ->
+ UiPeerSync(
+ peerId = peerId,
+ connected = connected,
+ ratio = 0.0,
+ level = UiWaterline.PREVIEW,
+ )
+ },
+ lastEvent = lastEvent,
+ )
+ }
+
+ private fun refreshConfigSnapshot(savedAtMs: Long) {
+ _config.value = ConfigSnapshot(
+ configDir = "browser (in-session)",
+ lastSavedAtMs = savedAtMs,
+ signalingEndpoint = signalingEndpoint,
+ iceServers = iceServers,
+ )
+ }
+
+ private fun nowMs(): Long = Clock.System.now().toEpochMilliseconds()
+
+ private companion object {
+ const val POLL_INTERVAL_MS = 100L
+ }
+}
+
+private val wasmRuntime = WasmDistributionRuntime()
+
+actual fun provideRuntime(): OrgFlowRuntime = OrgFlowRuntime(wasmRuntime, wasmRuntime)