diff --git a/CHANGELOG.md b/CHANGELOG.md index 399b5d9..c589652 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8e66663..715fe19 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 21901 - versionName = "2.19.1" + versionCode = 21902 + versionName = "2.19.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt index 6c72def..3a3eb92 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt @@ -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, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index ef58672..264c5ac 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -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 } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt index d3baf96..a0e23b9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt @@ -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, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt index 3b1c2e9..6b13564 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt @@ -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 -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 4021109..e6328e5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -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, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt index f2fdd95..4b72c36 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt @@ -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 = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt index 2a4beec..cf4449e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt @@ -36,6 +36,14 @@ val GUTTER_WIDTH = 48.dp */ val GUTTER_CONTENT_START_INSET = 8.dp +/** + * End inset for everything that lines up with the day columns — the week's day + * header, the all-day strip and the timeline itself. Without it the last column + * runs flush into the screen edge while the gutter gives the other side room + * (#192); 8dp mirrors [GUTTER_CONTENT_START_INSET]. + */ +val TIMELINE_CONTENT_END_INSET = 8.dp + private val BADGE_HEIGHT = 20.dp /** How far the fixed hour labels recede while a block is being dragged. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt index d2b9056..0e73450 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt @@ -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 { 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) + } + } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 6b6d4fe..712e7a8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -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 @@ -118,6 +119,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH import de.jeanlucmakiola.calendula.ui.common.HourGutter +import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.week.TimedBlock @@ -471,7 +473,11 @@ private fun AllDayStrip( // Height is hoisted + animated so it resizes smoothly; padding sits // inside it so the content area is lanes * row height. .height(height) - .padding(vertical = ALL_DAY_VERTICAL_PADDING), + .padding( + top = ALL_DAY_VERTICAL_PADDING, + bottom = ALL_DAY_VERTICAL_PADDING, + end = TIMELINE_CONTENT_END_INSET, + ), ) { // Keep the gutter-width offset so the bars line up with the day column. Spacer(Modifier.width(GUTTER_WIDTH)) @@ -525,6 +531,7 @@ private fun AllDayBar( maxLines = 1, overflow = TextOverflow.Ellipsis, color = eventInk(fill), + textDecoration = declinedDecoration(event.isDeclined), ) } } @@ -570,6 +577,7 @@ private fun Timeline( modifier = Modifier .weight(1f) .fillMaxHeight() + .padding(end = TIMELINE_CONTENT_END_INSET) .clip(RoundedCornerShape(16.dp)) .verticalScroll(scrollState) .onGloballyPositioned { dragController.geometry.viewport = it }, @@ -772,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) { @@ -792,7 +801,12 @@ private fun DayLoading() { // Same scale resolution as the loaded timeline, so the skeleton's column // doesn't resize the moment the real day arrives. val totalHeight = scale.hourHeight(maxHeight) * 24 - Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(end = TIMELINE_CONTENT_END_INSET) + .verticalScroll(scrollState), + ) { Spacer(Modifier.width(GUTTER_WIDTH)) Box( modifier = Modifier diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index 353a9ea..cd068cb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -48,9 +48,14 @@ class EventDetailViewModel @Inject constructor( ) : ViewModel() { private val _target = MutableStateFlow(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.Idle) val deleteState: StateFlow = _deleteState.asStateFlow() @@ -58,11 +63,14 @@ class EventDetailViewModel @Inject constructor( combine(_target, _reload) { target, _ -> target } .flatMapLatest { target -> if (target == null) { + loadedTarget = null flowOf(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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index ffcccf6..a929d87 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -19,6 +19,8 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -111,6 +113,7 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource @@ -138,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 @@ -439,6 +443,7 @@ fun MonthScreen( showWeekNumbers = showWeekNumbers, onRetry = jumpToToday, onOpenDay = onOpenDay, + onEventClick = onEventClick, ) } else if (viewStyle == MonthViewStyle.Split) { SplitMonthContent( @@ -463,6 +468,7 @@ fun MonthScreen( onSwipePrev = goPrev, onRetry = jumpToToday, onOpenDay = onOpenDay, + onEventClick = onEventClick, ) } } @@ -573,6 +579,7 @@ private fun MonthContent( onSwipePrev: () -> Unit, onRetry: () -> Unit, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, ) { val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() @@ -599,6 +606,7 @@ private fun MonthContent( state = s, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + onEventClick = onEventClick, ) } } @@ -617,6 +625,7 @@ private fun ContinuousMonthContent( showWeekNumbers: Boolean, onRetry: () -> Unit, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, ) { when (state) { // The scrolling styles get their own skeleton rather than the paged @@ -631,6 +640,7 @@ private fun ContinuousMonthContent( listState = listState, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + onEventClick = onEventClick, ) } else { ContinuousMonthGrid( @@ -638,6 +648,7 @@ private fun ContinuousMonthContent( listState = listState, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + onEventClick = onEventClick, ) } } @@ -736,7 +747,8 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER) /** Width of the split style's selected-day outline. */ private val SPLIT_SELECTION_STROKE = 1.5.dp -private const val MAX_EVENT_ROWS = 3 +/** Lanes of bars/pills a day cell draws before the rest become overflow dots. */ +internal const val MAX_EVENT_ROWS = 3 /** * Row height in the continuous grid. The paged grid divides the viewport between @@ -754,11 +766,15 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp */ private val CONTINUOUS_MONTH_GAP = 20.dp +/** Gap between the weekday header and the seamless stream's first week row. */ +private val DENSE_HEADER_GAP = 4.dp + @Composable internal fun MonthGrid( state: MonthUiState.Success, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, /** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */ selected: LocalDate? = null, ) { @@ -779,6 +795,7 @@ internal fun MonthGrid( inMonth = { it.month == month.month && it.year == month.year }, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + onEventClick = onEventClick, selected = selected, modifier = Modifier .fillMaxWidth() @@ -809,6 +826,7 @@ internal fun ContinuousMonthGrid( listState: LazyListState, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, ) { val monthCount = remember { continuousMonthCount() } @@ -836,6 +854,7 @@ internal fun ContinuousMonthGrid( today = state.today, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + onEventClick = onEventClick, ) } } @@ -855,6 +874,7 @@ private fun ContinuousMonthBlock( today: LocalDate, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, ) { val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) } Column( @@ -877,6 +897,7 @@ private fun ContinuousMonthBlock( blankOutside = true, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + onEventClick = onEventClick, modifier = Modifier .fillMaxWidth() .height(CONTINUOUS_ROW_HEIGHT), @@ -935,6 +956,7 @@ internal fun DenseMonthGrid( listState: LazyListState, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, ) { val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } @@ -942,10 +964,15 @@ internal fun DenseMonthGrid( state = listState, modifier = modifier .fillMaxSize() - .padding(horizontal = 8.dp), + // The gap under the weekday header is a real margin, not content + // padding: a lazy list scrolls its rows *through* the before-content + // padding, so scrolling to a week landed its row that far down with + // the tail of the previous one showing above it (#191). + .padding(horizontal = 8.dp) + .padding(top = DENSE_HEADER_GAP), verticalArrangement = Arrangement.spacedBy(2.dp), // Bottom inset clears the FAB stack so the last row stays tappable. - contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp), + contentPadding = PaddingValues(bottom = 96.dp), ) { items(count = weekCount, key = { it }) { index -> val week = state.weeksByIndex[index] @@ -960,6 +987,7 @@ internal fun DenseMonthGrid( inMonth = { true }, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + onEventClick = onEventClick, labelMonthOnFirst = true, modifier = Modifier .fillMaxWidth() @@ -1187,6 +1215,9 @@ private fun SplitMonthBody( onSelectDay(it) onSetExpanded(false) }, + // A tapped chip is asking for that event, not for its day, so it + // opens the detail from here too rather than collapsing (#187). + onEventClick = onEventClick, onCollapse = { onSetExpanded(false) }, ) } else { @@ -1287,6 +1318,7 @@ private fun SplitMonthExpanded( showWeekNumbers: Boolean, swipeModifier: Modifier, onPickDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, onCollapse: () -> Unit, ) { val slideSpec = rememberCalendarSlideSpec() @@ -1309,6 +1341,7 @@ private fun SplitMonthExpanded( state = s, showWeekNumbers = showWeekNumbers, onOpenDay = onPickDay, + onEventClick = onEventClick, selected = sel, ) } @@ -1789,7 +1822,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) { modifier = Modifier.padding(bottom = 8.dp), ) } else { - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(DENSE_HEADER_GAP)) } // More rows than a viewport holds; the clip takes the overflow. repeat(6) { @@ -1837,6 +1870,7 @@ private fun MonthWeekRow( inMonth: (LocalDate) -> Boolean, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, blankOutside: Boolean = false, labelMonthOnFirst: Boolean = false, @@ -2135,11 +2169,17 @@ private fun MonthWeekRow( } } - // Tap layer: in month view a tap on any day opens that day. Padded and + // Tap layer: a tap on a chip opens that event, anything else opens the + // day (#187). The chips take no pointer input of their own — this + // layer covers them — so the lane under the finger is resolved + // geometrically, exactly as the drag pickup above does it; the down + // position is read on the initial pass, which consumes nothing and so + // leaves both the click and a pickup in flight untouched. Padded and // clipped to the background pill so the ripple matches it. A blanked // 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()) { - week.days.forEach { d -> + week.days.forEachIndexed { col, d -> if (blankOutside && !inMonth(d)) { Spacer(Modifier.weight(1f).fillMaxHeight()) } else { @@ -2147,9 +2187,33 @@ private fun MonthWeekRow( Modifier .weight(1f) .fillMaxHeight() + .pointerInput(col) { + awaitEachGesture { + downY[col] = awaitFirstDown( + requireUnconsumed = false, + pass = PointerEventPass.Initial, + ).position.y + } + } .padding(horizontal = CELL_GAP, vertical = 1.dp) .clip(CELL_SHAPE) - .clickable { onOpenDay(d) }, + .clickable { + // 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) + }, ) } } @@ -2158,6 +2222,43 @@ 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, + band: Array, +): 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 * from the geometry the row just laid out, and abandons the gesture on empty @@ -2358,6 +2459,7 @@ private fun MonthBar( maxLines = 1, overflow = TextOverflow.Ellipsis, color = eventInk(fill), + textDecoration = declinedDecoration(event.isDeclined), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt index 7bc6618..ffe8d27 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -45,6 +45,7 @@ internal fun MonthStylePreview( state = sample.month, showWeekNumbers = false, onOpenDay = {}, + onEventClick = {}, ) MonthViewStyle.Continuous -> ContinuousMonthGrid( state = sample.continuous, @@ -55,6 +56,7 @@ internal fun MonthStylePreview( ), showWeekNumbers = false, onOpenDay = {}, + onEventClick = {}, ) MonthViewStyle.Dense -> DenseMonthGrid( state = sample.continuous, @@ -66,6 +68,7 @@ internal fun MonthStylePreview( ), showWeekNumbers = false, onOpenDay = {}, + onEventClick = {}, ) MonthViewStyle.Split -> { SplitMonthGrid( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 508dcbe..68dfcda 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 869c3a2..10b3fd1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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 @@ -129,6 +130,7 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH import de.jeanlucmakiola.calendula.ui.common.HourGutter +import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec @@ -506,7 +508,7 @@ private fun WeekDayHeader( Row( modifier = Modifier .fillMaxWidth() - .padding(top = 4.dp, bottom = 8.dp), + .padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET), ) { // Mirror the day-column layout (empty weekday line + spacer) so the // badge lines up vertically with the date numbers. The start inset centres @@ -602,7 +604,11 @@ private fun AllDayStrip( // Height is hoisted + animated so it slides and resizes smoothly; // padding sits inside it so the content area is lanes * row height. .height(height) - .padding(vertical = ALL_DAY_VERTICAL_PADDING), + .padding( + top = ALL_DAY_VERTICAL_PADDING, + bottom = ALL_DAY_VERTICAL_PADDING, + end = TIMELINE_CONTENT_END_INSET, + ), ) { // Keep the gutter-width offset so the bars line up with the day columns. Spacer(Modifier.width(GUTTER_WIDTH)) @@ -662,6 +668,7 @@ private fun AllDayBar( maxLines = 1, overflow = TextOverflow.Ellipsis, color = eventInk(fill), + textDecoration = declinedDecoration(event.isDeclined), ) } } @@ -708,6 +715,7 @@ private fun Timeline( modifier = Modifier .weight(1f) .fillMaxHeight() + .padding(end = TIMELINE_CONTENT_END_INSET) .clip(RoundedCornerShape(16.dp)) .verticalScroll(scrollState) .onGloballyPositioned { dragController.geometry.viewport = it }, @@ -940,6 +948,7 @@ private fun EventBlock( maxLines = titleMaxLines, overflow = TextOverflow.Ellipsis, color = eventInk(fill, alpha = 0.85f), + textDecoration = declinedDecoration(block.event.isDeclined), ) } if (showTime) { @@ -977,7 +986,12 @@ private fun WeekLoading() { // Same scale resolution as the loaded timeline, so the skeleton's // columns don't resize the moment the real week arrives. val totalHeight = scale.hourHeight(maxHeight) * 24 - Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(end = TIMELINE_CONTENT_END_INSET) + .verticalScroll(scrollState), + ) { Spacer(Modifier.width(GUTTER_WIDTH)) repeat(7) { Box( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt index a529f0f..d679fe5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index c40288a..622d846 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -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 @@ -24,7 +25,6 @@ import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.lazy.LazyColumn import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent -import androidx.glance.appwidget.updateAll import androidx.glance.background import androidx.glance.currentState import androidx.glance.state.PreferencesGlanceStateDefinition @@ -74,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 @@ -113,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,17 +135,23 @@ 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 { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { - AgendaWidget().updateAll(context.applicationContext) + AgendaWidget().update(context.applicationContext, glanceId) } } /** * 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. */ @@ -416,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), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 24f19a3..6778bda 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -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 @@ -25,7 +26,6 @@ import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.state.updateAppWidgetState -import androidx.glance.appwidget.updateAll import androidx.glance.background import androidx.glance.currentState import androidx.glance.layout.Alignment @@ -33,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 @@ -97,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) { @@ -118,7 +136,17 @@ class MonthWidget : GlanceAppWidget() { } } -/** Step the displayed month by the `delta` action parameter (±1). */ +/** + * Step the displayed month by the `delta` action parameter (±1). + * + * Redrawn through [GlanceAppWidget.update] with the id the callback was handed, + * never `updateAll`. `updateAll` resolves its targets through the provider-name + * -> app-widget-id map Glance persists for the *receiver*, and in a process that + * has done nothing else yet — the one a tap wakes after a reboot — that lookup + * comes back empty and the redraw is dropped: the state write lands, nothing + * recomposes, and the arrows read as dead until something else opens the app and + * starts a session (#18). The tapped widget's own id needs no lookup. + */ class ShiftMonthAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { val delta = parameters[deltaKey] ?: 0 @@ -126,7 +154,7 @@ class ShiftMonthAction : ActionCallback { val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone()) prefs[MONTH_INDEX_KEY] = cur + delta } - MonthWidget().updateAll(context.applicationContext) + MonthWidget().update(context.applicationContext, glanceId) } companion object { @@ -134,11 +162,11 @@ class ShiftMonthAction : ActionCallback { } } -/** Jump the displayed month back to the current month. */ +/** Jump the displayed month back to the current month. See [ShiftMonthAction]. */ class ResetMonthAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) } - MonthWidget().updateAll(context.applicationContext) + MonthWidget().update(context.applicationContext, glanceId) } } @@ -286,29 +314,75 @@ 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 -> - DayNumber( - date = date, - isToday = date == today, - inMonth = date.month == currentMonth, - colW = colW, - ) + Box( + modifier = GlanceModifier + .width(colW) + .fillMaxHeight() + .clickable(openDayAction(context, date)), + ) {} } } - 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 -> - LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW) - Spacer(GlanceModifier.height(1.dp)) + 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, + 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 = 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 * grid) — the same target the in-app month grid uses when a day cell is tapped. @@ -318,64 +392,93 @@ 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, - ) { + val 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, + 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) - .then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier), + .cornerRadius(DAY_NUMBER_HEIGHT / 2) + .background(GlanceTheme.colors.primary), contentAlignment = Alignment.Center, ) { - 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, - ), - ) + 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 { + val cells = mutableListOf() + 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, + ) } } } @@ -414,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, 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) { - Text( - text = "+$hidden", - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), - modifier = GlanceModifier.padding(start = 3.dp), - ) - } + 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 = "+$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)) } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt index a546014..1c7dd1b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt @@ -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() + } + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt new file mode 100644 index 0000000..2a3ae75 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt @@ -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() + 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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipAtCellYTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipAtCellYTest.kt new file mode 100644 index 0000000..2f1ee4c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipAtCellYTest.kt @@ -0,0 +1,122 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * Which chip a tap in a month cell lands on (#187) — the geometry the tap layer + * uses to tell "open this event" from "open this day", given that the chips take + * no pointer input of their own. + */ +class ChipAtCellYTest { + + private val zone = TimeZone.UTC + private val jul26 = YearMonth(2026, Month.JULY) + + /** Band starts 40px down the cell; each lane is 20px tall. */ + private val bandTop = 40f + private val laneHeight = 20f + + /** July 2026 starts on a Wednesday, so this row — Jul 6–12 — sits wholly inside it. */ + private fun rowOfJuly6(events: List) = + 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() + } +} diff --git a/fastlane/metadata/android/ar/changelogs/21902.txt b/fastlane/metadata/android/ar/changelogs/21902.txt new file mode 100644 index 0000000..13c969a --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/21902.txt @@ -0,0 +1,8 @@ +إصلاحات +• النقر على حدث في عرض الشهر يفتح الحدث نفسه بدل يومه. +• التعديل يظهر فور إعادة فتح الحدث. +• لم تعد أسهم أداة الشهر تتوقف بعد بضع نقرات: تحديث ضخم كان يُسقط مضيف الأدوات في المشغّل. +• «اليوم» في الأسابيع المتصلة يصل إلى الأسبوع الحالي تماماً، وعادت مسافة الهامش على يسار عرضي اليوم والأسبوع. + +تغييرات +• الحدث الذي رفضته يظهر مشطوباً في كل مكان ولم يعد يُذكّرك به. diff --git a/fastlane/metadata/android/de-DE/changelogs/21902.txt b/fastlane/metadata/android/de-DE/changelogs/21902.txt new file mode 100644 index 0000000..d60845b --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/21902.txt @@ -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. diff --git a/fastlane/metadata/android/en-GB/changelogs/21902.txt b/fastlane/metadata/android/en-GB/changelogs/21902.txt new file mode 100644 index 0000000..7d9cd37 --- /dev/null +++ b/fastlane/metadata/android/en-GB/changelogs/21902.txt @@ -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. diff --git a/fastlane/metadata/android/en-US/changelogs/21902.txt b/fastlane/metadata/android/en-US/changelogs/21902.txt new file mode 100644 index 0000000..7d9cd37 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21902.txt @@ -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. diff --git a/fastlane/metadata/android/es-ES/changelogs/21902.txt b/fastlane/metadata/android/es-ES/changelogs/21902.txt new file mode 100644 index 0000000..aaf5e31 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/21902.txt @@ -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. diff --git a/fastlane/metadata/android/fr-FR/changelogs/21902.txt b/fastlane/metadata/android/fr-FR/changelogs/21902.txt new file mode 100644 index 0000000..2dfb272 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/21902.txt @@ -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. diff --git a/fastlane/metadata/android/it-IT/changelogs/21902.txt b/fastlane/metadata/android/it-IT/changelogs/21902.txt new file mode 100644 index 0000000..ba49a8d --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/21902.txt @@ -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. diff --git a/fastlane/metadata/android/pl-PL/changelogs/21902.txt b/fastlane/metadata/android/pl-PL/changelogs/21902.txt new file mode 100644 index 0000000..ccd69ec --- /dev/null +++ b/fastlane/metadata/android/pl-PL/changelogs/21902.txt @@ -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. diff --git a/fastlane/metadata/android/pt-BR/changelogs/21902.txt b/fastlane/metadata/android/pt-BR/changelogs/21902.txt new file mode 100644 index 0000000..9fbb84e --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/21902.txt @@ -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ê. diff --git a/fastlane/metadata/android/pt-PT/changelogs/21902.txt b/fastlane/metadata/android/pt-PT/changelogs/21902.txt new file mode 100644 index 0000000..819b5ec --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/21902.txt @@ -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. diff --git a/fastlane/metadata/android/ru-RU/changelogs/21902.txt b/fastlane/metadata/android/ru-RU/changelogs/21902.txt new file mode 100644 index 0000000..bb6a3fe --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/21902.txt @@ -0,0 +1,8 @@ +Исправлено +• Нажатие на событие в виде месяца открывает событие, а не его день. +• Изменение видно сразу при повторном открытии события. +• Стрелки виджета месяца больше не отмирают после нескольких нажатий: слишком большое обновление роняло хост виджетов лаунчера. +• «Сегодня» в непрерывных неделях попадает точно на текущую неделю, а у видов дня и недели снова есть отступ справа. + +Изменения +• Отклонённое событие везде зачёркнуто и больше не напоминает о себе. diff --git a/fastlane/metadata/android/zh-CN/changelogs/21902.txt b/fastlane/metadata/android/zh-CN/changelogs/21902.txt new file mode 100644 index 0000000..11080bf --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/21902.txt @@ -0,0 +1,8 @@ +修复 +• 在月视图中点按事件现在会打开该事件本身,而不是它所在的那一天。 +• 编辑后再次打开事件,改动会立即显示。 +• 月视图小部件的箭头不再点几下就失灵:过大的更新会拖垮启动器的小部件宿主。 +• 连续周视图中的“今天”会精确定位到本周,日视图和周视图右侧也重新留出了间距。 + +变更 +• 已拒绝的事件在各处均以删除线标记,并且不再提醒。