Compare commits
5 Commits
fix/214-wi
...
d0db7502a3
| Author | SHA1 | Date | |
|---|---|---|---|
| d0db7502a3 | |||
| c7375c28d8 | |||
| d8e06f059c | |||
| a5b38aec9e | |||
|
|
2bf7d00f39 |
41
CHANGELOG.md
41
CHANGELOG.md
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.19.2] — 2026-08-17
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **A meeting you declined is now struck through** wherever it appears — month,
|
||||||
|
week, day, agenda, search and both widgets — and no longer schedules a
|
||||||
|
reminder. Declining an invitation in Google Calendar left the event looking
|
||||||
|
like any other in Calendula, and it still notified you about a meeting you had
|
||||||
|
said no to. It stays visible rather than disappearing: the organiser still
|
||||||
|
expects an answer from you, and the slot is still spoken for ([#180]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Tapping an event in month view opens the event**, not the day it sits on.
|
||||||
|
Every month style is affected — page, rolling, seamless weeks — and until now
|
||||||
|
the only way to reach an event from the month was to open its day first and
|
||||||
|
find it again there. Tapping anywhere else in the cell still opens the day
|
||||||
|
([#187]).
|
||||||
|
- **An edit made to an event now shows the moment you re-open it.** Adding a
|
||||||
|
description to an event and opening it again showed the sheet as it was before
|
||||||
|
the save, because the detail was only re-read when a different occurrence was
|
||||||
|
opened ([#196]).
|
||||||
|
- **The month widget's arrows stopped working after a couple of taps.** The grid
|
||||||
|
was serialised as roughly 740 views, and one update ran to half a megabyte —
|
||||||
|
more than the launcher's buffer takes. The third update overran it, and Android
|
||||||
|
responded by dropping the whole widget host, which killed updates for *every*
|
||||||
|
widget on the home screen, ours and other apps', until the launcher rebound.
|
||||||
|
The grid now draws 192 views, and a resized widget reflows to its new size
|
||||||
|
instead of clipping ([#214]).
|
||||||
|
- Tapping a widget's refresh or month arrows redraws that widget by its own id
|
||||||
|
instead of asking Android to update all of them, which does nothing in a
|
||||||
|
process the tap has just woken from cold ([#18]).
|
||||||
|
- **Jump-to-today in seamless weeks lands on the current week** instead of
|
||||||
|
leaving a sliver of the previous row on screen ([#191]).
|
||||||
|
- Day and week view no longer run their events flush against the right edge
|
||||||
|
([#192]).
|
||||||
|
|
||||||
## [2.19.1] — 2026-08-11
|
## [2.19.1] — 2026-08-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1414,3 +1449,9 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||||
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
||||||
|
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
||||||
|
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
||||||
|
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
||||||
|
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
||||||
|
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
||||||
|
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
||||||
|
|||||||
@@ -48,9 +48,14 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _target = MutableStateFlow<Target?>(null)
|
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)
|
private val _reload = MutableStateFlow(0)
|
||||||
|
|
||||||
|
// Last target whose content is already on screen; a re-read of it skips the
|
||||||
|
// Loading skeleton so the sheet doesn't blank out between two identical reads.
|
||||||
|
private var loadedTarget: Target? = null
|
||||||
|
|
||||||
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
|
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
|
||||||
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
|
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
|
||||||
|
|
||||||
@@ -58,11 +63,14 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
combine(_target, _reload) { target, _ -> target }
|
combine(_target, _reload) { target, _ -> target }
|
||||||
.flatMapLatest { target ->
|
.flatMapLatest { target ->
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
|
loadedTarget = null
|
||||||
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
|
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
|
||||||
} else {
|
} else {
|
||||||
flow {
|
flow {
|
||||||
emit(EventDetailUiState.Loading)
|
if (loadedTarget != target) emit(EventDetailUiState.Loading)
|
||||||
emit(loadDetail(target))
|
val loaded = loadDetail(target)
|
||||||
|
loadedTarget = target.takeIf { loaded is EventDetailUiState.Success }
|
||||||
|
emit(loaded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2177,7 +2177,7 @@ private fun MonthWeekRow(
|
|||||||
// leaves both the click and a pickup in flight untouched. Padded and
|
// leaves both the click and a pickup in flight untouched. Padded and
|
||||||
// clipped to the background pill so the ripple matches it. A blanked
|
// clipped to the background pill so the ripple matches it. A blanked
|
||||||
// cell isn't part of this month, so it takes no taps either.
|
// cell isn't part of this month, so it takes no taps either.
|
||||||
val downY = remember(week.days.size) { FloatArray(week.days.size) }
|
val downY = remember(week.days.size) { FloatArray(week.days.size) { NO_DOWN_Y } }
|
||||||
Row(Modifier.matchParentSize()) {
|
Row(Modifier.matchParentSize()) {
|
||||||
week.days.forEachIndexed { col, d ->
|
week.days.forEachIndexed { col, d ->
|
||||||
if (blankOutside && !inMonth(d)) {
|
if (blankOutside && !inMonth(d)) {
|
||||||
@@ -2198,9 +2198,14 @@ private fun MonthWeekRow(
|
|||||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
.clip(CELL_SHAPE)
|
.clip(CELL_SHAPE)
|
||||||
.clickable {
|
.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(
|
val chip = week.chipAtCellY(
|
||||||
col = col,
|
col = col,
|
||||||
cellY = downY[col],
|
cellY = cellY,
|
||||||
bandTopInCell = bandTopInCell(
|
bandTopInCell = bandTopInCell(
|
||||||
cellCoordinates,
|
cellCoordinates,
|
||||||
bandCoordinates,
|
bandCoordinates,
|
||||||
@@ -2231,6 +2236,11 @@ private fun bandTopInCell(
|
|||||||
return bandTop - cellTop
|
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 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
|
* the row's day-column box. Null for a tap above the band (the day number), on an
|
||||||
|
|||||||
@@ -74,11 +74,6 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
|
||||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
|
||||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
|
||||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
||||||
* The range is read reactively in the composition ([currentState]) so a settings
|
* The range is read reactively in the composition ([currentState]) so a settings
|
||||||
@@ -113,6 +108,11 @@ internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_to
|
|||||||
*/
|
*/
|
||||||
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
|
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||||
|
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||||
|
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||||
|
*/
|
||||||
class AgendaWidget : GlanceAppWidget() {
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
@@ -136,9 +136,11 @@ class AgendaWidget : GlanceAppWidget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-reads the calendar and redraws the widget (header refresh button). Targets
|
* Redraws the widget (header refresh button). Targets the tapped widget's own id
|
||||||
* the tapped widget's own id rather than `updateAll`, whose provider-name lookup
|
* rather than `updateAll`, whose provider-name lookup is empty in a process a tap
|
||||||
* is empty in a process a tap woke from cold — see `ShiftMonthAction` (#18).
|
* woke from cold — see `ShiftMonthAction` (#18). A cold process re-reads the
|
||||||
|
* calendar in the `provideGlance` preamble; a live session only recomposes from
|
||||||
|
* the snapshot it already has (see [AGENDA_RANGE_KEY]).
|
||||||
*/
|
*/
|
||||||
class RefreshAgendaAction : ActionCallback {
|
class RefreshAgendaAction : ActionCallback {
|
||||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
@@ -149,7 +151,7 @@ class RefreshAgendaAction : ActionCallback {
|
|||||||
/**
|
/**
|
||||||
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
||||||
* stays well inside the binder transaction limit regardless of range and calendar
|
* stays well inside the binder transaction limit regardless of range and calendar
|
||||||
* size (see the [SizeMode.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
|
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
||||||
* risking a failed update for.
|
* risking a failed update for.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import kotlin.time.Instant
|
|||||||
/**
|
/**
|
||||||
* Re-opening an occurrence must re-read it (#196): the view model outlives the
|
* 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.
|
* 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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class EventDetailViewModelTest {
|
class EventDetailViewModelTest {
|
||||||
@@ -72,19 +74,20 @@ class EventDetailViewModelTest {
|
|||||||
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher)
|
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
|
@Test
|
||||||
fun `re-opening the same occurrence re-reads it`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
fun `re-opening the same occurrence re-reads it`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||||
var stored: String? = null
|
var stored: String? = null
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val vm = viewModel(tempDir, fakeSource { stored })
|
||||||
calendarsResult = listOf(
|
|
||||||
CalendarSource(
|
|
||||||
id = 1L, displayName = "Cal", accountName = "acc@local", accountType = "LOCAL",
|
|
||||||
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
eventDetailResult = { detail(stored) }
|
|
||||||
}
|
|
||||||
val vm = viewModel(tempDir, fake)
|
|
||||||
val collector = launch(Job()) { vm.state.collect {} }
|
val collector = launch(Job()) { vm.state.collect {} }
|
||||||
|
|
||||||
vm.open(42L, beginMillis, endMillis)
|
vm.open(42L, beginMillis, endMillis)
|
||||||
@@ -100,4 +103,22 @@ class EventDetailViewModelTest {
|
|||||||
|
|
||||||
collector.cancel()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
fastlane/metadata/android/en-US/changelogs/21902.txt
Normal file
8
fastlane/metadata/android/en-US/changelogs/21902.txt
Normal 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.
|
||||||
Reference in New Issue
Block a user