Compare commits
1 Commits
v2.19.2
...
558aa2e69b
| Author | SHA1 | Date | |
|---|---|---|---|
| 558aa2e69b |
41
CHANGELOG.md
41
CHANGELOG.md
@@ -7,41 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [2.19.2] — 2026-08-17
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **A meeting you declined is now struck through** wherever it appears — month,
|
|
||||||
week, day, agenda, search and both widgets — and no longer schedules a
|
|
||||||
reminder. Declining an invitation in Google Calendar left the event looking
|
|
||||||
like any other in Calendula, and it still notified you about a meeting you had
|
|
||||||
said no to. It stays visible rather than disappearing: the organiser still
|
|
||||||
expects an answer from you, and the slot is still spoken for ([#180]).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Tapping an event in month view opens the event**, not the day it sits on.
|
|
||||||
Every month style is affected — page, rolling, seamless weeks — and until now
|
|
||||||
the only way to reach an event from the month was to open its day first and
|
|
||||||
find it again there. Tapping anywhere else in the cell still opens the day
|
|
||||||
([#187]).
|
|
||||||
- **An edit made to an event now shows the moment you re-open it.** Adding a
|
|
||||||
description to an event and opening it again showed the sheet as it was before
|
|
||||||
the save, because the detail was only re-read when a different occurrence was
|
|
||||||
opened ([#196]).
|
|
||||||
- **The month widget's arrows stopped working after a couple of taps.** The grid
|
|
||||||
was serialised as roughly 740 views, and one update ran to half a megabyte —
|
|
||||||
more than the launcher's buffer takes. The third update overran it, and Android
|
|
||||||
responded by dropping the whole widget host, which killed updates for *every*
|
|
||||||
widget on the home screen, ours and other apps', until the launcher rebound.
|
|
||||||
The grid now draws 192 views, and a resized widget reflows to its new size
|
|
||||||
instead of clipping ([#214]).
|
|
||||||
- Tapping a widget's refresh or month arrows redraws that widget by its own id
|
|
||||||
instead of asking Android to update all of them, which does nothing in a
|
|
||||||
process the tap has just woken from cold ([#18]).
|
|
||||||
- **Jump-to-today in seamless weeks lands on the current week** instead of
|
|
||||||
leaving a sliver of the previous row on screen ([#191]).
|
|
||||||
- Day and week view no longer run their events flush against the right edge
|
|
||||||
([#192]).
|
|
||||||
|
|
||||||
## [2.19.1] — 2026-08-11
|
## [2.19.1] — 2026-08-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1449,9 +1414,3 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||||
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
||||||
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
|
||||||
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
|
||||||
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
|
||||||
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
|
||||||
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
|
||||||
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -39,7 +38,5 @@ 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,7 +52,6 @@ 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
|
||||||
@@ -65,7 +64,6 @@ 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 {
|
||||||
@@ -184,7 +182,6 @@ 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
|
||||||
@@ -200,7 +197,6 @@ 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,6 +1,5 @@
|
|||||||
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
|
||||||
@@ -43,7 +42,5 @@ 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,16 +44,9 @@ 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.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
|
||||||
"${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,13 +67,6 @@ 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,13 +24,11 @@ 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
|
||||||
@@ -131,8 +129,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 = declinedTitle(title, event.isDeclined),
|
title = title,
|
||||||
summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
|
summary = agendaTimeSummary(event, day, zone),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
leading = {
|
leading = {
|
||||||
|
|||||||
@@ -36,14 +36,6 @@ 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,10 +1,6 @@
|
|||||||
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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,30 +14,3 @@ 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,7 +106,6 @@ 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
|
||||||
@@ -119,7 +118,6 @@ 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
|
||||||
@@ -473,11 +471,7 @@ 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(
|
.padding(vertical = ALL_DAY_VERTICAL_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))
|
||||||
@@ -531,7 +525,6 @@ private fun AllDayBar(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill),
|
color = eventInk(fill),
|
||||||
textDecoration = declinedDecoration(event.isDeclined),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -577,7 +570,6 @@ 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 },
|
||||||
@@ -780,7 +772,6 @@ 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) {
|
||||||
@@ -801,12 +792,7 @@ 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(
|
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||||
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
|
||||||
|
|||||||
@@ -48,14 +48,9 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _target = MutableStateFlow<Target?>(null)
|
private val _target = MutableStateFlow<Target?>(null)
|
||||||
// Bumped by retry() and by re-opening the target already shown, to re-run
|
// Bumped by retry() to re-run the load for the same target.
|
||||||
// the load without changing the target.
|
|
||||||
private val _reload = MutableStateFlow(0)
|
private val _reload = MutableStateFlow(0)
|
||||||
|
|
||||||
// Last target whose content is already on screen; a re-read of it skips the
|
|
||||||
// Loading skeleton so the sheet doesn't blank out between two identical reads.
|
|
||||||
private var loadedTarget: Target? = null
|
|
||||||
|
|
||||||
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
|
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
|
||||||
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
|
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
|
||||||
|
|
||||||
@@ -63,14 +58,11 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
combine(_target, _reload) { target, _ -> target }
|
combine(_target, _reload) { target, _ -> target }
|
||||||
.flatMapLatest { target ->
|
.flatMapLatest { target ->
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
loadedTarget = null
|
|
||||||
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
|
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
|
||||||
} else {
|
} else {
|
||||||
flow {
|
flow {
|
||||||
if (loadedTarget != target) emit(EventDetailUiState.Loading)
|
emit(EventDetailUiState.Loading)
|
||||||
val loaded = loadDetail(target)
|
emit(loadDetail(target))
|
||||||
loadedTarget = target.takeIf { loaded is EventDetailUiState.Success }
|
|
||||||
emit(loaded)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,15 +78,9 @@ 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) {
|
||||||
val target = Target(eventId, beginMillis, endMillis)
|
_target.value = 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,8 +19,6 @@ 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
|
||||||
@@ -113,7 +111,6 @@ 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
|
||||||
@@ -141,7 +138,6 @@ 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
|
||||||
@@ -443,7 +439,6 @@ 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(
|
||||||
@@ -468,7 +463,6 @@ fun MonthScreen(
|
|||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -579,7 +573,6 @@ 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()
|
||||||
@@ -606,7 +599,6 @@ private fun MonthContent(
|
|||||||
state = s,
|
state = s,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -625,7 +617,6 @@ 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
|
||||||
@@ -640,7 +631,6 @@ private fun ContinuousMonthContent(
|
|||||||
listState = listState,
|
listState = listState,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ContinuousMonthGrid(
|
ContinuousMonthGrid(
|
||||||
@@ -648,7 +638,6 @@ private fun ContinuousMonthContent(
|
|||||||
listState = listState,
|
listState = listState,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -747,8 +736,7 @@ 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
|
||||||
/** Lanes of bars/pills a day cell draws before the rest become overflow dots. */
|
private const val MAX_EVENT_ROWS = 3
|
||||||
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
|
||||||
@@ -766,15 +754,11 @@ 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,
|
||||||
) {
|
) {
|
||||||
@@ -795,7 +779,6 @@ 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()
|
||||||
@@ -826,7 +809,6 @@ 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() }
|
||||||
@@ -854,7 +836,6 @@ internal fun ContinuousMonthGrid(
|
|||||||
today = state.today,
|
today = state.today,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -874,7 +855,6 @@ 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(
|
||||||
@@ -897,7 +877,6 @@ 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),
|
||||||
@@ -956,7 +935,6 @@ 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) }
|
||||||
@@ -964,15 +942,10 @@ internal fun DenseMonthGrid(
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
// The gap under the weekday header is a real margin, not content
|
.padding(horizontal = 8.dp),
|
||||||
// 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(bottom = 96.dp),
|
contentPadding = PaddingValues(top = 4.dp, 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]
|
||||||
@@ -987,7 +960,6 @@ 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()
|
||||||
@@ -1215,9 +1187,6 @@ 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 {
|
||||||
@@ -1318,7 +1287,6 @@ 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()
|
||||||
@@ -1341,7 +1309,6 @@ private fun SplitMonthExpanded(
|
|||||||
state = s,
|
state = s,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onPickDay,
|
onOpenDay = onPickDay,
|
||||||
onEventClick = onEventClick,
|
|
||||||
selected = sel,
|
selected = sel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1822,7 +1789,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
|
|||||||
modifier = Modifier.padding(bottom = 8.dp),
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.height(DENSE_HEADER_GAP))
|
Spacer(Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
// 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) {
|
||||||
@@ -1870,7 +1837,6 @@ 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,
|
||||||
@@ -2169,17 +2135,11 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tap layer: a tap on a chip opens that event, anything else opens the
|
// Tap layer: in month view a tap on any day opens that day. Padded and
|
||||||
// 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) { NO_DOWN_Y } }
|
|
||||||
Row(Modifier.matchParentSize()) {
|
Row(Modifier.matchParentSize()) {
|
||||||
week.days.forEachIndexed { col, d ->
|
week.days.forEach { d ->
|
||||||
if (blankOutside && !inMonth(d)) {
|
if (blankOutside && !inMonth(d)) {
|
||||||
Spacer(Modifier.weight(1f).fillMaxHeight())
|
Spacer(Modifier.weight(1f).fillMaxHeight())
|
||||||
} else {
|
} else {
|
||||||
@@ -2187,33 +2147,9 @@ 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 {
|
.clickable { onOpenDay(d) },
|
||||||
// Cleared on read: a click with no fresh down
|
|
||||||
// (TalkBack, D-pad) would otherwise resolve the
|
|
||||||
// previous tap's position and reopen its chip.
|
|
||||||
val cellY = downY[col]
|
|
||||||
downY[col] = NO_DOWN_Y
|
|
||||||
val chip = week.chipAtCellY(
|
|
||||||
col = col,
|
|
||||||
cellY = cellY,
|
|
||||||
bandTopInCell = bandTopInCell(
|
|
||||||
cellCoordinates,
|
|
||||||
bandCoordinates,
|
|
||||||
),
|
|
||||||
rowHeightPx = rowHeightPx,
|
|
||||||
)
|
|
||||||
if (chip != null) onEventClick(chip) else onOpenDay(d)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2222,43 +2158,6 @@ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stand-in [cellY] for "no touch down recorded", which resolves to no chip.
|
|
||||||
*/
|
|
||||||
private const val NO_DOWN_Y = Float.NEGATIVE_INFINITY
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
||||||
@@ -2459,7 +2358,6 @@ private fun MonthBar(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill),
|
color = eventInk(fill),
|
||||||
textDecoration = declinedDecoration(event.isDeclined),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ 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,
|
||||||
@@ -56,7 +55,6 @@ internal fun MonthStylePreview(
|
|||||||
),
|
),
|
||||||
showWeekNumbers = false,
|
showWeekNumbers = false,
|
||||||
onOpenDay = {},
|
onOpenDay = {},
|
||||||
onEventClick = {},
|
|
||||||
)
|
)
|
||||||
MonthViewStyle.Dense -> DenseMonthGrid(
|
MonthViewStyle.Dense -> DenseMonthGrid(
|
||||||
state = sample.continuous,
|
state = sample.continuous,
|
||||||
@@ -68,7 +66,6 @@ internal fun MonthStylePreview(
|
|||||||
),
|
),
|
||||||
showWeekNumbers = false,
|
showWeekNumbers = false,
|
||||||
onOpenDay = {},
|
onOpenDay = {},
|
||||||
onEventClick = {},
|
|
||||||
)
|
)
|
||||||
MonthViewStyle.Split -> {
|
MonthViewStyle.Split -> {
|
||||||
SplitMonthGrid(
|
SplitMonthGrid(
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ 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
|
||||||
@@ -520,7 +519,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 = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined),
|
title = marked(event.title, hit.titleSpans, highlight),
|
||||||
summary = searchSummary(hit, highlight),
|
summary = searchSummary(hit, highlight),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ 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
|
||||||
@@ -130,7 +129,6 @@ 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
|
||||||
@@ -508,7 +506,7 @@ private fun WeekDayHeader(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET),
|
.padding(top = 4.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
// 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
|
||||||
@@ -604,11 +602,7 @@ 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(
|
.padding(vertical = ALL_DAY_VERTICAL_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))
|
||||||
@@ -668,7 +662,6 @@ private fun AllDayBar(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill),
|
color = eventInk(fill),
|
||||||
textDecoration = declinedDecoration(event.isDeclined),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -715,7 +708,6 @@ 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 },
|
||||||
@@ -948,7 +940,6 @@ 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) {
|
||||||
@@ -986,12 +977,7 @@ 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(
|
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||||
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,7 +4,6 @@ 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
|
||||||
|
|
||||||
@@ -35,10 +34,3 @@ 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,6 +1,5 @@
|
|||||||
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
|
||||||
@@ -25,6 +24,7 @@ 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
|
||||||
@@ -74,6 +74,11 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||||
|
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||||
|
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
||||||
* The range is read reactively in the composition ([currentState]) so a settings
|
* The range is read reactively in the composition ([currentState]) so a settings
|
||||||
@@ -108,11 +113,6 @@ internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_to
|
|||||||
*/
|
*/
|
||||||
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
|
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
|
||||||
|
|
||||||
/**
|
|
||||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
|
||||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
|
||||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
|
||||||
*/
|
|
||||||
class AgendaWidget : GlanceAppWidget() {
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
@@ -135,23 +135,17 @@ class AgendaWidget : GlanceAppWidget() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Re-reads the calendar and redraws the widget (header refresh button). */
|
||||||
* 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). A cold process re-reads the
|
|
||||||
* calendar in the `provideGlance` preamble; a live session only recomposes from
|
|
||||||
* the snapshot it already has (see [AGENDA_RANGE_KEY]).
|
|
||||||
*/
|
|
||||||
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().update(context.applicationContext, glanceId)
|
AgendaWidget().updateAll(context.applicationContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
||||||
* stays well inside the binder transaction limit regardless of range and calendar
|
* stays well inside the binder transaction limit regardless of range and calendar
|
||||||
* size (see the [SizeMode.Single] note above). Far more than fits on screen — a
|
* size (see the [SizeMode.Exact] note above). Far more than fits on screen — a
|
||||||
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
||||||
* risking a failed update for.
|
* risking a failed update for.
|
||||||
*/
|
*/
|
||||||
@@ -422,11 +416,7 @@ private fun EventRow(
|
|||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(
|
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
|
||||||
color = titleColor,
|
|
||||||
fontSize = metrics.eventTitle,
|
|
||||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = eventTimeSummary(context, event, day, is24Hour),
|
text = eventTimeSummary(context, event, day, is24Hour),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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
|
||||||
@@ -26,6 +25,7 @@ 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,7 +33,6 @@ 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
|
||||||
@@ -98,27 +97,10 @@ 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) {
|
||||||
@@ -136,17 +118,7 @@ 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
|
||||||
@@ -154,7 +126,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().update(context.applicationContext, glanceId)
|
MonthWidget().updateAll(context.applicationContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -162,11 +134,11 @@ class ShiftMonthAction : ActionCallback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jump the displayed month back to the current month. See [ShiftMonthAction]. */
|
/** Jump the displayed month back to the current month. */
|
||||||
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().update(context.applicationContext, glanceId)
|
MonthWidget().updateAll(context.applicationContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,75 +286,29 @@ private fun WeekRow(
|
|||||||
colW: Dp,
|
colW: Dp,
|
||||||
modifier: GlanceModifier,
|
modifier: GlanceModifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
val hidden = week.hiddenPerDay()
|
// Day numbers.
|
||||||
Box(modifier = modifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.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 ->
|
week.days.forEach { date ->
|
||||||
Box(
|
DayNumber(
|
||||||
modifier = GlanceModifier
|
date = date,
|
||||||
.width(colW)
|
isToday = date == today,
|
||||||
.fillMaxHeight()
|
inMonth = date.month == currentMonth,
|
||||||
.clickable(openDayAction(context, date)),
|
colW = colW,
|
||||||
) {}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
// Day numbers. Fixed height so every week's number row lines up whether
|
// One lane row per event row. A multi-day span is a single Box spanning
|
||||||
// or not it holds today's larger pill.
|
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||||
Row(
|
repeat(MAX_LANES) { lane ->
|
||||||
modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
|
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Spacer(GlanceModifier.height(1.dp))
|
||||||
) {
|
|
||||||
week.days.forEach { date ->
|
|
||||||
DayNumber(
|
|
||||||
date = date,
|
|
||||||
isToday = date == today,
|
|
||||||
inMonth = date.month == currentMonth,
|
|
||||||
colW = colW,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
|
||||||
// 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.
|
|
||||||
// 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)
|
|
||||||
Spacer(GlanceModifier.height(1.dp))
|
|
||||||
}
|
|
||||||
if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW)
|
|
||||||
}
|
}
|
||||||
|
OverflowRow(week = week, colW = colW)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* How many lanes this week actually fills, counting from the top. The grid draws
|
|
||||||
* that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views
|
|
||||||
* per day and most weeks leave the last two empty.
|
|
||||||
*/
|
|
||||||
private fun MonthWeek.usedLanes(): Int {
|
|
||||||
for (lane in MAX_LANES - 1 downTo 0) {
|
|
||||||
val filled = spans.any { it.lane == lane } ||
|
|
||||||
days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null }
|
|
||||||
if (filled) return lane + 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
|
|
||||||
private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
|
|
||||||
val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
|
||||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
|
||||||
val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
|
|
||||||
((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open [date]'s day view rooted in the month view (so back returns to the month
|
* Open [date]'s day view rooted in the month view (so back returns to the month
|
||||||
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
||||||
@@ -392,93 +318,64 @@ private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
|
|||||||
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 style = TextStyle(
|
val context = LocalContext.current
|
||||||
color = when {
|
Box(
|
||||||
isToday -> GlanceTheme.colors.onPrimary
|
modifier = GlanceModifier
|
||||||
inMonth -> GlanceTheme.colors.onSurface
|
.width(colW)
|
||||||
else -> GlanceTheme.colors.onSurfaceVariant
|
.height(DAY_NUMBER_HEIGHT)
|
||||||
},
|
.clickable(openDayAction(context, date)),
|
||||||
fontSize = 11.sp,
|
contentAlignment = Alignment.Center,
|
||||||
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(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.size(DAY_NUMBER_HEIGHT)
|
.size(DAY_NUMBER_HEIGHT)
|
||||||
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
|
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
|
||||||
.background(GlanceTheme.colors.primary),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(text = date.day.toString(), style = style)
|
Text(
|
||||||
|
text = date.day.toString(),
|
||||||
|
style = TextStyle(
|
||||||
|
color = when {
|
||||||
|
isToday -> GlanceTheme.colors.onPrimary
|
||||||
|
inMonth -> GlanceTheme.colors.onSurface
|
||||||
|
else -> GlanceTheme.colors.onSurfaceVariant
|
||||||
|
},
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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()) {
|
||||||
week.laneCells(lane).forEach { cell ->
|
var col = 0
|
||||||
if (cell.event == null) {
|
while (col < 7) {
|
||||||
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
|
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||||
|
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 {
|
||||||
SpanBar(
|
val timed = timedEventAt(week, lane, col, week.days[col])
|
||||||
event = cell.event,
|
if (timed != null) {
|
||||||
dark = dark,
|
SpanBar(event = timed, dark = dark, soften = soften, width = colW)
|
||||||
soften = soften,
|
} else {
|
||||||
width = colW * cell.cols,
|
// Empty lane cell: a tap opens that day, so blank space in a
|
||||||
)
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -517,39 +414,41 @@ 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(
|
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
|
||||||
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(hidden: List<Int>, colW: Dp) {
|
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||||
|
val context = LocalContext.current
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
var gap = 0
|
week.days.forEachIndexed { col, date ->
|
||||||
hidden.forEach { count ->
|
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||||
if (count == 0) {
|
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||||
gap += 1
|
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
||||||
return@forEach
|
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
||||||
|
// 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.
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.width(colW)
|
||||||
|
.height(LANE_HEIGHT)
|
||||||
|
.clickable(openDayAction(context, date)),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
if (hidden > 0) {
|
||||||
|
Text(
|
||||||
|
text = "+$hidden",
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||||
|
modifier = GlanceModifier.padding(start = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (gap > 0) {
|
|
||||||
Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
|
||||||
gap = 0
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "+$count",
|
|
||||||
maxLines = 1,
|
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
|
||||||
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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
|
||||||
@@ -18,7 +17,6 @@ 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,
|
||||||
@@ -30,7 +28,6 @@ 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
|
||||||
@@ -93,20 +90,4 @@ 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
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.
|
|
||||||
* The re-read stays silent — the loaded content must not blink back to the
|
|
||||||
* skeleton on the way.
|
|
||||||
*/
|
|
||||||
@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)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply {
|
|
||||||
calendarsResult = listOf(
|
|
||||||
CalendarSource(
|
|
||||||
id = 1L, displayName = "Cal", accountName = "acc@local", accountType = "LOCAL",
|
|
||||||
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
eventDetailResult = { detail(description()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `re-opening the same occurrence re-reads it`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
|
||||||
var stored: String? = null
|
|
||||||
val vm = viewModel(tempDir, fakeSource { stored })
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the re-read does not fall back to the skeleton`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
|
||||||
val vm = viewModel(tempDir, fakeSource { null })
|
|
||||||
val seen = mutableListOf<EventDetailUiState>()
|
|
||||||
val collector = launch(Job()) { vm.state.collect { seen += it } }
|
|
||||||
|
|
||||||
vm.open(42L, beginMillis, endMillis)
|
|
||||||
advanceUntilIdle()
|
|
||||||
assertThat(vm.state.value).isInstanceOf(EventDetailUiState.Success::class.java)
|
|
||||||
|
|
||||||
seen.clear()
|
|
||||||
vm.open(42L, beginMillis, endMillis)
|
|
||||||
advanceUntilIdle()
|
|
||||||
assertThat(seen).doesNotContain(EventDetailUiState.Loading)
|
|
||||||
|
|
||||||
collector.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
إصلاحات
|
|
||||||
• النقر على حدث في عرض الشهر يفتح الحدث نفسه بدل يومه.
|
|
||||||
• التعديل يظهر فور إعادة فتح الحدث.
|
|
||||||
• لم تعد أسهم أداة الشهر تتوقف بعد بضع نقرات: تحديث ضخم كان يُسقط مضيف الأدوات في المشغّل.
|
|
||||||
• «اليوم» في الأسابيع المتصلة يصل إلى الأسبوع الحالي تماماً، وعادت مسافة الهامش على يسار عرضي اليوم والأسبوع.
|
|
||||||
|
|
||||||
تغييرات
|
|
||||||
• الحدث الذي رفضته يظهر مشطوباً في كل مكان ولم يعد يُذكّرك به.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
Behoben
|
|
||||||
• Ein Tippen auf einen Termin in der Monatsansicht öffnet ihn statt des Tages.
|
|
||||||
• Eine Änderung ist sofort sichtbar, wenn der Termin neu geöffnet wird.
|
|
||||||
• Die Pfeile des Monats-Widgets sterben nicht mehr nach wenigen Tipps – eine zu große Aktualisierung riss den Widget-Host mit.
|
|
||||||
• „Heute“ springt in nahtlosen Wochen genau auf die aktuelle Woche; Wochen- und Tagesansicht haben rechts wieder Abstand.
|
|
||||||
|
|
||||||
Geändert
|
|
||||||
• Ein abgelehnter Termin ist überall durchgestrichen und erinnert nicht mehr.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
Fixed
|
|
||||||
• Tapping an event in month view now opens the event instead of its day.
|
|
||||||
• An edit shows straight away when you re-open the event.
|
|
||||||
• The month widget's arrows no longer die after a few taps — an oversized update was killing the launcher's widget host.
|
|
||||||
• Jump-to-today in seamless weeks lands on the current week, and day/week view no longer run flush against the right edge.
|
|
||||||
|
|
||||||
Changed
|
|
||||||
• A meeting you declined is struck through everywhere and no longer reminds you.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
Fixed
|
|
||||||
• Tapping an event in month view now opens the event instead of its day.
|
|
||||||
• An edit shows straight away when you re-open the event.
|
|
||||||
• The month widget's arrows no longer die after a few taps — an oversized update was killing the launcher's widget host.
|
|
||||||
• Jump-to-today in seamless weeks lands on the current week, and day/week view no longer run flush against the right edge.
|
|
||||||
|
|
||||||
Changed
|
|
||||||
• A meeting you declined is struck through everywhere and no longer reminds you.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
Corregido
|
|
||||||
• Tocar un evento en la vista de mes abre el evento, no su día.
|
|
||||||
• Un cambio se ve en cuanto vuelves a abrir el evento.
|
|
||||||
• Las flechas del widget de mes ya no se quedan muertas tras unos toques: una actualización demasiado grande tumbaba el host de widgets del launcher.
|
|
||||||
• «Hoy» en semanas continuas llega justo a la semana actual, y las vistas de día y semana vuelven a tener margen a la derecha.
|
|
||||||
|
|
||||||
Cambios
|
|
||||||
• Un evento que has rechazado aparece tachado en todas partes y ya no avisa.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
Corrigé
|
|
||||||
• Appuyer sur un événement en vue mois ouvre l'événement, pas sa journée.
|
|
||||||
• Une modification apparaît dès que l'événement est rouvert.
|
|
||||||
• Les flèches du widget mois ne meurent plus après quelques appuis : une mise à jour trop lourde emportait l'hôte de widgets du lanceur.
|
|
||||||
• « Aujourd'hui » en semaines continues arrive pile sur la semaine en cours ; les vues jour et semaine ont de nouveau une marge à droite.
|
|
||||||
|
|
||||||
Modifié
|
|
||||||
• Un événement refusé est barré partout et ne vous rappelle plus rien.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
Corretto
|
|
||||||
• Toccare un evento nella vista mese apre l'evento, non il suo giorno.
|
|
||||||
• Una modifica si vede subito riaprendo l'evento.
|
|
||||||
• Le frecce del widget mese non muoiono più dopo pochi tocchi: un aggiornamento troppo grande abbatteva l'host dei widget del launcher.
|
|
||||||
• «Oggi» nelle settimane continue arriva esattamente sulla settimana corrente, e le viste giorno e settimana hanno di nuovo un margine a destra.
|
|
||||||
|
|
||||||
Modifiche
|
|
||||||
• Un evento rifiutato è barrato ovunque e non invia più promemoria.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
Poprawki
|
|
||||||
• Dotknięcie wydarzenia w widoku miesiąca otwiera wydarzenie, a nie jego dzień.
|
|
||||||
• Zmiana jest widoczna od razu po ponownym otwarciu wydarzenia.
|
|
||||||
• Strzałki widżetu miesiąca nie zamierają już po kilku dotknięciach – zbyt duża aktualizacja kładła host widżetów launchera.
|
|
||||||
• „Dziś” w ciągłych tygodniach trafia dokładnie w bieżący tydzień, a widoki dnia i tygodnia znów mają margines z prawej.
|
|
||||||
|
|
||||||
Zmiany
|
|
||||||
• Odrzucone wydarzenie jest wszędzie przekreślone i nie przypomina o sobie.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
Corrigido
|
|
||||||
• Tocar em um evento na visualização de mês abre o evento, e não o seu dia.
|
|
||||||
• Uma alteração aparece assim que o evento é reaberto.
|
|
||||||
• As setas do widget de mês não morrem mais depois de alguns toques: uma atualização grande demais derrubava o host de widgets do launcher.
|
|
||||||
• "Hoje" nas semanas contínuas chega exatamente na semana atual, e as visualizações de dia e semana voltaram a ter margem à direita.
|
|
||||||
|
|
||||||
Alterações
|
|
||||||
• Um evento recusado fica riscado em todo lugar e não lembra mais você.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
Corrigido
|
|
||||||
• Tocar num evento na vista de mês abre o evento e não o seu dia.
|
|
||||||
• Uma alteração aparece assim que o evento é reaberto.
|
|
||||||
• As setas do widget de mês já não morrem ao fim de alguns toques: uma atualização demasiado grande derrubava o anfitrião de widgets do launcher.
|
|
||||||
• «Hoje» nas semanas contínuas chega mesmo à semana atual, e as vistas de dia e semana voltam a ter margem à direita.
|
|
||||||
|
|
||||||
Alterações
|
|
||||||
• Um evento recusado fica riscado em todo o lado e deixa de lembrar.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
Исправлено
|
|
||||||
• Нажатие на событие в виде месяца открывает событие, а не его день.
|
|
||||||
• Изменение видно сразу при повторном открытии события.
|
|
||||||
• Стрелки виджета месяца больше не отмирают после нескольких нажатий: слишком большое обновление роняло хост виджетов лаунчера.
|
|
||||||
• «Сегодня» в непрерывных неделях попадает точно на текущую неделю, а у видов дня и недели снова есть отступ справа.
|
|
||||||
|
|
||||||
Изменения
|
|
||||||
• Отклонённое событие везде зачёркнуто и больше не напоминает о себе.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
修复
|
|
||||||
• 在月视图中点按事件现在会打开该事件本身,而不是它所在的那一天。
|
|
||||||
• 编辑后再次打开事件,改动会立即显示。
|
|
||||||
• 月视图小部件的箭头不再点几下就失灵:过大的更新会拖垮启动器的小部件宿主。
|
|
||||||
• 连续周视图中的“今天”会精确定位到本周,日视图和周视图右侧也重新留出了间距。
|
|
||||||
|
|
||||||
变更
|
|
||||||
• 已拒绝的事件在各处均以删除线标记,并且不再提醒。
|
|
||||||
Reference in New Issue
Block a user