diff options
Diffstat (limited to 'modules')
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) { |
