From ddf3e203a01c7afd72eed98f4c661d1c315fb8ed Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 11:20:46 +0200 Subject: [PATCH 1/3] Fit a chosen hour range into the timeline on open (#322) --- .../jeanlucmakiola/calendula/MainActivity.kt | 1 + .../calendula/data/prefs/SettingsPrefs.kt | 25 ++++ .../calendula/ui/common/TimelineHours.kt | 58 +++++++++ .../calendula/ui/common/TimelineScale.kt | 21 ++++ .../calendula/ui/common/TimelineZoom.kt | 71 ++++++++++- .../calendula/ui/day/DayScreen.kt | 13 +- .../calendula/ui/settings/SettingsUiState.kt | 2 + .../ui/settings/SettingsViewModel.kt | 15 ++- .../calendula/ui/settings/ViewsSettings.kt | 69 +++++++++++ .../calendula/ui/week/WeekScreen.kt | 15 +-- app/src/main/res/values/strings.xml | 7 ++ .../calendula/data/prefs/SettingsPrefsTest.kt | 19 +++ .../calendula/ui/common/TimelineHoursTest.kt | 115 ++++++++++++++++++ 13 files changed, 404 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 8801b44..347b910 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -170,6 +170,7 @@ class MainActivity : AppCompatActivity() { // weeks and switching between the week and day view. val timelineZoom = rememberTimelineZoom( stored = settings.timelineScale, + hours = settings.timelineHours, onPersist = settingsViewModel::setTimelineScale, ) val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle() 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 c911efa..f919d49 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.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale import de.jeanlucmakiola.calendula.ui.common.storageValue @@ -287,6 +288,27 @@ class SettingsPrefs @Inject constructor( store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() } } + /** + * The hours the day, week and multi-day timelines fit to the screen when they + * open (#322). Off by default; the hours survive switching it off. + */ + val timelineHours: Flow = store.data.map { prefs -> + TimelineHours.of( + enabled = prefs[TIMELINE_HOURS_ENABLED_KEY] ?: false, + startHour = prefs[TIMELINE_HOURS_START_KEY] ?: TimelineHours.DEFAULT_START_HOUR, + endHour = prefs[TIMELINE_HOURS_END_KEY] ?: TimelineHours.DEFAULT_END_HOUR, + ) + } + + suspend fun setTimelineHours(hours: TimelineHours) { + val valid = TimelineHours.of(hours.enabled, hours.startHour, hours.endHour) + store.edit { + it[TIMELINE_HOURS_ENABLED_KEY] = valid.enabled + it[TIMELINE_HOURS_START_KEY] = valid.startHour + it[TIMELINE_HOURS_END_KEY] = valid.endHour + } + } + /** How many days the multi-day view shows side by side (#193). */ val multiDayCount: Flow = store.data.map { prefs -> (prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS) @@ -1037,6 +1059,9 @@ class SettingsPrefs @Inject constructor( private const val MINUTES_PER_DAY = 1_440 internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes") internal val MULTI_DAY_COUNT_KEY = intPreferencesKey("multi_day_count") + internal val TIMELINE_HOURS_ENABLED_KEY = booleanPreferencesKey("timeline_hours_enabled") + internal val TIMELINE_HOURS_START_KEY = intPreferencesKey("timeline_hours_start") + internal val TIMELINE_HOURS_END_KEY = intPreferencesKey("timeline_hours_end") /** Default snooze delay for the notification "Snooze" action. */ const val DEFAULT_SNOOZE_MINUTES = 10 const val DEFAULT_MULTI_DAY_COUNT = 3 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt new file mode 100644 index 0000000..1efb986 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt @@ -0,0 +1,58 @@ +package de.jeanlucmakiola.calendula.ui.common + +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * The hours the week and day timelines fit to the screen when they open (#322). + * + * Nothing outside the range is hidden: [TimelineScale.FitHours] only sizes the + * hour so that [startHour]..[endHour] fills the viewport, and the timeline opens + * scrolled to [startHour]. The hours are kept when [enabled] is switched off, so + * switching it back on restores the chosen range. + */ +data class TimelineHours( + val enabled: Boolean = false, + val startHour: Int = DEFAULT_START_HOUR, + val endHour: Int = DEFAULT_END_HOUR, +) { + val span: Int get() = endHour - startHour + + companion object { + const val DEFAULT_START_HOUR = 7 + const val DEFAULT_END_HOUR = 22 + + /** Shortest range that can be chosen, in hours. */ + const val MIN_SPAN = 2 + + /** + * A range with [startHour] and [endHour] held to the day and at least + * [MIN_SPAN] apart; the start gives way when the two collide. + */ + fun of(enabled: Boolean, startHour: Int, endHour: Int): TimelineHours { + val end = endHour.coerceIn(MIN_SPAN, 24) + val start = startHour.coerceIn(0, end - MIN_SPAN) + return TimelineHours(enabled, start, end) + } + + /** Start hours that still leave [MIN_SPAN] before [endHour]. */ + fun startOptions(endHour: Int): List = (0..endHour - MIN_SPAN).toList() + + /** End hours at least [MIN_SPAN] after [startHour]; 24 is midnight. */ + fun endOptions(startHour: Int): List = (startHour + MIN_SPAN..24).toList() + } +} + +/** + * The scroll offset that puts [startHour] at the top of a timeline laid out + * with [maxValue] scroll range over a [viewportPx] viewport, or null while that + * layout is not yet at [hourPx] per hour — the scale changes a frame before the + * content grows to match it, and scrolling against the old layout would land on + * the wrong hour. + */ +internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? { + if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null + val laidOutHourPx = (maxValue + viewportPx) / 24f + if (abs(laidOutHourPx - hourPx) > 1f) return null + return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue) +} 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 index d7c69f1..227e5f6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt @@ -38,6 +38,12 @@ sealed interface TimelineScale { /** A height the user pinched to. Build it through [custom], which clamps. */ data class Custom(val hourHeight: Dp) : TimelineScale + /** + * [startHour]..[endHour] fills the viewport (#322). Applied when a timeline + * opens with [TimelineHours] on, never picked from the preset list. + */ + data class FitHours(val startHour: Int, val endHour: Int) : TimelineScale + companion object { /** The named steps the settings picker offers, coarse to roomy. */ val presets: List = listOf(FitDay, Compact, Regular, Comfortable) @@ -74,6 +80,9 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) { is TimelineScale.Custom -> this.hourHeight .coerceAtMost(MAX_PINCH_HOUR_HEIGHT) .coerceAtLeast(fillHourHeight(viewportHeight)) + is TimelineScale.FitHours -> (viewportHeight / (endHour - startHour).coerceIn(1, 24).toFloat()) + .coerceAtMost(MAX_PINCH_HOUR_HEIGHT) + .coerceAtLeast(fillHourHeight(viewportHeight)) } /** @@ -134,6 +143,7 @@ val TimelineScale.labelRes: Int TimelineScale.Regular -> R.string.timeline_scale_regular TimelineScale.Comfortable -> R.string.timeline_scale_comfortable is TimelineScale.Custom -> R.string.timeline_scale_custom + is TimelineScale.FitHours -> R.string.settings_visible_hours } @get:StringRes @@ -144,11 +154,15 @@ val TimelineScale.descriptionRes: Int TimelineScale.Regular -> R.string.timeline_scale_regular_summary TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary is TimelineScale.Custom -> R.string.timeline_scale_custom_summary + is TimelineScale.FitHours -> R.string.settings_visible_hours_summary } /** Marks a stored custom height; the rest of the value is its dp. */ private const val CUSTOM_PREFIX = "custom:" +/** Marks a stored hour range, as `start-end`. */ +private const val HOURS_PREFIX = "hours:" + /** * Stored names for the presets. Spelled out rather than taken from `toString()` * so R8 can't rename them out from under an existing install — and they match @@ -171,6 +185,7 @@ private val PRESET_NAMES: Map = mapOf( */ fun TimelineScale.storageValue(): String = when (this) { is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value + is TimelineScale.FitHours -> "$HOURS_PREFIX$startHour-$endHour" TimelineScale.FitDay, TimelineScale.Compact, TimelineScale.Regular, @@ -188,5 +203,11 @@ fun parseTimelineScale(stored: String?): TimelineScale = when { stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull() ?.let { TimelineScale.custom(it.dp) } ?: TimelineScale.Regular + stored.startsWith(HOURS_PREFIX) -> stored.removePrefix(HOURS_PREFIX).split('-') + .mapNotNull { it.toIntOrNull() } + .takeIf { it.size == 2 } + ?.let { (start, end) -> TimelineHours.of(enabled = true, start, end) } + ?.let { TimelineScale.FitHours(it.startHour, it.endHour) } + ?: TimelineScale.Regular else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt index 69527f9..2580e62 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt @@ -13,11 +13,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlin.math.abs import kotlin.math.floor import kotlin.math.roundToInt @@ -49,13 +53,41 @@ class TimelineZoom( var isPinching: Boolean by mutableStateOf(false) private set + /** The visible-hours preference (#322); a timeline re-fits when it changes. */ + var hours: TimelineHours by mutableStateOf(TimelineHours()) + private set + + private var stored: TimelineScale = initial + /** * Take a value that came from the preference. Ignored mid-pinch: the stored * value is a frame or two behind the fingers there, and letting it land would - * snap the timeline back while the user is still pinching. + * snap the timeline back while the user is still pinching. With visible + * hours on it is only remembered, so a late-arriving preference can't undo + * the fit a timeline just opened with. */ fun adopt(stored: TimelineScale) { - if (!isPinching) scale = stored + this.stored = stored + if (!isPinching && !hours.enabled) scale = stored + } + + /** Take the visible-hours preference; switching it off hands back the stored scale. */ + fun adoptHours(hours: TimelineHours) { + val wasEnabled = this.hours.enabled + this.hours = hours + if (wasEnabled && !hours.enabled && !isPinching) scale = stored + } + + /** + * A timeline is opening. With visible hours on, the range replaces whatever + * scale is in force — a stored preset and a pinch alike — and its start hour + * is returned for the timeline to scroll to; otherwise null. + */ + fun open(): Int? { + val hours = hours + if (!hours.enabled) return null + if (!isPinching) scale = TimelineScale.FitHours(hours.startHour, hours.endHour) + return hours.startHour } fun beginPinch() { @@ -66,9 +98,10 @@ class TimelineZoom( scale = TimelineScale.custom(hourHeight) } + /** Settles the pinch. With visible hours on it only lasts until the next [open]. */ fun endPinch() { isPinching = false - persist(scale) + if (!hours.enabled) persist(scale) } } @@ -82,16 +115,18 @@ private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) { val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom } /** - * The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches - * back through [onPersist]. + * The app-wide [TimelineZoom], seeded from [stored] and [hours] and writing + * settled pinches back through [onPersist]. */ @Composable fun rememberTimelineZoom( stored: TimelineScale, + hours: TimelineHours, onPersist: (TimelineScale) -> Unit, ): TimelineZoom { val persist by rememberUpdatedState(onPersist) val zoom = remember { TimelineZoom(stored) { persist(it) } } + LaunchedEffect(hours) { zoom.adoptHours(hours) } // Picking a preset in Settings has to reach the timelines, and so does the // stored value arriving after the first frame. LaunchedEffect(stored) { zoom.adopt(stored) } @@ -242,3 +277,29 @@ internal fun anchoredScroll( * as a scroll stays one. */ private const val PINCH_SLOP = 0.08f + +/** + * Where a timeline opens (#322): scrolled to the start of the visible hours when + * they are on, otherwise centred on noon. Re-runs when the visible-hours + * preference changes, so a range picked in Settings shows on the way back. + */ +@Composable +fun OpenTimelineScroll(scrollState: ScrollState, zoom: TimelineZoom) { + val density = LocalDensity.current + LaunchedEffect(zoom, zoom.hours) { + val startHour = zoom.open() + if (startHour == null) { + snapshotFlow { scrollState.maxValue }.first { it > 0 } + // 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) + } else { + val target = snapshotFlow { + val viewport = scrollState.viewportSize + val hourPx = with(density) { zoom.scale.hourHeight(viewport.toDp()).toPx() } + startHourScroll(scrollState.maxValue, viewport, startHour, hourPx) + }.filterNotNull().first() + scrollState.scrollTo(target) + } + } +} 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 8adc2f6..13b2ef2 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 @@ -131,6 +131,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom +import de.jeanlucmakiola.calendula.ui.common.OpenTimelineScroll import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom @@ -142,7 +143,6 @@ import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.week.TimedBlock -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone @@ -303,15 +303,10 @@ private fun DayContent( val fadeSpec = rememberCalendarFadeSpec() // Shared by every page and the gutter, so the vertical scroll position - // survives day-to-day swipes. We only centre on noon once, on first entry - // into the day view (i.e. when arriving from the month/week view). + // survives day-to-day swipes. Positioned once on entry into the day view: + // at the visible hours' start if set (#322), otherwise noon. val scrollState = rememberScrollState() - LaunchedEffect(Unit) { - snapshotFlow { scrollState.maxValue }.first { it > 0 } - // 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) - } + OpenTimelineScroll(scrollState, LocalTimelineZoom.current) // Above the pager: a page change mid-drag would strand the floating block // inside the outgoing page. 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 2741f5a..16111c1 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.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.widget.WidgetSize @@ -64,6 +65,7 @@ data class SettingsUiState( val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged, /** How tall an hour is drawn in the week and day timelines (#56). */ val timelineScale: TimelineScale = TimelineScale.Regular, + val timelineHours: TimelineHours = TimelineHours(), /** 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 3ca82ab..75ebca1 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.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings @@ -180,9 +181,11 @@ class SettingsViewModel @Inject constructor( prefs.drawerViewOrder, prefs.monthViewStyle, prefs.widgetSize, - prefs.timelineScale, - ) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale -> - ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale) + combine(prefs.timelineScale, prefs.timelineHours, ::Pair), + ) { quickSwitch, drawer, monthStyle, widgetSize, (timelineScale, timelineHours) -> + ViewCustomization( + quickSwitch, drawer, monthStyle, widgetSize, timelineScale, timelineHours, + ) }, ) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization -> MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization) @@ -209,6 +212,7 @@ class SettingsViewModel @Inject constructor( monthViewStyle = misc.viewCustomization.monthViewStyle, widgetSize = misc.viewCustomization.widgetSize, timelineScale = misc.viewCustomization.timelineScale, + timelineHours = misc.viewCustomization.timelineHours, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -328,6 +332,7 @@ class SettingsViewModel @Inject constructor( val monthViewStyle: MonthViewStyle, val widgetSize: WidgetSize, val timelineScale: TimelineScale, + val timelineHours: TimelineHours, ) /** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */ @@ -626,6 +631,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setTimelineScale(scale) } } + fun setTimelineHours(hours: TimelineHours) { + viewModelScope.launch { prefs.setTimelineHours(hours) } + } + 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 2f26da4..9e5723f 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 @@ -34,9 +34,12 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker 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.LocalUse24HourFormat +import de.jeanlucmakiola.calendula.ui.common.TimelineHours 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.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.icon import de.jeanlucmakiola.calendula.ui.common.label @@ -74,6 +77,9 @@ internal fun ViewsScreen( var showAgendaScreenRange by remember { mutableStateOf(false) } var showTimelineScale by remember { mutableStateOf(false) } var showMultiDayCount by remember { mutableStateOf(false) } + var showHoursStart by remember { mutableStateOf(false) } + var showHoursEnd by remember { mutableStateOf(false) } + val hours = state.timelineHours CollapsingScaffold( title = stringResource(R.string.settings_section_views), @@ -174,6 +180,40 @@ internal fun ViewsScreen( position = Position.Middle, onClick = { showTimelineScale = true }, ) + GroupedRow( + title = stringResource(R.string.settings_visible_hours), + summary = if (hours.enabled) { + stringResource( + R.string.settings_visible_hours_on_summary, + hourLabel(hours.startHour), + hourLabel(hours.endHour), + ) + } else { + stringResource(R.string.settings_visible_hours_summary) + }, + position = Position.Middle, + trailing = { + Switch( + checked = hours.enabled, + onCheckedChange = { viewModel.setTimelineHours(hours.copy(enabled = it)) }, + ) + }, + onClick = { viewModel.setTimelineHours(hours.copy(enabled = !hours.enabled)) }, + ) + if (hours.enabled) { + GroupedRow( + title = stringResource(R.string.settings_visible_hours_start), + summary = hourLabel(hours.startHour), + position = Position.Middle, + onClick = { showHoursStart = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_visible_hours_end), + summary = hourLabel(hours.endHour), + position = Position.Middle, + onClick = { showHoursEnd = true }, + ) + } GroupedRow( title = stringResource(R.string.settings_hour_lines), summary = stringResource(R.string.settings_hour_lines_summary), @@ -378,6 +418,30 @@ internal fun ViewsScreen( onDismiss = { showTimelineScale = false }, ) } + if (showHoursStart) { + OptionPicker( + title = stringResource(R.string.settings_visible_hours_start), + header = { PickerDescription(stringResource(R.string.settings_visible_hours_start_hint)) }, + predictiveBack = true, + options = TimelineHours.startOptions(hours.endHour), + selected = hours.startHour, + label = { hourLabel(it) }, + onSelect = { viewModel.setTimelineHours(hours.copy(startHour = it)) }, + onDismiss = { showHoursStart = false }, + ) + } + if (showHoursEnd) { + OptionPicker( + title = stringResource(R.string.settings_visible_hours_end), + header = { PickerDescription(stringResource(R.string.settings_visible_hours_end_hint)) }, + predictiveBack = true, + options = TimelineHours.endOptions(hours.startHour), + selected = hours.endHour, + label = { hourLabel(it) }, + onSelect = { viewModel.setTimelineHours(hours.copy(endHour = it)) }, + onDismiss = { showHoursEnd = false }, + ) + } if (showAgendaScreenRange) { AgendaRangePicker( title = stringResource(R.string.settings_agenda_range), @@ -475,3 +539,8 @@ internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode) @Composable private fun multiDayLabel(count: Int): String = pluralStringResource(R.plurals.view_multi_day, count, count) + +/** A whole hour in the app's clock convention; 24 reads as the end of the day. */ +@Composable +private fun hourLabel(hour: Int): String = + formatMinuteOfDay(hour * 60, LocalUse24HourFormat.current, currentLocale()) 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 855d5fc..7cee5ee 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 @@ -141,6 +141,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom +import de.jeanlucmakiola.calendula.ui.common.OpenTimelineScroll import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom @@ -154,7 +155,6 @@ import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.floret.time.isoWeekNumber -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone @@ -414,16 +414,11 @@ private fun WeekContent( val fadeSpec = rememberCalendarFadeSpec() // Shared by every page and the gutter, so the vertical scroll position - // survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on - // noon once, on first entry into the week view (i.e. when arriving from the - // month/day view), not on every swipe. + // survives week-to-week swipes (e.g. 18:00 stays centred). Positioned once + // on entry into the week view, not on every swipe: at the visible hours' + // start if set (#322), otherwise noon. val scrollState = rememberScrollState() - LaunchedEffect(Unit) { - snapshotFlow { scrollState.maxValue }.first { it > 0 } - // 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) - } + OpenTimelineScroll(scrollState, LocalTimelineZoom.current) // Above the pager: a page change mid-drag would strand the floating block // inside the outgoing page. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e912658..87ead00 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -489,6 +489,13 @@ Roomier blocks, more scrolling Custom The height you pinched the timeline to + Visible hours + Fit a range of hours to the screen when week, multi-day or day view opens + %1$s – %2$s fills the screen when a view opens + From + Until + The hour at the top of the screen when week, multi-day or day view opens. Earlier hours are still a scroll away. + The hour at the bottom of the screen when a view opens. Later hours are still a scroll away, and pinching zooms until the view is opened again. Dim completed events Fade events that have already ended in month and week view Drag to reschedule 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 7c77993..42fc64f 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 @@ -14,6 +14,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.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import kotlinx.coroutines.flow.first @@ -125,6 +126,24 @@ class SettingsPrefsTest { assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.Comfortable) } + @Test + fun `visible hours default off and round-trip`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = false, 7, 22)) + prefs.setTimelineHours(TimelineHours(enabled = true, 6, 20)) + assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = true, 6, 20)) + // Switching off keeps the chosen hours for next time. + prefs.setTimelineHours(TimelineHours(enabled = false, 6, 20)) + assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = false, 6, 20)) + } + + @Test + fun `visible hours are stored valid`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + prefs.setTimelineHours(TimelineHours(enabled = true, 23, 22)) + assertThat(prefs.timelineHours.first()).isEqualTo(TimelineHours(enabled = true, 20, 22)) + } + @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/TimelineHoursTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt new file mode 100644 index 0000000..f7c0610 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt @@ -0,0 +1,115 @@ +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 TimelineHoursTest { + + private val viewport = 600.dp + + @Test + fun `the chosen range exactly fills the viewport`() { + // 07–22 is 15 hours: 600dp / 15 = 40dp an hour. + assertThat(TimelineScale.FitHours(7, 22).hourHeight(viewport)).isEqualTo(40.dp) + } + + @Test + fun `the whole day fits like FitDay`() { + assertThat(TimelineScale.FitHours(0, 24).hourHeight(viewport)) + .isEqualTo(TimelineScale.FitDay.hourHeight(viewport)) + } + + @Test + fun `a short range on a tall screen stops at the pinch ceiling`() { + assertThat(TimelineScale.FitHours(9, 11).hourHeight(2000.dp)).isEqualTo(MAX_PINCH_HOUR_HEIGHT) + } + + @Test + fun `ranges are held to the day and the minimum span`() { + assertThat(TimelineHours.of(true, -3, 30)).isEqualTo(TimelineHours(true, 0, 24)) + assertThat(TimelineHours.of(true, 23, 22)).isEqualTo(TimelineHours(true, 20, 22)) + assertThat(TimelineHours.of(true, 5, 1)).isEqualTo(TimelineHours(true, 0, 2)) + } + + @Test + fun `the pickers only offer valid pairings`() { + assertThat(TimelineHours.startOptions(22)).isEqualTo((0..20).toList()) + assertThat(TimelineHours.endOptions(7)).isEqualTo((9..24).toList()) + } + + @Test + fun `opening scrolls to the start hour once the layout is at the fitted height`() { + // 40px an hour over a 600px viewport: 960px of content, 360px of scroll. + assertThat(startHourScroll(maxValue = 360, viewportPx = 600, startHour = 7, hourPx = 40f)) + .isEqualTo(280) + } + + @Test + fun `a layout still at the previous scale is waited out`() { + // Still 56px an hour: 1344 - 600 = 744 of scroll. + assertThat(startHourScroll(maxValue = 744, viewportPx = 600, startHour = 7, hourPx = 40f)) + .isNull() + // Not laid out at all yet. + assertThat(startHourScroll(maxValue = Int.MAX_VALUE, viewportPx = 0, startHour = 7, hourPx = 40f)) + .isNull() + } + + @Test + fun `the whole day needs no scroll`() { + assertThat(startHourScroll(maxValue = 0, viewportPx = 600, startHour = 0, hourPx = 25f)) + .isEqualTo(0) + } + + @Test + fun `a fitted range round-trips through storage`() { + val scale = TimelineScale.FitHours(6, 20) + assertThat(parseTimelineScale(scale.storageValue())).isEqualTo(scale) + assertThat(parseTimelineScale("hours:x-2")).isEqualTo(TimelineScale.Regular) + } + + @Test + fun `opening with visible hours on replaces a pinched scale`() { + val persisted = mutableListOf() + val zoom = TimelineZoom(TimelineScale.custom(90.dp)) { persisted += it } + zoom.adoptHours(TimelineHours(true, 7, 22)) + + assertThat(zoom.open()).isEqualTo(7) + assertThat(zoom.scale).isEqualTo(TimelineScale.FitHours(7, 22)) + + // A pinch overrides it for the visit but is not saved over the preference. + zoom.beginPinch() + zoom.pinchTo(100.dp) + zoom.endPinch() + assertThat(zoom.scale).isEqualTo(TimelineScale.custom(100.dp)) + assertThat(persisted).isEmpty() + + // Opening again fits the range again. + zoom.open() + assertThat(zoom.scale).isEqualTo(TimelineScale.FitHours(7, 22)) + } + + @Test + fun `a late stored scale does not undo the fit, and switching off restores it`() { + val zoom = TimelineZoom(TimelineScale.Regular) {} + zoom.adoptHours(TimelineHours(true, 7, 22)) + zoom.open() + zoom.adopt(TimelineScale.Comfortable) + assertThat(zoom.scale).isEqualTo(TimelineScale.FitHours(7, 22)) + + zoom.adoptHours(TimelineHours(false, 7, 22)) + assertThat(zoom.open()).isNull() + assertThat(zoom.scale).isEqualTo(TimelineScale.Comfortable) + } + + @Test + fun `with visible hours off a pinch is still saved`() { + val persisted = mutableListOf() + val zoom = TimelineZoom(TimelineScale.Regular) { persisted += it } + assertThat(zoom.open()).isNull() + zoom.beginPinch() + zoom.pinchTo(70.dp) + zoom.endPinch() + assertThat(persisted).containsExactly(TimelineScale.custom(70.dp)) + } +} From 406642050e8a904e41505da599b0b59107590b7b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 11:58:26 +0200 Subject: [PATCH 2/3] Hold the fitted hour height after opening (#322) --- .../calendula/ui/common/TimelineZoom.kt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt index 2580e62..75367b9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineZoom.kt @@ -90,6 +90,14 @@ class TimelineZoom( return hours.startHour } + /** + * Holds the height [open] fitted, so a viewport that changes afterwards (the + * all-day strip following a swipe) doesn't rescale the hours under the scroll. + */ + fun holdFit(hourHeight: Dp) { + if (!isPinching && scale is TimelineScale.FitHours) scale = TimelineScale.Custom(hourHeight) + } + fun beginPinch() { isPinching = true } @@ -294,12 +302,14 @@ fun OpenTimelineScroll(scrollState: ScrollState, zoom: TimelineZoom) { // so centring the range centres midday at whatever hour height is in force. scrollState.scrollTo(scrollState.maxValue / 2) } else { - val target = snapshotFlow { + val (target, hourHeight) = snapshotFlow { val viewport = scrollState.viewportSize - val hourPx = with(density) { zoom.scale.hourHeight(viewport.toDp()).toPx() } - startHourScroll(scrollState.maxValue, viewport, startHour, hourPx) + val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() }) + val hourPx = with(density) { hourHeight.toPx() } + startHourScroll(scrollState.maxValue, viewport, startHour, hourPx)?.let { it to hourHeight } }.filterNotNull().first() scrollState.scrollTo(target) + zoom.holdFit(hourHeight) } } } From 252689038ec8c7b6fda8af8928e9aa8128240b14 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 27 Sep 2026 12:04:32 +0200 Subject: [PATCH 3/3] Hand the timeline scale row over to visible hours while it's on (#322) --- .../calendula/ui/settings/ViewsSettings.kt | 9 +++++++-- app/src/main/res/values/strings.xml | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) 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 9e5723f..4f6e859 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 @@ -176,9 +176,14 @@ internal fun ViewsScreen( ) GroupedRow( title = stringResource(R.string.settings_timeline_scale), - summary = stringResource(state.timelineScale.labelRes), + summary = if (hours.enabled) { + stringResource(R.string.settings_timeline_scale_by_visible_hours) + } else { + stringResource(state.timelineScale.labelRes) + }, position = Position.Middle, - onClick = { showTimelineScale = true }, + dimmed = hours.enabled, + onClick = if (hours.enabled) null else ({ showTimelineScale = true }), ) GroupedRow( title = stringResource(R.string.settings_visible_hours), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 87ead00..82e8ca8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -478,6 +478,7 @@ Multi-day view How many days the multi-day view shows side by side. Today opens in the first column. Hour height + Set by Visible hours How much vertical space one hour takes in week and day view. Both views share this setting. You can also pinch the timeline with two fingers to set any height in between. Fit whole day All 24 hours on one screen, no scrolling