Compare commits
10 Commits
feat/edit-
...
v2.14.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 95c5fe76e6 | |||
| 34fa9c9c3e | |||
| 53793bfb68 | |||
| 9c7c8cb03a | |||
| 4fea176e28 | |||
| cf9492c7ba | |||
| e6def9e5f7 | |||
| 8ddc11159b | |||
|
|
6736bae5ee | ||
|
|
21d1726d7a |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -7,7 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.14.1] — 2026-07-13
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Deleting one occurrence of a repeating event no longer breaks the series.
|
||||||
|
Choosing "This event" when deleting an occurrence of a recurring event could
|
||||||
|
wipe out every *other* occurrence while leaving the one you deleted behind as
|
||||||
|
a stale, still-tappable ghost — and deleting it again brought the series back.
|
||||||
|
A single-occurrence delete now removes exactly that occurrence and leaves the
|
||||||
|
rest of the series untouched, and the deleted occurrence disappears from the
|
||||||
|
grid straight away. This holds on every kind of calendar, including the
|
||||||
|
on-device ones Calendula keeps for contact birthdays and anniversaries, where
|
||||||
|
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
|
||||||
|
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
|
||||||
|
Calendula to answer the "new event" hand-off from other apps and widgets; now
|
||||||
|
it also answers the "open this event" one, so tapping an existing event in a
|
||||||
|
widget such as Todo Agenda offers Calendula and lands on that event's details.
|
||||||
|
Thanks to @bushrang3r for the report ([#48]).
|
||||||
|
- Events created from other apps get your default reminder. An event handed over
|
||||||
|
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
|
||||||
|
widget's "+" — opened with no reminder at all, ignoring the default set in
|
||||||
|
Settings. It now starts with your default reminder, the same as an event you
|
||||||
|
create in Calendula. An event opened from an `.ics` file is treated differently,
|
||||||
|
because the file has its own say: Calendula keeps whatever reminders it carries
|
||||||
|
(including none at all) and asks you once whether to apply your default instead
|
||||||
|
— it never quietly overrides the file. If you have no default set, it doesn't
|
||||||
|
ask ([#49]).
|
||||||
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
||||||
app (such as DAVx5), the event colour picker showed every colour the account
|
app (such as DAVx5), the event colour picker showed every colour the account
|
||||||
publishes — nearly 150 swatches in alphabetical order, many of them
|
publishes — nearly 150 swatches in alphabetical order, many of them
|
||||||
@@ -888,3 +913,6 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||||
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||||
|
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
||||||
|
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||||
|
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||||
|
|||||||
@@ -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 = 21400
|
versionCode = 21401
|
||||||
versionName = "2.14.0"
|
versionName = "2.14.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,58 +97,32 @@
|
|||||||
<data android:mimeType="time/epoch" />
|
<data android:mimeType="time/epoch" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Open a .ics/.vcs file (file manager / email attachment / browser).
|
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||||
The three MIME types cover the common labels the same calendar
|
|
||||||
data arrives under: iCalendar 2.0 (text/calendar), the older
|
|
||||||
vCalendar 1.0 / .vcs (text/x-vcalendar), and application/ics some
|
|
||||||
mail apps emit — Android cross-products the scheme and mimeType
|
|
||||||
tags, so each MIME is accepted on both schemes (matches Etar). -->
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="content" />
|
<data android:scheme="content" android:mimeType="text/calendar" />
|
||||||
<data android:scheme="file" />
|
<data android:scheme="file" android:mimeType="text/calendar" />
|
||||||
<data android:mimeType="text/calendar" />
|
|
||||||
<data android:mimeType="text/x-vcalendar" />
|
|
||||||
<data android:mimeType="application/ics" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
|
<!-- Receive a .ics shared from another app. -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="text/calendar" />
|
<data android:mimeType="text/calendar" />
|
||||||
<data android:mimeType="text/x-vcalendar" />
|
|
||||||
<data android:mimeType="application/ics" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
<!-- 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:
|
to create a new event, the way the AOSP calendar accepts it:
|
||||||
ACTION_INSERT on the events dir mime type, carrying the new
|
ACTION_INSERT on the events dir mime type, carrying the new
|
||||||
event's fields as CalendarContract extras
|
event's fields as CalendarContract extras
|
||||||
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
|
(MainActivity.insertFormOrNull, issue #30). -->
|
||||||
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
|
|
||||||
to the same prefilled create form. -->
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.INSERT" />
|
<action android:name="android.intent.action.INSERT" />
|
||||||
<action android:name="android.intent.action.EDIT" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Edit an existing event another app/assistant/widget points at:
|
|
||||||
ACTION_EDIT on content://com.android.calendar/events/<id>, the way
|
|
||||||
AOSP fires it. Opens the occurrence in the edit form (not the
|
|
||||||
read-only detail — that's the VIEW filter above). Matched by the
|
|
||||||
provider's item MIME type, like the VIEW filter. The occurrence's
|
|
||||||
times ride as EXTRA_EVENT_BEGIN_TIME / EXTRA_EVENT_END_TIME when
|
|
||||||
supplied (MainActivity.editEventKeyOrNull). -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.EDIT" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Open an existing event another app/widget points at (e.g. tapping
|
<!-- Open an existing event another app/widget points at (e.g. tapping
|
||||||
an event in the Todo Agenda widget): ACTION_VIEW on
|
an event in the Todo Agenda widget): ACTION_VIEW on
|
||||||
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
||||||
|
|||||||
@@ -70,11 +70,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// CalendarHost, which opens it in the create form for review.
|
// CalendarHost, which opens it in the create form for review.
|
||||||
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
||||||
|
|
||||||
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
|
||||||
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
|
||||||
// detail channel; consumed once by CalendarHost.
|
|
||||||
private var requestedEditKey by mutableStateOf<LongArray?>(null)
|
|
||||||
|
|
||||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
// 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
|
// over the calendar on the next launch (the single-crash path). A startup
|
||||||
// crash-loop is handled out of band, before setContent — see below.
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
@@ -100,7 +95,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
requestedInsertForm = intent.insertFormOrNull()
|
requestedInsertForm = intent.insertFormOrNull()
|
||||||
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
|
||||||
@@ -151,8 +145,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onImportConsumed = { requestedImportUri = null },
|
onImportConsumed = { requestedImportUri = null },
|
||||||
requestedInsertForm = requestedInsertForm,
|
requestedInsertForm = requestedInsertForm,
|
||||||
onInsertConsumed = { requestedInsertForm = null },
|
onInsertConsumed = { requestedInsertForm = null },
|
||||||
requestedEditKey = requestedEditKey,
|
|
||||||
onEditKeyConsumed = { requestedEditKey = null },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// A persistent corner marker so a debug build is never
|
// A persistent corner marker so a debug build is never
|
||||||
@@ -191,7 +183,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,19 +203,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A prefilled new-event form from an external launch asking us to create an
|
* A prefilled new-event form from an external `ACTION_INSERT` launch — another
|
||||||
* event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
|
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30).
|
||||||
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
* The new event's fields ride as CalendarContract extras; anything omitted
|
||||||
* event", i.e. create). The new event's fields ride as CalendarContract
|
* falls back to the in-app "new event" defaults in [buildInsertEventForm].
|
||||||
* extras; anything omitted falls back to the in-app "new event" defaults in
|
|
||||||
* [buildInsertEventForm].
|
|
||||||
*/
|
*/
|
||||||
private fun Intent.insertFormOrNull(): EventForm? {
|
private fun Intent.insertFormOrNull(): EventForm? {
|
||||||
// ACTION_EDIT on an existing event routes to the edit form instead
|
if (action != Intent.ACTION_INSERT) return null
|
||||||
// ([editEventKeyOrNull]); only an id-less EDIT is a create.
|
|
||||||
val isCreate = action == Intent.ACTION_INSERT ||
|
|
||||||
(action == Intent.ACTION_EDIT && editEventKeyOrNull() == null)
|
|
||||||
if (!isCreate) return null
|
|
||||||
return buildInsertEventForm(
|
return buildInsertEventForm(
|
||||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||||
@@ -333,31 +318,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
|
|
||||||
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
|
||||||
* an assistant, task app, or widget that wants to open the event for editing
|
|
||||||
* rather than viewing). Opens it in the edit form. Reuses the same
|
|
||||||
* occurrence-key channel as reminder/view taps; the caller passes the
|
|
||||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
|
|
||||||
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
|
|
||||||
* [EventEditViewModel.openForEdit] falls back to the event row's own
|
|
||||||
* DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]).
|
|
||||||
*/
|
|
||||||
private fun Intent.editEventKeyOrNull(): LongArray? {
|
|
||||||
if (action != Intent.ACTION_EDIT) return null
|
|
||||||
val uri = data ?: return null
|
|
||||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
|
||||||
val segments = uri.pathSegments
|
|
||||||
if (segments.firstOrNull() != "events") return null
|
|
||||||
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
|
||||||
return longArrayOf(
|
|
||||||
eventId,
|
|
||||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
|
||||||
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// The calendar provider's authority/host. A date tap arrives as
|
// The calendar provider's authority/host. A date tap arrives as
|
||||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||||
|
|||||||
@@ -979,6 +979,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.DURATION,
|
CalendarContract.Events.DURATION,
|
||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events._SYNC_ID,
|
||||||
|
CalendarContract.Events.EXDATE,
|
||||||
),
|
),
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
@@ -989,6 +991,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
timezone = c.getString(2),
|
timezone = c.getString(2),
|
||||||
duration = c.getString(3),
|
duration = c.getString(3),
|
||||||
allDay = c.getInt(4),
|
allDay = c.getInt(4),
|
||||||
|
syncId = c.getString(5),
|
||||||
|
exdate = c.getString(6),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -1001,6 +1005,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val timezone: String?,
|
val timezone: String?,
|
||||||
val duration: String?,
|
val duration: String?,
|
||||||
val allDay: Int,
|
val allDay: Int,
|
||||||
|
/** Null on a local calendar (and before a synced event's first push). */
|
||||||
|
val syncId: String? = null,
|
||||||
|
val exdate: String? = null,
|
||||||
) {
|
) {
|
||||||
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||||
@@ -1191,15 +1198,38 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
// A cancelled exception row hides exactly this occurrence; the sync
|
|
||||||
// adapter turns it into an EXDATE/cancelled VEVENT upstream. It has to
|
|
||||||
// carry the full time set (DTSTART + DURATION + zone), not just STATUS:
|
|
||||||
// the provider only demotes the cloned exception to a single instance —
|
|
||||||
// clearing the inherited RRULE — when it can derive that instance from
|
|
||||||
// those columns. A STATUS-only cancel left the RRULE standing and
|
|
||||||
// cancelled the whole series, wiping every other occurrence (#47), the
|
|
||||||
// same trap the edit path documents (Codeberg #16).
|
|
||||||
val row = querySeriesRow(eventId)
|
val row = querySeriesRow(eventId)
|
||||||
|
if (row.syncId == null) {
|
||||||
|
// No _sync_id — a local calendar, or a synced event not pushed yet.
|
||||||
|
// A cancelled exception can only attach to its parent through
|
||||||
|
// ORIGINAL_SYNC_ID, so with none the link never forms and the
|
||||||
|
// provider's expansion of the *parent* collapses, taking every other
|
||||||
|
// occurrence with it (#47 on a local calendar). EXDATE needs no link.
|
||||||
|
// Calendula's own contact special-date calendars are local and hold
|
||||||
|
// yearly series, so this path is reached in normal use.
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = row.exdate,
|
||||||
|
occurrenceMillis = beginMillis,
|
||||||
|
dtStartMillis = row.dtStartMillis,
|
||||||
|
rrule = row.rrule,
|
||||||
|
duration = row.duration,
|
||||||
|
timezone = row.timezone,
|
||||||
|
allDay = row.allDay,
|
||||||
|
)
|
||||||
|
val updated = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
values.toContentValues(), null, null,
|
||||||
|
)
|
||||||
|
if (updated == 0) {
|
||||||
|
throw WriteFailedException("exdate occurrence event id=$eventId begin=$beginMillis")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A cancelled exception row hides exactly this occurrence; the sync
|
||||||
|
// adapter turns it into an EXDATE/cancelled VEVENT upstream. It carries
|
||||||
|
// the full time set (DTSTART + DURATION + zone) so the provider derives a
|
||||||
|
// single instance rather than cloning the master's RRULE — the same trap
|
||||||
|
// the edit path documents (Codeberg #16).
|
||||||
val values = buildOccurrenceCancelValues(
|
val values = buildOccurrenceCancelValues(
|
||||||
originalInstanceMillis = beginMillis,
|
originalInstanceMillis = beginMillis,
|
||||||
dtStartMillis = beginMillis,
|
dtStartMillis = beginMillis,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -36,15 +37,18 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
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 — derive the length from DURATION (as SearchMapper and
|
||||||
// specific occurrence supply the real per-occurrence times from
|
// IcsExportMapper do). Callers that opened a specific occurrence overwrite
|
||||||
// CalendarContract.Instances. A present-but-backwards DTEND is malformed,
|
// both times with the per-occurrence values from CalendarContract.Instances;
|
||||||
// but dropping the row would make the event un-openable — the same trap as
|
// a caller that names no occurrence (a bare content://.../events/<id> VIEW
|
||||||
// the pre-1970 DTSTART bug above (issue #34): it would surface as the
|
// intent, issue #48) keeps this row's own times, so the length has to be
|
||||||
// generic error screen with no way to open the event and fix it. Clamp to a
|
// right here or the series renders zero-length. A present-but-backwards
|
||||||
// zero-length event instead (matching SearchMapper's coerceAtLeast).
|
// 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).
|
||||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||||
begin
|
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
|
||||||
} else {
|
} else {
|
||||||
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,73 @@ internal fun buildOccurrenceCancelValues(
|
|||||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The master-row columns that drop the occurrence at [occurrenceMillis] from a
|
||||||
|
* series by adding it to `EXDATE` — the path for events that have **no
|
||||||
|
* `_sync_id`** (a local calendar, or a synced event not yet pushed).
|
||||||
|
*
|
||||||
|
* A cancelled exception row (see [buildOccurrenceCancelValues]) only attaches to
|
||||||
|
* its parent through `ORIGINAL_SYNC_ID`. Without a `_sync_id` the link never
|
||||||
|
* forms, and the provider's expansion of the *parent* collapses — every other
|
||||||
|
* occurrence disappears (Codeberg #47, reproduced on a local calendar). EXDATE
|
||||||
|
* needs no link, and is the canonical iCalendar way to drop an occurrence, so a
|
||||||
|
* sync adapter carries it upstream unchanged if the calendar later syncs.
|
||||||
|
*
|
||||||
|
* The whole time/recurrence set is rewritten alongside it on purpose. The
|
||||||
|
* provider does **not** treat an EXDATE-only update as a recurrence change: it
|
||||||
|
* leaves the expanded `Instances` rows untouched, so the occurrence stays visible
|
||||||
|
* (and, symmetrically, un-excluding one leaves it hidden). Writing DTSTART with
|
||||||
|
* it forces the re-expansion — but DTSTART *alone* makes the provider recompute
|
||||||
|
* `lastDate` as if the event were a single instance, collapsing the series to its
|
||||||
|
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
|
||||||
|
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
|
||||||
|
*
|
||||||
|
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
|
||||||
|
* of the same occurrence is folded away). All-day series take the `VALUE=DATE`
|
||||||
|
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
|
||||||
|
*/
|
||||||
|
internal fun buildOccurrenceExdateValues(
|
||||||
|
existingExdate: String?,
|
||||||
|
occurrenceMillis: Long,
|
||||||
|
dtStartMillis: Long,
|
||||||
|
rrule: String?,
|
||||||
|
duration: String?,
|
||||||
|
timezone: String?,
|
||||||
|
allDay: Int,
|
||||||
|
): Map<String, Any?> {
|
||||||
|
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
||||||
|
val existing = existingExdate?.split(',')
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.filter { it.isNotEmpty() }
|
||||||
|
.orEmpty()
|
||||||
|
val merged = (existing + stamp).distinct().joinToString(",")
|
||||||
|
return mapOf(
|
||||||
|
CalendarContract.Events.EXDATE to merged,
|
||||||
|
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||||
|
CalendarContract.Events.RRULE to rrule,
|
||||||
|
CalendarContract.Events.DURATION to duration,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
||||||
|
CalendarContract.Events.ALL_DAY to allDay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
||||||
|
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
||||||
|
* reads off the UTC calendar day.
|
||||||
|
*/
|
||||||
|
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
|
||||||
|
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
|
||||||
|
return if (isAllDay) {
|
||||||
|
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
|
||||||
|
} else {
|
||||||
|
"%04d%02d%02dT%02d%02d%02dZ".format(
|
||||||
|
utc.year, utc.monthValue, utc.dayOfMonth,
|
||||||
|
utc.hour, utc.minute, utc.second,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ internal object EventDetailProjection {
|
|||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
CalendarContract.Events.EVENT_COLOR_KEY,
|
CalendarContract.Events.EVENT_COLOR_KEY,
|
||||||
|
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
||||||
|
// needs it to render a series opened without a named occurrence.
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -104,6 +107,7 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_EVENT_TIMEZONE = 15
|
const val IDX_EVENT_TIMEZONE = 15
|
||||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
const val IDX_EVENT_COLOR_KEY = 17
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
|
const val IDX_DURATION = 18
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -75,8 +75,6 @@ fun CalendarHost(
|
|||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
requestedInsertForm: EventForm? = null,
|
requestedInsertForm: EventForm? = null,
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
requestedEditKey: LongArray? = null,
|
|
||||||
onEditKeyConsumed: () -> Unit = {},
|
|
||||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
// Wait for the persisted default view before seeding the stack, so the app
|
// Wait for the persisted default view before seeding the stack, so the app
|
||||||
@@ -214,20 +212,6 @@ fun CalendarHost(
|
|||||||
importForm = null
|
importForm = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
|
||||||
// widget) opens the occurrence straight in the edit form. Drop any covering
|
|
||||||
// overlay first — the edit overlay sits below Settings/import in the Box, so
|
|
||||||
// without this it would open hidden underneath them. Same held-key pattern as
|
|
||||||
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
|
|
||||||
LaunchedEffect(requestedEditKey) {
|
|
||||||
if (requestedEditKey != null) {
|
|
||||||
dismissCoveringOverlays()
|
|
||||||
heldEditKey = requestedEditKey
|
|
||||||
editKey = requestedEditKey
|
|
||||||
onEditKeyConsumed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A home-screen widget launch asks to open a date (→ day view), open an
|
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||||
// event's detail, or start a create. Handled once and cleared, mirroring
|
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ fun RootScreen(
|
|||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
requestedEditKey: LongArray? = null,
|
|
||||||
onEditKeyConsumed: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
@@ -90,8 +88,6 @@ fun RootScreen(
|
|||||||
onImportConsumed = onImportConsumed,
|
onImportConsumed = onImportConsumed,
|
||||||
requestedInsertForm = requestedInsertForm,
|
requestedInsertForm = requestedInsertForm,
|
||||||
onInsertConsumed = onInsertConsumed,
|
onInsertConsumed = onInsertConsumed,
|
||||||
requestedEditKey = requestedEditKey,
|
|
||||||
onEditKeyConsumed = onEditKeyConsumed,
|
|
||||||
)
|
)
|
||||||
false -> ReminderOnboardingScreen(
|
false -> ReminderOnboardingScreen(
|
||||||
onFinished = reminderOnboarding::finish,
|
onFinished = reminderOnboarding::finish,
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|||||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -397,12 +396,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
||||||
* tapped occurrence's own times, like on the detail screen. An external
|
* tapped occurrence's own times, like on the detail screen. No-op while a
|
||||||
* "edit this event" (`ACTION_EDIT`) that names no occurrence passes
|
* form is open, so user edits survive configuration changes.
|
||||||
* [NO_OCCURRENCE_TIME]; the row's own DTSTART/DTEND is used then, so the
|
|
||||||
* form loads the event's real times instead of the epoch (mirrors the
|
|
||||||
* detail screen's #48 fallback). No-op while a form is open, so user edits
|
|
||||||
* survive configuration changes.
|
|
||||||
*/
|
*/
|
||||||
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
@@ -416,12 +411,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val zone = TimeZone.currentSystemDefault()
|
val zone = TimeZone.currentSystemDefault()
|
||||||
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
|
||||||
?: detail.instance.start.toEpochMilliseconds()
|
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
|
||||||
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
|
||||||
?: detail.instance.end.toEpochMilliseconds()
|
|
||||||
val snapshot = detail.toEditSnapshot(begin, end, zone)
|
|
||||||
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
|
|
||||||
// Sections holding data must show even when not in the defaults.
|
// Sections holding data must show even when not in the defaults.
|
||||||
_revealed.value = snapshot.form.populatedFields()
|
_revealed.value = snapshot.form.populatedFields()
|
||||||
_form.value = snapshot.form
|
_form.value = snapshot.form
|
||||||
|
|||||||
@@ -310,7 +310,7 @@
|
|||||||
<string name="settings_language">App-Sprache</string>
|
<string name="settings_language">App-Sprache</string>
|
||||||
<string name="settings_language_auto">Systemstandard</string>
|
<string name="settings_language_auto">Systemstandard</string>
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Design, Standartansicht, Wochenstart</string>
|
<string name="settings_appearance_subtitle">Design, Standardansicht, Wochenstart</string>
|
||||||
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
||||||
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
||||||
<string name="settings_section_about">Über</string>
|
<string name="settings_section_about">Über</string>
|
||||||
@@ -474,4 +474,20 @@
|
|||||||
<string name="special_dates_default_title_birthday">Geburtstag von {name} ({year})</string>
|
<string name="special_dates_default_title_birthday">Geburtstag von {name} ({year})</string>
|
||||||
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
|
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
|
||||||
<string name="special_dates_default_title_custom">{name}</string>
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
|
<string name="settings_week_numbers">Kalenderwochen</string>
|
||||||
|
<string name="settings_week_numbers_summary">Kalenderwochen der Monatsansicht anzeigen</string>
|
||||||
|
<string name="calendars_export_title">Kalender exportieren</string>
|
||||||
|
<string name="calendars_export_hint">Wähle aus, welche Kalender in die .ics-Datei aufgenommen werden sollen.</string>
|
||||||
|
<string name="calendars_export_action">Exportieren</string>
|
||||||
|
<string name="calendars_restore_header">Wiederherstellen</string>
|
||||||
|
<string name="calendars_restore_action">Aus .ics-Datei wiederherstellen</string>
|
||||||
|
<string name="calendars_restore_hint">Importiere Ereignisse aus einem Backup oder einer anderen Kalender-App.</string>
|
||||||
|
<string name="import_done_dedup_note">Bereits im Kalender vorhandene Ereignisse wurden übersprungen.</string>
|
||||||
|
<string name="import_done_added_label">Hinzugefügt</string>
|
||||||
|
<string name="import_done_skipped_label">Duplikate</string>
|
||||||
|
<string name="import_button">Importieren</string>
|
||||||
|
<plurals name="import_title_count">
|
||||||
|
<item quantity="one">%d Ereignis wird importiert</item>
|
||||||
|
<item quantity="other">%d Ereignisse werden importiert</item>
|
||||||
|
</plurals>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -16,17 +16,17 @@
|
|||||||
<string name="state_failure_no_calendars">Nessun calendario configurato.</string>
|
<string name="state_failure_no_calendars">Nessun calendario configurato.</string>
|
||||||
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
||||||
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
|
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
|
||||||
<string name="permission_rationale_title">Guarda tutti i tuoi eventi, magnificamente</string>
|
<string name="permission_rationale_title">Gestisci magnificamente tutti i tuoi eventi</string>
|
||||||
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa che ti chiederà.</string>
|
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno e nessuna informazione lascerà mai il tuo dispositivo.</string>
|
||||||
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
|
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
|
||||||
<string name="permission_denied_title">Accesso al calendario negato</string>
|
<string name="permission_denied_title">Accesso al calendario negato</string>
|
||||||
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
|
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
|
||||||
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
|
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
|
||||||
<string name="permission_retry_button">Riprova</string>
|
<string name="permission_retry_button">Riprova</string>
|
||||||
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
|
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
|
||||||
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo telefono.</string>
|
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo dispositivo.</string>
|
||||||
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
|
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
|
||||||
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo semplicemente compare.</string>
|
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo, semplicemente, compare.</string>
|
||||||
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
|
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
|
||||||
<string name="permission_benefit_privacy_body">Zero telemetria, zero analytics, zero pubblicità.</string>
|
<string name="permission_benefit_privacy_body">Zero telemetria, zero analytics, zero pubblicità.</string>
|
||||||
<string name="permission_privacy_footnote">Rimane sul tuo dispositivo - nessun accesso a internet</string>
|
<string name="permission_privacy_footnote">Rimane sul tuo dispositivo - nessun accesso a internet</string>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<string name="month_action_settings">Impostazioni</string>
|
<string name="month_action_settings">Impostazioni</string>
|
||||||
<string name="month_a11y_today_prefix">Oggi</string>
|
<string name="month_a11y_today_prefix">Oggi</string>
|
||||||
<string name="week_today_action">Questa settimana</string>
|
<string name="week_today_action">Questa settimana</string>
|
||||||
<string name="week_number_label">Wk</string>
|
<string name="week_number_label">Sett.</string>
|
||||||
<string name="day_today_action">Oggi</string>
|
<string name="day_today_action">Oggi</string>
|
||||||
<string name="event_detail_back">Indietro</string>
|
<string name="event_detail_back">Indietro</string>
|
||||||
<string name="event_detail_edit">Modifica</string>
|
<string name="event_detail_edit">Modifica</string>
|
||||||
@@ -73,14 +73,14 @@
|
|||||||
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
|
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
|
||||||
<string name="event_edit_attendees">Partecipanti</string>
|
<string name="event_edit_attendees">Partecipanti</string>
|
||||||
<string name="event_edit_add_guest">Aggiungi partecipante</string>
|
<string name="event_edit_add_guest">Aggiungi partecipante</string>
|
||||||
<string name="event_edit_add_guest_hint">Aggiungi un partecipante via email…</string>
|
<string name="event_edit_add_guest_hint">Aggiungi un partecipante tramite indirizzo email…</string>
|
||||||
<string name="event_edit_add_guest_from_contacts">Aggiungi dai contatti</string>
|
<string name="event_edit_add_guest_from_contacts">Aggiungi dai contatti</string>
|
||||||
<string name="event_edit_location_from_contacts">Scegli un indirizzo dai contatti</string>
|
<string name="event_edit_location_from_contacts">Scegli un indirizzo dai contatti</string>
|
||||||
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
|
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
|
||||||
<string name="event_edit_attendee_required">Obbligatorio</string>
|
<string name="event_edit_attendee_required">Obbligatorio</string>
|
||||||
<string name="event_edit_attendee_optional">Facoltativo</string>
|
<string name="event_edit_attendee_optional">Facoltativo</string>
|
||||||
<string name="event_edit_attendees_note_synced">Calendula non manda gli inviti agli eventi. Il tuo account calendario potrà inviare le email ai partecipanti al momento della sincronizzazione.</string>
|
<string name="event_edit_attendees_note_synced">Calendula non manda gli inviti agli eventi. Il tuo account calendario potrà inviare le email ai partecipanti al momento della sincronizzazione.</string>
|
||||||
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno ha ricevuto notifiche.</string>
|
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno dei partecipanti ha ricevuto notifiche.</string>
|
||||||
<string name="event_edit_reminder_custom">Personalizza</string>
|
<string name="event_edit_reminder_custom">Personalizza</string>
|
||||||
<string name="reminder_unit_minutes">minuti</string>
|
<string name="reminder_unit_minutes">minuti</string>
|
||||||
<string name="reminder_unit_hours">ore</string>
|
<string name="reminder_unit_hours">ore</string>
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
<string name="event_edit_availability">Disponibilità</string>
|
<string name="event_edit_availability">Disponibilità</string>
|
||||||
<string name="event_edit_visibility">Visibilità</string>
|
<string name="event_edit_visibility">Visibilità</string>
|
||||||
<string name="event_edit_color">Colore</string>
|
<string name="event_edit_color">Colore</string>
|
||||||
<string name="event_edit_color_default">Colore calendario</string>
|
<string name="event_edit_color_default">Colore del calendario</string>
|
||||||
<string name="event_edit_color_custom">Colore personalizzato</string>
|
<string name="event_edit_color_custom">Colore personalizzato</string>
|
||||||
<string name="event_edit_color_reset">Reset</string>
|
<string name="event_edit_color_reset">Reset</string>
|
||||||
<string name="event_edit_color_unsupported">Non disponibile per questo calendario</string>
|
<string name="event_edit_color_unsupported">Non disponibile per questo calendario</string>
|
||||||
@@ -99,13 +99,13 @@
|
|||||||
<string name="event_edit_conflict_body">Durante la modifica l\'evento è stato cambiato, tramite sincronizzazione o tramite altra app. Cosa fare con le modifiche in corso?</string>
|
<string name="event_edit_conflict_body">Durante la modifica l\'evento è stato cambiato, tramite sincronizzazione o tramite altra app. Cosa fare con le modifiche in corso?</string>
|
||||||
<string name="event_edit_conflict_overwrite">Salva le modifiche</string>
|
<string name="event_edit_conflict_overwrite">Salva le modifiche</string>
|
||||||
<string name="event_edit_conflict_overwrite_hint">Sovrascrivi solo i campi modificati</string>
|
<string name="event_edit_conflict_overwrite_hint">Sovrascrivi solo i campi modificati</string>
|
||||||
<string name="event_edit_conflict_discard">Annulla le mie modifiche</string>
|
<string name="event_edit_conflict_discard">Annulla le modifiche</string>
|
||||||
<string name="event_edit_conflict_discard_hint">Mantieni l\'evento com\'è ora</string>
|
<string name="event_edit_conflict_discard_hint">Lascia l\'evento invariato</string>
|
||||||
<string name="event_edit_gone_title">Evento eliminato</string>
|
<string name="event_edit_gone_title">Evento eliminato</string>
|
||||||
<string name="event_edit_gone_body">Nel frattempo, questo evento è stato eliminato, ad esempio su un altro dispositivo. Le modifiche non possono più essere salvate.</string>
|
<string name="event_edit_gone_body">Nel frattempo, questo evento è stato eliminato, ad esempio su un altro dispositivo. Le modifiche non possono più essere salvate.</string>
|
||||||
<string name="event_edit_recurrence_none">Non si ripete</string>
|
<string name="event_edit_recurrence_none">Non si ripete</string>
|
||||||
<string name="event_edit_recurrence_custom">Personalizza</string>
|
<string name="event_edit_recurrence_custom">Personalizza</string>
|
||||||
<string name="event_edit_recurrence_every">La serie</string>
|
<string name="event_edit_recurrence_every">Ogni</string>
|
||||||
<string name="recurrence_unit_days">giorni</string>
|
<string name="recurrence_unit_days">giorni</string>
|
||||||
<string name="recurrence_unit_weeks">settimane</string>
|
<string name="recurrence_unit_weeks">settimane</string>
|
||||||
<string name="recurrence_unit_months">mesi</string>
|
<string name="recurrence_unit_months">mesi</string>
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
<string name="event_edit_recurrence_ends">Fine</string>
|
<string name="event_edit_recurrence_ends">Fine</string>
|
||||||
<string name="event_edit_recurrence_end_never">Mai</string>
|
<string name="event_edit_recurrence_end_never">Mai</string>
|
||||||
<string name="event_edit_recurrence_end_until">Fino alla data</string>
|
<string name="event_edit_recurrence_end_until">Fino alla data</string>
|
||||||
<string name="event_edit_recurrence_end_count">Dopo un certo numero di occorrenze</string>
|
<string name="event_edit_recurrence_end_count">Numero di occorrenze</string>
|
||||||
<string name="event_edit_recurrence_times">occorrenze</string>
|
<string name="event_edit_recurrence_times">occorrenze</string>
|
||||||
<string name="event_edit_error_recurrence_ends_before_start">Fine antecedente l\'inizio</string>
|
<string name="event_edit_error_recurrence_ends_before_start">Fine antecedente l\'inizio</string>
|
||||||
<string name="event_availability_busy">Occupato</string>
|
<string name="event_availability_busy">Occupato</string>
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
<string name="event_detail_location">Luogo</string>
|
<string name="event_detail_location">Luogo</string>
|
||||||
<string name="event_detail_description">Descrizione</string>
|
<string name="event_detail_description">Descrizione</string>
|
||||||
<string name="event_detail_attendees">Partecipanti</string>
|
<string name="event_detail_attendees">Partecipanti</string>
|
||||||
<string name="recurrence_daily">Ogni giorni</string>
|
<string name="recurrence_daily">Ogni giorno</string>
|
||||||
<string name="recurrence_weekly">Ogni settimana</string>
|
<string name="recurrence_weekly">Ogni settimana</string>
|
||||||
<string name="recurrence_monthly">Ogni mese</string>
|
<string name="recurrence_monthly">Ogni mese</string>
|
||||||
<string name="recurrence_yearly">Ogni anno</string>
|
<string name="recurrence_yearly">Ogni anno</string>
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
<string name="reminder_benefit_reversible_body">Lo switch è nelle Impostazioni, sezione Notifiche.</string>
|
<string name="reminder_benefit_reversible_body">Lo switch è nelle Impostazioni, sezione Notifiche.</string>
|
||||||
<string name="reminder_onboarding_enable_button">Attiva i promemoria</string>
|
<string name="reminder_onboarding_enable_button">Attiva i promemoria</string>
|
||||||
<string name="reminder_onboarding_skip_button">Non ora</string>
|
<string name="reminder_onboarding_skip_button">Non ora</string>
|
||||||
<string name="reminder_action_snooze">Posponi</string>
|
<string name="reminder_action_snooze">Posticipa</string>
|
||||||
<string name="reminder_action_dismiss">Ignora</string>
|
<string name="reminder_action_dismiss">Ignora</string>
|
||||||
<string name="view_month">Mese</string>
|
<string name="view_month">Mese</string>
|
||||||
<string name="view_week">Settimana</string>
|
<string name="view_week">Settimana</string>
|
||||||
@@ -418,16 +418,16 @@
|
|||||||
<string name="settings_default_reminder">Promemoria standard</string>
|
<string name="settings_default_reminder">Promemoria standard</string>
|
||||||
<string name="settings_notifications_subtitle">Promemoria evento</string>
|
<string name="settings_notifications_subtitle">Promemoria evento</string>
|
||||||
<string name="shortcut_new_event_short">Nuovo evento</string>
|
<string name="shortcut_new_event_short">Nuovo evento</string>
|
||||||
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente</string>
|
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
|
||||||
<string name="settings_font_headings">Carattere dell\'intestazione</string>
|
<string name="settings_font_headings">Carattere del titolo</string>
|
||||||
<string name="settings_font_body">Carattere del corpo</string>
|
<string name="settings_font_body">Carattere del corpo</string>
|
||||||
<string name="settings_font_system">Predefinito di sistema</string>
|
<string name="settings_font_system">Predefinito di sistema</string>
|
||||||
<string name="settings_font_choose_file">Scegli file…</string>
|
<string name="settings_font_choose_file">Scegli file…</string>
|
||||||
<string name="settings_font_custom_selected">Carattere personalizzato</string>
|
<string name="settings_font_custom_selected">Font personalizzato</string>
|
||||||
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
||||||
<string name="settings_section_views">Viste</string>
|
<string name="settings_section_views">Viste</string>
|
||||||
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
|
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
|
||||||
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere mediante il pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
|
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
|
||||||
<string name="settings_drawer_order_header">Menù di navigazione</string>
|
<string name="settings_drawer_order_header">Menù di navigazione</string>
|
||||||
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
|
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
|
||||||
<string name="reorder_drag_handle">Trascina per riordinare</string>
|
<string name="reorder_drag_handle">Trascina per riordinare</string>
|
||||||
@@ -463,4 +463,7 @@
|
|||||||
<string name="special_dates_calendar_custom">Date speciali</string>
|
<string name="special_dates_calendar_custom">Date speciali</string>
|
||||||
<string name="special_dates_default_title_birthday">Compleanno di {name} ({year})</string>
|
<string name="special_dates_default_title_birthday">Compleanno di {name} ({year})</string>
|
||||||
<string name="special_dates_default_title_anniversary">Anniversario di {name} ({year})</string>
|
<string name="special_dates_default_title_anniversary">Anniversario di {name} ({year})</string>
|
||||||
|
<string name="font_lora">Lora</string>
|
||||||
|
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||||
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class EventDetailMapperTest {
|
|||||||
accessLevel: Any? = null,
|
accessLevel: Any? = null,
|
||||||
timezone: String? = null,
|
timezone: String? = null,
|
||||||
selfStatus: Any? = null,
|
selfStatus: Any? = null,
|
||||||
|
duration: String? = null,
|
||||||
): MapColumnReader = MapColumnReader(
|
): MapColumnReader = MapColumnReader(
|
||||||
EventDetailProjection.IDX_EVENT_ID to eventId,
|
EventDetailProjection.IDX_EVENT_ID to eventId,
|
||||||
EventDetailProjection.IDX_TITLE to title,
|
EventDetailProjection.IDX_TITLE to title,
|
||||||
@@ -51,6 +52,7 @@ class EventDetailMapperTest {
|
|||||||
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
||||||
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
||||||
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
|
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
|
||||||
|
EventDetailProjection.IDX_DURATION to duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun attendeeReader(
|
private fun attendeeReader(
|
||||||
@@ -134,12 +136,32 @@ class EventDetailMapperTest {
|
|||||||
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
|
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
|
||||||
// A yearly birthday/anniversary anchored before the epoch has a
|
// A yearly birthday/anniversary anchored before the epoch has a
|
||||||
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
|
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
|
||||||
// no DTEND (they use DURATION), so it stays end == begin.
|
// no DTEND (they use DURATION), so the length comes from DURATION.
|
||||||
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
||||||
val detail = detailReader(dtstart = begin, dtend = null).toDetail()
|
val detail = detailReader(dtstart = begin, dtend = null, duration = "P1D").toDetail()
|
||||||
assertThat(detail).isNotNull()
|
assertThat(detail).isNotNull()
|
||||||
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
|
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
|
||||||
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin)
|
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin + 86_400_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `absent dtend takes its length from DURATION (issue #48)`() {
|
||||||
|
// A recurring series row has no DTEND. Opened without a named
|
||||||
|
// occurrence — a bare content://…/events/<id> VIEW intent — the row's
|
||||||
|
// own times are what render, so DURATION has to supply the length or
|
||||||
|
// the event shows as zero-length (10:00–10:00).
|
||||||
|
val begin = 1_000_000_000L
|
||||||
|
val detail = detailReader(dtstart = begin, dtend = null, duration = "PT1H").toDetail()
|
||||||
|
assertThat(detail).isNotNull()
|
||||||
|
assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin + 3_600_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `absent dtend and absent DURATION stays zero-length`() {
|
||||||
|
val begin = 1_000_000_000L
|
||||||
|
val detail = detailReader(dtstart = begin, dtend = null, duration = null).toDetail()
|
||||||
|
assertThat(detail).isNotNull()
|
||||||
|
assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -260,6 +260,77 @@ class EventWriteMapperTest {
|
|||||||
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
|
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- buildOccurrenceExdateValues ("delete only this event", no _sync_id) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exdate drop excludes the occurrence and rewrites the recurrence set`() {
|
||||||
|
// 2026-07-15T08:00:00Z.
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = null,
|
||||||
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
duration = "PT1H",
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
allDay = 0,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
|
||||||
|
// The whole time/recurrence set rides along: an EXDATE-only update is not
|
||||||
|
// treated as a recurrence change, so the provider would leave the expanded
|
||||||
|
// instances (and the occurrence) in place. DTSTART alone is worse — it
|
||||||
|
// makes the provider recompute lastDate as a single instance and collapse
|
||||||
|
// the series to its first occurrence.
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_783_929_600_000L)
|
||||||
|
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY;COUNT=5")
|
||||||
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("PT1H")
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exdate drop appends to an existing exdate list`() {
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = "20260714T080000Z",
|
||||||
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
duration = "PT1H",
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
allDay = 0,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE])
|
||||||
|
.isEqualTo("20260714T080000Z,20260715T080000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exdate drop folds away a repeated occurrence`() {
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = "20260715T080000Z",
|
||||||
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
duration = "PT1H",
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
allDay = 0,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day exdate drop uses the date-only form`() {
|
||||||
|
// An all-day DTSTART sits at UTC midnight, so the date reads off UTC.
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = null,
|
||||||
|
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
|
||||||
|
dtStartMillis = 1_783_900_800_000L,
|
||||||
|
rrule = "FREQ=YEARLY",
|
||||||
|
duration = "P1D",
|
||||||
|
timezone = "UTC",
|
||||||
|
allDay = 1,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715")
|
||||||
|
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
// --- per-event colour ---
|
// --- per-event colour ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
34
fastlane/metadata/android/en-US/changelogs/21401.txt
Normal file
34
fastlane/metadata/android/en-US/changelogs/21401.txt
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
### Fixed
|
||||||
|
- Deleting one occurrence of a repeating event no longer breaks the series.
|
||||||
|
Choosing "This event" when deleting an occurrence of a recurring event could
|
||||||
|
wipe out every *other* occurrence while leaving the one you deleted behind as
|
||||||
|
a stale, still-tappable ghost — and deleting it again brought the series back.
|
||||||
|
A single-occurrence delete now removes exactly that occurrence and leaves the
|
||||||
|
rest of the series untouched, and the deleted occurrence disappears from the
|
||||||
|
grid straight away. This holds on every kind of calendar, including the
|
||||||
|
on-device ones Calendula keeps for contact birthdays and anniversaries, where
|
||||||
|
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
|
||||||
|
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
|
||||||
|
Calendula to answer the "new event" hand-off from other apps and widgets; now
|
||||||
|
it also answers the "open this event" one, so tapping an existing event in a
|
||||||
|
widget such as Todo Agenda offers Calendula and lands on that event's details.
|
||||||
|
Thanks to @bushrang3r for the report ([#48]).
|
||||||
|
- Events created from other apps get your default reminder. An event handed over
|
||||||
|
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
|
||||||
|
widget's "+" — opened with no reminder at all, ignoring the default set in
|
||||||
|
Settings. It now starts with your default reminder, the same as an event you
|
||||||
|
create in Calendula. An event opened from an `.ics` file is treated differently,
|
||||||
|
because the file has its own say: Calendula keeps whatever reminders it carries
|
||||||
|
(including none at all) and asks you once whether to apply your default instead
|
||||||
|
— it never quietly overrides the file. If you have no default set, it doesn't
|
||||||
|
ask ([#49]).
|
||||||
|
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
||||||
|
app (such as DAVx5), the event colour picker showed every colour the account
|
||||||
|
publishes — nearly 150 swatches in alphabetical order, many of them
|
||||||
|
duplicates or near-identical shades. The picker now shows only visually
|
||||||
|
distinct colours, arranged as a rainbow; near-duplicate shades and the
|
||||||
|
washed-out neutrals are folded away so no two swatches look alike. Picked
|
||||||
|
colours still sync exactly as before, and calendars with hand-picked
|
||||||
|
palettes (like Google's) are unaffected. Thanks to @ptab for the report
|
||||||
|
([#22]).
|
||||||
|
|
||||||
Reference in New Issue
Block a user