Compare commits
17 Commits
renovate/c
...
2bf7d00f39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf7d00f39 | ||
| 7431a7390e | |||
| a0a559648e | |||
| 1e9eca268e | |||
|
|
187b4743df | ||
|
|
b61dfa441c | ||
|
|
9f6659a2a3 | ||
|
|
ddc5d56cc3 | ||
|
|
b4f977214a | ||
|
|
02f697faf7 | ||
| 2dd81f1327 | |||
| bc805a9a3a | |||
| 8abd3afdff | |||
| d45f63b9fa | |||
| c7b3ff2d6f | |||
| 61ccf5f247 | |||
| 558aa2e69b |
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,12 +351,36 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 6–12 — 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user