Compare commits

..

8 Commits

18 changed files with 283 additions and 129 deletions

View File

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

View File

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

View File

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

View File

@@ -44,16 +44,9 @@ class ProviderReminderInstanceSource @Inject constructor(
// `visible` is the flag the app's one visibility model writes (#75).
// The status clause mirrors CalendarDataSource.instances: NULL means
// "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 " +
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${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})"
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
return context.contentResolver.query(
uri, OCCURRENCE_PROJECTION, selection, null, null,
)?.use { c ->

View File

@@ -67,13 +67,6 @@ data class EventInstance(
* Instances query already yields one row per occurrence.
*/
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,13 +24,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
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.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.eventAccent
@@ -131,8 +129,8 @@ internal fun AgendaEventRow(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = declinedTitle(title, event.isDeclined),
summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
title = title,
summary = agendaTimeSummary(event, day, zone),
position = position,
minHeight = 64.dp,
leading = {

View File

@@ -36,6 +36,14 @@ val GUTTER_WIDTH = 48.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
/** How far the fixed hour labels recede while a block is being dragged. */

View File

@@ -1,10 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common
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
/**
@@ -18,30 +14,3 @@ val LocalDimCutoff = compositionLocalOf<Instant?> { null }
/** Opacity applied to a completed/past event chip when it is dimmed. */
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,7 +106,6 @@ import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
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
@@ -119,6 +118,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
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.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
@@ -472,7 +472,11 @@ private fun AllDayStrip(
// Height is hoisted + animated so it resizes smoothly; padding sits
// inside it so the content area is lanes * row 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.
Spacer(Modifier.width(GUTTER_WIDTH))
@@ -526,7 +530,6 @@ private fun AllDayBar(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
}
}
@@ -572,6 +575,7 @@ private fun Timeline(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it },
@@ -774,7 +778,6 @@ private fun EventBlock(
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
)
}
if (showTime) {
@@ -795,7 +798,12 @@ private fun DayLoading() {
// Same scale resolution as the loaded timeline, so the skeleton's column
// doesn't resize the moment the real day arrives.
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))
Box(
modifier = Modifier

View File

@@ -19,6 +19,8 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.isSystemInDarkTheme
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.drawscope.Stroke
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.platform.LocalDensity
import androidx.compose.ui.res.stringResource
@@ -138,7 +141,6 @@ 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.eventAccent
@@ -440,6 +442,7 @@ fun MonthScreen(
showWeekNumbers = showWeekNumbers,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
} else if (viewStyle == MonthViewStyle.Split) {
SplitMonthContent(
@@ -464,6 +467,7 @@ fun MonthScreen(
onSwipePrev = goPrev,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
@@ -574,6 +578,7 @@ private fun MonthContent(
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
@@ -600,6 +605,7 @@ private fun MonthContent(
state = s,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
@@ -618,6 +624,7 @@ private fun ContinuousMonthContent(
showWeekNumbers: Boolean,
onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) {
when (state) {
// The scrolling styles get their own skeleton rather than the paged
@@ -632,6 +639,7 @@ private fun ContinuousMonthContent(
listState = listState,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
} else {
ContinuousMonthGrid(
@@ -639,6 +647,7 @@ private fun ContinuousMonthContent(
listState = listState,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
@@ -737,7 +746,8 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER)
/** Width of the split style's selected-day outline. */
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
@@ -755,11 +765,15 @@ private val CONTINUOUS_ROW_HEIGHT = 112.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
internal fun MonthGrid(
state: MonthUiState.Success,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
selected: LocalDate? = null,
) {
@@ -780,6 +794,7 @@ internal fun MonthGrid(
inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
selected = selected,
modifier = Modifier
.fillMaxWidth()
@@ -810,6 +825,7 @@ internal fun ContinuousMonthGrid(
listState: LazyListState,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
val monthCount = remember { continuousMonthCount() }
@@ -837,6 +853,7 @@ internal fun ContinuousMonthGrid(
today = state.today,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
@@ -856,6 +873,7 @@ private fun ContinuousMonthBlock(
today: LocalDate,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) {
val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) }
Column(
@@ -878,6 +896,7 @@ private fun ContinuousMonthBlock(
blankOutside = true,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.height(CONTINUOUS_ROW_HEIGHT),
@@ -936,6 +955,7 @@ internal fun DenseMonthGrid(
listState: LazyListState,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
@@ -943,10 +963,15 @@ internal fun DenseMonthGrid(
state = listState,
modifier = modifier
.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),
// 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 ->
val week = state.weeksByIndex[index]
@@ -961,6 +986,7 @@ internal fun DenseMonthGrid(
inMonth = { true },
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
labelMonthOnFirst = true,
modifier = Modifier
.fillMaxWidth()
@@ -1188,6 +1214,9 @@ private fun SplitMonthBody(
onSelectDay(it)
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) },
)
} else {
@@ -1288,6 +1317,7 @@ private fun SplitMonthExpanded(
showWeekNumbers: Boolean,
swipeModifier: Modifier,
onPickDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCollapse: () -> Unit,
) {
val slideSpec = rememberCalendarSlideSpec()
@@ -1310,6 +1340,7 @@ private fun SplitMonthExpanded(
state = s,
showWeekNumbers = showWeekNumbers,
onOpenDay = onPickDay,
onEventClick = onEventClick,
selected = sel,
)
}
@@ -1790,7 +1821,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
modifier = Modifier.padding(bottom = 8.dp),
)
} else {
Spacer(Modifier.height(4.dp))
Spacer(Modifier.height(DENSE_HEADER_GAP))
}
// More rows than a viewport holds; the clip takes the overflow.
repeat(6) {
@@ -1838,6 +1869,7 @@ private fun MonthWeekRow(
inMonth: (LocalDate) -> Boolean,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
blankOutside: Boolean = false,
labelMonthOnFirst: Boolean = false,
@@ -2136,11 +2168,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
// 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()) {
week.days.forEach { d ->
week.days.forEachIndexed { col, d ->
if (blankOutside && !inMonth(d)) {
Spacer(Modifier.weight(1f).fillMaxHeight())
} else {
@@ -2148,9 +2186,28 @@ private fun MonthWeekRow(
Modifier
.weight(1f)
.fillMaxHeight()
.pointerInput(col) {
awaitEachGesture {
downY[col] = awaitFirstDown(
requireUnconsumed = false,
pass = PointerEventPass.Initial,
).position.y
}
}
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.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)
},
)
}
}
@@ -2159,6 +2216,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
* from the geometry the row just laid out, and abandons the gesture on empty
@@ -2359,7 +2448,6 @@ private fun MonthBar(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
}
}

View File

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

View File

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

View File

@@ -90,7 +90,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.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
@@ -130,6 +129,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_WIDTH
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.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
@@ -507,7 +507,7 @@ private fun WeekDayHeader(
Row(
modifier = Modifier
.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
// badge lines up vertically with the date numbers. The start inset centres
@@ -603,7 +603,11 @@ private fun AllDayStrip(
// Height is hoisted + animated so it slides and resizes smoothly;
// padding sits inside it so the content area is lanes * row 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.
Spacer(Modifier.width(GUTTER_WIDTH))
@@ -663,7 +667,6 @@ private fun AllDayBar(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
}
}
@@ -710,6 +713,7 @@ private fun Timeline(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it },
@@ -942,7 +946,6 @@ private fun EventBlock(
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
)
}
if (showTime) {
@@ -980,7 +983,12 @@ private fun WeekLoading() {
// Same scale resolution as the loaded timeline, so the skeleton's
// columns don't resize the moment the real week arrives.
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))
repeat(7) {
Box(

View File

@@ -4,7 +4,6 @@ import android.os.Build
import androidx.compose.runtime.Composable
import androidx.glance.GlanceTheme
import androidx.glance.material3.ColorProviders
import androidx.glance.text.TextDecoration
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
@@ -35,10 +34,3 @@ fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
}
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,6 +1,5 @@
package de.jeanlucmakiola.calendula.widget.agenda
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable
@@ -25,7 +24,6 @@ import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.updateAll
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.state.PreferencesGlanceStateDefinition
@@ -136,10 +134,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 {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
AgendaWidget().updateAll(context.applicationContext)
AgendaWidget().update(context.applicationContext, glanceId)
}
}
@@ -417,11 +419,7 @@ private fun EventRow(
Text(
text = title,
maxLines = 1,
style = TextStyle(
color = titleColor,
fontSize = metrics.eventTitle,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
)
Text(
text = eventTimeSummary(context, event, day, is24Hour),

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable
@@ -26,7 +25,6 @@ import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.appwidget.updateAll
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
@@ -119,7 +117,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 {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val delta = parameters[deltaKey] ?: 0
@@ -127,7 +135,7 @@ class ShiftMonthAction : ActionCallback {
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
prefs[MONTH_INDEX_KEY] = cur + delta
}
MonthWidget().updateAll(context.applicationContext)
MonthWidget().update(context.applicationContext, glanceId)
}
companion object {
@@ -135,11 +143,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 {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
MonthWidget().updateAll(context.applicationContext)
MonthWidget().update(context.applicationContext, glanceId)
}
}
@@ -415,11 +423,7 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width:
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(
color = ColorProvider(eventInk(fill)),
fontSize = 9.sp,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
modifier = GlanceModifier.padding(horizontal = 3.dp),
)
}

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import kotlin.time.Instant
import org.junit.jupiter.api.Test
@@ -18,7 +17,6 @@ class InstanceMapperTest {
eventColor: Any? = null,
calendarColor: Int = 0xFFAABBCC.toInt(),
location: String? = null,
selfAttendeeStatus: Int = CalendarContract.Attendees.ATTENDEE_STATUS_NONE,
): MapColumnReader = MapColumnReader(
InstanceProjection.IDX_INSTANCE_ID to instanceId,
InstanceProjection.IDX_EVENT_ID to eventId,
@@ -30,7 +28,6 @@ class InstanceMapperTest {
InstanceProjection.IDX_EVENT_COLOR to eventColor,
InstanceProjection.IDX_CALENDAR_COLOR to calendarColor,
InstanceProjection.IDX_LOCATION to location,
InstanceProjection.IDX_SELF_ATTENDEE_STATUS to selfAttendeeStatus,
)
@Test
@@ -93,20 +90,4 @@ class InstanceMapperTest {
val inst = reader(location = "Berlin").toEventInstance()
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,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()
}
}