Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7492888b1a | ||
|
|
17223cf0bb | ||
|
|
21c770afb3 | ||
|
|
aee23dc751 | ||
|
|
705835a66f | ||
|
|
c2302c033e | ||
|
|
596f76bbaa | ||
|
|
85f77f2332 | ||
|
|
d471255340 | ||
|
|
dcb1e891f1 | ||
|
|
7bf41fa0f8 | ||
|
|
a23ea81c48 | ||
|
|
52aa48fda7 | ||
|
|
264b1ebbf4 | ||
|
|
bd04f7692c | ||
|
|
26de9d369d | ||
|
|
27e5b41038 | ||
|
|
2119a36969 | ||
|
|
8d3081a086 | ||
|
|
d1664b28de | ||
|
|
252689038e | ||
|
|
406642050e | ||
|
|
ddf3e203a0 | ||
|
|
e8ed4c30e1 | ||
|
|
d7315caa20 | ||
|
|
9f05a53ed2 | ||
|
|
9ca10fc6ee |
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Pages can open the split agenda.** Swipe up from a full month to reveal the compact grid and month agenda; tap a day to filter it, then swipe down to return. The transition gives haptic feedback ([#111]).
|
||||||
|
- **Week numbers open their week.** Tap a week number in the month grid to open Week at that row; Back returns to Month ([#197]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Split view opens with the month's agenda.** It starts at today in the current month; tapping a day filters the list, and the filter chip restores the full month. Swiping the list moves by day when filtered and by month otherwise ([#142]).
|
||||||
|
- **Today stands out in the month grid.** Its whole cell has a distinct background; tapping Today briefly expands that cell and nudges its neighbours aside ([#241]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Multi-day events stay above shorter events.** Month and Week bars, Split day lists, and Agenda now place multi-day events before one-day all-day events, then timed events; declined events remain last ([#291]).
|
||||||
|
|
||||||
## [2.21.0] — 2026-09-23
|
## [2.21.0] — 2026-09-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
|||||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
@@ -169,6 +170,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// weeks and switching between the week and day view.
|
// weeks and switching between the week and day view.
|
||||||
val timelineZoom = rememberTimelineZoom(
|
val timelineZoom = rememberTimelineZoom(
|
||||||
stored = settings.timelineScale,
|
stored = settings.timelineScale,
|
||||||
|
hours = settings.timelineHours,
|
||||||
onPersist = settingsViewModel::setTimelineScale,
|
onPersist = settingsViewModel::setTimelineScale,
|
||||||
)
|
)
|
||||||
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||||
@@ -188,6 +190,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
LocalUse24HourFormat provides use24Hour,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
LocalShowHourGrid provides settings.showHourGrid,
|
LocalShowHourGrid provides settings.showHourGrid,
|
||||||
LocalTimelineZoom provides timelineZoom,
|
LocalTimelineZoom provides timelineZoom,
|
||||||
|
LocalMultiDayCount provides settings.multiDayCount,
|
||||||
LocalSoftenColors provides settings.softenColors,
|
LocalSoftenColors provides settings.softenColors,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
RootScreen(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.storageValue
|
import de.jeanlucmakiola.calendula.ui.common.storageValue
|
||||||
@@ -287,6 +288,36 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setMultiDayCount(count: Int) {
|
||||||
|
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
||||||
* historical layout, where it's an extended FAB that fades in above the "+"
|
* historical layout, where it's an extended FAB that fades in above the "+"
|
||||||
@@ -948,10 +979,20 @@ class SettingsPrefs @Inject constructor(
|
|||||||
if (view in config.enabled) view.name else "!${view.name}"
|
if (view in config.enabled) view.name else "!${view.name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep the given order (de-duplicated), then append any views it omits. */
|
/** Keep stored order; insert the new multi-day view after Week on upgrades. */
|
||||||
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
||||||
val ordered = seen.distinct()
|
val ordered = seen.distinct().toMutableList()
|
||||||
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
|
IMPLEMENTED_VIEWS.forEach { view ->
|
||||||
|
if (view !in ordered) {
|
||||||
|
val index = if (view == CalendarView.MultiDay && CalendarView.Week in ordered) {
|
||||||
|
ordered.indexOf(CalendarView.Week) + 1
|
||||||
|
} else {
|
||||||
|
ordered.size
|
||||||
|
}
|
||||||
|
ordered.add(index, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ordered
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -1017,8 +1058,14 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||||
private const val MINUTES_PER_DAY = 1_440
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
|
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. */
|
/** Default snooze delay for the notification "Snooze" action. */
|
||||||
const val DEFAULT_SNOOZE_MINUTES = 10
|
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||||
|
const val DEFAULT_MULTI_DAY_COUNT = 3
|
||||||
|
val MULTI_DAY_COUNTS = 3..6
|
||||||
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
||||||
stringPreferencesKey("per_calendar_reminder_override")
|
stringPreferencesKey("per_calendar_reminder_override")
|
||||||
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ class ReminderNotifier @Inject constructor(
|
|||||||
is24Hour = is24Hour,
|
is24Hour = is24Hour,
|
||||||
today = Instant.now().atZone(zone).toLocalDate(),
|
today = Instant.now().atZone(zone).toLocalDate(),
|
||||||
firstDayOfWeek = firstDayOfWeek,
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
|
todayLabel = context.getString(R.string.reminder_day_today),
|
||||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ import java.util.Locale
|
|||||||
* alone would be ambiguous)
|
* alone would be ambiguous)
|
||||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
||||||
* already unambiguous)
|
* already unambiguous)
|
||||||
* - all-day, one day: "11 Jun 2026"
|
* - all-day, one day: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules,
|
||||||
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
* plus [todayLabel] — no time range to lean on)
|
||||||
|
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
|
||||||
*
|
*
|
||||||
* All-day instances already carry an explicit date, so they never gain a
|
* All-day instances store UTC midnights with an exclusive end, so they are read
|
||||||
* relative prefix. They store UTC midnights with an exclusive end, so they are
|
* in UTC and the end day is the last *covered* day (issue #308).
|
||||||
* read in UTC and the end day is the last *covered* day.
|
|
||||||
*/
|
*/
|
||||||
fun reminderTimeText(
|
fun reminderTimeText(
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
@@ -43,6 +43,7 @@ fun reminderTimeText(
|
|||||||
is24Hour: Boolean,
|
is24Hour: Boolean,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
firstDayOfWeek: DayOfWeek,
|
firstDayOfWeek: DayOfWeek,
|
||||||
|
todayLabel: String,
|
||||||
tomorrowLabel: String,
|
tomorrowLabel: String,
|
||||||
yesterdayLabel: String,
|
yesterdayLabel: String,
|
||||||
): String {
|
): String {
|
||||||
@@ -53,11 +54,16 @@ fun reminderTimeText(
|
|||||||
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
.minusDays(1)
|
.minusDays(1)
|
||||||
.coerceAtLeast(firstDay)
|
.coerceAtLeast(firstDay)
|
||||||
return if (lastDay == firstDay) {
|
val start = relativeDayPrefix(
|
||||||
dateFormat.format(firstDay)
|
day = firstDay,
|
||||||
} else {
|
today = today,
|
||||||
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
}
|
locale = locale,
|
||||||
|
dateFormat = dateFormat,
|
||||||
|
tomorrowLabel = tomorrowLabel,
|
||||||
|
yesterdayLabel = yesterdayLabel,
|
||||||
|
) ?: todayLabel
|
||||||
|
return if (lastDay == firstDay) start else start + RANGE + dateFormat.format(lastDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
|
||||||
|
/** Lower values appear first in lists and get the upper visible lanes in grids. */
|
||||||
|
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
|
||||||
|
isDeclined -> 3
|
||||||
|
spansMultipleDays(zone) -> 0
|
||||||
|
isAllDay -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
|
||||||
|
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
|
||||||
|
compareBy<EventInstance> { it.displayPriority(zone) }
|
||||||
|
.thenBy { it.start }
|
||||||
|
.thenBy { it.title }
|
||||||
|
.thenBy { it.instanceId }
|
||||||
@@ -34,6 +34,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||||
@@ -45,6 +46,7 @@ import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
|||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.MultiDayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
@@ -111,6 +113,11 @@ fun CalendarHost(
|
|||||||
pendingDayIso = date.toString()
|
pendingDayIso = date.toString()
|
||||||
viewStack = viewStack.drillToDay()
|
viewStack = viewStack.drillToDay()
|
||||||
}
|
}
|
||||||
|
var pendingWeekIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
val onOpenWeek: (LocalDate) -> Unit = { date ->
|
||||||
|
pendingWeekIso = date.toString()
|
||||||
|
viewStack = viewStack.drillToWeek()
|
||||||
|
}
|
||||||
|
|
||||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||||
// it overlays whichever calendar view is active. We forward the tapped
|
// it overlays whichever calendar view is active. We forward the tapped
|
||||||
@@ -379,6 +386,20 @@ fun CalendarHost(
|
|||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
todayInToolbar = todayInToolbar,
|
todayInToolbar = todayInToolbar,
|
||||||
|
initialDateIso = pendingWeekIso,
|
||||||
|
onInitialDateConsumed = { pendingWeekIso = null },
|
||||||
|
)
|
||||||
|
CalendarView.MultiDay -> MultiDayScreen(
|
||||||
|
selectedView = currentView,
|
||||||
|
onSelectView = onSelectView,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -396,6 +417,7 @@ fun CalendarHost(
|
|||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onOpenWeek = onOpenWeek,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||||
@@ -86,11 +87,7 @@ fun groupAgendaDays(
|
|||||||
return byDay.map { (date, dayEvents) ->
|
return byDay.map { (date, dayEvents) ->
|
||||||
AgendaDay(
|
AgendaDay(
|
||||||
date = date,
|
date = date,
|
||||||
events = dayEvents.sortedWith(
|
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
|
||||||
compareByDescending<EventInstance> { it.isAllDay }
|
|
||||||
.thenBy { it.start }
|
|
||||||
.thenBy { it.title },
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ fun CalendarDrawer(
|
|||||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||||
viewOrder.forEachIndexed { index, view ->
|
viewOrder.forEachIndexed { index, view ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(view.labelRes),
|
title = view.label(),
|
||||||
position = positionOf(index, viewOrder.size),
|
position = positionOf(index, viewOrder.size),
|
||||||
selected = view == currentView,
|
selected = view == currentView,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.core.VectorConverter
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.lerp
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlin.math.floor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One height for every page of something that varies from page to page — the
|
||||||
|
* timelines' all-day strip, the split grid's rows — blended between the two
|
||||||
|
* pages a swipe sits between, so what sits below it moves with the finger rather
|
||||||
|
* than jumping on settle. It follows the swipe directly and only springs when a
|
||||||
|
* page's own height changes at rest. [fallback] stands in until the pages have
|
||||||
|
* reported theirs, so it doesn't open from zero.
|
||||||
|
*
|
||||||
|
* @param heights each composed page's own height, by page index.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun pagedHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
|
||||||
|
val currentFallback by rememberUpdatedState(fallback)
|
||||||
|
val target by remember(pagerState, heights) {
|
||||||
|
derivedStateOf {
|
||||||
|
val position = pagerState.currentPage + pagerState.currentPageOffsetFraction
|
||||||
|
val from = floor(position).toInt()
|
||||||
|
val a = heights[from]
|
||||||
|
val b = heights[from + 1]
|
||||||
|
lerp(a ?: b ?: currentFallback, b ?: a ?: currentFallback, position - from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val height = remember { Animatable(target, Dp.VectorConverter) }
|
||||||
|
LaunchedEffect(height) {
|
||||||
|
snapshotFlow { target to pagerState.isScrollInProgress }.collectLatest { (h, scrolling) ->
|
||||||
|
if (scrolling) height.snapTo(h) else height.animateTo(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return height.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A deliberate jump (Today, jump-to-date): animated, or snapped under reduced motion. */
|
||||||
|
suspend fun PagerState.jumpToPage(
|
||||||
|
page: Int,
|
||||||
|
reduceMotion: Boolean,
|
||||||
|
spec: FiniteAnimationSpec<Float>,
|
||||||
|
) {
|
||||||
|
if (reduceMotion) scrollToPage(page) else animateScrollToPage(page, animationSpec = spec)
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drag distance that turns a calendar page, shared by the month, week and day
|
|
||||||
* views so all three answer a swipe at the same point.
|
|
||||||
*
|
|
||||||
* It was 6dp once, which is inside the distance a tap wanders: brushing the grid
|
|
||||||
* changed the month, and a page that turns on an unintended gesture reads as the
|
|
||||||
* animation misfiring rather than as the gesture being over-eager.
|
|
||||||
*/
|
|
||||||
val CALENDAR_SWIPE_THRESHOLD = 24.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The whole-page horizontal swipe: one page per gesture, committed **the moment
|
|
||||||
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
|
|
||||||
*
|
|
||||||
* Waiting for the lift meant the page sat still under a finger that had already
|
|
||||||
* travelled far enough to ask for it, and the answer only arrived once you let
|
|
||||||
* go — which reads as the view being slow rather than as a deliberate commit.
|
|
||||||
* Firing on the threshold is what makes the gesture feel like it is being
|
|
||||||
* followed. The trade is that a drag can no longer be taken back by dragging the
|
|
||||||
* other way; in practice, once you have moved 24dp deliberately you meant it, and
|
|
||||||
* the page you land on is one swipe back.
|
|
||||||
*
|
|
||||||
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
|
|
||||||
* underneath this, and a two-dimensional detector here would claim those drags
|
|
||||||
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
|
|
||||||
* detector's slop while a vertical one is consumed below, and the two coexist.
|
|
||||||
* (The month view's split style needs a vertical axis as well, so it keeps its
|
|
||||||
* own axis-locking detector rather than using this.)
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberCalendarPageSwipe(
|
|
||||||
onSwipeNext: () -> Unit,
|
|
||||||
onSwipePrev: () -> Unit,
|
|
||||||
): Modifier {
|
|
||||||
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
|
|
||||||
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
|
|
||||||
var accum = 0f
|
|
||||||
// One page per gesture: without this a long drag would keep re-firing
|
|
||||||
// every time the accumulator crossed the threshold again.
|
|
||||||
var fired = false
|
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragStart = {
|
|
||||||
accum = 0f
|
|
||||||
fired = false
|
|
||||||
},
|
|
||||||
onDragEnd = {
|
|
||||||
accum = 0f
|
|
||||||
fired = false
|
|
||||||
},
|
|
||||||
onDragCancel = {
|
|
||||||
accum = 0f
|
|
||||||
fired = false
|
|
||||||
},
|
|
||||||
onHorizontalDrag = { _, drag ->
|
|
||||||
accum += drag
|
|
||||||
if (!fired) {
|
|
||||||
val commit = when {
|
|
||||||
accum < -threshold -> onSwipeNext
|
|
||||||
accum > threshold -> onSwipePrev
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (commit != null) {
|
|
||||||
fired = true
|
|
||||||
commit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.ContentTransform
|
|
||||||
import androidx.compose.animation.SizeTransform
|
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -19,12 +12,12 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
* the reduce-motion check — now live in floret-kit's identity module
|
* the reduce-motion check — now live in floret-kit's identity module
|
||||||
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
||||||
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
||||||
* here is only what's specific to paging the calendar grid: the directional
|
* here is only what's specific to paging the calendar grid: the specs its
|
||||||
* month/week/day slide and the specs that feed it.
|
* pagers settle with, and that the view switch reuses.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The M3 Expressive spatial spring used for the month/week/day slide: the
|
* The M3 Expressive spatial spring the calendar's motion is built on: the
|
||||||
* *default* spring-physics spec from the active motion scheme, rather than a
|
* *default* spring-physics spec from the active motion scheme, rather than a
|
||||||
* fixed easing curve.
|
* fixed easing curve.
|
||||||
*
|
*
|
||||||
@@ -40,6 +33,12 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||||
MaterialTheme.motionScheme.defaultSpatialSpec()
|
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||||
|
|
||||||
|
/** The same spring as [rememberCalendarSlideSpec], for a pager settling onto its page. */
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
|
||||||
|
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The effects spec from the active motion scheme, for the opacity half of the
|
* The effects spec from the active motion scheme, for the opacity half of the
|
||||||
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||||
@@ -53,52 +52,3 @@ fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
|||||||
@Composable
|
@Composable
|
||||||
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
||||||
MaterialTheme.motionScheme.defaultEffectsSpec()
|
MaterialTheme.motionScheme.defaultEffectsSpec()
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
|
|
||||||
* outgoing page slides and fades one way while the incoming one arrives from the
|
|
||||||
* other, position on a spring and opacity on an easing curve.
|
|
||||||
*
|
|
||||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
|
||||||
* (e.g. "today"); a jump reuses the forward direction.
|
|
||||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
|
||||||
* @param fadeSpec effects spec for the opacity half, and for the whole
|
|
||||||
* transition under reduced motion; typically
|
|
||||||
* [rememberCalendarFadeSpec].
|
|
||||||
* @param reduceMotion when true, drop the movement and cross-fade alone.
|
|
||||||
*/
|
|
||||||
fun calendarSlideTransition(
|
|
||||||
slideDir: Int,
|
|
||||||
spec: FiniteAnimationSpec<IntOffset>,
|
|
||||||
fadeSpec: FiniteAnimationSpec<Float>,
|
|
||||||
reduceMotion: Boolean,
|
|
||||||
): ContentTransform {
|
|
||||||
if (reduceMotion) {
|
|
||||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
|
||||||
}
|
|
||||||
val dir = if (slideDir == 0) 1 else slideDir
|
|
||||||
return ContentTransform(
|
|
||||||
targetContentEnter =
|
|
||||||
slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
|
|
||||||
initialContentExit =
|
|
||||||
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
|
|
||||||
// AnimatedContent clips to the animating container by default, which
|
|
||||||
// shears the pages against the viewport edge as they pass. Left off even
|
|
||||||
// where the two pages differ in height — the split grid stands as many
|
|
||||||
// rows as its month spans (#162) — since a page sliding out over the row
|
|
||||||
// below it reads as travel, and the shear reads as a fault.
|
|
||||||
sizeTransform = SizeTransform(clip = false),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How far a page travels, as a fraction of the container width.
|
|
||||||
*
|
|
||||||
* A full width was the obvious reading of "paging", but the two pages are
|
|
||||||
* stacked and both opaque, so a full-width slide showed one grid racing across
|
|
||||||
* another — the movement carried the whole transition and had a long way to go.
|
|
||||||
* Under M3's shared-axis pattern the offset only has to *hint* the direction
|
|
||||||
* while the cross-fade does the swapping, so a fifth of the width is plenty and
|
|
||||||
* leaves nothing skating past.
|
|
||||||
*/
|
|
||||||
private const val SLIDE_TRAVEL_DIVISOR = 5
|
|
||||||
|
|||||||
@@ -1,37 +1,53 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CalendarViewDay
|
import androidx.compose.material.icons.filled.CalendarViewDay
|
||||||
import androidx.compose.material.icons.filled.CalendarViewMonth
|
import androidx.compose.material.icons.filled.CalendarViewMonth
|
||||||
import androidx.compose.material.icons.filled.CalendarViewWeek
|
import androidx.compose.material.icons.filled.CalendarViewWeek
|
||||||
import androidx.compose.material.icons.filled.ViewAgenda
|
import androidx.compose.material.icons.filled.ViewAgenda
|
||||||
|
import androidx.compose.material.icons.filled.ViewColumn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
|
||||||
/** The top-level calendar views the user can switch between (spec M1). */
|
/** The top-level calendar views the user can switch between (spec M1). */
|
||||||
enum class CalendarView {
|
enum class CalendarView {
|
||||||
Month,
|
Month,
|
||||||
Week,
|
Week,
|
||||||
|
MultiDay,
|
||||||
Day,
|
Day,
|
||||||
Agenda,
|
Agenda,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
|
/** How many days the multi-day view shows (#193), provided once at the app root. */
|
||||||
@get:StringRes
|
val LocalMultiDayCount = staticCompositionLocalOf { SettingsPrefs.DEFAULT_MULTI_DAY_COUNT }
|
||||||
val CalendarView.labelRes: Int
|
|
||||||
get() = when (this) {
|
/**
|
||||||
CalendarView.Month -> R.string.view_month
|
* The view's name, shared by the top-bar pill, the drawer and Settings. The
|
||||||
CalendarView.Week -> R.string.view_week
|
* multi-day view is named after its day count ("3 days").
|
||||||
CalendarView.Day -> R.string.view_day
|
*/
|
||||||
CalendarView.Agenda -> R.string.view_agenda
|
@Composable
|
||||||
|
fun CalendarView.label(): String = when (this) {
|
||||||
|
CalendarView.Month -> stringResource(R.string.view_month)
|
||||||
|
CalendarView.Week -> stringResource(R.string.view_week)
|
||||||
|
CalendarView.MultiDay -> {
|
||||||
|
val count = LocalMultiDayCount.current
|
||||||
|
pluralStringResource(R.plurals.view_multi_day, count, count)
|
||||||
}
|
}
|
||||||
|
CalendarView.Day -> stringResource(R.string.view_day)
|
||||||
|
CalendarView.Agenda -> stringResource(R.string.view_agenda)
|
||||||
|
}
|
||||||
|
|
||||||
/** Leading icon for the view in the drawer's View section. */
|
/** Leading icon for the view in the drawer's View section. */
|
||||||
val CalendarView.icon: ImageVector
|
val CalendarView.icon: ImageVector
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
CalendarView.Month -> Icons.Filled.CalendarViewMonth
|
CalendarView.Month -> Icons.Filled.CalendarViewMonth
|
||||||
CalendarView.Week -> Icons.Filled.CalendarViewWeek
|
CalendarView.Week -> Icons.Filled.CalendarViewWeek
|
||||||
|
CalendarView.MultiDay -> Icons.Filled.ViewColumn
|
||||||
CalendarView.Day -> Icons.Filled.CalendarViewDay
|
CalendarView.Day -> Icons.Filled.CalendarViewDay
|
||||||
CalendarView.Agenda -> Icons.Filled.ViewAgenda
|
CalendarView.Agenda -> Icons.Filled.ViewAgenda
|
||||||
}
|
}
|
||||||
@@ -41,7 +57,13 @@ val CalendarView.icon: ImageVector
|
|||||||
* through these in order.
|
* through these in order.
|
||||||
*/
|
*/
|
||||||
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
||||||
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda)
|
listOf(
|
||||||
|
CalendarView.Month,
|
||||||
|
CalendarView.Week,
|
||||||
|
CalendarView.MultiDay,
|
||||||
|
CalendarView.Day,
|
||||||
|
CalendarView.Agenda,
|
||||||
|
)
|
||||||
|
|
||||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||||
@@ -107,3 +129,7 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
|
|||||||
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
|
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
|
||||||
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
||||||
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
||||||
|
|
||||||
|
/** Push the week view over the month after a week-number tap. */
|
||||||
|
fun List<CalendarView>.drillToWeek(): List<CalendarView> =
|
||||||
|
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.onClick
|
||||||
|
import androidx.compose.ui.semantics.role
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** Thickness of the out-of-view edge bar. */
|
||||||
|
private val EDGE_BAR_THICKNESS = 3.dp
|
||||||
|
|
||||||
|
/** Band the bar sits in, and the room a revealed event keeps from the edge. */
|
||||||
|
private val EDGE_TOUCH_HEIGHT = 24.dp
|
||||||
|
|
||||||
|
/** How far from the viewport's edge a tap on empty column still hits the bar. */
|
||||||
|
private val EDGE_TAP_REACH = 16.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the bar sits in from the viewport's edges — enough to clear the
|
||||||
|
* timeline's rounded 16dp corners on the outer columns.
|
||||||
|
*/
|
||||||
|
private val EDGE_BAR_VERTICAL_INSET = 4.dp
|
||||||
|
private val EDGE_BAR_HORIZONTAL_INSET = 6.dp
|
||||||
|
|
||||||
|
/** Distance outside the viewport over which a bar fades before its event appears. */
|
||||||
|
private val EDGE_FADE_LEAD = 24.dp
|
||||||
|
|
||||||
|
/** The nearest timed blocks wholly above and below a column's viewport. */
|
||||||
|
@Immutable
|
||||||
|
data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) {
|
||||||
|
companion object {
|
||||||
|
val None = OutOfViewEvents(null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which edge of the viewport a hidden event sits past. */
|
||||||
|
enum class ViewportEdge { Top, Bottom }
|
||||||
|
|
||||||
|
/** Where [block]'s rendered bottom lands, in minutes — short blocks keep a floor. */
|
||||||
|
internal fun renderedEndMin(block: TimedBlock): Float =
|
||||||
|
maxOf(block.endMin.toFloat(), block.startMin + MIN_EVENT_FRACTION * 60f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin]
|
||||||
|
* (minutes of the day), nearest first. A block leaves this set as soon as any
|
||||||
|
* part of its rendered height enters the viewport.
|
||||||
|
*/
|
||||||
|
fun outOfViewEvents(
|
||||||
|
blocks: List<TimedBlock>,
|
||||||
|
topMin: Float,
|
||||||
|
bottomMin: Float,
|
||||||
|
): OutOfViewEvents {
|
||||||
|
val above = blocks
|
||||||
|
.filter { renderedEndMin(it) <= topMin }
|
||||||
|
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin }))
|
||||||
|
val below = blocks
|
||||||
|
.filter { it.startMin >= bottomMin }
|
||||||
|
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) }))
|
||||||
|
return OutOfViewEvents(above, below)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scroll offset that brings [block] into a [viewportPx]-tall viewport from
|
||||||
|
* past [edge], with [marginPx] of room so it clears the edge bar. An event above
|
||||||
|
* lands with its start at the top; one below lands whole at the bottom when it
|
||||||
|
* fits, and with its start at the top when it does not.
|
||||||
|
*/
|
||||||
|
fun revealScrollTarget(
|
||||||
|
block: TimedBlock,
|
||||||
|
edge: ViewportEdge,
|
||||||
|
hourPx: Float,
|
||||||
|
viewportPx: Int,
|
||||||
|
marginPx: Float,
|
||||||
|
maxScroll: Int,
|
||||||
|
): Int {
|
||||||
|
val startPx = block.startMin / 60f * hourPx
|
||||||
|
val endPx = renderedEndMin(block) / 60f * hourPx
|
||||||
|
val target = when (edge) {
|
||||||
|
ViewportEdge.Top -> startPx - marginPx
|
||||||
|
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
|
||||||
|
}
|
||||||
|
return target.roundToInt().coerceIn(0, maxOf(maxScroll, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The edge whose bar a tap at [y] (content px) lands on, if that bar is showing
|
||||||
|
* and the tap is within [reachPx] of the viewport's edge.
|
||||||
|
*/
|
||||||
|
fun tappedEdge(
|
||||||
|
events: OutOfViewEvents,
|
||||||
|
y: Float,
|
||||||
|
viewportTop: Float,
|
||||||
|
viewportPx: Int,
|
||||||
|
reachPx: Float,
|
||||||
|
): ViewportEdge? = when {
|
||||||
|
events.above != null && y - viewportTop <= reachPx -> ViewportEdge.Top
|
||||||
|
events.below != null && viewportTop + viewportPx - y <= reachPx -> ViewportEdge.Bottom
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fade while the event is still outside; never draw its bar at the boundary. */
|
||||||
|
internal fun edgeBarAlpha(gapPx: Float, fadeLeadPx: Float): Float = when {
|
||||||
|
gapPx <= 0f -> 0f
|
||||||
|
fadeLeadPx <= 0f -> 1f
|
||||||
|
else -> (gapPx / fadeLeadPx).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The out-of-view state of one day column (#188): which events hide past its
|
||||||
|
* edges, and the scroll that reveals them.
|
||||||
|
*
|
||||||
|
* The bars take no touches themselves, so event blocks under them keep their
|
||||||
|
* taps and drags; the column's own tap handler offers taps to [tap] first.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class OutOfViewEdges internal constructor(
|
||||||
|
private val scrollState: ScrollState,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
internal var blocks by mutableStateOf(emptyList<TimedBlock>())
|
||||||
|
internal var hourPx by mutableFloatStateOf(0f)
|
||||||
|
internal var fadeLeadPx by mutableFloatStateOf(0f)
|
||||||
|
internal var marginPx by mutableFloatStateOf(0f)
|
||||||
|
internal var reachPx by mutableFloatStateOf(0f)
|
||||||
|
internal var reduceMotion = false
|
||||||
|
internal var revealSpec: FiniteAnimationSpec<Float>? = null
|
||||||
|
|
||||||
|
val events: OutOfViewEvents by derivedStateOf {
|
||||||
|
val viewport = scrollState.viewportSize
|
||||||
|
if (viewport <= 0 || hourPx <= 0f) {
|
||||||
|
OutOfViewEvents.None
|
||||||
|
} else {
|
||||||
|
val top = scrollState.value.toFloat()
|
||||||
|
outOfViewEvents(
|
||||||
|
blocks = blocks,
|
||||||
|
topMin = top / hourPx * 60f,
|
||||||
|
bottomMin = (top + viewport) / hourPx * 60f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scrolls the event hidden past [edge] into view, if there is one. */
|
||||||
|
fun reveal(edge: ViewportEdge) {
|
||||||
|
val block = if (edge == ViewportEdge.Top) events.above else events.below
|
||||||
|
block ?: return
|
||||||
|
val target = revealScrollTarget(
|
||||||
|
block = block,
|
||||||
|
edge = edge,
|
||||||
|
hourPx = hourPx,
|
||||||
|
viewportPx = scrollState.viewportSize,
|
||||||
|
marginPx = marginPx,
|
||||||
|
maxScroll = scrollState.maxValue,
|
||||||
|
)
|
||||||
|
val spec = revealSpec
|
||||||
|
scope.launch {
|
||||||
|
if (reduceMotion || spec == null) {
|
||||||
|
scrollState.scrollTo(target)
|
||||||
|
} else {
|
||||||
|
scrollState.animateScrollTo(target, spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
|
||||||
|
fun tap(y: Float): Boolean {
|
||||||
|
val edge = tappedEdge(
|
||||||
|
events = events,
|
||||||
|
y = y,
|
||||||
|
viewportTop = scrollState.value.toFloat(),
|
||||||
|
viewportPx = scrollState.viewportSize,
|
||||||
|
reachPx = reachPx,
|
||||||
|
) ?: return false
|
||||||
|
reveal(edge)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberOutOfViewEdges(
|
||||||
|
blocks: List<TimedBlock>,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
hourPx: Float,
|
||||||
|
): OutOfViewEdges {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val edges = remember(scrollState, scope) { OutOfViewEdges(scrollState, scope) }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val revealSpec = rememberCalendarPageSpec()
|
||||||
|
SideEffect {
|
||||||
|
edges.blocks = blocks
|
||||||
|
edges.hourPx = hourPx
|
||||||
|
edges.fadeLeadPx = with(density) { EDGE_FADE_LEAD.toPx() }
|
||||||
|
edges.marginPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx().toFloat() }
|
||||||
|
edges.reachPx = with(density) { EDGE_TAP_REACH.toPx() }
|
||||||
|
edges.reduceMotion = reduceMotion
|
||||||
|
edges.revealSpec = revealSpec
|
||||||
|
}
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin bars at the top and bottom of a day column's visible viewport, tinted in
|
||||||
|
* the colour of the nearest timed event hidden past that edge (#188).
|
||||||
|
*
|
||||||
|
* Lives inside the column's scrolling content and follows the viewport by
|
||||||
|
* offsetting against [scrollState] at placement rather than recomposing every
|
||||||
|
* frame. Opacity follows the gap to the hidden event and reaches zero before
|
||||||
|
* the event enters view. Taps reach it through [OutOfViewEdges.tap]; screen
|
||||||
|
* readers get a button.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BoxScope.OutOfViewEdgeBars(
|
||||||
|
edges: OutOfViewEdges,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
dark: Boolean,
|
||||||
|
) {
|
||||||
|
val touchPx = with(LocalDensity.current) { EDGE_TOUCH_HEIGHT.roundToPx() }
|
||||||
|
val events = edges.events
|
||||||
|
EdgeBar(
|
||||||
|
block = events.above,
|
||||||
|
edge = ViewportEdge.Top,
|
||||||
|
dark = dark,
|
||||||
|
onReveal = edges::reveal,
|
||||||
|
scrollState = scrollState,
|
||||||
|
hourPx = edges.hourPx,
|
||||||
|
fadeLeadPx = edges.fadeLeadPx,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.offset { IntOffset(0, scrollState.value) },
|
||||||
|
)
|
||||||
|
EdgeBar(
|
||||||
|
block = events.below,
|
||||||
|
edge = ViewportEdge.Bottom,
|
||||||
|
dark = dark,
|
||||||
|
onReveal = edges::reveal,
|
||||||
|
scrollState = scrollState,
|
||||||
|
hourPx = edges.hourPx,
|
||||||
|
fadeLeadPx = edges.fadeLeadPx,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EdgeBar(
|
||||||
|
block: TimedBlock?,
|
||||||
|
edge: ViewportEdge,
|
||||||
|
dark: Boolean,
|
||||||
|
onReveal: (ViewportEdge) -> Unit,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
hourPx: Float,
|
||||||
|
fadeLeadPx: Float,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
block ?: return
|
||||||
|
val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
|
||||||
|
val label = stringResource(
|
||||||
|
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(EDGE_TOUCH_HEIGHT)
|
||||||
|
.graphicsLayer {
|
||||||
|
val gapPx = when (edge) {
|
||||||
|
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx
|
||||||
|
ViewportEdge.Bottom -> block.startMin / 60f * hourPx -
|
||||||
|
(scrollState.value + scrollState.viewportSize)
|
||||||
|
}
|
||||||
|
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
|
||||||
|
}
|
||||||
|
.semantics {
|
||||||
|
role = Role.Button
|
||||||
|
contentDescription = label
|
||||||
|
onClick {
|
||||||
|
onReveal(edge)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = if (edge == ViewportEdge.Top) Alignment.TopCenter else Alignment.BottomCenter,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
horizontal = EDGE_BAR_HORIZONTAL_INSET,
|
||||||
|
vertical = EDGE_BAR_VERTICAL_INSET,
|
||||||
|
)
|
||||||
|
.height(EDGE_BAR_THICKNESS)
|
||||||
|
.background(color, CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One shared state per pager page — a week, a day, a month — so a page and the
|
||||||
|
* screen's anchor read the same query, and the pages either side stay loaded
|
||||||
|
* across a swipe.
|
||||||
|
*
|
||||||
|
* Every entry shares on its own job, cancelled when [keep] lets it go: a
|
||||||
|
* `stateIn` on the ViewModel's scope would stay running, holding its last state,
|
||||||
|
* until the ViewModel itself was cleared. Main thread only.
|
||||||
|
*
|
||||||
|
* @param keep whether a cached key is still worth holding once [get] asks for another.
|
||||||
|
*/
|
||||||
|
class PageStateCache<K, S>(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val initial: S,
|
||||||
|
private val keep: (cached: K, requested: K) -> Boolean,
|
||||||
|
private val load: (K) -> Flow<S>,
|
||||||
|
) {
|
||||||
|
private class Entry<S>(val state: StateFlow<S>, val job: Job)
|
||||||
|
|
||||||
|
private val entries = HashMap<K, Entry<S>>()
|
||||||
|
|
||||||
|
/** The number of entries held, for tests. */
|
||||||
|
internal val size: Int get() = entries.size
|
||||||
|
|
||||||
|
fun get(key: K): StateFlow<S> {
|
||||||
|
val iterator = entries.iterator()
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
val (cached, entry) = iterator.next()
|
||||||
|
if (cached != key && !keep(cached, key)) {
|
||||||
|
entry.job.cancel()
|
||||||
|
iterator.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries.getOrPut(key) {
|
||||||
|
val job = SupervisorJob(scope.coroutineContext[Job])
|
||||||
|
Entry(
|
||||||
|
state = load(key).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial),
|
||||||
|
job = job,
|
||||||
|
)
|
||||||
|
}.state
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -284,8 +284,8 @@ class TimelineGeometry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hoisted drag state for one timeline (#68). It lives above the per-page
|
* Hoisted drag state for one timeline (#68). It lives above the pager, so a
|
||||||
* `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the
|
* page change mid-drag can't strand a ghost, and the
|
||||||
* block it renders is drawn in an overlay, clear of the day column's clip.
|
* block it renders is drawn in an overlay, clear of the day column's clip.
|
||||||
*/
|
*/
|
||||||
@Stable
|
@Stable
|
||||||
|
|||||||
@@ -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. */
|
/** A height the user pinched to. Build it through [custom], which clamps. */
|
||||||
data class Custom(val hourHeight: Dp) : TimelineScale
|
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 {
|
companion object {
|
||||||
/** The named steps the settings picker offers, coarse to roomy. */
|
/** The named steps the settings picker offers, coarse to roomy. */
|
||||||
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable)
|
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
|
is TimelineScale.Custom -> this.hourHeight
|
||||||
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
|
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
|
||||||
.coerceAtLeast(fillHourHeight(viewportHeight))
|
.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.Regular -> R.string.timeline_scale_regular
|
||||||
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
|
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
|
||||||
is TimelineScale.Custom -> R.string.timeline_scale_custom
|
is TimelineScale.Custom -> R.string.timeline_scale_custom
|
||||||
|
is TimelineScale.FitHours -> R.string.settings_visible_hours
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
@@ -144,11 +154,15 @@ val TimelineScale.descriptionRes: Int
|
|||||||
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
|
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
|
||||||
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
|
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
|
||||||
is TimelineScale.Custom -> R.string.timeline_scale_custom_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. */
|
/** Marks a stored custom height; the rest of the value is its dp. */
|
||||||
private const val CUSTOM_PREFIX = "custom:"
|
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()`
|
* 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
|
* 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) {
|
fun TimelineScale.storageValue(): String = when (this) {
|
||||||
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
|
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
|
||||||
|
is TimelineScale.FitHours -> "$HOURS_PREFIX$startHour-$endHour"
|
||||||
TimelineScale.FitDay,
|
TimelineScale.FitDay,
|
||||||
TimelineScale.Compact,
|
TimelineScale.Compact,
|
||||||
TimelineScale.Regular,
|
TimelineScale.Regular,
|
||||||
@@ -188,5 +203,11 @@ fun parseTimelineScale(stored: String?): TimelineScale = when {
|
|||||||
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
|
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
|
||||||
?.let { TimelineScale.custom(it.dp) }
|
?.let { TimelineScale.custom(it.dp) }
|
||||||
?: TimelineScale.Regular
|
?: 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
|
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.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -49,13 +53,49 @@ class TimelineZoom(
|
|||||||
var isPinching: Boolean by mutableStateOf(false)
|
var isPinching: Boolean by mutableStateOf(false)
|
||||||
private set
|
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
|
* 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
|
* 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) {
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
fun beginPinch() {
|
||||||
@@ -66,9 +106,10 @@ class TimelineZoom(
|
|||||||
scale = TimelineScale.custom(hourHeight)
|
scale = TimelineScale.custom(hourHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Settles the pinch. With visible hours on it only lasts until the next [open]. */
|
||||||
fun endPinch() {
|
fun endPinch() {
|
||||||
isPinching = false
|
isPinching = false
|
||||||
persist(scale)
|
if (!hours.enabled) persist(scale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,16 +123,18 @@ private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) {
|
|||||||
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
|
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches
|
* The app-wide [TimelineZoom], seeded from [stored] and [hours] and writing
|
||||||
* back through [onPersist].
|
* settled pinches back through [onPersist].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberTimelineZoom(
|
fun rememberTimelineZoom(
|
||||||
stored: TimelineScale,
|
stored: TimelineScale,
|
||||||
|
hours: TimelineHours,
|
||||||
onPersist: (TimelineScale) -> Unit,
|
onPersist: (TimelineScale) -> Unit,
|
||||||
): TimelineZoom {
|
): TimelineZoom {
|
||||||
val persist by rememberUpdatedState(onPersist)
|
val persist by rememberUpdatedState(onPersist)
|
||||||
val zoom = remember { TimelineZoom(stored) { persist(it) } }
|
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
|
// Picking a preset in Settings has to reach the timelines, and so does the
|
||||||
// stored value arriving after the first frame.
|
// stored value arriving after the first frame.
|
||||||
LaunchedEffect(stored) { zoom.adopt(stored) }
|
LaunchedEffect(stored) { zoom.adopt(stored) }
|
||||||
@@ -242,3 +285,31 @@ internal fun anchoredScroll(
|
|||||||
* as a scroll stays one.
|
* as a scroll stays one.
|
||||||
*/
|
*/
|
||||||
private const val PINCH_SLOP = 0.08f
|
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, hourHeight) = snapshotFlow {
|
||||||
|
val viewport = scrollState.viewportSize
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,6 @@ fun ViewSwitcherPill(
|
|||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
modifier = modifier.padding(end = trailingInset),
|
modifier = modifier.padding(end = trailingInset),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(current.labelRes))
|
Text(current.label())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.daysUntil
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
|
||||||
|
/** Pages the day pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
|
||||||
|
internal const val DAY_PAGE_COUNT: Int = 140_000
|
||||||
|
|
||||||
|
private const val EPOCH_PAGE = DAY_PAGE_COUNT / 2
|
||||||
|
|
||||||
|
/** The day at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||||
|
private val PAGE_EPOCH = LocalDate(2000, 1, 1)
|
||||||
|
|
||||||
|
/** The day shown on pager [page]. */
|
||||||
|
internal fun dayForPage(page: Int): LocalDate =
|
||||||
|
PAGE_EPOCH.plus(page - EPOCH_PAGE, DateTimeUnit.DAY)
|
||||||
|
|
||||||
|
/** The pager page showing [date]. */
|
||||||
|
internal fun dayPageFor(date: LocalDate): Int =
|
||||||
|
(EPOCH_PAGE + PAGE_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PagerDefaults
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -43,12 +58,10 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -93,6 +106,8 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdgeBars
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberOutOfViewEdges
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||||
@@ -109,18 +124,16 @@ import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
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.MIN_EVENT_FRACTION
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||||
@@ -132,12 +145,10 @@ import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
||||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -145,6 +156,9 @@ import kotlin.math.roundToInt
|
|||||||
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
|
|
||||||
|
/** Breathing room between the all-day strip and the scrolling timeline below. */
|
||||||
|
private val TIMELINE_TOP_GAP = 8.dp
|
||||||
|
|
||||||
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
||||||
internal fun DayUiState.Success.allDayStripHeight(): Dp {
|
internal fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||||
if (allDay.isEmpty()) return 0.dp
|
if (allDay.isEmpty()) return 0.dp
|
||||||
@@ -169,7 +183,7 @@ fun DayScreen(
|
|||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val date by viewModel.date.collectAsStateWithLifecycle()
|
val anchorPage by viewModel.anchorPage.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// When opened from the month grid, anchor to the tapped date.
|
// When opened from the month grid, anchor to the tapped date.
|
||||||
LaunchedEffect(initialDateIso) {
|
LaunchedEffect(initialDateIso) {
|
||||||
@@ -179,36 +193,39 @@ fun DayScreen(
|
|||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val isOnToday = when (val s = state) {
|
// Opens on the tapped date directly, rather than on today and then correcting.
|
||||||
is DayUiState.Success -> s.date == s.today
|
val pagerState = rememberPagerState(
|
||||||
else -> true
|
initialPage = initialDateIso?.let { dayPageFor(LocalDate.parse(it)) } ?: anchorPage,
|
||||||
|
) { DAY_PAGE_COUNT }
|
||||||
|
val pageSpec = rememberCalendarPageSpec()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
// The pager leads and the anchor follows once it settles, so the anchor only
|
||||||
|
// moves on its own to correct the pager — e.g. re-entry from the month grid
|
||||||
|
// on another date. Snapped, since that is a correction rather than a move.
|
||||||
|
LaunchedEffect(anchorPage) {
|
||||||
|
if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) {
|
||||||
|
pagerState.scrollToPage(anchorPage)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(pagerState) {
|
||||||
|
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
|
||||||
|
}
|
||||||
|
// The day under the finger, so the title turns over mid-swipe, not after it.
|
||||||
|
val date = dayForPage(pagerState.currentPage)
|
||||||
|
|
||||||
// Drives whether the title carries the year. Falls back to the clock only
|
// Off the ticking clock rather than the loaded day, so it turns over at
|
||||||
// while the first load is in flight, when there is no state to read today from.
|
// midnight; derived, so the per-minute tick doesn't recompose the screen.
|
||||||
val currentYear = when (val s = state) {
|
val nowState = rememberCurrentMinute()
|
||||||
is DayUiState.Success -> s.today.year
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
|
||||||
}
|
val isOnToday = state !is DayUiState.Success || date == today
|
||||||
|
|
||||||
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
// Straight to the pager: a tap mid-fling must still land, and the anchor
|
||||||
var slideDir by remember { mutableIntStateOf(0) }
|
// hasn't caught up with the fling yet to tell the difference.
|
||||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
|
||||||
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
|
||||||
// Slide toward today: viewing the future → today comes in from the left
|
|
||||||
// (back), viewing the past → from the right (forward).
|
|
||||||
val jumpToToday = {
|
|
||||||
slideDir = when (val s = state) {
|
|
||||||
is DayUiState.Success -> if (s.today < s.date) -1 else 1
|
|
||||||
else -> 0
|
|
||||||
}
|
|
||||||
viewModel.goToToday()
|
|
||||||
}
|
|
||||||
// Drawer jump-to-date: slide from the side the target lies on.
|
|
||||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
slideDir = if (target < date) -1 else 1
|
scope.launch { pagerState.jumpToPage(dayPageFor(target), reduceMotion, pageSpec) }
|
||||||
viewModel.goToDate(target)
|
|
||||||
}
|
}
|
||||||
|
val jumpToToday = { jumpToDate(today) }
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -240,7 +257,7 @@ fun DayScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
DayTopBar(
|
DayTopBar(
|
||||||
date = date,
|
date = date,
|
||||||
currentYear = currentYear,
|
currentYear = today.year,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
@@ -262,10 +279,9 @@ fun DayScreen(
|
|||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
DayContent(
|
DayContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
pagerState = pagerState,
|
||||||
onSwipeNext = goNext,
|
day = viewModel::day,
|
||||||
onSwipePrev = goPrev,
|
onRetry = viewModel::goToToday,
|
||||||
onRetry = jumpToToday,
|
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -279,73 +295,27 @@ fun DayScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DayContent(
|
private fun DayContent(
|
||||||
state: DayUiState,
|
state: DayUiState,
|
||||||
slideDir: Int,
|
pagerState: PagerState,
|
||||||
onSwipeNext: () -> Unit,
|
day: (LocalDate) -> StateFlow<DayUiState>,
|
||||||
onSwipePrev: () -> Unit,
|
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
|
|
||||||
// Hoisted above the per-day AnimatedContent so the vertical scroll position
|
// 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
|
// survives day-to-day swipes. Positioned once on entry into the day view:
|
||||||
// into the day view (i.e. when arriving from the month/week view).
|
// at the visible hours' start if set (#322), otherwise noon.
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
LaunchedEffect(Unit) {
|
OpenTimelineScroll(scrollState, LocalTimelineZoom.current)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
// Above the pager: a page change mid-drag would strand the floating block
|
||||||
// day during a swipe, so the strip slides along but never jumps in height.
|
// inside the outgoing page.
|
||||||
val targetAllDayHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp
|
|
||||||
val allDayHeight by animateDpAsState(
|
|
||||||
targetValue = targetAllDayHeight,
|
|
||||||
label = "day-all-day-strip-height",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Whole-page horizontal swipe, one level above the timeline's vertical
|
|
||||||
// scroll: a horizontal drag crosses this detector's slop, while a vertical
|
|
||||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
|
||||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
|
||||||
|
|
||||||
// Above the AnimatedContent: a page change mid-drag would strand the
|
|
||||||
// floating block inside the outgoing page.
|
|
||||||
val dragController = rememberTimelineDragController()
|
val dragController = rememberTimelineDragController()
|
||||||
val move = LocalEventMove.current
|
val move = LocalEventMove.current
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val onDrop: (TimelineDrop) -> Unit = { drop ->
|
||||||
Box(modifier = modifier) {
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = state,
|
|
||||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
|
||||||
contentKey = { s ->
|
|
||||||
when (s) {
|
|
||||||
is DayUiState.Success -> "success-${s.date}"
|
|
||||||
is DayUiState.Failure -> "failure-${s.reason}"
|
|
||||||
DayUiState.Loading -> "loading"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
|
||||||
label = "day-transition",
|
|
||||||
) { s ->
|
|
||||||
when (s) {
|
|
||||||
DayUiState.Loading -> DayLoading()
|
|
||||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
|
||||||
is DayUiState.Success -> DaySuccess(
|
|
||||||
state = s,
|
|
||||||
scrollState = scrollState,
|
|
||||||
allDayHeight = allDayHeight,
|
|
||||||
dragController = dragController,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onCreateAt = onCreateAt,
|
|
||||||
onDrop = { drop ->
|
|
||||||
move?.move(
|
move?.move(
|
||||||
MoveRequest(
|
MoveRequest(
|
||||||
eventId = drop.event.eventId,
|
eventId = drop.event.eventId,
|
||||||
@@ -354,7 +324,31 @@ private fun DayContent(
|
|||||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
// Loading and failure concern the provider as a whole, so they stand in
|
||||||
|
// for the pager; a page still loading its own day handles that itself.
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = state,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentKey = { it::class },
|
||||||
|
transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) },
|
||||||
|
label = "day-state",
|
||||||
|
) { s ->
|
||||||
|
when (s) {
|
||||||
|
DayUiState.Loading -> DayLoading()
|
||||||
|
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
|
is DayUiState.Success -> DayPager(
|
||||||
|
pagerState = pagerState,
|
||||||
|
day = day,
|
||||||
|
today = s.today,
|
||||||
|
initialStripHeight = s.allDayStripHeight(),
|
||||||
|
scrollState = scrollState,
|
||||||
|
dragController = dragController,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,6 +356,101 @@ private fun DayContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The days as pages that follow the finger and snap once a swipe passes half a
|
||||||
|
* page or is flung (#336), beside a gutter that stays put.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DayPager(
|
||||||
|
pagerState: PagerState,
|
||||||
|
day: (LocalDate) -> StateFlow<DayUiState>,
|
||||||
|
today: LocalDate,
|
||||||
|
initialStripHeight: Dp,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
dragController: TimelineDragController,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
) {
|
||||||
|
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
|
||||||
|
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
|
||||||
|
DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
// The days either side are composed ahead, so a swipe lands on a
|
||||||
|
// loaded day rather than watching it fill in.
|
||||||
|
beyondViewportPageCount = 1,
|
||||||
|
// A held block moves within its day; the page stays put under it.
|
||||||
|
userScrollEnabled = !dragController.isDragging,
|
||||||
|
flingBehavior = PagerDefaults.flingBehavior(
|
||||||
|
state = pagerState,
|
||||||
|
snapAnimationSpec = rememberCalendarPageSpec(),
|
||||||
|
),
|
||||||
|
) { page ->
|
||||||
|
val date = dayForPage(page)
|
||||||
|
val pageState by remember(date) { day(date) }.collectAsStateWithLifecycle()
|
||||||
|
val loaded = pageState as? DayUiState.Success
|
||||||
|
// Until its day arrives a page shows the empty column.
|
||||||
|
val empty = remember(date, today) {
|
||||||
|
DayUiState.Success(date = date, today = today, allDay = emptyList(), timed = emptyList())
|
||||||
|
}
|
||||||
|
val stripHeight = loaded?.allDayStripHeight()
|
||||||
|
DisposableEffect(page, stripHeight) {
|
||||||
|
if (stripHeight != null) stripHeights[page] = stripHeight
|
||||||
|
onDispose { stripHeights.remove(page) }
|
||||||
|
}
|
||||||
|
DayPage(
|
||||||
|
state = loaded ?: empty,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
scrollState = scrollState,
|
||||||
|
dragController = dragController,
|
||||||
|
active = page == pagerState.currentPage,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What stays put while the days swipe: the hour gutter, with [pages] filling the rest. */
|
||||||
|
@Composable
|
||||||
|
private fun DayFrame(
|
||||||
|
allDayHeight: Dp,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
dragController: TimelineDragController,
|
||||||
|
pages: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) {
|
||||||
|
Spacer(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(allDayHeight)
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||||
|
// Resolves the hour height off the same viewport height the pages'
|
||||||
|
// timelines have, so labels and column agree.
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val zoom = LocalTimelineZoom.current
|
||||||
|
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||||
|
// Start inset so the labels centre on the top bar hamburger,
|
||||||
|
// matching the week view.
|
||||||
|
HourGutter(
|
||||||
|
scrollState = scrollState,
|
||||||
|
hourHeight = hourHeight,
|
||||||
|
dragController = dragController,
|
||||||
|
modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single day in its frame, without the pager — for the Settings preview. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun DaySuccess(
|
internal fun DaySuccess(
|
||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
@@ -371,6 +460,37 @@ internal fun DaySuccess(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
) {
|
||||||
|
DayFrame(allDayHeight = allDayHeight, scrollState = scrollState, dragController = dragController) {
|
||||||
|
DayPage(
|
||||||
|
state = state,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
scrollState = scrollState,
|
||||||
|
dragController = dragController,
|
||||||
|
active = true,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One day's page: all-day strip and column.
|
||||||
|
*
|
||||||
|
* @param active whether this is the page on screen, the only one that may
|
||||||
|
* publish the drag geometry.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DayPage(
|
||||||
|
state: DayUiState.Success,
|
||||||
|
allDayHeight: Dp,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
dragController: TimelineDragController,
|
||||||
|
active: Boolean,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// All-day strip collapses to nothing when the day has no all-day events,
|
// All-day strip collapses to nothing when the day has no all-day events,
|
||||||
@@ -389,13 +509,12 @@ internal fun DaySuccess(
|
|||||||
.background(MaterialTheme.colorScheme.surface),
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Breathing room between the top section and the scrolling timeline
|
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||||
// below.
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
|
active = active,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
@@ -483,8 +602,6 @@ private fun AllDayStrip(
|
|||||||
end = TIMELINE_CONTENT_END_INSET,
|
end = TIMELINE_CONTENT_END_INSET,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
// Keep the gutter-width offset so the bars line up with the day column.
|
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
|
||||||
// Bars are positioned absolutely by lane (vertical stacking); each spans
|
// Bars are positioned absolutely by lane (vertical stacking); each spans
|
||||||
// the full day-column width. clipToBounds keeps bars from spilling out
|
// the full day-column width. clipToBounds keeps bars from spilling out
|
||||||
// while the height animates.
|
// while the height animates.
|
||||||
@@ -547,6 +664,7 @@ private fun Timeline(
|
|||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
|
active: Boolean,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
@@ -568,31 +686,22 @@ private fun Timeline(
|
|||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||||
val totalHeight = hourHeight * 24
|
val totalHeight = hourHeight * 24
|
||||||
// The pinch sits on the Row, above both scroll viewports: it has to
|
// The pinch sits above the scroll viewport: it has to outrank the
|
||||||
// outrank the vertical scroll, and it does that by watching the initial
|
// vertical scroll, and it does that by watching the initial pass, which
|
||||||
// pass, which only reaches it if it is their ancestor.
|
// only reaches it if it is the viewport's ancestor.
|
||||||
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
||||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
// Scrolls on the same state as the gutter and the other pages, so they
|
||||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
// all stay aligned. A static, rounded-clipped window — the content
|
||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// scrolls inside it, so the soft corners are permanent at any scroll
|
||||||
// soft corners are permanent at any scroll position.
|
// position.
|
||||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
|
||||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
|
||||||
// labels centre on the top bar hamburger, matching the week view.
|
|
||||||
HourGutter(
|
|
||||||
scrollState = scrollState,
|
|
||||||
hourHeight = hourHeight,
|
|
||||||
dragController = dragController,
|
|
||||||
)
|
|
||||||
// Day column: rounded, clipped scroll viewport (permanent corners).
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxSize()
|
||||||
.fillMaxHeight()
|
.then(pinch)
|
||||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
|
||||||
) {
|
) {
|
||||||
DayColumnCard(
|
DayColumnCard(
|
||||||
blocks = state.timed,
|
blocks = state.timed,
|
||||||
@@ -600,6 +709,7 @@ private fun Timeline(
|
|||||||
date = state.date,
|
date = state.date,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
hourHeight = hourHeight,
|
hourHeight = hourHeight,
|
||||||
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
@@ -608,8 +718,10 @@ private fun Timeline(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(totalHeight)
|
.height(totalHeight)
|
||||||
// The scrolling content itself, so its root position
|
// The scrolling content itself, so its root position
|
||||||
// already folds in the scroll offset.
|
// already folds in the scroll offset. Only the page on
|
||||||
|
// screen publishes it: the ones either side are laid out too.
|
||||||
.onGloballyPositioned { coords ->
|
.onGloballyPositioned { coords ->
|
||||||
|
if (!active) return@onGloballyPositioned
|
||||||
dragController.geometry.let {
|
dragController.geometry.let {
|
||||||
it.grid = coords
|
it.grid = coords
|
||||||
it.scroll = scrollState
|
it.scroll = scrollState
|
||||||
@@ -624,7 +736,6 @@ private fun Timeline(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -634,6 +745,7 @@ private fun DayColumnCard(
|
|||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
hourHeight: Dp,
|
hourHeight: Dp,
|
||||||
|
scrollState: ScrollState,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
@@ -653,6 +765,8 @@ private fun DayColumnCard(
|
|||||||
// them, or taps land on the scale the column had before the zoom (#148).
|
// them, or taps land on the scale the column had before the zoom (#148).
|
||||||
val currentHourPx = rememberUpdatedState(hourPx)
|
val currentHourPx = rememberUpdatedState(hourPx)
|
||||||
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||||
|
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
|
||||||
|
val currentEdges = rememberUpdatedState(edges)
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular column — the soft corners come from the outer
|
// Plain rectangular column — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -680,6 +794,7 @@ private fun DayColumnCard(
|
|||||||
// only fires on the column background. Snaps to the tapped hour.
|
// only fires on the column background. Snaps to the tapped hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
detectTapGestures { offset ->
|
detectTapGestures { offset ->
|
||||||
|
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
|
||||||
currentOnCreateAt.value(
|
currentOnCreateAt.value(
|
||||||
date,
|
date,
|
||||||
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||||
@@ -741,6 +856,11 @@ private fun DayColumnCard(
|
|||||||
if (date == today) {
|
if (date == today) {
|
||||||
NowLine(date = date, hourHeight = hourHeight)
|
NowLine(date = date, hourHeight = hourHeight)
|
||||||
}
|
}
|
||||||
|
OutOfViewEdgeBars(
|
||||||
|
edges = edges,
|
||||||
|
scrollState = scrollState,
|
||||||
|
dark = dark,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -18,21 +19,25 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.filterNot
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.daysUntil
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/** How far from the requested day [DayViewModel.day] keeps other days cached. */
|
||||||
|
private const val DAY_CACHE_DAYS = 7
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -47,32 +52,53 @@ class DayViewModel @Inject constructor(
|
|||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
private val _date = MutableStateFlow(todayDate)
|
private val _date = MutableStateFlow(todayDate)
|
||||||
val date: StateFlow<LocalDate> = _date
|
|
||||||
|
|
||||||
|
/** The pager page the anchor day sits on. */
|
||||||
|
val anchorPage: StateFlow<Int> = _date
|
||||||
|
.map { dayPageFor(it) }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = dayPageFor(todayDate),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The anchor day's state. Once loaded it never falls back to [DayUiState.Loading]:
|
||||||
|
* moving the anchor keeps the last result until the new day arrives, since
|
||||||
|
* the pages draw their own days and this only gates the failure screen.
|
||||||
|
*/
|
||||||
val state: StateFlow<DayUiState> = _date
|
val state: StateFlow<DayUiState> = _date
|
||||||
.flatMapLatest { day ->
|
.flatMapLatest { date -> day(date).filterNot { it is DayUiState.Loading } }
|
||||||
val range = dayRange(day, zone)
|
|
||||||
combine(
|
|
||||||
repository.calendars(),
|
|
||||||
repository.instances(range),
|
|
||||||
) { calendars, instances ->
|
|
||||||
buildState(day, calendars, instances)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
.flowOn(io)
|
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = DayUiState.Loading,
|
initialValue = DayUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun goToPrev() {
|
private val days = PageStateCache<LocalDate, DayUiState>(
|
||||||
_date.value = _date.value.minus(1, DateTimeUnit.DAY)
|
scope = viewModelScope,
|
||||||
|
initial = DayUiState.Loading,
|
||||||
|
// Far enough to cover the pages either side and a swipe back, so paging
|
||||||
|
// through a month doesn't keep a month of queries around.
|
||||||
|
keep = { cached, requested -> abs(cached.daysUntil(requested)) <= DAY_CACHE_DAYS },
|
||||||
|
) { date ->
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
repository.instances(dayRange(date, zone)),
|
||||||
|
) { calendars, instances ->
|
||||||
|
buildState(date, calendars, instances)
|
||||||
|
}
|
||||||
|
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToNext() {
|
/** The day [date], shared between its pager page and [state]. Main thread only. */
|
||||||
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
|
fun day(date: LocalDate): StateFlow<DayUiState> = days.get(date)
|
||||||
|
|
||||||
|
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||||
|
fun onPageSettled(page: Int) {
|
||||||
|
if (dayPageFor(_date.value) != page) _date.value = dayForPage(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToToday() {
|
fun goToToday() {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,9 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
|
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
||||||
@@ -25,6 +27,9 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.filterNot
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
@@ -44,6 +49,9 @@ import kotlin.time.Clock
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */
|
||||||
|
private const val MONTH_CACHE_SPAN = 2
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MonthViewModel @Inject constructor(
|
class MonthViewModel @Inject constructor(
|
||||||
@@ -87,6 +95,21 @@ class MonthViewModel @Inject constructor(
|
|||||||
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
||||||
val month: StateFlow<YearMonth> = _month
|
val month: StateFlow<YearMonth> = _month
|
||||||
|
|
||||||
|
/** The pager page the anchor month sits on, for the paged and split styles. */
|
||||||
|
val anchorPage: StateFlow<Int> = _month
|
||||||
|
.map { monthIndexOf(it) }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = monthIndexOf(_month.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The anchor month's state. Once loaded it never falls back to
|
||||||
|
* [MonthUiState.Loading]: moving the anchor keeps the last result until the
|
||||||
|
* new month arrives, since the pages draw their own months and this feeds
|
||||||
|
* the failure screen and the split style's day pane.
|
||||||
|
*/
|
||||||
val state: StateFlow<MonthUiState> =
|
val state: StateFlow<MonthUiState> =
|
||||||
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
|
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
|
||||||
.flatMapLatest { (ym, ws, style) ->
|
.flatMapLatest { (ym, ws, style) ->
|
||||||
@@ -94,22 +117,48 @@ class MonthViewModel @Inject constructor(
|
|||||||
// Split draw, so under a scrolling style this query is a month
|
// Split draw, so under a scrolling style this query is a month
|
||||||
// of provider work for a view that isn't on screen.
|
// of provider work for a view that isn't on screen.
|
||||||
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
|
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
|
||||||
val range = monthGridRange(ym, ws, zone)
|
month(ym, ws).filterNot { it is MonthUiState.Loading }
|
||||||
combine(
|
|
||||||
repository.calendars(),
|
|
||||||
repository.instances(range),
|
|
||||||
) { calendars, instances ->
|
|
||||||
buildState(ym, ws, calendars, instances)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
.flowOn(io)
|
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = MonthUiState.Loading,
|
initialValue = MonthUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val months = PageStateCache<Pair<YearMonth, DayOfWeek>, MonthUiState>(
|
||||||
|
scope = viewModelScope,
|
||||||
|
initial = MonthUiState.Loading,
|
||||||
|
// Far enough to cover the pages either side and a swipe back, so paging
|
||||||
|
// through a year doesn't keep a year of queries around. A week-start
|
||||||
|
// change lays every month out afresh.
|
||||||
|
keep = { (cached, cachedWs), (requested, ws) ->
|
||||||
|
cachedWs == ws && abs(monthIndexOf(cached) - monthIndexOf(requested)) <= MONTH_CACHE_SPAN
|
||||||
|
},
|
||||||
|
) { (ym, ws) ->
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
repository.instances(monthGridRange(ym, ws, zone)),
|
||||||
|
) { calendars, instances ->
|
||||||
|
buildState(ym, ws, calendars, instances)
|
||||||
|
}
|
||||||
|
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Month [ym] laid out with weeks starting on [ws], shared between its pager
|
||||||
|
* page and [state]. Main thread only.
|
||||||
|
*/
|
||||||
|
fun month(ym: YearMonth, ws: DayOfWeek): StateFlow<MonthUiState> = months.get(ym to ws)
|
||||||
|
|
||||||
|
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||||
|
fun onPageSettled(page: Int) {
|
||||||
|
if (monthIndexOf(_month.value) == page) return
|
||||||
|
_month.value = yearMonthForIndex(page)
|
||||||
|
realignSelection()
|
||||||
|
}
|
||||||
|
|
||||||
// --- Continuous + Dense styles (#38) ----------------------------------
|
// --- Continuous + Dense styles (#38) ----------------------------------
|
||||||
//
|
//
|
||||||
// These scroll through every month there is, so they can't load "a month" —
|
// These scroll through every month there is, so they can't load "a month" —
|
||||||
@@ -244,12 +293,10 @@ class MonthViewModel @Inject constructor(
|
|||||||
|
|
||||||
// --- Split style (#53) ------------------------------------------------
|
// --- Split style (#53) ------------------------------------------------
|
||||||
//
|
//
|
||||||
// The first selected-day concept in the app: the other views drill straight
|
// Null means the full-month agenda; a date narrows the pane to that day.
|
||||||
// into a date, while the split style keeps one selected and lists it below
|
|
||||||
// the grid.
|
|
||||||
|
|
||||||
private val _selectedDate = MutableStateFlow(todayDate)
|
private val _selectedDate = MutableStateFlow<LocalDate?>(null)
|
||||||
val selectedDate: StateFlow<LocalDate> = _selectedDate
|
val selectedDate: StateFlow<LocalDate?> = _selectedDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select [date], following it to its month if it sits in the grid's leading
|
* Select [date], following it to its month if it sits in the grid's leading
|
||||||
@@ -257,33 +304,33 @@ class MonthViewModel @Inject constructor(
|
|||||||
* silently list a date the grid isn't pointing at.
|
* silently list a date the grid isn't pointing at.
|
||||||
*/
|
*/
|
||||||
fun selectDate(date: LocalDate) {
|
fun selectDate(date: LocalDate) {
|
||||||
_selectedDate.value = date
|
|
||||||
val target = YearMonth(date.year, date.month)
|
val target = YearMonth(date.year, date.month)
|
||||||
|
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
|
||||||
if (target != _month.value) _month.value = target
|
if (target != _month.value) _month.value = target
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun clearSelection() { _selectedDate.value = null }
|
||||||
* Move the selection with the month: today if the new month holds it, else
|
|
||||||
* its 1st. Leaving the old date selected would list a day the grid no longer
|
/** A pane swipe walks days when filtered, months when showing the agenda. */
|
||||||
* shows.
|
fun stepSplitPane(days: Int) {
|
||||||
*/
|
val selected = _selectedDate.value
|
||||||
|
if (selected == null) {
|
||||||
|
_month.value = yearMonthForIndex(monthIndexOf(_month.value) + days)
|
||||||
|
} else {
|
||||||
|
val next = selected.plus(days, DateTimeUnit.DAY)
|
||||||
|
_selectedDate.value = next
|
||||||
|
_month.value = YearMonth(next.year, next.month)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moving the grid to another month restores its full agenda. */
|
||||||
private fun realignSelection() {
|
private fun realignSelection() {
|
||||||
_selectedDate.value = selectionForMonth(_month.value, todayDate)
|
_selectedDate.value = null
|
||||||
}
|
|
||||||
|
|
||||||
fun goToPrev() {
|
|
||||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
|
||||||
realignSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun goToNext() {
|
|
||||||
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
|
||||||
realignSelection()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToToday() {
|
fun goToToday() {
|
||||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||||
_selectedDate.value = todayDate
|
_selectedDate.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -292,13 +339,12 @@ class MonthViewModel @Inject constructor(
|
|||||||
* otherwise sit wherever paged navigation last left it (today's month, on a
|
* otherwise sit wherever paged navigation last left it (today's month, on a
|
||||||
* fresh open) — and a switch to a paged style, or a reseed of the other
|
* fresh open) — and a switch to a paged style, or a reseed of the other
|
||||||
* scrolling style's list, would jump there instead of holding position. The
|
* scrolling style's list, would jump there instead of holding position. The
|
||||||
* selection is realigned alongside it so that landing on the Split style
|
* selection is cleared alongside it so Split opens the visible month's agenda.
|
||||||
* shows a live day in the month on screen rather than a stale, off-month one.
|
|
||||||
*/
|
*/
|
||||||
fun syncScrollMonth(ym: YearMonth) {
|
fun syncScrollMonth(ym: YearMonth) {
|
||||||
if (_month.value == ym) return
|
if (_month.value == ym) return
|
||||||
_month.value = ym
|
_month.value = ym
|
||||||
_selectedDate.value = selectionForMonth(ym, todayDate)
|
_selectedDate.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||||
@@ -461,11 +507,7 @@ internal fun instancesByDay(
|
|||||||
days.associateWith { day ->
|
days.associateWith { day ->
|
||||||
instances
|
instances
|
||||||
.filter { it.coversDay(day, zone) }
|
.filter { it.coversDay(day, zone) }
|
||||||
.sortedWith(
|
.sortedWith(eventDisplayComparator(zone))
|
||||||
compareBy<EventInstance> { it.isDeclined }
|
|
||||||
.thenByDescending { it.isAllDay }
|
|
||||||
.thenBy { it.start },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -506,11 +548,10 @@ private const val WINDOW_EDGE = 2
|
|||||||
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0)
|
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which day the split style should select when the grid lands on [month]:
|
* First day shown in the split agenda: today in the current month, otherwise
|
||||||
* [today] when the month holds it, otherwise the 1st. Pure so the rule can be
|
* the first of the visible month.
|
||||||
* tested without standing up a view model and a provider behind it.
|
|
||||||
*/
|
*/
|
||||||
internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||||
if (YearMonth(today.year, today.month) == month) {
|
if (YearMonth(today.year, today.month) == month) {
|
||||||
today
|
today
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
import de.jeanlucmakiola.calendula.ui.common.label
|
||||||
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
|
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
@@ -67,6 +68,11 @@ internal fun ViewStep(
|
|||||||
height = PREVIEW_HEIGHT,
|
height = PREVIEW_HEIGHT,
|
||||||
)
|
)
|
||||||
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
|
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||||
|
CalendarView.MultiDay -> WeekViewPreview(
|
||||||
|
weekStart = weekStart,
|
||||||
|
height = PREVIEW_HEIGHT,
|
||||||
|
dayCount = LocalMultiDayCount.current,
|
||||||
|
)
|
||||||
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
|
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
|
||||||
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
||||||
}
|
}
|
||||||
@@ -74,7 +80,7 @@ internal fun ViewStep(
|
|||||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||||
val isSelected = view == choice.defaultView
|
val isSelected = view == choice.defaultView
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(view.labelRes),
|
title = view.label(),
|
||||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
leading = {
|
leading = {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
@@ -34,6 +35,8 @@ data class SettingsUiState(
|
|||||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||||
/** Whether the week/day timeline seats each hour in its own cell (v2.11). */
|
/** Whether the week/day timeline seats each hour in its own cell (v2.11). */
|
||||||
val showHourGrid: Boolean = false,
|
val showHourGrid: Boolean = false,
|
||||||
|
/** Days the multi-day view shows side by side (#193). */
|
||||||
|
val multiDayCount: Int = SettingsPrefs.DEFAULT_MULTI_DAY_COUNT,
|
||||||
/** How the Agenda screen treats events that already ended today. */
|
/** How the Agenda screen treats events that already ended today. */
|
||||||
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||||
/** Whether the month/week grids fade events that have already finished. */
|
/** Whether the month/week grids fade events that have already finished. */
|
||||||
@@ -62,6 +65,7 @@ data class SettingsUiState(
|
|||||||
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
||||||
/** How tall an hour is drawn in the week and day timelines (#56). */
|
/** How tall an hour is drawn in the week and day timelines (#56). */
|
||||||
val timelineScale: TimelineScale = TimelineScale.Regular,
|
val timelineScale: TimelineScale = TimelineScale.Regular,
|
||||||
|
val timelineHours: TimelineHours = TimelineHours(),
|
||||||
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||||
@@ -148,11 +149,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
prefs.todayButtonInToolbar,
|
prefs.todayButtonInToolbar,
|
||||||
prefs.dragToReschedule,
|
prefs.dragToReschedule,
|
||||||
::Pair,
|
prefs.multiDayCount,
|
||||||
|
::Triple,
|
||||||
),
|
),
|
||||||
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove) ->
|
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove, multiDay) ->
|
||||||
DisplayToggles(
|
DisplayToggles(
|
||||||
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove,
|
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, multiDay,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||||
@@ -164,6 +166,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
softenColors = toggles.softenColors,
|
softenColors = toggles.softenColors,
|
||||||
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||||
dragToReschedule = toggles.dragToReschedule,
|
dragToReschedule = toggles.dragToReschedule,
|
||||||
|
multiDayCount = toggles.multiDayCount,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
@@ -178,9 +181,11 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.drawerViewOrder,
|
prefs.drawerViewOrder,
|
||||||
prefs.monthViewStyle,
|
prefs.monthViewStyle,
|
||||||
prefs.widgetSize,
|
prefs.widgetSize,
|
||||||
prefs.timelineScale,
|
combine(prefs.timelineScale, prefs.timelineHours, ::Pair),
|
||||||
) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale ->
|
) { quickSwitch, drawer, monthStyle, widgetSize, (timelineScale, timelineHours) ->
|
||||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale)
|
ViewCustomization(
|
||||||
|
quickSwitch, drawer, monthStyle, widgetSize, timelineScale, timelineHours,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||||
@@ -197,6 +202,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
softenColors = views.softenColors,
|
softenColors = views.softenColors,
|
||||||
todayButtonInToolbar = views.todayButtonInToolbar,
|
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||||
dragToReschedule = views.dragToReschedule,
|
dragToReschedule = views.dragToReschedule,
|
||||||
|
multiDayCount = views.multiDayCount,
|
||||||
agendaShowRangeBar = misc.showRangeBar,
|
agendaShowRangeBar = misc.showRangeBar,
|
||||||
autofocusEventTitle = misc.autofocusEventTitle,
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
pastEventDisplay = misc.pastEventDisplay,
|
pastEventDisplay = misc.pastEventDisplay,
|
||||||
@@ -206,6 +212,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||||
widgetSize = misc.viewCustomization.widgetSize,
|
widgetSize = misc.viewCustomization.widgetSize,
|
||||||
timelineScale = misc.viewCustomization.timelineScale,
|
timelineScale = misc.viewCustomization.timelineScale,
|
||||||
|
timelineHours = misc.viewCustomization.timelineHours,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -298,6 +305,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
val todayButtonInToolbar: Boolean,
|
val todayButtonInToolbar: Boolean,
|
||||||
val dragToReschedule: Boolean,
|
val dragToReschedule: Boolean,
|
||||||
|
val multiDayCount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class DisplayToggles(
|
private data class DisplayToggles(
|
||||||
@@ -307,6 +315,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
val todayButtonInToolbar: Boolean,
|
val todayButtonInToolbar: Boolean,
|
||||||
val dragToReschedule: Boolean,
|
val dragToReschedule: Boolean,
|
||||||
|
val multiDayCount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class MiscSettings(
|
private data class MiscSettings(
|
||||||
@@ -323,6 +332,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val monthViewStyle: MonthViewStyle,
|
val monthViewStyle: MonthViewStyle,
|
||||||
val widgetSize: WidgetSize,
|
val widgetSize: WidgetSize,
|
||||||
val timelineScale: TimelineScale,
|
val timelineScale: TimelineScale,
|
||||||
|
val timelineHours: TimelineHours,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||||
@@ -532,6 +542,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMultiDayCount(count: Int) {
|
||||||
|
viewModelScope.launch { prefs.setMultiDayCount(count) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setShowHourGrid(enabled: Boolean) {
|
fun setShowHourGrid(enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setShowHourGrid(enabled) }
|
viewModelScope.launch { prefs.setShowHourGrid(enabled) }
|
||||||
}
|
}
|
||||||
@@ -617,6 +631,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setTimelineScale(scale) }
|
viewModelScope.launch { prefs.setTimelineScale(scale) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTimelineHours(hours: TimelineHours) {
|
||||||
|
viewModelScope.launch { prefs.setTimelineHours(hours) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
@@ -32,11 +34,15 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
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.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.label
|
||||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
@@ -70,6 +76,10 @@ internal fun ViewsScreen(
|
|||||||
var showPastEvents by remember { mutableStateOf(false) }
|
var showPastEvents by remember { mutableStateOf(false) }
|
||||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||||
var showTimelineScale by remember { mutableStateOf(false) }
|
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(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_views),
|
title = stringResource(R.string.settings_section_views),
|
||||||
@@ -82,7 +92,7 @@ internal fun ViewsScreen(
|
|||||||
SectionHeader(stringResource(R.string.settings_views_all_header))
|
SectionHeader(stringResource(R.string.settings_views_all_header))
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_default_view),
|
title = stringResource(R.string.settings_default_view),
|
||||||
summary = stringResource(state.defaultView.labelRes),
|
summary = state.defaultView.label(),
|
||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
onClick = { showDefaultView = true },
|
onClick = { showDefaultView = true },
|
||||||
)
|
)
|
||||||
@@ -159,11 +169,56 @@ internal fun ViewsScreen(
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
SectionHeader(stringResource(R.string.settings_week_day_header))
|
SectionHeader(stringResource(R.string.settings_week_day_header))
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_timeline_scale),
|
title = stringResource(R.string.settings_multi_day_count),
|
||||||
summary = stringResource(state.timelineScale.labelRes),
|
summary = multiDayLabel(state.multiDayCount),
|
||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
onClick = { showTimelineScale = true },
|
onClick = { showMultiDayCount = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_timeline_scale),
|
||||||
|
summary = if (hours.enabled) {
|
||||||
|
stringResource(R.string.settings_timeline_scale_by_visible_hours)
|
||||||
|
} else {
|
||||||
|
stringResource(state.timelineScale.labelRes)
|
||||||
|
},
|
||||||
|
position = Position.Middle,
|
||||||
|
dimmed = hours.enabled,
|
||||||
|
onClick = if (hours.enabled) null else ({ 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(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_hour_lines),
|
title = stringResource(R.string.settings_hour_lines),
|
||||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||||
@@ -275,7 +330,7 @@ internal fun ViewsScreen(
|
|||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
options = IMPLEMENTED_VIEWS,
|
options = IMPLEMENTED_VIEWS,
|
||||||
selected = state.defaultView,
|
selected = state.defaultView,
|
||||||
label = { stringResource(it.labelRes) },
|
label = { it.label() },
|
||||||
leading = {
|
leading = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = it.icon,
|
imageVector = it.icon,
|
||||||
@@ -337,6 +392,18 @@ internal fun ViewsScreen(
|
|||||||
onDismiss = { showPastEvents = false },
|
onDismiss = { showPastEvents = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showMultiDayCount) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_multi_day_count),
|
||||||
|
header = { PickerDescription(stringResource(R.string.settings_multi_day_count_hint)) },
|
||||||
|
predictiveBack = true,
|
||||||
|
options = SettingsPrefs.MULTI_DAY_COUNTS.toList(),
|
||||||
|
selected = state.multiDayCount,
|
||||||
|
label = { multiDayLabel(it) },
|
||||||
|
onSelect = viewModel::setMultiDayCount,
|
||||||
|
onDismiss = { showMultiDayCount = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showTimelineScale) {
|
if (showTimelineScale) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_timeline_scale),
|
title = stringResource(R.string.settings_timeline_scale),
|
||||||
@@ -356,6 +423,30 @@ internal fun ViewsScreen(
|
|||||||
onDismiss = { showTimelineScale = false },
|
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) {
|
if (showAgendaScreenRange) {
|
||||||
AgendaRangePicker(
|
AgendaRangePicker(
|
||||||
title = stringResource(R.string.settings_agenda_range),
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
@@ -384,7 +475,7 @@ private fun ViewRow(
|
|||||||
trailing: @Composable (() -> Unit)? = null,
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(view.labelRes),
|
title = view.label(),
|
||||||
position = position,
|
position = position,
|
||||||
dimmed = dimmed,
|
dimmed = dimmed,
|
||||||
minHeight = ReorderableRowHeight,
|
minHeight = ReorderableRowHeight,
|
||||||
@@ -449,3 +540,12 @@ internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode)
|
|||||||
PastEventDisplay.DIM -> R.string.settings_past_events_dim
|
PastEventDisplay.DIM -> R.string.settings_past_events_dim
|
||||||
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
|
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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())
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.daysUntil
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
|
||||||
|
/** Pages the week pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
|
||||||
|
internal const val WEEK_PAGE_COUNT: Int = 20_000
|
||||||
|
|
||||||
|
private const val EPOCH_PAGE = WEEK_PAGE_COUNT / 2
|
||||||
|
|
||||||
|
/** The week at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||||
|
private val PAGE_EPOCH = LocalDate(2000, 1, 3)
|
||||||
|
|
||||||
|
/** First day of the week shown on pager [page], with weeks starting on [firstDay]. */
|
||||||
|
internal fun weekStartForPage(page: Int, firstDay: DayOfWeek): LocalDate =
|
||||||
|
PAGE_EPOCH.startOfWeek(firstDay).plus((page - EPOCH_PAGE) * 7, DateTimeUnit.DAY)
|
||||||
|
|
||||||
|
/** The pager page whose week contains [date], with weeks starting on [firstDay]. */
|
||||||
|
internal fun weekPageFor(date: LocalDate, firstDay: DayOfWeek): Int {
|
||||||
|
val days = PAGE_EPOCH.startOfWeek(firstDay).daysUntil(date.startOfWeek(firstDay))
|
||||||
|
return (EPOCH_PAGE + days / 7).coerceIn(0, WEEK_PAGE_COUNT - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a timeline pager cuts the calendar into pages of [dayCount] days. */
|
||||||
|
sealed interface PageGrid {
|
||||||
|
val dayCount: Int
|
||||||
|
val pageCount: Int
|
||||||
|
|
||||||
|
/** First day shown on pager [page]. */
|
||||||
|
fun startForPage(page: Int): LocalDate
|
||||||
|
|
||||||
|
/** The pager page showing [date]. */
|
||||||
|
fun pageFor(date: LocalDate): Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole weeks, starting on [firstDay]. */
|
||||||
|
data class WeekGrid(val firstDay: DayOfWeek) : PageGrid {
|
||||||
|
override val dayCount: Int get() = 7
|
||||||
|
override val pageCount: Int get() = WEEK_PAGE_COUNT
|
||||||
|
override fun startForPage(page: Int): LocalDate = weekStartForPage(page, firstDay)
|
||||||
|
override fun pageFor(date: LocalDate): Int = weekPageFor(date, firstDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pages the multi-day pager spans — over three centuries at the widest page. */
|
||||||
|
internal const val DAYS_PAGE_COUNT: Int = 40_000
|
||||||
|
|
||||||
|
private const val DAYS_EPOCH_PAGE = DAYS_PAGE_COUNT / 2
|
||||||
|
|
||||||
|
/** A fixed day the multi-day pages are counted from. */
|
||||||
|
private val DAYS_EPOCH = LocalDate(2000, 1, 1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs of [dayCount] days (#193), offset [phase] days from a fixed epoch so any
|
||||||
|
* date can start a page. The phase stays put while paging and only changes when
|
||||||
|
* a jump moves another date to the first column.
|
||||||
|
*/
|
||||||
|
data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid {
|
||||||
|
override val pageCount: Int get() = DAYS_PAGE_COUNT
|
||||||
|
|
||||||
|
override fun startForPage(page: Int): LocalDate =
|
||||||
|
DAYS_EPOCH.plus(phase + (page - DAYS_EPOCH_PAGE) * dayCount, DateTimeUnit.DAY)
|
||||||
|
|
||||||
|
override fun pageFor(date: LocalDate): Int =
|
||||||
|
(DAYS_EPOCH_PAGE + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
|
||||||
|
.coerceIn(0, DAYS_PAGE_COUNT - 1)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The grid of [dayCount]-day pages on which [date] is a page's first day. */
|
||||||
|
fun startingAt(date: LocalDate, dayCount: Int): DaysGrid =
|
||||||
|
DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,20 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PagerDefaults
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pagedHeight
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -48,12 +61,10 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
@@ -69,6 +80,7 @@ import androidx.compose.ui.semantics.contentDescription
|
|||||||
import androidx.compose.ui.semantics.customActions
|
import androidx.compose.ui.semantics.customActions
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -120,17 +132,18 @@ import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdgeBars
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberOutOfViewEdges
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
|
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
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.MIN_EVENT_FRACTION
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||||
@@ -142,17 +155,14 @@ import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
|
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
||||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -162,6 +172,14 @@ private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
|||||||
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
||||||
private val COLUMN_GAP = 2.dp
|
private val COLUMN_GAP = 2.dp
|
||||||
|
|
||||||
|
/** Breathing room between the header block and the scrolling timeline below. */
|
||||||
|
private val TIMELINE_TOP_GAP = 8.dp
|
||||||
|
private val HEADER_TOP_PADDING = 4.dp
|
||||||
|
private val HEADER_BOTTOM_PADDING = 8.dp
|
||||||
|
|
||||||
|
/** The header's date slot, reserved whether or not it holds today's circle. */
|
||||||
|
private val DATE_SLOT_SIZE = 28.dp
|
||||||
|
|
||||||
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
||||||
internal fun WeekUiState.Success.allDayStripHeight(): Dp {
|
internal fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||||
if (allDaySpans.isEmpty()) return 0.dp
|
if (allDaySpans.isEmpty()) return 0.dp
|
||||||
@@ -169,7 +187,6 @@ internal fun WeekUiState.Success.allDayStripHeight(): Dp {
|
|||||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WeekScreen(
|
fun WeekScreen(
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
@@ -182,11 +199,87 @@ fun WeekScreen(
|
|||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
todayInToolbar: Boolean = false,
|
todayInToolbar: Boolean = false,
|
||||||
|
initialDateIso: String? = null,
|
||||||
|
onInitialDateConsumed: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
LaunchedEffect(initialDateIso) {
|
||||||
|
initialDateIso?.let {
|
||||||
|
viewModel.goToDate(LocalDate.parse(it))
|
||||||
|
onInitialDateConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PagedTimelineScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
showWeekNumber = true,
|
||||||
|
selectedView = selectedView,
|
||||||
|
onSelectView = onSelectView,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The multi-day view (#193): the week view's timeline over the day count from Settings. */
|
||||||
|
@Composable
|
||||||
|
fun MultiDayScreen(
|
||||||
|
selectedView: CalendarView,
|
||||||
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
todayInToolbar: Boolean = false,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: MultiDayViewModel = hiltViewModel(),
|
||||||
|
) = PagedTimelineScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
// A run of days often straddles two weeks, so no single number fits it.
|
||||||
|
showWeekNumber = false,
|
||||||
|
selectedView = selectedView,
|
||||||
|
onSelectView = onSelectView,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun PagedTimelineScreen(
|
||||||
|
viewModel: PagedTimelineViewModel,
|
||||||
|
showWeekNumber: Boolean,
|
||||||
|
selectedView: CalendarView,
|
||||||
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView>,
|
||||||
|
drawerViewOrder: List<CalendarView>,
|
||||||
|
todayInToolbar: Boolean,
|
||||||
|
modifier: Modifier,
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
val position by viewModel.position.collectAsStateWithLifecycle()
|
||||||
|
val grid = position.grid
|
||||||
|
val anchorPage = position.page
|
||||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||||
// The instant before which an event counts as completed, or null when dimming
|
// The instant before which an event counts as completed, or null when dimming
|
||||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||||
@@ -199,39 +292,49 @@ fun WeekScreen(
|
|||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val isOnCurrentWeek = when (val s = state) {
|
// A new grid numbers the pages differently, so it gets a pager of its own,
|
||||||
// True when today falls inside the displayed week — independent of which
|
// opened straight on the anchor's page: the first day of the week changing,
|
||||||
// weekday the user picked as the first day.
|
// or a multi-day jump moving another date into the first column.
|
||||||
is WeekUiState.Success ->
|
val pagerState = key(grid) {
|
||||||
s.today >= s.weekStart && s.today <= s.weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
rememberPagerState(initialPage = anchorPage) { grid.pageCount }
|
||||||
else -> true
|
|
||||||
}
|
}
|
||||||
|
val pageSpec = rememberCalendarPageSpec()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
// The pager leads and the anchor follows once it settles, so this only
|
||||||
|
// catches the anchor moving on its own. Snapped, since that is a correction
|
||||||
|
// rather than a move.
|
||||||
|
LaunchedEffect(pagerState, anchorPage) {
|
||||||
|
if (pagerState.currentPage != anchorPage && !pagerState.isScrollInProgress) {
|
||||||
|
pagerState.scrollToPage(anchorPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The page it opened on is skipped: a grid change landing in between would
|
||||||
|
// read it as a different page.
|
||||||
|
LaunchedEffect(pagerState) {
|
||||||
|
snapshotFlow { pagerState.settledPage }.drop(1).collect(viewModel::onPageSettled)
|
||||||
|
}
|
||||||
|
// The page under the finger, so the title turns over mid-swipe, not after it.
|
||||||
|
val weekStart = grid.startForPage(pagerState.currentPage)
|
||||||
|
val weekEnd = weekStart.plus(grid.dayCount - 1, kotlinx.datetime.DateTimeUnit.DAY)
|
||||||
|
|
||||||
// Drives whether the title carries the year. Falls back to the clock only
|
// Off the ticking clock rather than the loaded page, so it turns over at
|
||||||
// while the first load is in flight, when there is no state to read today from.
|
// midnight; derived, so the per-minute tick doesn't recompose the screen.
|
||||||
val currentYear = when (val s = state) {
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
is WeekUiState.Success -> s.today.year
|
val today by remember(zone) { derivedStateOf { nowState.value.toLocalDateTime(zone).date } }
|
||||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
val isOnCurrentWeek = state !is WeekUiState.Success || (today >= weekStart && today <= weekEnd)
|
||||||
}
|
|
||||||
|
|
||||||
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
// Straight to the pager: a tap mid-fling must still land, and the anchor
|
||||||
var slideDir by remember { mutableIntStateOf(0) }
|
// hasn't caught up with the fling yet to tell the difference. A date that
|
||||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
// would open mid-page on this grid re-frames the pages around it instead.
|
||||||
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
|
||||||
// Slide toward today: viewing the future → today comes in from the left
|
|
||||||
// (back), viewing the past → from the right (forward).
|
|
||||||
val jumpToToday = {
|
|
||||||
slideDir = when (val s = state) {
|
|
||||||
is WeekUiState.Success -> if (s.today < s.weekStart) -1 else 1
|
|
||||||
else -> 0
|
|
||||||
}
|
|
||||||
viewModel.goToToday()
|
|
||||||
}
|
|
||||||
// Drawer jump-to-date: slide from the side the target week lies on.
|
|
||||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||||
slideDir = if (target < weekStart) -1 else 1
|
val page = grid.pageFor(target)
|
||||||
viewModel.goToDate(target)
|
if (grid is DaysGrid && grid.startForPage(page) != target) {
|
||||||
|
viewModel.jumpTo(target)
|
||||||
|
} else {
|
||||||
|
scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
val jumpToToday = { jumpToDate(today) }
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -263,7 +366,8 @@ fun WeekScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
WeekTopBar(
|
WeekTopBar(
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
currentYear = currentYear,
|
weekEnd = weekEnd,
|
||||||
|
currentYear = today.year,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
@@ -281,8 +385,6 @@ fun WeekScreen(
|
|||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = {
|
onCreate = {
|
||||||
// Anchor on today when it's in view, else the week's first day.
|
// Anchor on today when it's in view, else the week's first day.
|
||||||
val today = Clock.System.now()
|
|
||||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
|
||||||
onCreateEvent(if (isOnCurrentWeek) today else weekStart, null)
|
onCreateEvent(if (isOnCurrentWeek) today else weekStart, null)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -291,10 +393,11 @@ fun WeekScreen(
|
|||||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||||
WeekContent(
|
WeekContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
pagerState = pagerState,
|
||||||
onSwipeNext = goNext,
|
grid = grid,
|
||||||
onSwipePrev = goPrev,
|
showWeekNumber = showWeekNumber,
|
||||||
onRetry = jumpToToday,
|
span = viewModel::span,
|
||||||
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||||
@@ -310,78 +413,31 @@ fun WeekScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun WeekContent(
|
private fun WeekContent(
|
||||||
state: WeekUiState,
|
state: WeekUiState,
|
||||||
slideDir: Int,
|
pagerState: PagerState,
|
||||||
onSwipeNext: () -> Unit,
|
grid: PageGrid,
|
||||||
onSwipePrev: () -> Unit,
|
showWeekNumber: Boolean,
|
||||||
|
span: (DaySpan) -> StateFlow<WeekUiState>,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
|
|
||||||
// Hoisted above the per-week AnimatedContent so the vertical scroll position
|
// 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
|
// survives week-to-week swipes (e.g. 18:00 stays centred). Positioned once
|
||||||
// noon once, on first entry into the week view (i.e. when arriving from the
|
// on entry into the week view, not on every swipe: at the visible hours'
|
||||||
// month/day view), not on every swipe.
|
// start if set (#322), otherwise noon.
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
LaunchedEffect(Unit) {
|
OpenTimelineScroll(scrollState, LocalTimelineZoom.current)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
// Above the pager: a page change mid-drag would strand the floating block
|
||||||
// week during a swipe, so the strip slides along but never jumps in height;
|
// inside the outgoing page.
|
||||||
// it just springs smoothly from the old to the new size.
|
|
||||||
val targetAllDayHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp
|
|
||||||
val allDayHeight by animateDpAsState(
|
|
||||||
targetValue = targetAllDayHeight,
|
|
||||||
label = "all-day-strip-height",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Whole-page horizontal swipe. It sits one level above the timeline's
|
|
||||||
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
|
||||||
// while a vertical drag is consumed by the inner scroll first — so the two
|
|
||||||
// gestures coexist without fighting.
|
|
||||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
|
||||||
|
|
||||||
// Above the AnimatedContent: a page change mid-drag would strand the
|
|
||||||
// floating block inside the outgoing page.
|
|
||||||
val dragController = rememberTimelineDragController()
|
val dragController = rememberTimelineDragController()
|
||||||
val move = LocalEventMove.current
|
val move = LocalEventMove.current
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val onDrop: (TimelineDrop) -> Unit = { drop ->
|
||||||
Box(modifier = modifier) {
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = state,
|
|
||||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
|
||||||
contentKey = { s ->
|
|
||||||
when (s) {
|
|
||||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
|
||||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
|
||||||
WeekUiState.Loading -> "loading"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
|
||||||
label = "week-transition",
|
|
||||||
) { s ->
|
|
||||||
when (s) {
|
|
||||||
WeekUiState.Loading -> WeekLoading()
|
|
||||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
|
||||||
is WeekUiState.Success -> WeekSuccess(
|
|
||||||
state = s,
|
|
||||||
scrollState = scrollState,
|
|
||||||
allDayHeight = allDayHeight,
|
|
||||||
dragController = dragController,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onOpenDay = onOpenDay,
|
|
||||||
onCreateAt = onCreateAt,
|
|
||||||
onDrop = { drop ->
|
|
||||||
move?.move(
|
move?.move(
|
||||||
MoveRequest(
|
MoveRequest(
|
||||||
eventId = drop.event.eventId,
|
eventId = drop.event.eventId,
|
||||||
@@ -390,7 +446,34 @@ private fun WeekContent(
|
|||||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
// Loading and failure concern the provider as a whole, so they stand in
|
||||||
|
// for the pager; a page still loading its own week handles that itself.
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = state,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentKey = { it::class },
|
||||||
|
transitionSpec = { fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec)) },
|
||||||
|
label = "week-state",
|
||||||
|
) { s ->
|
||||||
|
when (s) {
|
||||||
|
WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount)
|
||||||
|
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
|
is WeekUiState.Success -> WeekPager(
|
||||||
|
pagerState = pagerState,
|
||||||
|
grid = grid,
|
||||||
|
showWeekNumber = showWeekNumber,
|
||||||
|
span = span,
|
||||||
|
today = s.today,
|
||||||
|
initialStripHeight = s.allDayStripHeight(),
|
||||||
|
scrollState = scrollState,
|
||||||
|
dragController = dragController,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,9 +481,130 @@ private fun WeekContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The weeks as pages that follow the finger and snap once a swipe passes half a
|
||||||
|
* page or is flung (#131), beside a gutter that stays put.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun WeekPager(
|
||||||
|
pagerState: PagerState,
|
||||||
|
grid: PageGrid,
|
||||||
|
showWeekNumber: Boolean,
|
||||||
|
span: (DaySpan) -> StateFlow<WeekUiState>,
|
||||||
|
today: LocalDate,
|
||||||
|
initialStripHeight: Dp,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
dragController: TimelineDragController,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
) {
|
||||||
|
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
|
||||||
|
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
|
||||||
|
WeekFrame(
|
||||||
|
weekStart = grid.startForPage(pagerState.currentPage),
|
||||||
|
showWeekNumber = showWeekNumber,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
scrollState = scrollState,
|
||||||
|
dragController = dragController,
|
||||||
|
) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
// The weeks either side are composed ahead, so a swipe lands on a
|
||||||
|
// loaded week rather than watching it fill in.
|
||||||
|
beyondViewportPageCount = 1,
|
||||||
|
// A held block moves within its week; the page stays put under it.
|
||||||
|
userScrollEnabled = !dragController.isDragging,
|
||||||
|
flingBehavior = PagerDefaults.flingBehavior(
|
||||||
|
state = pagerState,
|
||||||
|
snapAnimationSpec = rememberCalendarPageSpec(),
|
||||||
|
),
|
||||||
|
) { page ->
|
||||||
|
val pageSpan = DaySpan(grid.startForPage(page), grid.dayCount)
|
||||||
|
val pageState by remember(pageSpan) { span(pageSpan) }.collectAsStateWithLifecycle()
|
||||||
|
val loaded = pageState as? WeekUiState.Success
|
||||||
|
// Until its days arrive a page shows their frame without events.
|
||||||
|
val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) }
|
||||||
|
val stripHeight = loaded?.allDayStripHeight()
|
||||||
|
DisposableEffect(page, stripHeight) {
|
||||||
|
if (stripHeight != null) stripHeights[page] = stripHeight
|
||||||
|
onDispose { stripHeights.remove(page) }
|
||||||
|
}
|
||||||
|
WeekPage(
|
||||||
|
state = loaded ?: empty,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
scrollState = scrollState,
|
||||||
|
dragController = dragController,
|
||||||
|
active = page == pagerState.currentPage,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A page with no events yet, for one whose days are still loading. */
|
||||||
|
private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success {
|
||||||
|
val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
|
||||||
|
return WeekUiState.Success(
|
||||||
|
weekStart = span.start,
|
||||||
|
today = today,
|
||||||
|
days = days,
|
||||||
|
allDaySpans = emptyList(),
|
||||||
|
timedByDay = emptyMap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What stays put while the weeks swipe: the week-number badge and the hour
|
||||||
|
* gutter, with [pages] filling the rest.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun WeekFrame(
|
||||||
|
weekStart: LocalDate,
|
||||||
|
showWeekNumber: Boolean,
|
||||||
|
allDayHeight: Dp,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
dragController: TimelineDragController,
|
||||||
|
pages: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(modifier = Modifier.width(GUTTER_WIDTH).fillMaxHeight()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
) {
|
||||||
|
WeekNumberCell(weekStart, showBadge = showWeekNumber)
|
||||||
|
Spacer(Modifier.height(allDayHeight))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||||
|
// Resolves the hour height off the same viewport height the pages'
|
||||||
|
// timelines have, so labels and columns agree.
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val zoom = LocalTimelineZoom.current
|
||||||
|
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||||
|
HourGutter(
|
||||||
|
scrollState = scrollState,
|
||||||
|
hourHeight = hourHeight,
|
||||||
|
dragController = dragController,
|
||||||
|
modifier = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(modifier = Modifier.weight(1f).fillMaxHeight()) { pages() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single week in its frame, without the pager — for the Settings preview. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun WeekSuccess(
|
internal fun WeekSuccess(
|
||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
|
showWeekNumber: Boolean = true,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
@@ -408,6 +612,45 @@ internal fun WeekSuccess(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
|
) {
|
||||||
|
WeekFrame(
|
||||||
|
weekStart = state.weekStart,
|
||||||
|
showWeekNumber = showWeekNumber,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
scrollState = scrollState,
|
||||||
|
dragController = dragController,
|
||||||
|
) {
|
||||||
|
WeekPage(
|
||||||
|
state = state,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
scrollState = scrollState,
|
||||||
|
dragController = dragController,
|
||||||
|
active = true,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One week's page: header, all-day strip and day columns.
|
||||||
|
*
|
||||||
|
* @param active whether this is the page on screen, the only one that may
|
||||||
|
* publish the drag geometry.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun WeekPage(
|
||||||
|
state: WeekUiState.Success,
|
||||||
|
allDayHeight: Dp,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
dragController: TimelineDragController,
|
||||||
|
active: Boolean,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -424,13 +667,12 @@ internal fun WeekSuccess(
|
|||||||
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Breathing room between the top section and the scrolling timeline
|
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||||
// below.
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
|
active = active,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
@@ -442,6 +684,7 @@ internal fun WeekSuccess(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun WeekTopBar(
|
private fun WeekTopBar(
|
||||||
weekStart: LocalDate,
|
weekStart: LocalDate,
|
||||||
|
weekEnd: LocalDate,
|
||||||
currentYear: Int,
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
@@ -453,9 +696,9 @@ private fun WeekTopBar(
|
|||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val (title, shortTitle) = remember(weekStart, locale, currentYear) {
|
val (title, shortTitle) = remember(weekStart, weekEnd, locale, currentYear) {
|
||||||
formatWeekTitle(weekStart, locale, currentYear) to
|
formatWeekTitle(weekStart, weekEnd, locale, currentYear) to
|
||||||
formatWeekTitle(weekStart, locale, currentYear, abbreviated = true)
|
formatWeekTitle(weekStart, weekEnd, locale, currentYear, abbreviated = true)
|
||||||
}
|
}
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
@@ -505,24 +748,11 @@ private fun WeekDayHeader(
|
|||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val weekStart = days.first()
|
|
||||||
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET),
|
.padding(top = HEADER_TOP_PADDING, bottom = HEADER_BOTTOM_PADDING, end = TIMELINE_CONTENT_END_INSET),
|
||||||
) {
|
) {
|
||||||
// Mirror the day-column layout (empty weekday line + spacer) so the
|
|
||||||
// badge lines up vertically with the date numbers. The start inset centres
|
|
||||||
// the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET).
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(text = " ", style = MaterialTheme.typography.labelSmall)
|
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
WeekNumberBadge(weekNumber = weekNumber)
|
|
||||||
}
|
|
||||||
days.forEach { date ->
|
days.forEach { date ->
|
||||||
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
||||||
val isToday = date == today
|
val isToday = date == today
|
||||||
@@ -533,16 +763,19 @@ private fun WeekDayHeader(
|
|||||||
.clickable { onOpenDay(date) },
|
.clickable { onOpenDay(date) },
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
// One line, as the gutter cell beside it assumes (see WeekNumberCell).
|
||||||
Text(
|
Text(
|
||||||
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Clip,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
// Always reserve the 28dp circle slot so the header height is
|
// Always reserve the circle slot so the header height is
|
||||||
// identical whether or not the week contains today.
|
// identical whether or not the week contains today.
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.size(28.dp),
|
modifier = Modifier.size(DATE_SLOT_SIZE),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (isToday) {
|
if (isToday) {
|
||||||
@@ -572,6 +805,33 @@ private fun WeekDayHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The header's gutter cell, laid out like a day's (empty weekday line, spacer,
|
||||||
|
* date slot) so the badge lines up with the date numbers and the cell is exactly
|
||||||
|
* as tall as the header beside it. The start inset centres the badge on the top
|
||||||
|
* bar's hamburger (see GUTTER_CONTENT_START_INSET).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean) {
|
||||||
|
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
start = GUTTER_CONTENT_START_INSET,
|
||||||
|
top = HEADER_TOP_PADDING,
|
||||||
|
bottom = HEADER_BOTTOM_PADDING,
|
||||||
|
),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(text = " ", style = MaterialTheme.typography.labelSmall)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Box(modifier = Modifier.height(DATE_SLOT_SIZE), contentAlignment = Alignment.TopCenter) {
|
||||||
|
if (showBadge) WeekNumberBadge(weekNumber = weekNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
|
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
|
||||||
* filled box and bold number — at the month grid's size, so the two agree (#213). */
|
* filled box and bold number — at the month grid's size, so the two agree (#213). */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -612,8 +872,6 @@ private fun AllDayStrip(
|
|||||||
end = TIMELINE_CONTENT_END_INSET,
|
end = TIMELINE_CONTENT_END_INSET,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
// Keep the gutter-width offset so the bars line up with the day columns.
|
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
|
||||||
// Span bars are positioned absolutely so a multi-day event is one
|
// Span bars are positioned absolutely so a multi-day event is one
|
||||||
// connected bar across columns rather than a chip per day. clipToBounds
|
// connected bar across columns rather than a chip per day. clipToBounds
|
||||||
// keeps bars from spilling out while the height animates.
|
// keeps bars from spilling out while the height animates.
|
||||||
@@ -623,7 +881,7 @@ private fun AllDayStrip(
|
|||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.clipToBounds(),
|
.clipToBounds(),
|
||||||
) {
|
) {
|
||||||
val colWidth = maxWidth / 7
|
val colWidth = maxWidth / state.days.size
|
||||||
state.allDaySpans.forEach { span ->
|
state.allDaySpans.forEach { span ->
|
||||||
val spanCols = span.endCol - span.startCol + 1
|
val spanCols = span.endCol - span.startCol + 1
|
||||||
AllDayBar(
|
AllDayBar(
|
||||||
@@ -682,6 +940,7 @@ private fun Timeline(
|
|||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
|
active: Boolean,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
@@ -703,40 +962,32 @@ private fun Timeline(
|
|||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||||
val totalHeight = hourHeight * 24
|
val totalHeight = hourHeight * 24
|
||||||
// The pinch sits on the Row, above both scroll viewports: it has to
|
// The pinch sits above the scroll viewport: it has to outrank the
|
||||||
// outrank the vertical scroll, and it does that by watching the initial
|
// vertical scroll, and it does that by watching the initial pass, which
|
||||||
// pass, which only reaches it if it is their ancestor.
|
// only reaches it if it is the viewport's ancestor.
|
||||||
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
||||||
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
// Scrolls on the same state as the gutter and the other pages, so they
|
||||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
// all stay aligned. A static, rounded-clipped window — the content
|
||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// scrolls inside it, so the soft corners are permanent at any scroll
|
||||||
// soft corners are permanent at any scroll position (not just at the
|
// position (not just at the day's start/end).
|
||||||
// day's start/end).
|
|
||||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
|
||||||
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
|
||||||
// as the header badge so the labels sit under it and on the hamburger.
|
|
||||||
HourGutter(
|
|
||||||
scrollState = scrollState,
|
|
||||||
hourHeight = hourHeight,
|
|
||||||
dragController = dragController,
|
|
||||||
)
|
|
||||||
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxSize()
|
||||||
.fillMaxHeight()
|
.then(pinch)
|
||||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
.onGloballyPositioned { if (active) dragController.geometry.viewport = it },
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(totalHeight)
|
.height(totalHeight)
|
||||||
// The scrolling content itself, so its root position
|
// The scrolling content itself, so its root position
|
||||||
// already folds in the scroll offset.
|
// already folds in the scroll offset. Only the page on
|
||||||
|
// screen publishes it: the ones either side are laid out too.
|
||||||
.onGloballyPositioned { coords ->
|
.onGloballyPositioned { coords ->
|
||||||
|
if (!active) return@onGloballyPositioned
|
||||||
val gap = with(density) { COLUMN_GAP.toPx() }
|
val gap = with(density) { COLUMN_GAP.toPx() }
|
||||||
dragController.geometry.let {
|
dragController.geometry.let {
|
||||||
it.grid = coords
|
it.grid = coords
|
||||||
@@ -758,6 +1009,7 @@ private fun Timeline(
|
|||||||
date = day,
|
date = day,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
hourHeight = hourHeight,
|
hourHeight = hourHeight,
|
||||||
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
@@ -770,7 +1022,6 @@ private fun Timeline(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -780,6 +1031,7 @@ private fun DayColumnCard(
|
|||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
hourHeight: Dp,
|
hourHeight: Dp,
|
||||||
|
scrollState: ScrollState,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
@@ -799,6 +1051,8 @@ private fun DayColumnCard(
|
|||||||
// them, or taps land on the scale the column had before the zoom (#148).
|
// them, or taps land on the scale the column had before the zoom (#148).
|
||||||
val currentHourPx = rememberUpdatedState(hourPx)
|
val currentHourPx = rememberUpdatedState(hourPx)
|
||||||
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||||
|
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
|
||||||
|
val currentEdges = rememberUpdatedState(edges)
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular columns — the soft corners come from the outer
|
// Plain rectangular columns — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -826,6 +1080,7 @@ private fun DayColumnCard(
|
|||||||
// blocks are consumed by their own handler first. Snaps to hour.
|
// blocks are consumed by their own handler first. Snaps to hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
detectTapGestures { offset ->
|
detectTapGestures { offset ->
|
||||||
|
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
|
||||||
currentOnCreateAt.value(
|
currentOnCreateAt.value(
|
||||||
date,
|
date,
|
||||||
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||||
@@ -887,6 +1142,11 @@ private fun DayColumnCard(
|
|||||||
if (date == today) {
|
if (date == today) {
|
||||||
NowLine(date = date, hourHeight = hourHeight)
|
NowLine(date = date, hourHeight = hourHeight)
|
||||||
}
|
}
|
||||||
|
OutOfViewEdgeBars(
|
||||||
|
edges = edges,
|
||||||
|
scrollState = scrollState,
|
||||||
|
dark = dark,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1015,14 +1275,14 @@ private fun EventBlock(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekLoading() {
|
private fun WeekLoading(dayCount: Int) {
|
||||||
val scale = LocalTimelineZoom.current.scale
|
val scale = LocalTimelineZoom.current.scale
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header skeleton
|
// Header skeleton
|
||||||
Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
repeat(7) {
|
repeat(dayCount) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -1053,7 +1313,7 @@ private fun WeekLoading() {
|
|||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
repeat(7) {
|
repeat(dayCount) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -1093,11 +1353,11 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
|
|||||||
*/
|
*/
|
||||||
private fun formatWeekTitle(
|
private fun formatWeekTitle(
|
||||||
weekStart: LocalDate,
|
weekStart: LocalDate,
|
||||||
|
weekEnd: LocalDate,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
currentYear: Int,
|
currentYear: Int,
|
||||||
abbreviated: Boolean = false,
|
abbreviated: Boolean = false,
|
||||||
): String {
|
): String {
|
||||||
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
|
||||||
return formatCalendarTitle(
|
return formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
|
|||||||
@@ -9,17 +9,20 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.displayPriority
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filterNot
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -30,6 +33,7 @@ import kotlinx.datetime.LocalDate
|
|||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.daysUntil
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
@@ -38,19 +42,38 @@ import java.util.Locale
|
|||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
const val MINUTES_PER_DAY: Int = 24 * 60
|
const val MINUTES_PER_DAY: Int = 24 * 60
|
||||||
|
|
||||||
|
/** How far from the requested page [PagedTimelineViewModel.span] keeps other pages cached. */
|
||||||
|
private const val SPAN_CACHE_DAYS = 28
|
||||||
|
|
||||||
|
/** A run of [count] days from [start]: what one timeline page shows. */
|
||||||
|
data class DaySpan(val start: LocalDate, val count: Int)
|
||||||
|
|
||||||
|
/** How the pager cuts the calendar, and the page the anchor sits on. */
|
||||||
|
data class PagerPosition(val grid: PageGrid, val page: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A timeline paged in runs of days: the week view, and the multi-day view (#193).
|
||||||
|
* The pager leads; the anchor follows it once it settles, and the grid it is
|
||||||
|
* cut by is derived from the anchor through [gridRule], so a subclass decides
|
||||||
|
* whether a jump re-frames the pages or only moves between them.
|
||||||
|
*
|
||||||
|
* @param gridRule the grid to use for a given anchor, as the settings behind it change.
|
||||||
|
* @param initialRule the rule used until [gridRule] first emits.
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@HiltViewModel
|
abstract class PagedTimelineViewModel(
|
||||||
class WeekViewModel @Inject constructor(
|
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
settingsPrefs: SettingsPrefs,
|
settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
private val io: CoroutineDispatcher,
|
||||||
|
gridRule: Flow<(LocalDate) -> PageGrid>,
|
||||||
|
initialRule: (LocalDate) -> PageGrid,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
private val locale: Locale = Locale.getDefault()
|
|
||||||
|
|
||||||
/** Whether to fade events that have already finished (display concern only). */
|
/** Whether to fade events that have already finished (display concern only). */
|
||||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||||
@@ -63,76 +86,83 @@ class WeekViewModel @Inject constructor(
|
|||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
/** First day of the week, from the Settings preference (AUTO → locale). */
|
// A day on the visible page; the page itself comes from the grid, so a
|
||||||
private val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
// settings change re-frames the same days instead of jumping.
|
||||||
.map { it.resolveFirstDay(locale) }
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = DayOfWeek.MONDAY,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Anchor is a representative day inside the visible week; the actual week
|
|
||||||
// start is derived against [weekStart], so changing the first-day preference
|
|
||||||
// re-frames the same week instead of jumping.
|
|
||||||
private val _anchor = MutableStateFlow(todayDate)
|
private val _anchor = MutableStateFlow(todayDate)
|
||||||
|
|
||||||
val weekStartDate: StateFlow<LocalDate> =
|
val position: StateFlow<PagerPosition> =
|
||||||
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
combine(_anchor, gridRule) { anchor, rule -> rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) } }
|
||||||
|
.distinctUntilChanged()
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY),
|
initialValue = initialRule(todayDate).let { PagerPosition(it, it.pageFor(todayDate)) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The anchor page's state. Once loaded it never falls back to [WeekUiState.Loading]:
|
||||||
|
* moving the anchor keeps the last result until the new page arrives, since
|
||||||
|
* the pages draw their own days and this only gates the failure screen.
|
||||||
|
*/
|
||||||
val state: StateFlow<WeekUiState> =
|
val state: StateFlow<WeekUiState> =
|
||||||
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
|
position.map { DaySpan(it.grid.startForPage(it.page), it.grid.dayCount) }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.flatMapLatest { start ->
|
.flatMapLatest { span -> span(span).filterNot { it is WeekUiState.Loading } }
|
||||||
val range = weekRange(start, zone)
|
|
||||||
combine(
|
|
||||||
repository.calendars(),
|
|
||||||
repository.instances(range),
|
|
||||||
) { calendars, instances ->
|
|
||||||
buildState(start, calendars, instances)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
.flowOn(io)
|
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = WeekUiState.Loading,
|
initialValue = WeekUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun goToPrev() {
|
private val spans = PageStateCache<DaySpan, WeekUiState>(
|
||||||
_anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY)
|
scope = viewModelScope,
|
||||||
|
initial = WeekUiState.Loading,
|
||||||
|
// Far enough to cover the pages either side and a swipe back, so paging
|
||||||
|
// through a year doesn't keep a year of queries around.
|
||||||
|
keep = { cached, requested ->
|
||||||
|
cached.count == requested.count &&
|
||||||
|
abs(cached.start.daysUntil(requested.start)) <= SPAN_CACHE_DAYS
|
||||||
|
},
|
||||||
|
) { span ->
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
repository.instances(spanRange(span.start, span.count, zone)),
|
||||||
|
) { calendars, instances ->
|
||||||
|
buildState(span, calendars, instances)
|
||||||
|
}
|
||||||
|
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToNext() {
|
/** The days of one page, shared between that page and [state]. Main thread only. */
|
||||||
_anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY)
|
fun span(span: DaySpan): StateFlow<WeekUiState> = spans.get(span)
|
||||||
|
|
||||||
|
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||||
|
fun onPageSettled(page: Int) {
|
||||||
|
val grid = position.value.grid
|
||||||
|
if (grid.pageFor(_anchor.value) != page) _anchor.value = grid.startForPage(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToToday() {
|
/** Move the anchor to [date], re-framing the pages if the grid depends on it. */
|
||||||
_anchor.value = todayDate
|
fun jumpTo(date: LocalDate) {
|
||||||
}
|
|
||||||
|
|
||||||
/** Jump to the week containing [date] (drawer jump-to-date). */
|
|
||||||
fun goToDate(date: LocalDate) {
|
|
||||||
_anchor.value = date
|
_anchor.value = date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun goToToday() = jumpTo(todayDate)
|
||||||
|
fun goToDate(date: LocalDate) = jumpTo(date)
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
start: LocalDate,
|
span: DaySpan,
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
): WeekUiState {
|
): WeekUiState {
|
||||||
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
|
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
|
||||||
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
|
||||||
val allDay = instances.filter { it.isAllDay }
|
val allDay = instances.filter { it.isAllDay }
|
||||||
val timed = instances.filterNot { it.isAllDay }
|
val timed = instances.filterNot { it.isAllDay }
|
||||||
return WeekUiState.Success(
|
return WeekUiState.Success(
|
||||||
weekStart = start,
|
weekStart = span.start,
|
||||||
today = todayDate,
|
today = todayDate,
|
||||||
days = days,
|
days = days,
|
||||||
allDaySpans = layoutAllDay(allDay, days, zone),
|
allDaySpans = layoutAllDay(allDay, days, zone),
|
||||||
@@ -141,6 +171,44 @@ class WeekViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whole weeks, starting on the first day from Settings (AUTO → locale). */
|
||||||
|
@HiltViewModel
|
||||||
|
class WeekViewModel @Inject constructor(
|
||||||
|
repository: CalendarRepository,
|
||||||
|
settingsPrefs: SettingsPrefs,
|
||||||
|
@IoDispatcher io: CoroutineDispatcher,
|
||||||
|
) : PagedTimelineViewModel(
|
||||||
|
repository = repository,
|
||||||
|
settingsPrefs = settingsPrefs,
|
||||||
|
io = io,
|
||||||
|
gridRule = settingsPrefs.weekStart.map { pref ->
|
||||||
|
val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault()))
|
||||||
|
val rule: (LocalDate) -> PageGrid = { grid }
|
||||||
|
rule
|
||||||
|
},
|
||||||
|
initialRule = { WeekGrid(DayOfWeek.MONDAY) },
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The multi-day view (#193): runs of the day count from Settings, with the
|
||||||
|
* anchor always a page's first day, so today — or a date jumped to — opens in
|
||||||
|
* the first column.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class MultiDayViewModel @Inject constructor(
|
||||||
|
repository: CalendarRepository,
|
||||||
|
settingsPrefs: SettingsPrefs,
|
||||||
|
@IoDispatcher io: CoroutineDispatcher,
|
||||||
|
) : PagedTimelineViewModel(
|
||||||
|
repository = repository,
|
||||||
|
settingsPrefs = settingsPrefs,
|
||||||
|
io = io,
|
||||||
|
gridRule = settingsPrefs.multiDayCount.map { count ->
|
||||||
|
{ anchor: LocalDate -> DaysGrid.startingAt(anchor, count) }
|
||||||
|
},
|
||||||
|
initialRule = { DaysGrid.startingAt(it, SettingsPrefs.DEFAULT_MULTI_DAY_COUNT) },
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lay out all-day events as connected horizontal spans across the visible week.
|
* Lay out all-day events as connected horizontal spans across the visible week.
|
||||||
* Each event becomes one [AllDaySpan] from its first to its last covered column;
|
* Each event becomes one [AllDaySpan] from its first to its last covered column;
|
||||||
@@ -160,7 +228,13 @@ internal fun layoutAllDay(
|
|||||||
}
|
}
|
||||||
// Declined bars are packed after every other one, so on any day they
|
// Declined bars are packed after every other one, so on any day they
|
||||||
// cover they land in a lane below it (#230).
|
// cover they land in a lane below it (#230).
|
||||||
.sortedWith(compareBy({ it.event.isDeclined }, { it.startCol }, { it.endCol }))
|
.sortedWith(
|
||||||
|
compareBy<Raw> { it.event.displayPriority(zone) }
|
||||||
|
.thenBy { it.startCol }
|
||||||
|
.thenBy { it.endCol }
|
||||||
|
.thenBy { it.event.start }
|
||||||
|
.thenBy { it.event.instanceId },
|
||||||
|
)
|
||||||
|
|
||||||
// What each lane already holds, rather than just how far right it reaches.
|
// What each lane already holds, rather than just how far right it reaches.
|
||||||
// A single "last occupied column" only answers correctly while spans arrive
|
// A single "last occupied column" only answers correctly while spans arrive
|
||||||
@@ -194,10 +268,10 @@ internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
|
|||||||
return minus(offset, DateTimeUnit.DAY)
|
return minus(offset, DateTimeUnit.DAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Half-open instant range covering the seven days starting at [start]. */
|
/** Instant range covering the [count] days starting at [start]. */
|
||||||
internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
internal fun spanRange(start: LocalDate, count: Int, zone: TimeZone): ClosedRange<Instant> {
|
||||||
val from = start.atStartOfDayIn(zone)
|
val from = start.atStartOfDayIn(zone)
|
||||||
val to = start.plus(6, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
val to = start.plus(count - 1, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||||
return from..to
|
return from..to
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,13 @@ internal fun WeekViewPreview(
|
|||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
height: Dp,
|
height: Dp,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
dayCount: Int = 7,
|
||||||
) {
|
) {
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
val state = remember(today, weekStart, zone) { sampleWeekState(today, weekStart, zone) }
|
val state = remember(today, weekStart, dayCount, zone) {
|
||||||
|
sampleWeekState(today, weekStart, dayCount, zone)
|
||||||
|
}
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
@@ -46,6 +49,7 @@ internal fun WeekViewPreview(
|
|||||||
ScaledViewPreview(height = height, modifier = modifier) {
|
ScaledViewPreview(height = height, modifier = modifier) {
|
||||||
WeekSuccess(
|
WeekSuccess(
|
||||||
state = state,
|
state = state,
|
||||||
|
showWeekNumber = dayCount == 7,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = state.allDayStripHeight(),
|
allDayHeight = state.allDayStripHeight(),
|
||||||
dragController = rememberTimelineDragController(),
|
dragController = rememberTimelineDragController(),
|
||||||
@@ -61,10 +65,11 @@ internal fun WeekViewPreview(
|
|||||||
private fun sampleWeekState(
|
private fun sampleWeekState(
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
|
dayCount: Int,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
): WeekUiState.Success {
|
): WeekUiState.Success {
|
||||||
val start = today.startOfWeek(weekStart)
|
val start = if (dayCount == 7) today.startOfWeek(weekStart) else today
|
||||||
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
val days = (0 until dayCount).map { start.plus(it, DateTimeUnit.DAY) }
|
||||||
val events = sampleTimelineEvents(days, today, zone)
|
val events = sampleTimelineEvents(days, today, zone)
|
||||||
return WeekUiState.Success(
|
return WeekUiState.Success(
|
||||||
weekStart = start,
|
weekStart = start,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Calendula</string>
|
<string name="app_name">Calendula</string>
|
||||||
<string name="app_tagline">A modern calendar.</string>
|
<string name="app_tagline">A modern calendar.</string>
|
||||||
<!-- Alternative launcher label offered by the "App name" setting (issue #44).
|
<!-- Alternative launcher label offered by the "App name" setting (issue #44),
|
||||||
translatable="false": this string becomes a launcher component's label, so
|
shown under the app icon. Translate it as the plain, generic word your
|
||||||
it must never be changed by an unreviewed community translation. -->
|
language uses for a calendar app (e.g. "Kalender"), nothing else (#251). -->
|
||||||
<string name="app_name_calendar_alias" translatable="false">Calendar</string>
|
<string name="app_name_calendar_alias">Calendar</string>
|
||||||
|
|
||||||
<!-- Loading / Failure / Success generic strings (used across screens) -->
|
<!-- Loading / Failure / Success generic strings (used across screens) -->
|
||||||
<string name="state_loading">Loading…</string>
|
<string name="state_loading">Loading…</string>
|
||||||
@@ -54,7 +54,9 @@
|
|||||||
<!-- Month view (S1) -->
|
<!-- Month view (S1) -->
|
||||||
<string name="month_prev">Previous month</string>
|
<string name="month_prev">Previous month</string>
|
||||||
<string name="month_next">Next month</string>
|
<string name="month_next">Next month</string>
|
||||||
|
<string name="month_open_week">Open week %1$d</string>
|
||||||
<string name="month_today_action">Today</string>
|
<string name="month_today_action">Today</string>
|
||||||
|
<string name="month_split_clear_filter">Show full month agenda</string>
|
||||||
<string name="month_more_actions">More actions</string>
|
<string name="month_more_actions">More actions</string>
|
||||||
<string name="month_open_menu">Open menu</string>
|
<string name="month_open_menu">Open menu</string>
|
||||||
<string name="month_action_settings">Settings</string>
|
<string name="month_action_settings">Settings</string>
|
||||||
@@ -67,6 +69,10 @@
|
|||||||
<!-- Day view (S3) -->
|
<!-- Day view (S3) -->
|
||||||
<string name="day_today_action">Today</string>
|
<string name="day_today_action">Today</string>
|
||||||
|
|
||||||
|
<!-- Week and day timelines — edge bar for events scrolled out of view (#188) -->
|
||||||
|
<string name="timeline_earlier_events">Earlier events</string>
|
||||||
|
<string name="timeline_later_events">Later events</string>
|
||||||
|
|
||||||
<!-- Event detail screen (S4) -->
|
<!-- Event detail screen (S4) -->
|
||||||
<string name="event_detail_back">Back</string>
|
<string name="event_detail_back">Back</string>
|
||||||
<string name="event_detail_edit">Edit</string>
|
<string name="event_detail_edit">Edit</string>
|
||||||
@@ -349,12 +355,18 @@
|
|||||||
<string name="reminder_action_snooze">Snooze</string>
|
<string name="reminder_action_snooze">Snooze</string>
|
||||||
<string name="reminder_action_dismiss">Dismiss</string>
|
<string name="reminder_action_dismiss">Dismiss</string>
|
||||||
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
|
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
|
||||||
|
<string name="reminder_day_today">Today</string>
|
||||||
<string name="reminder_day_tomorrow">Tomorrow</string>
|
<string name="reminder_day_tomorrow">Tomorrow</string>
|
||||||
<string name="reminder_day_yesterday">Yesterday</string>
|
<string name="reminder_day_yesterday">Yesterday</string>
|
||||||
|
|
||||||
<!-- View switcher (M1) -->
|
<!-- View switcher (M1) -->
|
||||||
<string name="view_month">Month</string>
|
<string name="view_month">Month</string>
|
||||||
<string name="view_week">Week</string>
|
<string name="view_week">Week</string>
|
||||||
|
<!-- The multi-day view, named after how many days it shows (#193) -->
|
||||||
|
<plurals name="view_multi_day">
|
||||||
|
<item quantity="one">%d day</item>
|
||||||
|
<item quantity="other">%d days</item>
|
||||||
|
</plurals>
|
||||||
<string name="view_day">Day</string>
|
<string name="view_day">Day</string>
|
||||||
<string name="view_agenda">Agenda</string>
|
<string name="view_agenda">Agenda</string>
|
||||||
<string name="view_section">View</string>
|
<string name="view_section">View</string>
|
||||||
@@ -470,7 +482,10 @@
|
|||||||
each hour") describes a feature that no longer exists and needs redoing. -->
|
each hour") describes a feature that no longer exists and needs redoing. -->
|
||||||
<string name="settings_hour_lines">Hour grid</string>
|
<string name="settings_hour_lines">Hour grid</string>
|
||||||
<string name="settings_hour_lines_summary">Seat each hour in its own cell in week and day view</string>
|
<string name="settings_hour_lines_summary">Seat each hour in its own cell in week and day view</string>
|
||||||
|
<string name="settings_multi_day_count">Multi-day view</string>
|
||||||
|
<string name="settings_multi_day_count_hint">How many days the multi-day view shows side by side. Today opens in the first column.</string>
|
||||||
<string name="settings_timeline_scale">Hour height</string>
|
<string name="settings_timeline_scale">Hour height</string>
|
||||||
|
<string name="settings_timeline_scale_by_visible_hours">Set by Visible hours</string>
|
||||||
<string name="settings_timeline_scale_hint">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.</string>
|
<string name="settings_timeline_scale_hint">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.</string>
|
||||||
<string name="timeline_scale_fit_day">Fit whole day</string>
|
<string name="timeline_scale_fit_day">Fit whole day</string>
|
||||||
<string name="timeline_scale_fit_day_summary">All 24 hours on one screen, no scrolling</string>
|
<string name="timeline_scale_fit_day_summary">All 24 hours on one screen, no scrolling</string>
|
||||||
@@ -482,6 +497,13 @@
|
|||||||
<string name="timeline_scale_comfortable_summary">Roomier blocks, more scrolling</string>
|
<string name="timeline_scale_comfortable_summary">Roomier blocks, more scrolling</string>
|
||||||
<string name="timeline_scale_custom">Custom</string>
|
<string name="timeline_scale_custom">Custom</string>
|
||||||
<string name="timeline_scale_custom_summary">The height you pinched the timeline to</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">Dim completed events</string>
|
||||||
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||||
<string name="settings_drag_to_reschedule">Drag to reschedule</string>
|
<string name="settings_drag_to_reschedule">Drag to reschedule</string>
|
||||||
@@ -530,7 +552,7 @@
|
|||||||
<string name="settings_month_header">Month view</string>
|
<string name="settings_month_header">Month view</string>
|
||||||
<string name="settings_month_view_style">Month view style</string>
|
<string name="settings_month_view_style">Month view style</string>
|
||||||
<string name="month_style_paged">Pages</string>
|
<string name="month_style_paged">Pages</string>
|
||||||
<string name="month_style_paged_summary">One month fills the screen. Swipe left or right to change month.</string>
|
<string name="month_style_paged_summary">One month fills the screen. Swipe left or right to change month, or swipe up to show the month agenda.</string>
|
||||||
<string name="month_style_continuous">Scrolling months</string>
|
<string name="month_style_continuous">Scrolling months</string>
|
||||||
<string name="month_style_continuous_summary">Each month sits under its own heading, with a little space setting it apart from the next.</string>
|
<string name="month_style_continuous_summary">Each month sits under its own heading, with a little space setting it apart from the next.</string>
|
||||||
<string name="month_style_dense">Seamless weeks</string>
|
<string name="month_style_dense">Seamless weeks</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.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -125,6 +126,24 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.Comfortable)
|
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
|
@Test
|
||||||
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
|
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
@@ -235,7 +254,8 @@ class SettingsPrefsTest {
|
|||||||
prefs.setQuickSwitchConfig(config)
|
prefs.setQuickSwitchConfig(config)
|
||||||
val loaded = prefs.quickSwitchConfig.first()
|
val loaded = prefs.quickSwitchConfig.first()
|
||||||
assertThat(loaded.order).containsExactly(
|
assertThat(loaded.order).containsExactly(
|
||||||
CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day,
|
CalendarView.Agenda, CalendarView.Month, CalendarView.Week,
|
||||||
|
CalendarView.MultiDay, CalendarView.Day,
|
||||||
).inOrder()
|
).inOrder()
|
||||||
assertThat(loaded.enabled).containsExactly(CalendarView.Agenda, CalendarView.Month)
|
assertThat(loaded.enabled).containsExactly(CalendarView.Agenda, CalendarView.Month)
|
||||||
assertThat(loaded.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
assertThat(loaded.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
||||||
@@ -252,13 +272,14 @@ class SettingsPrefsTest {
|
|||||||
m
|
m
|
||||||
}
|
}
|
||||||
val config = prefs.quickSwitchConfig.first()
|
val config = prefs.quickSwitchConfig.first()
|
||||||
// Stored order first (Day, Week), then the omitted views in default order.
|
// Stored order first, with the new view after Week.
|
||||||
assertThat(config.order).containsExactly(
|
assertThat(config.order).containsExactly(
|
||||||
CalendarView.Day, CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
|
CalendarView.Day, CalendarView.Week, CalendarView.MultiDay,
|
||||||
|
CalendarView.Month, CalendarView.Agenda,
|
||||||
).inOrder()
|
).inOrder()
|
||||||
// Day was explicitly disabled; the appended Month & Agenda default enabled.
|
// Day was explicitly disabled; the missing views default enabled.
|
||||||
assertThat(config.enabled).containsExactly(
|
assertThat(config.enabled).containsExactly(
|
||||||
CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
|
CalendarView.Week, CalendarView.MultiDay, CalendarView.Month, CalendarView.Agenda,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,9 +293,12 @@ class SettingsPrefsTest {
|
|||||||
val newOrder = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day)
|
val newOrder = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day)
|
||||||
prefs.updateQuickSwitch { it.copy(order = newOrder) }
|
prefs.updateQuickSwitch { it.copy(order = newOrder) }
|
||||||
val config = prefs.quickSwitchConfig.first()
|
val config = prefs.quickSwitchConfig.first()
|
||||||
assertThat(config.order).containsExactlyElementsIn(newOrder).inOrder()
|
assertThat(config.order).containsExactly(
|
||||||
assertThat(config.enabled).containsExactly(
|
|
||||||
CalendarView.Agenda, CalendarView.Month, CalendarView.Week,
|
CalendarView.Agenda, CalendarView.Month, CalendarView.Week,
|
||||||
|
CalendarView.MultiDay, CalendarView.Day,
|
||||||
|
).inOrder()
|
||||||
|
assertThat(config.enabled).containsExactly(
|
||||||
|
CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.MultiDay,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +310,8 @@ class SettingsPrefsTest {
|
|||||||
listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month),
|
listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month),
|
||||||
)
|
)
|
||||||
assertThat(prefs.drawerViewOrder.first()).containsExactly(
|
assertThat(prefs.drawerViewOrder.first()).containsExactly(
|
||||||
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
|
CalendarView.Agenda, CalendarView.Day, CalendarView.Week,
|
||||||
|
CalendarView.MultiDay, CalendarView.Month,
|
||||||
).inOrder()
|
).inOrder()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +325,8 @@ class SettingsPrefsTest {
|
|||||||
m
|
m
|
||||||
}
|
}
|
||||||
assertThat(prefs.drawerViewOrder.first()).containsExactly(
|
assertThat(prefs.drawerViewOrder.first()).containsExactly(
|
||||||
CalendarView.Agenda, CalendarView.Week, CalendarView.Month, CalendarView.Day,
|
CalendarView.Agenda, CalendarView.Week, CalendarView.MultiDay,
|
||||||
|
CalendarView.Month, CalendarView.Day,
|
||||||
).inOrder()
|
).inOrder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-10
@@ -38,6 +38,7 @@ class ReminderTimeTextTest {
|
|||||||
is24Hour = is24Hour,
|
is24Hour = is24Hour,
|
||||||
today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(),
|
today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(),
|
||||||
firstDayOfWeek = firstDayOfWeek,
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
|
todayLabel = "Today",
|
||||||
tomorrowLabel = "Tomorrow",
|
tomorrowLabel = "Tomorrow",
|
||||||
yesterdayLabel = "Yesterday",
|
yesterdayLabel = "Yesterday",
|
||||||
)
|
)
|
||||||
@@ -130,38 +131,74 @@ class ReminderTimeTextTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `all-day single day shows one date, read in UTC`() {
|
fun `all-day single day today reads as today, read in UTC`() {
|
||||||
val text = text(
|
val text = text(
|
||||||
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||||
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
// Zone must not matter for all-day events: UTC midnight is
|
// Zone must not matter for all-day events: UTC midnight is
|
||||||
// 02:00 in Berlin — naive local reading would shift the day.
|
// the previous evening in Los Angeles — naive local reading would shift the day.
|
||||||
|
zone = ZoneId.of("America/Los_Angeles"),
|
||||||
today = LocalDate.of(2026, 6, 11),
|
today = LocalDate.of(2026, 6, 11),
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026")
|
assertThat(text).isEqualTo("Today")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `all-day event tomorrow still shows the exact date, no relative prefix`() {
|
fun `all-day event tomorrow reads as tomorrow`() {
|
||||||
val text = text(
|
val text = text(
|
||||||
beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
||||||
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
today = LocalDate.of(2026, 6, 11),
|
today = LocalDate.of(2026, 6, 11),
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("12.06.2026")
|
assertThat(text).isEqualTo("Tomorrow")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
|
fun `all-day event later this week reads as the short weekday`() {
|
||||||
|
// 2026-06-11 is a Thursday; Saturday the 13th shares its week.
|
||||||
val text = text(
|
val text = text(
|
||||||
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
||||||
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 14)),
|
||||||
|
isAllDay = true,
|
||||||
|
locale = Locale.US,
|
||||||
|
today = LocalDate.of(2026, 6, 11),
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("Sat")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day event next week falls back to the exact date`() {
|
||||||
|
val text = text(
|
||||||
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 16)),
|
||||||
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 17)),
|
||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
today = LocalDate.of(2026, 6, 11),
|
today = LocalDate.of(2026, 6, 11),
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
assertThat(text).isEqualTo("16.06.2026")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day multi-day reads relative start and the last covered day`() {
|
||||||
|
val text = text(
|
||||||
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
||||||
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 14)),
|
||||||
|
isAllDay = true,
|
||||||
|
today = LocalDate.of(2026, 6, 11),
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("Tomorrow – 13.06.2026")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day multi-day further out keeps both dates`() {
|
||||||
|
val text = text(
|
||||||
|
beginMillis = utcMidnight(LocalDate.of(2026, 7, 1)),
|
||||||
|
endMillis = utcMidnight(LocalDate.of(2026, 7, 3)),
|
||||||
|
isAllDay = true,
|
||||||
|
today = LocalDate.of(2026, 6, 11),
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("01.07.2026 – 02.07.2026")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -173,6 +210,6 @@ class ReminderTimeTextTest {
|
|||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
today = LocalDate.of(2026, 6, 11),
|
today = LocalDate.of(2026, 6, 11),
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026")
|
assertThat(text).isEqualTo("Today")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,4 +156,5 @@ class EventToneTest {
|
|||||||
assertThat(eventTone(0xFFFFF6C0.toInt(), dark = false, harmonise = false).onContainer)
|
assertThat(eventTone(0xFFFFF6C0.toInt(), dark = false, harmonise = false).onContainer)
|
||||||
.isEqualTo(0xFF000000.toInt())
|
.isEqualTo(0xFF000000.toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,16 @@ class GroupAgendaDaysTest {
|
|||||||
.containsExactly(LocalDate(2026, 7, 2))
|
.containsExactly(LocalDate(2026, 7, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multi-day events precede single all-day and timed events`() {
|
||||||
|
val multi = event(1, "Multi", at(2026, 7, 1), at(2026, 7, 4), isAllDay = true)
|
||||||
|
val single = event(2, "Single", at(2026, 7, 2), at(2026, 7, 3), isAllDay = true)
|
||||||
|
val timed = event(3, "Timed", at(2026, 7, 2, 8), at(2026, 7, 2, 9))
|
||||||
|
val second = days(listOf(timed, single, multi)).first { it.date == LocalDate(2026, 7, 2) }
|
||||||
|
assertThat(second.events.map { it.title })
|
||||||
|
.containsExactly("Multi", "Single", "Timed").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an event ending exactly at midnight does not reach the next day`() {
|
fun `an event ending exactly at midnight does not reach the next day`() {
|
||||||
val e = event(1, "late", at(2026, 7, 1, 22, 0), at(2026, 7, 2, 0, 0))
|
val e = event(1, "late", at(2026, 7, 1, 22, 0), at(2026, 7, 2, 0, 0))
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/** Which events a column's edge bars point at, and where a tap scrolls (#188). */
|
||||||
|
class OutOfViewEdgesTest {
|
||||||
|
|
||||||
|
private fun block(id: Long, startMin: Int, endMin: Int) = TimedBlock(
|
||||||
|
event = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "Event $id",
|
||||||
|
start = Instant.fromEpochMilliseconds(0),
|
||||||
|
end = Instant.fromEpochMilliseconds(0),
|
||||||
|
isAllDay = false,
|
||||||
|
color = 0xFF112233.toInt(),
|
||||||
|
location = null,
|
||||||
|
),
|
||||||
|
startMin = startMin,
|
||||||
|
endMin = endMin,
|
||||||
|
lane = 0,
|
||||||
|
laneCount = 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val early = block(1, 7 * 60, 8 * 60)
|
||||||
|
private val breakfast = block(2, 8 * 60, 9 * 60)
|
||||||
|
private val lunch = block(3, 12 * 60, 13 * 60)
|
||||||
|
private val dinner = block(4, 19 * 60, 20 * 60)
|
||||||
|
private val late = block(5, 21 * 60, 22 * 60)
|
||||||
|
|
||||||
|
// Viewport 10:00–17:00.
|
||||||
|
private fun outOf(vararg blocks: TimedBlock) =
|
||||||
|
outOfViewEvents(blocks.toList(), topMin = 600f, bottomMin = 1020f)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the nearest hidden event on each side is picked`() {
|
||||||
|
val events = outOf(early, breakfast, lunch, dinner, late)
|
||||||
|
assertThat(events.above).isEqualTo(breakfast)
|
||||||
|
assertThat(events.below).isEqualTo(dinner)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event in view points nowhere`() {
|
||||||
|
assertThat(outOf(lunch)).isEqualTo(OutOfViewEvents.None)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event cut by the edge is not out of view`() {
|
||||||
|
val straddling = block(6, 9 * 60, 11 * 60)
|
||||||
|
assertThat(outOf(straddling).above).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event disappears from the edge as soon as a sliver enters view`() {
|
||||||
|
val sliver = block(6, 9 * 60, 10 * 60 + 5)
|
||||||
|
assertThat(outOf(sliver).above).isNull()
|
||||||
|
val tip = block(7, 17 * 60 - 5, 18 * 60)
|
||||||
|
assertThat(outOf(tip).below).isNull()
|
||||||
|
val justAbove = block(8, 9 * 60, 10 * 60)
|
||||||
|
val justBelow = block(9, 17 * 60, 18 * 60)
|
||||||
|
assertThat(outOf(justAbove).above).isEqualTo(justAbove)
|
||||||
|
assertThat(outOf(justBelow).below).isEqualTo(justBelow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the bar fades before its event reaches the viewport`() {
|
||||||
|
assertThat(edgeBarAlpha(gapPx = 24f, fadeLeadPx = 24f)).isEqualTo(1f)
|
||||||
|
assertThat(edgeBarAlpha(gapPx = 12f, fadeLeadPx = 24f)).isEqualTo(0.5f)
|
||||||
|
assertThat(edgeBarAlpha(gapPx = 0f, fadeLeadPx = 24f)).isEqualTo(0f)
|
||||||
|
assertThat(edgeBarAlpha(gapPx = -1f, fadeLeadPx = 24f)).isEqualTo(0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a column scrolled to its top points at nothing above it`() {
|
||||||
|
val first = block(6, 0, 30)
|
||||||
|
val events = outOfViewEvents(listOf(first), topMin = 0f, bottomMin = 420f)
|
||||||
|
assertThat(events.above).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a short event is judged by the height it renders at`() {
|
||||||
|
// Five minutes ending at 09:58 still draws 26 minutes tall, past 10:00.
|
||||||
|
val blip = block(6, 9 * 60 + 53, 9 * 60 + 58)
|
||||||
|
assertThat(outOf(blip).above).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event above scrolls to its start less the margin`() {
|
||||||
|
val target = revealScrollTarget(breakfast, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700)
|
||||||
|
assertThat(target).isEqualTo(780)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event below that fits lands whole at the bottom`() {
|
||||||
|
val target = revealScrollTarget(dinner, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700)
|
||||||
|
// Its end, 2000, plus the margin, sits at the viewport's bottom.
|
||||||
|
assertThat(target).isEqualTo(1320)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event below taller than the viewport lands with its start at the top`() {
|
||||||
|
val long = block(6, 12 * 60, 22 * 60)
|
||||||
|
val target = revealScrollTarget(long, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700)
|
||||||
|
assertThat(target).isEqualTo(1180)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the target stays within the scroll range`() {
|
||||||
|
val first = block(6, 0, 30)
|
||||||
|
assertThat(
|
||||||
|
revealScrollTarget(first, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700),
|
||||||
|
).isEqualTo(0)
|
||||||
|
val last = block(7, 23 * 60, 24 * 60)
|
||||||
|
assertThat(
|
||||||
|
revealScrollTarget(last, ViewportEdge.Bottom, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700),
|
||||||
|
).isEqualTo(1700)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Viewport 1000..1700 px with both bars showing, 40 px of reach.
|
||||||
|
private fun tapped(y: Float, events: OutOfViewEvents = OutOfViewEvents(breakfast, dinner)) =
|
||||||
|
tappedEdge(events, y, viewportTop = 1000f, viewportPx = 700, reachPx = 40f)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tap near an edge with a bar hits that bar`() {
|
||||||
|
assertThat(tapped(1010f)).isEqualTo(ViewportEdge.Top)
|
||||||
|
assertThat(tapped(1040f)).isEqualTo(ViewportEdge.Top)
|
||||||
|
assertThat(tapped(1690f)).isEqualTo(ViewportEdge.Bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tap past the bar's reach is left to the column`() {
|
||||||
|
assertThat(tapped(1041f)).isNull()
|
||||||
|
assertThat(tapped(1350f)).isNull()
|
||||||
|
assertThat(tapped(1659f)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tap near an edge without a bar is left to the column`() {
|
||||||
|
assertThat(tapped(1010f, OutOfViewEvents(null, dinner))).isNull()
|
||||||
|
assertThat(tapped(1690f, OutOfViewEvents(breakfast, null))).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class PageStateCacheTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same key shares one state`() = runTest {
|
||||||
|
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { flow { emit(it) } }
|
||||||
|
assertThat(cache.get(5)).isSameInstanceAs(cache.get(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a dropped entry stops loading`() = runTest(UnconfinedTestDispatcher()) {
|
||||||
|
var running = 0
|
||||||
|
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { key ->
|
||||||
|
flow {
|
||||||
|
running++
|
||||||
|
try {
|
||||||
|
emit(key)
|
||||||
|
awaitCancellation()
|
||||||
|
} finally {
|
||||||
|
running--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backgroundScope.launch { cache.get(0).collect {} }
|
||||||
|
assertThat(running).isEqualTo(1)
|
||||||
|
|
||||||
|
// Far enough away that 0 is let go, even though it is still collected.
|
||||||
|
cache.get(10)
|
||||||
|
assertThat(running).isEqualTo(0)
|
||||||
|
assertThat(cache.size).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nearby entries are kept`() = runTest {
|
||||||
|
val cache = PageStateCache<Int, Int>(backgroundScope, 0, { a, b -> abs(a - b) <= 2 }) { flow { emit(it) } }
|
||||||
|
cache.get(4)
|
||||||
|
cache.get(5)
|
||||||
|
cache.get(6)
|
||||||
|
assertThat(cache.size).isEqualTo(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,14 @@ class ViewBackStackTest {
|
|||||||
assertThat(stack.drillToDay()).isEqualTo(stack)
|
assertThat(stack.drillToDay()).isEqualTo(stack)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `week number drills into week and back returns to month`() {
|
||||||
|
val stack = listOf(CalendarView.Month).drillToWeek()
|
||||||
|
assertThat(stack).containsExactly(CalendarView.Month, CalendarView.Week).inOrder()
|
||||||
|
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Month)
|
||||||
|
assertThat(stack.drillToWeek()).isEqualTo(stack)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `agenda widget date tap backs out agenda then home`() {
|
fun `agenda widget date tap backs out agenda then home`() {
|
||||||
// openDate(source = Agenda): root in Agenda over the Week home, then drill.
|
// openDate(source = Agenda): root in Agenda over the Week home, then drill.
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class DayPagingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a page and its day round-trip`() {
|
||||||
|
listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day ->
|
||||||
|
assertThat(dayForPage(dayPageFor(day))).isEqualTo(day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `adjacent pages are adjacent days across a month end`() {
|
||||||
|
val page = dayPageFor(LocalDate(2026, 2, 28))
|
||||||
|
assertThat(dayForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1))
|
||||||
|
assertThat(dayForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -222,17 +222,17 @@ class ContinuousMonthIndexTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the split selection follows the month, landing on today when it's there`() {
|
fun `the split agenda starts at today in the current month`() {
|
||||||
val today = LocalDate(2026, 6, 10)
|
val today = LocalDate(2026, 6, 10)
|
||||||
assertThat(selectionForMonth(jun26, today)).isEqualTo(today)
|
assertThat(agendaStartForMonth(jun26, today)).isEqualTo(today)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the split selection falls to the 1st of any other month`() {
|
fun `the split agenda starts at the 1st of another month`() {
|
||||||
val today = LocalDate(2026, 6, 10)
|
val today = LocalDate(2026, 6, 10)
|
||||||
assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today))
|
assertThat(agendaStartForMonth(YearMonth(2026, Month.JULY), today))
|
||||||
.isEqualTo(LocalDate(2026, 7, 1))
|
.isEqualTo(LocalDate(2026, 7, 1))
|
||||||
assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today))
|
assertThat(agendaStartForMonth(YearMonth(2025, Month.JUNE), today))
|
||||||
.isEqualTo(LocalDate(2025, 6, 1))
|
.isEqualTo(LocalDate(2025, 6, 1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,18 @@ class MonthLayoutTest {
|
|||||||
assertThat(week.spans.map { it.lane }).containsExactly(0, 1)
|
assertThat(week.spans.map { it.lane }).containsExactly(0, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multi-day bar is above a one-day all-day bar on the same date`() {
|
||||||
|
val day = LocalDate(2026, 6, 10)
|
||||||
|
val single = allDay(day, id = 1L, title = "Single")
|
||||||
|
val multi = allDay(day, day.plus(2, DateTimeUnit.DAY), id = 2L, title = "Multi")
|
||||||
|
val week = layoutCalendarWeek(weekOf8th, listOf(single, multi), zone)
|
||||||
|
val lanes = week.spans.associate { it.event.title to it.lane }
|
||||||
|
assertThat(lanes.getValue("Multi")).isLessThan(lanes.getValue("Single"))
|
||||||
|
assertThat(instancesByDay(listOf(day), listOf(single, multi), zone).getValue(day).map { it.title })
|
||||||
|
.containsExactly("Multi", "Single").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `single-day timed events stay pills and sort by start`() {
|
fun `single-day timed events stay pills and sort by start`() {
|
||||||
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L)
|
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L)
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ class WeekLayoutTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `weekRange spans seven days`() {
|
fun `spanRange spans seven days`() {
|
||||||
val range = weekRange(mon, zone)
|
val range = spanRange(mon, 7, zone)
|
||||||
assertThat(range.start).isEqualTo(at(mon, 0, 0))
|
assertThat(range.start).isEqualTo(at(mon, 0, 0))
|
||||||
// endInclusive is the last second of day 7 (Sunday 2026-06-14)
|
// endInclusive is the last second of day 7 (Sunday 2026-06-14)
|
||||||
assertThat(range.endInclusive).isEqualTo(LocalDate(2026, 6, 14).atTime(23, 59, 59).toInstant(zone))
|
assertThat(range.endInclusive).isEqualTo(LocalDate(2026, 6, 14).atTime(23, 59, 59).toInstant(zone))
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class WeekPagingTest {
|
||||||
|
|
||||||
|
// 2026-09-27 is a Sunday.
|
||||||
|
private val sunday = LocalDate(2026, 9, 27)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a page and its week round-trip for every first day`() {
|
||||||
|
DayOfWeek.entries.forEach { firstDay ->
|
||||||
|
val page = weekPageFor(sunday, firstDay)
|
||||||
|
val start = weekStartForPage(page, firstDay)
|
||||||
|
assertThat(start).isEqualTo(sunday.startOfWeek(firstDay))
|
||||||
|
assertThat(weekPageFor(start, firstDay)).isEqualTo(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every day of a week maps to the same page`() {
|
||||||
|
val start = sunday.startOfWeek(DayOfWeek.MONDAY)
|
||||||
|
val pages = (0..6).map { weekPageFor(start.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
|
||||||
|
assertThat(pages.toSet()).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `adjacent pages are adjacent weeks`() {
|
||||||
|
val page = weekPageFor(sunday, DayOfWeek.SUNDAY)
|
||||||
|
assertThat(weekStartForPage(page + 1, DayOfWeek.SUNDAY))
|
||||||
|
.isEqualTo(LocalDate(2026, 10, 4))
|
||||||
|
assertThat(weekStartForPage(page - 1, DayOfWeek.SUNDAY))
|
||||||
|
.isEqualTo(LocalDate(2026, 9, 20))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dates before the epoch page resolve too`() {
|
||||||
|
val old = LocalDate(1990, 5, 16)
|
||||||
|
val page = weekPageFor(old, DayOfWeek.MONDAY)
|
||||||
|
assertThat(weekStartForPage(page, DayOfWeek.MONDAY)).isEqualTo(LocalDate(1990, 5, 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multi-day pages begin on the selected date and advance by the chosen count`() {
|
||||||
|
(3..6).forEach { count ->
|
||||||
|
val grid = DaysGrid.startingAt(sunday, count)
|
||||||
|
val page = grid.pageFor(sunday)
|
||||||
|
assertThat(grid.startForPage(page)).isEqualTo(sunday)
|
||||||
|
assertThat(grid.startForPage(page + 1)).isEqualTo(sunday.plus(count, DateTimeUnit.DAY))
|
||||||
|
assertThat(grid.pageFor(sunday.plus(count - 1, DateTimeUnit.DAY))).isEqualTo(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multi-day pages also work before the epoch`() {
|
||||||
|
val date = LocalDate(1990, 5, 16)
|
||||||
|
val grid = DaysGrid.startingAt(date, 3)
|
||||||
|
val page = grid.pageFor(date)
|
||||||
|
assertThat(grid.startForPage(page)).isEqualTo(date)
|
||||||
|
assertThat(grid.startForPage(page - 1)).isEqualTo(LocalDate(1990, 5, 13))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user