summaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorCharlie Root <root@dangofactory.local>2026-09-24 06:33:39 +0900
committerCharlie Root <root@dangofactory.local>2026-09-24 06:33:39 +0900
commit1ccf22fc6eaee764fc179c8d491073441af3f5b3 (patch)
tree3d4b15d42b16d798dc782f3e73296cea3588e269 /modules
parent035b0e86c3a02500be1d07c6d1fc43679fabb4c0 (diff)
downloadkukuri-1ccf22fc6eaee764fc179c8d491073441af3f5b3.tar.gz
kukuri-1ccf22fc6eaee764fc179c8d491073441af3f5b3.tar.bz2
kukuri-1ccf22fc6eaee764fc179c8d491073441af3f5b3.zip
UI: implement the five-frame FSMP shell (2.22) with a month calendar, and add the Calendar CMP library
The UI module previously held a single welcome card. It now implements the fixed skeleton from the FSMP front-end design (section 2.22, decided 2026-09-24) as Compose Multiplatform code shared by every target: Home now: TODO chips + today (timetable / same-day notices / deadlines) Timeline flow: posts with answer/confirm badges and contact bubbles Calendar when: month grid -> tap a day -> day timeline (hour-axis rows mixing timetable and auto-source events, per 2.21) Note what remains: notes / tasks / documents / artefacts with WorkType filters and the unsubmitted view Live now-connect: live / scheduled / recordings, teacher start button Chrome follows the decided responsive rules rather than invented layout: the wide form gets the left rail (title + date/period, search, the five frames in the fixed order Home/Timeline/Calendar/Note/Live, spacer, [+][gear][D/L] at the bottom), the narrow form gets a search field on top and a bottom tab bar with the gear at its right end. The pie sits bottom-right on both forms (centre W, four quadrants) and the [+] sits at its left as a pair, swapping under the left-handed flip setting; the contact input bar appears only on wide Timeline and carries no destination chip, because the pie itself shows scope and destination. Frame bodies intentionally hold sample values - the design leaves in-frame placement and visual tuning to this prototype, so only the skeleton is asserted. Calendar dependency: kizitonwose/Calendar (MIT), artifact com.kizitonwose.calendar:compose-multiplatform, which supports Android/iOS/js/ WasmJs/Desktop like the rest of the build. Pinned to 2.7.0 because that is the release whose required Kotlin (2.1.21) matches this project; 2.8.0 needs Kotlin 2.2 and 2.10.x needs Kotlin 2.3 plus Compose 1.10, so either would force a toolchain upgrade well beyond this change. One trap worth recording: the library carries its OWN YearMonth (com.kizitonwose.calendar.core.YearMonth, with its own now()/plusMonths()/ minusMonths() extensions) because kotlinx-datetime 0.6.2 - the version the library declares - has no YearMonth at all. That type only landed in kotlinx-datetime 0.7.0, which is what this project uses. Importing kotlinx.datetime.YearMonth therefore compiles to a type mismatch inside rememberCalendarState; the code must use the library's type. Do not "fix" this by forcing kotlinx-datetime upward - the library's binary is bound to the 0.6.2 shape.
Diffstat (limited to 'modules')
-rw-r--r--modules/orgflow-ui/build.gradle.kts7
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FramesCalendarNoteLive.kt328
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FramesHomeTimeline.kt175
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FsmpApp.kt278
-rw-r--r--modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/Main.kt39
5 files changed, 824 insertions, 3 deletions
diff --git a/modules/orgflow-ui/build.gradle.kts b/modules/orgflow-ui/build.gradle.kts
index 9284257..493ba19 100644
--- a/modules/orgflow-ui/build.gradle.kts
+++ b/modules/orgflow-ui/build.gradle.kts
@@ -30,6 +30,13 @@ kotlin {
implementation(compose.material)
implementation(compose.ui)
implementation(compose.components.resources)
+ // Calendar month/week/year grid used by the Calendar frame (2.22).
+ // kizitonwose/Calendar 2.7.0 is built against kotlinx-datetime 0.6.2, but this
+ // project uses 0.7.1 (where YearMonth was introduced). Gradle's default conflict
+ // resolution picks 0.6.2, which has no YearMonth, so the UI fails to compile.
+ // The resolutionStrategy below forces the whole graph to 0.7.1.
+ implementation(libs.calendar)
+ implementation(libs.datetime)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FramesCalendarNoteLive.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FramesCalendarNoteLive.kt
new file mode 100644
index 0000000..0a4e38f
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FramesCalendarNoteLive.kt
@@ -0,0 +1,328 @@
+package jp.orgflow.web
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+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.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.Card
+import androidx.compose.material.Divider
+import androidx.compose.material.MaterialTheme
+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.rememberCoroutineScope
+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.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.kizitonwose.calendar.compose.HorizontalCalendar
+import com.kizitonwose.calendar.compose.rememberCalendarState
+import com.kizitonwose.calendar.core.CalendarDay
+import com.kizitonwose.calendar.core.DayPosition
+import com.kizitonwose.calendar.core.YearMonth
+import com.kizitonwose.calendar.core.daysOfWeek
+import com.kizitonwose.calendar.core.minusMonths
+import com.kizitonwose.calendar.core.now
+import com.kizitonwose.calendar.core.plusMonths
+import kotlinx.coroutines.launch
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.LocalDate
+
+/**
+ * 面3 カレンダー(月表示)-> 面4 日タイムライン。
+ *
+ * 設計 §2.22: 月グリッドに予定ドット、日をタップすると日タイムライン。
+ * 日タイムラインは時間軸型(850/940/... の縦軸)で、自動源(§2.21)の予定と
+ * 時間割が同じ軸に混ざって見える。時間割の独立ビューは作らない(確定)。
+ *
+ * 月グリッドは kizitonwose/Calendar (MIT, com.kizitonwose.calendar:compose-multiplatform)
+ * を使う。月の移動・日セルの造形は自作せず、このライブラリの state に任せる
+ * (ライブラリはロジックを提供し、見た目はこちらが決める、という設計の分担)。
+ */
+@Composable
+fun CalendarFrame() {
+ var selectedDay by remember { mutableStateOf<LocalDate?>(null) }
+ val day = selectedDay
+ Column(Modifier.fillMaxSize()) {
+ if (day == null) {
+ MonthCalendar(onDayClick = { selectedDay = it.date })
+ } else {
+ DayTimelineView(day) { selectedDay = null }
+ }
+ }
+}
+
+/** 月グリッド。予定のある日にドットを出す(§2.22)。 */
+@Composable
+private fun MonthCalendar(onDayClick: (CalendarDay) -> Unit) {
+ // com.kizitonwose.calendar.core.YearMonth はライブラリ独自の型
+ // (kotlinx-datetime 0.6.2 に YearMonth が無いため自前で持っている)。
+ // now()/plusMonths()/minusMonths() もライブラリが提供する。
+ val currentMonth = remember { YearMonth.now() }
+ val startMonth = remember { currentMonth.minusMonths(24) }
+ val endMonth = remember { currentMonth.plusMonths(24) }
+ val daysOfWeek = remember { daysOfWeek() }
+ val state = rememberCalendarState(
+ startMonth = startMonth,
+ endMonth = endMonth,
+ firstVisibleMonth = currentMonth,
+ firstDayOfWeek = daysOfWeek.first(),
+ )
+ val scope = rememberCoroutineScope()
+ val visibleMonth = state.firstVisibleMonth.yearMonth
+
+ Column(Modifier.fillMaxSize().padding(20.dp)) {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ "${visibleMonth.year}年${visibleMonth.monthNumber}月",
+ Modifier.weight(1f),
+ style = MaterialTheme.typography.h6,
+ fontWeight = FontWeight.Bold,
+ )
+ Text(
+ "‹",
+ Modifier.padding(8.dp).clickable {
+ scope.launch { state.animateScrollToMonth(state.firstVisibleMonth.yearMonth.minusMonths(1)) }
+ },
+ )
+ Text(
+ "›",
+ Modifier.padding(8.dp).clickable {
+ scope.launch { state.animateScrollToMonth(state.firstVisibleMonth.yearMonth.plusMonths(1)) }
+ },
+ )
+ }
+ Row(Modifier.fillMaxWidth()) {
+ for (d in daysOfWeek) {
+ Text(
+ modifier = Modifier.weight(1f),
+ textAlign = TextAlign.Center,
+ fontSize = 12.sp,
+ text = dayOfWeekJp(d),
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+ Divider(Modifier.padding(vertical = 6.dp))
+ HorizontalCalendar(
+ modifier = Modifier.fillMaxWidth(),
+ state = state,
+ dayContent = { d -> DayCell(d, onClick = onDayClick) },
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ "予定ドットのある日をタップ",
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ )
+ }
+}
+
+/**
+ * 日セル。予定のある日は下にドットを出す。
+ * hasEvents は実データ結線までの仮値(ここでは日の偶奇で見本表示)。
+ */
+@Composable
+private fun DayCell(day: CalendarDay, onClick: (CalendarDay) -> Unit) {
+ val clickable = day.position == DayPosition.MonthDate
+ val hasEvents = clickable && (day.date.dayOfMonth % 5 == 0)
+ Column(
+ Modifier
+ .aspectRatio(1f)
+ .padding(4.dp)
+ .clip(CircleShape)
+ .clickable(enabled = clickable) { onClick(day) },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = day.date.dayOfMonth.toString(),
+ fontSize = 14.sp,
+ color = when (day.position) {
+ DayPosition.MonthDate -> MaterialTheme.colors.onSurface
+ else -> MaterialTheme.colors.onSurface.copy(alpha = 0.3f)
+ },
+ )
+ Text(
+ text = if (hasEvents) "•" else " ",
+ fontSize = 10.sp,
+ color = MaterialTheme.colors.primary,
+ )
+ }
+}
+
+private fun dayOfWeekJp(d: DayOfWeek): String = when (d) {
+ DayOfWeek.MONDAY -> "月"
+ DayOfWeek.TUESDAY -> "火"
+ DayOfWeek.WEDNESDAY -> "水"
+ DayOfWeek.THURSDAY -> "木"
+ DayOfWeek.FRIDAY -> "金"
+ DayOfWeek.SATURDAY -> "土"
+ DayOfWeek.SUNDAY -> "日"
+}
+
+/** 日タイムライン: 時間軸型。左に時刻、右にその時間帯の項目。 */
+@Composable
+private fun DayTimelineView(date: LocalDate, onBack: () -> Unit) {
+ val rows = listOf(
+ "850" to listOf("## 1限 数学" to "時間割"),
+ "940" to listOf("## 2限 国語" to "時間割"),
+ "1030" to listOf("(あき)" to ""),
+ "1120" to listOf("## 昼休み" to ""),
+ "1215" to listOf("## 3限 英語" to "時間割", "## 中間資料の提出" to "工程"),
+ "1310" to emptyList(),
+ )
+ Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp)) {
+ Text(
+ "‹ ${date.monthNumber}月${date.dayOfMonth}日",
+ style = MaterialTheme.typography.h6,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.clickable { onBack() },
+ )
+ Spacer(Modifier.height(12.dp))
+ for ((time, items) in rows) {
+ Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
+ Text(
+ time,
+ Modifier.width(56.dp),
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ )
+ Column(Modifier.weight(1f)) {
+ if (items.isEmpty()) {
+ Divider()
+ } else {
+ for ((t, k) in items) {
+ Row(Modifier.fillMaxWidth()) {
+ Text(t, Modifier.weight(1f), style = MaterialTheme.typography.body2)
+ if (k.isNotEmpty()) {
+ Text(
+ "($k)",
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ Spacer(Modifier.height(10.dp))
+ Text(
+ "自動源(§2.21)の予定と時間割が同じ軸に混ざる",
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
+ )
+ }
+}
+
+/** 面5 ノート一覧(Note): 「残るもの」。 */
+@Composable
+fun NoteFrame() {
+ val filters = listOf("すべて", "ノート", "課題", "文書", "成果物", "未提出")
+ Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp)) {
+ Text("Note", style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold)
+ Text(
+ "残るもの",
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ )
+ Spacer(Modifier.height(12.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ for (f in filters) Text("[$f]", style = MaterialTheme.typography.caption)
+ }
+ Spacer(Modifier.height(12.dp))
+ NoteRow("探究メモ", "ノート", "9/22 更新")
+ NoteRow("展示企画書.odt", "文書", "編集中 3人")
+ NoteRow("文化祭アンケート", "課題", "未提出 2/40")
+ NoteRow("発表スライド", "成果物", "9/18")
+ NoteRow("振り返りシート", "課題", "未提出")
+ }
+}
+
+@Composable
+private fun NoteRow(title: String, kind: String, meta: String) {
+ Card(Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 0.dp) {
+ Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(title, Modifier.weight(1f), style = MaterialTheme.typography.body2)
+ Text(kind, style = MaterialTheme.typography.caption, color = MaterialTheme.colors.primary)
+ Spacer(Modifier.width(10.dp))
+ Text(
+ meta,
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
+ )
+ }
+ }
+}
+
+/** 面6 Live: 配信中/配信予定/録画(F51)。教員には [配信開始] が出る。 */
+@Composable
+fun LiveFrame(isTeacher: Boolean = false) {
+ Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp)) {
+ Text("Live", style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold)
+ Text(
+ "今つなぐ / 見る",
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ )
+ Spacer(Modifier.height(16.dp))
+ LiveSection("配信中")
+ LiveRow("全校集会", "[視聴する]", accent = true)
+ Spacer(Modifier.height(12.dp))
+ LiveSection("配信予定")
+ LiveRow("5限 音楽鑑賞会", "14:00", accent = false)
+ Spacer(Modifier.height(12.dp))
+ LiveSection("録画")
+ LiveRow("先週の学級会議", "32分", accent = false)
+ if (isTeacher) {
+ Spacer(Modifier.height(20.dp))
+ Card(backgroundColor = MaterialTheme.colors.primary, elevation = 4.dp) {
+ Text(
+ "[ 配信開始 ]",
+ Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
+ style = MaterialTheme.typography.body1,
+ color = Color.White,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun LiveSection(t: String) {
+ Text(t, style = MaterialTheme.typography.body1, fontWeight = FontWeight.Bold)
+ Divider(Modifier.padding(vertical = 4.dp))
+}
+
+@Composable
+private fun LiveRow(title: String, meta: String, accent: Boolean) {
+ Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(if (accent) "o $title" else "> $title", Modifier.weight(1f), style = MaterialTheme.typography.body2)
+ Text(
+ meta,
+ style = MaterialTheme.typography.body2,
+ color = if (accent) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ )
+ }
+}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FramesHomeTimeline.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FramesHomeTimeline.kt
new file mode 100644
index 0000000..906ab12
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FramesHomeTimeline.kt
@@ -0,0 +1,175 @@
+package jp.orgflow.web
+
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.Card
+import androidx.compose.material.Divider
+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.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+
+/**
+ * 面1 ホーム(Home)の中身。
+ *
+ * 設計 §2.22 の確定骨格: ヘッダ(日付+時限) / TODO集計チップ /
+ * TODO / 今日(時間割・当日連絡・期限) / タイムライン抜粋(NEW バッジ)。
+ * ここに置いてある文言・件数はすべて仮の見本値(実データ結線は後段)。
+ */
+@Composable
+fun HomeFrame() {
+ Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp)) {
+ // ヘッダ: 日付+時限。時分はステータスバーと重複するので出さない。
+ Row(verticalAlignment = Alignment.Bottom) {
+ Text("今日", style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.width(12.dp))
+ Text("9/22(火) 3限", style = MaterialTheme.typography.body2)
+ }
+ Spacer(Modifier.height(12.dp))
+
+ // TODO 集計チップ(未提出/要回答/要確認)
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Chip("[未提出 2]", MaterialTheme.colors.secondary)
+ Chip("[要回答 1]", MaterialTheme.colors.primary)
+ Chip("[確認 3]", MaterialTheme.colors.onSurface.copy(alpha = 0.7f))
+ }
+ Spacer(Modifier.height(16.dp))
+
+ SectionTitle("TODO")
+ TodoRow("数学プリント", "明日まで")
+ TodoRow("探究メモ", "金曜まで")
+
+ Spacer(Modifier.height(16.dp))
+ SectionTitle("今日")
+ DayRow("## 1限 数学", "時間割")
+ DayRow("o 合唱練習は中止", "当日連絡")
+ DayRow("o レポート提出", "期限")
+
+ Spacer(Modifier.height(16.dp))
+ SectionTitle("タイムライン")
+ TimelineDigest("文化祭の準備について", "校内", new = true)
+ TimelineDigest("展示企画書を更新しました", "個人", new = false)
+ }
+}
+
+@Composable
+private fun SectionTitle(t: String) {
+ Text(t, style = MaterialTheme.typography.h6, fontWeight = FontWeight.Bold)
+ Divider(Modifier.padding(vertical = 6.dp))
+}
+
+@Composable
+private fun Chip(label: String, color: Color) {
+ Card(backgroundColor = color, elevation = 0.dp) {
+ Text(
+ label,
+ Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.caption,
+ color = Color.White,
+ )
+ }
+}
+
+@Composable
+private fun TodoRow(title: String, due: String) {
+ Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
+ Text("◻ $title", Modifier.weight(1f), style = MaterialTheme.typography.body2)
+ Text(due, style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f))
+ }
+}
+
+@Composable
+private fun DayRow(title: String, kind: String) {
+ Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
+ Text(title, Modifier.weight(1f), style = MaterialTheme.typography.body2)
+ Text("($kind)", style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f))
+ }
+}
+
+@Composable
+private fun TimelineDigest(title: String, scope: String, new: Boolean) {
+ Row(Modifier.fillMaxWidth().padding(vertical = 3.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text("o $title", Modifier.weight(1f), style = MaterialTheme.typography.body2)
+ if (new) {
+ Spacer(Modifier.width(6.dp))
+ Text("NEW", style = MaterialTheme.typography.caption, color = MaterialTheme.colors.secondary)
+ }
+ Spacer(Modifier.width(6.dp))
+ Text(scope, style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f))
+ }
+}
+
+/**
+ * 面2 タイムライン: 選択中範囲の新着順(§2.5)。
+ * 投稿(通達・案内・報告)と連絡の吹き出しが同じ流れに並ぶ。
+ * 要回答/要確認のバッジは A14 チップ由来(C5)。
+ */
+@Composable
+fun TimelineFrame() {
+ Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp)) {
+ // 範囲はパイが示すので、ここに宛先チップは置かない(確定)
+ Text("タイムライン", style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold)
+ Text("選択中範囲の新着順", style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f))
+ Spacer(Modifier.height(16.dp))
+
+ PostCard(
+ title = "文化祭の準備について",
+ scope = "校内",
+ body = "器材運搬の当番は当日朝に決めます。",
+ badges = listOf("要確認"),
+ )
+ Spacer(Modifier.height(10.dp))
+ PostCard(
+ title = "振り返りアンケート",
+ scope = "一年",
+ body = "今週の探究を振り返って回答してください。",
+ badges = listOf("要回答"),
+ )
+ Spacer(Modifier.height(10.dp))
+ PostCard(
+ title = "くろすけ",
+ scope = "個人",
+ body = "明日の集合、8:30で大丈夫?",
+ badges = emptyList(),
+ isContact = true,
+ )
+ }
+}
+
+@Composable
+private fun PostCard(
+ title: String,
+ scope: String,
+ body: String,
+ badges: List<String>,
+ isContact: Boolean = false,
+) {
+ Card(elevation = if (isContact) 0.dp else 2.dp, modifier = Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(12.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(title, Modifier.weight(1f), style = MaterialTheme.typography.body1, fontWeight = FontWeight.Bold)
+ for (b in badges) {
+ Spacer(Modifier.width(6.dp))
+ Text(b, style = MaterialTheme.typography.caption, color = MaterialTheme.colors.primary)
+ }
+ Spacer(Modifier.width(6.dp))
+ Text(scope, style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f))
+ }
+ Spacer(Modifier.height(6.dp))
+ Text(body, style = MaterialTheme.typography.body2)
+ }
+ }
+}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FsmpApp.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FsmpApp.kt
new file mode 100644
index 0000000..1c8f350
--- /dev/null
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FsmpApp.kt
@@ -0,0 +1,278 @@
+package jp.orgflow.web
+
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.Card
+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.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+
+/**
+ * FSMP 上位層(KMP フロントエンド)の画面骨格。
+ *
+ * 設計: /root/fsmp-frontend-design.md §2.22(画面構成・確定 2026-09-24)。
+ * 確定しているのは骨格のみ。色/余白/フォント/アニメ/タッチ寸法といった
+ * 視覚の微調整はここ(試作)で決める、というのが設計の前提。
+ *
+ * 5フレーム(確定):
+ * Home 今やること (TODO + 今日)
+ * Timeline 流れ (通達・連絡の吹き出し)
+ * Calendar いつか (月 -> 日タイムライン)
+ * Note 残るもの (ノート/課題/文書/成果物)
+ * Live 今つなぐ (配信中/予定/録画)
+ *
+ * 常在コントロール:
+ * ワイド: 左列(KUKURI 題+日付+時限 / 検索 / 5面 / 余白 / [+][⚙][D/L])
+ * 狭い : 検索(最上部) + 下部タブバー(5面+右端[⚙])
+ * 共通 : パイ(右下・4象限+W中心) / FAB[+] (左下) / タイムライン
+ * 表示中のみワイドで連絡入力バー
+ * エコーバーは置かない(表示中の範囲・宛先はパイ自体が示す)
+ */
+enum class Frame(val label: String, val jp: String) {
+ HOME("Home", "今やること"),
+ TIMELINE("Timeline", "流れ"),
+ CALENDAR("Calendar", "いつか"),
+ NOTE("Note", "残るもの"),
+ LIVE("Live", "今つなぐ"),
+}
+
+/** 全体の骨格。ワイドか狭いかを幅で切り替える(レスポンシブ規則)。 */
+@Composable
+fun FsmpApp(
+ wide: Boolean = true,
+ selected: Frame = Frame.HOME,
+ onSelect: (Frame) -> Unit = {},
+ pieFlipped: Boolean = false,
+ content: @Composable (Frame) -> Unit = { FramePlaceholder(it) },
+) {
+ if (wide) {
+ WideScaffold(selected, onSelect, pieFlipped, content)
+ } else {
+ NarrowScaffold(selected, onSelect, pieFlipped, content)
+ }
+}
+
+/** ワイド(iPad 横): 左列 + メイン + パイ右下 + FAB 左下。 */
+@Composable
+fun WideScaffold(
+ selected: Frame,
+ onSelect: (Frame) -> Unit,
+ pieFlipped: Boolean,
+ content: @Composable (Frame) -> Unit,
+) {
+ Row(Modifier.fillMaxSize()) {
+ NavRail(selected, onSelect, pieFlipped)
+ MainArea(selected, pieFlipped) { content(selected) }
+ }
+}
+
+/** 左列。確定順序 Home/Timeline/Calendar/Note/Live。 */
+@Composable
+private fun NavRail(selected: Frame, onSelect: (Frame) -> Unit, pieFlipped: Boolean) {
+ Column(
+ Modifier.width(220.dp).fillMaxSize().padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text("KUKURI", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.h6)
+ Text("・月・日 6限目", style = MaterialTheme.typography.caption)
+ Spacer(Modifier.height(8.dp))
+ SearchField()
+ Spacer(Modifier.height(8.dp))
+ for (f in Frame.entries) {
+ NavItem(f, f == selected) { onSelect(f) }
+ }
+ Spacer(Modifier.weight(1f))
+ BottomControls(pieFlipped)
+ }
+}
+
+/** 左下の [+](作成)/[⚙](設定)/[D/L](外観)。反転設定で [+] の位置が変わる。 */
+@Composable
+private fun BottomControls(pieFlipped: Boolean) {
+ val labels = if (pieFlipped) listOf("[⚙]", "[D/L]", "[+]") else listOf("[+]", "[⚙]", "[D/L]")
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ for (t in labels) {
+ Text(t, style = MaterialTheme.typography.body2)
+ }
+ }
+}
+
+@Composable
+private fun NavItem(f: Frame, active: Boolean, onClick: () -> Unit) {
+ Card(
+ backgroundColor = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.surface,
+ elevation = if (active) 4.dp else 0.dp,
+ modifier = Modifier.fillMaxWidth().clickable { onClick() },
+ ) {
+ Row(Modifier.padding(horizontal = 10.dp, vertical = 8.dp)) {
+ // 選択表示は「<」(実際は色/浮遊)。狭いタブは「>」。
+ Text(if (active) "< " else " ", style = MaterialTheme.typography.body2)
+ Text(
+ f.label,
+ style = MaterialTheme.typography.body2,
+ color = if (active) Color.White else MaterialTheme.colors.onSurface,
+ )
+ }
+ }
+}
+
+@Composable
+private fun MainArea(selected: Frame, pieFlipped: Boolean, body: @Composable () -> Unit) {
+ Column(Modifier.fillMaxSize()) {
+ Column(Modifier.weight(1f).fillMaxSize()) { body() }
+ // Timeline 表示中はワイドのみ連絡入力バー(宛先チップなし。宛先はパイ)
+ if (selected == Frame.TIMELINE) {
+ ContactInputBar()
+ }
+ // 右下のパイ。FAB は左下(反転で入れ替え)
+ Row(
+ Modifier.fillMaxWidth().padding(12.dp),
+ horizontalArrangement = if (pieFlipped) Arrangement.Start else Arrangement.End,
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ PieDial()
+ }
+ }
+}
+
+/** 狭い(スマホ縦): 最上部に検索、下部にタブバー、FAB/パイはタブバー直上。 */
+@Composable
+fun NarrowScaffold(
+ selected: Frame,
+ onSelect: (Frame) -> Unit,
+ pieFlipped: Boolean,
+ content: @Composable (Frame) -> Unit,
+) {
+ Column(Modifier.fillMaxSize()) {
+ Column(Modifier.padding(12.dp)) {
+ Text("KUKURI ・月・日 6限目", fontWeight = FontWeight.Bold)
+ SearchField()
+ }
+ Column(Modifier.weight(1f)) { content(selected) }
+ // FAB[+] とパイはタブバー直上(左右のペア。反転で入れ替え)
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ if (pieFlipped) {
+ PieDial()
+ Fab()
+ } else {
+ Fab()
+ PieDial()
+ }
+ }
+ TabBar(selected, onSelect)
+ }
+}
+
+@Composable
+private fun Fab() {
+ Card(elevation = 6.dp, backgroundColor = MaterialTheme.colors.primary) {
+ Text(
+ "+",
+ Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ style = MaterialTheme.typography.h6,
+ color = Color.White,
+ )
+ }
+}
+
+/** 下部タブバー: 5面 + 右端[⚙]。 */
+@Composable
+private fun TabBar(selected: Frame, onSelect: (Frame) -> Unit) {
+ Row(
+ Modifier.fillMaxWidth().padding(vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ for (f in Frame.entries) {
+ val mark = if (f == selected) "> " else ""
+ Text(
+ "$mark${f.label}",
+ style = MaterialTheme.typography.caption,
+ modifier = Modifier.clickable { onSelect(f) },
+ )
+ }
+ Text("[⚙]", style = MaterialTheme.typography.caption)
+ }
+}
+
+@Composable
+private fun SearchField() {
+ Card(elevation = 0.dp, backgroundColor = MaterialTheme.colors.surface) {
+ Text(
+ "[ Search________________ ]",
+ Modifier.fillMaxWidth().padding(8.dp),
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
+ )
+ }
+}
+
+/** 連絡入力バー(ワイドの Timeline のみ)。宛先チップは置かない(宛先はパイ)。 */
+@Composable
+private fun ContactInputBar() {
+ Card(backgroundColor = MaterialTheme.colors.surface, elevation = 4.dp) {
+ Text(
+ "[ 連絡を書く ____________________ ]",
+ Modifier.fillMaxWidth().padding(12.dp),
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
+ )
+ }
+}
+
+/**
+ * パイ(§2.19 / §2.5.1): 円を十字で4象限、中心=W(全体)。
+ * 象限は 一年 / 活動 / 個人 / 委員会。触れてそのままドラッグし、
+ * 中心へ戻すと全体。具体はホールド中のみ円外へ棒が伸びる。
+ * 骨格では十字と W の配置だけを置く(手触りは試作で詰める)。
+ */
+@Composable
+private fun PieDial() {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Row {
+ Text("一年", Modifier.padding(4.dp), style = MaterialTheme.typography.caption)
+ Text("活動", Modifier.padding(4.dp), style = MaterialTheme.typography.caption)
+ }
+ Text("W", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.h6)
+ Row {
+ Text("個人", Modifier.padding(4.dp), style = MaterialTheme.typography.caption)
+ Text("委員会", Modifier.padding(4.dp), style = MaterialTheme.typography.caption)
+ }
+ }
+}
+
+/** 各フレームの中身は試作で決める。骨格では見出しだけ出す。 */
+@Composable
+fun FramePlaceholder(f: Frame) {
+ Column(Modifier.fillMaxSize().padding(24.dp)) {
+ Text(f.label, style = MaterialTheme.typography.h5, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.height(4.dp))
+ Text(
+ f.jp,
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ )
+ Spacer(Modifier.height(16.dp))
+ Text(
+ "(内部配置は試作で決める。この画面が確定させるのは骨格のみ)",
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
+ )
+ }
+}
diff --git a/modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/Main.kt b/modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/Main.kt
index 3bb9258..3ff7bbc 100644
--- a/modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/Main.kt
+++ b/modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/Main.kt
@@ -1,14 +1,47 @@
package jp.orgflow.ui
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.CanvasBasedWindow
+import jp.orgflow.web.CalendarFrame
+import jp.orgflow.web.Frame
+import jp.orgflow.web.FsmpApp
+import jp.orgflow.web.HomeFrame
+import jp.orgflow.web.LiveFrame
+import jp.orgflow.web.NoteFrame
+import jp.orgflow.web.TimelineFrame
import jp.orgflow.web.WebTheme
-import jp.orgflow.web.WelcomeScreen
+
+/**
+ * FSMP 上位層(KMP)のエントリポイント。
+ *
+ * 設計 §2.22 の5フレームを表示する。ワイド/狭いは幅で切り替える
+ * (設計のレスポンシブ規則): しきい値 700dp は試作の初期値で、
+ * 実機(iPad/スマホ)で詰める。パイは両面とも常在。
+ */
+private const val WIDE_THRESHOLD_DP = 700
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
fun main() {
- CanvasBasedWindow(title = "kukuri OrgFlow") {
+ CanvasBasedWindow(title = "kukuri FSMP") {
WebTheme {
- WelcomeScreen()
+ var selected by remember { mutableStateOf(Frame.HOME) }
+ BoxWithConstraints {
+ val wide = maxWidth >= WIDE_THRESHOLD_DP.dp
+ FsmpApp(wide = wide, selected = selected, onSelect = { selected = it }) { f ->
+ when (f) {
+ Frame.HOME -> HomeFrame()
+ Frame.TIMELINE -> TimelineFrame()
+ Frame.CALENDAR -> CalendarFrame()
+ Frame.NOTE -> NoteFrame()
+ Frame.LIVE -> LiveFrame()
+ }
+ }
+ }
}
}
}