Put the day view on the paged timeline (#184)
This commit is contained in:
@@ -50,7 +50,7 @@ import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
|||||||
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.week.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
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,136 +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.common.PageStateCache
|
|
||||||
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.filterNot
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
|
||||||
import kotlinx.datetime.atTime
|
|
||||||
import kotlinx.datetime.daysUntil
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Instant
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.math.abs
|
|
||||||
|
|
||||||
/** How far from the requested day [DayViewModel.day] keeps other days cached. */
|
|
||||||
private const val DAY_CACHE_DAYS = 7
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@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)
|
|
||||||
|
|
||||||
/** The pager page the anchor day sits on. */
|
|
||||||
val anchorPage: StateFlow<Int> = _date
|
|
||||||
.map { dayPageFor(it) }
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = dayPageFor(todayDate),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The anchor day's state. Once loaded it never falls back to [DayUiState.Loading]:
|
|
||||||
* moving the anchor keeps the last result until the new day arrives, since
|
|
||||||
* the pages draw their own days and this only gates the failure screen.
|
|
||||||
*/
|
|
||||||
val state: StateFlow<DayUiState> = _date
|
|
||||||
.flatMapLatest { date -> day(date).filterNot { it is DayUiState.Loading } }
|
|
||||||
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = DayUiState.Loading,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val days = PageStateCache<LocalDate, DayUiState>(
|
|
||||||
scope = viewModelScope,
|
|
||||||
initial = DayUiState.Loading,
|
|
||||||
// Far enough to cover the pages either side and a swipe back, so paging
|
|
||||||
// through a month doesn't keep a month of queries around.
|
|
||||||
keep = { cached, requested -> abs(cached.daysUntil(requested)) <= DAY_CACHE_DAYS },
|
|
||||||
) { date ->
|
|
||||||
combine(
|
|
||||||
repository.calendars(),
|
|
||||||
repository.instances(dayRange(date, zone)),
|
|
||||||
) { calendars, instances ->
|
|
||||||
buildState(date, calendars, instances)
|
|
||||||
}
|
|
||||||
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
|
||||||
.flowOn(io)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The day [date], shared between its pager page and [state]. Main thread only. */
|
|
||||||
fun day(date: LocalDate): StateFlow<DayUiState> = days.get(date)
|
|
||||||
|
|
||||||
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
|
||||||
fun onPageSettled(page: Int) {
|
|
||||||
if (dayPageFor(_date.value) != page) _date.value = dayForPage(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun goToToday() {
|
|
||||||
_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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,6 @@ 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.label
|
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
|
||||||
@@ -73,7 +72,7 @@ internal fun ViewStep(
|
|||||||
height = PREVIEW_HEIGHT,
|
height = PREVIEW_HEIGHT,
|
||||||
dayCount = LocalMultiDayCount.current,
|
dayCount = LocalMultiDayCount.current,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
|
CalendarView.Day -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT, dayCount = 1)
|
||||||
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,3 +73,23 @@ data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid {
|
|||||||
DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.BiasAlignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -184,6 +185,9 @@ private val HEADER_BOTTOM_PADDING = 8.dp
|
|||||||
/** The header's date slot, reserved whether or not it holds today's circle. */
|
/** The header's date slot, reserved whether or not it holds today's circle. */
|
||||||
private val DATE_SLOT_SIZE = 28.dp
|
private val DATE_SLOT_SIZE = 28.dp
|
||||||
|
|
||||||
|
/** A start-aligned header cell's inset, lining its date up with the event titles below. */
|
||||||
|
private val HEADER_START_INSET = BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING
|
||||||
|
|
||||||
/** The weekday header's height, which the day view takes off to fit the week's hours (#184). */
|
/** The weekday header's height, which the day view takes off to fit the week's hours (#184). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun weekDayHeaderHeight(): Dp {
|
internal fun weekDayHeaderHeight(): Dp {
|
||||||
@@ -216,7 +220,7 @@ fun WeekScreen(
|
|||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
) = PagedTimelineScreen(
|
) = PagedTimelineScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
showWeekNumber = true,
|
kind = TimelineKind.Week,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
@@ -247,8 +251,7 @@ fun MultiDayScreen(
|
|||||||
viewModel: MultiDayViewModel = hiltViewModel(),
|
viewModel: MultiDayViewModel = hiltViewModel(),
|
||||||
) = PagedTimelineScreen(
|
) = PagedTimelineScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
// A run of days often straddles two weeks, so no single number fits it.
|
kind = TimelineKind.MultiDay,
|
||||||
showWeekNumber = false,
|
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
@@ -262,11 +265,66 @@ fun MultiDayScreen(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** The day view: the week view's timeline one day at a time. */
|
||||||
|
@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(),
|
||||||
|
) = PagedTimelineScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
kind = TimelineKind.Day,
|
||||||
|
selectedView = selectedView,
|
||||||
|
onSelectView = onSelectView,
|
||||||
|
// Tapping the one column's header would only reopen this day.
|
||||||
|
onOpenDay = {},
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
|
modifier = modifier,
|
||||||
|
initialDate = initialDateIso?.let(LocalDate::parse),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What sets the timeline views apart; everything else they share. */
|
||||||
|
internal enum class TimelineKind(
|
||||||
|
val showWeekNumber: Boolean,
|
||||||
|
val todayText: Int,
|
||||||
|
/** Where a header cell sits in its column: centred, or at the start like the event titles. */
|
||||||
|
val headerBias: Float,
|
||||||
|
) {
|
||||||
|
Week(showWeekNumber = true, todayText = R.string.week_today_action, headerBias = 0f),
|
||||||
|
|
||||||
|
// A run of days often straddles two weeks, so no single number fits it.
|
||||||
|
MultiDay(showWeekNumber = false, todayText = R.string.week_today_action, headerBias = 0f),
|
||||||
|
Day(showWeekNumber = false, todayText = R.string.day_today_action, headerBias = -1f),
|
||||||
|
;
|
||||||
|
|
||||||
|
fun title(start: LocalDate, end: LocalDate, locale: Locale, currentYear: Int, abbreviated: Boolean): String =
|
||||||
|
if (this == Day) {
|
||||||
|
formatDayTitle(start, locale, currentYear, abbreviated)
|
||||||
|
} else {
|
||||||
|
formatWeekTitle(start, end, locale, currentYear, abbreviated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun PagedTimelineScreen(
|
private fun PagedTimelineScreen(
|
||||||
viewModel: PagedTimelineViewModel,
|
viewModel: PagedTimelineViewModel,
|
||||||
showWeekNumber: Boolean,
|
kind: TimelineKind,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
@@ -278,7 +336,9 @@ private fun PagedTimelineScreen(
|
|||||||
drawerViewOrder: List<CalendarView>,
|
drawerViewOrder: List<CalendarView>,
|
||||||
todayInToolbar: Boolean,
|
todayInToolbar: Boolean,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
|
initialDate: LocalDate? = null,
|
||||||
) {
|
) {
|
||||||
|
LaunchedEffect(initialDate) { initialDate?.let(viewModel::jumpTo) }
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val position by viewModel.position.collectAsStateWithLifecycle()
|
val position by viewModel.position.collectAsStateWithLifecycle()
|
||||||
val grid = position.grid
|
val grid = position.grid
|
||||||
@@ -368,6 +428,7 @@ private fun PagedTimelineScreen(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
WeekTopBar(
|
WeekTopBar(
|
||||||
|
kind = kind,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
weekEnd = weekEnd,
|
weekEnd = weekEnd,
|
||||||
currentYear = today.year,
|
currentYear = today.year,
|
||||||
@@ -384,7 +445,7 @@ private fun PagedTimelineScreen(
|
|||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = !isOnCurrentWeek && !todayInToolbar,
|
todayVisible = !isOnCurrentWeek && !todayInToolbar,
|
||||||
todayText = stringResource(R.string.week_today_action),
|
todayText = stringResource(kind.todayText),
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = {
|
onCreate = {
|
||||||
// Anchor on today when it's in view, else the week's first day.
|
// Anchor on today when it's in view, else the week's first day.
|
||||||
@@ -398,7 +459,7 @@ private fun PagedTimelineScreen(
|
|||||||
state = state,
|
state = state,
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
grid = grid,
|
grid = grid,
|
||||||
showWeekNumber = showWeekNumber,
|
kind = kind,
|
||||||
span = viewModel::span,
|
span = viewModel::span,
|
||||||
onRetry = viewModel::goToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
@@ -418,7 +479,7 @@ private fun WeekContent(
|
|||||||
state: WeekUiState,
|
state: WeekUiState,
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
grid: PageGrid,
|
grid: PageGrid,
|
||||||
showWeekNumber: Boolean,
|
kind: TimelineKind,
|
||||||
span: (DaySpan) -> StateFlow<WeekUiState>,
|
span: (DaySpan) -> StateFlow<WeekUiState>,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
@@ -466,7 +527,7 @@ private fun WeekContent(
|
|||||||
is WeekUiState.Success -> WeekPager(
|
is WeekUiState.Success -> WeekPager(
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
grid = grid,
|
grid = grid,
|
||||||
showWeekNumber = showWeekNumber,
|
kind = kind,
|
||||||
span = span,
|
span = span,
|
||||||
today = s.today,
|
today = s.today,
|
||||||
initialStripHeight = s.allDayStripHeight(),
|
initialStripHeight = s.allDayStripHeight(),
|
||||||
@@ -491,7 +552,7 @@ private fun WeekContent(
|
|||||||
private fun WeekPager(
|
private fun WeekPager(
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
grid: PageGrid,
|
grid: PageGrid,
|
||||||
showWeekNumber: Boolean,
|
kind: TimelineKind,
|
||||||
span: (DaySpan) -> StateFlow<WeekUiState>,
|
span: (DaySpan) -> StateFlow<WeekUiState>,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
initialStripHeight: Dp,
|
initialStripHeight: Dp,
|
||||||
@@ -506,7 +567,7 @@ private fun WeekPager(
|
|||||||
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
|
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
|
||||||
WeekFrame(
|
WeekFrame(
|
||||||
weekStart = grid.startForPage(pagerState.currentPage),
|
weekStart = grid.startForPage(pagerState.currentPage),
|
||||||
showWeekNumber = showWeekNumber,
|
showWeekNumber = kind.showWeekNumber,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
@@ -537,6 +598,7 @@ private fun WeekPager(
|
|||||||
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
|
ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) {
|
||||||
WeekPage(
|
WeekPage(
|
||||||
state = loaded ?: empty,
|
state = loaded ?: empty,
|
||||||
|
headerBias = kind.headerBias,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
@@ -610,6 +672,7 @@ private fun WeekFrame(
|
|||||||
internal fun WeekSuccess(
|
internal fun WeekSuccess(
|
||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
showWeekNumber: Boolean = true,
|
showWeekNumber: Boolean = true,
|
||||||
|
headerBias: Float = 0f,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
@@ -627,6 +690,7 @@ internal fun WeekSuccess(
|
|||||||
) {
|
) {
|
||||||
WeekPage(
|
WeekPage(
|
||||||
state = state,
|
state = state,
|
||||||
|
headerBias = headerBias,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
@@ -648,6 +712,7 @@ internal fun WeekSuccess(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun WeekPage(
|
private fun WeekPage(
|
||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
|
headerBias: Float,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
@@ -663,7 +728,7 @@ private fun WeekPage(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.surface),
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
) {
|
) {
|
||||||
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
|
WeekDayHeader(days = state.days, today = state.today, bias = headerBias, onOpenDay = onOpenDay)
|
||||||
// This strip is painted on `surface`, so an outlined chip has to
|
// This strip is painted on `surface`, so an outlined chip has to
|
||||||
// fill with that and not with the columns' container (#230).
|
// fill with that and not with the columns' container (#230).
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
@@ -689,6 +754,7 @@ private fun WeekPage(
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekTopBar(
|
private fun WeekTopBar(
|
||||||
|
kind: TimelineKind,
|
||||||
weekStart: LocalDate,
|
weekStart: LocalDate,
|
||||||
weekEnd: LocalDate,
|
weekEnd: LocalDate,
|
||||||
currentYear: Int,
|
currentYear: Int,
|
||||||
@@ -702,9 +768,9 @@ private fun WeekTopBar(
|
|||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val (title, shortTitle) = remember(weekStart, weekEnd, locale, currentYear) {
|
val (title, shortTitle) = remember(kind, weekStart, weekEnd, locale, currentYear) {
|
||||||
formatWeekTitle(weekStart, weekEnd, locale, currentYear) to
|
kind.title(weekStart, weekEnd, locale, currentYear, abbreviated = false) to
|
||||||
formatWeekTitle(weekStart, weekEnd, locale, currentYear, abbreviated = true)
|
kind.title(weekStart, weekEnd, locale, currentYear, abbreviated = true)
|
||||||
}
|
}
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
@@ -751,6 +817,7 @@ private fun WeekTopBar(
|
|||||||
private fun WeekDayHeader(
|
private fun WeekDayHeader(
|
||||||
days: List<LocalDate>,
|
days: List<LocalDate>,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
|
bias: Float,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
@@ -766,8 +833,9 @@ private fun WeekDayHeader(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.clickable { onOpenDay(date) },
|
.clickable { onOpenDay(date) }
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.padding(start = HEADER_START_INSET * -bias),
|
||||||
|
horizontalAlignment = BiasAlignment.Horizontal(bias),
|
||||||
) {
|
) {
|
||||||
// One line, as the gutter cell beside it assumes (see WeekNumberCell).
|
// One line, as the gutter cell beside it assumes (see WeekNumberCell).
|
||||||
Text(
|
Text(
|
||||||
@@ -1384,3 +1452,17 @@ private fun formatWeekTitle(
|
|||||||
forceYear = weekEnd.year != weekStart.year,
|
forceYear = weekEnd.year != weekStart.year,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The day's title: its weekday and date, abbreviated to the date alone when short of room. */
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNot
|
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
|
||||||
@@ -208,6 +209,20 @@ class MultiDayViewModel @Inject constructor(
|
|||||||
initialRule = { DaysGrid.startingAt(it, SettingsPrefs.DEFAULT_MULTI_DAY_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;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ internal fun WeekViewPreview(
|
|||||||
WeekSuccess(
|
WeekSuccess(
|
||||||
state = state,
|
state = state,
|
||||||
showWeekNumber = dayCount == 7,
|
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(),
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -64,4 +64,18 @@ class WeekPagingTest {
|
|||||||
assertThat(grid.startForPage(page)).isEqualTo(date)
|
assertThat(grid.startForPage(page)).isEqualTo(date)
|
||||||
assertThat(grid.startForPage(page - 1)).isEqualTo(LocalDate(1990, 5, 13))
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user