diff options
| author | Charlie Root <root@dangofactory.local> | 2026-09-24 06:33:39 +0900 |
|---|---|---|
| committer | Charlie Root <root@dangofactory.local> | 2026-09-24 06:33:39 +0900 |
| commit | 1ccf22fc6eaee764fc179c8d491073441af3f5b3 (patch) | |
| tree | 3d4b15d42b16d798dc782f3e73296cea3588e269 /modules | |
| parent | 035b0e86c3a02500be1d07c6d1fc43679fabb4c0 (diff) | |
| download | kukuri-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')
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() + } + } + } } } } |
