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:
@@ -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