Compare commits

...

17 Commits

Author SHA1 Message Date
Jean-Luc Makiola
2bf7d00f39 Shrink the month widget's RemoteViews so it stops killing the widget host (#214) (#215)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/215
2026-08-17 20:55:40 +02:00
7431a7390e Keep SizeMode.Exact: Single and Responsive never reflow on resize (#214)
GlanceAppWidget.resize returns early for both, so the grid was never recomposed
for a new size and a resized widget clipped instead of reflowing. Exact costs one
serialised copy per host size, which the view cuts have made affordable again.

Drops the options-derived width that went with Single; LocalSize is exact here.
2026-08-17 19:56:20 +02:00
a0a559648e Shrink the month widget's RemoteViews so it stops killing the widget host (#214)
The grid rendered ~740 views and SizeMode.Exact serialised a full copy per host
size, so one update was a 450-540 KB oneway binder push. Three of those overran
the launcher's 1 MB async buffer, and AppWidgetServiceImpl dropped the whole
host on the TransactionTooLargeException — which is why the arrows went dead
after a couple of taps and only came back when the launcher rebound.

One composition sized from the host's own options, day-open taps moved to a
single strip per column, day numbers as bare text, and no views for lanes or a
"+N" row a week doesn't fill. 742 views -> 192.
2026-08-17 19:30:49 +02:00
1e9eca268e Restore the #180 merge dropped by a merge race (#210) 2026-08-17 18:26:19 +02:00
Jean-Luc Makiola
187b4743df Re-read the event detail when the same occurrence is reopened (#196) (#206)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/206
2026-08-17 18:15:53 +02:00
Jean-Luc Makiola
b61dfa441c Give the day and week timelines an end inset (#192) (#207)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/207
2026-08-17 18:15:18 +02:00
Jean-Luc Makiola
9f6659a2a3 Make the seamless stream's header gap a margin, not content padding (#191) (#208)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/208
2026-08-17 18:14:56 +02:00
Jean-Luc Makiola
ddc5d56cc3 Open the event when a month chip is tapped (#187) (#209)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/209
2026-08-17 18:14:38 +02:00
Jean-Luc Makiola
b4f977214a Mark declined invitations and stop planning their reminders (#180) (#210)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/210
2026-08-17 18:14:04 +02:00
Jean-Luc Makiola
02f697faf7 Redraw the tapped widget by its own id, not updateAll (#18) (#211)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/211
2026-08-17 18:13:49 +02:00
2dd81f1327 Redraw the tapped widget by its own id, not updateAll (#18) 2026-08-17 17:39:48 +02:00
bc805a9a3a Mark declined invitations and stop planning their reminders (#180) 2026-08-17 17:33:59 +02:00
8abd3afdff Open the event when a month chip is tapped (#187) 2026-08-17 17:24:39 +02:00
d45f63b9fa Make the seamless stream's header gap a margin, not content padding (#191) 2026-08-17 17:16:32 +02:00
c7b3ff2d6f Give the day and week timelines an end inset (#192) 2026-08-17 17:11:42 +02:00
61ccf5f247 Re-read the event detail when the same occurrence is reopened (#196) 2026-08-17 17:07:33 +02:00
558aa2e69b Start 2.19.2 2026-08-17 17:00:55 +02:00
21 changed files with 668 additions and 112 deletions

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag + // which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21901 versionCode = 21902
versionName = "2.19.1" versionName = "2.19.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.provider.CalendarContract
import android.util.Log import android.util.Log
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
@@ -38,5 +39,7 @@ 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) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
) )
} }

View File

@@ -52,6 +52,7 @@ internal object InstanceProjection {
CalendarContract.Instances.EVENT_COLOR, CalendarContract.Instances.EVENT_COLOR,
CalendarContract.Instances.CALENDAR_COLOR, CalendarContract.Instances.CALENDAR_COLOR,
CalendarContract.Instances.EVENT_LOCATION, CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.SELF_ATTENDEE_STATUS,
) )
const val IDX_INSTANCE_ID = 0 const val IDX_INSTANCE_ID = 0
@@ -64,6 +65,7 @@ internal object InstanceProjection {
const val IDX_EVENT_COLOR = 7 const val IDX_EVENT_COLOR = 7
const val IDX_CALENDAR_COLOR = 8 const val IDX_CALENDAR_COLOR = 8
const val IDX_LOCATION = 9 const val IDX_LOCATION = 9
const val IDX_SELF_ATTENDEE_STATUS = 10
} }
internal object EventDetailProjection { internal object EventDetailProjection {
@@ -182,6 +184,7 @@ internal object SearchProjection {
CalendarContract.Events.RDATE, CalendarContract.Events.RDATE,
// Excerpted, not just filtered on: a hit has to show what it matched. // Excerpted, not just filtered on: a hit has to show what it matched.
CalendarContract.Events.DESCRIPTION, CalendarContract.Events.DESCRIPTION,
CalendarContract.Events.SELF_ATTENDEE_STATUS,
) )
const val IDX_ID = 0 const val IDX_ID = 0
@@ -197,6 +200,7 @@ internal object SearchProjection {
const val IDX_RRULE = 10 const val IDX_RRULE = 10
const val IDX_RDATE = 11 const val IDX_RDATE = 11
const val IDX_DESCRIPTION = 12 const val IDX_DESCRIPTION = 12
const val IDX_SELF_ATTENDEE_STATUS = 13
} }
/** /**

View File

@@ -1,5 +1,6 @@
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
@@ -42,5 +43,7 @@ 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) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
) )
} }

View File

@@ -44,9 +44,16 @@ class ProviderReminderInstanceSource @Inject constructor(
// `visible` is the flag the app's one visibility model writes (#75). // `visible` is the flag the app's one visibility model writes (#75).
// The status clause mirrors CalendarDataSource.instances: NULL means // The status clause mirrors CalendarDataSource.instances: NULL means
// "normal", so a bare `!= CANCELED` would drop every ordinary event. // "normal", so a bare `!= CANCELED` would drop every ordinary event.
// An invitation the user declined is answered — it stays on the calendar
// struck through, but it plans nothing (#180). NULL again means "no
// answer recorded", which is not a "no".
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " + val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
"(${CalendarContract.Instances.STATUS} IS NULL OR " + "(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})" "${CalendarContract.Instances.STATUS} != " +
"${CalendarContract.Events.STATUS_CANCELED}) AND " +
"(${CalendarContract.Instances.SELF_ATTENDEE_STATUS} IS NULL OR " +
"${CalendarContract.Instances.SELF_ATTENDEE_STATUS} != " +
"${CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED})"
return context.contentResolver.query( return context.contentResolver.query(
uri, OCCURRENCE_PROJECTION, selection, null, null, uri, OCCURRENCE_PROJECTION, selection, null, null,
)?.use { c -> )?.use { c ->

View File

@@ -67,6 +67,13 @@ data class EventInstance(
* Instances query already yields one row per occurrence. * Instances query already yields one row per occurrence.
*/ */
val isRecurring: Boolean = false, 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).
*/
val isDeclined: Boolean = false,
) )
/** /**

View File

@@ -24,11 +24,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign 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.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
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.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
@@ -129,8 +131,8 @@ internal fun AgendaEventRow(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow( GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = title, title = declinedTitle(title, event.isDeclined),
summary = agendaTimeSummary(event, day, zone), summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
position = position, position = position,
minHeight = 64.dp, minHeight = 64.dp,
leading = { leading = {

View File

@@ -36,6 +36,14 @@ val GUTTER_WIDTH = 48.dp
*/ */
val GUTTER_CONTENT_START_INSET = 8.dp val GUTTER_CONTENT_START_INSET = 8.dp
/**
* End inset for everything that lines up with the day columns — the week's day
* header, the all-day strip and the timeline itself. Without it the last column
* runs flush into the screen edge while the gutter gives the other side room
* (#192); 8dp mirrors [GUTTER_CONTENT_START_INSET].
*/
val TIMELINE_CONTENT_END_INSET = 8.dp
private val BADGE_HEIGHT = 20.dp private val BADGE_HEIGHT = 20.dp
/** How far the fixed hour labels recede while a block is being dragged. */ /** How far the fixed hour labels recede while a block is being dragged. */

View File

@@ -1,6 +1,10 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -14,3 +18,30 @@ val LocalDimCutoff = compositionLocalOf<Instant?> { null }
/** Opacity applied to a completed/past event chip when it is dimmed. */ /** Opacity applied to a completed/past event chip when it is dimmed. */
const val EventDimAlpha = 0.4f const val EventDimAlpha = 0.4f
/**
* How a declined invitation's title is struck through (#180) — the mark every
* surface uses to say "you answered no", chosen over hiding the event because it
* is still something the organiser expects you at. Null for everything else, so
* it drops straight into a `Text`'s `textDecoration`.
*/
fun declinedDecoration(isDeclined: Boolean): TextDecoration? =
if (isDeclined) TextDecoration.LineThrough else null
/** [declinedDecoration] for the rows that take styled text rather than a `String`. */
fun declinedTitle(title: String, isDeclined: Boolean): AnnotatedString = if (isDeclined) {
AnnotatedString(title, SpanStyle(textDecoration = TextDecoration.LineThrough))
} else {
AnnotatedString(title)
}
/** [declinedTitle] over already-styled text, e.g. a search hit's marked runs. */
fun declinedTitle(title: AnnotatedString, isDeclined: Boolean): AnnotatedString =
if (!isDeclined) {
title
} else {
buildAnnotatedString {
append(title)
addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), 0, title.length)
}
}

View File

@@ -106,6 +106,7 @@ import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk 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
@@ -118,6 +119,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.week.TimedBlock import de.jeanlucmakiola.calendula.ui.week.TimedBlock
@@ -471,7 +473,11 @@ private fun AllDayStrip(
// Height is hoisted + animated so it resizes smoothly; padding sits // Height is hoisted + animated so it resizes smoothly; padding sits
// inside it so the content area is lanes * row height. // inside it so the content area is lanes * row height.
.height(height) .height(height)
.padding(vertical = ALL_DAY_VERTICAL_PADDING), .padding(
top = ALL_DAY_VERTICAL_PADDING,
bottom = ALL_DAY_VERTICAL_PADDING,
end = TIMELINE_CONTENT_END_INSET,
),
) { ) {
// Keep the gutter-width offset so the bars line up with the day column. // Keep the gutter-width offset so the bars line up with the day column.
Spacer(Modifier.width(GUTTER_WIDTH)) Spacer(Modifier.width(GUTTER_WIDTH))
@@ -525,6 +531,7 @@ private fun AllDayBar(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = eventInk(fill), color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
) )
} }
} }
@@ -570,6 +577,7 @@ private fun Timeline(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState) .verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it }, .onGloballyPositioned { dragController.geometry.viewport = it },
@@ -772,6 +780,7 @@ private fun EventBlock(
maxLines = if (showTime) 1 else 2, maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f), color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
) )
} }
if (showTime) { if (showTime) {
@@ -792,7 +801,12 @@ private fun DayLoading() {
// Same scale resolution as the loaded timeline, so the skeleton's column // Same scale resolution as the loaded timeline, so the skeleton's column
// doesn't resize the moment the real day arrives. // doesn't resize the moment the real day arrives.
val totalHeight = scale.hourHeight(maxHeight) * 24 val totalHeight = scale.hourHeight(maxHeight) * 24
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { Row(
modifier = Modifier
.fillMaxSize()
.padding(end = TIMELINE_CONTENT_END_INSET)
.verticalScroll(scrollState),
) {
Spacer(Modifier.width(GUTTER_WIDTH)) Spacer(Modifier.width(GUTTER_WIDTH))
Box( Box(
modifier = Modifier modifier = Modifier

View File

@@ -78,9 +78,15 @@ class EventDetailViewModel @Inject constructor(
* the occurrence's own times (from `CalendarContract.Instances`); they * the occurrence's own times (from `CalendarContract.Instances`); they
* override the series DTSTART/DTEND so recurring events show the correct * override the series DTSTART/DTEND so recurring events show the correct
* date instead of the first occurrence. * date instead of the first occurrence.
*
* Re-opening the *same* occurrence always re-reads it. The view model
* outlives the sheet, and a `StateFlow` conflates an identical value away,
* so assigning the target alone would leave an edit that changed no time —
* adding a description, say (#196) — showing the state from before the save.
*/ */
fun open(eventId: Long, beginMillis: Long, endMillis: Long) { fun open(eventId: Long, beginMillis: Long, endMillis: Long) {
_target.value = Target(eventId, beginMillis, endMillis) val target = Target(eventId, beginMillis, endMillis)
if (_target.value == target) _reload.value += 1 else _target.value = target
} }
/** Re-run the current load after a failure. */ /** Re-run the current load after a failure. */

View File

@@ -19,6 +19,8 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -111,6 +113,7 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -138,6 +141,7 @@ 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.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
@@ -439,6 +443,7 @@ fun MonthScreen(
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onRetry = jumpToToday, onRetry = jumpToToday,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
) )
} else if (viewStyle == MonthViewStyle.Split) { } else if (viewStyle == MonthViewStyle.Split) {
SplitMonthContent( SplitMonthContent(
@@ -463,6 +468,7 @@ fun MonthScreen(
onSwipePrev = goPrev, onSwipePrev = goPrev,
onRetry = jumpToToday, onRetry = jumpToToday,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
) )
} }
} }
@@ -573,6 +579,7 @@ private fun MonthContent(
onSwipePrev: () -> Unit, onSwipePrev: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) { ) {
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
@@ -599,6 +606,7 @@ private fun MonthContent(
state = s, state = s,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
) )
} }
} }
@@ -617,6 +625,7 @@ private fun ContinuousMonthContent(
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onRetry: () -> Unit, onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) { ) {
when (state) { when (state) {
// The scrolling styles get their own skeleton rather than the paged // The scrolling styles get their own skeleton rather than the paged
@@ -631,6 +640,7 @@ private fun ContinuousMonthContent(
listState = listState, listState = listState,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
) )
} else { } else {
ContinuousMonthGrid( ContinuousMonthGrid(
@@ -638,6 +648,7 @@ private fun ContinuousMonthContent(
listState = listState, listState = listState,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
) )
} }
} }
@@ -736,7 +747,8 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER)
/** Width of the split style's selected-day outline. */ /** Width of the split style's selected-day outline. */
private val SPLIT_SELECTION_STROKE = 1.5.dp private val SPLIT_SELECTION_STROKE = 1.5.dp
private const val MAX_EVENT_ROWS = 3 /** Lanes of bars/pills a day cell draws before the rest become overflow dots. */
internal const val MAX_EVENT_ROWS = 3
/** /**
* Row height in the continuous grid. The paged grid divides the viewport between * Row height in the continuous grid. The paged grid divides the viewport between
@@ -754,11 +766,15 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp
*/ */
private val CONTINUOUS_MONTH_GAP = 20.dp private val CONTINUOUS_MONTH_GAP = 20.dp
/** Gap between the weekday header and the seamless stream's first week row. */
private val DENSE_HEADER_GAP = 4.dp
@Composable @Composable
internal fun MonthGrid( internal fun MonthGrid(
state: MonthUiState.Success, state: MonthUiState.Success,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */ /** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
selected: LocalDate? = null, selected: LocalDate? = null,
) { ) {
@@ -779,6 +795,7 @@ internal fun MonthGrid(
inMonth = { it.month == month.month && it.year == month.year }, inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
selected = selected, selected = selected,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -809,6 +826,7 @@ internal fun ContinuousMonthGrid(
listState: LazyListState, listState: LazyListState,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val monthCount = remember { continuousMonthCount() } val monthCount = remember { continuousMonthCount() }
@@ -836,6 +854,7 @@ internal fun ContinuousMonthGrid(
today = state.today, today = state.today,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
) )
} }
} }
@@ -855,6 +874,7 @@ private fun ContinuousMonthBlock(
today: LocalDate, today: LocalDate,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) { ) {
val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) } val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) }
Column( Column(
@@ -877,6 +897,7 @@ private fun ContinuousMonthBlock(
blankOutside = true, blankOutside = true,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(CONTINUOUS_ROW_HEIGHT), .height(CONTINUOUS_ROW_HEIGHT),
@@ -935,6 +956,7 @@ internal fun DenseMonthGrid(
listState: LazyListState, listState: LazyListState,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
@@ -942,10 +964,15 @@ internal fun DenseMonthGrid(
state = listState, state = listState,
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 8.dp), // The gap under the weekday header is a real margin, not content
// padding: a lazy list scrolls its rows *through* the before-content
// padding, so scrolling to a week landed its row that far down with
// the tail of the previous one showing above it (#191).
.padding(horizontal = 8.dp)
.padding(top = DENSE_HEADER_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
// Bottom inset clears the FAB stack so the last row stays tappable. // Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp), contentPadding = PaddingValues(bottom = 96.dp),
) { ) {
items(count = weekCount, key = { it }) { index -> items(count = weekCount, key = { it }) { index ->
val week = state.weeksByIndex[index] val week = state.weeksByIndex[index]
@@ -960,6 +987,7 @@ internal fun DenseMonthGrid(
inMonth = { true }, inMonth = { true },
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
labelMonthOnFirst = true, labelMonthOnFirst = true,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1187,6 +1215,9 @@ private fun SplitMonthBody(
onSelectDay(it) onSelectDay(it)
onSetExpanded(false) onSetExpanded(false)
}, },
// A tapped chip is asking for that event, not for its day, so it
// opens the detail from here too rather than collapsing (#187).
onEventClick = onEventClick,
onCollapse = { onSetExpanded(false) }, onCollapse = { onSetExpanded(false) },
) )
} else { } else {
@@ -1287,6 +1318,7 @@ private fun SplitMonthExpanded(
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
swipeModifier: Modifier, swipeModifier: Modifier,
onPickDay: (LocalDate) -> Unit, onPickDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCollapse: () -> Unit, onCollapse: () -> Unit,
) { ) {
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
@@ -1309,6 +1341,7 @@ private fun SplitMonthExpanded(
state = s, state = s,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onPickDay, onOpenDay = onPickDay,
onEventClick = onEventClick,
selected = sel, selected = sel,
) )
} }
@@ -1789,7 +1822,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
modifier = Modifier.padding(bottom = 8.dp), modifier = Modifier.padding(bottom = 8.dp),
) )
} else { } else {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(DENSE_HEADER_GAP))
} }
// More rows than a viewport holds; the clip takes the overflow. // More rows than a viewport holds; the clip takes the overflow.
repeat(6) { repeat(6) {
@@ -1837,6 +1870,7 @@ private fun MonthWeekRow(
inMonth: (LocalDate) -> Boolean, inMonth: (LocalDate) -> Boolean,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
blankOutside: Boolean = false, blankOutside: Boolean = false,
labelMonthOnFirst: Boolean = false, labelMonthOnFirst: Boolean = false,
@@ -2135,11 +2169,17 @@ private fun MonthWeekRow(
} }
} }
// Tap layer: in month view a tap on any day opens that day. Padded and // Tap layer: a tap on a chip opens that event, anything else opens the
// day (#187). The chips take no pointer input of their own — this
// layer covers them — so the lane under the finger is resolved
// geometrically, exactly as the drag pickup above does it; the down
// position is read on the initial pass, which consumes nothing and so
// leaves both the click and a pickup in flight untouched. Padded and
// clipped to the background pill so the ripple matches it. A blanked // clipped to the background pill so the ripple matches it. A blanked
// cell isn't part of this month, so it takes no taps either. // cell isn't part of this month, so it takes no taps either.
val downY = remember(week.days.size) { FloatArray(week.days.size) }
Row(Modifier.matchParentSize()) { Row(Modifier.matchParentSize()) {
week.days.forEach { d -> week.days.forEachIndexed { col, d ->
if (blankOutside && !inMonth(d)) { if (blankOutside && !inMonth(d)) {
Spacer(Modifier.weight(1f).fillMaxHeight()) Spacer(Modifier.weight(1f).fillMaxHeight())
} else { } else {
@@ -2147,9 +2187,28 @@ private fun MonthWeekRow(
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.pointerInput(col) {
awaitEachGesture {
downY[col] = awaitFirstDown(
requireUnconsumed = false,
pass = PointerEventPass.Initial,
).position.y
}
}
.padding(horizontal = CELL_GAP, vertical = 1.dp) .padding(horizontal = CELL_GAP, vertical = 1.dp)
.clip(CELL_SHAPE) .clip(CELL_SHAPE)
.clickable { onOpenDay(d) }, .clickable {
val chip = week.chipAtCellY(
col = col,
cellY = downY[col],
bandTopInCell = bandTopInCell(
cellCoordinates,
bandCoordinates,
),
rowHeightPx = rowHeightPx,
)
if (chip != null) onEventClick(chip) else onOpenDay(d)
},
) )
} }
} }
@@ -2158,6 +2217,38 @@ private fun MonthWeekRow(
} }
} }
/**
* How far the event band sits below the top of the row's day-column box, or null
* while either is unmeasured. Read off the live coordinates rather than summed
* from the padding constants, so it can't drift from what the row actually drew.
*/
private fun bandTopInCell(
cell: Array<LayoutCoordinates?>,
band: Array<LayoutCoordinates?>,
): Float? {
val cellTop = cell[0]?.takeIf { it.isAttached }?.positionInRoot()?.y ?: return null
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y ?: return null
return bandTop - cellTop
}
/**
* The chip at [cellY] in column [col], where [cellY] is measured from the top of
* the row's day-column box. Null for a tap above the band (the day number), on an
* empty lane, or on the overflow dots — all of which mean "open the day", the
* dots included: their point is that the day holds more than fits.
*/
internal fun MonthWeek.chipAtCellY(
col: Int,
cellY: Float,
bandTopInCell: Float?,
rowHeightPx: Float,
): EventInstance? {
if (bandTopInCell == null || rowHeightPx <= 0f) return null
val bandY = cellY - bandTopInCell
if (bandY < 0f) return null
return chipAt(col, (bandY / rowHeightPx).toInt(), MAX_EVENT_ROWS)
}
/** /**
* The row-level pickup for month chips: resolves which chip the press landed on * The row-level pickup for month chips: resolves which chip the press landed on
* from the geometry the row just laid out, and abandons the gesture on empty * from the geometry the row just laid out, and abandons the gesture on empty
@@ -2358,6 +2449,7 @@ private fun MonthBar(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = eventInk(fill), color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
) )
} }
} }

View File

@@ -45,6 +45,7 @@ internal fun MonthStylePreview(
state = sample.month, state = sample.month,
showWeekNumbers = false, showWeekNumbers = false,
onOpenDay = {}, onOpenDay = {},
onEventClick = {},
) )
MonthViewStyle.Continuous -> ContinuousMonthGrid( MonthViewStyle.Continuous -> ContinuousMonthGrid(
state = sample.continuous, state = sample.continuous,
@@ -55,6 +56,7 @@ internal fun MonthStylePreview(
), ),
showWeekNumbers = false, showWeekNumbers = false,
onOpenDay = {}, onOpenDay = {},
onEventClick = {},
) )
MonthViewStyle.Dense -> DenseMonthGrid( MonthViewStyle.Dense -> DenseMonthGrid(
state = sample.continuous, state = sample.continuous,
@@ -66,6 +68,7 @@ internal fun MonthStylePreview(
), ),
showWeekNumbers = false, showWeekNumbers = false,
onOpenDay = {}, onOpenDay = {},
onEventClick = {},
) )
MonthViewStyle.Split -> { MonthViewStyle.Split -> {
SplitMonthGrid( SplitMonthGrid(

View File

@@ -85,6 +85,7 @@ import de.jeanlucmakiola.calendula.domain.SearchMonth
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
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
@@ -519,7 +520,7 @@ private fun SearchResultRow(
// Faded like a past event anywhere else in the app — search reaches back // Faded like a past event anywhere else in the app — search reaches back
// through the whole history. // through the whole history.
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier, modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
title = marked(event.title, hit.titleSpans, highlight), title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined),
summary = searchSummary(hit, highlight), summary = searchSummary(hit, highlight),
position = position, position = position,
minHeight = 64.dp, minHeight = 64.dp,

View File

@@ -90,6 +90,7 @@ 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.BlockTimeLabel import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
@@ -129,6 +130,7 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
@@ -506,7 +508,7 @@ private fun WeekDayHeader(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 4.dp, bottom = 8.dp), .padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET),
) { ) {
// Mirror the day-column layout (empty weekday line + spacer) so the // Mirror the day-column layout (empty weekday line + spacer) so the
// badge lines up vertically with the date numbers. The start inset centres // badge lines up vertically with the date numbers. The start inset centres
@@ -602,7 +604,11 @@ private fun AllDayStrip(
// Height is hoisted + animated so it slides and resizes smoothly; // Height is hoisted + animated so it slides and resizes smoothly;
// padding sits inside it so the content area is lanes * row height. // padding sits inside it so the content area is lanes * row height.
.height(height) .height(height)
.padding(vertical = ALL_DAY_VERTICAL_PADDING), .padding(
top = ALL_DAY_VERTICAL_PADDING,
bottom = ALL_DAY_VERTICAL_PADDING,
end = TIMELINE_CONTENT_END_INSET,
),
) { ) {
// Keep the gutter-width offset so the bars line up with the day columns. // Keep the gutter-width offset so the bars line up with the day columns.
Spacer(Modifier.width(GUTTER_WIDTH)) Spacer(Modifier.width(GUTTER_WIDTH))
@@ -662,6 +668,7 @@ private fun AllDayBar(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = eventInk(fill), color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
) )
} }
} }
@@ -708,6 +715,7 @@ private fun Timeline(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState) .verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it }, .onGloballyPositioned { dragController.geometry.viewport = it },
@@ -940,6 +948,7 @@ private fun EventBlock(
maxLines = titleMaxLines, maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f), color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
) )
} }
if (showTime) { if (showTime) {
@@ -977,7 +986,12 @@ private fun WeekLoading() {
// Same scale resolution as the loaded timeline, so the skeleton's // Same scale resolution as the loaded timeline, so the skeleton's
// columns don't resize the moment the real week arrives. // columns don't resize the moment the real week arrives.
val totalHeight = scale.hourHeight(maxHeight) * 24 val totalHeight = scale.hourHeight(maxHeight) * 24
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { Row(
modifier = Modifier
.fillMaxSize()
.padding(end = TIMELINE_CONTENT_END_INSET)
.verticalScroll(scrollState),
) {
Spacer(Modifier.width(GUTTER_WIDTH)) Spacer(Modifier.width(GUTTER_WIDTH))
repeat(7) { repeat(7) {
Box( Box(

View File

@@ -4,6 +4,7 @@ import android.os.Build
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.glance.GlanceTheme import androidx.glance.GlanceTheme
import androidx.glance.material3.ColorProviders import androidx.glance.material3.ColorProviders
import androidx.glance.text.TextDecoration
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
@@ -34,3 +35,10 @@ fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
} }
GlanceTheme(colors = colors, content = content) GlanceTheme(colors = colors, content = content)
} }
/**
* Glance's counterpart to the app's `declinedDecoration`: a declined invitation
* reads the same on the home screen as it does inside the app (#180).
*/
fun glanceDeclinedDecoration(isDeclined: Boolean): TextDecoration =
if (isDeclined) TextDecoration.LineThrough else TextDecoration.None

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.widget.agenda package de.jeanlucmakiola.calendula.widget.agenda
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -24,7 +25,6 @@ import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.updateAll
import androidx.glance.background import androidx.glance.background
import androidx.glance.currentState import androidx.glance.currentState
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
@@ -135,10 +135,14 @@ class AgendaWidget : GlanceAppWidget() {
} }
} }
/** Re-reads the calendar and redraws the widget (header refresh button). */ /**
* Re-reads the calendar and redraws the widget (header refresh button). Targets
* the tapped widget's own id rather than `updateAll`, whose provider-name lookup
* is empty in a process a tap woke from cold — see `ShiftMonthAction` (#18).
*/
class RefreshAgendaAction : ActionCallback { class RefreshAgendaAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
AgendaWidget().updateAll(context.applicationContext) AgendaWidget().update(context.applicationContext, glanceId)
} }
} }
@@ -416,7 +420,11 @@ private fun EventRow(
Text( Text(
text = title, text = title,
maxLines = 1, maxLines = 1,
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle), style = TextStyle(
color = titleColor,
fontSize = metrics.eventTitle,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
) )
Text( Text(
text = eventTimeSummary(context, event, day, is24Hour), text = eventTimeSummary(context, event, day, is24Hour),

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.widget.month package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -25,7 +26,6 @@ import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.appwidget.updateAll
import androidx.glance.background import androidx.glance.background
import androidx.glance.currentState import androidx.glance.currentState
import androidx.glance.layout.Alignment import androidx.glance.layout.Alignment
@@ -33,6 +33,7 @@ import androidx.glance.layout.Box
import androidx.glance.layout.Column import androidx.glance.layout.Column
import androidx.glance.layout.Row import androidx.glance.layout.Row
import androidx.glance.layout.Spacer import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height import androidx.glance.layout.height
@@ -97,10 +98,27 @@ private fun yearMonthOf(index: Int): YearMonth =
* inter-cell seam, with rounded end caps. The displayed month lives in Glance * inter-cell seam, with rounded end caps. The displayed month lives in Glance
* state and is read reactively in the composition ([currentState]) so the arrows * state and is read reactively in the composition ([currentState]) so the arrows
* move it via plain recomposition, not a (here-unreliable) widget session reload. * move it via plain recomposition, not a (here-unreliable) widget session reload.
*
* Everything here is written to keep the published `RemoteViews` small. A widget
* update reaches the launcher as a *oneway* binder call, and the host's async
* buffer is 1 MB for all of it; overrun throws `TransactionTooLargeException` and
* the system drops the whole host, freezing every widget on the home screen until
* the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the
* host reports, so the ceiling arrives at half the view count you would expect: a
* grid that emitted a view per cell blew past it after three taps (#214). Every
* row here emits only what it fills.
*/ */
class MonthWidget : GlanceAppWidget() { class MonthWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition override val stateDefinition = PreferencesGlanceStateDefinition
/**
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
* return early from `GlanceAppWidget.resize` — under those the grid is never
* recomposed for a new size and a resized widget clips instead of reflowing.
* Exact costs one serialised copy per host size, which is affordable now that
* a rendering is ~190 views rather than ~740.
*/
override val sizeMode = SizeMode.Exact override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
@@ -118,7 +136,17 @@ class MonthWidget : GlanceAppWidget() {
} }
} }
/** Step the displayed month by the `delta` action parameter (±1). */ /**
* Step the displayed month by the `delta` action parameter (±1).
*
* Redrawn through [GlanceAppWidget.update] with the id the callback was handed,
* never `updateAll`. `updateAll` resolves its targets through the provider-name
* -> app-widget-id map Glance persists for the *receiver*, and in a process that
* has done nothing else yet — the one a tap wakes after a reboot — that lookup
* comes back empty and the redraw is dropped: the state write lands, nothing
* recomposes, and the arrows read as dead until something else opens the app and
* starts a session (#18). The tapped widget's own id needs no lookup.
*/
class ShiftMonthAction : ActionCallback { class ShiftMonthAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val delta = parameters[deltaKey] ?: 0 val delta = parameters[deltaKey] ?: 0
@@ -126,7 +154,7 @@ class ShiftMonthAction : ActionCallback {
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone()) val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
prefs[MONTH_INDEX_KEY] = cur + delta prefs[MONTH_INDEX_KEY] = cur + delta
} }
MonthWidget().updateAll(context.applicationContext) MonthWidget().update(context.applicationContext, glanceId)
} }
companion object { companion object {
@@ -134,11 +162,11 @@ class ShiftMonthAction : ActionCallback {
} }
} }
/** Jump the displayed month back to the current month. */ /** Jump the displayed month back to the current month. See [ShiftMonthAction]. */
class ResetMonthAction : ActionCallback { class ResetMonthAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) } updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
MonthWidget().updateAll(context.applicationContext) MonthWidget().update(context.applicationContext, glanceId)
} }
} }
@@ -286,9 +314,31 @@ private fun WeekRow(
colW: Dp, colW: Dp,
modifier: GlanceModifier, modifier: GlanceModifier,
) { ) {
Column(modifier = modifier.fillMaxWidth()) { val context = LocalContext.current
// Day numbers. val hidden = week.hiddenPerDay()
Row(modifier = GlanceModifier.fillMaxWidth()) { Box(modifier = modifier.fillMaxWidth()) {
// Day-open targets, one full-height strip per column, sitting *under* the
// content: a tap anywhere in a day still opens it, as in the app, without
// every cell in the column carrying a click target of its own. The content
// above is not clickable, so touches fall through to this layer; only an
// event bar opts out to open its own detail.
Row(modifier = GlanceModifier.fillMaxSize()) {
week.days.forEach { date ->
Box(
modifier = GlanceModifier
.width(colW)
.fillMaxHeight()
.clickable(openDayAction(context, date)),
) {}
}
}
Column(modifier = GlanceModifier.fillMaxWidth()) {
// Day numbers. Fixed height so every week's number row lines up whether
// or not it holds today's larger pill.
Row(
modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
verticalAlignment = Alignment.CenterVertically,
) {
week.days.forEach { date -> week.days.forEach { date ->
DayNumber( DayNumber(
date = date, date = date,
@@ -301,13 +351,37 @@ private fun WeekRow(
Spacer(GlanceModifier.height(2.dp)) Spacer(GlanceModifier.height(2.dp))
// One lane row per event row. A multi-day span is a single Box spanning // One lane row per event row. A multi-day span is a single Box spanning
// its columns (colW * n) so it's connected with no seam and rounded ends. // its columns (colW * n) so it's connected with no seam and rounded ends.
repeat(MAX_LANES) { lane -> // Lanes past the last one this week fills emit nothing at all.
repeat(week.usedLanes()) { lane ->
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW) LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
Spacer(GlanceModifier.height(1.dp)) Spacer(GlanceModifier.height(1.dp))
} }
OverflowRow(week = week, colW = colW) if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW)
} }
} }
}
/**
* How many lanes this week actually fills, counting from the top. The grid draws
* that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views
* per day and most weeks leave the last two empty.
*/
private fun MonthWeek.usedLanes(): Int {
for (lane in MAX_LANES - 1 downTo 0) {
val filled = spans.any { it.lane == lane } ||
days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null }
if (filled) return lane + 1
}
return 0
}
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
}
/** /**
* Open [date]'s day view rooted in the month view (so back returns to the month * Open [date]'s day view rooted in the month view (so back returns to the month
@@ -318,25 +392,14 @@ private fun WeekRow(
private fun openDayAction(context: Context, date: LocalDate) = private fun openDayAction(context: Context, date: LocalDate) =
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)) actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
/**
* A day's number. Every day but today is a bare centred [Text] carrying the column
* width itself — a wrapping Box costs three views, and there are 42 of these.
* Today keeps its filled circle, which does need the Box.
*/
@Composable @Composable
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) { private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
val context = LocalContext.current val style = TextStyle(
Box(
modifier = GlanceModifier
.width(colW)
.height(DAY_NUMBER_HEIGHT)
.clickable(openDayAction(context, date)),
contentAlignment = Alignment.Center,
) {
Box(
modifier = GlanceModifier
.size(DAY_NUMBER_HEIGHT)
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
contentAlignment = Alignment.Center,
) {
Text(
text = date.day.toString(),
style = TextStyle(
color = when { color = when {
isToday -> GlanceTheme.colors.onPrimary isToday -> GlanceTheme.colors.onPrimary
inMonth -> GlanceTheme.colors.onSurface inMonth -> GlanceTheme.colors.onSurface
@@ -344,38 +407,78 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
}, },
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
), textAlign = TextAlign.Center,
) )
if (!isToday) {
Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW))
return
}
Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) {
Box(
modifier = GlanceModifier
.size(DAY_NUMBER_HEIGHT)
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
.background(GlanceTheme.colors.primary),
contentAlignment = Alignment.Center,
) {
Text(text = date.day.toString(), style = style)
} }
} }
} }
/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
private data class LaneCell(val event: EventInstance?, val cols: Int)
/**
* A lane split into bars and the gaps between them, with consecutive blank columns
* merged into one gap: the week's day-open taps come from the strip underneath, so
* a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
*/
private fun MonthWeek.laneCells(lane: Int): List<LaneCell> {
val cells = mutableListOf<LaneCell>()
var gap = 0
var col = 0
fun closeGap() {
if (gap > 0) cells += LaneCell(null, gap)
gap = 0
}
while (col < 7) {
val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null
when {
span != null -> {
closeGap()
cells += LaneCell(span.event, span.endCol - col + 1)
col = span.endCol + 1
}
timed != null -> {
closeGap()
cells += LaneCell(timed, 1)
col += 1
}
else -> {
gap += 1
col += 1
}
}
}
closeGap()
return cells
}
@Composable @Composable
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) { private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
var col = 0 week.laneCells(lane).forEach { cell ->
while (col < 7) { if (cell.event == null) {
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
if (span != null) {
val cols = span.endCol - col + 1
SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
col = span.endCol + 1
} else { } else {
val timed = timedEventAt(week, lane, col, week.days[col]) SpanBar(
if (timed != null) { event = cell.event,
SpanBar(event = timed, dark = dark, soften = soften, width = colW) dark = dark,
} else { soften = soften,
// Empty lane cell: a tap opens that day, so blank space in a width = colW * cell.cols,
// day column is a day-open target just like the number is. )
Box(
GlanceModifier
.width(colW)
.height(LANE_HEIGHT)
.clickable(openDayAction(context, week.days[col])),
) {}
}
col += 1
} }
} }
} }
@@ -414,41 +517,39 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width:
Text( Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) }, text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1, maxLines = 1,
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp), style = TextStyle(
color = ColorProvider(eventInk(fill)),
fontSize = 9.sp,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
modifier = GlanceModifier.padding(horizontal = 3.dp), modifier = GlanceModifier.padding(horizontal = 3.dp),
) )
} }
} }
} }
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
@Composable @Composable
private fun OverflowRow(week: MonthWeek, colW: Dp) { private fun OverflowRow(hidden: List<Int>, colW: Dp) {
val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
week.days.forEachIndexed { col, date -> var gap = 0
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES } hidden.forEach { count ->
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0) if (count == 0) {
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size) gap += 1
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown return@forEach
// The overflow row is part of the day column too: tapping it (whether }
// it shows "+N" or is blank) opens that day, same as the app. if (gap > 0) {
Box( Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
modifier = GlanceModifier gap = 0
.width(colW) }
.height(LANE_HEIGHT)
.clickable(openDayAction(context, date)),
contentAlignment = Alignment.CenterStart,
) {
if (hidden > 0) {
Text( Text(
text = "+$hidden", text = "+$count",
maxLines = 1, maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
modifier = GlanceModifier.padding(start = 3.dp), modifier = GlanceModifier.width(colW).padding(start = 3.dp),
) )
} }
} if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
}
} }
} }

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import kotlin.time.Instant import kotlin.time.Instant
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -17,6 +18,7 @@ class InstanceMapperTest {
eventColor: Any? = null, eventColor: Any? = null,
calendarColor: Int = 0xFFAABBCC.toInt(), calendarColor: Int = 0xFFAABBCC.toInt(),
location: String? = null, location: String? = null,
selfAttendeeStatus: Int = CalendarContract.Attendees.ATTENDEE_STATUS_NONE,
): MapColumnReader = MapColumnReader( ): MapColumnReader = MapColumnReader(
InstanceProjection.IDX_INSTANCE_ID to instanceId, InstanceProjection.IDX_INSTANCE_ID to instanceId,
InstanceProjection.IDX_EVENT_ID to eventId, InstanceProjection.IDX_EVENT_ID to eventId,
@@ -28,6 +30,7 @@ class InstanceMapperTest {
InstanceProjection.IDX_EVENT_COLOR to eventColor, InstanceProjection.IDX_EVENT_COLOR to eventColor,
InstanceProjection.IDX_CALENDAR_COLOR to calendarColor, InstanceProjection.IDX_CALENDAR_COLOR to calendarColor,
InstanceProjection.IDX_LOCATION to location, InstanceProjection.IDX_LOCATION to location,
InstanceProjection.IDX_SELF_ATTENDEE_STATUS to selfAttendeeStatus,
) )
@Test @Test
@@ -90,4 +93,20 @@ class InstanceMapperTest {
val inst = reader(location = "Berlin").toEventInstance() val inst = reader(location = "Berlin").toEventInstance()
assertThat(inst!!.location).isEqualTo("Berlin") assertThat(inst!!.location).isEqualTo("Berlin")
} }
@Test
fun `a declined invitation is marked, any other answer is not`() {
assertThat(reader().toEventInstance()!!.isDeclined).isFalse()
assertThat(
reader(selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED)
.toEventInstance()!!.isDeclined,
).isTrue()
listOf(
CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED,
CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE,
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
).forEach { status ->
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse()
}
}
} }

View File

@@ -0,0 +1,103 @@
package de.jeanlucmakiola.calendula.ui.detail
import android.content.ContextWrapper
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Instant
/**
* Re-opening an occurrence must re-read it (#196): the view model outlives the
* sheet, so an edit that changed no time would otherwise show the pre-save row.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class EventDetailViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
private val beginMillis = 1_781_164_800_000L
private val endMillis = beginMillis + 3_600_000L
private fun detail(description: String?) = EventDetail(
instance = EventInstance(
instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup",
start = Instant.fromEpochMilliseconds(beginMillis),
end = Instant.fromEpochMilliseconds(endMillis),
isAllDay = false, color = 0xFF000000.toInt(), location = null,
),
description = description, organizer = null, attendees = emptyList(), rrule = null,
)
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): EventDetailViewModel {
val prefs = CalendarPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("detail_prefs.preferences_pb").toFile() },
),
)
val settings = SettingsPrefs(
PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcher),
produceFile = { tempDir.resolve("detail_settings.preferences_pb").toFile() },
),
)
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
// Only `shareUri()` touches the exporter, and nothing here shares.
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher)
}
@Test
fun `re-opening the same occurrence re-reads it`(@TempDir tempDir: Path) = runTest(dispatcher) {
var stored: String? = null
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(
CalendarSource(
id = 1L, displayName = "Cal", accountName = "acc@local", accountType = "LOCAL",
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
),
)
eventDetailResult = { detail(stored) }
}
val vm = viewModel(tempDir, fake)
val collector = launch(Job()) { vm.state.collect {} }
vm.open(42L, beginMillis, endMillis)
advanceUntilIdle()
assertThat((vm.state.value as EventDetailUiState.Success).detail.description).isNull()
// The edit screen saved a description; the tapped occurrence is unchanged.
stored = "Bring the roadmap"
vm.open(42L, beginMillis, endMillis)
advanceUntilIdle()
assertThat((vm.state.value as EventDetailUiState.Success).detail.description)
.isEqualTo("Bring the roadmap")
collector.cancel()
}
}

View File

@@ -0,0 +1,122 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* Which chip a tap in a month cell lands on (#187) — the geometry the tap layer
* uses to tell "open this event" from "open this day", given that the chips take
* no pointer input of their own.
*/
class ChipAtCellYTest {
private val zone = TimeZone.UTC
private val jul26 = YearMonth(2026, Month.JULY)
/** Band starts 40px down the cell; each lane is 20px tall. */
private val bandTop = 40f
private val laneHeight = 20f
/** July 2026 starts on a Wednesday, so this row — Jul 612 — sits wholly inside it. */
private fun rowOfJuly6(events: List<EventInstance>) =
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1]
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "A$id",
start = from.atTime(0, 0).toInstant(zone),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
isAllDay = true,
color = 0xFF2196F3.toInt(),
location = null,
)
private fun timed(date: LocalDate, hour: Int, id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T$id",
start = date.atTime(hour, 0).toInstant(zone),
end = date.atTime(hour + 1, 0).toInstant(zone),
isAllDay = false,
color = 0xFFF44336.toInt(),
location = null,
)
private fun MonthWeek.chipAt(col: Int, cellY: Float) =
chipAtCellY(col = col, cellY = cellY, bandTopInCell = bandTop, rowHeightPx = laneHeight)
@Test
fun `a tap on a lane resolves to the chip seated there`() {
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)
val week = rowOfJuly6(listOf(bar, meeting))
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
assertThat(week.chipAt(col = 1, cellY = bandTop + 5f)?.eventId).isEqualTo(1L)
assertThat(week.chipAt(col = 1, cellY = bandTop + laneHeight + 5f)?.eventId).isEqualTo(2L)
}
@Test
fun `a multi-day bar answers on every column it covers`() {
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
val week = rowOfJuly6(listOf(bar))
(1..3).forEach { col ->
assertThat(week.chipAt(col = col, cellY = bandTop + 5f)?.eventId).isEqualTo(1L)
}
// Jul 10 is past the bar's last day.
assertThat(week.chipAt(col = 4, cellY = bandTop + 5f)).isNull()
}
@Test
fun `a tap above the band is the day number, not a chip`() {
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
assertThat(week.chipAt(col = 1, cellY = bandTop - 1f)).isNull()
assertThat(week.chipAt(col = 1, cellY = 0f)).isNull()
}
@Test
fun `a tap on an empty lane of a day that has chips falls through to the day`() {
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
assertThat(week.chipAt(col = 1, cellY = bandTop + laneHeight * 2 + 5f)).isNull()
}
@Test
fun `a tap on the overflow row opens the day rather than a hidden event`() {
val events = (1..MAX_EVENT_ROWS + 2).map {
timed(LocalDate(2026, 7, 7), hour = it, id = it.toLong())
}
val week = rowOfJuly6(events)
// The dots sit one lane below the last one the row draws.
val overflowY = bandTop + laneHeight * MAX_EVENT_ROWS + 2f
assertThat(week.chipAt(col = 1, cellY = overflowY)).isNull()
}
@Test
fun `unmeasured geometry resolves to no chip`() {
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
assertThat(
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = null, rowHeightPx = laneHeight),
).isNull()
assertThat(
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = bandTop, rowHeightPx = 0f),
).isNull()
}
}