Release v2.20.4 (#313)
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 13m14s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m26s

Patch release rounding up the three issues in the 2.20.4 milestone.

- An event opened from a reminder or a home-screen widget came back on every
  later launch — an error screen once that event had been deleted. Android
  hands back the intent that started the task each time the activity is
  rebuilt, and the event channels acted on it every time instead of once
  (#309).
- "Today" did nothing in the Agenda once you had scrolled ahead: the agenda is
  browsed by scrolling, not by moving its date, so resetting the date alone
  changed nothing. It now scrolls the list back, and the button stays offered
  while the list is scrolled away (#305).
- The self-hosted F-Droid repo filed the app under "Time" instead of
  "Calendar & Agenda" (#294).

Plus the release commit: CHANGELOG section, versionName 2.20.4 / versionCode
22004, and the en-US 22004 changelog. Merging this to main triggers the release
pipeline.

Closes #309
Closes #305
Closes #294

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/313
This commit is contained in:
Jean-Luc Makiola
2026-09-20 21:16:11 +02:00
co-authored by makiolaj
parent c4bb7ae0b2
commit 5c20c5f6eb
6 changed files with 91 additions and 8 deletions
+21
View File
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.20.4] — 2026-09-20
### Fixed
- **The app no longer reopens the event you last came in on.** Opening an event
from a reminder or a home-screen widget made the app return to that same
event on every later launch — and show an error screen once the event had
been deleted. Android hands an app the launch it was started with again each
time it is rebuilt, and Calendula was acting on it every time instead of
once ([#309]).
- **"Today" works in the agenda again.** Scrolling a few days ahead and tapping
today did nothing, because the agenda is browsed by scrolling rather than by
moving its date, and the button only moved the date. It now brings the list
back to today, and it stays offered while the list is scrolled away — before,
it was hidden in exactly the situation it is for ([#305]).
- **Calendula is filed under "Calendar & Agenda" in the self-hosted repo.**
Browsing by category in an F-Droid client set to prefer that repo listed the
app under "Time" instead ([#294]).
## [2.20.3] — 2026-09-12 ## [2.20.3] — 2026-09-12
### Fixed ### Fixed
@@ -1693,3 +1711,6 @@ automatically, with zero telemetry and no internet permission.
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239 [#239]: https://codeberg.org/jlmakiola/calendula/issues/239
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297 [#297]: https://codeberg.org/jlmakiola/calendula/issues/297
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298 [#298]: https://codeberg.org/jlmakiola/calendula/issues/298
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
+2 -2
View File
@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag + // which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 22003 versionCode = 22004
versionName = "2.20.3" versionName = "2.20.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -113,11 +113,26 @@ class MainActivity : AppCompatActivity() {
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_YES
applyEdgeToEdge() applyEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull() // Android hands the intent that started the task back to onCreate every
// time the activity is recreated — on a rotation, or on a restart after
// the system reclaimed the process. Re-running a launch that opens one
// specific event is what made a single reminder or widget tap re-open
// that event on every later launch, an error screen once the event had
// been deleted (#309); the user left it long ago, and the activity
// restores where they actually were along with its saved state.
//
// Only those channels are held back. An .ics import or a prefilled
// create form keeps no saved state of its own, so re-reading the intent
// is what carries one through a recreation.
val replaying = savedInstanceState != null || intent.isRelaunch()
if (!replaying) {
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedEditKey = intent.editEventKeyOrNull()
}
requestedNav = intent.navRequestOrNull() requestedNav = intent.navRequestOrNull()
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
requestedImportUri = intent.importUriOrNull() requestedImportUri = intent.importUriOrNull()
requestedInsert = intent.insertRequestOrNull() requestedInsert = intent.insertRequestOrNull()
requestedEditKey = intent.editEventKeyOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent { setContent {
// One activity-scoped SettingsViewModel drives both the theme here // One activity-scoped SettingsViewModel drives both the theme here
@@ -242,6 +257,16 @@ class MainActivity : AppCompatActivity() {
intent.editEventKeyOrNull()?.let { requestedEditKey = it } intent.editEventKeyOrNull()?.let { requestedEditKey = it }
} }
/**
* Whether this launch is the task being resumed rather than a fresh
* delivery — Android sets the flag when an activity is started from the
* recents list, where the intent it carries is the one the task was started
* with however long ago. `onNewIntent` is the channel a real new request
* arrives on while the task lives, so nothing is lost by ignoring these.
*/
private fun Intent.isRelaunch(): Boolean =
flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
/** /**
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`) * The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the * or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
@@ -13,7 +13,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
@@ -32,6 +34,8 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -106,6 +110,27 @@ fun AgendaScreen(
} }
val successState = state as? AgendaUiState.Success val successState = state as? AgendaUiState.Success
// The agenda window always starts at the anchor, so moving through it is a
// list scroll, not an anchor change — "today" has to bring the list back to
// the top as well, and stay offered while it is scrolled away (#305).
val listState = rememberLazyListState()
val scrolledAway by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
}
}
// A new window is rendered from its first day, so the list belongs at the
// top. Keyed on the anchor the days arrived with, not the one just
// requested: scrolling before the new rows compose leaves the list keyed to
// a row that reappears further down, and it follows it there.
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
val jumpToToday = {
// Off today, moving the anchor is what resets the list (above); on today
// the window doesn't change, so the scroll back is the whole action.
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
viewModel.goToToday()
}
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
drawerContent = { drawerContent = {
@@ -139,14 +164,15 @@ fun AgendaScreen(
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar, showTodayButton = todayInToolbar,
onToday = viewModel::goToToday, onToday = jumpToToday,
) )
}, },
floatingActionButton = { floatingActionButton = {
CalendarFabColumn( CalendarFabColumn(
todayVisible = !isOnToday && !todayInToolbar, todayVisible = (!isOnToday || (scrolledAway && successState != null)) &&
!todayInToolbar,
todayText = stringResource(R.string.agenda_today_action), todayText = stringResource(R.string.agenda_today_action),
onToday = viewModel::goToToday, onToday = jumpToToday,
onCreate = { onCreateEvent(anchor, null) }, onCreate = { onCreateEvent(anchor, null) },
) )
}, },
@@ -195,6 +221,7 @@ fun AgendaScreen(
state = state, state = state,
pastDisplay = pastDisplay, pastDisplay = pastDisplay,
showToday = showToday, showToday = showToday,
listState = listState,
onRetry = viewModel::goToToday, onRetry = viewModel::goToToday,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
@@ -298,6 +325,7 @@ internal fun AgendaContent(
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
) { ) {
when (state) { when (state) {
AgendaUiState.Loading -> Box(modifier) AgendaUiState.Loading -> Box(modifier)
@@ -326,12 +354,16 @@ internal fun AgendaContent(
enabled = showToday && state.anchor == state.today, enabled = showToday && state.anchor == state.today,
) )
if (days.isEmpty()) { if (days.isEmpty()) {
// Nothing to scroll: drop the position a previous list left, so
// the today FAB doesn't linger over an empty screen.
LaunchedEffect(Unit) { listState.scrollToItem(0) }
AgendaEmpty(modifier) AgendaEmpty(modifier)
} else { } else {
AgendaList( AgendaList(
days = days, days = days,
today = state.today, today = state.today,
zone = state.zone, zone = state.zone,
listState = listState,
dimPast = pastDisplay == PastEventDisplay.DIM, dimPast = pastDisplay == PastEventDisplay.DIM,
now = now, now = now,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -349,6 +381,7 @@ private fun AgendaList(
days: List<AgendaDay>, days: List<AgendaDay>,
today: LocalDate, today: LocalDate,
zone: TimeZone, zone: TimeZone,
listState: LazyListState,
dimPast: Boolean, dimPast: Boolean,
now: Instant, now: Instant,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
@@ -357,6 +390,7 @@ private fun AgendaList(
) { ) {
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
state = listState,
// Bottom inset clears the FAB stack so the last row stays tappable. // Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) { ) {
@@ -0,0 +1,3 @@
Fixed
• The app no longer reopens the event you last came in on. Opening an event from a reminder or a widget made it return to that event on every later launch, or show an error screen once the event was gone.
• "Today" works in the Agenda again. It now scrolls the list back to today, and stays offered while you are scrolled away from it.
@@ -4,7 +4,7 @@ Name: Calendula
Summary: A modern Material 3 Expressive calendar for Android. Summary: A modern Material 3 Expressive calendar for Android.
Categories: Categories:
- Time - Calendar & Agenda
SourceCode: https://codeberg.org/jlmakiola/calendula SourceCode: https://codeberg.org/jlmakiola/calendula
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues IssueTracker: https://codeberg.org/jlmakiola/calendula/issues