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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EventInstance> { 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<EventInstance> { it.isAllDay }.thenBy { it.start })
|
||||
.sortedWith(
|
||||
compareBy<EventInstance> { it.isDeclined }
|
||||
.thenByDescending { it.isAllDay }
|
||||
.thenBy { it.start },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<EventInstance>,
|
||||
@@ -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<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 ->
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user