Fit a chosen hour range into the timeline on open (#322)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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<TimelineHours> = 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<Int> = 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
|
||||
|
||||
@@ -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<Int> = (0..endHour - MIN_SPAN).toList()
|
||||
|
||||
/** End hours at least [MIN_SPAN] after [startHour]; 24 is midnight. */
|
||||
fun endOptions(startHour: Int): List<Int> = (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)
|
||||
}
|
||||
@@ -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<TimelineScale> = 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<TimelineScale, String> = 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||
|
||||
@@ -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<CalendarView>) {
|
||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -489,6 +489,13 @@
|
||||
<string name="timeline_scale_comfortable_summary">Roomier blocks, more scrolling</string>
|
||||
<string name="timeline_scale_custom">Custom</string>
|
||||
<string name="timeline_scale_custom_summary">The height you pinched the timeline to</string>
|
||||
<string name="settings_visible_hours">Visible hours</string>
|
||||
<string name="settings_visible_hours_summary">Fit a range of hours to the screen when week, multi-day or day view opens</string>
|
||||
<string name="settings_visible_hours_on_summary">%1$s – %2$s fills the screen when a view opens</string>
|
||||
<string name="settings_visible_hours_start">From</string>
|
||||
<string name="settings_visible_hours_end">Until</string>
|
||||
<string name="settings_visible_hours_start_hint">The hour at the top of the screen when week, multi-day or day view opens. Earlier hours are still a scroll away.</string>
|
||||
<string name="settings_visible_hours_end_hint">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.</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_drag_to_reschedule">Drag to reschedule</string>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<TimelineScale>()
|
||||
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<TimelineScale>()
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user