summaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FramesCalendarNoteLive.kt552
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FramesHomeTimeline.kt157
-rw-r--r--modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/WebTheme.kt22
3 files changed, 536 insertions, 195 deletions
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
index da0d233..73d1a2d 100644
--- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FramesCalendarNoteLive.kt
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FramesCalendarNoteLive.kt
@@ -15,6 +15,7 @@ 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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
@@ -24,6 +25,7 @@ import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
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
@@ -37,7 +39,12 @@ 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.ContentHeightMode
import com.kizitonwose.calendar.compose.HorizontalCalendar
+import com.kizitonwose.calendar.compose.VerticalYearCalendar
+import com.kizitonwose.calendar.compose.yearcalendar.rememberYearCalendarState
+import com.kizitonwose.calendar.core.ExperimentalCalendarApi
+import com.kizitonwose.calendar.core.Year
import com.kizitonwose.calendar.compose.rememberCalendarState
import com.kizitonwose.calendar.core.CalendarDay
import com.kizitonwose.calendar.core.DayPosition
@@ -51,44 +58,129 @@ import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
/**
- * 面3 カレンダー(月表示)-> 面4 日タイムライン。
+ * 面3 カレンダー。3段のドリルダウン(2026-09-25 指示
+ * 「September 2026 から年カレンダーにもとべるように」)。
*
- * 設計 §2.22: 月グリッドに予定ドット、日をタップすると日タイムライン。
- * 日タイムラインは時間軸型(850/940/... の縦軸)で、自動源(§2.21)の予定と
- * 時間割が同じ軸に混ざって見える。時間割の独立ビューは作らない(確定)。
+ * 年表示 → 月表示 → 日表示
*
- * 月グリッドは kizitonwose/Calendar (MIT, com.kizitonwose.calendar:compose-multiplatform)
- * を使う。月の移動・日セルの造形は自作せず、このライブラリの state に任せる
- * (ライブラリはロジックを提供し、見た目はこちらが決める、という設計の分担)。
+ * 月見出しをタップすると年表示へ上がる。年表示で月をタップすると
+ * その月の月表示へ下りる。月表示で日をタップすると日表示(時間軸)。
+ * 日表示の左上の戻るで月表示へ戻る。
*
- * 2026-09-25 指示: 月グリッドの見た目を参照画像(Calendar Library)に合わせる。
- * - 月見出しは中央寄せ "September 2026"(英語月名 + 年)、左右にシェブロン
- * - 曜日行は大文字3字 "SUN MON TUE WED THU FRI SAT"
- * - 日セルは下辺に予定の色バー(セル幅の約55%)。複数予定は縦積み
- * - 格子は細い暗灰色の罫線
- * - 前月/翌月の日付は dim
- * - 下部中央にピル型 "Close" ボタン
- * 予定の色は種別(§2.5.1 のパイ4象限 = 一年/活動/個人/委員会)に対応させる。
+ * 3段にした理由: 予定は「いつか」を探すとき、まず年の俯瞰から
+ * 月を選び、月の中で日を選ぶ、という順に向かうことがある。
+ * 月表示だけだと、遠い月へは矢印を何度も押すことになる。
*/
@Composable
fun CalendarFrame() {
+ var mode by remember { mutableStateOf(CalMode.Month) }
var selectedDay by remember { mutableStateOf<LocalDate?>(null) }
+ // 年表示から下りるときの移動先。月表示の state を作り直さずに
+ // animateScrollToMonth で飛ばすために、次に表示すべき月を渡す。
+ var pendingMonth by remember { mutableStateOf<YearMonth?>(null) }
val today = remember { todayLocalDate() }
val day = selectedDay
- Column(Modifier.fillMaxSize()) {
- if (day == null) {
- MonthCalendar(today = today, selected = null, onDayClick = { selectedDay = it.date })
- } else {
- DayTimelineView(listOf(day)) { selectedDay = null }
+
+ Column(Modifier.fillMaxSize().background(GridGround)) {
+ when {
+ day != null -> DayTimelineView(listOf(day)) { selectedDay = null }
+ mode == CalMode.Year -> YearView(today = today) { ym ->
+ pendingMonth = ym
+ mode = CalMode.Month
+ }
+ else -> MonthCalendar(
+ today = today,
+ selected = null,
+ onDayClick = { selectedDay = it.date },
+ onTitleClick = { mode = CalMode.Year },
+ jumpToMonth = pendingMonth,
+ onJumpConsumed = { pendingMonth = null },
+ )
}
}
}
+/** カレンダーの表示段。 */
+private enum class CalMode { Year, Month }
+
+/**
+ * 年表示。1年を12ヶ月ぶん並べ、月をタップするとその月へ下りる。
+ * 今日の月は区別できるようにする。
+ */
+@OptIn(ExperimentalCalendarApi::class)
+@Composable
+private fun YearView(today: LocalDate, onMonthClick: (YearMonth) -> Unit) {
+ val currentYear = remember { Year(today.year) }
+ val state = rememberYearCalendarState(
+ startYear = Year(currentYear.value - 2),
+ endYear = Year(currentYear.value + 2),
+ firstVisibleYear = currentYear,
+ )
+ val daysOfWeek = remember { daysOfWeek() }
+ VerticalYearCalendar(
+ modifier = Modifier.fillMaxSize(),
+ state = state,
+ dayContent = { day ->
+ // 年表示では日をタップさせない(月の選択に集中する)
+ Box(Modifier.aspectRatio(1f).padding(1.dp)) {
+ Text(
+ day.date.dayOfMonth.toString(),
+ Modifier.align(Alignment.Center),
+ fontSize = 9.sp,
+ color = if (day.position == DayPosition.MonthDate) {
+ MaterialTheme.colors.onSurface.copy(alpha = 0.8f)
+ } else {
+ MaterialTheme.colors.onSurface.copy(alpha = 0.25f)
+ },
+ )
+ }
+ },
+ monthHeader = { month ->
+ Text(
+ "${month.yearMonth.monthNumber}月",
+ Modifier.fillMaxWidth().clickable { onMonthClick(month.yearMonth) },
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center,
+ color = if (month.yearMonth.year == today.year &&
+ month.yearMonth.monthNumber == today.monthNumber
+ ) Color.White else MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
+ )
+ },
+ yearHeader = { year ->
+ Text(
+ year.year.value.toString(),
+ Modifier.fillMaxWidth().padding(vertical = 8.dp),
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ color = Color.White,
+ )
+ },
+ )
+}
+
+/**
+ * Example 3 の色(sample/src/main/res/values/colors.xml より)。
+ * 値をそのまま使う。勝手に作らない。
+ */
+/**
+ * 月グリッドの色。値は WebTheme.kt が持つ(2026-09-25「色を背景と合わせろ」)。
+ *
+ * Example 3 の仕組み(セルを地色より一段明るくし、セル間の 1dp の隙間から
+ * 地色が透けて格子線に見える)はそのまま使い、色だけテーマの階調に写像した。
+ * ここで独自の色を直書きしない — 直書きするとカレンダーだけ浮く。
+ */
+private val GridGround = AppGround // 隙間から見える地(他のフレームと同じ地)
+private val GridCell = CalendarCellColor // セル
+private val GridHeader = CalendarHeaderColor // 見出し帯
+private val GridToday = CalendarTodayColor // 今日のセル
+
/**
* 予定の種別。§2.5.1 のパイ4象限に対応する。
* 参照画像の 5 色(teal/blue/red/brown/gray)をそのまま種別に割り当てる。
*/
-private enum class EventKind(val color: Color) {
+internal enum class EventKind(val color: Color) {
Year(Color(0xFF008080)), // 一年(teal)
Activity(Color(0xFF0000FF)), // 活動(blue)
Personal(Color(0xFFFF0000)), // 個人(red)
@@ -128,7 +220,14 @@ private fun dayOfWeekEn(ordinal: Int): String = when (ordinal) {
/** 月グリッド。予定のある日に色バーを出す(参照画像)。 */
@Composable
-private fun MonthCalendar(today: LocalDate, selected: LocalDate?, onDayClick: (CalendarDay) -> Unit) {
+private fun MonthCalendar(
+ today: LocalDate,
+ selected: LocalDate?,
+ onDayClick: (CalendarDay) -> Unit,
+ onTitleClick: () -> Unit = {},
+ jumpToMonth: YearMonth? = null,
+ onJumpConsumed: () -> Unit = {},
+) {
// 今日の月を得る。kotlinx-datetime の now() は kotlin.time.Clock を要求し、
// この構成の Wasm ではリンクできない(IrLinkageError)。
// また com.kizitonwose.calendar.core の YearMonth.Companion.now も同じ理由で
@@ -152,56 +251,86 @@ private fun MonthCalendar(today: LocalDate, selected: LocalDate?, onDayClick: (C
val scope = rememberCoroutineScope()
val visibleMonth = state.firstVisibleMonth.yearMonth
- Column(Modifier.fillMaxSize().padding(20.dp)) {
- // 見出し: 中央寄せ "September 2026"、左右にシェブロン(参照画像)
- Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ // 年表示から選ばれた月へ移動する。state の作り直しではなく
+ // animateScrollToMonth で飛ばす(月の範囲は前後24ヶ月あるので届く)。
+ LaunchedEffect(jumpToMonth) {
+ val target = jumpToMonth ?: return@LaunchedEffect
+ state.scrollToMonth(target)
+ onJumpConsumed()
+ }
+
+ Column(Modifier.fillMaxSize().background(GridGround)) {
+ // 見出し: Example 3 の SimpleCalendarTitle に合わせる
+ // height 40dp / fontSize 22sp / FontWeight.Medium /
+ // textAlign Center / 左右に矢印アイコン
+ // 地色は toolbar_color (#282828)
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .background(GridHeader)
+ .height(40.dp)
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
Text(
"‹",
- Modifier.padding(8.dp).clickable {
+ Modifier.clickable {
scope.launch { state.animateScrollToMonth(state.firstVisibleMonth.yearMonth.minusMonths(1)) }
- },
+ }.padding(horizontal = 6.dp),
fontSize = 20.sp,
+ color = Color.White,
)
+ // 見出しタップで年表示へ上がる(2026-09-25 指示)。
Text(
"${monthNameEn(visibleMonth.monthNumber)} ${visibleMonth.year}",
- Modifier.weight(1f),
+ Modifier.weight(1f).clickable { onTitleClick() },
textAlign = TextAlign.Center,
- style = MaterialTheme.typography.h6,
- fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Medium,
+ color = Color.White,
)
Text(
"›",
- Modifier.padding(8.dp).clickable {
+ Modifier.clickable {
scope.launch { state.animateScrollToMonth(state.firstVisibleMonth.yearMonth.plusMonths(1)) }
- },
+ }.padding(horizontal = 6.dp),
fontSize = 20.sp,
+ color = Color.White,
)
}
- // 曜日行: 大文字3字、細い罫線で下を区切る(参照画像)
- Row(Modifier.fillMaxWidth().padding(top = 10.dp)) {
+ // 曜日行: Example 3 の MonthHeader に合わせる
+ // fontSize 12sp / Color.White / FontWeight.Light
+ Row(Modifier.fillMaxWidth().padding(top = 8.dp)) {
for (d in daysOfWeek) {
Text(
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
- fontSize = 11.sp,
+ fontSize = 12.sp,
// d の型はターゲットで変わる (Android は java.time.DayOfWeek、
// 他は kotlinx.datetime.DayOfWeek)。ordinal は両方で使えるので、
// 曜日名は ordinal から引く。daysOfWeek() の並びは設定した
// firstDayOfWeek に従うため、ordinal で英語名を引く。
text = dayOfWeekEn(d.ordinal),
- fontWeight = FontWeight.Medium,
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.85f),
+ fontWeight = FontWeight.Light,
+ color = Color.White,
)
}
}
- // 罫線(参照画像の格子線に合わせた細い暗灰色)
- Divider(
- Modifier.padding(vertical = 6.dp),
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.20f),
- )
+ // 罫線は引かない。Example 3 の格子線は、セルの padding(1dp) から
+ // 下の地色(#0E0E0E)が見えることで生じる。
+ //
+ // ContentHeightMode.Wrap を明示するのが必須。
+ // 既定は Fill で、その公式仕様は「月の日が親の高さに広がって
+ // 埋める。Calendar 自体も fillMaxHeight か height で作られて
+ // いる必要がある」。ここは親が Column(fillMaxSize) の途中で
+ // 高さが確定していないため、Fill のままだと割り当てが不定に
+ // なり、下の週が描かれない(実機で 20-30 日が消える症状を確認)。
+ // Wrap なら DayCell の .aspectRatio(1f) が高さを決め、7列の
+ // 幅から正方形が決まるので、幅が足りない時も崩れない。
HorizontalCalendar(
modifier = Modifier.fillMaxWidth(),
state = state,
+ contentHeightMode = ContentHeightMode.Wrap,
dayContent = { d -> DayCell(d, today = today, selected = selected, onClick = onDayClick) },
)
Spacer(Modifier.height(10.dp))
@@ -212,7 +341,7 @@ private fun MonthCalendar(today: LocalDate, selected: LocalDate?, onDayClick: (C
"Close",
Modifier
.clip(RoundedCornerShape(50))
- .background(MaterialTheme.colors.secondary)
+ .background(GridHeader)
.clickable {
scope.launch { state.animateScrollToMonth(state.firstVisibleMonth.yearMonth) }
}
@@ -225,16 +354,31 @@ private fun MonthCalendar(today: LocalDate, selected: LocalDate?, onDayClick: (C
}
/**
- * 日セル。参照画像(Calendar Library)に合わせる。
+ * 日セル。kizitonwose/Calendar 公式サンプル Example 3 の Day を
+ * そのまま移植する(2026-09-25 指示「Example 3 にできているか」)。
*
- * +----------+
- * | 17 | <- 日付(上段)
- * | ▬▬▬▬ | <- 予定の色バー(下辺、セル幅の約55%)
- * | ▬▬▬▬ | 複数予定は縦積み(上から順)
- * +----------+
+ * Example 3 の実装(sample/.../compose/Example3Page.kt の Day)を
+ * 一次情報として確認し、寸法・配置をそのまま使う:
*
- * バーはセル内で日付の下の行に置くため、日付と重ならない。
- * 選択/今日はセルの地色で示し、予定バーはその上に描く。
+ * Box(
+ * .aspectRatio(1f) <- 正方形(ソースに
+ * "important for square-sizing!" と明記)
+ * .border(1.dp, selected のときだけ) <- 選択の枠
+ * .padding(1.dp) <- この 1dp の隙間から
+ * 下の地色が見えて格子線になる。
+ * 線を引いているのではない
+ * .background(itemBackgroundColor) <- セル地 #1B1B1B
+ * ) {
+ * 日付: .align(TopEnd) + padding(top=3,end=4), 12.sp
+ * 当月 = Color.Unspecified / 前後月 = #616161
+ * 予定: Column .align(BottomCenter) .fillMaxWidth()
+ * padding(bottom=8.dp), spacedBy(6.dp)
+ * 各バー .fillMaxWidth().height(5.dp)
+ * }
+ *
+ * 前の実装は「日付を中央、バーを幅55%・高さ3dp」にしていたが、
+ * それは参照画像と違っていた。Example 3 が唯一の正解なので
+ * 数値をそのまま合わせる。
*/
@Composable
private fun DayCell(day: CalendarDay, today: LocalDate, selected: LocalDate?, onClick: (CalendarDay) -> Unit) {
@@ -243,44 +387,46 @@ private fun DayCell(day: CalendarDay, today: LocalDate, selected: LocalDate?, on
val isToday = day.date == today
val events = if (isMonthDate) eventsOf(day.date) else emptyList()
- Column(
+ Box(
Modifier
.aspectRatio(1f)
- .padding(2.dp)
- .clip(RoundedCornerShape(4.dp))
- .then(
- when {
- isSelected -> Modifier.background(MaterialTheme.colors.primary)
- isToday -> Modifier.background(MaterialTheme.colors.secondary)
- else -> Modifier
- },
+ .border(
+ width = if (isSelected) 1.dp else 0.dp,
+ color = if (isSelected) Color.White else Color.Transparent,
)
- .clickable(enabled = isMonthDate) { onClick(day) }
- .padding(top = 6.dp, bottom = 6.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
+ .padding(1.dp)
+ .background(if (isToday) GridToday else GridCell)
+ .clickable(enabled = isMonthDate) { onClick(day) },
) {
- // 上段: 日付
+ // 日付は右上(Example 3 と同じ)
Text(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(top = 3.dp, end = 4.dp),
text = day.date.dayOfMonth.toString(),
- fontSize = 14.sp,
- fontWeight = if (isSelected || isToday) FontWeight.Bold else FontWeight.Normal,
color = when {
- isSelected || isToday -> Color.White
- isMonthDate -> MaterialTheme.colors.onSurface
- else -> MaterialTheme.colors.onSurface.copy(alpha = 0.35f)
+ isSelected -> Color.White
+ isMonthDate -> Color.White
+ else -> MaterialTheme.colors.onSurface.copy(alpha = 0.38f)
},
+ fontSize = 12.sp,
)
- Spacer(Modifier.weight(1f))
- // 下段: 予定の色バー。セル幅の約55%を中央に置く。
- for (e in events) {
- Box(
- Modifier
- .padding(top = 2.dp)
- .fillMaxWidth(0.55f)
- .height(3.dp)
- .clip(RoundedCornerShape(2.dp))
- .background(e.color),
- )
+ // 予定バーは下辺中央、セル幅いっぱい(Example 3 と同じ)
+ Column(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .padding(bottom = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ for (e in events) {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(5.dp)
+ .background(e.color),
+ )
+ }
}
}
}
@@ -301,46 +447,52 @@ private fun dayOfWeekJp(ordinal: Int): String = when (ordinal) {
}
/**
- * 日ビュー: Google カレンダーの日表示と同じく、時間軸で画面いっぱいに
- * 予定を並べる。設計 2.22 の「日タイムライン」を、時刻スロットと
- * 予定ブロックを持つ形にしたもの。
+ * 日ビュー: Google カレンダーの日表示に寄せる(2026-09-25 指示)。
*
- * 1日だけでなく複数日にも適用できるように、日付の並びを受け取る
- * (days.size == 1 なら日表示、7 なら週表示)。
- * 予定は「開始時刻・長さ・種別」を持ち、時間軸上の高さで表す。
- * 時間割と自動源(2.21)の予定が同じ軸に混ざる。
+ * Google カレンダーの日表示の作り:
+ * ・1時間あたりの高さを固定し、24時間ぶんの縦に長い面を作る
+ * ・予定は「開始時刻の位置」から「長さに比例した高さ」のブロックで置く
+ * ・時刻ラベルは左の細い列に、時間の区切り線に合わせて置く
+ * ・縦スクロールで前后の時刻へ移動できる
+ *
+ * 前の実装は「時刻と項目の行を並べた表」で、Google カレンダーとは
+ * 別物だった。ここでブロック配置に作り替える。
+ *
+ * 時間割(2.7)と自動源(2.21)の予定が同じ軸に混ざるのは変えない。
+ * 予定の色は §2.5.1 のパイ4象限の色を使い、月表示のバーと揃える。
*/
@Composable
private fun DayTimelineView(days: List<LocalDate>, onBack: () -> Unit) {
val today = remember { todayLocalDate() }
+ val hourHeight = 56.dp // 1時間の高さ(Google カレンダー相当)
+ val startHour = 6 // 6時から翌0時まで表示する
+ val endHour = 24
+ val scroll = rememberScrollState()
+
+ // 時刻(時, 分)と長さ(分)を持つ予定。見本(実データは後段)。
+ data class TEvent(val hour: Int, val minute: Int, val minutes: Int, val title: String, val kind: EventKind?, val label: String)
- // 見本の予定(実データは後段)。時間割 + 工程 + 昼休み。
- // 設計 面4 の ASCII:
- // +----+-----------------------------+
- // | 850| ## 1限 数学 (時間割) |
- // | 940| ## 2限 国語 (時間割) |
- // |1030| (あき) |
- // |1215| ## 昼休み |
- // |1310| ## 3限 英語 / 中間資料(工程)|
- // +----+-----------------------------+
- // 時刻は4桁(850/940/1030/1215/1310)で、その時間帯の項目を右に並べる。
- // 時間割と自動源(2.21)の予定が同じ軸に混ざる。
- val rows = listOf(
- "850" to listOf("1限 数学" to "時間割"),
- "940" to listOf("2限 国語" to "時間割"),
- "1030" to listOf("(あき)" to ""),
- "1120" to listOf("" to ""),
- "1215" to listOf("昼休み" to ""),
- "1310" to listOf("3限 英語" to "時間割", "中間資料の提出" to "工程"),
+ val events = listOf(
+ TEvent(8, 50, 50, "1限 数学", EventKind.Year, "時間割"),
+ TEvent(9, 40, 50, "2限 国語", EventKind.Year, "時間割"),
+ TEvent(12, 15, 45, "昼休み", null, ""),
+ TEvent(13, 10, 50, "3限 英語", EventKind.Year, "時間割"),
+ TEvent(13, 30, 30, "中間資料の提出", EventKind.Activity, "工程"),
)
+ // 予定のない授業の時限も罫線だけ出す(6-24時の各時)
- Column(Modifier.fillMaxSize().padding(16.dp)) {
- // 見出し: 対象日(複数日なら範囲)。1日でも複数日でも同じ実装。
- Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.fillMaxSize().background(GridGround)) {
+ // 見出し: 対象日(複数日なら範囲)。Google カレンダーの日表示と同じく
+ // 左に戻る矢印、中央に日付。地は他のフレームと同じ。
+ Row(
+ Modifier.fillMaxWidth().background(GridHeader).height(48.dp).padding(horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
Text(
"‹",
- Modifier.clickable { onBack() }.padding(end = 10.dp),
- style = MaterialTheme.typography.h6,
+ Modifier.clickable { onBack() }.padding(horizontal = 8.dp),
+ fontSize = 20.sp,
+ color = Color.White,
)
Text(
if (days.size == 1) {
@@ -351,56 +503,69 @@ private fun DayTimelineView(days: List<LocalDate>, onBack: () -> Unit) {
"${days.first().monthNumber}/${days.first().dayOfMonth} - " +
"${days.last().monthNumber}/${days.last().dayOfMonth}"
},
- style = MaterialTheme.typography.h6,
- fontWeight = FontWeight.Bold,
+ Modifier.weight(1f),
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ color = Color.White,
)
}
- Spacer(Modifier.height(10.dp))
- // 時刻と項目の枠。左の時刻列に罫線、右に項目。
- Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) {
- for ((time, items) in rows) {
- Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
- Text(
- time,
- Modifier.width(48.dp).padding(top = 8.dp, end = 8.dp),
- style = MaterialTheme.typography.caption,
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
- textAlign = TextAlign.End,
- )
- Column(Modifier.weight(1f)) {
- // 時間の区切り線(設計の横罫線)
- Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.18f))
- if (items.isEmpty()) {
- Spacer(Modifier.height(26.dp))
- } else {
- for ((title, kind) in items) {
- Row(
- Modifier.fillMaxWidth().padding(vertical = 5.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(title, Modifier.weight(1f), style = MaterialTheme.typography.body2)
- if (kind.isNotEmpty()) {
- Text(
- "($kind)",
- style = MaterialTheme.typography.caption,
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.55f),
- )
- }
- }
- }
+ // 本体: 時間軸 + 予定ブロック。縦スクロールで別の時刻へ移動できる。
+ Row(Modifier.fillMaxWidth().weight(1f).verticalScroll(scroll)) {
+ // 左の時刻列。1時間ごとのラベル。
+ Column(Modifier.width(52.dp)) {
+ for (h in startHour until endHour) {
+ Box(Modifier.height(hourHeight), contentAlignment = Alignment.TopEnd) {
+ Text(
+ "${h}:00",
+ Modifier.padding(top = 2.dp, end = 6.dp),
+ fontSize = 11.sp,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ )
+ }
+ }
+ }
+ // 右の面。1時間ごとの横罫線の上に、予定をブロックで置く。
+ Box(Modifier.weight(1f).height(hourHeight * (endHour - startHour))) {
+ // 時間の区切り線
+ Column(Modifier.fillMaxSize()) {
+ for (h in startHour until endHour) {
+ Box(Modifier.fillMaxWidth().height(hourHeight)) {
+ Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.15f))
+ }
+ }
+ }
+ // 予定ブロック。開始時刻の位置に、長さ比例の高さで置く。
+ for (e in events) {
+ val offsetHours = (e.hour - startHour) + e.minute / 60f
+ val blockHeight = hourHeight * (e.minutes / 60f)
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 4.dp, top = hourHeight * offsetHours, end = 8.dp)
+ .height(blockHeight)
+ .clip(RoundedCornerShape(4.dp))
+ .background(e.kind?.color ?: MaterialTheme.colors.surface)
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ Text(
+ e.title,
+ Modifier.weight(1f),
+ fontSize = 13.sp,
+ color = Color.White,
+ )
+ if (e.label.isNotEmpty()) {
+ Text(
+ e.label,
+ fontSize = 11.sp,
+ color = Color.White.copy(alpha = 0.85f),
+ )
}
}
}
}
- Divider(color = MaterialTheme.colors.onSurface.copy(alpha = 0.18f))
}
- Spacer(Modifier.height(8.dp))
- Text(
- "時間割と自動源(2.21)の予定が同じ軸に混ざる",
- style = MaterialTheme.typography.caption,
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
- )
}
}
@@ -489,17 +654,23 @@ private fun OutlineButton(label: String) {
}
}
-/** 面6 Live: 配信中/配信予定/録画(F51)。教員には [配信開始] が出る。 */
+/**
+ * 面6 Live: 配信中/配信予定/録画(F51)。教員には [配信開始] が出る。
+ *
+ * 2026-09-25 指示「Live中のものは強調するのは」。
+ * 配信中のものだけを別扱いにする:
+ * ・見出しに赤い点を付け、いま起きていることを示す
+ * ・行の地を差し色(ワイン)で塗り、他の行と区別する
+ * ・[視聴する] を塗りボタンにして、押すべき対象を1つに絞る
+ * Google カレンダーの「予定の色で強さを変える」考えと同じで、
+ * 色と塗りで強さの階層を作る。色は §2.5.1 のパイと同じ語彙を使う。
+ */
@Composable
fun LiveFrame(isTeacher: Boolean = false) {
- // 設計 面6 の ASCII:
- // o 配信中 / > 全校集会 [視聴する]
- // 配信予定 / 5限 音楽鑑賞会 14:00
- // 録画 / > 先週の学級会議 32分
- // 説明文は置かず、見出しとボタンだけで構成する。
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp)) {
- LiveSection("配信中")
- ActionButton("全校集会", "視聴する")
+ // 配信中: 見出しに赤点、行は差し色で塗る
+ LiveSection("配信中", live = true)
+ ActionButton("全校集会", "視聴する", filled = true, live = true)
Spacer(Modifier.height(16.dp))
LiveSection("配信予定")
ActionButton("5限 音楽鑑賞会", "14:00")
@@ -514,30 +685,77 @@ fun LiveFrame(isTeacher: Boolean = false) {
}
@Composable
-private fun LiveSection(t: String) {
- Text(t, style = MaterialTheme.typography.body1, fontWeight = FontWeight.Bold)
+private fun LiveSection(t: String, live: Boolean = false) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (live) {
+ // 配信中を示す点。§2.5.1 の「個人」の赤を使う。
+ Box(
+ Modifier
+ .padding(end = 6.dp)
+ .size(10.dp)
+ .clip(CircleShape)
+ .background(EventKind.Personal.color),
+ )
+ }
+ Text(
+ t,
+ style = MaterialTheme.typography.body1,
+ fontWeight = FontWeight.Bold,
+ color = if (live) EventKind.Personal.color else MaterialTheme.colors.onSurface,
+ )
+ }
Divider(Modifier.padding(vertical = 6.dp))
}
-/** 行全体がボタン。filled のときは主色で塗る(配信開始など)。 */
+/**
+ * 行全体がボタン。
+ * filled : 主色で塗る(配信開始・視聴する)
+ * live : 配信中。左端に赤い点を出し、地を一段強くする
+ */
@Composable
-private fun ActionButton(title: String, meta: String?, filled: Boolean = false) {
+private fun ActionButton(
+ title: String,
+ meta: String?,
+ filled: Boolean = false,
+ live: Boolean = false,
+) {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 3.dp)
.clip(RoundedCornerShape(10.dp))
- .background(if (filled) MaterialTheme.colors.primary else MaterialTheme.colors.surface)
+ .background(
+ when {
+ filled -> MaterialTheme.colors.primary
+ live -> MaterialTheme.colors.surface
+ else -> MaterialTheme.colors.surface
+ },
+ )
.clickable { }
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- Text(">", Modifier.width(18.dp), style = MaterialTheme.typography.body2,
- color = if (filled) Color.White else MaterialTheme.colors.primary)
+ if (live) {
+ Box(
+ Modifier
+ .padding(end = 8.dp)
+ .size(8.dp)
+ .clip(CircleShape)
+ .background(if (filled) Color.White else EventKind.Personal.color),
+ )
+ } else {
+ Text(
+ ">",
+ Modifier.width(18.dp),
+ style = MaterialTheme.typography.body2,
+ color = if (filled) Color.White else MaterialTheme.colors.primary,
+ )
+ }
Text(
title,
Modifier.weight(1f),
style = MaterialTheme.typography.body2,
+ fontWeight = if (live) FontWeight.Bold else FontWeight.Normal,
color = if (filled) Color.White else MaterialTheme.colors.onSurface,
)
if (meta != null) {
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
index 2441df1..eaed903 100644
--- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FramesHomeTimeline.kt
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/FramesHomeTimeline.kt
@@ -1,6 +1,9 @@
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
@@ -8,8 +11,11 @@ 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.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
@@ -17,9 +23,11 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
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.unit.dp
+import androidx.compose.ui.unit.sp
/**
* 面1 ホーム(Home)。設計 2.22 の ASCII(面1)に従う。
@@ -39,47 +47,140 @@ import androidx.compose.ui.unit.dp
* Home は「導線の役割」なので、他フレームへの入口として最も厚く見せる
* (2026-09-25 指示)。ここに挙げた区画・件数は見本値。
*/
+/**
+ * 面1 Home。2026-09-25 指示「HomeについてLiveと形式をおなじようにしろ。
+ * ただしNewや未読などを表示」。
+ *
+ * Live の形式 = 「見出し帯 + カード行(行全体がボタン、右端に注記)」。
+ * Home もその形に揃え、違いは右端の注記だけにする。これで
+ * 5面のうち2面が同じ見た目になり、利用者は形を覚え直さなくてよい。
+ *
+ * NEW/未読は Live の「配信中」と同じ扱いにする:
+ * ・見出しに件数バッジ(NEW なら赤、未読なら白抜き)
+ * ・行の左端に点を出し、地を一段強くする
+ * 色は §2.5.1 のパイの語彙(個人=赤)を使い、Live の配信中と同じ赤にする。
+ */
@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(16.dp))
- Text("9/22(火) 3限", style = MaterialTheme.typography.body1)
+ Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp)) {
+ // 今日の日付と時限。Live の見出し帯と同じ高さ・字面にする。
+ Row(Modifier.fillMaxWidth().height(40.dp), verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier
+ .padding(end = 8.dp)
+ .size(10.dp)
+ .clip(CircleShape)
+ .background(EventKind.Personal.color),
+ )
+ Text("今日", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = Color.White)
+ Spacer(Modifier.width(12.dp))
+ Text(
+ "9/22(火) 3限",
+ Modifier.weight(1f),
+ fontSize = 13.sp,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
+ )
}
- Spacer(Modifier.height(6.dp))
+ Spacer(Modifier.height(8.dp))
- // TODO 集計チップ(未提出/要回答/確認)
+ // 集計チップ(未提出/要回答/確認)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CountChip("未提出", "2")
CountChip("要回答", "1")
CountChip("確認", "3")
}
- Spacer(Modifier.height(14.dp))
+ Spacer(Modifier.height(16.dp))
- // TODO
- SectionLabel("TODO")
- Line("◻", "数学プリント", "明日まで")
- Line("◻", "探究メモ", "金曜まで")
- Spacer(Modifier.height(12.dp))
+ // TODO: 期限つきのもの。右端は期限。
+ HomeSection("TODO")
+ HomeRow("◻", "数学プリント", "明日まで")
+ HomeRow("◻", "探究メモ", "金曜まで")
+ Spacer(Modifier.height(16.dp))
- // 今日(時間割・当日連絡・期限が同じ並びに混ざる)
- SectionLabel("今日")
- Line("##", "1限 数学", "時間割")
- Line("o", "合唱練習は中止", "当日連絡")
- Line("o", "レポート提出", "期限")
- Spacer(Modifier.height(12.dp))
+ // 今日: 時間割・当日連絡・期限が同じ並びに混ざる。右端は出所。
+ HomeSection("今日")
+ HomeRow("##", "1限 数学", "時間割")
+ HomeRow("o", "合唱練習は中止", "当日連絡")
+ HomeRow("o", "レポート提出", "期限")
+ Spacer(Modifier.height(16.dp))
- // タイムラインの抜粋(新しいものだけ。NEW バッジ)
- Row(verticalAlignment = Alignment.CenterVertically) {
- SectionLabel("タイムライン", inline = true)
- Spacer(Modifier.width(8.dp))
- Text("NEW", style = MaterialTheme.typography.caption, color = MaterialTheme.colors.primary)
+ // タイムラインの抜粋: 未読と NEW をここで示す。
+ HomeSection("タイムライン", badge = "NEW", badgeCount = 1)
+ HomeRow("o", "教務 明日の持ち物", "要確認", unread = true)
+ HomeRow("o", "部長 練習は中止です", "10:02", unread = true)
+ }
+}
+
+/**
+ * 見出し帯。Live の LiveSection と同じ形。
+ * badge を渡すと件数バッジを出す(NEW/未読)。
+ */
+@Composable
+private fun HomeSection(label: String, badge: String? = null, badgeCount: Int = 0) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(label, style = MaterialTheme.typography.body1, fontWeight = FontWeight.Bold)
+ if (badge != null) {
+ Spacer(Modifier.width(6.dp))
+ Text(
+ if (badgeCount > 0) "$badge $badgeCount" else badge,
+ Modifier
+ .clip(RoundedCornerShape(6.dp))
+ .background(EventKind.Personal.color)
+ .padding(horizontal = 6.dp, vertical = 1.dp),
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White,
+ )
+ }
+ }
+ Divider(Modifier.padding(vertical = 6.dp))
+}
+
+/**
+ * カード行。Live の ActionButton と同じ形(行全体がボタン、右端に注記)。
+ * unread のときは左端に赤い点を出し、文字を太くする。
+ */
+@Composable
+private fun HomeRow(mark: String, body: String, note: String, unread: Boolean = false) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 3.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(MaterialTheme.colors.surface)
+ .clickable { }
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (unread) {
+ Box(
+ Modifier
+ .padding(end = 8.dp)
+ .size(8.dp)
+ .clip(CircleShape)
+ .background(EventKind.Personal.color),
+ )
+ } else {
+ Text(
+ mark,
+ Modifier.width(18.dp),
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.primary,
+ )
+ }
+ Text(
+ body,
+ Modifier.weight(1f),
+ style = MaterialTheme.typography.body2,
+ fontWeight = if (unread) FontWeight.Bold else FontWeight.Normal,
+ )
+ if (note.isNotEmpty()) {
+ Text(
+ note,
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.55f),
+ )
}
- Line("o", "教務 明日の持ち物", "要確認")
}
}
diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/WebTheme.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/WebTheme.kt
index 52b9867..cd39a4f 100644
--- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/WebTheme.kt
+++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/web/WebTheme.kt
@@ -36,6 +36,28 @@ private val DiscordHigh = Color(0xFF3A3C42) // 一段明るい面(選択など
private val PaperGray = Color(0xFFF2F0F0)
private val White = Color(0xFFFFFFFF)
+/**
+ * カレンダーの月グリッド専用の地色(2026-09-25 指示「色を背景と合わせろ」)。
+ *
+ * Example 3 は page_bg #0E0E0E / cell_bg #1B1B1B という独自の暗さを使って
+ * いたが、本アプリの地は #313338 なので、そのまま持ってくると
+ * カレンダーだけ真っ黒に浮く。Example 3 の「セルを地色より一段明るくし、
+ * セル間の 1dp の隙間から地色が透けて格子線に見える」という仕組みは
+ * そのままに、値だけテーマの階調へ写像する。
+ *
+ * 地(隙間として見える) = DiscordGround #313338
+ * セル = DiscordHigh #3A3C42 (一段明るい面)
+ * 見出し帯 = DiscordRaised #2B2D31
+ *
+ * これで他のフレームと同じ地の上にカレンダーが載る。
+ */
+internal val CalendarCellColor = DiscordHigh // セル(地より一段明るい)
+internal val CalendarHeaderColor = DiscordRaised // 見出し帯
+internal val CalendarTodayColor = WineDeep // 今日のセル(差し色の濃ワイン)
+
+/** 他のフレームと同じ地。カレンダーのセル隙間もこれが見えて格子線になる。 */
+internal val AppGround = DiscordGround
+
@Composable
fun WebTheme(dark: Boolean = true, content: @Composable () -> Unit) {
val colors = if (dark) {