Compare commits

..

1 Commits

Author SHA1 Message Date
Jean-Luc Makiola
d03985eada Release 2.19.2 (#216)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 16m50s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m34s
Release 2.19.2 — `versionName` 2.19.2 / `versionCode` 21902. Merging this cuts the release.

A meeting you declined is struck through everywhere it appears and no longer schedules a reminder (#180). Tapping an event in month view opens the event instead of its day, in every month style (#187). An edit shows immediately when the same occurrence is re-opened (#196).

The month widget's arrows died after a couple of taps: the grid serialised ~740 views and one update ran to 450–540 KB, which overran the launcher's async binder buffer and made Android drop the whole widget host — killing updates for every widget on the home screen. The grid is down to 192 views, and `SizeMode.Exact` is kept so a resized widget reflows instead of clipping (#214). Widget taps now redraw the tapped widget by its own id rather than `updateAll`, whose provider lookup is empty in a cold-woken process (#18).

Jump-to-today in seamless weeks lands on the current week row (#191), and the day and week timelines got an end inset (#192).

Two follow-ups beyond the issues: the detail re-read keeps the loaded content on screen instead of blinking back to the skeleton, and a month chip tap with no recorded touch down (TalkBack, D-pad) no longer resolves to the previous tap's chip.

Release notes: `CHANGELOG.md` 2.19.2 section and `fastlane/metadata/android/en-US/changelogs/21902.txt` (471 chars).

Closes #18
Closes #180
Closes #187
Closes #191
Closes #192
Closes #196
Closes #214

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/216
2026-08-17 21:54:46 +02:00
30 changed files with 581 additions and 104 deletions

View File

@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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
### Added
@@ -1414,3 +1449,9 @@ automatically, with zero telemetry and no internet permission.
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
[#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

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.provider.CalendarContract
import android.util.Log
import de.jeanlucmakiola.calendula.domain.EventInstance
@@ -38,5 +39,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
color = color,
location = getString(InstanceProjection.IDX_LOCATION),
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
)
}

View File

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

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
@@ -42,5 +43,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
location = getString(SearchProjection.IDX_LOCATION),
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
)
}

View File

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

View File

@@ -67,6 +67,13 @@ data class EventInstance(
* Instances query already yields one row per occurrence.
*/
val isRecurring: Boolean = false,
/**
* This device user answered "no" to the invitation
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is
* still an appointment someone expects an answer about — but every surface
* strikes it through, and it plans no reminders (#180).
*/
val isDeclined: Boolean = false,
)
/**

View File

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

View File

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

View File

@@ -106,6 +106,7 @@ import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -530,6 +531,7 @@ private fun AllDayBar(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
}
}
@@ -778,6 +780,7 @@ private fun EventBlock(
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
)
}
if (showTime) {

View File

@@ -48,9 +48,14 @@ class EventDetailViewModel @Inject constructor(
) : ViewModel() {
private val _target = MutableStateFlow<Target?>(null)
// Bumped by retry() to re-run the load for the same target.
// Bumped by retry() and by re-opening the target already shown, to re-run
// the load without changing the target.
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)
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
@@ -58,11 +63,14 @@ class EventDetailViewModel @Inject constructor(
combine(_target, _reload) { target, _ -> target }
.flatMapLatest { target ->
if (target == null) {
loadedTarget = null
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
} else {
flow {
emit(EventDetailUiState.Loading)
emit(loadDetail(target))
if (loadedTarget != target) emit(EventDetailUiState.Loading)
val loaded = loadDetail(target)
loadedTarget = target.takeIf { loaded is EventDetailUiState.Success }
emit(loaded)
}
}
}
@@ -78,9 +86,15 @@ class EventDetailViewModel @Inject constructor(
* the occurrence's own times (from `CalendarContract.Instances`); they
* override the series DTSTART/DTEND so recurring events show the correct
* 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) {
_target.value = Target(eventId, beginMillis, endMillis)
val target = Target(eventId, beginMillis, endMillis)
if (_target.value == target) _reload.value += 1 else _target.value = target
}
/** Re-run the current load after a failure. */

View File

@@ -141,6 +141,7 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
@@ -2176,7 +2177,7 @@ private fun MonthWeekRow(
// leaves both the click and a pickup in flight untouched. Padded and
// clipped to the background pill so the ripple matches it. A blanked
// cell isn't part of this month, so it takes no taps either.
val downY = remember(week.days.size) { FloatArray(week.days.size) }
val downY = remember(week.days.size) { FloatArray(week.days.size) { NO_DOWN_Y } }
Row(Modifier.matchParentSize()) {
week.days.forEachIndexed { col, d ->
if (blankOutside && !inMonth(d)) {
@@ -2197,9 +2198,14 @@ private fun MonthWeekRow(
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.clip(CELL_SHAPE)
.clickable {
// 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 = downY[col],
cellY = cellY,
bandTopInCell = bandTopInCell(
cellCoordinates,
bandCoordinates,
@@ -2230,6 +2236,11 @@ private fun bandTopInCell(
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
@@ -2448,6 +2459,7 @@ private fun MonthBar(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
}
}

View File

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

View File

@@ -90,6 +90,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
@@ -667,6 +668,7 @@ private fun AllDayBar(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
}
}
@@ -946,6 +948,7 @@ private fun EventBlock(
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
)
}
if (showTime) {

View File

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

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.widget.agenda
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable
@@ -73,11 +74,6 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
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]).
* The range is read reactively in the composition ([currentState]) so a settings
@@ -112,6 +108,11 @@ internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_to
*/
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() {
override val stateDefinition = PreferencesGlanceStateDefinition
@@ -135,9 +136,11 @@ class AgendaWidget : GlanceAppWidget() {
}
/**
* Re-reads the calendar and redraws the widget (header refresh button). Targets
* the tapped widget's own id rather than `updateAll`, whose provider-name lookup
* is empty in a process a tap woke from cold — see `ShiftMonthAction` (#18).
* 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 {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
@@ -148,7 +151,7 @@ class RefreshAgendaAction : ActionCallback {
/**
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
* stays well inside the binder transaction limit regardless of range and calendar
* size (see the [SizeMode.Exact] note above). Far more than fits on screen — a
* size (see the [SizeMode.Single] note above). Far more than fits on screen — a
* user scrolling a home-screen widget past a hundred rows is not a case worth
* risking a failed update for.
*/
@@ -419,7 +422,11 @@ private fun EventRow(
Text(
text = title,
maxLines = 1,
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
style = TextStyle(
color = titleColor,
fontSize = metrics.eventTitle,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
)
Text(
text = eventTimeSummary(context, event, day, is24Hour),

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable
@@ -32,6 +33,7 @@ import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
@@ -96,10 +98,27 @@ private fun yearMonthOf(index: Int): YearMonth =
* 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
* 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() {
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 suspend fun provideGlance(context: Context, id: GlanceId) {
@@ -295,9 +314,31 @@ private fun WeekRow(
colW: Dp,
modifier: GlanceModifier,
) {
Column(modifier = modifier.fillMaxWidth()) {
// Day numbers.
Row(modifier = GlanceModifier.fillMaxWidth()) {
val context = LocalContext.current
val hidden = week.hiddenPerDay()
Box(modifier = modifier.fillMaxWidth()) {
// Day-open targets, one full-height strip per column, sitting *under* the
// content: a tap anywhere in a day still opens it, as in the app, without
// every cell in the column carrying a click target of its own. The content
// above is not clickable, so touches fall through to this layer; only an
// event bar opts out to open its own detail.
Row(modifier = GlanceModifier.fillMaxSize()) {
week.days.forEach { date ->
Box(
modifier = GlanceModifier
.width(colW)
.fillMaxHeight()
.clickable(openDayAction(context, date)),
) {}
}
}
Column(modifier = GlanceModifier.fillMaxWidth()) {
// Day numbers. Fixed height so every week's number row lines up whether
// or not it holds today's larger pill.
Row(
modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
verticalAlignment = Alignment.CenterVertically,
) {
week.days.forEach { date ->
DayNumber(
date = date,
@@ -310,12 +351,36 @@ private fun WeekRow(
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.
repeat(MAX_LANES) { lane ->
// Lanes past the last one this week fills emit nothing at all.
repeat(week.usedLanes()) { lane ->
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
Spacer(GlanceModifier.height(1.dp))
}
OverflowRow(week = week, colW = colW)
if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW)
}
}
}
/**
* How many lanes this week actually fills, counting from the top. The grid draws
* that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views
* per day and most weeks leave the last two empty.
*/
private fun MonthWeek.usedLanes(): Int {
for (lane in MAX_LANES - 1 downTo 0) {
val filled = spans.any { it.lane == lane } ||
days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null }
if (filled) return lane + 1
}
return 0
}
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
}
/**
@@ -327,25 +392,14 @@ private fun WeekRow(
private fun openDayAction(context: Context, date: LocalDate) =
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
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
val context = LocalContext.current
Box(
modifier = GlanceModifier
.width(colW)
.height(DAY_NUMBER_HEIGHT)
.clickable(openDayAction(context, date)),
contentAlignment = Alignment.Center,
) {
Box(
modifier = GlanceModifier
.size(DAY_NUMBER_HEIGHT)
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
contentAlignment = Alignment.Center,
) {
Text(
text = date.day.toString(),
style = TextStyle(
val style = TextStyle(
color = when {
isToday -> GlanceTheme.colors.onPrimary
inMonth -> GlanceTheme.colors.onSurface
@@ -353,38 +407,78 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
},
fontSize = 11.sp,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
),
textAlign = TextAlign.Center,
)
if (!isToday) {
Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW))
return
}
Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) {
Box(
modifier = GlanceModifier
.size(DAY_NUMBER_HEIGHT)
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
.background(GlanceTheme.colors.primary),
contentAlignment = Alignment.Center,
) {
Text(text = date.day.toString(), style = style)
}
}
}
/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
private data class LaneCell(val event: EventInstance?, val cols: Int)
/**
* A lane split into bars and the gaps between them, with consecutive blank columns
* merged into one gap: the week's day-open taps come from the strip underneath, so
* a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
*/
private fun MonthWeek.laneCells(lane: Int): List<LaneCell> {
val cells = mutableListOf<LaneCell>()
var gap = 0
var col = 0
fun closeGap() {
if (gap > 0) cells += LaneCell(null, gap)
gap = 0
}
while (col < 7) {
val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null
when {
span != null -> {
closeGap()
cells += LaneCell(span.event, span.endCol - col + 1)
col = span.endCol + 1
}
timed != null -> {
closeGap()
cells += LaneCell(timed, 1)
col += 1
}
else -> {
gap += 1
col += 1
}
}
}
closeGap()
return cells
}
@Composable
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) {
var col = 0
while (col < 7) {
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
week.laneCells(lane).forEach { cell ->
if (cell.event == null) {
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
} else {
val timed = timedEventAt(week, lane, col, week.days[col])
if (timed != null) {
SpanBar(event = timed, dark = dark, soften = soften, width = colW)
} else {
// 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
SpanBar(
event = cell.event,
dark = dark,
soften = soften,
width = colW * cell.cols,
)
}
}
}
@@ -423,41 +517,39 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width:
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
style = TextStyle(
color = ColorProvider(eventInk(fill)),
fontSize = 9.sp,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
modifier = GlanceModifier.padding(horizontal = 3.dp),
)
}
}
}
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
@Composable
private fun OverflowRow(week: MonthWeek, colW: Dp) {
val context = LocalContext.current
private fun OverflowRow(hidden: List<Int>, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
week.days.forEachIndexed { col, date ->
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
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) {
var gap = 0
hidden.forEach { count ->
if (count == 0) {
gap += 1
return@forEach
}
if (gap > 0) {
Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
gap = 0
}
Text(
text = "+$hidden",
text = "+$count",
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
modifier = GlanceModifier.padding(start = 3.dp),
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
)
}
}
}
if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
}
}

View File

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

View File

@@ -0,0 +1,124 @@
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()
}
}

View File

@@ -0,0 +1,8 @@
إصلاحات
• النقر على حدث في عرض الشهر يفتح الحدث نفسه بدل يومه.
• التعديل يظهر فور إعادة فتح الحدث.
• لم تعد أسهم أداة الشهر تتوقف بعد بضع نقرات: تحديث ضخم كان يُسقط مضيف الأدوات في المشغّل.
• «اليوم» في الأسابيع المتصلة يصل إلى الأسبوع الحالي تماماً، وعادت مسافة الهامش على يسار عرضي اليوم والأسبوع.
تغييرات
• الحدث الذي رفضته يظهر مشطوباً في كل مكان ولم يعد يُذكّرك به.

View File

@@ -0,0 +1,8 @@
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.

View File

@@ -0,0 +1,8 @@
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.

View File

@@ -0,0 +1,8 @@
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.

View File

@@ -0,0 +1,8 @@
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.

View File

@@ -0,0 +1,8 @@
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.

View File

@@ -0,0 +1,8 @@
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.

View File

@@ -0,0 +1,8 @@
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.

View File

@@ -0,0 +1,8 @@
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ê.

View File

@@ -0,0 +1,8 @@
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.

View File

@@ -0,0 +1,8 @@
Исправлено
• Нажатие на событие в виде месяца открывает событие, а не его день.
• Изменение видно сразу при повторном открытии события.
• Стрелки виджета месяца больше не отмирают после нескольких нажатий: слишком большое обновление роняло хост виджетов лаунчера.
• «Сегодня» в непрерывных неделях попадает точно на текущую неделю, а у видов дня и недели снова есть отступ справа.
Изменения
• Отклонённое событие везде зачёркнуто и больше не напоминает о себе.

View File

@@ -0,0 +1,8 @@
修复
• 在月视图中点按事件现在会打开该事件本身,而不是它所在的那一天。
• 编辑后再次打开事件,改动会立即显示。
• 月视图小部件的箭头不再点几下就失灵:过大的更新会拖垮启动器的小部件宿主。
• 连续周视图中的“今天”会精确定位到本周,日视图和周视图右侧也重新留出了间距。
变更
• 已拒绝的事件在各处均以删除线标记,并且不再提醒。