Compare commits

..
Author SHA1 Message Date
Jean-Luc Makiola bb9fd994fc Merge back preview (#185) 2026-09-30 14:09:04 +02:00
makiolaj d387f3d7bf Merge 2.22.0 work into back preview (#185) 2026-09-30 14:08:04 +02:00
Jean-Luc Makiola 3bead4a21f Merge view morph (#184) 2026-09-30 13:59:10 +02:00
makiolaj 422904c9b9 Merge 2.22.0 work into view morph (#184) 2026-09-30 13:57:59 +02:00
makiolaj 51b9c56c3c Refine view switching and timeline morph (#184) 2026-09-30 13:28:47 +02:00
Jean-Luc Makiola 2af0f44fbe Merge consistent event ordering (#291) 2026-09-30 13:27:27 +02:00
makiolaj 7492888b1a Prioritize multi-day events across calendar views (#291) 2026-09-30 13:26:29 +02:00
Jean-Luc Makiola 17223cf0bb Merge week-number navigation (#197) 2026-09-30 13:24:26 +02:00
makiolaj 21c770afb3 Open week view from month week numbers (#197) 2026-09-30 13:23:37 +02:00
Jean-Luc Makiola aee23dc751 Merge today highlight in month views (#241) 2026-09-30 13:19:51 +02:00
makiolaj 705835a66f Highlight today in the month grid (#241) 2026-09-30 13:19:05 +02:00
Jean-Luc Makiola c2302c033e Merge split day list in Pages month view (#111) 2026-09-30 13:16:27 +02:00
makiolaj 596f76bbaa Open split day list from Pages month style (#111) 2026-09-30 13:15:32 +02:00
Jean-Luc Makiola 85f77f2332 Merge full month agenda in split view (#142) 2026-09-30 13:12:22 +02:00
makiolaj d471255340 Show month agenda by default in split view (#142) 2026-09-30 13:11:21 +02:00
Jean-Luc Makiola dcb1e891f1 Merge localized launcher name (#251) 2026-09-30 13:01:55 +02:00
Jean-Luc Makiola 7bf41fa0f8 Merge relative all-day reminder dates (#308) 2026-09-30 13:00:15 +02:00
Jean-Luc Makiola a23ea81c48 Merge out-of-view indicators (#188) 2026-09-30 12:57:42 +02:00
Jean-Luc Makiola 52aa48fda7 Merge visible time range (#322) 2026-09-30 12:53:29 +02:00
Jean-Luc Makiola 264b1ebbf4 Merge month paging and multi-day view (#193, #336) 2026-09-30 12:50:30 +02:00
makiolaj bd04f7692c Let translators localise the Calendar launcher name (#251) 2026-09-30 12:50:01 +02:00
makiolaj 26de9d369d Fix the zone named in an all-day reminder test comment (#308) 2026-09-30 12:49:49 +02:00
makiolaj 27e5b41038 Say today or tomorrow in all-day reminders too (#308) 2026-09-30 12:49:49 +02:00
makiolaj 2119a36969 Match edge indicators to event colors and clear before reveal (#188) 2026-09-30 12:48:06 +02:00
makiolaj 8d3081a086 Let taps reach events under the edge bar (#188) 2026-09-30 12:48:06 +02:00
makiolaj d1664b28de Tint a day column's edge when events sit out of view (#188) 2026-09-30 12:48:06 +02:00
makiolaj 252689038e Hand the timeline scale row over to visible hours while it's on (#322) 2026-09-30 12:47:50 +02:00
makiolaj 406642050e Hold the fitted hour height after opening (#322) 2026-09-30 12:47:50 +02:00
makiolaj ddf3e203a0 Fit a chosen hour range into the timeline on open (#322) 2026-09-30 12:47:50 +02:00
makiolaj e8ed4c30e1 Add a multi-day view with a configurable day count (#193) 2026-09-30 12:44:22 +02:00
makiolaj d7315caa20 Page the month view with a pager that follows the finger (#336)
Paged and Split now page like the week and day views; Split keeps its
vertical expand drag, now vertical-only beside the pager, and the pane
follows the grid's row count mid-swipe. The old trigger swipe and slide
transition are gone with their last callers.

Closes #336
2026-09-30 12:44:22 +02:00
makiolaj 62ea5ce9d3 Share one top bar across views and smooth the view switches (#184) 2026-09-27 21:12:22 +02:00
makiolaj 254fafe42b Document how the views switch (#184) 2026-09-27 19:31:59 +02:00
makiolaj f9dd7a22b7 Hold the top bar and FAB still across view switches (#184) 2026-09-27 19:28:10 +02:00
makiolaj 7d7d16ca31 Resize timeline columns in place when switching views (#184) 2026-09-27 19:26:24 +02:00
makiolaj 6ed0b30230 Lay timeline columns out through one geometry (#184) 2026-09-27 19:22:09 +02:00
makiolaj 8d3d28c240 Share one focused date across views (#184) 2026-09-27 19:18:13 +02:00
makiolaj f9738b0c23 Put the day view on the paged timeline (#184) 2026-09-27 19:13:47 +02:00
makiolaj 39ca7d869a Carry the timeline hours between views (#184) 2026-09-27 19:10:49 +02:00
makiolaj 0c36ed2a75 Spring the view back even though the gesture job is cancelled (#185) 2026-09-27 12:11:15 +02:00
makiolaj e84378cc5b Preview the previous view on a back gesture (#185) 2026-09-27 12:11:15 +02:00
makiolaj 57dcda5b54 Spring overlays back after a cancelled back gesture (#184) 2026-09-27 12:11:10 +02:00
makiolaj df33eca5f2 Reset the back preview when an overlay reopens (#184) 2026-09-27 12:06:45 +02:00
makiolaj dc841a91e0 Remember the view morph scope across recompositions (#184) 2026-09-27 12:01:41 +02:00
makiolaj f7805cb0b5 Morph events between views instead of fading (#184) 2026-09-27 11:21:32 +02:00
makiolaj 33c61202d9 Add a multi-day view with a configurable day count (#193) 2026-09-25 21:25:17 +02:00
Jean-Luc Makiolaandmakiolaj 9f05a53ed2 Page the day view with a pager that follows the finger (#336) (#337)
Moves the day view onto the same pager as the week view (#131): days follow the finger and snap past half a page or on a fling, the hour gutter stays fixed, neighbouring days are loaded ahead. Opening a day from the month grid lands on that date directly.

Also, from review:
- The per-page state cache (week and day) leaked: dropped entries kept their `stateIn` coroutine and last loaded events alive until the ViewModel was cleared. It is now a shared `PageStateCache` in `common/` that gives each entry its own job and cancels it on eviction.
- The week and day anchor `state` chains had lost their `.catch`, so a settings-flow error crashed instead of showing the failure screen.

Part of #336; the month view follows in its own PR.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/337
2026-09-25 20:49:25 +02:00
makiolaj a2b6793406 Page the month view with a pager that follows the finger (#336)
Paged and Split now page like the week and day views; Split keeps its
vertical expand drag, now vertical-only beside the pager, and the pane
follows the grid's row count mid-swipe. The old trigger swipe and slide
transition are gone with their last callers.

Closes #336
2026-09-25 14:40:04 +02:00
makiolaj eda616b42e Cancel dropped page states and restore the failure catch (#336)
Evicted week and day states kept their sharing coroutine, and their last
loaded events, alive until the view was closed; each now shares on its
own job, cancelled when it is let go.
2026-09-25 14:35:50 +02:00
makiolaj 7c17bf2ff4 Page the day view with a pager that follows the finger (#336)
Same model as the week view: days slide with the finger and snap past half
a page or on a fling, the hour gutter stays put, and the neighbouring days
are loaded ahead. The strip-height blend moves to common so both views
share it.
2026-09-25 13:41:28 +02:00
Jean-Luc Makiola 9ca10fc6ee Page the week view with a pager that follows the finger (#131)
Closes #131
2026-09-25 13:28:03 +02:00
81 changed files with 4952 additions and 2663 deletions
+13
View File
@@ -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() }
} }
/** 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) }
}
/**
* 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
}
}
/** /**
* 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 }
@@ -1,24 +1,40 @@
package de.jeanlucmakiola.calendula.ui package de.jeanlucmakiola.calendula.ui
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.SeekableTransitionState
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.screen
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
@@ -26,18 +42,38 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip
import de.jeanlucmakiola.calendula.ui.common.StayingPut
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFab
import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar
import de.jeanlucmakiola.calendula.ui.common.ChromeHost
import de.jeanlucmakiola.calendula.ui.common.ChromeSlot
import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove 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.LocalTimelineScroll
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.ViewFocus
import de.jeanlucmakiola.calendula.ui.common.ViewFocusSaver
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory
import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
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
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.edit.ImportSource import de.jeanlucmakiola.calendula.ui.edit.ImportSource
@@ -45,16 +81,21 @@ 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.WeekScreen import de.jeanlucmakiola.calendula.ui.week.TimelineScreen
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
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.coroutines.cancellation.CancellationException
import kotlin.time.Clock import kotlin.time.Clock
/** /**
* Holds the top-level view back stack (spec M1) and swaps between the calendar * Holds the top-level view back stack (spec M1) and swaps between the calendar
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher * screens. Each screen owns its own ViewModel; they all open on the one focused
* pill in their top bars writes back here via [onSelectView]. * date held here ([ViewFocus]), and the view-switcher pill in their top bars
* writes back here via [onSelectView].
* *
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view. * The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
* A lateral switch (pill / drawer) builds a visit history so back retraces it * A lateral switch (pill / drawer) builds a visit history so back retraces it
@@ -71,6 +112,7 @@ import kotlin.time.Clock
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the * as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
* widget's view.) * widget's view.)
*/ */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun CalendarHost( fun CalendarHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -102,14 +144,25 @@ fun CalendarHost(
var viewStack by rememberSaveable(stateSaver = viewStackSaver) { var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView)) mutableStateOf(listOf(defaultView))
} }
var navigationVersion by remember { mutableIntStateOf(0) }
val view = viewStack.last() val view = viewStack.last()
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) } // The date every view opens on and carries along (#184); tapping a day
// anywhere focuses it and opens the day view there.
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
// Tapping a day in the month grid opens the day view anchored to that date. val switchScope = rememberCoroutineScope()
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) } val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
navigationVersion++
viewStack = move(viewStack)
}
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
val onOpenDay: (LocalDate) -> Unit = { date -> val onOpenDay: (LocalDate) -> Unit = { date ->
pendingDayIso = date.toString() focus.navigateTo(date)
viewStack = viewStack.drillToDay() switchTo { it.drillToDay() }
}
val onOpenWeek: (LocalDate) -> Unit = { date ->
focus.navigateTo(date)
switchTo { it.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
@@ -259,13 +312,14 @@ fun CalendarHost(
// [requestedDetailKey]. Date/event opens root the stack in the widget's own // [requestedDetailKey]. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default. // view so backing out returns there (then home), not to the default.
LaunchedEffect(widgetNavRequest) { LaunchedEffect(widgetNavRequest) {
if (widgetNavRequest != null) navigationVersion++
when (val req = widgetNavRequest) { when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> { is WidgetNavRequest.OpenDate -> {
// Drill the day view in over the widget's view: drop any overlay // Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form. // that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays() dismissCoveringOverlays()
createDateIso = null createDateIso = null
pendingDayIso = req.dateIso focus.navigateTo(LocalDate.parse(req.dateIso))
// No widget source (an external date tap) roots over the default // No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits. // home view, so backing out of the day returns home then exits.
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay() viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
@@ -289,7 +343,7 @@ fun CalendarHost(
// default home — so backing out returns to the default, then exits. // default home — so backing out returns to the default, then exits.
dismissCoveringOverlays() dismissCoveringOverlays()
createDateIso = null createDateIso = null
pendingDayIso = null focus.navigateTo(localToday())
viewStack = viewBaseStack(defaultView, req.view ?: defaultView) viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
onWidgetNavConsumed() onWidgetNavConsumed()
} }
@@ -340,6 +394,9 @@ fun CalendarHost(
} }
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
// Overlays leave from the predictive-back preview (#184): shrinking on from
// where the gesture let go instead of snapping back and sliding off.
val overlayExit = predictiveBackExit()
// Base-level back: pop the view stack while no overlay covers it (each overlay // Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view, // owns its own BackHandler and takes precedence). Disabled at the home view,
@@ -347,76 +404,150 @@ fun CalendarHost(
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null || val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
editKey != null || showSettings || showCalendars || showBackup || editKey != null || showSettings || showCalendars || showBackup ||
importUri != null || importForm != null importUri != null || importForm != null
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) { // The view switch runs on a seekable transition, so a back gesture can scrub
viewStack = viewStack.dropLast(1) // it (#185): the previous view morphs in under the finger, releasing finishes
// the pop and cancelling springs back. Reduced motion keeps a plain back.
val viewTransition = remember { SeekableTransitionState(view) }
LaunchedEffect(view) { viewTransition.animateTo(view) }
val reduceMotion = rememberReduceMotion()
val viewBackEnabled = !anyOverlayVisible && viewStack.size > 1
if (reduceMotion) {
BackHandler(enabled = viewBackEnabled) {
navigationVersion++
viewStack = viewStack.dropLast(1)
}
} else {
PredictiveBackHandler(enabled = viewBackEnabled) { events ->
val previous = viewStack[viewStack.size - 2]
try {
events.collect { viewTransition.seekTo(it.progress, targetState = previous) }
navigationVersion++
viewStack = viewStack.dropLast(1)
} catch (e: CancellationException) {
withContext(NonCancellable) { viewTransition.animateTo(viewTransition.currentState) }
throw e
}
}
} }
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
// Switching between the peer views (month/week/day/agenda) is lateral // Switching between the month, timeline and agenda screens is lateral
// navigation, so it fades through rather than sliding — paging *within* a // navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type. // view keeps the directional slide. What both views show morphs across
// (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade.
val viewSwitch = fadeThrough() val viewSwitch = fadeThrough()
val morphBounds = rememberViewMorphBoundsTransform()
val morphFade = rememberCalendarFadeSpec()
val morphClip = remember { BelowTopBarClip() }
val stayingPut = remember { StayingPut() }
val chromeHost = remember { ChromeHost() }
val drawerState = rememberDrawerState(DrawerValue.Closed)
val density = LocalDensity.current
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
val timelineScroll = remember { TimelineScrollMemory() }
CompositionLocalProvider( CompositionLocalProvider(
LocalEventMove provides moveScope, LocalEventMove provides moveScope,
LocalTimelineScroll provides timelineScroll,
LocalViewFocus provides focus,
// The failure state's way out when every calendar is switched off // The failure state's way out when every calendar is switched off
// (#239); nothing else in the calendar surfaces navigates here. // (#239); nothing else in the calendar surfaces navigates here.
LocalManageCalendars provides { showCalendars = true }, LocalManageCalendars provides remember { { showCalendars = true } },
) { ) {
AnimatedContent( // One drawer, top bar and FAB for every screen (#184): a switch changes
targetState = view, // what they say, from what the screen on show publishes, rather than
// swapping them for another screen's.
ModalNavigationDrawer(
drawerState = drawerState,
// Open only via the menu button where edge-swipe would fight paging;
// the agenda has no paging to fight.
gesturesEnabled = drawerState.isOpen || view.screen == ViewScreen.Agenda,
drawerContent = {
CalendarDrawer(
currentView = view,
currentDate = chromeHost.chrome?.drawerDate ?: focus.date,
drawerState = drawerState,
viewOrder = drawerViewOrder,
onSelectView = { target ->
// Once the drawer is out of the way, so the switch is seen whole.
val requestVersion = ++navigationVersion
switchScope.launch {
drawerState.close()
if (navigationVersion == requestVersion) onSelectView(target)
}
},
onJumpToDate = { target ->
chromeHost.actions?.onJumpToDate?.invoke(target)
switchScope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
switchScope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
topBar = {
CalendarTopBar(
host = chromeHost,
selectedView = view,
quickSwitchViews = quickSwitchViews,
onCycleView = { onSelectView(viewStack.last().next(quickSwitchViews)) },
onOpenDrawer = { switchScope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
)
},
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
) { innerPadding ->
SharedTransitionLayout(
Modifier
.padding(innerPadding)
.fillMaxSize()
.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y },
) {
// Keyed on the screen, not the view: the day, multi-day and week views
// are one timeline that resizes between them itself (#184), so switching
// among them must not replay this transition.
rememberTransition(viewTransition, label = "view-switch").AnimatedContent(
transitionSpec = { viewSwitch }, transitionSpec = { viewSwitch },
label = "view-switch", contentKey = { it.screen },
) { currentView -> ) { currentView ->
when (currentView) { val screen = currentView.screen
CalendarView.Week -> WeekScreen( val sharedScope = this@SharedTransitionLayout
selectedView = currentView, val visibilityScope = this@AnimatedContent
onSelectView = onSelectView, val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) {
if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut)
}
CompositionLocalProvider(
LocalViewMorph provides viewMorph,
LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen),
) {
when (screen) {
ViewScreen.Timeline -> TimelineScreen(
view = currentView,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
CalendarView.Day -> DayScreen( ViewScreen.Month -> MonthScreen(
selectedView = currentView, onOpenDay = onOpenDay,
onSelectView = onSelectView, onOpenWeek = onOpenWeek,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
CalendarView.Month -> MonthScreen( ViewScreen.Agenda -> AgendaScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews, quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
CalendarView.Agenda -> AgendaScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
} }
}
}
}
}
} }
} }
@@ -429,11 +560,12 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = showSearch, visible = showSearch,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
SearchScreen( SearchScreen(
onBack = { showSearch = false }, onBack = { showSearch = false },
onEventClick = onEventClick, onEventClick = onEventClick,
visible = showSearch,
) )
} }
@@ -442,7 +574,7 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = detailKey != null, visible = detailKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
activeKey?.let { key -> activeKey?.let { key ->
EventDetailScreen( EventDetailScreen(
@@ -462,6 +594,7 @@ fun CalendarHost(
importForm = form importForm = form
detailKey = null detailKey = null
}, },
visible = detailKey != null,
) )
} }
} }
@@ -470,7 +603,7 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = createDateIso != null, visible = createDateIso != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
(createDateIso ?: heldCreateIso)?.let { iso -> (createDateIso ?: heldCreateIso)?.let { iso ->
EventEditScreen( EventEditScreen(
@@ -479,6 +612,7 @@ fun CalendarHost(
onClose = { createDateIso = null }, onClose = { createDateIso = null },
onSaved = { createDateIso = null }, onSaved = { createDateIso = null },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
visible = createDateIso != null,
) )
} }
} }
@@ -487,7 +621,7 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = editKey != null, visible = editKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
(editKey ?: heldEditKey)?.let { key -> (editKey ?: heldEditKey)?.let { key ->
EventEditScreen( EventEditScreen(
@@ -499,6 +633,7 @@ fun CalendarHost(
detailKey = null detailKey = null
}, },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
visible = editKey != null,
) )
} }
} }
@@ -507,12 +642,13 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = showSettings, visible = showSettings,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
SettingsScreen( SettingsScreen(
onBack = { showSettings = false }, onBack = { showSettings = false },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
onOpenBackup = { showBackup = true }, onOpenBackup = { showBackup = true },
visible = showSettings,
) )
} }
@@ -564,11 +700,12 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = showCalendars, visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
CalendarsScreen( CalendarsScreen(
onBack = { showCalendars = false }, onBack = { showCalendars = false },
onOpenBackup = { showBackup = true }, onOpenBackup = { showBackup = true },
visible = showCalendars,
) )
} }
@@ -576,7 +713,7 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = showBackup, visible = showBackup,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
// Settings can open this screen without the manager underneath, so // Settings can open this screen without the manager underneath, so
// its failure states' "Manage calendars" way out has to open the // its failure states' "Manage calendars" way out has to open the
@@ -603,6 +740,7 @@ fun CalendarHost(
showBackup = false showBackup = false
showCalendars = false showCalendars = false
}, },
visible = showBackup,
) )
} }
} }
@@ -614,3 +752,6 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
save = { stack -> stack.map(CalendarView::ordinal) }, save = { stack -> stack.map(CalendarView::ordinal) },
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } }, restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
) )
private fun localToday(): LocalDate =
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
@@ -1,5 +1,18 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
import kotlinx.coroutines.flow.drop
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -18,34 +31,22 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
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.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -58,14 +59,9 @@ import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
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.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
@@ -81,26 +77,22 @@ import kotlin.time.Instant
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AgendaScreen( fun AgendaScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS, quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(), viewModel: AgendaViewModel = hiltViewModel(),
) { ) {
// Opens on the focused date and hands back the day scrolled to the top.
val focus = LocalViewFocus.current
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle() val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle() val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) } var showRangePicker by remember { mutableStateOf(false) }
@@ -124,112 +116,88 @@ fun AgendaScreen(
// requested: scrolling before the new rows compose leaves the list keyed to // requested: scrolling before the new rows compose leaves the list keyed to
// a row that reappears further down, and it follows it there. // a row that reappears further down, and it follows it there.
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) } LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
val jumpToToday = { val jumpToToday: () -> Unit = {
// Off today, moving the anchor is what resets the list (above); on today // Off today, moving the anchor is what resets the list (above); on today
// the window doesn't change, so the scroll back is the whole action. // the window doesn't change, so the scroll back is the whole action.
if (isOnToday) scope.launch { listState.animateScrollToItem(0) } if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
viewModel.goToToday() viewModel.goToToday()
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
} }
ModalNavigationDrawer( PublishChrome(
drawerState = drawerState, ScreenChrome(
drawerContent = { title = stringResource(R.string.view_agenda),
CalendarDrawer( titleDate = null,
currentView = selectedView, drawerDate = anchor,
currentDate = anchor, awayFromToday = !isOnToday || (scrolledAway && successState != null),
drawerState = drawerState, todayText = stringResource(R.string.agenda_today_action),
viewOrder = drawerViewOrder, ),
onSelectView = { view -> ScreenActions(
onSelectView(view) onToday = jumpToToday,
scope.launch { drawerState.close() } onJumpToDate = { target ->
}, focus?.date = target
onJumpToDate = { target -> viewModel.goToDate(target)
viewModel.goToDate(target)
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
modifier = modifier,
topBar = {
AgendaTopBar(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
)
}, },
floatingActionButton = { onCreate = { onCreateEvent(anchor, null) },
CalendarFabColumn( ),
todayVisible = (!isOnToday || (scrolledAway && successState != null)) && )
!todayInToolbar,
todayText = stringResource(R.string.agenda_today_action), ScreenBody(modifier = modifier) { innerPadding ->
onToday = jumpToToday, Column(
onCreate = { onCreateEvent(anchor, null) }, modifier = Modifier
) .fillMaxSize()
}, .padding(innerPadding),
) { innerPadding -> ) {
Column( // One bar at the top: the "showing …" header on the left and the
modifier = Modifier // session range switcher on the right (one settings toggle).
.fillMaxSize() successState?.takeIf { it.showRangeBar }?.let { s ->
.padding(innerPadding), // end lines the selector up with whatever ends the top bar:
) { // the view switcher's background, or — once #150 hides it —
// One bar at the top: the "showing …" header on the left and the // the search icon's glyph.
// session range switcher on the right (one settings toggle). val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
successState?.takeIf { it.showRangeBar }?.let { s -> AppBarSpacing.Inset
// end lines the selector up with whatever ends the top bar: } else {
// the view switcher's background, or — once #150 hides it — AppBarSpacing.IconTrailingInset
// the search icon's glyph.
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
AppBarSpacing.Inset
} else {
AppBarSpacing.IconTrailingInset
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(
start = RANGE_BAR_TEXT_INSET,
end = selectorEnd,
top = 8.dp,
bottom = 8.dp,
),
) {
AgendaRangeBanner(
range = s.range,
start = s.anchor,
end = s.rangeEnd,
modifier = Modifier.weight(1f),
)
AgendaRangePill(
range = s.range,
isOverride = s.rangeIsOverride,
onClick = { showRangePicker = true },
)
}
} }
AgendaContent( Row(
state = state, verticalAlignment = Alignment.CenterVertically,
pastDisplay = pastDisplay,
showToday = showToday,
listState = listState,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
modifier = Modifier modifier = Modifier
.weight(1f) .fillMaxWidth()
.fillMaxWidth(), .padding(
) start = RANGE_BAR_TEXT_INSET,
end = selectorEnd,
top = 8.dp,
bottom = 8.dp,
),
) {
AgendaRangeBanner(
range = s.range,
start = s.anchor,
end = s.rangeEnd,
modifier = Modifier.weight(1f),
)
AgendaRangePill(
range = s.range,
isOverride = s.rangeIsOverride,
onClick = { showRangePicker = true },
)
}
} }
AgendaContent(
state = state,
pastDisplay = pastDisplay,
showToday = showToday,
listState = listState,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onTopDay = if (successState?.anchor == anchor) {
{ focus?.date = it }
} else null,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
)
} }
} }
@@ -326,6 +294,7 @@ internal fun AgendaContent(
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(), listState: LazyListState = rememberLazyListState(),
onTopDay: ((LocalDate) -> Unit)? = null,
) { ) {
when (state) { when (state) {
AgendaUiState.Loading -> Box(modifier) AgendaUiState.Loading -> Box(modifier)
@@ -368,6 +337,7 @@ internal fun AgendaContent(
now = now, now = now,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onTopDay = onTopDay,
modifier = modifier, modifier = modifier,
) )
} }
@@ -386,8 +356,20 @@ private fun AgendaList(
now: Instant, now: Instant,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onTopDay: ((LocalDate) -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (onTopDay != null) {
val itemDates by rememberUpdatedState(agendaItemDates(days))
val currentOnTopDay by rememberUpdatedState(onTopDay)
// The day the list opened on is where the view already is; only a
// scroll moves it on.
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.drop(1)
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
}
}
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
state = listState, state = listState,
@@ -420,7 +402,7 @@ private fun AgendaList(
zone = zone, zone = zone,
position = positionOf(index, day.events.size), position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now), dimmed = dimPast && event.hasEnded(now),
modifier = animateItemMotion(), modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) }, onClick = { onEventClick(event) },
) )
} }
@@ -430,6 +412,13 @@ private fun AgendaList(
} }
} }
/** The day of each item [AgendaList] lays out for [days], in its order. */
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
days.flatMap { day ->
// Header, the placeholder or the events, then the gap.
List(2 + day.events.size.coerceAtLeast(1)) { day.date }
}
@Composable @Composable
private fun AgendaEmpty(modifier: Modifier = Modifier) { private fun AgendaEmpty(modifier: Modifier = Modifier) {
Column( Column(
@@ -451,58 +440,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
) )
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
) {
TopAppBar(
title = {
// A plain label rather than a CalendarTitleButton, so it takes that
// one's inset and one-line clamp itself.
Text(
text = stringResource(R.string.view_agenda),
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
)
},
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
@@ -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 },
),
) )
} }
} }
@@ -82,6 +82,7 @@ private val RESTORE_MIME_TYPES = arrayOf(
fun BackupScreen( fun BackupScreen(
onBack: () -> Unit, onBack: () -> Unit,
onImport: (Uri) -> Unit, onImport: (Uri) -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(), viewModel: CalendarsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.backupState.collectAsStateWithLifecycle() val state by viewModel.backupState.collectAsStateWithLifecycle()
@@ -137,6 +138,7 @@ fun BackupScreen(
scrollable = state is BackupUiState.Ready, scrollable = state is BackupUiState.Ready,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
when (val s = state) { when (val s = state) {
BackupUiState.Loading -> BackupLoading() BackupUiState.Loading -> BackupLoading()
@@ -133,6 +133,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
fun CalendarsScreen( fun CalendarsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onOpenBackup: () -> Unit, onOpenBackup: () -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(), viewModel: CalendarsViewModel = hiltViewModel(),
) { ) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle() val calendars by viewModel.calendars.collectAsStateWithLifecycle()
@@ -177,6 +178,7 @@ fun CalendarsScreen(
onConsumeError = viewModel::consumeError, onConsumeError = viewModel::consumeError,
onOpenBackup = onOpenBackup, onOpenBackup = onOpenBackup,
onBack = onBack, onBack = onBack,
visible = visible,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id }, onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetVisible = viewModel::setCalendarVisible, onSetVisible = viewModel::setCalendarVisible,
@@ -193,6 +195,7 @@ private fun CalendarsList(
onConsumeError: () -> Unit, onConsumeError: () -> Unit,
onOpenBackup: () -> Unit, onOpenBackup: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean,
onAdd: () -> Unit, onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit, onEdit: (CalendarSource) -> Unit,
onSetVisible: (Long, Boolean) -> Unit, onSetVisible: (Long, Boolean) -> Unit,
@@ -218,6 +221,7 @@ private fun CalendarsList(
onBack = onBack, onBack = onBack,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
// What the per-calendar / per-account switches below actually do. // What the per-calendar / per-account switches below actually do.
HintText(stringResource(R.string.calendars_visibility_hint)) HintText(stringResource(R.string.calendars_visibility_hint))
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap import androidx.compose.animation.core.snap
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -13,15 +14,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlin.math.roundToInt
/** Gap a timed block leaves to its neighbours in the column. */ /** Gap a timed block leaves to its neighbours in the column. */
val BLOCK_OUTER_INSET = 1.dp val BLOCK_OUTER_INSET = 1.dp
@@ -224,9 +229,13 @@ fun BlockTimeLabel(
} }
} }
/** Where a timed block sits in its column, after tweening. */ /**
* Where a timed block sits in its column, after tweening: across as shares of
* the column's width, so a column resizing carries its blocks with it (#184),
* down in the timeline's own units.
*/
@Immutable @Immutable
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp) data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y: Dp, val height: Dp)
/** /**
* A timed block's placement, tweened rather than jumped. Continuity comes from * A timed block's placement, tweened rather than jumped. Continuity comes from
@@ -239,17 +248,49 @@ data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement { fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() || val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
LocalTimelineZoom.current.isPinching val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
) { val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
snap() val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
} else { val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
MaterialTheme.motionScheme.fastSpatialSpec() val animatedY by animateDpAsState(y, dpSpec, label = "block-y")
val animatedHeight by animateDpAsState(height, dpSpec, label = "block-height")
return BlockPlacement(animatedX, animatedWidth, animatedY, animatedHeight)
}
/** Seats a block across its [placement]'s share of a [LaneColumn]. */
fun Modifier.laneSlot(placement: BlockPlacement): Modifier =
then(LaneSlot(placement.xFraction, placement.widthFraction))
private data class LaneSlot(val x: Float, val width: Float) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any = this@LaneSlot
}
/**
* A day column's blocks, each measured across the share of the column its
* [laneSlot] gives it; anything else fills the column. The shares are applied
* at layout time, so the column can change width every frame without its
* blocks recomposing.
*/
@Composable
fun LaneColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val width = constraints.maxWidth
val loose = constraints.copy(minWidth = 0, minHeight = 0)
val placed = measurables.map { measurable ->
val slot = measurable.parentData as? LaneSlot
if (slot == null) {
measurable.measure(loose) to 0
} else {
val left = (slot.x * width).roundToInt()
val right = ((slot.x + slot.width) * width).roundToInt()
val laneWidth = (right - left).coerceAtLeast(0)
measurable.measure(loose.copy(minWidth = laneWidth, maxWidth = laneWidth)) to left
}
}
layout(width, constraints.maxHeight) {
placed.forEach { (placeable, x) -> placeable.placeRelative(x, 0) }
}
} }
val animatedX by animateDpAsState(x, spec, label = "block-x")
val animatedY by animateDpAsState(y, spec, label = "block-y")
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
} }
@@ -0,0 +1,161 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate
/**
* What a calendar screen shows in the top bar, the FAB and the drawer, which the
* host draws once for all of them (#184): switching screens changes what they
* say rather than swapping one bar for another.
*
* @param titleDate the date the title's picker opens on, or null for a title
* that is only a label.
* @param drawerDate the date the drawer's picker opens on.
* @param awayFromToday whether "today" is worth offering.
*/
@Immutable
data class ScreenChrome(
val title: String,
val shortTitle: String = title,
val titleDate: LocalDate?,
val drawerDate: LocalDate,
val awayFromToday: Boolean,
val todayText: String,
)
/** What the chrome does for the screen on show; read when used, so not observed. */
class ScreenActions(
val onToday: () -> Unit,
val onJumpToDate: (LocalDate) -> Unit,
val onCreate: () -> Unit,
)
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
@Stable
class ChromeHost {
var chrome by mutableStateOf<ScreenChrome?>(null)
private set
var actions: ScreenActions? = null
private set
internal fun publish(chrome: ScreenChrome, actions: ScreenActions) {
this.chrome = chrome
this.actions = actions
}
}
/** The host's chrome, and whether the screen reading it is the one on show rather than one leaving. */
class ChromeSlot(val host: ChromeHost, val current: Boolean)
val LocalChromeSlot = staticCompositionLocalOf<ChromeSlot?> { null }
/** Hands [chrome] and [actions] to the host, while this screen is the one on show. */
@Composable
fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
val slot = LocalChromeSlot.current ?: return
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
}
/** The top bar every calendar screen shares. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarTopBar(
host: ChromeHost,
selectedView: CalendarView,
quickSwitchViews: List<CalendarView>,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
) {
val chrome = host.chrome
TopAppBar(
title = {
if (chrome != null) {
CalendarTitleButton(
title = chrome.title,
currentDate = chrome.titleDate,
onJumpToDate = { host.actions?.onJumpToDate?.invoke(it) },
shortTitle = chrome.shortTitle,
)
}
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
TodayAction(show = showTodayButton, onToday = { host.actions?.onToday?.invoke() })
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
)
},
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
/** The FAB column every calendar screen shares. */
@Composable
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
val chrome = host.chrome ?: return
CalendarFabColumn(
todayVisible = chrome.awayFromToday && !todayInToolbar,
todayText = chrome.todayText,
onToday = { host.actions?.onToday?.invoke() },
onCreate = { host.actions?.onCreate?.invoke() },
)
}
/**
* A calendar screen's body under the shared chrome, on the ground the screen's
* own Scaffold used to give it; the host's Scaffold has already made room for
* the bars, so [content] gets no padding of its own.
*/
@Composable
fun ScreenBody(modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit) {
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
content(PaddingValues())
}
}
@@ -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,5 +1,13 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -10,12 +18,12 @@ import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
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
@@ -33,7 +41,8 @@ import kotlinx.datetime.LocalDate
* drawer uses and reporting the chosen day through [onJumpToDate]. * drawer uses and reporting the chosen day through [onJumpToDate].
* *
* [currentDate] seeds the picker with whatever the bar is currently naming (the * [currentDate] seeds the picker with whatever the bar is currently naming (the
* visible day, week start or month anchor). * visible day, week start or month anchor); null makes the title a plain label,
* as the agenda's is, and the caret folds away.
* *
* [shortTitle] replaces [title] when the full one does not fit the width the app * [shortTitle] replaces [title] when the full one does not fit the width the app
* bar hands the title slot (#165). Either way the line clamps to one and * bar hands the title slot (#165). Either way the line clamps to one and
@@ -42,7 +51,7 @@ import kotlinx.datetime.LocalDate
@Composable @Composable
fun CalendarTitleButton( fun CalendarTitleButton(
title: String, title: String,
currentDate: LocalDate, currentDate: LocalDate?,
onJumpToDate: (LocalDate) -> Unit, onJumpToDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
shortTitle: String = title, shortTitle: String = title,
@@ -54,6 +63,7 @@ fun CalendarTitleButton(
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.clip(MaterialTheme.shapes.large) .clip(MaterialTheme.shapes.large)
.clickable( .clickable(
enabled = currentDate != null,
onClickLabel = stringResource(R.string.drawer_jump_to_date), onClickLabel = stringResource(R.string.drawer_jump_to_date),
role = Role.Button, role = Role.Button,
) { showDatePicker = true } ) { showDatePicker = true }
@@ -71,21 +81,38 @@ fun CalendarTitleButton(
availableWidth = constraints.maxWidth, availableWidth = constraints.maxWidth,
) )
} }
Text( // Cross-fades, so switching views or paging turns the title over
text = shown, // rather than snapping it (#184).
style = style, val fadeSpec = rememberCalendarFadeSpec()
maxLines = 1, AnimatedContent(
overflow = TextOverflow.Ellipsis, targetState = shown,
transitionSpec = {
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
},
contentAlignment = Alignment.CenterStart,
) { text ->
Text(
text = text,
style = style,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
AnimatedVisibility(
visible = currentDate != null,
enter = fadeIn(rememberCalendarFadeSpec()) + expandHorizontally(),
exit = fadeOut(rememberCalendarFadeSpec()) + shrinkHorizontally(),
) {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
if (showDatePicker) { if (showDatePicker && currentDate != null) {
CalendarDatePickerDialog( CalendarDatePickerDialog(
initial = currentDate, initial = currentDate,
onConfirm = { onConfirm = {
@@ -1,13 +1,7 @@
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.core.spring
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 +13,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 +34,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]
@@ -55,50 +55,17 @@ fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.defaultEffectsSpec() MaterialTheme.motionScheme.defaultEffectsSpec()
/** /**
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the * A switch between views (#184): the default spatial spring, a little softer, as
* outgoing page slides and fades one way while the incoming one arrives from the * the whole screen reshapes rather than a page sliding by; softer still when
* other, position on a spring and opacity on an easing curve. * [long], for a change the eye has further to follow.
*
* @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( @Composable
slideDir: Int, fun <T> rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec<T> =
spec: FiniteAnimationSpec<IntOffset>, spring(
fadeSpec: FiniteAnimationSpec<Float>, dampingRatio = VIEW_SWITCH_DAMPING,
reduceMotion: Boolean, stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS,
): 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),
) )
}
/** private const val VIEW_SWITCH_DAMPING = 0.9f
* How far a page travels, as a fraction of the container width. private const val VIEW_SWITCH_STIFFNESS = 450f
* private const val LONG_VIEW_SWITCH_STIFFNESS = 340f
* 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,16 @@ 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
/** The screens the views are drawn by: the day, multi-day and week views share one timeline (#184). */
enum class ViewScreen { Month, Timeline, Agenda }
val CalendarView.screen: ViewScreen
get() = when (this) {
CalendarView.Month -> ViewScreen.Month
CalendarView.Agenda -> ViewScreen.Agenda
CalendarView.Week, CalendarView.MultiDay, CalendarView.Day -> ViewScreen.Timeline
}
/** 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
@@ -59,7 +59,11 @@ internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Fl
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel * coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
* height; [color] is resolved by the caller from the theme. * height; [color] is resolved by the caller from the theme.
*/ */
fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Modifier = fun Modifier.hourGridCells(
show: Boolean,
hourHeightPx: Float,
color: Color,
): Modifier =
if (!show) { if (!show) {
this this
} else { } else {
@@ -71,10 +75,11 @@ fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Mo
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()), hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
) )
val cellSize = Size(size.width, cellHeight) val cellSize = Size(size.width, cellHeight)
val top = inset
for (hour in 0 until 24) { for (hour in 0 until 24) {
drawRoundRect( drawRoundRect(
color = color, color = color,
topLeft = Offset(0f, hour * hourHeightPx + inset), topLeft = Offset(0f, hour * hourHeightPx + top),
size = cellSize, size = cellSize,
cornerRadius = radius, cornerRadius = radius,
) )
@@ -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
} }
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlin.math.roundToInt
/**
* Where 00:00 sat in the last timeline shown, measured from the top of its
* view's content, so the next timeline view opens on the same hours (#184): an
* event in both keeps its place and only grows sideways.
*/
class TimelineScrollMemory {
var midnightY: Float? = null
}
val LocalTimelineScroll = staticCompositionLocalOf<TimelineScrollMemory?> { null }
/** A timeline's scroll, and where it opens before it can scroll there. */
@Stable
class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) {
/** Whether it is still to be put on noon, which it only can be once laid out. */
var noonPending by mutableStateOf(noonPending)
internal set
/** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */
fun top(viewport: Float, content: Float): Float =
if (noonPending) {
(openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f)
.coerceIn(0f, (content - viewport).coerceAtLeast(0f))
} else state.value.toFloat()
}
val LocalOpeningTimeline = staticCompositionLocalOf<TimelineScroll?> { null }
/**
* The scroll state of a timeline whose scrolling area starts [timelineTop] below
* its view's content top. Opens where the last timeline left off, or on noon the
* first time.
*/
@Composable
fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
val memory = LocalTimelineScroll.current
val density = LocalDensity.current
val zoom = LocalTimelineZoom.current
val topPx = with(density) { timelineTop.toPx() }
val remembered = memory?.midnightY
val scrollState = rememberScrollState(
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
)
val scroll = remember(scrollState) {
TimelineScroll(
scrollState,
noonPending = remembered == null,
openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
)
}
val currentTop by rememberUpdatedState(topPx)
var firstOpen by remember(scrollState) { mutableStateOf(true) }
LaunchedEffect(scrollState, zoom, zoom.hours) {
if (firstOpen && remembered != null) {
firstOpen = false
return@LaunchedEffect
}
firstOpen = false
val startHour = zoom.open()
if (startHour == null) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
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)
}
scroll.noonPending = false
}
if (memory != null) {
LaunchedEffect(scrollState, memory) {
snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it }
}
}
return scroll
}
@@ -49,13 +49,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 +102,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 +119,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) }
@@ -7,9 +7,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -59,7 +59,8 @@ fun TodayAction(show: Boolean, onToday: () -> Unit) {
val description = stringResource(R.string.today_jump_action) val description = stringResource(R.string.today_jump_action)
IconButton( IconButton(
onClick = onToday, onClick = onToday,
modifier = Modifier.semantics { contentDescription = description }, modifier = Modifier
.semantics { contentDescription = description },
) { ) {
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() } val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
Box( Box(
@@ -0,0 +1,49 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.datetime.LocalDate
/**
* The date every calendar view is looking at (#184), so switching views keeps
* the same days on screen and what is shown in both can travel between them.
*
* A view reads it once, as it comes on screen, and writes it back only when the
* user moves: a view settling where it opened must not nudge it, or switching
* back and forth would drift.
*/
@Stable
class ViewFocus(initial: LocalDate) {
var date by mutableStateOf(initial)
/** Explicit navigation, including a request for the view already on screen. */
var navigationVersion by mutableStateOf(0)
private set
fun navigateTo(target: LocalDate) {
date = target
navigationVersion++
}
}
val ViewFocusSaver: Saver<ViewFocus, String> = Saver(
save = { it.date.toString() },
restore = { ViewFocus(LocalDate.parse(it)) },
)
val LocalViewFocus = staticCompositionLocalOf<ViewFocus?> { null }
/**
* Runs [enter] with the focused date once, as the view comes on screen, before
* it reads its own position: its first frame is then already on that date.
*/
@Composable
fun <T : Any> EnterOnFocus(key: T, enter: (LocalDate) -> Unit) {
val focus = LocalViewFocus.current
remember(key) { focus?.date?.also(enter) }
}
@@ -0,0 +1,166 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
/**
* What the views share when switching between them (#184): an event visible in
* both grows from where it sat into where it lands, a date label travels to its
* new seat, and everything else fades through.
*
* An element is tagged with the day it is drawn *from*, not just its instance: a
* multi-day event is drawn once per day, per week row or per timeline column,
* and only one element per key may take part. The segment that starts on the
* same day in both views is the one that travels; the rest fade where they are.
*/
sealed interface ViewMorphKey {
data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey
data class Day(val date: LocalDate) : ViewMorphKey
companion object {
/** [event]'s occurrence as drawn from [day]. */
fun event(event: EventInstance, day: LocalDate): Event =
Event(event.eventId, event.start.toEpochMilliseconds(), day)
}
}
/** The view switch's scopes and specs, captured where they exist. Null under reduced motion. */
@OptIn(ExperimentalSharedTransitionApi::class)
class ViewMorphScope(
val shared: SharedTransitionScope,
val visibility: AnimatedVisibilityScope,
val boundsTransform: BoundsTransform,
val fadeSpec: FiniteAnimationSpec<Float>,
val clip: BelowTopBarClip,
val stayingPut: StayingPut,
)
/**
* The events a view has but keeps out of the switch, being scrolled out of
* sight: the other view leaves them untagged too, or its half would travel to
* nowhere and snap into place at the end.
*/
class StayingPut {
val keys = mutableStateSetOf<ViewMorphKey.Event>()
}
/** Marks [key] as staying put while [stays], for as long as it is composed. */
@Composable
fun StayPut(key: ViewMorphKey.Event, stays: Boolean) {
val stayingPut = LocalViewMorph.current?.stayingPut ?: return
if (!stays) return
DisposableEffect(stayingPut, key) {
stayingPut.keys += key
onDispose { stayingPut.keys -= key }
}
}
/**
* Keeps what travels below the top bar, which every view has at the same height:
* the overlay draws above the whole screen, so an event scrolled under the bar
* would otherwise cross it. [top] is the bar's bottom edge in the shared layout.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
class BelowTopBarClip : SharedTransitionScope.OverlayClip {
var top = 0f
override fun getClipPath(
sharedContentState: SharedTransitionScope.SharedContentState,
bounds: Rect,
layoutDirection: LayoutDirection,
density: Density,
): Path = Path().apply { addRect(Rect(-FAR, top, FAR, FAR)) }
private companion object {
const val FAR = 1e6f
}
}
val LocalViewMorph = compositionLocalOf<ViewMorphScope?> { null }
/**
* Whether a pager page may carry the morph tags: only the settled one. Its
* neighbours are composed ahead and repeat some of its dates, which would both
* claim its keys and fly in from off screen.
*/
fun morphPageActive(page: Int, settledPage: Int): Boolean = page == settledPage
/** Provides the view morph to [content] only while [active] — see [morphPageActive]. */
@Composable
fun ViewMorphPage(active: Boolean, content: @Composable () -> Unit) {
val morph = LocalViewMorph.current
CompositionLocalProvider(LocalViewMorph provides morph.takeIf { active }, content = content)
}
/**
* Tags an event chip, bar, block or row. Its bounds are shared and the two faces
* cross-fade inside them; remeasured rather than scaled, so a title keeps its
* real size and is clipped while there is no room, instead of smearing.
*
* Goes after the element's offset and before its size, so the travelling bounds
* drive the measurement.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
val morph = LocalViewMorph.current ?: return this
if (key in morph.stayingPut.keys) return this
return with(morph.shared) {
this@morphEvent.sharedBounds(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform,
clipInOverlayDuringTransition = morph.clip,
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
zIndexInOverlay = 1f,
)
}
}
/** Tags a day's number, which is small on both sides, so it scales on its way. */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.morphDay(date: LocalDate): Modifier {
val morph = LocalViewMorph.current ?: return this
return with(morph.shared) {
this@morphDay.sharedBounds(
sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date)),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform,
clipInOverlayDuringTransition = morph.clip,
zIndexInOverlay = 1f,
)
}
}
/** The view switch's travel, on the same spring as the timeline's columns resizing. */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun rememberViewMorphBoundsTransform(): BoundsTransform {
val spec = rememberViewSwitchSpec<Rect>()
return remember(spec) { BoundsTransform { _, _ -> spec } }
}
@@ -1,12 +1,17 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
/** /**
@@ -30,11 +35,23 @@ fun ViewSwitcherPill(
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset, trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
) { ) {
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
val fadeSpec = rememberCalendarFadeSpec()
FilledTonalButton( FilledTonalButton(
onClick = onCycle, onClick = onCycle,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
modifier = modifier.padding(end = trailingInset), modifier = modifier
.padding(end = trailingInset),
) { ) {
Text(stringResource(current.labelRes)) // The label cross-fades and the pill eases to its width, so cycling
// views turns it over rather than snapping it (#184).
AnimatedContent(
targetState = current.label(),
transitionSpec = {
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
},
contentAlignment = Alignment.Center,
) { label ->
Text(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,903 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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.layout.width
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
import de.jeanlucmakiola.calendula.ui.common.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
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.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.util.Locale
import kotlin.math.roundToInt
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
private val ALL_DAY_ROW_HEIGHT = 20.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Total all-day strip height for the day (0 when there are no all-day events). */
internal fun DayUiState.Success.allDayStripHeight(): Dp {
if (allDay.isEmpty()) return 0.dp
val lanes = allDay.maxOf { it.lane } + 1
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DayScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> 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,
initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val date by viewModel.date.collectAsStateWithLifecycle()
// When opened from the month grid, anchor to the tapped date.
LaunchedEffect(initialDateIso) {
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
}
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val isOnToday = when (val s = state) {
is DayUiState.Success -> s.date == s.today
else -> true
}
// Drives whether the title carries the year. Falls back to the clock only
// while the first load is in flight, when there is no state to read today from.
val currentYear = when (val s = state) {
is DayUiState.Success -> s.today.year
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
}
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
var slideDir by remember { mutableIntStateOf(0) }
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 ->
slideDir = if (target < date) -1 else 1
viewModel.goToDate(target)
}
ModalNavigationDrawer(
drawerState = drawerState,
// Open only via the menu button — edge-swipe would fight the day swipe.
gesturesEnabled = drawerState.isOpen,
drawerContent = {
CalendarDrawer(
currentView = selectedView,
currentDate = date,
drawerState = drawerState,
viewOrder = drawerViewOrder,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
},
onJumpToDate = { target ->
jumpToDate(target)
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
modifier = modifier,
topBar = {
DayTopBar(
date = date,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnToday && !todayInToolbar,
todayText = stringResource(R.string.day_today_action),
onToday = jumpToToday,
onCreate = { onCreateEvent(date, null) },
)
},
) { innerPadding ->
DayContent(
state = state,
slideDir = slideDir,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
}
@Composable
private fun DayContent(
state: DayUiState,
slideDir: Int,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-day AnimatedContent so the vertical scroll position
// survives day-to-day swipes. We only centre on noon once, on first entry
// into the day view (i.e. when arriving from the month/week view).
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range *is* noon: the content spans a full 24 hours, so
// centring the range centres midday at whatever hour height is in force.
scrollState.scrollTo(scrollState.maxValue / 2)
}
// Single, hoisted all-day strip height — shared by the outgoing and incoming
// day during a swipe, so the strip slides along but never jumps in height.
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 move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() }
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(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
},
)
}
}
TimelineDragOverlay(dragController)
}
}
@Composable
internal fun DaySuccess(
state: DayUiState.Success,
scrollState: ScrollState,
allDayHeight: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events,
// so the timeline sits directly under the app bar.
// This strip is painted on `surface`, so an outlined chip has to fill
// with that and not with the column's container (#230).
CompositionLocalProvider(
LocalChipGround provides MaterialTheme.colorScheme.surface,
) {
AllDayStrip(
state = state,
height = allDayHeight,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
)
}
// Breathing room between the top section and the scrolling timeline
// below.
Spacer(Modifier.height(8.dp))
Timeline(
state = state,
scrollState = scrollState,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DayTopBar(
date: LocalDate,
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
) {
val locale = currentLocale()
val (title, shortTitle) = remember(date, locale, currentYear) {
formatDayTitle(date, locale, currentYear) to
formatDayTitle(date, locale, currentYear, abbreviated = true)
}
TopAppBar(
title = {
CalendarTitleButton(
title = title,
currentDate = date,
onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
)
},
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
@Composable
private fun AllDayStrip(
state: DayUiState.Success,
height: Dp,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
Row(
modifier = modifier
// Height is hoisted + animated so it resizes smoothly; padding sits
// inside it so the content area is lanes * row height.
.height(height)
.padding(
top = ALL_DAY_VERTICAL_PADDING,
bottom = ALL_DAY_VERTICAL_PADDING,
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
// the full day-column width. clipToBounds keeps bars from spilling out
// while the height animates.
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clipToBounds(),
) {
val barWidth = maxWidth
state.allDay.forEach { span ->
AllDayBar(
event = span.event,
dark = dark,
onClick = { onEventClick(span.event) },
modifier = Modifier
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
.width(barWidth)
.height(ALL_DAY_ROW_HEIGHT)
.padding(horizontal = 1.dp, vertical = 1.dp),
)
}
}
}
}
@Composable
private fun AllDayBar(
event: EventInstance,
dark: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val paint = eventPaint(event, dark)
Box(
modifier = modifier
.eventSurface(paint, EventChipShape)
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall.trimmedLines(),
maxLines = 1,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = paint.titleInk,
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
)
}
}
@Composable
private fun Timeline(
state: DayUiState.Success,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
val dark = isSystemInDarkTheme()
val zoom = LocalTimelineZoom.current
val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
// What the hour grid takes off a block's ends, for the floating copy to
// take off its own. Per-end, a cut edge keeps it — that much the drag
// geometry decides itself, from the slice it is drawing.
val blockInsetPx = with(density) {
hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx()
}
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
// bar, date header and all-day strip.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val hourHeight = zoom.scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The pinch sits on the Row, above both scroll viewports: it has to
// outrank the vertical scroll, and it does that by watching the initial
// pass, which only reaches it if it is their ancestor.
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
// Gutter and day column are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll 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(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it },
) {
DayColumnCard(
blocks = state.timed,
dark = dark,
date = state.date,
today = state.today,
hourHeight = hourHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.fillMaxWidth()
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset.
.onGloballyPositioned { coords ->
dragController.geometry.let {
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = 0f
it.columnWidthPx = coords.size.width.toFloat()
it.days = listOf(state.date)
it.isRtl = isRtl
}
},
)
}
}
}
}
@Composable
private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
date: LocalDate,
today: LocalDate,
hourHeight: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourGrid = LocalShowHourGrid.current
val zone = remember { TimeZone.currentSystemDefault() }
// Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks)
}
// The tap detector outlives the composition that installed it — a pinch or a
// Settings change moves the hour height without restarting it — so it reads
// the height and the callback through state handles instead of capturing
// them, or taps land on the scale the column had before the zoom (#148).
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
Card(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape,
colors = CardDefaults.cardColors(
// With the grid on, the container colour moves onto the hour cells
// and the column behind them recedes to surface, so an hour boundary
// reads as negative space between two surfaces.
containerColor = if (showHourGrid) {
MaterialTheme.colorScheme.surface
} else {
MaterialTheme.colorScheme.surfaceContainer
},
),
modifier = modifier,
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// The hour cells sit over the column background but under the
// event blocks (drawBehind paints before the children).
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer)
// Tap an empty slot to create an event there. Taps on event
// blocks are consumed by their own click handler first, so this
// only fires on the column background. Snaps to the tapped hour.
.pointerInput(date) {
detectTapGestures { offset ->
currentOnCreateAt.value(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
)
}
},
) {
val colWidth = maxWidth
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates
// a column holding two occurrences of one series.
val ordinals = mutableMapOf<Long, Int>()
blocks.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
key(block.event.eventId, ordinal) {
val laneWidth = colWidth / block.laneCount
val top = hourHeight * (block.startMin / 60f)
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
val place = animatedBlockPlacement(
x = laneWidth * block.lane,
y = top,
width = laneWidth,
height = height,
)
// Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells.
val cuts = remember(block, date, zone) {
timedBlockCuts(
block.continuesBefore(date, zone),
block.continuesAfter(date, zone),
)
}
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
val blockHeight = (place.height - topInset - bottomInset)
.coerceAtLeast(0.dp)
EventBlock(
block = block,
dark = dark,
height = blockHeight,
width = place.width,
date = date,
cuts = cuts,
topInset = topInset,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y + topInset)
.width(place.width)
.height(blockHeight)
.padding(horizontal = BLOCK_OUTER_INSET),
)
}
}
// Current-time line, on top of the events, only on today's column.
if (date == today) {
NowLine(date = date, hourHeight = hourHeight)
}
}
}
}
@Composable
private fun EventBlock(
block: TimedBlock,
dark: Boolean,
height: Dp,
width: Dp,
date: LocalDate,
cuts: ChipCuts,
topInset: Dp,
dragController: TimelineDragController,
onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
minToHm(block.endMin, use24Hour, locale)
val density = LocalDensity.current
val metrics = rememberBlockTextMetrics(height)
// A block that cannot afford both lines spends its space on the title, and
// one too short even for that drops the title rather than serving a sliced one.
// Height alone decides: a duration threshold would keep hiding the time on a
// half-hour block the user has pinched open to three times the room it needs.
val showTime = metrics.available >= metrics.titleLine + metrics.timeLine
val showTitle = metrics.fitsTitle
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// Only lines the block can actually draw: a block too short for the time is
// too short for a second title line too, and asking for one served a sliced
// one — as well as handing the drag copy a count it couldn't honour, so the
// title re-wrapped the moment the block was lifted (#267).
val titleBudget = metrics.titleBudget(metrics.available).coerceAtLeast(1)
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
// On a day column — wide enough for "09:30–11:00" several times over — the
// range never needs the second line, until lanes cut the column down.
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
if (showTime) metrics.timeLine else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val paint = eventPaint(block.event, dark)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event)
// The drop takes this offset back off, so a tail clipped at midnight lands
// where the event's own start belongs (#253).
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
val topInsetPx = with(density) { topInset.toPx() }
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ ->
dragController.begin(
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
// Measured off the block's placement rather than off where the
// hour grid seats it: the copy is drawn from that placement and
// re-seated the same half-gap down (#267).
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
)
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
)
val lifted = draggable && dragController.ghosts(block)
val ghost = ghostAlpha(lifted)
Box(
modifier = modifier
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.eventSurface(paint, shape, cuts)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
},
) {
Column {
if (showTitle) {
BlockTitle(
title = title,
maxLines = titleMaxLines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
)
}
if (showTime) {
BlockTimeLabel(
label = timeLabel,
color = paint.secondaryInk,
maxLines = timeMaxLines,
)
}
}
}
}
@Composable
private fun DayLoading() {
val scale = LocalTimelineZoom.current.scale
val scrollState = rememberScrollState()
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's column
// doesn't resize the moment the real day arrives.
val hourHeight = scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The skeleton wears the loaded column's own ground, so the arrival of
// the real day doesn't flash a solid block into a gapped grid.
val showHourGrid = LocalShowHourGrid.current
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
Row(
modifier = Modifier
.fillMaxSize()
.padding(end = TIMELINE_CONTENT_END_INSET)
.verticalScroll(scrollState),
) {
Spacer(Modifier.width(GUTTER_WIDTH))
Box(
modifier = Modifier
.weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(
if (showHourGrid) MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceContainer,
)
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer),
)
}
}
}
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
formatMinuteOfDay(min, is24Hour, locale)
/** [abbreviated] drops the weekday, leaving the date itself (#165). */
private fun formatDayTitle(
date: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
locale = locale,
currentYear = currentYear,
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
)
@@ -1,25 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.AllDaySpan
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.datetime.LocalDate
/**
* The day view is a single-column slice of the week view (spec S3). It reuses the
* week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day
* spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol]
* == 0) and only their [AllDaySpan.lane] (vertical stacking) matters.
*/
sealed interface DayUiState {
data object Loading : DayUiState
data class Failure(val reason: FailureReason) : DayUiState
data class Success(
val date: LocalDate,
val today: LocalDate,
/** All-day/multi-day events covering this day, stacked by lane. */
val allDay: List<AllDaySpan>,
/** Timed events clipped to this day with overlap lanes resolved. */
val timed: List<TimedBlock>,
) : DayUiState
}
@@ -1,110 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class DayViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
private val _date = MutableStateFlow(todayDate)
val date: StateFlow<LocalDate> = _date
val state: StateFlow<DayUiState> = _date
.flatMapLatest { day ->
val range = dayRange(day, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(day, calendars, instances)
}
}
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DayUiState.Loading,
)
fun goToPrev() {
_date.value = _date.value.minus(1, DateTimeUnit.DAY)
}
fun goToNext() {
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
}
fun goToToday() {
_date.value = todayDate
}
/** Jump to a specific date (e.g. when opened from the month grid). */
fun goToDate(date: LocalDate) {
_date.value = date
}
private fun buildState(
day: LocalDate,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): DayUiState {
calendarListFailure(calendars)?.let { return DayUiState.Failure(it) }
val days = listOf(day)
val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
return DayUiState.Success(
date = day,
today = todayDate,
allDay = layoutAllDay(allDay, days, zone),
timed = layoutDay(timed, day, zone),
)
}
}
/** Half-open instant range covering the single calendar [date]. */
internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange<Instant> {
val from = date.atStartOfDayIn(zone)
val to = date.atTime(23, 59, 59).toInstant(zone)
return from..to
}
@@ -1,63 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* A live, scaled-down Day view for the onboarding view chooser. Today's date is
* real; only the events are stand-ins.
*/
@Composable
internal fun DayViewPreview(
height: Dp,
modifier: Modifier = Modifier,
) {
val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
val state = remember(today, zone) { sampleDayState(today, zone) }
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range is noon — the same centring the live view does.
scrollState.scrollTo(scrollState.maxValue / 2)
}
ScaledViewPreview(height = height, modifier = modifier) {
DaySuccess(
state = state,
scrollState = scrollState,
allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(),
onEventClick = {},
onCreateAt = { _, _ -> },
onDrop = {},
)
}
}
/** Sample day state, laid out through the same helpers the live view uses. */
private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success {
val days = listOf(today)
val events = sampleTimelineEvents(days, today, zone)
return DayUiState.Success(
date = today,
today = today,
allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone),
timed = layoutDay(events, today, zone),
)
}
@@ -140,6 +140,7 @@ fun EventDetailScreen(
onBack: () -> Unit, onBack: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onDuplicate: (EventForm) -> Unit, onDuplicate: (EventForm) -> Unit,
visible: Boolean = true,
viewModel: EventDetailViewModel = hiltViewModel(), viewModel: EventDetailViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(eventId, beginMillis, endMillis) { LaunchedEffect(eventId, beginMillis, endMillis) {
@@ -227,7 +228,7 @@ fun EventDetailScreen(
} }
Scaffold( Scaffold(
modifier = Modifier.predictiveBack(onBack = onBack), modifier = Modifier.predictiveBack(onBack = onBack, visible = visible),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -203,6 +203,7 @@ fun EventEditScreen(
initialForm: EventForm? = null, initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File, initialFormSource: ImportSource = ImportSource.File,
onManageCalendars: (() -> Unit)? = null, onManageCalendars: (() -> Unit)? = null,
visible: Boolean = true,
viewModel: EventEditViewModel = hiltViewModel(), viewModel: EventEditViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(initialDateIso, editKey, initialForm) { LaunchedEffect(initialDateIso, editKey, initialForm) {
@@ -285,7 +286,7 @@ fun EventEditScreen(
} }
Scaffold( Scaffold(
modifier = Modifier.predictiveBack(onBack = close), modifier = Modifier.predictiveBack(onBack = close, visible = visible),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -78,6 +78,7 @@ fun ImportScreen(
onOpenSingle: (EventForm) -> Unit, onOpenSingle: (EventForm) -> Unit,
forceMany: Boolean = false, forceMany: Boolean = false,
onManageCalendars: (() -> Unit)? = null, onManageCalendars: (() -> Unit)? = null,
visible: Boolean = true,
viewModel: ImportViewModel = hiltViewModel(), viewModel: ImportViewModel = hiltViewModel(),
) { ) {
// hiltViewModel() resolves to the Activity's store and is retained across // hiltViewModel() resolves to the Activity's store and is retained across
@@ -103,7 +104,7 @@ fun ImportScreen(
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
.predictiveBack(onBack = onClose) .predictiveBack(onBack = onClose, visible = visible)
.fillMaxSize(), .fillMaxSize(),
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -23,13 +23,14 @@ import de.jeanlucmakiola.floret.components.positionOf
* what the *recipient of the binary* is told. Each row opens the project's source. * what the *recipient of the binary* is told. Each row opens the project's source.
*/ */
@Composable @Composable
internal fun LicencesScreen(onBack: () -> Unit) { internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean = true) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.licences_title), title = stringResource(R.string.licences_title),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
Text( Text(
stringResource(R.string.licences_intro), stringResource(R.string.licences_intro),
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,11 +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.month.MonthStylePreview import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.month.descriptionRes import de.jeanlucmakiola.calendula.ui.month.descriptionRes
@@ -67,14 +67,19 @@ 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.Day -> DayViewPreview(height = PREVIEW_HEIGHT) CalendarView.MultiDay -> WeekViewPreview(
weekStart = weekStart,
height = PREVIEW_HEIGHT,
dayCount = LocalMultiDayCount.current,
)
CalendarView.Day -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT, dayCount = 1)
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT) CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
} }
} }
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 = {
@@ -126,6 +126,7 @@ fun SearchScreen(
onBack: () -> Unit, onBack: () -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
visible: Boolean = true,
viewModel: SearchViewModel = hiltViewModel(), viewModel: SearchViewModel = hiltViewModel(),
) { ) {
val query by viewModel.query.collectAsStateWithLifecycle() val query by viewModel.query.collectAsStateWithLifecycle()
@@ -184,7 +185,7 @@ fun SearchScreen(
BackHandler(enabled = inSelection) { viewModel.clearSelection() } BackHandler(enabled = inSelection) { viewModel.clearSelection() }
Scaffold( Scaffold(
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection), modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection, visible = visible),
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
topBar = { topBar = {
SearchTopBar( SearchTopBar(
@@ -76,6 +76,7 @@ internal fun AppearanceScreen(
state: SettingsUiState, state: SettingsUiState,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean = true,
) { ) {
var showTheme by remember { mutableStateOf(false) } var showTheme by remember { mutableStateOf(false) }
var showBrandFont by remember { mutableStateOf(false) } var showBrandFont by remember { mutableStateOf(false) }
@@ -96,6 +97,7 @@ internal fun AppearanceScreen(
title = stringResource(R.string.settings_section_appearance), title = stringResource(R.string.settings_section_appearance),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
// Theme & colour // Theme & colour
GroupedRow( GroupedRow(
@@ -41,6 +41,7 @@ internal fun EventFormScreen(
state: SettingsUiState, state: SettingsUiState,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean = true,
) { ) {
var showDefaultDuration by remember { mutableStateOf(false) } var showDefaultDuration by remember { mutableStateOf(false) }
// The calendar whose duration-override picker is open, if any. // The calendar whose duration-override picker is open, if any.
@@ -53,6 +54,7 @@ internal fun EventFormScreen(
title = stringResource(R.string.settings_section_event_form), title = stringResource(R.string.settings_section_event_form),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
SettingsHint(stringResource(R.string.settings_form_fields_hint)) SettingsHint(stringResource(R.string.settings_form_fields_hint))
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -63,6 +63,7 @@ internal fun NotificationsScreen(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
onOpenSpecialDates: () -> Unit, onOpenSpecialDates: () -> Unit,
visible: Boolean = true,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
@@ -92,6 +93,7 @@ internal fun NotificationsScreen(
title = stringResource(R.string.settings_section_notifications), title = stringResource(R.string.settings_section_notifications),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_reminders), title = stringResource(R.string.settings_reminders),
@@ -2,9 +2,7 @@ package de.jeanlucmakiola.calendula.ui.settings
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -66,6 +64,7 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.openIssueTracker import de.jeanlucmakiola.floret.crash.openIssueTracker
import de.jeanlucmakiola.floret.crash.submitCrashReport import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.locale.AppLanguage import de.jeanlucmakiola.floret.locale.AppLanguage
/** The settings sub-screens reached from the hub's category rows. */ /** The settings sub-screens reached from the hub's category rows. */
@@ -85,11 +84,14 @@ fun SettingsScreen(
onManageCalendars: () -> Unit, onManageCalendars: () -> Unit,
onOpenBackup: () -> Unit, onOpenBackup: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
visible: Boolean = true,
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) } var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
// Leaves from the predictive-back preview rather than sliding off at full size.
val overlayExit = predictiveBackExit()
Box( Box(
modifier = modifier modifier = modifier
@@ -101,61 +103,63 @@ fun SettingsScreen(
onOpenSection = { section = it }, onOpenSection = { section = it },
onManageCalendars = onManageCalendars, onManageCalendars = onManageCalendars,
onOpenBackup = onOpenBackup, onOpenBackup = onOpenBackup,
visible = visible,
) )
AnimatedVisibility( AnimatedVisibility(
visible = section == SettingsSection.Appearance, visible = section == SettingsSection.Appearance,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }) AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Appearance)
} }
AnimatedVisibility( AnimatedVisibility(
visible = section == SettingsSection.Views, visible = section == SettingsSection.Views,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null }) ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Views)
} }
AnimatedVisibility( AnimatedVisibility(
visible = section == SettingsSection.EventForm, visible = section == SettingsSection.EventForm,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null }) EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.EventForm)
} }
AnimatedVisibility( AnimatedVisibility(
visible = section == SettingsSection.Notifications, visible = section == SettingsSection.Notifications,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
NotificationsScreen( NotificationsScreen(
state = state, state = state,
viewModel = viewModel, viewModel = viewModel,
onBack = { section = null }, onBack = { section = null },
onOpenSpecialDates = { section = SettingsSection.SpecialDates }, onOpenSpecialDates = { section = SettingsSection.SpecialDates },
visible = section == SettingsSection.Notifications,
) )
} }
AnimatedVisibility( AnimatedVisibility(
visible = section == SettingsSection.SpecialDates, visible = section == SettingsSection.SpecialDates,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null }) SpecialDatesScreen(viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.SpecialDates)
} }
AnimatedVisibility( AnimatedVisibility(
visible = section == SettingsSection.Widgets, visible = section == SettingsSection.Widgets,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }) WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets)
} }
AnimatedVisibility( AnimatedVisibility(
visible = section == SettingsSection.Licences, visible = section == SettingsSection.Licences,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = overlayExit,
) { ) {
LicencesScreen(onBack = { section = null }) LicencesScreen(onBack = { section = null }, visible = section == SettingsSection.Licences)
} }
} }
} }
@@ -170,8 +174,14 @@ private fun SettingsHub(
onOpenSection: (SettingsSection) -> Unit, onOpenSection: (SettingsSection) -> Unit,
onManageCalendars: () -> Unit, onManageCalendars: () -> Unit,
onOpenBackup: () -> Unit, onOpenBackup: () -> Unit,
visible: Boolean,
) { ) {
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) { CollapsingScaffold(
title = stringResource(R.string.settings_title),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
// Card and support row are one grouped block, so the call to action // Card and support row are one grouped block, so the call to action
// continues the container instead of sitting inside it as a button. // continues the container instead of sitting inside it as a button.
Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() } Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() }
@@ -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) }
} }
@@ -46,6 +46,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
internal fun SpecialDatesScreen( internal fun SpecialDatesScreen(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean = true,
) { ) {
val state by viewModel.specialDatesState.collectAsStateWithLifecycle() val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
val settings by viewModel.state.collectAsStateWithLifecycle() val settings by viewModel.state.collectAsStateWithLifecycle()
@@ -76,6 +77,7 @@ internal fun SpecialDatesScreen(
title = stringResource(R.string.settings_section_special_dates), title = stringResource(R.string.settings_section_special_dates),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
// Paused banner: the permission was revoked after enabling. // Paused banner: the permission was revoked after enabling.
if (state.enabled && state.stalledPermission) { if (state.enabled && state.stalledPermission) {
@@ -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
@@ -62,6 +68,7 @@ internal fun ViewsScreen(
state: SettingsUiState, state: SettingsUiState,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean = true,
) { ) {
var showMonthStyle by remember { mutableStateOf(false) } var showMonthStyle by remember { mutableStateOf(false) }
var showDefaultView by remember { mutableStateOf(false) } var showDefaultView by remember { mutableStateOf(false) }
@@ -70,11 +77,16 @@ 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),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
val config = state.quickSwitchConfig val config = state.quickSwitchConfig
@@ -82,7 +94,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 +171,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 +332,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 +394,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 +425,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 +477,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 +542,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())
@@ -42,6 +42,7 @@ internal fun WidgetsScreen(
state: SettingsUiState, state: SettingsUiState,
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean = true,
) { ) {
var showAgendaWidgetRange by remember { mutableStateOf(false) } var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showWidgetSize by remember { mutableStateOf(false) } var showWidgetSize by remember { mutableStateOf(false) }
@@ -50,6 +51,7 @@ internal fun WidgetsScreen(
title = stringResource(R.string.settings_section_widgets), title = stringResource(R.string.settings_section_widgets),
onBack = onBack, onBack = onBack,
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
SettingsHint(stringResource(R.string.settings_widgets_hint)) SettingsHint(stringResource(R.string.settings_widgets_hint))
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -0,0 +1,169 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
import kotlin.math.roundToInt
/** A day column's place across the timeline, in pixels from its start edge. */
@Immutable
data class ColumnSlot(val left: Float, val width: Float) {
val right: Float get() = left + width
}
/**
* [date]'s column on a page showing this span across [width] pixels, with [gap]
* between columns. A date off the page carries on at the same pitch, off screen
* on its side — where a view switch slides it in from, or out to.
*/
fun DaySpan.slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
val pitch = (width + gap) / count
return ColumnSlot(left = start.daysUntil(date) * pitch, width = pitch - gap)
}
/** The last day of the span. */
val DaySpan.end: LocalDate get() = start.plus(count - 1, DateTimeUnit.DAY)
/**
* The timeline's columns: one page's, or — while switching between the day, the
* multi-day and the week view (#184) — every day of both pages, each column
* between where it sits in the one and where it sits in the other.
*
* @param progress how far from [from] to [to], read at layout time only.
*/
@Stable
class ColumnGeometry(val from: DaySpan, val to: DaySpan, val progress: () -> Float) {
/** Every day either page shows, in order: the two overlap, so they run on unbroken. */
val dates: List<LocalDate> = run {
val first = minOf(from.start, to.start)
val last = maxOf(from.end, to.end)
(0..first.daysUntil(last)).map { first.plus(it, DateTimeUnit.DAY) }
}
fun slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
val a = from.slot(date, width, gap)
if (from == to) return a
val b = to.slot(date, width, gap)
val p = progress()
return ColumnSlot(lerp(a.left, b.left, p), lerp(a.width, b.width, p))
}
/** The span of columns [range] covers, between its extent on each page. */
fun extent(fromRange: ClosedRange<LocalDate>, toRange: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
val a = from.extent(fromRange, width, gap)
if (from == to && fromRange == toRange) return a
val b = to.extent(toRange, width, gap)
val p = progress()
val left = lerp(a.left, b.left, p)
return ColumnSlot(left, lerp(a.right, b.right, p) - left)
}
companion object {
/** One page's columns, standing still. */
fun of(span: DaySpan): ColumnGeometry = ColumnGeometry(span, span) { 0f }
}
}
private fun DaySpan.extent(range: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
val left = slot(range.start, width, gap).left
return ColumnSlot(left, slot(range.endInclusive, width, gap).right - left)
}
internal fun lerp(a: Float, b: Float, p: Float): Float = a + (b - a) * p
/**
* Lays [content] out once per day of [geometry], each across its column. The
* columns are placed in the layout pass, so a view switch moving them every
* frame re-lays them out without recomposing them. Children fill the height
* the layout is given, or wrap it when it has none.
*/
@Composable
fun TimelineColumns(
geometry: ColumnGeometry,
gap: Dp,
modifier: Modifier = Modifier,
content: @Composable (LocalDate) -> Unit,
) {
val dates = geometry.dates
Layout(
content = {
dates.forEach { date ->
key(date) { Box(propagateMinConstraints = true) { content(date) } }
}
},
modifier = modifier,
) { measurables, constraints ->
val width = constraints.maxWidth
val gapPx = gap.toPx()
val slots = dates.map { geometry.slot(it, width.toFloat(), gapPx) }
val placeables = measurables.mapIndexed { i, measurable ->
val left = slots[i].left.roundToInt()
val columnWidth = (slots[i].right.roundToInt() - left).coerceAtLeast(0)
measurable.measure(constraints.copy(minWidth = columnWidth, maxWidth = columnWidth))
}
val height = placeables.maxOfOrNull { it.height }?.coerceAtLeast(constraints.minHeight)
?: constraints.minHeight
layout(width, height) {
placeables.forEachIndexed { i, placeable ->
placeable.placeRelative(slots[i].left.roundToInt(), 0)
}
}
}
}
/**
* An all-day bar as each page lays it out: the days it covers there, clipped to
* the page, and its lane. A bar only one page has keeps its days on the other,
* so it slides out with them rather than vanishing.
*/
@Immutable
data class PlacedBar(
val event: EventInstance,
/** The day the bar is drawn from on the page it is tagged on, for the view morph. */
val keyDate: LocalDate,
val fromRange: ClosedRange<LocalDate>,
val toRange: ClosedRange<LocalDate>,
val fromLane: Int,
val toLane: Int,
)
/** [state]'s all-day bars, standing still. */
internal fun staticBars(state: WeekUiState.Success): List<PlacedBar> =
state.allDaySpans.map { span ->
val range = state.days[span.startCol]..state.days[span.endCol]
PlacedBar(span.event, range.start, range, range, span.lane, span.lane)
}
/**
* The all-day bars of both pages of a view switch, each once: matched by
* occurrence, placed on each page as that page lays it out.
*/
internal fun morphBars(from: WeekUiState.Success?, to: WeekUiState.Success?): List<PlacedBar> {
val fromBars = from?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
val toBars = to?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
return (fromBars.keys + toBars.keys).map { occurrence ->
val a = fromBars[occurrence]
val b = toBars[occurrence]
PlacedBar(
event = (a ?: b)!!.event,
keyDate = (a ?: b)!!.keyDate,
fromRange = (a ?: b)!!.fromRange,
toRange = (b ?: a)!!.toRange,
fromLane = (a ?: b)!!.fromLane,
toLane = (b ?: a)!!.toLane,
)
}
}
private fun EventInstance.occurrence(): Pair<Long, Long> = eventId to start.toEpochMilliseconds()
@@ -0,0 +1,32 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.animation.core.Animatable
import androidx.compose.runtime.Stable
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.LocalDate
/**
* A switch between two timeline views in progress (#184): the page on screen
* when it started, the page it ends on, and how far between the two it is.
*/
@Stable
internal class TimelineMorph(
val fromKind: TimelineKind,
val toKind: TimelineKind,
val from: DaySpan,
val to: DaySpan,
val fromState: StateFlow<WeekUiState>,
val toState: StateFlow<WeekUiState>,
) {
val progress = Animatable(0f)
/** Whether the columns change size a lot, from a single day to a week or back, and take longer for it. */
val isLong: Boolean get() = maxOf(from.count, to.count) > LONG_SWITCH_RATIO * minOf(from.count, to.count)
/** Where each day's events come from: the page it was on, else the page it goes to. */
fun sourceOf(date: LocalDate): Side = if (date in from.start..from.end) Side.From else Side.To
enum class Side { From, To }
}
private const val LONG_SWITCH_RATIO = 3
@@ -0,0 +1,107 @@
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))
}
}
/** Pages the day pager spans — a little under four centuries, centred on [DAY_EPOCH]. */
internal const val DAY_PAGE_COUNT: Int = 140_000
private const val DAY_EPOCH_PAGE = DAY_PAGE_COUNT / 2
/** The day at [DAY_EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val DAY_EPOCH = LocalDate(2000, 1, 1)
/** Single days: every date starts a page of its own. */
data object DayGrid : PageGrid {
override val dayCount: Int get() = 1
override val pageCount: Int get() = DAY_PAGE_COUNT
override fun startForPage(page: Int): LocalDate =
DAY_EPOCH.plus(page - DAY_EPOCH_PAGE, DateTimeUnit.DAY)
override fun pageFor(date: LocalDate): Int =
(DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
}
/**
* Where the focused date goes when the pager settles on [page] (#184): kept if
* the page still shows it, otherwise moved by the pages travelled, so it keeps
* its place in the page — the same weekday a week on.
*/
internal fun PageGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate {
val start = startForPage(page)
val end = start.plus(dayCount - 1, DateTimeUnit.DAY)
if (focus in start..end) return focus
return focus.plus((page - pageFor(focus)) * dayCount, DateTimeUnit.DAY).coerceIn(start, end)
}
File diff suppressed because it is too large Load Diff
@@ -9,18 +9,22 @@ 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.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -30,6 +34,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 +43,42 @@ 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)
/** Where [anchor] sits under [rule]. */
fun positionOf(anchor: LocalDate, rule: (LocalDate) -> PageGrid): PagerPosition =
rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) }
/**
* 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 +91,91 @@ 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 anchor: StateFlow<LocalDate> = _anchor
val weekStartDate: StateFlow<LocalDate> = /**
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) } * The grid for a given anchor, held eagerly so a screen can place its pager
* on its very first frame rather than a frame after the anchor moved.
*/
val rule: StateFlow<(LocalDate) -> PageGrid> =
gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule)
private val position: StateFlow<PagerPosition> =
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
.distinctUntilChanged()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L), started = SharingStarted.WhileSubscribed(5_000L),
initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY), initialValue = positionOf(todayDate, initialRule),
) )
/**
* 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 = rule.value(_anchor.value)
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 +184,58 @@ 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) },
)
/** The day view: the timeline one day at a time. */
@HiltViewModel
class DayViewModel @Inject constructor(
repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
@IoDispatcher io: CoroutineDispatcher,
) : PagedTimelineViewModel(
repository = repository,
settingsPrefs = settingsPrefs,
io = io,
gridRule = flowOf { _: LocalDate -> DayGrid },
initialRule = { DayGrid },
)
/** /**
* 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 +255,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 +295,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,8 @@ internal fun WeekViewPreview(
ScaledViewPreview(height = height, modifier = modifier) { ScaledViewPreview(height = height, modifier = modifier) {
WeekSuccess( WeekSuccess(
state = state, state = state,
showWeekNumber = dayCount == 7,
headerBias = if (dayCount == 1) TimelineKind.Day.headerBias else 0f,
scrollState = scrollState, scrollState = scrollState,
allDayHeight = state.allDayStripHeight(), allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(), dragController = rememberTimelineDragController(),
@@ -61,10 +66,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,
+27 -5
View File
@@ -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()
} }
@@ -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())
} }
} }
@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
import kotlin.time.Instant
import org.junit.jupiter.api.Test
/** The day each agenda list item belongs to, for the focus it hands back (#184). */
class AgendaItemDatesTest {
private val monday = LocalDate(2026, 9, 28)
private val tuesday = LocalDate(2026, 9, 29)
private fun event(id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "Event",
start = Instant.fromEpochMilliseconds(0),
end = Instant.fromEpochMilliseconds(0),
isAllDay = false,
color = 0xFF000000.toInt(),
location = null,
)
@Test
fun `each day counts its header, events and gap`() {
val days = listOf(AgendaDay(monday, listOf(event(1), event(2))), AgendaDay(tuesday, listOf(event(3))))
assertThat(agendaItemDates(days)).containsExactly(
monday, monday, monday, monday,
tuesday, tuesday, tuesday,
).inOrder()
}
@Test
fun `an empty day counts its placeholder`() {
assertThat(agendaItemDates(listOf(AgendaDay(monday, emptyList())))).hasSize(3)
}
}
@@ -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,50 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ViewMorphKeyTest {
private fun instance(instanceId: Long, eventId: Long = 7, startMillis: Long = 1_000L) = EventInstance(
instanceId = instanceId,
eventId = eventId,
calendarId = 1,
title = "Standup",
start = Instant.fromEpochMilliseconds(startMillis),
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
isAllDay = false,
color = 0,
location = null,
)
private val day = LocalDate(2026, 9, 25)
@Test
fun `the same occurrence on the same day matches across views`() {
// Each view reads its own instances query, so the row id is no anchor.
assertThat(ViewMorphKey.event(instance(instanceId = 1), day))
.isEqualTo(ViewMorphKey.event(instance(instanceId = 99), day))
}
@Test
fun `a multi-day event keys each day it is drawn from apart`() {
assertThat(ViewMorphKey.event(instance(1), day))
.isNotEqualTo(ViewMorphKey.event(instance(1), LocalDate(2026, 9, 26)))
}
@Test
fun `two occurrences of one series stay apart`() {
assertThat(ViewMorphKey.event(instance(1, startMillis = 1_000L), day))
.isNotEqualTo(ViewMorphKey.event(instance(2, startMillis = 2_000L), day))
}
@Test
fun `only the settled page morphs`() {
assertThat(morphPageActive(page = 5, settledPage = 5)).isTrue()
assertThat(morphPageActive(page = 4, settledPage = 5)).isFalse()
assertThat(morphPageActive(page = 6, settledPage = 5)).isFalse()
}
}
@@ -0,0 +1,20 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/** Which screen draws each view: the timeline views share one, so switching among them resizes it (#184). */
class ViewScreenTest {
@Test
fun `the day, multi-day and week views share the timeline`() {
assertThat(listOf(CalendarView.Day, CalendarView.MultiDay, CalendarView.Week).map { it.screen }.toSet())
.containsExactly(ViewScreen.Timeline)
}
@Test
fun `month and agenda each have their own screen`() {
assertThat(CalendarView.Month.screen).isEqualTo(ViewScreen.Month)
assertThat(CalendarView.Agenda.screen).isEqualTo(ViewScreen.Agenda)
}
}
@@ -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)
@@ -0,0 +1,107 @@
package de.jeanlucmakiola.calendula.ui.week
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlin.time.Instant
import org.junit.jupiter.api.Test
/** The timeline's columns, standing still and mid view switch (#184). */
class ColumnGeometryTest {
// 2026-09-27 is a Sunday; the week runs Sun..Sat.
private val sunday = LocalDate(2026, 9, 27)
private val week = DaySpan(sunday, 7)
private val monday = sunday.plus(1, DateTimeUnit.DAY)
private val day = DaySpan(monday, 1)
private val width = 700f
private val gap = 7f
@Test
fun `a page's columns follow the pitch`() {
val slot = week.slot(sunday.plus(3, DateTimeUnit.DAY), width, gap)
assertThat(slot.left).isEqualTo(3 * 101f)
assertThat(slot.width).isEqualTo(94f)
}
@Test
fun `the ends of a switch are the two pages`() {
var p = 0f
val geometry = ColumnGeometry(week, day) { p }
assertThat(geometry.slot(monday, width, gap)).isEqualTo(week.slot(monday, width, gap))
p = 1f
assertThat(geometry.slot(monday, width, gap)).isEqualTo(day.slot(monday, width, gap))
}
@Test
fun `days off the target page end off screen on their side`() {
val geometry = ColumnGeometry(week, day) { 1f }
assertThat(geometry.slot(sunday, width, gap).right).isAtMost(0f)
assertThat(geometry.slot(monday.plus(1, DateTimeUnit.DAY), width, gap).left).isAtLeast(width)
}
@Test
fun `a switch covers both pages without a break`() {
// Focus on Saturday: the six-day run starting there reaches into next week.
val saturday = sunday.plus(6, DateTimeUnit.DAY)
val geometry = ColumnGeometry(week, DaySpan(saturday, 6)) { 0f }
assertThat(geometry.dates).hasSize(12)
assertThat(geometry.dates.first()).isEqualTo(sunday)
assertThat(geometry.dates.zipWithNext().all { (a, b) -> b == a.plus(1, DateTimeUnit.DAY) }).isTrue()
}
@Test
fun `a bar spans its extent on each page`() {
val tuesday = monday.plus(1, DateTimeUnit.DAY)
val range = monday..tuesday
val geometry = ColumnGeometry(week, day) { 0f }
val extent = geometry.extent(range, monday..monday, width, 0f)
assertThat(extent.left).isEqualTo(week.slot(monday, width, 0f).left)
assertThat(extent.right).isEqualTo(week.slot(tuesday, width, 0f).right)
}
@Test
fun `a bar only one page has keeps its days on the other`() {
val bar = AllDaySpan(event(1), startCol = 2, endCol = 3, lane = 1)
val from = state(week, listOf(bar))
val to = state(day, emptyList())
val placed = morphBars(from, to).single()
assertThat(placed.fromRange).isEqualTo(placed.toRange)
assertThat(placed.toLane).isEqualTo(1)
}
@Test
fun `a bar on both pages is placed once, clipped to each`() {
val event = event(1)
val from = state(week, listOf(AllDaySpan(event, startCol = 0, endCol = 6, lane = 0)))
val to = state(day, listOf(AllDaySpan(event, startCol = 0, endCol = 0, lane = 0)))
val placed = morphBars(from, to).single()
assertThat(placed.fromRange).isEqualTo(sunday..sunday.plus(6, DateTimeUnit.DAY))
assertThat(placed.toRange).isEqualTo(monday..monday)
}
private fun state(span: DaySpan, bars: List<AllDaySpan>): WeekUiState.Success {
val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) }
return WeekUiState.Success(
weekStart = span.start,
today = sunday,
days = days,
allDaySpans = bars,
timedByDay = emptyMap(),
)
}
private fun event(id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "Event",
start = Instant.fromEpochMilliseconds(0),
end = Instant.fromEpochMilliseconds(0),
isAllDay = true,
color = 0xFF000000.toInt(),
location = null,
)
}
@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.ui.week
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
/** Where the focused date goes as a timeline pages (#184). */
class FocusOnPageTest {
// 2026-09-30 is a Wednesday.
private val wednesday = LocalDate(2026, 9, 30)
@Test
fun `a page that still shows the focus keeps it`() {
val grid = WeekGrid(DayOfWeek.MONDAY)
assertThat(grid.focusOnPage(wednesday, grid.pageFor(wednesday))).isEqualTo(wednesday)
}
@Test
fun `a week on keeps the weekday`() {
val grid = WeekGrid(DayOfWeek.MONDAY)
val page = grid.pageFor(wednesday)
assertThat(grid.focusOnPage(wednesday, page + 1)).isEqualTo(LocalDate(2026, 10, 7))
assertThat(grid.focusOnPage(wednesday, page - 2)).isEqualTo(LocalDate(2026, 9, 16))
}
@Test
fun `a run of days keeps the focus's column`() {
val grid = DaysGrid.startingAt(LocalDate(2026, 9, 29), 3)
val page = grid.pageFor(wednesday)
assertThat(grid.focusOnPage(wednesday, page + 1)).isEqualTo(LocalDate(2026, 10, 3))
}
@Test
fun `a day page is the focus`() {
val page = DayGrid.pageFor(wednesday)
assertThat(DayGrid.focusOnPage(wednesday, page + 5)).isEqualTo(LocalDate(2026, 10, 5))
}
}
@@ -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,81 @@
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))
}
@Test
fun `a day page and its day round-trip`() {
listOf(LocalDate(2026, 9, 27), LocalDate(1990, 5, 16), LocalDate(2100, 2, 28)).forEach { day ->
assertThat(DayGrid.startForPage(DayGrid.pageFor(day))).isEqualTo(day)
}
}
@Test
fun `adjacent day pages are adjacent days across a month end`() {
val page = DayGrid.pageFor(LocalDate(2026, 2, 28))
assertThat(DayGrid.startForPage(page + 1)).isEqualTo(LocalDate(2026, 3, 1))
assertThat(DayGrid.startForPage(page - 1)).isEqualTo(LocalDate(2026, 2, 27))
}
}
+35 -3
View File
@@ -27,7 +27,7 @@ the package list (recurring writes, save conflicts, reminder delivery).
```mermaid ```mermaid
flowchart TD flowchart TD
subgraph UI ["ui/ — Compose screens + ViewModels"] subgraph UI ["ui/ — Compose screens + ViewModels"]
Screens["Month / Week / Day\nDetail / Edit / Settings\nOnboarding wizard"] Screens["Month / Timeline / Agenda\nDetail / Edit / Settings\nOnboarding wizard"]
end end
subgraph Data ["data/"] subgraph Data ["data/"]
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"] Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
@@ -79,10 +79,42 @@ flowchart TD
There is no navigation library. `MainActivity` hosts `RootScreen`, which There is no navigation library. `MainActivity` hosts `RootScreen`, which
gates on the first-launch wizard (`ui/onboarding/`), then shows gates on the first-launch wizard (`ui/onboarding/`), then shows
`CalendarHost`. `CalendarHost` holds the active view (month/week/day) `CalendarHost`. `CalendarHost` holds the active view (month, day/multi-day/week,
plus overlay state for detail, edit, and settings — full-screen overlays agenda) plus overlay state for detail, edit, and settings — full-screen overlays
driven by `AnimatedVisibility` with a *held-key* pattern: the last shown driven by `AnimatedVisibility` with a *held-key* pattern: the last shown
key stays alive through the slide-out so content never flashes empty. key stays alive through the slide-out so content never flashes empty.
Overlays leave through floret-kit's `predictiveBackExit`, so a committed back
gesture finishes from its scaled preview.
### Switching views (#184)
- **One focused date.** `CalendarHost` owns a `ViewFocus` (`LocalViewFocus`)
that every view opens on and carries along. A view *reads* it once, on
entry, synchronously (`EnterOnFocus` before it reads its own position, so
its first frame is already on that date), and *writes* it only when the user
moves — a pager settling (`focusOnPage` keeps the date's place in the page),
a jump, Today, a tapped day, the agenda's top row. Writing on entry would
drift the date on every round trip.
- **Three screens, not five.** The host's `AnimatedContent` is keyed on
`ViewScreen` (month, timeline, agenda), not on the view: day, multi-day and
week are one `TimelineScreen` with a `TimelineKind` each and a view model
each. Keying the three on one `contentKey` instead would still replay the
enter transition on every switch among them.
- **Timeline ↔ timeline resizes in place.** `ColumnGeometry` places every
column (header cell, all-day bar, day column) in the layout pass; a switch
swaps the pager for a frame over both pages' days, each column lerped
between its slot on the one page and the other — a day the page doesn't
show carries on at its pitch, off screen, which is what slides it in or
out. Blocks sit in their column as shares of its width (`LaneColumn`), so
nothing recomposes per frame. The frame starts and ends exactly as the two
pages lay out, so the pagers either side hand over without a jump.
- **Across screens, what both show morphs.** Inside a `SharedTransitionLayout`,
an event shown in both views travels from its old bounds to its new ones,
keyed per occurrence *and* per day it is drawn from (`ViewMorphKey`); only a
pager's settled page carries the tags. The drawer, top bar and FAB are not
per screen: CalendarHost draws them once, above the switch, from what the
screen on show publishes (`PublishChrome` in `CalendarChrome.kt`), so a
switch only changes what they say.
A tapped reminder notification routes through `MainActivity` (`singleTop` + A tapped reminder notification routes through `MainActivity` (`singleTop` +
`onNewIntent`) as an external detail key that `CalendarHost` consumes `onNewIntent`) as an external detail key that `CalendarHost` consumes
exactly like an event tap. exactly like an event tap.
+1 -1
View File
@@ -1,5 +1,5 @@
[versions] [versions]
agp = "9.4.0" agp = "9.2.1"
kotlin = "2.3.21" kotlin = "2.3.21"
ksp = "2.3.11" ksp = "2.3.11"
hilt = "2.60.1" hilt = "2.60.1"