Compare commits
7 Commits
c6ce0830b1
...
fix/recurr
| Author | SHA1 | Date | |
|---|---|---|---|
| 76846420a2 | |||
| 1a3e4f501f | |||
| 974d65f619 | |||
| 2239c38ecc | |||
| 79ad70e0c0 | |||
| f168021f51 | |||
| a2b1e7792b |
32
CHANGELOG.md
32
CHANGELOG.md
@@ -5,9 +5,19 @@ 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).
|
||||||
|
|
||||||
## [Unreleased]
|
## [2.13.0] — 2026-07-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Calendula now speaks Spanish and Italian. Both arrived as community
|
||||||
|
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
|
||||||
|
— a **huge thank you** to
|
||||||
|
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
|
||||||
|
translation and to
|
||||||
|
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
|
||||||
|
one! Pick your language under Settings → Language or in Android's per-app
|
||||||
|
language settings. Strings added in this very release may still show in
|
||||||
|
English until the translations catch up. Want Calendula in your language?
|
||||||
|
Translating happens entirely in the browser — every contribution is welcome.
|
||||||
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
||||||
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
||||||
birthdays, anniversaries and other dates into local "Birthdays",
|
birthdays, anniversaries and other dates into local "Birthdays",
|
||||||
@@ -33,6 +43,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
date number — now opens that day, and the "today" button snaps the grid back
|
date number — now opens that day, and the "today" button snaps the grid back
|
||||||
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
||||||
suggestions ([#18], [#20]).
|
suggestions ([#18], [#20]).
|
||||||
|
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
|
||||||
|
and **Body font** pickers: keep the system default, choose a bundled face
|
||||||
|
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
|
||||||
|
face), or load any `.ttf`/`.otf` file from your device, independently per
|
||||||
|
role. Font size and colour stay with Android's accessibility scaling and the
|
||||||
|
app theme, as discussed on the issue. Thanks to @abrossimow for the
|
||||||
|
suggestion ([#19]).
|
||||||
|
- Choose what the view-switch button cycles through. Settings → Appearance now
|
||||||
|
lets you pick which views the top-right quick-switch button rotates between
|
||||||
|
and drag them into your preferred order; views you switch off stay reachable
|
||||||
|
from the navigation menu, which can be reordered the same way. Thanks to
|
||||||
|
@abrossimow for the suggestion ([#24]).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Month widget arrows and "today" button work again. On release builds the
|
- Month widget arrows and "today" button work again. On release builds the
|
||||||
@@ -49,6 +71,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
nothing applied. The edited occurrence is now stored correctly, so the change
|
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||||
lands on just that one event and leaves the rest of the series untouched
|
lands on just that one event and leaves the rest of the series untouched
|
||||||
([#16]).
|
([#16]).
|
||||||
|
- Every calendar can be picked when creating an event. The event editor's
|
||||||
|
calendar picker was a fixed-height dialog, so with many calendars anything
|
||||||
|
past the first nine or so was simply unreachable. It is now a full-screen,
|
||||||
|
scrollable list grouped by account, with each calendar's colour shown.
|
||||||
|
Thanks to @dschuermann for the report ([#29]).
|
||||||
|
|
||||||
## [2.12.0] — 2026-06-28
|
## [2.12.0] — 2026-06-28
|
||||||
|
|
||||||
@@ -779,4 +806,7 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||||
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||||
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||||
|
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||||
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||||
|
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
|
||||||
|
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||||
|
|||||||
@@ -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 = 21200
|
versionCode = 21300
|
||||||
versionName = "2.12.0"
|
versionName = "2.13.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,26 +23,29 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
attendees: List<Attendee>,
|
attendees: List<Attendee>,
|
||||||
reminders: List<Reminder>,
|
reminders: List<Reminder>,
|
||||||
): EventDetail? {
|
): EventDetail? {
|
||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||||
|
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||||
if (begin < 0L) {
|
// negative — only an *absent* DTSTART marks a malformed row worth dropping.
|
||||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
// Dropping negatives made every occurrence of such a series un-openable
|
||||||
|
// (the detail loads the ancient series-master DTSTART), see issue #34.
|
||||||
|
if (isNull(EventDetailProjection.IDX_DTSTART)) {
|
||||||
|
Log.w(TAG, "Dropping event with missing dtstart")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
// DTEND is null. Keep the event (end == begin); callers that opened a
|
||||||
// specific occurrence supply the real per-occurrence times from
|
// specific occurrence supply the real per-occurrence times from
|
||||||
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
|
// CalendarContract.Instances. A present-but-backwards DTEND is malformed,
|
||||||
|
// but dropping the row would make the event un-openable — the same trap as
|
||||||
|
// the pre-1970 DTSTART bug above (issue #34): it would surface as the
|
||||||
|
// generic error screen with no way to open the event and fix it. Clamp to a
|
||||||
|
// zero-length event instead (matching SearchMapper's coerceAtLeast).
|
||||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||||
begin
|
begin
|
||||||
} else {
|
} else {
|
||||||
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
|
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||||
if (rawEnd < begin) {
|
|
||||||
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
rawEnd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
|||||||
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
||||||
*/
|
*/
|
||||||
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||||
|
// A pre-1970 series anchor is a legitimately negative epoch-millis DTSTART
|
||||||
|
// (see EventDetailMapper / issue #34); drop only a genuinely absent one, so
|
||||||
|
// long-running birthdays/anniversaries still surface in search.
|
||||||
|
if (isNull(SearchProjection.IDX_DTSTART)) return null
|
||||||
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
||||||
if (dtStart < 0L) return null
|
|
||||||
val end = when {
|
val end = when {
|
||||||
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
||||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||||
|
|||||||
@@ -4,14 +4,9 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Resources
|
|
||||||
import android.provider.Settings
|
|
||||||
import android.text.format.DateFormat
|
|
||||||
import androidx.compose.material3.TimePicker
|
import androidx.compose.material3.TimePicker
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
@@ -27,10 +22,13 @@ fun TimePickerAlert(
|
|||||||
onConfirm: (LocalTime) -> Unit,
|
onConfirm: (LocalTime) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// Honour the app's own time-format preference (the value every time label
|
||||||
|
// reads), not the system/locale clock — otherwise an explicit 24h setting
|
||||||
|
// still showed an AM/PM dial (issue #27).
|
||||||
val state = rememberTimePickerState(
|
val state = rememberTimePickerState(
|
||||||
initialHour = initial.hour,
|
initialHour = initial.hour,
|
||||||
initialMinute = initial.minute,
|
initialMinute = initial.minute,
|
||||||
is24Hour = deviceUses24HourClock(LocalContext.current),
|
is24Hour = LocalUse24HourFormat.current,
|
||||||
)
|
)
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -45,24 +43,3 @@ fun TimePickerAlert(
|
|||||||
text = { TimePicker(state = state) },
|
text = { TimePicker(state = state) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the clock should read 24-hour, matching the rest of the device.
|
|
||||||
*
|
|
||||||
* [DateFormat.is24HourFormat] resolves a "locale default" system setting against
|
|
||||||
* the *app's* context locale — and this app applies a per-app language
|
|
||||||
* (AppCompatDelegate), so an English UI on a German-region phone would wrongly
|
|
||||||
* read 12-hour while the system clock shows 24-hour. So we honour an explicit
|
|
||||||
* system 12/24 override, and otherwise fall back to the **device** locale
|
|
||||||
* (Resources.getSystem), not the app's.
|
|
||||||
*/
|
|
||||||
private fun deviceUses24HourClock(context: Context): Boolean =
|
|
||||||
when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) {
|
|
||||||
"24" -> true
|
|
||||||
"12" -> false
|
|
||||||
// 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour.
|
|
||||||
else -> {
|
|
||||||
val deviceLocale = Resources.getSystem().configuration.locales[0]
|
|
||||||
!DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -303,7 +303,6 @@
|
|||||||
<string name="reminder_none">Ninguno</string>
|
<string name="reminder_none">Ninguno</string>
|
||||||
<string name="reminder_use_default">Usar recordatorio por defecto</string>
|
<string name="reminder_use_default">Usar recordatorio por defecto</string>
|
||||||
<string name="reminder_custom_amount">Cantidad</string>
|
<string name="reminder_custom_amount">Cantidad</string>
|
||||||
<string name="reminder_custom_with_value">Personalizado (%1$s)</string>
|
|
||||||
<string name="reminder_custom_set">Guardar</string>
|
<string name="reminder_custom_set">Guardar</string>
|
||||||
<string name="settings_calendar_reminders_title">Recordatorios por calendario</string>
|
<string name="settings_calendar_reminders_title">Recordatorios por calendario</string>
|
||||||
<string name="settings_calendar_reminders_hint">Sobreescribe el por recordatorio defecto por calendario — De forma separada para eventos con duración fija o que duran todo el dia. Un calendario puede mantener el por defecto o tener su propia configuracion.</string>
|
<string name="settings_calendar_reminders_hint">Sobreescribe el por recordatorio defecto por calendario — De forma separada para eventos con duración fija o que duran todo el dia. Un calendario puede mantener el por defecto o tener su propia configuracion.</string>
|
||||||
|
|||||||
@@ -285,7 +285,6 @@
|
|||||||
<string name="reminder_none">Nessuno</string>
|
<string name="reminder_none">Nessuno</string>
|
||||||
<string name="reminder_use_default">Usa il promemoria standard</string>
|
<string name="reminder_use_default">Usa il promemoria standard</string>
|
||||||
<string name="reminder_custom_amount">Valore</string>
|
<string name="reminder_custom_amount">Valore</string>
|
||||||
<string name="reminder_custom_with_value">Personalizza (%1$s)</string>
|
|
||||||
<string name="reminder_custom_set">Imposta</string>
|
<string name="reminder_custom_set">Imposta</string>
|
||||||
<string name="settings_calendar_reminders_title">Promemoria per calendario</string>
|
<string name="settings_calendar_reminders_title">Promemoria per calendario</string>
|
||||||
<string name="settings_calendar_reminders_hint">Sovrascrivi i reminder standard con reminder specifici per calendario, separati per eventi giornalieri ed eventi ad ore. Ogni calendario può usare i promemoria di default, ignorarli o avere i suoi personalizzati.</string>
|
<string name="settings_calendar_reminders_hint">Sovrascrivi i reminder standard con reminder specifici per calendario, separati per eventi giornalieri ed eventi ad ore. Ogni calendario può usare i promemoria di default, ignorarli o avere i suoi personalizzati.</string>
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ class EventDetailMapperTest {
|
|||||||
eventColor: Any? = null,
|
eventColor: Any? = null,
|
||||||
eventColorKey: String? = null,
|
eventColorKey: String? = null,
|
||||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||||
dtstart: Long = 1_000_000_000L,
|
dtstart: Long? = 1_000_000_000L,
|
||||||
dtend: Long = 1_000_003_600L,
|
dtend: Long? = 1_000_003_600L,
|
||||||
allDay: Int = 0,
|
allDay: Int = 0,
|
||||||
location: String? = "Berlin",
|
location: String? = "Berlin",
|
||||||
calendarId: Long = 7L,
|
calendarId: Long = 7L,
|
||||||
@@ -120,9 +120,31 @@ class EventDetailMapperTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `dtend before dtstart drops detail`() {
|
fun `dtend before dtstart is clamped to a zero-length event, not dropped`() {
|
||||||
|
// A backwards DTEND is malformed, but dropping it would make the event
|
||||||
|
// un-openable (the "Something went wrong" trap, same as issue #34); keep
|
||||||
|
// it as a zero-length event so the user can still open and fix it.
|
||||||
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
|
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
|
||||||
assertThat(detail).isNull()
|
assertThat(detail).isNotNull()
|
||||||
|
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(2000L)
|
||||||
|
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(2000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
|
||||||
|
// A yearly birthday/anniversary anchored before the epoch has a
|
||||||
|
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
|
||||||
|
// no DTEND (they use DURATION), so it stays end == begin.
|
||||||
|
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
||||||
|
val detail = detailReader(dtstart = begin, dtend = null).toDetail()
|
||||||
|
assertThat(detail).isNotNull()
|
||||||
|
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
|
||||||
|
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `absent dtstart drops detail`() {
|
||||||
|
assertThat(detailReader(dtstart = null).toDetail()).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class SearchMapperTest {
|
||||||
|
|
||||||
|
private fun searchReader(
|
||||||
|
id: Long = 1L,
|
||||||
|
calendarId: Long = 7L,
|
||||||
|
title: String? = "Birthday",
|
||||||
|
dtstart: Long? = 1_000_000_000L,
|
||||||
|
dtend: Long? = null,
|
||||||
|
duration: String? = "P1D",
|
||||||
|
allDay: Int = 1,
|
||||||
|
eventColor: Any? = null,
|
||||||
|
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||||
|
location: String? = null,
|
||||||
|
): MapColumnReader = MapColumnReader(
|
||||||
|
SearchProjection.IDX_ID to id,
|
||||||
|
SearchProjection.IDX_CALENDAR_ID to calendarId,
|
||||||
|
SearchProjection.IDX_TITLE to title,
|
||||||
|
SearchProjection.IDX_DTSTART to dtstart,
|
||||||
|
SearchProjection.IDX_DTEND to dtend,
|
||||||
|
SearchProjection.IDX_DURATION to duration,
|
||||||
|
SearchProjection.IDX_ALL_DAY to allDay,
|
||||||
|
SearchProjection.IDX_EVENT_COLOR to eventColor,
|
||||||
|
SearchProjection.IDX_CALENDAR_COLOR to calendarColor,
|
||||||
|
SearchProjection.IDX_LOCATION to location,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pre-1970 negative dtstart still surfaces in search (issue #34)`() {
|
||||||
|
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
||||||
|
val result = searchReader(dtstart = begin, dtend = null, duration = "P1D").toSearchResult()
|
||||||
|
assertThat(result).isNotNull()
|
||||||
|
assertThat(result!!.start.toEpochMilliseconds()).isEqualTo(begin)
|
||||||
|
assertThat(result.title).isEqualTo("Birthday")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `absent dtstart drops the search hit`() {
|
||||||
|
assertThat(searchReader(dtstart = null).toSearchResult()).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
70
fastlane/metadata/android/en-US/changelogs/21300.txt
Normal file
70
fastlane/metadata/android/en-US/changelogs/21300.txt
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
### Added
|
||||||
|
- Calendula now speaks Spanish and Italian. Both arrived as community
|
||||||
|
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
|
||||||
|
— a **huge thank you** to
|
||||||
|
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
|
||||||
|
translation and to
|
||||||
|
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
|
||||||
|
one! Pick your language under Settings → Language or in Android's per-app
|
||||||
|
language settings. Strings added in this very release may still show in
|
||||||
|
English until the translations catch up. Want Calendula in your language?
|
||||||
|
Translating happens entirely in the browser — every contribution is welcome.
|
||||||
|
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
||||||
|
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
||||||
|
birthdays, anniversaries and other dates into local "Birthdays",
|
||||||
|
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
|
||||||
|
change. Each is a normal local calendar, so you set its colour, visibility and
|
||||||
|
reminders the usual way — new birthdays even start with a reminder a week before
|
||||||
|
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
|
||||||
|
This is the first feature to use the contacts permission: it is requested only
|
||||||
|
when you turn the feature on, everything stays on your device (Calendula has no
|
||||||
|
internet access), and your contacts are only ever read, never changed. Thanks
|
||||||
|
to @moonj for the suggestion ([#15]).
|
||||||
|
- Set more than one default reminder per calendar. A calendar's default
|
||||||
|
reminders — and the global defaults under Settings → Notifications — can now
|
||||||
|
hold several lead times instead of just one, so new events can start with, say,
|
||||||
|
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||||
|
multi-select; per-calendar overrides can still inherit the global default or
|
||||||
|
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||||
|
- Widget headers now open the app. Tapping the month/year title on the month
|
||||||
|
widget opens the app on the month view, and tapping the "Upcoming" title on
|
||||||
|
the agenda widget opens it on your default view — so there's a one-tap way
|
||||||
|
back into the app that lands where you'd expect, instead of only through a day
|
||||||
|
or event. On the month widget, tapping anywhere on a day — not just the small
|
||||||
|
date number — now opens that day, and the "today" button snaps the grid back
|
||||||
|
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
||||||
|
suggestions ([#18], [#20]).
|
||||||
|
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
|
||||||
|
and **Body font** pickers: keep the system default, choose a bundled face
|
||||||
|
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
|
||||||
|
face), or load any `.ttf`/`.otf` file from your device, independently per
|
||||||
|
role. Font size and colour stay with Android's accessibility scaling and the
|
||||||
|
app theme, as discussed on the issue. Thanks to @abrossimow for the
|
||||||
|
suggestion ([#19]).
|
||||||
|
- Choose what the view-switch button cycles through. Settings → Appearance now
|
||||||
|
lets you pick which views the top-right quick-switch button rotates between
|
||||||
|
and drag them into your preferred order; views you switch off stay reachable
|
||||||
|
from the navigation menu, which can be reordered the same way. Thanks to
|
||||||
|
@abrossimow for the suggestion ([#24]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Month widget arrows and "today" button work again. On release builds the
|
||||||
|
prev/next-month arrows and the jump-to-today control on the month widget did
|
||||||
|
nothing when tapped — code shrinking had stripped the tap handlers behind
|
||||||
|
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
|
||||||
|
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
||||||
|
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||||
|
popping up — matching how a disabled calendar's events already stay hidden
|
||||||
|
everywhere else in the app ([#17]).
|
||||||
|
- Editing a single occurrence of a recurring event works again. Choosing **Only
|
||||||
|
this event** and saving a change to one event in a repeating series silently
|
||||||
|
did nothing — the change was rejected and the edit form simply reappeared with
|
||||||
|
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||||
|
lands on just that one event and leaves the rest of the series untouched
|
||||||
|
([#16]).
|
||||||
|
- Every calendar can be picked when creating an event. The event editor's
|
||||||
|
calendar picker was a fixed-height dialog, so with many calendars anything
|
||||||
|
past the first nine or so was simply unreachable. It is now a full-screen,
|
||||||
|
scrollable list grouped by account, with each calendar's colour shown.
|
||||||
|
Thanks to @dschuermann for the report ([#29]).
|
||||||
|
|
||||||
Reference in New Issue
Block a user