diff options
| author | charlie <root@dangofactory.local> | 2026-09-25 21:29:39 +0900 |
|---|---|---|
| committer | charlie <root@dangofactory.local> | 2026-09-25 21:29:39 +0900 |
| commit | 1ca23f3dbcf723b4a280fc1f809f4a9caadbcbd2 (patch) | |
| tree | aa0ea06d9b4b88b3ae90bfa858f7e36e3585b7d4 /modules | |
| parent | 1069fc2c0d3d56289e028bc8e7f9e0857c01c253 (diff) | |
| download | kukuri-1ca23f3dbcf723b4a280fc1f809f4a9caadbcbd2.tar.gz kukuri-1ca23f3dbcf723b4a280fc1f809f4a9caadbcbd2.tar.bz2 kukuri-1ca23f3dbcf723b4a280fc1f809f4a9caadbcbd2.zip | |
UI: finish the calendar (Example 3, year view, day view) and align Home/Live
Six changes requested in one pass, all compile-verified for wasm, jvm and
android and pushed to the local preview server for review.
1. Fix: the month grid was cut off when the width was tight.
HorizontalCalendar defaults to ContentHeightMode.Fill, whose documented
contract is "the days in each month spread to fill the parent's height" and
which therefore requires the calendar itself to be given a height. We gave
it only fillMaxWidth, so the height allocation was indeterminate and the
last weeks vanished (observed: rows ending at the 19th or 26th). Setting
ContentHeightMode.Wrap hands the height back to DayCell's aspectRatio(1f),
which derives it from the seven columns' width, so the grid is complete at
any width.
2. Calendar now matches the library's Example 3 (fetched the real sample
source rather than working from the screenshot):
- cell: aspectRatio(1f), 1dp border only when selected, padding(1dp),
background; the "gridlines" are that 1dp gap letting the page
background show through, not drawn lines
- date: top-right (align TopEnd, top 3dp / end 4dp), 12sp
- event bars: bottom-centre column, fillMaxWidth, 5dp tall, 6dp apart,
bottom padding 8dp
- month title: 40dp bar, 22sp, Medium, centred, arrows on both sides
- weekday row: 12sp, white, FontWeight.Light
The previous attempt copied the screenshot's look approximately (bars at
55% width and 3dp, date centred); the sample is the authority.
3. Event bars and colours now follow the background instead of hard-coding
Example 3's palette. Example 3 sits on #0E0E0E, this app sits on #313338,
so reusing its values made the calendar look like a black hole. The
mechanism (cell one step lighter than the ground, so the 1dp gap reads as a
gridline) is kept and only the values are mapped onto the theme's ramp:
ground #313338, cell #3A3C42, header #2B2D31, today wine #5A1524. Values
live in WebTheme.kt so no screen carries its own palette again.
4. Day view rebuilt on the Google Calendar model. It was a table of
time-and-item rows; it is now a fixed hour height with a long vertical
surface, events placed at their start time with height proportional to
duration, hour labels down a narrow left column, and vertical scrolling to
reach other times of day. Event colours reuse the month view's pie-quadrant
colours so the two views agree.
5. Calendar drills down three levels: year -> month -> day. Tapping the month
title goes up to the year; tapping a month in the year view comes back down
to that month; tapping a day opens the day view. Reason: reaching a distant
month otherwise means pressing the arrow many times, and "when is it"
questions often start from a year-level overview. Uses the library's
VerticalYearCalendar (2.7.0 has it under com.kizitonwose.calendar.compose,
gated by ExperimentalCalendarApi - the package is NOT ...compose.yearcalendar,
which is where the state's remember function lives).
6. Home and Live now share one shape: section heading + full-width card rows
with a right-aligned note. Home adds what Live does not have - NEW and unread
- shown as a count badge on the heading and a dot plus bolder text on the
row, reusing the same red as Live's on-air indicator so "live" and "new"
read as the same kind of attention. Live's on-air row is emphasised with a
dot in the heading, a filled watching button, and bold title.
EventKind and the calendar colour constants became internal so Home/Live can
share them instead of each screen defining its own copy.
Remaining known gaps: sample event data is still hard-coded; the Close button
resets the visible month because the month view has nothing to close; the
year view does not yet show event marks; and the calendar colour ramp is
deliberately provisional - it is a mapping of Example 3's contrast onto this
theme, not a measured choice.
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) { |
