Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7492888b1a | ||
|
|
17223cf0bb | ||
|
|
21c770afb3 | ||
|
|
aee23dc751 | ||
|
|
705835a66f | ||
|
|
c2302c033e | ||
|
|
596f76bbaa | ||
|
|
85f77f2332 | ||
|
|
d471255340 | ||
|
|
dcb1e891f1 | ||
|
|
7bf41fa0f8 | ||
|
|
a23ea81c48 | ||
|
|
52aa48fda7 | ||
|
|
264b1ebbf4 | ||
|
|
bd04f7692c |
@@ -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/),
|
||||
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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.TimeZone
|
||||
|
||||
/** Lower values appear first in lists and get the upper visible lanes in grids. */
|
||||
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
|
||||
isDeclined -> 3
|
||||
spansMultipleDays(zone) -> 0
|
||||
isAllDay -> 1
|
||||
else -> 2
|
||||
}
|
||||
|
||||
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
|
||||
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
|
||||
compareBy<EventInstance> { it.displayPriority(zone) }
|
||||
.thenBy { it.start }
|
||||
.thenBy { it.title }
|
||||
.thenBy { it.instanceId }
|
||||
@@ -34,6 +34,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||
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.selectView
|
||||
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||
@@ -112,6 +113,11 @@ fun CalendarHost(
|
||||
pendingDayIso = date.toString()
|
||||
viewStack = viewStack.drillToDay()
|
||||
}
|
||||
var pendingWeekIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val onOpenWeek: (LocalDate) -> Unit = { date ->
|
||||
pendingWeekIso = date.toString()
|
||||
viewStack = viewStack.drillToWeek()
|
||||
}
|
||||
|
||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||
// it overlays whichever calendar view is active. We forward the tapped
|
||||
@@ -380,6 +386,8 @@ fun CalendarHost(
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
initialDateIso = pendingWeekIso,
|
||||
onInitialDateConsumed = { pendingWeekIso = null },
|
||||
)
|
||||
CalendarView.MultiDay -> MultiDayScreen(
|
||||
selectedView = currentView,
|
||||
@@ -409,6 +417,7 @@ fun CalendarHost(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenWeek = onOpenWeek,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||
@@ -86,11 +87,7 @@ fun groupAgendaDays(
|
||||
return byDay.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedWith(
|
||||
compareByDescending<EventInstance> { it.isAllDay }
|
||||
.thenBy { it.start }
|
||||
.thenBy { it.title },
|
||||
),
|
||||
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,3 +129,7 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
|
||||
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
|
||||
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
||||
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
||||
|
||||
/** Push the week view over the month after a week-number tap. */
|
||||
fun List<CalendarView>.drillToWeek(): List<CalendarView> =
|
||||
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.EnterExitState
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerDefaults
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
@@ -18,10 +19,12 @@ import kotlinx.coroutines.flow.drop
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
@@ -55,10 +58,12 @@ import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.DrawerValue
|
||||
@@ -66,6 +71,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.InputChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -77,6 +83,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -85,6 +92,7 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -103,6 +111,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.monthBarCuts
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.abs
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||
@@ -131,6 +140,8 @@ import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
@@ -148,6 +159,7 @@ import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||
@@ -193,12 +205,55 @@ import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
|
||||
private val LocalOpenWeek = staticCompositionLocalOf<((LocalDate) -> Unit)?> { null }
|
||||
|
||||
/** A brief push from today's full cell into its immediate neighbours. */
|
||||
@Composable
|
||||
private fun rememberTodayRipple(active: Boolean): Animatable<Float, androidx.compose.animation.core.AnimationVector1D> {
|
||||
val pulse = remember { Animatable(0f) }
|
||||
val pulseKey = LocalTodayPulse.current
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
LaunchedEffect(pulseKey, active, reduceMotion) {
|
||||
if (active && pulseKey > 0 && !reduceMotion) {
|
||||
pulse.snapTo(0f)
|
||||
pulse.animateTo(1f, tween(durationMillis = 180))
|
||||
pulse.animateTo(
|
||||
0f,
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
pulse.snapTo(0f)
|
||||
}
|
||||
}
|
||||
return pulse
|
||||
}
|
||||
|
||||
/** Paint-only motion keeps hit targets and the month grid's lane geometry stable. */
|
||||
private fun Modifier.todayRipple(relativeColumn: Int?, progress: () -> Float): Modifier {
|
||||
if (relativeColumn == null || abs(relativeColumn) > 1) return this
|
||||
return graphicsLayer {
|
||||
val pulse = progress()
|
||||
if (relativeColumn == 0) {
|
||||
scaleX = 1f + 0.10f * pulse
|
||||
scaleY = 1f + 0.06f * pulse
|
||||
} else {
|
||||
translationX = (if (relativeColumn < 0) -1f else 1f) * 4.dp.toPx() * pulse
|
||||
scaleX = 1f - 0.025f * pulse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MonthScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onOpenWeek: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
@@ -327,6 +382,9 @@ fun MonthScreen(
|
||||
}
|
||||
|
||||
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
|
||||
var splitExpanded by rememberSaveable(viewStyle) {
|
||||
mutableStateOf(viewStyle == MonthViewStyle.Paged)
|
||||
}
|
||||
|
||||
// Continuous names the month on the block's own sticky header, so the bar
|
||||
// carries the year instead of repeating it two lines further down. Dense has
|
||||
@@ -348,7 +406,9 @@ fun MonthScreen(
|
||||
val jumpToMonth: (YearMonth) -> Unit = { ym ->
|
||||
scope.launch { pagerState.jumpToPage(monthIndexOf(ym), reduceMotion, pageSpec) }
|
||||
}
|
||||
var todayPulse by remember { mutableIntStateOf(0) }
|
||||
val jumpToToday: () -> Unit = {
|
||||
todayPulse++
|
||||
if (scrolling) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(
|
||||
@@ -428,7 +488,9 @@ fun MonthScreen(
|
||||
// today when its month is shown, else the 1st.
|
||||
onCreateEvent(
|
||||
when {
|
||||
viewStyle == MonthViewStyle.Split -> selectedDate
|
||||
(viewStyle == MonthViewStyle.Split ||
|
||||
(viewStyle == MonthViewStyle.Paged && !splitExpanded)) -> selectedDate
|
||||
?: if (isOnCurrentMonth) today else LocalDate(titleMonth.year, titleMonth.month, 1)
|
||||
isOnCurrentMonth -> today
|
||||
else -> LocalDate(titleMonth.year, titleMonth.month, 1)
|
||||
},
|
||||
@@ -454,6 +516,8 @@ fun MonthScreen(
|
||||
CompositionLocalProvider(
|
||||
LocalDimCutoff provides dimCutoff,
|
||||
LocalMonthDrag provides chipDrag,
|
||||
LocalTodayPulse provides todayPulse,
|
||||
LocalOpenWeek provides onOpenWeek,
|
||||
) {
|
||||
if (scrolling) {
|
||||
ContinuousMonthContent(
|
||||
@@ -465,14 +529,20 @@ fun MonthScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
} else if (viewStyle == MonthViewStyle.Split) {
|
||||
} else if (viewStyle == MonthViewStyle.Split || viewStyle == MonthViewStyle.Paged) {
|
||||
SplitMonthContent(
|
||||
state = state,
|
||||
selected = selectedDate,
|
||||
initiallyExpanded = viewStyle == MonthViewStyle.Paged,
|
||||
expanded = splitExpanded,
|
||||
onExpandedChange = { splitExpanded = it },
|
||||
pagerState = pagerState,
|
||||
weekStart = weekStart,
|
||||
monthState = viewModel::month,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onPaneSwipeNext = { viewModel.stepSplitPane(1) },
|
||||
onPaneSwipePrev = { viewModel.stepSplitPane(-1) },
|
||||
onClearSelection = viewModel::clearSelection,
|
||||
onRetry = jumpToToday,
|
||||
// A tapped leading/trailing day follows to its own
|
||||
// month, pager and all — straight away, or a swipe
|
||||
@@ -866,7 +936,7 @@ private fun rememberWeekNumberGutter(): Dp {
|
||||
val style = weekNumberStyle()
|
||||
return remember(style, density, measurer) {
|
||||
val text = with(density) { measurer.measure(WEEK_NUMBER_SAMPLE, style).size.width.toDp() }
|
||||
text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2
|
||||
(text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2).coerceAtLeast(48.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1293,11 +1363,17 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
|
||||
@Composable
|
||||
private fun SplitMonthContent(
|
||||
state: MonthUiState,
|
||||
selected: LocalDate,
|
||||
selected: LocalDate?,
|
||||
initiallyExpanded: Boolean,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
pagerState: PagerState,
|
||||
weekStart: DayOfWeek,
|
||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||
showWeekNumbers: Boolean,
|
||||
onPaneSwipeNext: () -> Unit,
|
||||
onPaneSwipePrev: () -> Unit,
|
||||
onClearSelection: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onSelectDay: (LocalDate) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
@@ -1306,15 +1382,23 @@ private fun SplitMonthContent(
|
||||
) {
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
// Back collapses before it does anything else. Expanding replaces the whole
|
||||
// screen, so it is a place you can be, and every other place in the app can
|
||||
// be backed out of. Declared deeper than CalendarHost's view-stack handler,
|
||||
// which is what makes it win while it is enabled.
|
||||
BackHandler(enabled = expanded) { expanded = false }
|
||||
val haptics = LocalHapticFeedback.current
|
||||
BackHandler(enabled = expanded != initiallyExpanded) {
|
||||
onExpandedChange(initiallyExpanded)
|
||||
}
|
||||
val expandDrag = rememberSplitExpandDrag(
|
||||
onExpand = { expanded = true },
|
||||
onCollapse = { expanded = false },
|
||||
onExpand = {
|
||||
if (!expanded) {
|
||||
onExpandedChange(true)
|
||||
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
}
|
||||
},
|
||||
onCollapse = {
|
||||
if (expanded) {
|
||||
onExpandedChange(false)
|
||||
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
when (state) {
|
||||
@@ -1357,13 +1441,13 @@ private fun SplitMonthContent(
|
||||
// the day you picked.
|
||||
onPickDay = {
|
||||
onSelectDay(it)
|
||||
expanded = false
|
||||
onExpandedChange(false)
|
||||
},
|
||||
// A tapped chip is asking for that event, not for its
|
||||
// day, so it opens the detail from here too rather
|
||||
// than collapsing (#187).
|
||||
onEventClick = onEventClick,
|
||||
onCollapse = { expanded = false },
|
||||
onCollapse = { onExpandedChange(false) },
|
||||
)
|
||||
} else {
|
||||
SplitMonthCollapsed(
|
||||
@@ -1373,10 +1457,13 @@ private fun SplitMonthContent(
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
expandDrag = expandDrag,
|
||||
onSelectDay = onSelectDay,
|
||||
onPaneSwipeNext = onPaneSwipeNext,
|
||||
onPaneSwipePrev = onPaneSwipePrev,
|
||||
onClearSelection = onClearSelection,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
onExpand = { expanded = true },
|
||||
onExpand = { onExpandedChange(true) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1398,10 +1485,13 @@ private class SplitMonths(
|
||||
private fun SplitMonthCollapsed(
|
||||
state: MonthUiState.Success,
|
||||
months: SplitMonths,
|
||||
selected: LocalDate,
|
||||
selected: LocalDate?,
|
||||
showWeekNumbers: Boolean,
|
||||
expandDrag: Modifier,
|
||||
onSelectDay: (LocalDate) -> Unit,
|
||||
onPaneSwipeNext: () -> Unit,
|
||||
onPaneSwipePrev: () -> Unit,
|
||||
onClearSelection: () -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
@@ -1440,18 +1530,64 @@ private fun SplitMonthCollapsed(
|
||||
}
|
||||
SplitExpandHandle(expanded = false, onToggle = onExpand)
|
||||
}
|
||||
SplitDayPane(
|
||||
date = selected,
|
||||
today = state.today,
|
||||
// Null, not empty: the selection moves to the new month before
|
||||
// its data arrives, and a missing key means "not loaded yet".
|
||||
// Passing an empty list would claim the day was free.
|
||||
events = state.instancesByDay[selected],
|
||||
zone = state.zone,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
Box(
|
||||
Modifier.weight(1f).fillMaxWidth()
|
||||
.then(rememberSplitPaneSwipe(onPaneSwipeNext, onPaneSwipePrev)),
|
||||
) {
|
||||
if (selected == null) {
|
||||
SplitMonthAgendaPane(
|
||||
state = state,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
} else {
|
||||
Column {
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = onClearSelection,
|
||||
label = { Text(agendaDayLabel(selected, state.today)) },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.month_split_clear_filter),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
SplitDayPane(
|
||||
date = selected,
|
||||
today = state.today,
|
||||
events = state.instancesByDay[selected],
|
||||
zone = state.zone,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberSplitPaneSwipe(onNext: () -> Unit, onPrev: () -> Unit): Modifier {
|
||||
val threshold = with(LocalDensity.current) { 24.dp.toPx() }
|
||||
val currentNext by rememberUpdatedState(onNext)
|
||||
val currentPrev by rememberUpdatedState(onPrev)
|
||||
return Modifier.pointerInput(threshold) {
|
||||
var distance = 0f
|
||||
var fired = false
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { distance = 0f; fired = false },
|
||||
onHorizontalDrag = { _, drag ->
|
||||
distance += drag
|
||||
if (!fired && kotlin.math.abs(distance) >= threshold) {
|
||||
fired = true
|
||||
if (distance < 0f) currentNext() else currentPrev()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1469,7 +1605,7 @@ private fun SplitMonthCollapsed(
|
||||
private fun SplitMonthExpanded(
|
||||
months: SplitMonths,
|
||||
/** Marked nowhere here; it only anchors the outline's morph. */
|
||||
selected: LocalDate,
|
||||
selected: LocalDate?,
|
||||
showWeekNumbers: Boolean,
|
||||
expandDrag: Modifier,
|
||||
onPickDay: (LocalDate) -> Unit,
|
||||
@@ -1542,7 +1678,7 @@ private fun SplitExpandHandle(
|
||||
* it's roughly a third of a paged row — which is the point: the space it gives
|
||||
* up goes to the day pane below.
|
||||
*/
|
||||
private val SPLIT_ROW_HEIGHT = 46.dp
|
||||
private val SPLIT_ROW_HEIGHT = 48.dp
|
||||
|
||||
/** Vertical padding and row gap of [SplitMonthGrid], which [splitGridHeight] pays for. */
|
||||
private val SPLIT_GRID_VERTICAL_PADDING = 4.dp
|
||||
@@ -1581,13 +1717,15 @@ private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
|
||||
@Composable
|
||||
internal fun SplitMonthGrid(
|
||||
state: MonthUiState.Success,
|
||||
selected: LocalDate,
|
||||
selected: LocalDate?,
|
||||
showWeekNumbers: Boolean,
|
||||
onSelectDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val month = state.month
|
||||
val hasToday = state.today.year == month.year && state.today.month == month.month
|
||||
val ripple = rememberTodayRipple(active = hasToday)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1595,6 +1733,7 @@ internal fun SplitMonthGrid(
|
||||
verticalArrangement = Arrangement.spacedBy(SPLIT_ROW_GAP),
|
||||
) {
|
||||
state.weeks.forEach { week ->
|
||||
val todayCol = week.days.indexOf(state.today).takeIf { hasToday && it >= 0 }
|
||||
Row(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT)) {
|
||||
if (showWeekNumbers) {
|
||||
WeekNumberGutter(
|
||||
@@ -1625,6 +1764,8 @@ internal fun SplitMonthGrid(
|
||||
// page transition.
|
||||
isSelected = day == selected && inMonth,
|
||||
inMonth = inMonth,
|
||||
relativeToToday = todayCol?.let { col - it },
|
||||
rippleProgress = { ripple.value },
|
||||
dark = dark,
|
||||
onClick = { onSelectDay(day) },
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
@@ -1651,6 +1792,8 @@ private fun SplitDayCell(
|
||||
isToday: Boolean,
|
||||
isSelected: Boolean,
|
||||
inMonth: Boolean,
|
||||
relativeToToday: Int?,
|
||||
rippleProgress: () -> Float,
|
||||
dark: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -1659,6 +1802,7 @@ private fun SplitDayCell(
|
||||
// the whole cell, which read as a second state on top of the day rather than
|
||||
// as a mark on it — and fought today's own circle when they coincided.
|
||||
val background = when {
|
||||
isToday && inMonth -> MaterialTheme.colorScheme.primaryContainer
|
||||
inMonth -> MaterialTheme.colorScheme.surfaceContainer
|
||||
else -> MaterialTheme.colorScheme.surfaceContainerLow
|
||||
}
|
||||
@@ -1691,6 +1835,7 @@ private fun SplitDayCell(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.todayRipple(relativeToToday, rippleProgress)
|
||||
.selectable(selected = isSelected, onClick = onClick),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
@@ -1816,6 +1961,54 @@ private fun SplitDots(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SplitMonthAgendaPane(
|
||||
state: MonthUiState.Success,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
) {
|
||||
val start = agendaStartForMonth(state.month, state.today)
|
||||
val days = state.instancesByDay.keys
|
||||
.filter { it >= start && it.year == state.month.year && it.month == state.month.month }
|
||||
.filter { state.instancesByDay[it].orEmpty().isNotEmpty() }
|
||||
.toMutableList()
|
||||
.apply { if (start !in this) add(start) }
|
||||
.sorted()
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val listState = key(state.month) { rememberLazyListState() }
|
||||
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||
days.forEach { day ->
|
||||
val events = state.instancesByDay[day].orEmpty()
|
||||
stickyHeader(key = "header-$day") {
|
||||
AgendaDayHeader(date = day, today = state.today, onOpenDay = onOpenDay)
|
||||
}
|
||||
if (events.isEmpty()) {
|
||||
item(key = "empty-$day") {
|
||||
AgendaEmptyDayRow(
|
||||
text = stringResource(R.string.month_split_no_events),
|
||||
onClick = { onCreateEvent(day) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(events, key = { _, event -> "$day-${event.instanceId}" }) { index, event ->
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
day = day,
|
||||
zone = state.zone,
|
||||
position = positionOf(index, events.size),
|
||||
dimmed = dimCutoff != null && event.hasEnded(dimCutoff),
|
||||
modifier = animateItemMotion(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item(key = "gap-$day") { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected day's events, in the agenda's own row vocabulary so the two
|
||||
* surfaces read as one app. The date header opens the full Day view, matching
|
||||
@@ -2039,6 +2232,8 @@ private fun MonthWeekRow(
|
||||
selected: LocalDate? = null,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val todayCol = week.days.indexOf(today).takeIf { it >= 0 && inMonth(today) }
|
||||
val ripple = rememberTodayRipple(active = todayCol != null)
|
||||
val overflowRow = rememberOverflowRowHeight()
|
||||
val laneCap = week.laneCapFor(rowHeight, overflowRow)
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
@@ -2158,15 +2353,17 @@ private fun MonthWeekRow(
|
||||
// Spanning bars draw on top of these, bridging cells, so they still read
|
||||
// as one continuous event.
|
||||
Row(Modifier.matchParentSize()) {
|
||||
week.days.forEach { d ->
|
||||
week.days.forEachIndexed { col, d ->
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.morphElement(MonthMorphKey.Cell(d))
|
||||
.todayRipple(todayCol?.let { col - it }) { ripple.value }
|
||||
.background(
|
||||
color = when {
|
||||
d == today && inMonth(d) -> MaterialTheme.colorScheme.primaryContainer
|
||||
inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
|
||||
// A blanked cell draws nothing at all: it is
|
||||
// the gap that shows where the month starts.
|
||||
@@ -2181,7 +2378,7 @@ private fun MonthWeekRow(
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
week.days.forEach { d ->
|
||||
week.days.forEachIndexed { col, d ->
|
||||
if (blankOutside && !inMonth(d)) {
|
||||
Spacer(Modifier.weight(1f).height(DAY_NUMBER_HEIGHT))
|
||||
} else {
|
||||
@@ -2198,7 +2395,8 @@ private fun MonthWeekRow(
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.morphElement(MonthMorphKey.DayNumber(d)),
|
||||
.morphElement(MonthMorphKey.DayNumber(d))
|
||||
.todayRipple(todayCol?.let { col - it }) { ripple.value },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2313,6 +2511,7 @@ private fun MonthWeekRow(
|
||||
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
||||
.width(colW)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.todayRipple(todayCol?.let { col - it }) { ripple.value }
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
@@ -2546,8 +2745,13 @@ private fun monthChipDragModifier(
|
||||
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
|
||||
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||
val label = stringResource(R.string.week_number_label)
|
||||
val openLabel = stringResource(R.string.month_open_week, weekNumber)
|
||||
val onOpenWeek = LocalOpenWeek.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) {
|
||||
onOpenWeek(weekStart)
|
||||
} else Modifier)
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE)
|
||||
.semantics { contentDescription = "$label $weekNumber" },
|
||||
|
||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||
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.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
||||
@@ -292,12 +293,10 @@ class MonthViewModel @Inject constructor(
|
||||
|
||||
// --- Split style (#53) ------------------------------------------------
|
||||
//
|
||||
// The first selected-day concept in the app: the other views drill straight
|
||||
// into a date, while the split style keeps one selected and lists it below
|
||||
// the grid.
|
||||
// Null means the full-month agenda; a date narrows the pane to that day.
|
||||
|
||||
private val _selectedDate = MutableStateFlow(todayDate)
|
||||
val selectedDate: StateFlow<LocalDate> = _selectedDate
|
||||
private val _selectedDate = MutableStateFlow<LocalDate?>(null)
|
||||
val selectedDate: StateFlow<LocalDate?> = _selectedDate
|
||||
|
||||
/**
|
||||
* Select [date], following it to its month if it sits in the grid's leading
|
||||
@@ -305,23 +304,33 @@ class MonthViewModel @Inject constructor(
|
||||
* silently list a date the grid isn't pointing at.
|
||||
*/
|
||||
fun selectDate(date: LocalDate) {
|
||||
_selectedDate.value = date
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* shows.
|
||||
*/
|
||||
fun clearSelection() { _selectedDate.value = null }
|
||||
|
||||
/** A pane swipe walks days when filtered, months when showing the agenda. */
|
||||
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() {
|
||||
_selectedDate.value = selectionForMonth(_month.value, todayDate)
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||
_selectedDate.value = todayDate
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,13 +339,12 @@ class MonthViewModel @Inject constructor(
|
||||
* 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
|
||||
* scrolling style's list, would jump there instead of holding position. The
|
||||
* selection is realigned alongside it so that landing on the Split style
|
||||
* shows a live day in the month on screen rather than a stale, off-month one.
|
||||
* selection is cleared alongside it so Split opens the visible month's agenda.
|
||||
*/
|
||||
fun syncScrollMonth(ym: YearMonth) {
|
||||
if (_month.value == ym) return
|
||||
_month.value = ym
|
||||
_selectedDate.value = selectionForMonth(ym, todayDate)
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||
@@ -499,11 +507,7 @@ internal fun instancesByDay(
|
||||
days.associateWith { day ->
|
||||
instances
|
||||
.filter { it.coversDay(day, zone) }
|
||||
.sortedWith(
|
||||
compareBy<EventInstance> { it.isDeclined }
|
||||
.thenByDescending { it.isAllDay }
|
||||
.thenBy { it.start },
|
||||
)
|
||||
.sortedWith(eventDisplayComparator(zone))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -544,11 +548,10 @@ private const val WINDOW_EDGE = 2
|
||||
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]:
|
||||
* [today] when the month holds it, otherwise the 1st. Pure so the rule can be
|
||||
* tested without standing up a view model and a provider behind it.
|
||||
* First day shown in the split agenda: today in the current month, otherwise
|
||||
* the first of the visible month.
|
||||
*/
|
||||
internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||
internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||
if (YearMonth(today.year, today.month) == month) {
|
||||
today
|
||||
} else {
|
||||
|
||||
@@ -199,23 +199,33 @@ fun WeekScreen(
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
initialDateIso: String? = null,
|
||||
onInitialDateConsumed: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: WeekViewModel = hiltViewModel(),
|
||||
) = PagedTimelineScreen(
|
||||
viewModel = viewModel,
|
||||
showWeekNumber = true,
|
||||
selectedView = selectedView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
modifier = modifier,
|
||||
)
|
||||
) {
|
||||
LaunchedEffect(initialDateIso) {
|
||||
initialDateIso?.let {
|
||||
viewModel.goToDate(LocalDate.parse(it))
|
||||
onInitialDateConsumed()
|
||||
}
|
||||
}
|
||||
PagedTimelineScreen(
|
||||
viewModel = viewModel,
|
||||
showWeekNumber = true,
|
||||
selectedView = selectedView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/** The multi-day view (#193): the week view's timeline over the day count from Settings. */
|
||||
@Composable
|
||||
|
||||
@@ -9,9 +9,9 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.displayPriority
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
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.ExperimentalCoroutinesApi
|
||||
@@ -150,6 +150,7 @@ abstract class PagedTimelineViewModel(
|
||||
}
|
||||
|
||||
fun goToToday() = jumpTo(todayDate)
|
||||
fun goToDate(date: LocalDate) = jumpTo(date)
|
||||
|
||||
private fun buildState(
|
||||
span: DaySpan,
|
||||
@@ -227,7 +228,13 @@ internal fun layoutAllDay(
|
||||
}
|
||||
// Declined bars are packed after every other one, so on any day they
|
||||
// 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.
|
||||
// A single "last occupied column" only answers correctly while spans arrive
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<resources>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="app_tagline">A modern calendar.</string>
|
||||
<!-- Alternative launcher label offered by the "App name" setting (issue #44).
|
||||
translatable="false": this string becomes a launcher component's label, so
|
||||
it must never be changed by an unreviewed community translation. -->
|
||||
<string name="app_name_calendar_alias" translatable="false">Calendar</string>
|
||||
<!-- Alternative launcher label offered by the "App name" setting (issue #44),
|
||||
shown under the app icon. Translate it as the plain, generic word your
|
||||
language uses for a calendar app (e.g. "Kalender"), nothing else (#251). -->
|
||||
<string name="app_name_calendar_alias">Calendar</string>
|
||||
|
||||
<!-- Loading / Failure / Success generic strings (used across screens) -->
|
||||
<string name="state_loading">Loading…</string>
|
||||
@@ -54,7 +54,9 @@
|
||||
<!-- Month view (S1) -->
|
||||
<string name="month_prev">Previous 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_split_clear_filter">Show full month agenda</string>
|
||||
<string name="month_more_actions">More actions</string>
|
||||
<string name="month_open_menu">Open menu</string>
|
||||
<string name="month_action_settings">Settings</string>
|
||||
@@ -550,7 +552,7 @@
|
||||
<string name="settings_month_header">Month view</string>
|
||||
<string name="settings_month_view_style">Month view style</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_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>
|
||||
|
||||
@@ -77,6 +77,16 @@ class GroupAgendaDaysTest {
|
||||
.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
|
||||
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))
|
||||
|
||||
@@ -65,6 +65,14 @@ class ViewBackStackTest {
|
||||
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
|
||||
fun `agenda widget date tap backs out agenda then home`() {
|
||||
// openDate(source = Agenda): root in Agenda over the Week home, then drill.
|
||||
|
||||
@@ -222,17 +222,17 @@ class ContinuousMonthIndexTest {
|
||||
}
|
||||
|
||||
@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)
|
||||
assertThat(selectionForMonth(jun26, today)).isEqualTo(today)
|
||||
assertThat(agendaStartForMonth(jun26, today)).isEqualTo(today)
|
||||
}
|
||||
|
||||
@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)
|
||||
assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today))
|
||||
assertThat(agendaStartForMonth(YearMonth(2026, Month.JULY), today))
|
||||
.isEqualTo(LocalDate(2026, 7, 1))
|
||||
assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today))
|
||||
assertThat(agendaStartForMonth(YearMonth(2025, Month.JUNE), today))
|
||||
.isEqualTo(LocalDate(2025, 6, 1))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,18 @@ class MonthLayoutTest {
|
||||
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
|
||||
fun `single-day timed events stay pills and sort by start`() {
|
||||
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L)
|
||||
|
||||
Reference in New Issue
Block a user