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.
This commit is contained in:
2026-07-31 15:52:05 +02:00
parent 0097a9c534
commit 37995d1fb3
11 changed files with 377 additions and 81 deletions

View File

@@ -29,6 +29,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines 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.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
@@ -141,6 +142,7 @@ class MainActivity : AppCompatActivity() {
CompositionLocalProvider( CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour, LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines, LocalShowHourLines provides settings.showHourLines,
LocalTimelineScale provides settings.timelineScale,
LocalSoftenColors provides settings.softenColors, LocalSoftenColors provides settings.softenColors,
) { ) {
RootScreen( RootScreen(

View File

@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig 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.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import de.jeanlucmakiola.calendula.widget.WidgetSize import de.jeanlucmakiola.calendula.widget.WidgetSize
@@ -270,6 +271,18 @@ class SettingsPrefs @Inject constructor(
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name } 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<TimelineScale> = 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 * 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 "+" * 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 DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers") internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style") 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 TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")

View File

@@ -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
}

View File

@@ -87,6 +87,9 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines 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.formatHourLabel
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
@@ -100,13 +103,11 @@ import kotlin.time.Clock
import java.util.Locale import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp private val GUTTER_WIDTH = 48.dp
/** Start inset for the gutter's hour labels so they centre on the top bar's /** 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 * 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. */ * 4dp inset + 24dp half icon button), matching the week view. */
private val GUTTER_CONTENT_START_INSET = 8.dp 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_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp
@@ -264,7 +265,6 @@ private fun DayContent(
onCreateAt: (LocalDate, Int) -> Unit, onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val density = LocalDensity.current
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
@@ -275,11 +275,9 @@ private fun DayContent(
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 } snapshotFlow { scrollState.maxValue }.first { it > 0 }
val maxV = scrollState.maxValue // Half the scroll range *is* noon: the content spans a full 24 hours, so
val target = with(density) { // centring the range centres midday at whatever hour height is in force.
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt() scrollState.scrollTo(scrollState.maxValue / 2)
}.coerceIn(0, maxV)
scrollState.scrollTo(target)
} }
// Single, hoisted all-day strip height — shared by the outgoing and incoming // Single, hoisted all-day strip height — shared by the outgoing and incoming
@@ -485,12 +483,17 @@ private fun Timeline(
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit, onCreateAt: (LocalDate, Int) -> Unit,
) { ) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale() 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 // Gutter and day column are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a // state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the // static, rounded-clipped window — the content scrolls inside it, so the
@@ -509,7 +512,7 @@ private fun Timeline(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(HOUR_HEIGHT), .height(hourHeight),
) { ) {
if (h > 0) { if (h > 0) {
Text( Text(
@@ -537,6 +540,7 @@ private fun Timeline(
dark = dark, dark = dark,
date = state.date, date = state.date,
today = state.today, today = state.today,
hourHeight = hourHeight,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateAt = onCreateAt, onCreateAt = onCreateAt,
modifier = Modifier modifier = Modifier
@@ -554,11 +558,12 @@ private fun DayColumnCard(
dark: Boolean, dark: Boolean,
date: LocalDate, date: LocalDate,
today: LocalDate, today: LocalDate,
hourHeight: Dp,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit, onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourLines = LocalShowHourLines.current val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant val hourLineColor = MaterialTheme.colorScheme.outlineVariant
Card( Card(
@@ -587,14 +592,16 @@ private fun DayColumnCard(
}, },
) { ) {
val colWidth = maxWidth val colWidth = maxWidth
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
blocks.forEach { block -> blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount val laneWidth = colWidth / block.laneCount
val top = HOUR_HEIGHT * (block.startMin / 60f) val top = hourHeight * (block.startMin / 60f)
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f) val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
EventBlock( EventBlock(
block = block, block = block,
dark = dark, dark = dark,
height = height,
onClick = { onEventClick(block.event) }, onClick = { onEventClick(block.event) },
modifier = Modifier modifier = Modifier
.offset(x = laneWidth * block.lane, y = top) .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. // Current-time line, on top of the events, only on today's column.
if (date == today) { if (date == today) {
NowLine(date = date, hourHeight = HOUR_HEIGHT) NowLine(date = date, hourHeight = hourHeight)
} }
} }
} }
@@ -615,6 +622,7 @@ private fun DayColumnCard(
private fun EventBlock( private fun EventBlock(
block: TimedBlock, block: TimedBlock,
dark: Boolean, dark: Boolean,
height: Dp,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -623,7 +631,20 @@ private fun EventBlock(
val locale = currentLocale() val locale = currentLocale()
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" + val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, 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 soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften) val fill = eventFill(block.event.color, dark, soften)
Box( Box(
@@ -634,13 +655,15 @@ private fun EventBlock(
.semantics { contentDescription = "$title, $timeLabel" }, .semantics { contentDescription = "$title, $timeLabel" },
) { ) {
Column { Column {
Text( if (showTitle) {
text = title, Text(
style = MaterialTheme.typography.labelMedium, text = title,
maxLines = if (showTime) 1 else 2, style = MaterialTheme.typography.labelMedium,
overflow = TextOverflow.Ellipsis, maxLines = if (showTime) 1 else 2,
color = eventInk(fill, alpha = 0.85f), overflow = TextOverflow.Ellipsis,
) color = eventInk(fill, alpha = 0.85f),
)
}
if (showTime) { if (showTime) {
Text( Text(
text = timeLabel, text = timeLabel,
@@ -656,17 +679,22 @@ private fun EventBlock(
@Composable @Composable
private fun DayLoading() { private fun DayLoading() {
val totalHeight = HOUR_HEIGHT * 24 val scale = LocalTimelineScale.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
Spacer(Modifier.width(GUTTER_WIDTH)) // Same scale resolution as the loaded timeline, so the skeleton's column
Box( // doesn't resize the moment the real day arrives.
modifier = Modifier val totalHeight = scale.hourHeight(maxHeight) * 24
.weight(1f) Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
.height(totalHeight) Spacer(Modifier.width(GUTTER_WIDTH))
.padding(horizontal = 2.dp) Box(
.background(MaterialTheme.colorScheme.surfaceContainer), modifier = Modifier
) .weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceContainer),
)
}
} }
} }

View File

@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig 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.month.MonthViewStyle
import de.jeanlucmakiola.calendula.widget.WidgetSize import de.jeanlucmakiola.calendula.widget.WidgetSize
@@ -57,6 +58,8 @@ data class SettingsUiState(
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default, val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
/** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */ /** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged, 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. */ /** Order of the views in the navigation drawer (#24); every view is always listed. */
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS, val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
/** Optional event-form fields shown by default (rest behind "more fields"). */ /** Optional event-form fields shown by default (rest behind "more fields"). */

View File

@@ -36,6 +36,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig 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.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
@@ -164,8 +165,9 @@ class SettingsViewModel @Inject constructor(
prefs.drawerViewOrder, prefs.drawerViewOrder,
prefs.monthViewStyle, prefs.monthViewStyle,
prefs.widgetSize, prefs.widgetSize,
) { quickSwitch, drawer, monthStyle, widgetSize -> prefs.timelineScale,
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize) ) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale ->
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale)
}, },
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization -> ) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization) MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
@@ -189,6 +191,7 @@ class SettingsViewModel @Inject constructor(
drawerViewOrder = misc.viewCustomization.drawerOrder, drawerViewOrder = misc.viewCustomization.drawerOrder,
monthViewStyle = misc.viewCustomization.monthViewStyle, monthViewStyle = misc.viewCustomization.monthViewStyle,
widgetSize = misc.viewCustomization.widgetSize, widgetSize = misc.viewCustomization.widgetSize,
timelineScale = misc.viewCustomization.timelineScale,
allowColorOnUnsupportedCalendars = defaults.allowColor, allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder, defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder, defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -295,6 +298,7 @@ class SettingsViewModel @Inject constructor(
val drawerOrder: List<CalendarView>, val drawerOrder: List<CalendarView>,
val monthViewStyle: MonthViewStyle, val monthViewStyle: MonthViewStyle,
val widgetSize: WidgetSize, val widgetSize: WidgetSize,
val timelineScale: TimelineScale,
) )
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */ /** 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) } viewModelScope.launch { prefs.setMonthViewStyle(style) }
} }
fun setTimelineScale(scale: TimelineScale) {
viewModelScope.launch { prefs.setTimelineScale(scale) }
}
fun setDrawerViewOrder(order: List<CalendarView>) { fun setDrawerViewOrder(order: List<CalendarView>) {
viewModelScope.launch { prefs.setDrawerViewOrder(order) } viewModelScope.launch { prefs.setDrawerViewOrder(order) }
} }

View File

@@ -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.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.PickerDescription import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig 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.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.icon import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.labelRes import de.jeanlucmakiola.calendula.ui.common.labelRes
@@ -68,6 +70,7 @@ internal fun ViewsScreen(
var showTimeFormat by remember { mutableStateOf(false) } var showTimeFormat by remember { mutableStateOf(false) }
var showPastEvents by remember { mutableStateOf(false) } var showPastEvents by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) } var showAgendaScreenRange by remember { mutableStateOf(false) }
var showTimelineScale by remember { mutableStateOf(false) }
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.settings_section_views), title = stringResource(R.string.settings_section_views),
@@ -144,10 +147,16 @@ internal fun ViewsScreen(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
SectionHeader(stringResource(R.string.settings_week_day_header)) 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( GroupedRow(
title = stringResource(R.string.settings_hour_lines), title = stringResource(R.string.settings_hour_lines),
summary = stringResource(R.string.settings_hour_lines_summary), summary = stringResource(R.string.settings_hour_lines_summary),
position = Position.Alone, position = Position.Bottom,
trailing = { trailing = {
Switch( Switch(
checked = state.showHourLines, checked = state.showHourLines,
@@ -321,6 +330,19 @@ internal fun ViewsScreen(
onDismiss = { showPastEvents = false }, 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) { if (showAgendaScreenRange) {
AgendaRangePicker( AgendaRangePicker(
title = stringResource(R.string.settings_agenda_range), title = stringResource(R.string.settings_agenda_range),

View File

@@ -97,6 +97,10 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines 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.formatHourLabel
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
@@ -113,15 +117,12 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
import kotlin.math.roundToInt
private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp private val GUTTER_WIDTH = 48.dp
/** Start inset for the gutter's content (week badge + hour labels) so it centres /** 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 * 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). */ * (the app bar's 4dp inset + 24dp half icon button). */
private val GUTTER_CONTENT_START_INSET = 8.dp 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_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp
@@ -294,7 +295,6 @@ private fun WeekContent(
onCreateAt: (LocalDate, Int) -> Unit, onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val density = LocalDensity.current
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
@@ -306,11 +306,9 @@ private fun WeekContent(
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 } snapshotFlow { scrollState.maxValue }.first { it > 0 }
val maxV = scrollState.maxValue // Half the scroll range *is* noon: the content spans a full 24 hours, so
val target = with(density) { // centring the range centres midday at whatever hour height is in force.
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt() scrollState.scrollTo(scrollState.maxValue / 2)
}.coerceIn(0, maxV)
scrollState.scrollTo(target)
} }
// Single, hoisted all-day strip height — shared by the outgoing and incoming // Single, hoisted all-day strip height — shared by the outgoing and incoming
@@ -620,12 +618,17 @@ private fun Timeline(
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit, onCreateAt: (LocalDate, Int) -> Unit,
) { ) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale() 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 // Gutter and day columns are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a // state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the // static, rounded-clipped window — the content scrolls inside it, so the
@@ -645,7 +648,7 @@ private fun Timeline(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(HOUR_HEIGHT), .height(hourHeight),
) { ) {
if (h > 0) { if (h > 0) {
Text( Text(
@@ -680,6 +683,7 @@ private fun Timeline(
dark = dark, dark = dark,
date = day, date = day,
today = state.today, today = state.today,
hourHeight = hourHeight,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateAt = onCreateAt, onCreateAt = onCreateAt,
modifier = Modifier modifier = Modifier
@@ -699,11 +703,12 @@ private fun DayColumnCard(
dark: Boolean, dark: Boolean,
date: LocalDate, date: LocalDate,
today: LocalDate, today: LocalDate,
hourHeight: Dp,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit, onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourLines = LocalShowHourLines.current val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant val hourLineColor = MaterialTheme.colorScheme.outlineVariant
Card( Card(
@@ -731,15 +736,17 @@ private fun DayColumnCard(
}, },
) { ) {
val colWidth = maxWidth val colWidth = maxWidth
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
blocks.forEach { block -> blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount val laneWidth = colWidth / block.laneCount
val top = HOUR_HEIGHT * (block.startMin / 60f) val top = hourHeight * (block.startMin / 60f)
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f) val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
EventBlock( EventBlock(
block = block, block = block,
dark = dark, dark = dark,
height = height, height = height,
width = laneWidth,
onClick = { onEventClick(block.event) }, onClick = { onEventClick(block.event) },
modifier = Modifier modifier = Modifier
.offset(x = laneWidth * block.lane, y = top) .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. // Current-time line, on top of the events, only on today's column.
if (date == today) { if (date == today) {
NowLine(date = date, hourHeight = HOUR_HEIGHT) NowLine(date = date, hourHeight = hourHeight)
} }
} }
} }
@@ -761,6 +768,7 @@ private fun EventBlock(
block: TimedBlock, block: TimedBlock,
dark: Boolean, dark: Boolean,
height: Dp, height: Dp,
width: Dp,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -769,10 +777,6 @@ private fun EventBlock(
val locale = currentLocale() val locale = currentLocale()
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" + val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, 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 density = LocalDensity.current
val titleLineHeight = with(density) { val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp() MaterialTheme.typography.labelMedium.lineHeight.toDp()
@@ -780,11 +784,30 @@ private fun EventBlock(
val timeLineHeight = with(density) { val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp() MaterialTheme.typography.labelSmall.lineHeight.toDp()
} }
// Wrap the title across as many lines as the block can fit (minus the 2.dp // What's left for text once the 2.dp top/bottom padding is paid for.
// top/bottom padding and the reserved time line) instead of clipping it to a val available = height - 4.dp
// single character on slim, overlapping blocks. // Only full-width (non-overlapping) blocks that are tall enough show the
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp // time. On narrow overlapping columns we drop it so the title can wrap to
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) // 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 dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
@@ -797,13 +820,15 @@ private fun EventBlock(
.semantics { contentDescription = "$title, $timeLabel" }, .semantics { contentDescription = "$title, $timeLabel" },
) { ) {
Column { Column {
Text( if (showTitle) {
text = title, Text(
style = MaterialTheme.typography.labelMedium, text = title,
maxLines = titleMaxLines, style = MaterialTheme.typography.labelMedium,
overflow = TextOverflow.Ellipsis, maxLines = titleMaxLines,
color = eventInk(fill, alpha = 0.85f), overflow = TextOverflow.Ellipsis,
) color = eventInk(fill, alpha = 0.85f),
)
}
if (showTime) { if (showTime) {
Text( Text(
text = timeLabel, text = timeLabel,
@@ -819,7 +844,7 @@ private fun EventBlock(
@Composable @Composable
private fun WeekLoading() { private fun WeekLoading() {
val totalHeight = HOUR_HEIGHT * 24 val scale = LocalTimelineScale.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Header skeleton // Header skeleton
@@ -838,16 +863,21 @@ private fun WeekLoading() {
) )
} }
} }
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
Spacer(Modifier.width(GUTTER_WIDTH)) // Same scale resolution as the loaded timeline, so the skeleton's
repeat(7) { // columns don't resize the moment the real week arrives.
Box( val totalHeight = scale.hourHeight(maxHeight) * 24
modifier = Modifier Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
.weight(1f) Spacer(Modifier.width(GUTTER_WIDTH))
.height(totalHeight) repeat(7) {
.padding(horizontal = 2.dp) Box(
.background(MaterialTheme.colorScheme.surfaceContainer), modifier = Modifier
) .weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceContainer),
)
}
} }
} }
} }

View File

@@ -363,6 +363,16 @@
<string name="settings_time_format_auto_summary">Following the system: %1$s</string> <string name="settings_time_format_auto_summary">Following the system: %1$s</string>
<string name="settings_hour_lines">Hour lines</string> <string name="settings_hour_lines">Hour lines</string>
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string> <string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
<string name="settings_timeline_scale">Hour height</string>
<string name="settings_timeline_scale_hint">How much vertical space one hour takes in week and day view. Both views share this setting.</string>
<string name="timeline_scale_fit_day">Fit whole day</string>
<string name="timeline_scale_fit_day_summary">All 24 hours on one screen, no scrolling</string>
<string name="timeline_scale_compact">Compact</string>
<string name="timeline_scale_compact_summary">More hours per screen, smaller blocks</string>
<string name="timeline_scale_regular">Regular</string>
<string name="timeline_scale_regular_summary">The standard spacing</string>
<string name="timeline_scale_comfortable">Comfortable</string>
<string name="timeline_scale_comfortable_summary">Roomier blocks, more scrolling</string>
<string name="settings_dim_completed">Dim completed events</string> <string name="settings_dim_completed">Dim completed events</string>
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string> <string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
<string name="settings_past_events">Past events</string> <string name="settings_past_events">Past events</string>

View File

@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig 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.month.MonthViewStyle
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -98,6 +99,16 @@ class SettingsPrefsTest {
assertThat(prefs.showHourLines.first()).isTrue() 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 @Test
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest { fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))

View File

@@ -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)
}
}
}