Draw an unanswered invitation as an outline (#230) (#268)

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 <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/268
This commit is contained in:
Jean-Luc Makiola
2026-09-04 16:45:07 +02:00
parent a4d77109fb
commit 76603be0b3
17 changed files with 416 additions and 87 deletions

View File

@@ -4,6 +4,7 @@ import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.provider.CalendarContract import android.provider.CalendarContract
import android.util.Log import android.util.Log
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
private const val TAG = "InstanceMapper" private const val TAG = "InstanceMapper"
@@ -39,7 +40,17 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0, isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
color = color, color = color,
location = getString(InstanceProjection.IDX_LOCATION), location = getString(InstanceProjection.IDX_LOCATION),
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) == response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
) )
} }
/**
* `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
}

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
@@ -43,7 +42,6 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
location = getString(SearchProjection.IDX_LOCATION), location = getString(SearchProjection.IDX_LOCATION),
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() || isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(), !getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) == response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
) )
} }

View File

@@ -68,14 +68,35 @@ data class EventInstance(
*/ */
val isRecurring: Boolean = false, val isRecurring: Boolean = false,
/** /**
* This device user answered "no" to the invitation * This device user's own answer to the invitation, as far as the grids care
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is * (#180, #230).
* still an appointment someone expects an answer about — but every surface
* strikes it through, and it plans no reminders (#180).
*/ */
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 * 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) * the current instant. An in-progress event (already started but not yet ended)

View File

@@ -29,6 +29,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance 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.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedTitle import de.jeanlucmakiola.calendula.ui.common.declinedTitle
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors

View File

@@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -81,8 +82,12 @@ fun BlockTitle(
color: Color, color: Color,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
textDecoration: TextDecoration? = null, 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 val style = MaterialTheme.typography.labelMedium
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) }
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val measurer = rememberTextMeasurer() val measurer = rememberTextMeasurer()
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() } val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }

View File

@@ -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),
)
}

View File

@@ -25,7 +25,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInRoot
@@ -604,7 +603,6 @@ const val SETTLE_FADE_MILLIS: Int = 250
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) { fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) } var origin by remember { mutableStateOf(Offset.Zero) }
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val use24Hour = LocalUse24HourFormat.current val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale() val locale = currentLocale()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
@@ -654,7 +652,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
animationSpec = tween(SETTLE_FADE_MILLIS), animationSpec = tween(SETTLE_FADE_MILLIS),
label = "drag-handover", 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) } val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
// Read off the event rather than the held block, so grabbing either half // 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 // 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, topLeftInRoot = piece.topLeftInRoot,
overlayOrigin = origin, overlayOrigin = origin,
sizePx = piece.sizePx, sizePx = piece.sizePx,
fill = fill, paint = paint,
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter), shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter),
lift = lift, lift = lift,
alpha = copyAlpha, alpha = copyAlpha,
title = title, title = title,
@@ -691,8 +690,9 @@ private fun DragCopy(
topLeftInRoot: Offset, topLeftInRoot: Offset,
overlayOrigin: Offset, overlayOrigin: Offset,
sizePx: IntSize, sizePx: IntSize,
fill: Color, paint: EventPaint,
shape: RoundedCornerShape, shape: RoundedCornerShape,
cuts: ChipCuts,
lift: Float, lift: Float,
alpha: Float, alpha: Float,
title: String, title: String,
@@ -722,7 +722,7 @@ private fun DragCopy(
this.shape = shape this.shape = shape
clip = false clip = false
} }
.background(fill, shape) .eventSurface(paint, shape, cuts)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp), .padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp),
) { ) {
Column { Column {
@@ -733,7 +733,9 @@ private fun DragCopy(
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap, softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = TITLE_INK_ALPHA), color = paint.titleInk,
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
) )
if (label != null) { if (label != null) {
Text( Text(
@@ -743,7 +745,7 @@ private fun DragCopy(
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap, softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), color = paint.secondaryInk,
) )
} }
} }

View File

@@ -39,6 +39,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
@@ -69,6 +70,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance 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.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton 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.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore 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.timedBlockShape
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction 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.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next 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.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.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat 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.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.asEventTime 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.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -373,6 +372,11 @@ internal fun DaySuccess(
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events, // All-day strip collapses to nothing when the day has no all-day events,
// so the timeline sits directly under the app bar. // so the timeline sits directly under the app bar.
// This strip is painted on `surface`, so an outlined chip has to fill
// with that and not with the column's container (#230).
CompositionLocalProvider(
LocalChipGround provides MaterialTheme.colorScheme.surface,
) {
AllDayStrip( AllDayStrip(
state = state, state = state,
height = allDayHeight, height = allDayHeight,
@@ -381,6 +385,7 @@ internal fun DaySuccess(
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface), .background(MaterialTheme.colorScheme.surface),
) )
}
// Breathing room between the top section and the scrolling timeline // Breathing room between the top section and the scrolling timeline
// below. // below.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -511,11 +516,10 @@ private fun AllDayBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val soften = LocalSoftenColors.current val paint = eventPaint(event, dark)
val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = modifier modifier = modifier
.background(fill, EventChipShape) .eventSurface(paint, EventChipShape)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title }, .semantics { contentDescription = title },
@@ -528,8 +532,9 @@ private fun AllDayBar(
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap, softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = TITLE_INK_ALPHA), color = paint.titleInk,
textDecoration = declinedDecoration(event.isDeclined), fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
) )
} }
} }
@@ -760,17 +765,17 @@ private fun EventBlock(
} else { } else {
1 1
} }
val soften = LocalSoftenColors.current val paint = eventPaint(block.event, dark)
val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event) val draggable = eventDragAllowed(block.event)
// The drop takes this offset back off, so a tail clipped at midnight lands // The drop takes this offset back off, so a tail clipped at midnight lands
// where the event's own start belongs (#253). // where the event's own start belongs (#253).
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) } val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(block, date, zone) { val cuts = remember(block, date, zone) {
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone)) timedBlockCuts(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
} }
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
val dragModifier = rememberEventDragSource( val dragModifier = rememberEventDragSource(
enabled = draggable, enabled = draggable,
key = block.event.instanceId, key = block.event.instanceId,
@@ -787,7 +792,7 @@ private fun EventBlock(
modifier = modifier modifier = modifier
// The source stays put as a ghost while its floating copy travels. // The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, shape) .eventSurface(paint, shape, cuts)
.clickable(onClick = onClick) .clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up. // the tap still works, since a drag consumes the up.
@@ -804,14 +809,15 @@ private fun EventBlock(
title = title, title = title,
maxLines = titleMaxLines, maxLines = titleMaxLines,
textWidth = textWidth, textWidth = textWidth,
color = eventInk(fill, alpha = TITLE_INK_ALPHA), color = paint.titleInk,
textDecoration = declinedDecoration(block.event.isDeclined), textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
) )
} }
if (showTime) { if (showTime) {
BlockTimeLabel( BlockTimeLabel(
label = timeLabel, label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), color = paint.secondaryInk,
maxLines = timeMaxLines, maxLines = timeMaxLines,
) )
} }

View File

@@ -88,6 +88,9 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.geometry.Offset 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 kotlin.math.roundToInt
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction 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.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha 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.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat 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.inlineTimeLabel
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.monthBarShape 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.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition 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 title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current val paint = eventPaint(event, dark)
val fill = eventFill(event.color, dark, soften)
// The same title/secondary ink pairing the week and day blocks use, with // The same title/secondary ink pairing the week and day blocks use, with
// the time on the quieter half. // the time on the quieter half.
val label = inlineTimeLabel( val label = inlineTimeLabel(
time = time.takeIf { showTime }, time = time.takeIf { showTime },
title = title, 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 // 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 // day blocks do it: what a screen reader hears shouldn't turn on how wide
@@ -2536,7 +2533,7 @@ private fun MonthBar(
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.then(if (ghost < 1f) Modifier.alpha(ghost) 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) .padding(horizontal = MONTH_CHIP_TEXT_PADDING)
.semantics { .semantics {
contentDescription = description contentDescription = description
@@ -2551,8 +2548,9 @@ private fun MonthBar(
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap, softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = TITLE_INK_ALPHA), color = paint.titleInk,
textDecoration = declinedDecoration(event.isDeclined), fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
) )
} }
} }

View File

@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason 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.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
@@ -439,14 +440,18 @@ internal fun layoutCalendarWeek(
days = days, days = days,
spans = spans, spans = spans,
timedByDay = days.associateWith { d -> 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<EventInstance> { it.isDeclined }.thenBy { it.start })
}, },
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } }, 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 * [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 * 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. * date in full without querying the provider again.
@@ -459,7 +464,11 @@ internal fun instancesByDay(
days.associateWith { day -> days.associateWith { day ->
instances instances
.filter { it.coversDay(day, zone) } .filter { it.coversDay(day, zone) }
.sortedWith(compareByDescending<EventInstance> { it.isAllDay }.thenBy { it.start }) .sortedWith(
compareBy<EventInstance> { it.isDeclined }
.thenByDescending { it.isAllDay }
.thenBy { it.start },
)
} }
/** /**

View File

@@ -82,6 +82,7 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack

View File

@@ -77,6 +77,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded 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.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton 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.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha 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_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel 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.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore 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.timedBlockShape
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction 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.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff 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.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.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat 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_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
import de.jeanlucmakiola.calendula.ui.common.asEventTime 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.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -414,8 +413,14 @@ internal fun WeekSuccess(
.background(MaterialTheme.colorScheme.surface), .background(MaterialTheme.colorScheme.surface),
) { ) {
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay) WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
// 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) AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
} }
}
// Breathing room between the top section and the scrolling timeline // Breathing room between the top section and the scrolling timeline
// below. // below.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -646,11 +651,10 @@ private fun AllDayBar(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current val paint = eventPaint(event, dark)
val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(fill, EventChipShape) .eventSurface(paint, EventChipShape)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title }, .semantics { contentDescription = title },
@@ -663,8 +667,9 @@ private fun AllDayBar(
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap, softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = TITLE_INK_ALPHA), color = paint.titleInk,
textDecoration = declinedDecoration(event.isDeclined), 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. // syllable. Below that the extra lines just stack fragments of the word.
val contentHeight = available - if (showTime) timeLineHeight else 0.dp val contentHeight = available - if (showTime) timeLineHeight else 0.dp
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val paint = eventPaint(block.event, dark)
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) { val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
1 1
} else { } else {
blockTextLines( blockTextLines(
text = title, 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, textWidth = textWidth,
max = titleBudget, max = titleBudget,
) )
@@ -929,17 +937,16 @@ private fun EventBlock(
} }
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) 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 zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event) val draggable = eventDragAllowed(block.event)
// The drop takes this offset back off, so a tail clipped at midnight lands // The drop takes this offset back off, so a tail clipped at midnight lands
// where the event's own start belongs (#253). // where the event's own start belongs (#253).
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) } val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(block, date, zone) { val cuts = remember(block, date, zone) {
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone)) timedBlockCuts(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
} }
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
val dragModifier = rememberEventDragSource( val dragModifier = rememberEventDragSource(
enabled = draggable, enabled = draggable,
key = block.event.instanceId, key = block.event.instanceId,
@@ -956,7 +963,7 @@ private fun EventBlock(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
// The source stays put as a ghost while its floating copy travels. // The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, shape) .eventSurface(paint, shape, cuts)
.clickable(onClick = onClick) .clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up. // the tap still works, since a drag consumes the up.
@@ -973,14 +980,15 @@ private fun EventBlock(
title = title, title = title,
maxLines = titleMaxLines, maxLines = titleMaxLines,
textWidth = textWidth, textWidth = textWidth,
color = eventInk(fill, alpha = TITLE_INK_ALPHA), color = paint.titleInk,
textDecoration = declinedDecoration(block.event.isDeclined), textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
) )
} }
if (showTime) { if (showTime) {
BlockTimeLabel( BlockTimeLabel(
label = timeLabel, label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), color = paint.secondaryInk,
maxLines = timeMaxLines, maxLines = timeMaxLines,
) )
} }

View File

@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.isDeclined
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow 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. * Lay out all-day events as connected horizontal spans across the visible week.
* Each event becomes one [AllDaySpan] from its first to its last covered column; * Each event becomes one [AllDaySpan] from its first to its last covered column;
* 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( internal fun layoutAllDay(
events: List<EventInstance>, events: List<EventInstance>,
@@ -158,21 +159,35 @@ internal fun layoutAllDay(
val covered = days.indices.filter { ev.coversDay(days[it], zone) } val covered = days.indices.filter { ev.coversDay(days[it], zone) }
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last()) 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<Int>() // 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<MutableList<IntRange>>()
return raw.map { r -> 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) { if (lane == -1) {
laneEnd.add(r.endCol) laneCols.add(mutableListOf(cols))
lane = laneEnd.size - 1 lane = laneCols.size - 1
} else { } else {
laneEnd[lane] = r.endCol laneCols[lane].add(cols)
} }
AllDaySpan(r.event, r.startCol, r.endCol, lane) 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. */ /** Beginning of the week (at [weekStart]) that contains this date. */
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate { internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering. // DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.widget.agenda package de.jeanlucmakiola.calendula.widget.agenda
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.widget.month package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration

View File

@@ -2,6 +2,8 @@ package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventResponse
import de.jeanlucmakiola.calendula.domain.isDeclined
import kotlin.time.Instant import kotlin.time.Instant
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -109,4 +111,22 @@ class InstanceMapperTest {
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse() 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)
}
}
} }

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -267,4 +268,70 @@ class MonthLayoutTest {
assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th) assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th)
assertThat(byDay.values.flatten()).isEmpty() 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)
} }