diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt index 3a3eb92..1fc7645 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt @@ -4,6 +4,7 @@ import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import android.provider.CalendarContract import android.util.Log import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.EventResponse private const val TAG = "InstanceMapper" @@ -39,7 +40,17 @@ internal fun ColumnReader.toEventInstance(): EventInstance? { isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0, color = color, location = getString(InstanceProjection.IDX_LOCATION), - isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) == - CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED, + response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)), ) } + +/** + * `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe" + * counts as going, and everything that is not an open or refused invitation — + * your own events included — falls through to [EventResponse.Going]. + */ +internal fun mapEventResponse(raw: Int): EventResponse = when (raw) { + CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> EventResponse.Declined + CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> EventResponse.Invited + else -> EventResponse.Going +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt index a0e23b9..23ee661 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt @@ -1,6 +1,5 @@ package de.jeanlucmakiola.calendula.data.calendar -import android.provider.CalendarContract import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis @@ -43,7 +42,6 @@ internal fun ColumnReader.toSearchResult(): EventInstance? { location = getString(SearchProjection.IDX_LOCATION), isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() || !getString(SearchProjection.IDX_RDATE).isNullOrEmpty(), - isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) == - CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED, + response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index e6328e5..fa3fad0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -68,14 +68,35 @@ data class EventInstance( */ val isRecurring: Boolean = false, /** - * This device user answered "no" to the invitation - * (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is - * still an appointment someone expects an answer about — but every surface - * strikes it through, and it plans no reminders (#180). + * This device user's own answer to the invitation, as far as the grids care + * (#180, #230). */ - val isDeclined: Boolean = false, + val response: EventResponse = EventResponse.Going, ) +/** + * How this device user stands towards an event's invitation + * (`Events.SELF_ATTENDEE_STATUS`), reduced to the three cases the calendar + * surfaces draw differently (#230). + */ +enum class EventResponse { + /** Your own event, or one you accepted — including a tentative "maybe". */ + Going, + + /** Invited, no answer given yet: drawn as an outline so it reads as still open. */ + Invited, + + /** + * You answered "no". The event stays on the calendar — it is still an + * appointment someone expects an answer about — but every surface strikes it + * through, and it plans no reminders (#180). + */ + Declined, +} + +/** Shorthand for the declined case, which most surfaces test on its own (#180). */ +val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined + /** * Whether this event has finished relative to [now] — its end is at or before * the current instant. An in-progress event (already started but not yet ended) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt index 4b72c36..31ac272 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.declinedTitle import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt index e6f9eab..5ddc2b3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow @@ -81,8 +82,12 @@ fun BlockTitle( color: Color, modifier: Modifier = Modifier, textDecoration: TextDecoration? = null, + fontWeight: FontWeight? = null, ) { + // Folded into the style rather than passed to the Text, so the wrap measured + // below is the wrap that gets drawn. val style = MaterialTheme.typography.labelMedium + .let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) } val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl val measurer = rememberTextMeasurer() val widthPx = with(LocalDensity.current) { textWidth.roundToPx() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventPaint.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventPaint.kt new file mode 100644 index 0000000..f29321e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventPaint.kt @@ -0,0 +1,165 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.background +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.EventResponse +import de.jeanlucmakiola.calendula.domain.isDeclined + +/** Stroke an invitation you have not answered is outlined with. */ +val EVENT_OUTLINE_WIDTH = 1.dp + +/** + * The surface an event chip is drawn on, which an outlined one fills itself with + * so it reads as the background showing through rather than as a pale block laid + * over it. Defaults to the `surfaceContainer` the month cells and the timeline + * columns carry; the all-day strips sit on plain `surface` and a floating drag + * copy wants a lifted one, so both provide their own. + */ +val LocalChipGround = compositionLocalOf { Color.Unspecified } + +/** + * Which edges of a chip the event runs past. Those edges are squared by + * [monthBarShape] / [timedBlockShape] so the cut reads as "this carries on", and + * an outline has to leave them open for the same reason — a stroke all the way + * round would close a bar carried across a week boundary into two boxes. + */ +@Immutable +data class ChipCuts( + val start: Boolean = false, + val end: Boolean = false, + val top: Boolean = false, + val bottom: Boolean = false, +) + +/** [ChipCuts] for a month or all-day bar, which is only ever cut left and right. */ +fun monthBarCuts(continuesLeft: Boolean, continuesRight: Boolean): ChipCuts = + ChipCuts(start = continuesLeft, end = continuesRight) + +/** [ChipCuts] for a timed block, which is only ever cut top and bottom. */ +fun timedBlockCuts(continuesBefore: Boolean, continuesAfter: Boolean): ChipCuts = + ChipCuts(top = continuesBefore, bottom = continuesAfter) + +/** + * How one event's chip or block is painted, which turns on your answer to its + * invitation (#180, #230). + * + * An invitation you have not answered is drawn as an outline: its calendar's + * colour on the border and on the title, over the surface the chip sits on. So + * it holds a chip's shape and a chip's weight without claiming the filled + * container an event you are going to gets. A declined one keeps the fill and + * its strike-through. + */ +@Immutable +data class EventPaint( + val fill: Color, + /** Null for a filled chip; the border colour when the chip is outlined. */ + val outline: Color?, + val titleInk: Color, + val secondaryInk: Color, + val decoration: TextDecoration?, + /** + * Weight to set the title at, or null to keep whatever the surface's own + * text style carries. + */ + val titleWeight: FontWeight?, +) + +/** [EventPaint] for [event] on a [dark] scheme. */ +@Composable +fun eventPaint(event: EventInstance, dark: Boolean): EventPaint { + val soften = LocalSoftenColors.current + if (event.response == EventResponse.Invited) { + // Harmonised even when the setting is off. Raw mode exists so a filled + // container matches what the sync source paints, and an outlined chip + // has no container — what it has is coloured *text*, which needs the + // lightness pinned against the surface or a pale calendar goes + // unreadable (eventTone returns the raw colour verbatim otherwise). + val accent = eventAccent(event.color, dark, soften = true) + val ground = LocalChipGround.current.takeIf { it != Color.Unspecified } + ?: MaterialTheme.colorScheme.surfaceContainer + return EventPaint( + fill = ground, + outline = accent, + titleInk = accent, + // A neutral token rather than the accent faded: the time is the + // smallest text on the chip, and an alpha step off an accent that is + // itself only just clear of the surface is where legibility goes. + secondaryInk = MaterialTheme.colorScheme.onSurfaceVariant, + decoration = null, + // The label styles' medium weight is set to carry ink on a filled + // container. In a calendar colour on a plain one it thickens into + // something harder to read, so step it back to regular. + titleWeight = FontWeight.Normal, + ) + } + val fill = eventFill(event.color, dark, soften) + return EventPaint( + fill = fill, + outline = null, + titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA), + secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA), + decoration = declinedDecoration(event.isDeclined), + titleWeight = null, + ) +} + +/** [this] set at the weight [paint] asks a title for, for measuring and for drawing alike. */ +fun TextStyle.withTitleWeight(paint: EventPaint): TextStyle = + if (paint.titleWeight == null) this else copy(fontWeight = paint.titleWeight) + +/** Seats a chip or block on [shape]: its fill, plus the border when it has one. */ +fun Modifier.eventSurface( + paint: EventPaint, + shape: Shape, + cuts: ChipCuts = ChipCuts(), +): Modifier { + val filled = background(paint.fill, shape) + val outline = paint.outline ?: return filled + return filled.clip(shape).drawBehind { drawChipOutline(outline, cuts) } +} + +/** + * The border, stroked as one round-rect whose edges run past the chip on every + * side the event continues over. Clipped to the chip's own shape, so those + * strokes — and the corners that would have turned back in — fall outside and + * the edge stays open. + */ +private fun DrawScope.drawChipOutline(color: Color, cuts: ChipCuts) { + val stroke = EVENT_OUTLINE_WIDTH.toPx() + val radius = EVENT_CHIP_CORNER.toPx() + // Far enough out that the corner arc clears the clip too, not just the edge. + val bleed = radius + stroke + val rtl = layoutDirection == LayoutDirection.Rtl + val leftCut = if (rtl) cuts.end else cuts.start + val rightCut = if (rtl) cuts.start else cuts.end + val left = if (leftCut) -bleed else stroke / 2f + val top = if (cuts.top) -bleed else stroke / 2f + val right = if (rightCut) size.width + bleed else size.width - stroke / 2f + val bottom = if (cuts.bottom) size.height + bleed else size.height - stroke / 2f + drawRoundRect( + color = color, + topLeft = Offset(left, top), + size = Size(right - left, bottom - top), + cornerRadius = CornerRadius(radius), + style = Stroke(width = stroke), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 7400e61..5c79f3a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInRoot @@ -604,7 +603,6 @@ const val SETTLE_FADE_MILLIS: Int = 250 fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) { var origin by remember { mutableStateOf(Offset.Zero) } val dark = isSystemInDarkTheme() - val soften = LocalSoftenColors.current val use24Hour = LocalUse24HourFormat.current val locale = currentLocale() val reduceMotion = rememberReduceMotion() @@ -654,7 +652,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = animationSpec = tween(SETTLE_FADE_MILLIS), label = "drag-handover", ) - val fill = eventFill(drag.event.color, dark, soften) + val paint = eventPaint(drag.event, dark) val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) } // Read off the event rather than the held block, so grabbing either half // of an event that crosses midnight names the same hours. An end past @@ -673,8 +671,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = topLeftInRoot = piece.topLeftInRoot, overlayOrigin = origin, sizePx = piece.sizePx, - fill = fill, + paint = paint, shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter), + cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter), lift = lift, alpha = copyAlpha, title = title, @@ -691,8 +690,9 @@ private fun DragCopy( topLeftInRoot: Offset, overlayOrigin: Offset, sizePx: IntSize, - fill: Color, + paint: EventPaint, shape: RoundedCornerShape, + cuts: ChipCuts, lift: Float, alpha: Float, title: String, @@ -722,7 +722,7 @@ private fun DragCopy( this.shape = shape clip = false } - .background(fill, shape) + .eventSurface(paint, shape, cuts) .padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp), ) { Column { @@ -733,7 +733,9 @@ private fun DragCopy( maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, - color = eventInk(fill, alpha = TITLE_INK_ALPHA), + color = paint.titleInk, + fontWeight = paint.titleWeight, + textDecoration = paint.decoration, ) if (label != null) { Text( @@ -743,7 +745,7 @@ private fun DragCopy( maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, - color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), + color = paint.secondaryInk, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 6925fec..53bd0a4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -39,6 +39,7 @@ 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 @@ -69,6 +70,9 @@ 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 @@ -95,6 +99,7 @@ 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.timedBlockCuts import de.jeanlucmakiola.calendula.ui.common.timedBlockShape import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed import de.jeanlucmakiola.calendula.ui.common.eventMoveAction @@ -108,11 +113,7 @@ 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.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.EventChipShape -import de.jeanlucmakiola.calendula.ui.common.eventFill -import de.jeanlucmakiola.calendula.ui.common.eventInk -import de.jeanlucmakiola.calendula.ui.common.declinedDecoration import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat @@ -120,8 +121,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.asEventTime -import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA -import de.jeanlucmakiola.calendula.ui.common.TITLE_INK_ALPHA import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay @@ -373,14 +372,20 @@ internal fun DaySuccess( 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. - AllDayStrip( - state = state, - height = allDayHeight, - onEventClick = onEventClick, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface), - ) + // 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)) @@ -511,11 +516,10 @@ private fun AllDayBar( modifier: Modifier = Modifier, ) { val title = event.title.ifBlank { stringResource(R.string.event_untitled) } - val soften = LocalSoftenColors.current - val fill = eventFill(event.color, dark, soften) + val paint = eventPaint(event, dark) Box( modifier = modifier - .background(fill, EventChipShape) + .eventSurface(paint, EventChipShape) .clickable(onClick = onClick) .padding(horizontal = 6.dp, vertical = 2.dp) .semantics { contentDescription = title }, @@ -528,8 +532,9 @@ private fun AllDayBar( maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, - color = eventInk(fill, alpha = TITLE_INK_ALPHA), - textDecoration = declinedDecoration(event.isDeclined), + color = paint.titleInk, + fontWeight = paint.titleWeight, + textDecoration = paint.decoration, ) } } @@ -760,17 +765,17 @@ private fun EventBlock( } else { 1 } - val soften = LocalSoftenColors.current - val fill = eventFill(block.event.color, dark, soften) + 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(block, date, zone) { - timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone)) + val cuts = remember(block, date, zone) { + timedBlockCuts(block.continuesBefore(date, zone), block.continuesAfter(date, zone)) } + val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) } val dragModifier = rememberEventDragSource( enabled = draggable, key = block.event.instanceId, @@ -787,7 +792,7 @@ private fun EventBlock( modifier = modifier // The source stays put as a ghost while its floating copy travels. .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) - .background(fill, shape) + .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. @@ -804,14 +809,15 @@ private fun EventBlock( title = title, maxLines = titleMaxLines, textWidth = textWidth, - color = eventInk(fill, alpha = TITLE_INK_ALPHA), - textDecoration = declinedDecoration(block.event.isDeclined), + color = paint.titleInk, + textDecoration = paint.decoration, + fontWeight = paint.titleWeight, ) } if (showTime) { BlockTimeLabel( label = timeLabel, - color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), + color = paint.secondaryInk, maxLines = timeMaxLines, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 5328653..4b5d54b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -88,6 +88,9 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.geometry.Offset +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 de.jeanlucmakiola.calendula.ui.common.rememberDragSurface import de.jeanlucmakiola.calendula.ui.common.eventMoveAction @@ -144,18 +147,13 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha -import de.jeanlucmakiola.calendula.ui.common.declinedDecoration import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat -import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA -import de.jeanlucmakiola.calendula.ui.common.TITLE_INK_ALPHA import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.monthBarShape -import de.jeanlucmakiola.calendula.ui.common.eventFill -import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition @@ -2513,14 +2511,13 @@ private fun MonthBar( val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val dimCutoff = LocalDimCutoff.current val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) - val soften = LocalSoftenColors.current - val fill = eventFill(event.color, dark, soften) + val paint = eventPaint(event, dark) // The same title/secondary ink pairing the week and day blocks use, with // the time on the quieter half. val label = inlineTimeLabel( time = time.takeIf { showTime }, title = title, - timeInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA), + timeInk = paint.secondaryInk, ) // Announced whether or not it is drawn, and comma-separated as the week and // day blocks do it: what a screen reader hears shouldn't turn on how wide @@ -2536,7 +2533,7 @@ private fun MonthBar( Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) - .background(fill, shape) + .eventSurface(paint, shape, monthBarCuts(continuesLeft, continuesRight)) .padding(horizontal = MONTH_CHIP_TEXT_PADDING) .semantics { contentDescription = description @@ -2551,8 +2548,9 @@ private fun MonthBar( maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, - color = eventInk(fill, alpha = TITLE_INK_ALPHA), - textDecoration = declinedDecoration(event.isDeclined), + color = paint.titleInk, + fontWeight = paint.titleWeight, + textDecoration = paint.decoration, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 81735d8..a7a70a9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays @@ -439,14 +440,18 @@ internal fun layoutCalendarWeek( days = days, spans = spans, timedByDay = days.associateWith { d -> - singles.filter { it.coversDay(d, zone) }.sortedBy { it.start } + // Declined last, so a day that overflows drops the events you said + // no to before the ones you are actually going to (#230). + singles.filter { it.coversDay(d, zone) } + .sortedWith(compareBy { it.isDeclined }.thenBy { it.start }) }, countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } }, ) } /** - * Every event touching each of [days], all-day first then by start time. Unlike + * Every event touching each of [days], declined ones last (#230), then all-day + * first and by start time within each group. Unlike * [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date * they cover and applies no display cap, so the split style's day pane can list a * date in full without querying the provider again. @@ -459,7 +464,11 @@ internal fun instancesByDay( days.associateWith { day -> instances .filter { it.coversDay(day, zone) } - .sortedWith(compareByDescending { it.isAllDay }.thenBy { it.start }) + .sortedWith( + compareBy { it.isDeclined } + .thenByDescending { it.isAllDay } + .thenBy { it.start }, + ) } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 68dfcda..e3333c8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -82,6 +82,7 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.SearchHit import de.jeanlucmakiola.calendula.domain.SearchMonth +import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.predictiveBack diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 444278e..740dd7f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -77,6 +77,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.hasEnded +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 @@ -86,7 +89,6 @@ 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.EventDimAlpha -import de.jeanlucmakiola.calendula.ui.common.declinedDecoration 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 @@ -104,6 +106,7 @@ 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.timedBlockCuts import de.jeanlucmakiola.calendula.ui.common.timedBlockShape import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed import de.jeanlucmakiola.calendula.ui.common.eventMoveAction @@ -112,16 +115,14 @@ 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.LocalDimCutoff -import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.EventChipShape -import de.jeanlucmakiola.calendula.ui.common.eventFill -import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute 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.calendula.ui.common.withTitleWeight import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat @@ -130,8 +131,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH import de.jeanlucmakiola.calendula.ui.common.asEventTime -import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA -import de.jeanlucmakiola.calendula.ui.common.TITLE_INK_ALPHA import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay @@ -414,7 +413,13 @@ internal fun WeekSuccess( .background(MaterialTheme.colorScheme.surface), ) { WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay) - AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick) + // This strip is painted on `surface`, so an outlined chip has to + // fill with that and not with the columns' container (#230). + CompositionLocalProvider( + LocalChipGround provides MaterialTheme.colorScheme.surface, + ) { + AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick) + } } // Breathing room between the top section and the scrolling timeline // below. @@ -646,11 +651,10 @@ private fun AllDayBar( val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val dimCutoff = LocalDimCutoff.current val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) - val soften = LocalSoftenColors.current - val fill = eventFill(event.color, dark, soften) + val paint = eventPaint(event, dark) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) - .background(fill, EventChipShape) + .eventSurface(paint, EventChipShape) .clickable(onClick = onClick) .padding(horizontal = 6.dp, vertical = 2.dp) .semantics { contentDescription = title }, @@ -663,8 +667,9 @@ private fun AllDayBar( maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, - color = eventInk(fill, alpha = TITLE_INK_ALPHA), - textDecoration = declinedDecoration(event.isDeclined), + color = paint.titleInk, + fontWeight = paint.titleWeight, + textDecoration = paint.decoration, ) } } @@ -900,12 +905,15 @@ private fun EventBlock( // syllable. Below that the extra lines just stack fragments of the word. val contentHeight = available - if (showTime) timeLineHeight else 0.dp val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) + val paint = eventPaint(block.event, dark) val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) { 1 } else { blockTextLines( text = title, - style = MaterialTheme.typography.labelMedium, + // At the weight BlockTitle will set it in, or an invited block — + // drawn a weight lighter — is budgeted a line it never fills (#230). + style = MaterialTheme.typography.labelMedium.withTitleWeight(paint), textWidth = textWidth, max = titleBudget, ) @@ -929,17 +937,16 @@ private fun EventBlock( } val dimCutoff = LocalDimCutoff.current val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) - val soften = LocalSoftenColors.current - val fill = eventFill(block.event.color, dark, soften) 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(block, date, zone) { - timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone)) + val cuts = remember(block, date, zone) { + timedBlockCuts(block.continuesBefore(date, zone), block.continuesAfter(date, zone)) } + val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) } val dragModifier = rememberEventDragSource( enabled = draggable, key = block.event.instanceId, @@ -956,7 +963,7 @@ private fun EventBlock( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) // The source stays put as a ghost while its floating copy travels. .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) - .background(fill, shape) + .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. @@ -973,14 +980,15 @@ private fun EventBlock( title = title, maxLines = titleMaxLines, textWidth = textWidth, - color = eventInk(fill, alpha = TITLE_INK_ALPHA), - textDecoration = declinedDecoration(block.event.isDeclined), + color = paint.titleInk, + textDecoration = paint.decoration, + fontWeight = paint.titleWeight, ) } if (showTime) { BlockTimeLabel( label = timeLabel, - color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), + color = paint.secondaryInk, maxLines = timeMaxLines, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 81a3b49..26ca439 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.domain.isDeclined import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -144,7 +145,7 @@ class WeekViewModel @Inject constructor( /** * 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; - * overlapping spans are stacked on separate lanes (greedy first-fit by start). + * overlapping spans are stacked on separate lanes (greedy first-fit). */ internal fun layoutAllDay( events: List, @@ -158,21 +159,35 @@ internal fun layoutAllDay( val covered = days.indices.filter { ev.coversDay(days[it], zone) } if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last()) } - .sortedWith(compareBy({ it.startCol }, { it.endCol })) + // 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 })) - val laneEnd = ArrayList() // last occupied column per lane + // What each lane already holds, rather than just how far right it reaches. + // A single "last occupied column" only answers correctly while spans arrive + // in non-decreasing start order, which the declined-last rule above breaks: + // a Monday bar seated after a Wednesday one would be refused a lane it is + // nowhere near, and each wasted lane costs the all-day strip a whole row and + // pushes a bar closer to the month grid's MAX_EVENT_ROWS cap. Seven columns + // and a handful of bars, so the scan is cheaper than the sort above it. + val laneCols = ArrayList>() return raw.map { r -> - var lane = laneEnd.indexOfFirst { it < r.startCol } + val cols = r.startCol..r.endCol + var lane = laneCols.indexOfFirst { seated -> seated.none { it overlaps cols } } if (lane == -1) { - laneEnd.add(r.endCol) - lane = laneEnd.size - 1 + laneCols.add(mutableListOf(cols)) + lane = laneCols.size - 1 } else { - laneEnd[lane] = r.endCol + laneCols[lane].add(cols) } AllDaySpan(r.event, r.startCol, r.endCol, lane) } } +/** Whether two column ranges share a column, so they cannot share a lane. */ +private infix fun IntRange.overlaps(other: IntRange): Boolean = + first <= other.last && other.first <= last + /** Beginning of the week (at [weekStart]) that contains this date. */ internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate { // DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 622d846..930c1eb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.widget.agenda +import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import android.content.res.Configuration diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 353db0c..43455e6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.widget.month +import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import android.content.res.Configuration diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt index 1c7dd1b..e52f384 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt @@ -2,6 +2,8 @@ package de.jeanlucmakiola.calendula.data.calendar import android.provider.CalendarContract import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventResponse +import de.jeanlucmakiola.calendula.domain.isDeclined import kotlin.time.Instant import org.junit.jupiter.api.Test @@ -109,4 +111,22 @@ class InstanceMapperTest { assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse() } } + + @Test + fun `an unanswered invitation reads as invited, an answered one does not`() { + assertThat( + reader(selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_INVITED) + .toEventInstance()!!.response, + ).isEqualTo(EventResponse.Invited) + mapOf( + CalendarContract.Attendees.ATTENDEE_STATUS_NONE to EventResponse.Going, + CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED to EventResponse.Going, + // "Maybe" is still an answer, so it keeps the solid chip (#230). + CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE to EventResponse.Going, + CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED to EventResponse.Declined, + ).forEach { (status, expected) -> + assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.response) + .isEqualTo(expected) + } + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt index e4fc21d..2cddeae 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.EventResponse import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate @@ -267,4 +268,70 @@ class MonthLayoutTest { assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th) assertThat(byDay.values.flatten()).isEmpty() } + + @Test + fun `a declined event sorts behind every answered one on its day`() { + val day = LocalDate(2026, 6, 10) + // Earliest of the three, and all-day on top of that, so only the + // declined ordering can put it last. + val declined = allDay(day, id = 1L, title = "Declined") + .copy(response = EventResponse.Declined) + val morning = timed(day, 9, 10, id = 2L, title = "Morning") + val evening = timed(day, 18, 19, id = 3L, title = "Evening") + val events = listOf(declined, morning, evening) + + assertThat(instancesByDay(listOf(day), events, zone).getValue(day).map { it.title }) + .containsExactly("Morning", "Evening", "Declined").inOrder() + + val timedOnly = + layoutCalendarWeek(weekOf8th, listOf(evening, morning, declinedTimed(day)), zone) + assertThat(timedOnly.timedByDay.getValue(day).map { it.title }) + .containsExactly("Morning", "Evening", "Declined early").inOrder() + } + + @Test + fun `a declined bar packed last still shares a lane it does not overlap`() { + // Sorting declined last means this bar is seated after one that reaches + // further right than it starts. A lane must still be offered on the + // columns it is actually free on, or the row grows a wasted rank. + val mon = weekOf8th[0] + val declined = allDay(mon, id = 1L, title = "Declined") + .copy(response = EventResponse.Declined) + val going = allDay(weekOf8th[2], weekOf8th[3], id = 2L, title = "Going") + val week = layoutCalendarWeek(weekOf8th, listOf(declined, going), zone) + + assertThat(week.spans.map { it.lane }).containsExactly(0, 0) + } + + @Test + fun `a declined all-day bar takes a lane below the ones you answered`() { + val day = LocalDate(2026, 6, 10) + val declined = + allDay(day, id = 1L, title = "Declined").copy(response = EventResponse.Declined) + val going = allDay(day, id = 2L, title = "Going") + // Declined listed first: only the sort, not the input order, may decide. + val week = layoutCalendarWeek(weekOf8th, listOf(declined, going), zone) + + val lanes = week.spans.associate { it.event.title to it.lane } + assertThat(lanes.getValue("Going")).isLessThan(lanes.getValue("Declined")) + } + + @Test + fun `overlapping bars never share a lane, whatever order they arrive in`() { + val a = allDay(weekOf8th[0], weekOf8th[3], id = 1L, title = "A") + val b = allDay(weekOf8th[2], weekOf8th[5], id = 2L, title = "B") + val c = allDay(weekOf8th[3], weekOf8th[4], id = 3L, title = "C") + .copy(response = EventResponse.Declined) + val lanes = layoutCalendarWeek(weekOf8th, listOf(c, b, a), zone) + .spans.associate { it.event.title to it.lane } + + assertThat(lanes.getValue("A")).isNotEqualTo(lanes.getValue("B")) + assertThat(lanes.getValue("B")).isNotEqualTo(lanes.getValue("C")) + assertThat(lanes.getValue("A")).isNotEqualTo(lanes.getValue("C")) + } + + /** A declined timed event starting before every other one in its test. */ + private fun declinedTimed(date: LocalDate) = + timed(date, 7, 8, id = 4L, title = "Declined early") + .copy(response = EventResponse.Declined) }