From 37995d1fb3b7f59cfffdee84b4617a341fc12e02 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 31 Jul 2026 15:52:05 +0200 Subject: [PATCH] Add a timeline scale setting for week and day view (#56) One shared "Hour height" setting under Settings > Views > Week & day: fit whole day, compact, regular (the old 56dp) or comfortable. Fit whole day derives the hour height from the timeline's own viewport, so the whole day fits without scrolling. The hour height was a private constant in both screens; it now comes from a preference via LocalTimelineScale. The minimum event height follows it as a fraction of an hour instead of a fixed 24dp, so short events keep the same relationship to their neighbours at every scale. While in there, blocks now only draw text they can draw whole: a block too short for a full line drops its title instead of showing a sliced one, one that cannot fit title and time keeps the title, and a block too narrow to hold more than a syllable stays on one ellipsised line rather than stacking letters vertically. --- .../jeanlucmakiola/calendula/MainActivity.kt | 2 + .../calendula/data/prefs/SettingsPrefs.kt | 14 +++ .../calendula/ui/common/TimelineScale.kt | 94 ++++++++++++++ .../calendula/ui/day/DayScreen.kt | 96 +++++++++----- .../calendula/ui/settings/SettingsUiState.kt | 3 + .../ui/settings/SettingsViewModel.kt | 12 +- .../calendula/ui/settings/ViewsSettings.kt | 24 +++- .../calendula/ui/week/WeekScreen.kt | 118 +++++++++++------- app/src/main/res/values/strings.xml | 10 ++ .../calendula/data/prefs/SettingsPrefsTest.kt | 11 ++ .../calendula/ui/common/TimelineScaleTest.kt | 74 +++++++++++ 11 files changed, 377 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 0a0c20f..ebb8af6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -29,6 +29,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.buildInsertEventForm import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines +import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.WidgetNavRequest @@ -141,6 +142,7 @@ class MainActivity : AppCompatActivity() { CompositionLocalProvider( LocalUse24HourFormat provides use24Hour, LocalShowHourLines provides settings.showHourLines, + LocalTimelineScale provides settings.timelineScale, LocalSoftenColors provides settings.softenColors, ) { RootScreen( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 52a2518..b5ceced 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import de.jeanlucmakiola.calendula.widget.WidgetSize @@ -270,6 +271,18 @@ class SettingsPrefs @Inject constructor( store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name } } + /** + * How tall an hour is drawn in the week and day timelines (#56). Defaults to + * [TimelineScale.Regular] — the historical 56dp scale. + */ + val timelineScale: Flow = store.data.map { prefs -> + prefs[TIMELINE_SCALE_KEY].toEnum(TimelineScale.Regular) + } + + suspend fun setTimelineScale(scale: TimelineScale) { + store.edit { it[TIMELINE_SCALE_KEY] = scale.name } + } + /** * Where the jump-to-today control lives (issue #60). Default OFF — the * historical layout, where it's an extended FAB that fades in above the "+" @@ -808,6 +821,7 @@ class SettingsPrefs @Inject constructor( internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers") internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style") + internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale") internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt new file mode 100644 index 0000000..958025b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt @@ -0,0 +1,94 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.annotation.StringRes +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R + +/** + * How tall one hour is drawn in the week and day timelines (#56). + * + * One shared setting for both views: they are the same grid at different widths, + * and a scale that only applied to one of them would read as a bug. + * + * [FitDay] is the answer to the actual complaint behind the issue — on a tall + * phone the default scale shows about half a day, so a whole week can hide + * appointments below the fold. It derives the hour height from the timeline's + * own viewport instead of a fixed value. + */ +enum class TimelineScale { + /** Whole day in one screen: the hour height follows the viewport. */ + FitDay, + + /** Denser than the default, still a fixed height. */ + Compact, + + /** The historical 56dp scale. */ + Regular, + + /** Roomier blocks, more scrolling. */ + Comfortable, +} + +/** The scale the timelines draw at, from the `timelineScale` preference. */ +val LocalTimelineScale = staticCompositionLocalOf { TimelineScale.Regular } + +/** + * Hour height for this scale. [viewportHeight] is the visible height of the + * scrolling timeline and is only consulted by [TimelineScale.FitDay]. + * + * The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop + * being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its + * blocks absurdly. On a screen too short for the whole day the clamp wins and + * the timeline still scrolls a little — honest, rather than unreadable. + */ +fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) { + TimelineScale.FitDay -> (viewportHeight / 24f).coerceIn(FIT_DAY_MIN, FIT_DAY_MAX) + TimelineScale.Compact -> 40.dp + TimelineScale.Regular -> 56.dp + TimelineScale.Comfortable -> 80.dp +} + +/** + * Shortest an event block may render, as a fraction of an hour. Blocks keep a + * floor so a 15-minute event stays tappable, but the floor scales with the hour + * height — a fixed 24dp would swallow half an hour once zoomed out and make + * short events overlap their neighbours. + */ +const val MIN_EVENT_FRACTION = 26f / 60f + +/** + * Narrowest an event block may be and still wrap its title over several lines. + * + * Wrapping is driven by the block's height, so a tall block on a lane-split + * column would otherwise stack two or three characters per line — "Da/ily", + * "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column + * clears this on any phone; a split one never does, while the day view's much + * wider columns keep wrapping even several lanes deep. + */ +val MIN_TITLE_WRAP_WIDTH = 36.dp + +/** Smallest hour height [TimelineScale.FitDay] will resolve to. */ +val FIT_DAY_MIN = 24.dp + +/** Largest hour height [TimelineScale.FitDay] will resolve to. */ +val FIT_DAY_MAX = 96.dp + +@get:StringRes +val TimelineScale.labelRes: Int + get() = when (this) { + TimelineScale.FitDay -> R.string.timeline_scale_fit_day + TimelineScale.Compact -> R.string.timeline_scale_compact + TimelineScale.Regular -> R.string.timeline_scale_regular + TimelineScale.Comfortable -> R.string.timeline_scale_comfortable + } + +@get:StringRes +val TimelineScale.descriptionRes: Int + get() = when (this) { + TimelineScale.FitDay -> R.string.timeline_scale_fit_day_summary + TimelineScale.Compact -> R.string.timeline_scale_compact_summary + TimelineScale.Regular -> R.string.timeline_scale_regular_summary + TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary + } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 842486e..6ed0599 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -87,6 +87,9 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines +import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale +import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION +import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines @@ -100,13 +103,11 @@ import kotlin.time.Clock import java.util.Locale import kotlin.math.roundToInt -private val HOUR_HEIGHT = 56.dp private val GUTTER_WIDTH = 48.dp /** Start inset for the gutter's hour labels so they centre on the top bar's * hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's * 4dp inset + 24dp half icon button), matching the week view. */ private val GUTTER_CONTENT_START_INSET = 8.dp -private val MIN_EVENT_HEIGHT = 24.dp private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp @@ -264,7 +265,6 @@ private fun DayContent( onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { - val density = LocalDensity.current val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() @@ -275,11 +275,9 @@ private fun DayContent( val scrollState = rememberScrollState() LaunchedEffect(Unit) { snapshotFlow { scrollState.maxValue }.first { it > 0 } - val maxV = scrollState.maxValue - val target = with(density) { - (HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt() - }.coerceIn(0, maxV) - scrollState.scrollTo(target) + // Half the scroll range *is* noon: the content spans a full 24 hours, so + // centring the range centres midday at whatever hour height is in force. + scrollState.scrollTo(scrollState.maxValue / 2) } // Single, hoisted all-day strip height — shared by the outgoing and incoming @@ -485,12 +483,17 @@ private fun Timeline( onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, ) { - val totalHeight = HOUR_HEIGHT * 24 val dark = isSystemInDarkTheme() val use24Hour = LocalUse24HourFormat.current val locale = currentLocale() + val scale = LocalTimelineScale.current - Box(modifier = Modifier.fillMaxSize()) { + // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the + // timeline's own viewport height, which is only known here — below the top + // bar, date header and all-day strip. + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val hourHeight = scale.hourHeight(maxHeight) + val totalHeight = hourHeight * 24 // Gutter and day column are two scroll viewports that SHARE one scroll // state, so they stay perfectly aligned. The day-column viewport is a // static, rounded-clipped window — the content scrolls inside it, so the @@ -509,7 +512,7 @@ private fun Timeline( Box( modifier = Modifier .fillMaxWidth() - .height(HOUR_HEIGHT), + .height(hourHeight), ) { if (h > 0) { Text( @@ -537,6 +540,7 @@ private fun Timeline( dark = dark, date = state.date, today = state.today, + hourHeight = hourHeight, onEventClick = onEventClick, onCreateAt = onCreateAt, modifier = Modifier @@ -554,11 +558,12 @@ private fun DayColumnCard( dark: Boolean, date: LocalDate, today: LocalDate, + hourHeight: Dp, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { - val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } + val hourPx = with(LocalDensity.current) { hourHeight.toPx() } val showHourLines = LocalShowHourLines.current val hourLineColor = MaterialTheme.colorScheme.outlineVariant Card( @@ -587,14 +592,16 @@ private fun DayColumnCard( }, ) { val colWidth = maxWidth + val minEventHeight = hourHeight * MIN_EVENT_FRACTION blocks.forEach { block -> val laneWidth = colWidth / block.laneCount - val top = HOUR_HEIGHT * (block.startMin / 60f) - val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f) - val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight + val top = hourHeight * (block.startMin / 60f) + val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) + val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight EventBlock( block = block, dark = dark, + height = height, onClick = { onEventClick(block.event) }, modifier = Modifier .offset(x = laneWidth * block.lane, y = top) @@ -605,7 +612,7 @@ private fun DayColumnCard( } // Current-time line, on top of the events, only on today's column. if (date == today) { - NowLine(date = date, hourHeight = HOUR_HEIGHT) + NowLine(date = date, hourHeight = hourHeight) } } } @@ -615,6 +622,7 @@ private fun DayColumnCard( private fun EventBlock( block: TimedBlock, dark: Boolean, + height: Dp, onClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -623,7 +631,20 @@ private fun EventBlock( val locale = currentLocale() val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + minToHm(block.endMin, use24Hour, locale) - val showTime = block.endMin - block.startMin >= 45 + val density = LocalDensity.current + val titleLineHeight = with(density) { + MaterialTheme.typography.labelMedium.lineHeight.toDp() + } + val timeLineHeight = with(density) { + MaterialTheme.typography.labelSmall.lineHeight.toDp() + } + // What's left for text once the 2.dp top/bottom padding is paid for. A block + // that cannot afford both lines spends its space on the title, and one too + // short even for that drops the title rather than serving a sliced one. + val available = height - 4.dp + val showTime = block.endMin - block.startMin >= 45 && + available >= titleLineHeight + timeLineHeight + val showTitle = available >= titleLineHeight val soften = LocalSoftenColors.current val fill = eventFill(block.event.color, dark, soften) Box( @@ -634,13 +655,15 @@ private fun EventBlock( .semantics { contentDescription = "$title, $timeLabel" }, ) { Column { - Text( - text = title, - style = MaterialTheme.typography.labelMedium, - maxLines = if (showTime) 1 else 2, - overflow = TextOverflow.Ellipsis, - color = eventInk(fill, alpha = 0.85f), - ) + if (showTitle) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + maxLines = if (showTime) 1 else 2, + overflow = TextOverflow.Ellipsis, + color = eventInk(fill, alpha = 0.85f), + ) + } if (showTime) { Text( text = timeLabel, @@ -656,17 +679,22 @@ private fun EventBlock( @Composable private fun DayLoading() { - val totalHeight = HOUR_HEIGHT * 24 + val scale = LocalTimelineScale.current val scrollState = rememberScrollState() - Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { - Spacer(Modifier.width(GUTTER_WIDTH)) - Box( - modifier = Modifier - .weight(1f) - .height(totalHeight) - .padding(horizontal = 2.dp) - .background(MaterialTheme.colorScheme.surfaceContainer), - ) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + // Same scale resolution as the loaded timeline, so the skeleton's column + // doesn't resize the moment the real day arrives. + val totalHeight = scale.hourHeight(maxHeight) * 24 + Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { + Spacer(Modifier.width(GUTTER_WIDTH)) + Box( + modifier = Modifier + .weight(1f) + .height(totalHeight) + .padding(horizontal = 2.dp) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 5c5c19e..680fa5a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.widget.WidgetSize @@ -57,6 +58,8 @@ data class SettingsUiState( val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default, /** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */ val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged, + /** How tall an hour is drawn in the week and day timelines (#56). */ + val timelineScale: TimelineScale = TimelineScale.Regular, /** Order of the views in the navigation drawer (#24); every view is always listed. */ val drawerViewOrder: List = IMPLEMENTED_VIEWS, /** Optional event-form fields shown by default (rest behind "more fields"). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 0e6000d..27397fb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -36,6 +36,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN @@ -164,8 +165,9 @@ class SettingsViewModel @Inject constructor( prefs.drawerViewOrder, prefs.monthViewStyle, prefs.widgetSize, - ) { quickSwitch, drawer, monthStyle, widgetSize -> - ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize) + prefs.timelineScale, + ) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale -> + ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale) }, ) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization -> MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization) @@ -189,6 +191,7 @@ class SettingsViewModel @Inject constructor( drawerViewOrder = misc.viewCustomization.drawerOrder, monthViewStyle = misc.viewCustomization.monthViewStyle, widgetSize = misc.viewCustomization.widgetSize, + timelineScale = misc.viewCustomization.timelineScale, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -295,6 +298,7 @@ class SettingsViewModel @Inject constructor( val drawerOrder: List, val monthViewStyle: MonthViewStyle, val widgetSize: WidgetSize, + val timelineScale: TimelineScale, ) /** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */ @@ -581,6 +585,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setMonthViewStyle(style) } } + fun setTimelineScale(scale: TimelineScale) { + viewModelScope.launch { prefs.setTimelineScale(scale) } + } + fun setDrawerViewOrder(order: List) { viewModelScope.launch { prefs.setDrawerViewOrder(order) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index 9483132..fd1dc62 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -33,7 +33,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.PickerDescription import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel +import de.jeanlucmakiola.calendula.ui.common.descriptionRes import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.icon import de.jeanlucmakiola.calendula.ui.common.labelRes @@ -68,6 +70,7 @@ internal fun ViewsScreen( var showTimeFormat by remember { mutableStateOf(false) } var showPastEvents by remember { mutableStateOf(false) } var showAgendaScreenRange by remember { mutableStateOf(false) } + var showTimelineScale by remember { mutableStateOf(false) } CollapsingScaffold( title = stringResource(R.string.settings_section_views), @@ -144,10 +147,16 @@ internal fun ViewsScreen( Spacer(Modifier.height(8.dp)) SectionHeader(stringResource(R.string.settings_week_day_header)) + GroupedRow( + title = stringResource(R.string.settings_timeline_scale), + summary = stringResource(state.timelineScale.labelRes), + position = Position.Top, + onClick = { showTimelineScale = true }, + ) GroupedRow( title = stringResource(R.string.settings_hour_lines), summary = stringResource(R.string.settings_hour_lines_summary), - position = Position.Alone, + position = Position.Bottom, trailing = { Switch( checked = state.showHourLines, @@ -321,6 +330,19 @@ internal fun ViewsScreen( onDismiss = { showPastEvents = false }, ) } + if (showTimelineScale) { + OptionPicker( + title = stringResource(R.string.settings_timeline_scale), + header = { PickerDescription(stringResource(R.string.settings_timeline_scale_hint)) }, + predictiveBack = true, + options = TimelineScale.entries, + selected = state.timelineScale, + label = { stringResource(it.labelRes) }, + summary = { stringResource(it.descriptionRes) }, + onSelect = viewModel::setTimelineScale, + onDismiss = { showTimelineScale = false }, + ) + } if (showAgendaScreenRange) { AgendaRangePicker( title = stringResource(R.string.settings_agenda_range), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index ea5243c..319a102 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -97,6 +97,10 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines +import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale +import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION +import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH +import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.formatHourLabel import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines @@ -113,15 +117,12 @@ import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock import java.time.format.TextStyle as JavaTextStyle import java.util.Locale -import kotlin.math.roundToInt -private val HOUR_HEIGHT = 56.dp private val GUTTER_WIDTH = 48.dp /** Start inset for the gutter's content (week badge + hour labels) so it centres * on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp * (the app bar's 4dp inset + 24dp half icon button). */ private val GUTTER_CONTENT_START_INSET = 8.dp -private val MIN_EVENT_HEIGHT = 24.dp private val ALL_DAY_ROW_HEIGHT = 24.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp @@ -294,7 +295,6 @@ private fun WeekContent( onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { - val density = LocalDensity.current val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() @@ -306,11 +306,9 @@ private fun WeekContent( val scrollState = rememberScrollState() LaunchedEffect(Unit) { snapshotFlow { scrollState.maxValue }.first { it > 0 } - val maxV = scrollState.maxValue - val target = with(density) { - (HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt() - }.coerceIn(0, maxV) - scrollState.scrollTo(target) + // Half the scroll range *is* noon: the content spans a full 24 hours, so + // centring the range centres midday at whatever hour height is in force. + scrollState.scrollTo(scrollState.maxValue / 2) } // Single, hoisted all-day strip height — shared by the outgoing and incoming @@ -620,12 +618,17 @@ private fun Timeline( onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, ) { - val totalHeight = HOUR_HEIGHT * 24 val dark = isSystemInDarkTheme() val use24Hour = LocalUse24HourFormat.current val locale = currentLocale() + val scale = LocalTimelineScale.current - Box(modifier = Modifier.fillMaxSize()) { + // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the + // timeline's own viewport height, which is only known here — below the top + // bar, day header and all-day strip. + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val hourHeight = scale.hourHeight(maxHeight) + val totalHeight = hourHeight * 24 // Gutter and day columns are two scroll viewports that SHARE one scroll // state, so they stay perfectly aligned. The day-column viewport is a // static, rounded-clipped window — the content scrolls inside it, so the @@ -645,7 +648,7 @@ private fun Timeline( Box( modifier = Modifier .fillMaxWidth() - .height(HOUR_HEIGHT), + .height(hourHeight), ) { if (h > 0) { Text( @@ -680,6 +683,7 @@ private fun Timeline( dark = dark, date = day, today = state.today, + hourHeight = hourHeight, onEventClick = onEventClick, onCreateAt = onCreateAt, modifier = Modifier @@ -699,11 +703,12 @@ private fun DayColumnCard( dark: Boolean, date: LocalDate, today: LocalDate, + hourHeight: Dp, onEventClick: (EventInstance) -> Unit, onCreateAt: (LocalDate, Int) -> Unit, modifier: Modifier = Modifier, ) { - val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } + val hourPx = with(LocalDensity.current) { hourHeight.toPx() } val showHourLines = LocalShowHourLines.current val hourLineColor = MaterialTheme.colorScheme.outlineVariant Card( @@ -731,15 +736,17 @@ private fun DayColumnCard( }, ) { val colWidth = maxWidth + val minEventHeight = hourHeight * MIN_EVENT_FRACTION blocks.forEach { block -> val laneWidth = colWidth / block.laneCount - val top = HOUR_HEIGHT * (block.startMin / 60f) - val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f) - val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight + val top = hourHeight * (block.startMin / 60f) + val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) + val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight EventBlock( block = block, dark = dark, height = height, + width = laneWidth, onClick = { onEventClick(block.event) }, modifier = Modifier .offset(x = laneWidth * block.lane, y = top) @@ -750,7 +757,7 @@ private fun DayColumnCard( } // Current-time line, on top of the events, only on today's column. if (date == today) { - NowLine(date = date, hourHeight = HOUR_HEIGHT) + NowLine(date = date, hourHeight = hourHeight) } } } @@ -761,6 +768,7 @@ private fun EventBlock( block: TimedBlock, dark: Boolean, height: Dp, + width: Dp, onClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -769,10 +777,6 @@ private fun EventBlock( val locale = currentLocale() val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + minToHm(block.endMin, use24Hour, locale) - // Only full-width (non-overlapping) blocks that are tall enough show the time. - // On narrow overlapping columns we drop it so the title can wrap to fill the - // whole block, mirroring Google Calendar. - val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1 val density = LocalDensity.current val titleLineHeight = with(density) { MaterialTheme.typography.labelMedium.lineHeight.toDp() @@ -780,11 +784,30 @@ private fun EventBlock( val timeLineHeight = with(density) { MaterialTheme.typography.labelSmall.lineHeight.toDp() } - // Wrap the title across as many lines as the block can fit (minus the 2.dp - // top/bottom padding and the reserved time line) instead of clipping it to a - // single character on slim, overlapping blocks. - val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp - val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) + // What's left for text once the 2.dp top/bottom padding is paid for. + val available = height - 4.dp + // Only full-width (non-overlapping) blocks that are tall enough show the + // time. On narrow overlapping columns we drop it so the title can wrap to + // fill the whole block, mirroring Google Calendar — and a block that cannot + // afford both lines spends its space on the title. + val showTime = block.endMin - block.startMin >= 45 && + block.laneCount == 1 && + available >= titleLineHeight + timeLineHeight + // A short block drops the title rather than serving a horizontally sliced + // one: half a letter reads as a rendering fault, while a bare colour chip + // reads as what it is — an event too brief to label. Tap still opens it, and + // the semantics description carries the full title either way. + val showTitle = available >= titleLineHeight + // Wrap the title across as many lines as the block can fit — but only once a + // line is wide enough to hold more than a syllable. Below that the extra + // lines just stack fragments of the word, and one ellipsised line reads + // better. + val contentHeight = available - if (showTime) timeLineHeight else 0.dp + val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) { + 1 + } else { + (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) + } val dimCutoff = LocalDimCutoff.current val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) val soften = LocalSoftenColors.current @@ -797,13 +820,15 @@ private fun EventBlock( .semantics { contentDescription = "$title, $timeLabel" }, ) { Column { - Text( - text = title, - style = MaterialTheme.typography.labelMedium, - maxLines = titleMaxLines, - overflow = TextOverflow.Ellipsis, - color = eventInk(fill, alpha = 0.85f), - ) + if (showTitle) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + maxLines = titleMaxLines, + overflow = TextOverflow.Ellipsis, + color = eventInk(fill, alpha = 0.85f), + ) + } if (showTime) { Text( text = timeLabel, @@ -819,7 +844,7 @@ private fun EventBlock( @Composable private fun WeekLoading() { - val totalHeight = HOUR_HEIGHT * 24 + val scale = LocalTimelineScale.current val scrollState = rememberScrollState() Column(modifier = Modifier.fillMaxSize()) { // Header skeleton @@ -838,16 +863,21 @@ private fun WeekLoading() { ) } } - Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { - Spacer(Modifier.width(GUTTER_WIDTH)) - repeat(7) { - Box( - modifier = Modifier - .weight(1f) - .height(totalHeight) - .padding(horizontal = 2.dp) - .background(MaterialTheme.colorScheme.surfaceContainer), - ) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + // Same scale resolution as the loaded timeline, so the skeleton's + // columns don't resize the moment the real week arrives. + val totalHeight = scale.hourHeight(maxHeight) * 24 + Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { + Spacer(Modifier.width(GUTTER_WIDTH)) + repeat(7) { + Box( + modifier = Modifier + .weight(1f) + .height(totalHeight) + .padding(horizontal = 2.dp) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cedc6ff..2b34eda 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -363,6 +363,16 @@ Following the system: %1$s Hour lines Show a separator line at each hour in week and day view + Hour height + How much vertical space one hour takes in week and day view. Both views share this setting. + Fit whole day + All 24 hours on one screen, no scrolling + Compact + More hours per screen, smaller blocks + Regular + The standard spacing + Comfortable + Roomier blocks, more scrolling Dim completed events Fade events that have already ended in month and week view Past events diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 7693801..05f97b9 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -98,6 +99,16 @@ class SettingsPrefsTest { assertThat(prefs.showHourLines.first()).isTrue() } + @Test + fun `timeline scale defaults to regular and round-trips`(@TempDir tempDir: Path) = runTest { + // Regular is the historical 56dp scale — an existing install that never + // opened the setting must keep the timeline it had (#56). + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.Regular) + prefs.setTimelineScale(TimelineScale.FitDay) + assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.FitDay) + } + @Test fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest { val prefs = SettingsPrefs(newDataStore(tempDir)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt new file mode 100644 index 0000000..cd394e6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt @@ -0,0 +1,74 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class TimelineScaleTest { + + /** A Pixel-7-ish timeline viewport: what the issue reporter is looking at. */ + private val phoneViewport = 670.dp + + @Test + fun `Regular is the timeline's original 56dp constant`() { + // The default must not move: an install that never opens the setting has + // to keep the week and day views it already had (#56). + assertThat(TimelineScale.Regular.hourHeight(phoneViewport)).isEqualTo(56.dp) + } + + @Test + fun `the fixed scales ignore the viewport`() { + for (scale in listOf(TimelineScale.Compact, TimelineScale.Regular, TimelineScale.Comfortable)) { + assertThat(scale.hourHeight(200.dp)).isEqualTo(scale.hourHeight(2000.dp)) + } + } + + @Test + fun `the fixed scales get taller in listed order`() { + assertThat(TimelineScale.Compact.hourHeight(phoneViewport)) + .isLessThan(TimelineScale.Regular.hourHeight(phoneViewport)) + assertThat(TimelineScale.Regular.hourHeight(phoneViewport)) + .isLessThan(TimelineScale.Comfortable.hourHeight(phoneViewport)) + } + + @Test + fun `fit-day puts all 24 hours inside a phone viewport`() { + // The whole point of the issue: no vertical scrolling to see the day. + val h = TimelineScale.FitDay.hourHeight(phoneViewport) + assertThat(h * 24).isAtMost(phoneViewport) + // …and it uses the space, rather than leaving most of it empty. + assertThat(h * 24).isGreaterThan(phoneViewport * 0.9f) + } + + @Test + fun `fit-day clamps instead of shrinking hours past legibility`() { + // A very short viewport (split screen, tiny device) would otherwise give + // hour rows too small for the gutter's 24 labels; the clamp wins and the + // timeline keeps a little scroll. + assertThat(TimelineScale.FitDay.hourHeight(120.dp)).isEqualTo(FIT_DAY_MIN) + } + + @Test + fun `fit-day clamps instead of stretching hours on a very tall viewport`() { + assertThat(TimelineScale.FitDay.hourHeight(4000.dp)).isEqualTo(FIT_DAY_MAX) + } + + @Test + fun `the minimum event height matches the old 24dp floor at the default scale`() { + // MIN_EVENT_FRACTION replaced a hardcoded 24dp; at Regular it must still + // land there, or short events change size for everyone who never touched + // the setting. + val floor = TimelineScale.Regular.hourHeight(phoneViewport) * MIN_EVENT_FRACTION + assertThat(floor.value).isWithin(0.5f).of(24f) + } + + @Test + fun `the minimum event height stays a fixed share of an hour`() { + // A fixed dp floor would swallow ever more of the day as the scale drops; + // as a fraction it always means the same duration. + for (scale in TimelineScale.entries) { + val hour = scale.hourHeight(phoneViewport) + assertThat((hour * MIN_EVENT_FRACTION) / hour).isWithin(0.001f).of(MIN_EVENT_FRACTION) + } + } +}