From 76603be0b3d9d394dd1876706ee609c60e041da1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 4 Sep 2026 16:45:07 +0200 Subject: [PATCH] Draw an unanswered invitation as an outline (#230) (#268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Events waiting on your answer now read differently from the ones you have answered. `EventInstance.isDeclined` becomes an `EventResponse` enum — `Going` / `Invited` / `Declined` — so there is one source of truth for the column; `isDeclined` stays as an extension property, so the surfaces that only care about that case are untouched. A tentative "maybe" counts as `Going`. An unanswered invitation is drawn as an outline: the calendar's colour on the border and on the title, at regular weight, filled with the surface the chip sits on. The outline leaves open whichever edges the event runs past, so a bar carried across a week boundary stays one bar. The accent is harmonised even when soften-colours is off — raw mode exists so a filled container matches the sync source, and an outlined chip has no container, only coloured text, which needs its lightness pinned or a pale calendar goes unreadable. Applied to the month chips, the week and day all-day bars, both timed blocks and the drag copy. Declined events also sort last in the month view now: the timed chips, the split style's day pane, and the all-day bars, which pack into a lane below the ones you answered. A day that overflows drops the events you said no to first. `layoutAllDay` had to stop deciding lanes from how far right each one reaches and track the columns it holds instead, or packing declined last would waste a lane. ### Deviations from the issue The issue asked for declined invitations to be outlined too, with the title crossed out. They keep their filled container instead: at chip size the strike-through needs something under it to read against, and on a hollow chip it was illegible. Declined is still distinguished — filled and struck through, against filled and plain — and the sort order is the extra separation it gained. Not covered, and each needs its own mark rather than this one: the agenda rows, search results and the split view's day pane carry a 6dp colour stripe rather than a filled chip, and both widgets are Glance, which has no border modifier. All four still mark declined only. Closes #230 Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/268 --- .../calendula/data/calendar/InstanceMapper.kt | 15 +- .../calendula/data/calendar/SearchMapper.kt | 4 +- .../jeanlucmakiola/calendula/domain/Models.kt | 31 +++- .../calendula/ui/agenda/AgendaRows.kt | 1 + .../calendula/ui/common/BlockPlacement.kt | 5 + .../calendula/ui/common/EventPaint.kt | 165 ++++++++++++++++++ .../calendula/ui/common/TimelineDrag.kt | 18 +- .../calendula/ui/day/DayScreen.kt | 60 ++++--- .../calendula/ui/month/MonthScreen.kt | 20 +-- .../calendula/ui/month/MonthViewModel.kt | 15 +- .../calendula/ui/search/SearchScreen.kt | 1 + .../calendula/ui/week/WeekScreen.kt | 50 +++--- .../calendula/ui/week/WeekViewModel.kt | 29 ++- .../calendula/widget/agenda/AgendaWidget.kt | 1 + .../calendula/widget/month/MonthWidget.kt | 1 + .../data/calendar/InstanceMapperTest.kt | 20 +++ .../calendula/ui/month/MonthLayoutTest.kt | 67 +++++++ 17 files changed, 416 insertions(+), 87 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventPaint.kt 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) }