Compare commits
4 Commits
a2b1e7792b
...
feat/inser
| Author | SHA1 | Date | |
|---|---|---|---|
| 65bd6c4254 | |||
| 2239c38ecc | |||
| 79ad70e0c0 | |||
| f168021f51 |
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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [2.13.0] — 2026-07-03
|
||||
|
||||
### 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",
|
||||
@@ -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
|
||||
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
|
||||
@@ -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
|
||||
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]).
|
||||
|
||||
## [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
|
||||
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||
[#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 +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 21200
|
||||
versionName = "2.12.0"
|
||||
versionCode = 21300
|
||||
versionName = "2.13.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -112,6 +112,17 @@
|
||||
<data android:mimeType="text/calendar" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
||||
to create a new event, the way the AOSP calendar accepts it:
|
||||
ACTION_INSERT on the events dir mime type, carrying the new
|
||||
event's fields as CalendarContract extras
|
||||
(MainActivity.insertFormOrNull, issue #30). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.INSERT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -25,6 +26,8 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
@@ -42,6 +45,7 @@ import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -60,6 +64,11 @@ class MainActivity : AppCompatActivity() {
|
||||
// by CalendarHost's import flow.
|
||||
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||
// app/widget asking us to create an event, issue #30). Consumed once by
|
||||
// CalendarHost, which opens it in the create form for review.
|
||||
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
||||
|
||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||
// over the calendar on the next launch (the single-crash path). A startup
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
@@ -84,6 +93,7 @@ class MainActivity : AppCompatActivity() {
|
||||
requestedDetailKey = intent.detailKeyOrNull()
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsertForm = intent.insertFormOrNull()
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
@@ -132,6 +142,8 @@ class MainActivity : AppCompatActivity() {
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = { requestedInsertForm = null },
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
@@ -169,6 +181,7 @@ class MainActivity : AppCompatActivity() {
|
||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,6 +201,31 @@ class MainActivity : AppCompatActivity() {
|
||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefilled new-event form from an external `ACTION_INSERT` launch — another
|
||||
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30).
|
||||
* The new event's fields ride as CalendarContract extras; anything omitted
|
||||
* falls back to the in-app "new event" defaults in [buildInsertEventForm].
|
||||
*/
|
||||
private fun Intent.insertFormOrNull(): EventForm? {
|
||||
if (action != Intent.ACTION_INSERT) return null
|
||||
return buildInsertEventForm(
|
||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
|
||||
title = getStringExtra(CalendarContract.Events.TITLE),
|
||||
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
|
||||
location = getStringExtra(CalendarContract.Events.EVENT_LOCATION),
|
||||
rrule = getStringExtra(CalendarContract.Events.RRULE),
|
||||
zone = TimeZone.currentSystemDefault(),
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
/** A Long extra's value, or null when the extra is absent. */
|
||||
private fun Intent.longExtraOrNull(key: String): Long? =
|
||||
if (hasExtra(key)) getLongExtra(key, 0L) else null
|
||||
|
||||
/**
|
||||
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
||||
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Build a prefilled [EventForm] from an `ACTION_INSERT` intent's extras (issue
|
||||
* #30). External apps and widgets (e.g. the Todo Agenda widget) launch the
|
||||
* calendar this way to create a new event, passing the fields as
|
||||
* [android.provider.CalendarContract] extras. Any field the intent omits falls
|
||||
* back to the same defaults the in-app "new event" uses — a timed start at the
|
||||
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so
|
||||
* it resolves to the last-used / first-writable calendar, exactly like the
|
||||
* `.ics` single-event and plain new-event paths.
|
||||
*
|
||||
* Pure (no Android types) so it is unit-testable; the intent parsing that reads
|
||||
* the extras lives in `MainActivity.insertFormOrNull`.
|
||||
*/
|
||||
fun buildInsertEventForm(
|
||||
beginMillis: Long?,
|
||||
endMillis: Long?,
|
||||
isAllDay: Boolean,
|
||||
title: String?,
|
||||
description: String?,
|
||||
location: String?,
|
||||
rrule: String?,
|
||||
zone: TimeZone,
|
||||
now: Instant,
|
||||
): EventForm {
|
||||
val (start, end) = if (isAllDay) {
|
||||
// All-day provider times are UTC midnights with an exclusive end; show
|
||||
// the last covered day and keep placeholder wall-clock times in case the
|
||||
// user switches the event to timed (mirrors EventDetail.toEditForm).
|
||||
val startDate = beginMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||
?: now.toLocalDateTime(zone).date
|
||||
val endDate = endMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
|
||||
?: startDate
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
} else {
|
||||
val startTime = beginMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
||||
?: nextFullHour(now, zone)
|
||||
val endTime = endMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
||||
?.takeIf { it >= startTime }
|
||||
?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||
startTime to endTime
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = null,
|
||||
title = title.orEmpty(),
|
||||
isAllDay = isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
location = location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
|
||||
// some callers include.
|
||||
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
|
||||
val hourMillis = 3_600_000L
|
||||
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
||||
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
||||
}
|
||||
@@ -72,6 +72,8 @@ fun CalendarHost(
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: EventForm? = null,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Wait for the persisted default view before seeding the stack, so the app
|
||||
@@ -176,6 +178,16 @@ fun CalendarHost(
|
||||
onImportConsumed()
|
||||
}
|
||||
}
|
||||
// An external ACTION_INSERT launch (another app/widget creating an event,
|
||||
// issue #30) arrives already prefilled — open it in the same create form the
|
||||
// single-event .ics path uses. [importForm] is the topmost overlay, so it
|
||||
// reveals on top of whatever was open without extra dismissal.
|
||||
LaunchedEffect(requestedInsertForm) {
|
||||
if (requestedInsertForm != null) {
|
||||
importForm = requestedInsertForm
|
||||
onInsertConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
// Close every overlay that can sit over the calendar, so an externally
|
||||
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||
|
||||
@@ -35,6 +35,8 @@ fun RootScreen(
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
@@ -84,6 +86,8 @@ fun RootScreen(
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class InsertEventFormTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
|
||||
// A fixed "now" at 2026-01-15T10:20:00Z; the next full hour is 11:00.
|
||||
private val now = LocalDateTime(2026, 1, 15, 10, 20).toInstant(zone)
|
||||
|
||||
private fun build(
|
||||
beginMillis: Long? = null,
|
||||
endMillis: Long? = null,
|
||||
isAllDay: Boolean = false,
|
||||
title: String? = null,
|
||||
description: String? = null,
|
||||
location: String? = null,
|
||||
rrule: String? = null,
|
||||
): EventForm = buildInsertEventForm(
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
isAllDay = isAllDay,
|
||||
title = title,
|
||||
description = description,
|
||||
location = location,
|
||||
rrule = rrule,
|
||||
zone = zone,
|
||||
now = now,
|
||||
)
|
||||
|
||||
private fun millis(dateTime: LocalDateTime): Long = dateTime.toInstant(zone).toEpochMilliseconds()
|
||||
|
||||
@Test
|
||||
fun `timed event maps all provided fields`() {
|
||||
val start = LocalDateTime(2026, 3, 1, 14, 30)
|
||||
val end = LocalDateTime(2026, 3, 1, 15, 45)
|
||||
val form = build(
|
||||
beginMillis = millis(start),
|
||||
endMillis = millis(end),
|
||||
title = "Standup",
|
||||
description = "Daily",
|
||||
location = "Room 1",
|
||||
rrule = "FREQ=DAILY",
|
||||
)
|
||||
assertThat(form.calendarId).isNull() // resolves to last-used / first-writable
|
||||
assertThat(form.isAllDay).isFalse()
|
||||
assertThat(form.start).isEqualTo(start)
|
||||
assertThat(form.end).isEqualTo(end)
|
||||
assertThat(form.title).isEqualTo("Standup")
|
||||
assertThat(form.description).isEqualTo("Daily")
|
||||
assertThat(form.location).isEqualTo("Room 1")
|
||||
assertThat(form.rrule).isEqualTo("FREQ=DAILY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing times default to the next full hour and a one-hour duration`() {
|
||||
val form = build(title = "Quick note")
|
||||
assertThat(form.start).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0))
|
||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0))
|
||||
assertThat(form.title).isEqualTo("Quick note")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timed event with only a start gets a one-hour end`() {
|
||||
val start = LocalDateTime(2026, 5, 2, 9, 0)
|
||||
val form = build(beginMillis = millis(start))
|
||||
assertThat(form.start).isEqualTo(start)
|
||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an end before the start is ignored and falls back to plus one hour`() {
|
||||
val start = LocalDateTime(2026, 5, 2, 9, 0)
|
||||
val badEnd = LocalDateTime(2026, 5, 2, 8, 0)
|
||||
val form = build(beginMillis = millis(start), endMillis = millis(badEnd))
|
||||
assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day event uses UTC dates and decrements the exclusive end`() {
|
||||
val startMillis = LocalDate(2026, 1, 1).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||
val endExclusive = LocalDate(2026, 1, 3).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||
val form = build(beginMillis = startMillis, endMillis = endExclusive, isAllDay = true, title = "Trip")
|
||||
assertThat(form.isAllDay).isTrue()
|
||||
assertThat(form.start.date).isEqualTo(LocalDate(2026, 1, 1))
|
||||
assertThat(form.end.date).isEqualTo(LocalDate(2026, 1, 2))
|
||||
// Placeholder wall-clock times survive a switch back to a timed event.
|
||||
assertThat(form.start.time).isEqualTo(LocalTime(9, 0))
|
||||
assertThat(form.end.time).isEqualTo(LocalTime(10, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day event without an end is a single day`() {
|
||||
val startMillis = LocalDate(2026, 6, 10).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||
val form = build(beginMillis = startMillis, isAllDay = true)
|
||||
assertThat(form.start.date).isEqualTo(LocalDate(2026, 6, 10))
|
||||
assertThat(form.end.date).isEqualTo(LocalDate(2026, 6, 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a leading RRULE prefix is stripped and a blank rule becomes null`() {
|
||||
assertThat(build(rrule = "RRULE:FREQ=WEEKLY").rrule).isEqualTo("FREQ=WEEKLY")
|
||||
assertThat(build(rrule = " ").rrule).isNull()
|
||||
assertThat(build(rrule = null).rrule).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