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