Compare commits
49 Commits
f81f593017
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 6519cbca79 | |||
| 50510a23da | |||
| 2279738371 | |||
| 3d59962112 | |||
| a96d8b6de9 | |||
| 94b9eeaa78 | |||
|
|
85fb091e22 | ||
| 2139f8729c | |||
| 98a48aa795 | |||
| d255d232b9 | |||
| 9999fcdd2a | |||
| 953ffdff97 | |||
| 5377a2b466 | |||
| e73148dc6c | |||
| a19772e3a7 | |||
| b3386eff43 | |||
| 79e74d9995 | |||
| 396a5610aa | |||
| b9329f6fb6 | |||
| 1b731a4ab0 | |||
| ab631365b2 | |||
| 9aa370d583 | |||
|
|
8cd75716ae | ||
|
|
4feccf2008 | ||
| 23e9a5a35b | |||
| 38a35be0f0 | |||
| f9f0572ec5 | |||
| 0221972e6d | |||
| 86ea72d56f | |||
| 114db7939c | |||
| 4503847c0d | |||
| 95c5fe76e6 | |||
| 34fa9c9c3e | |||
| 53793bfb68 | |||
| 9c7c8cb03a | |||
| 4fea176e28 | |||
| cf9492c7ba | |||
| e6def9e5f7 | |||
| 974185f354 | |||
| 6aacdd9111 | |||
| 4f263d00fe | |||
| bda002684c | |||
| b6bcd195b0 | |||
| 9fb4502ed0 | |||
| d398c72005 | |||
| 9d718e0f51 | |||
| 2218c11d3f | |||
| 60aa889c57 | |||
| 3c9767387b |
@@ -3,13 +3,15 @@ name: Translations
|
||||
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||
#
|
||||
# Runs on every PR (no path filter) so the required "Translations / check"
|
||||
# status is always reported — like the `ci` job. A path-filtered workflow is
|
||||
# skipped on unrelated PRs and never posts its status, which leaves that
|
||||
# required check pending forever and blocks the merge of any code-only PR into a
|
||||
# release/* branch. The check itself is cheap and simply passes when the
|
||||
# committed translations are consistent, so always running it costs nothing.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'app/src/main/res/values*/strings.xml'
|
||||
- 'app/src/main/res/xml/locales_config.xml'
|
||||
- 'scripts/check_translations.py'
|
||||
- '.gitea/workflows/translations.yaml'
|
||||
|
||||
concurrency:
|
||||
group: translations-${{ github.ref }}
|
||||
|
||||
111
CHANGELOG.md
111
CHANGELOG.md
@@ -5,9 +5,108 @@ 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.15.0] — 2026-07-15
|
||||
|
||||
### Added
|
||||
- Show raw calendar colours. Calendula normally softens each calendar and event
|
||||
colour toward a theme-fitting pastel so harsh sync colours read well on both
|
||||
light and dark; a new **Soften calendar colours** setting (Settings → Design,
|
||||
on by default) lets you turn that off and paint the exact colours your calendar
|
||||
source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
|
||||
@leonp5 for the report ([#36]).
|
||||
- Readable titles on dark event colours. An event bar's title now shows in white
|
||||
on a dark colour and near-black on a light one, chosen automatically from the
|
||||
colour's brightness, so a deep blue or purple event is legible at a glance in
|
||||
the busy Week and Month views instead of dark-on-dark. This applies whether or
|
||||
not colours are softened. Thanks to @ptab for the suggestion ([#21]).
|
||||
- A custom snooze duration. The **Snooze duration** setting (Settings →
|
||||
Notifications) gains a **Custom…** option next to the minute presets: pick any
|
||||
amount and switch between minutes and hours, so a snoozed reminder comes back
|
||||
after exactly the delay you want instead of only a preset one ([#40]).
|
||||
- Move an event to another calendar. When editing an existing event, the
|
||||
calendar row is now tappable — pick a different calendar and saving moves the
|
||||
event across, instead of having to delete it and recreate it elsewhere.
|
||||
Recurring series move as a whole, keeping their individually-edited and
|
||||
cancelled occurrences, and any reminders and guests come along too. A calendar
|
||||
can't simply be reassigned underneath an event, so Calendula recreates it on
|
||||
the target and removes the original — the same approach other calendar apps
|
||||
take. Thanks to @prismplex for the suggestion ([#39]).
|
||||
- Open an event straight into the edit form from another app. Calendula already
|
||||
answered the "new event" and "open this event" hand-offs from other apps and
|
||||
widgets; it now also answers the "edit this event" one, so an assistant, task
|
||||
app, or widget can send an existing event to Calendula and land on its edit
|
||||
screen rather than the read-only details. A hand-off with no event attached
|
||||
opens the same prefilled create form as "new event". Calendula also recognises
|
||||
a couple more file labels the same calendar data arrives under (`.vcs`
|
||||
vCalendar files and the `application/ics` type), so opening or sharing those
|
||||
into Calendula works too.
|
||||
- Keep today at the top of the agenda. A new **Always show today** setting
|
||||
(Settings → Agenda, on by default) anchors today as the first entry in both the
|
||||
Agenda screen and its home-screen widget even once nothing is left today —
|
||||
under today's header a "No more events today" note appears — so the first
|
||||
events you see are clearly today's rather than a future day's. Turn it off to
|
||||
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
|
||||
- Duplicate an event. The event details now carry a **Duplicate** action that
|
||||
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
|
||||
a one-off like a shift or an appointment can be recreated by just changing the
|
||||
day and time instead of re-typing every field. The copy keeps the original's
|
||||
time, and its title, location, notes, colour, guests and reminders come along;
|
||||
it's saved as its own single event (any repeat is left off — add one in the
|
||||
editor if you want it). Duplicate works from read-only calendars too, dropping
|
||||
the copy into a writable one. Thanks to @internet-rando for the suggestion
|
||||
([#52]).
|
||||
- French and Polish, in early form. Calendula has started speaking French and
|
||||
Polish, both contributed as community translations through
|
||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
They are partway there, so untranslated parts still show in English until they
|
||||
fill out — you can already pick either under Settings → Language or in Android's
|
||||
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
|
||||
(Polish) for getting them started; help finishing them is very welcome.
|
||||
|
||||
### Fixed
|
||||
- Reminders for events on another day no longer read as if they were today. A
|
||||
reminder fired ahead of time — say, the day before — used to show only the
|
||||
event's time, making it look like it was happening now. The notification now
|
||||
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
|
||||
week, or the date for anything further out. Thanks to @moonj for the report
|
||||
([#46]).
|
||||
- Agenda dates now read in your locale's format. The agenda's range bar and its
|
||||
day headers used a fixed day-month-year layout — and the range span even mixed
|
||||
two orders (e.g. "15 Jul – Aug 13, 2026") — instead of following your language's
|
||||
conventions. Dates across the agenda and its widget now use your locale's own
|
||||
field order, matching the rest of the app. The range bar also no longer repeats
|
||||
the range's name from the selector button beside it, showing just the dates.
|
||||
- Calendar gutters line up with the menu button. The Month view's week-number
|
||||
column, and the Week and Day views' hour labels, sat a few pixels left of the
|
||||
hamburger menu above them; they now line up with it. In Month view the day
|
||||
cells also sit squarely under their weekday letters.
|
||||
|
||||
## [2.14.1] — 2026-07-13
|
||||
|
||||
### 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
|
||||
@@ -883,8 +982,18 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
|
||||
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
|
||||
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
|
||||
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
||||
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||
[#36]: https://codeberg.org/jlmakiola/calendula/issues/36
|
||||
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
|
||||
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
|
||||
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
|
||||
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
|
||||
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
||||
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
||||
|
||||
@@ -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 = 21400
|
||||
versionName = "2.14.0"
|
||||
versionCode = 21500
|
||||
versionName = "2.15.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -97,32 +97,74 @@
|
||||
<data android:mimeType="time/epoch" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||
<!-- Open a .ics/.vcs 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>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" android:mimeType="text/calendar" />
|
||||
<data android:scheme="file" android:mimeType="text/calendar" />
|
||||
<data android:scheme="content" />
|
||||
<data android:scheme="file" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
<data android:mimeType="text/x-vcalendar" />
|
||||
<data android:mimeType="application/ics" />
|
||||
</intent-filter>
|
||||
<!-- Receive a .ics shared from another app. -->
|
||||
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/calendar" />
|
||||
<data android:mimeType="text/x-vcalendar" />
|
||||
<data android:mimeType="application/ics" />
|
||||
</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). -->
|
||||
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
|
||||
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
|
||||
to the same prefilled create form. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.INSERT" />
|
||||
<action android:name="android.intent.action.EDIT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||
</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
|
||||
an event in the Todo Agenda widget): ACTION_VIEW on
|
||||
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
||||
Matched by the provider's item MIME type, not the path — a
|
||||
content: VIEW intent carries the resolved type
|
||||
(vnd.android.cursor.item/event) and a path-only filter wouldn't
|
||||
match it. The occurrence's times ride as EXTRA_EVENT_BEGIN_TIME /
|
||||
EXTRA_EVENT_END_TIME when the launcher supplies them
|
||||
(MainActivity.viewEventKeyOrNull, issue #48). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
|
||||
@@ -29,9 +29,11 @@ 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.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||
import de.jeanlucmakiola.floret.components.DebugRibbon
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
@@ -69,6 +71,11 @@ class MainActivity : AppCompatActivity() {
|
||||
// CalendarHost, which opens it in the create form for review.
|
||||
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
|
||||
// over the calendar on the next launch (the single-crash path). A startup
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
@@ -90,10 +97,11 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
requestedDetailKey = intent.detailKeyOrNull()
|
||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsertForm = intent.insertFormOrNull()
|
||||
requestedEditKey = intent.editEventKeyOrNull()
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
@@ -133,6 +141,7 @@ class MainActivity : AppCompatActivity() {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
LocalSoftenColors provides settings.softenColors,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -144,6 +153,8 @@ class MainActivity : AppCompatActivity() {
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = { requestedInsertForm = null },
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = { requestedEditKey = null },
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
@@ -178,10 +189,11 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,13 +214,19 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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].
|
||||
* A prefilled new-event form from an external launch asking us to create an
|
||||
* event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
|
||||
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
||||
* event", i.e. create). 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
|
||||
// ACTION_EDIT on an existing event routes to the edit form instead
|
||||
// ([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(
|
||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||
@@ -293,6 +311,55 @@ class MainActivity : AppCompatActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail key for an external "open this event" — ACTION_VIEW on
|
||||
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
||||
* tapping an existing event in the Todo Agenda widget, issue #48). Reuses the
|
||||
* same occurrence-key channel as reminder taps. The launcher passes the
|
||||
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME` when
|
||||
* it has them; a bare URI omits them, so we carry [NO_OCCURRENCE_TIME] and
|
||||
* [EventDetailViewModel] falls back to the event row's own DTSTART/DTEND
|
||||
* rather than rendering at the epoch.
|
||||
*/
|
||||
private fun Intent.viewEventKeyOrNull(): LongArray? {
|
||||
if (action != Intent.ACTION_VIEW) 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,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// The calendar provider's authority/host. A date tap arrives as
|
||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||
|
||||
@@ -18,6 +18,8 @@ import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
@@ -179,6 +181,25 @@ interface CalendarDataSource {
|
||||
allDayReminderTimeMinutes: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Move an event (for recurring events: the whole series, with its modified
|
||||
* and cancelled occurrences) to [targetCalendarId], returning the new
|
||||
* `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in
|
||||
* place, so this is copy+delete: the row is re-inserted on the target (its
|
||||
* `UID_2445` preserved), its exceptions, reminders and editable guests
|
||||
* replayed, the [original]→[updated] field edits applied, then the source
|
||||
* deleted. The source is removed only once the copy fully succeeds; a failure
|
||||
* before that rolls the new event back, leaving the original untouched.
|
||||
* [allDayReminderTimeMinutes]: see [insertEvent].
|
||||
*/
|
||||
fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event by inserting a
|
||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||
@@ -495,7 +516,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
return resolver.query(
|
||||
uri,
|
||||
InstanceProjection.COLUMNS,
|
||||
null, null,
|
||||
// Hide cancelled occurrences: "delete only this event" writes a
|
||||
// cancelled exception for the one instance (#47). A NULL status is a
|
||||
// normal, un-cancelled event, so it must survive the filter — a bare
|
||||
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
||||
"${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
|
||||
null,
|
||||
CalendarContract.Instances.BEGIN + " ASC",
|
||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||
}
|
||||
@@ -879,6 +906,190 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
// CALENDAR_ID can't be updated in place, so re-create the event on the
|
||||
// target calendar and delete the source. Everything that identifies or
|
||||
// hangs off the event is copied first; the source row is removed only
|
||||
// once the copy (including its exceptions) is complete — a failure
|
||||
// anywhere before that rolls the new event back, so the move is
|
||||
// all-or-nothing and never leaves a half-copied duplicate.
|
||||
val master = queryMoveMaster(eventId)
|
||||
?: throw WriteFailedException("read event to move id=$eventId")
|
||||
// Keep the source UID so an .ics backup dedups and a sync adapter can
|
||||
// recognise the moved event; mint one only if the source carried none.
|
||||
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula"
|
||||
val newEventId = resolver.insert(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
|
||||
)?.let(ContentUris::parseId)
|
||||
?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId")
|
||||
try {
|
||||
copyReminderRows(fromEventId = eventId, toEventId = newEventId)
|
||||
insertAttendees(newEventId, editableAttendees(eventId))
|
||||
copyExceptions(fromEventId = eventId, toEventId = newEventId)
|
||||
// Apply the user's field edits exactly as a same-calendar "all
|
||||
// events" save would: the moved master carries the source's values,
|
||||
// so the dirty diff against [original] writes only what changed
|
||||
// (including a time or rrule edit made in the same save).
|
||||
updateEvent(newEventId, original, updated, allDayReminderTimeMinutes)
|
||||
} catch (t: Throwable) {
|
||||
// Undo the partial copy so a failed move leaves nothing behind; the
|
||||
// source is still intact (it's deleted only past this point).
|
||||
runCatching { deleteEvent(newEventId) }
|
||||
throw t
|
||||
}
|
||||
deleteEvent(eventId)
|
||||
return newEventId
|
||||
}
|
||||
|
||||
/** The master row of the event to move, as a verbatim-insert snapshot. */
|
||||
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
MoveMasterProjection.COLUMNS,
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
if (!c.moveToFirst()) return@use null
|
||||
val r = CursorColumnReader(c)
|
||||
MoveMaster(
|
||||
uid = r.getString(MoveMasterProjection.IDX_UID),
|
||||
event = MasterEventSnapshot(
|
||||
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
|
||||
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
|
||||
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
|
||||
dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND)
|
||||
.takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) },
|
||||
duration = r.getString(MoveMasterProjection.IDX_DURATION),
|
||||
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
|
||||
rdate = r.getString(MoveMasterProjection.IDX_RDATE),
|
||||
exdate = r.getString(MoveMasterProjection.IDX_EXDATE),
|
||||
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
|
||||
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
|
||||
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
|
||||
status = r.getInt(MoveMasterProjection.IDX_STATUS)
|
||||
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
|
||||
location = r.getString(MoveMasterProjection.IDX_LOCATION),
|
||||
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private data class MoveMaster(val uid: String?, val event: MasterEventSnapshot)
|
||||
|
||||
/** Copy every stored reminder row (raw offsets and method) onto [toEventId]. */
|
||||
private fun copyReminderRows(fromEventId: Long, toEventId: Long) = resolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Reminders.MINUTES, CalendarContract.Reminders.METHOD),
|
||||
CalendarContract.Reminders.EVENT_ID + " = ?",
|
||||
arrayOf(fromEventId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
while (c.moveToNext()) {
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, toEventId)
|
||||
put(CalendarContract.Reminders.MINUTES, c.getInt(0))
|
||||
put(CalendarContract.Reminders.METHOD, c.getInt(1))
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) {
|
||||
Log.w(TAG, "Failed to copy a reminder to moved event $toEventId")
|
||||
}
|
||||
}
|
||||
} ?: Unit
|
||||
|
||||
/**
|
||||
* The event's editable guests as [EventAttendee]s — the rows the move
|
||||
* re-creates. Mirrors the edit form's filter: the organizer and resource
|
||||
* rows (backend-owned, not user-editable) and any without an email are
|
||||
* dropped; the response status resets to "invited" on re-insert.
|
||||
*/
|
||||
private fun editableAttendees(eventId: Long): List<EventAttendee> = queryAttendees(eventId)
|
||||
.filter {
|
||||
it.relationship != AttendeeRelationship.Organizer && it.type != AttendeeType.Resource
|
||||
}
|
||||
.mapNotNull { a ->
|
||||
a.email?.takeIf { it.isNotBlank() }?.let { email ->
|
||||
EventAttendee(email = email, name = a.name, optional = a.type == AttendeeType.Optional)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay every exception of the series [fromEventId] against the moved series
|
||||
* [toEventId]. The copy preserved the recurrence skeleton, so each occurrence
|
||||
* time still resolves: a cancelled occurrence is re-hidden with a cancelled
|
||||
* exception, a modified one is re-inserted with its fields (then its reminders
|
||||
* reconciled and editable guests copied). No-op for a non-recurring event.
|
||||
*/
|
||||
private fun copyExceptions(fromEventId: Long, toEventId: Long) {
|
||||
queryExceptionRows(fromEventId).forEach { ex ->
|
||||
if (ex.isCancelled) {
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis)
|
||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||
}
|
||||
resolver.insert(
|
||||
ContentUris.withAppendedId(
|
||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
|
||||
),
|
||||
values,
|
||||
) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId")
|
||||
} else {
|
||||
val uri = resolver.insert(
|
||||
ContentUris.withAppendedId(
|
||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
|
||||
),
|
||||
buildCopiedExceptionValues(ex).toContentValues(),
|
||||
) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId")
|
||||
val newExceptionId = ContentUris.parseId(uri)
|
||||
// The provider may clone the parent's reminders onto the new
|
||||
// exception; reconcile to the source exception's exact set so
|
||||
// they neither double nor drop. Same DTSTART → same all-day
|
||||
// encoding, so the stored offsets match directly.
|
||||
reconcileReminders(
|
||||
newExceptionId,
|
||||
queryReminders(ex.exceptionEventId).map { it.minutes },
|
||||
)
|
||||
insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
|
||||
private fun queryExceptionRows(seriesEventId: Long): List<ExceptionRowSnapshot> = resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
ExceptionProjection.COLUMNS,
|
||||
"${CalendarContract.Events.ORIGINAL_ID} = ? AND ${CalendarContract.Events.DELETED} = 0",
|
||||
arrayOf(seriesEventId.toString()),
|
||||
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
|
||||
)?.use { c ->
|
||||
c.mapAll {
|
||||
val r = CursorColumnReader(c)
|
||||
val status = r.getInt(ExceptionProjection.IDX_STATUS)
|
||||
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
|
||||
ExceptionRowSnapshot(
|
||||
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID),
|
||||
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
|
||||
isCancelled = status == CalendarContract.Events.STATUS_CANCELED,
|
||||
status = status,
|
||||
title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(),
|
||||
isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0,
|
||||
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
|
||||
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
|
||||
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
|
||||
duration = r.getString(ExceptionProjection.IDX_DURATION),
|
||||
timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE),
|
||||
availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY),
|
||||
accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL),
|
||||
location = r.getString(ExceptionProjection.IDX_LOCATION),
|
||||
description = r.getString(ExceptionProjection.IDX_DESCRIPTION),
|
||||
)
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
override fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
@@ -973,6 +1184,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events._SYNC_ID,
|
||||
CalendarContract.Events.EXDATE,
|
||||
),
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
@@ -983,6 +1196,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
timezone = c.getString(2),
|
||||
duration = c.getString(3),
|
||||
allDay = c.getInt(4),
|
||||
syncId = c.getString(5),
|
||||
exdate = c.getString(6),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -995,6 +1210,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
val timezone: String?,
|
||||
val duration: String?,
|
||||
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]. */
|
||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||
@@ -1185,16 +1403,49 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
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.
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
|
||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||
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(
|
||||
originalInstanceMillis = beginMillis,
|
||||
dtStartMillis = beginMillis,
|
||||
duration = row.duration,
|
||||
timezone = row.timezone,
|
||||
allDay = row.allDay,
|
||||
)
|
||||
val uri = ContentUris.withAppendedId(
|
||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
||||
)
|
||||
resolver.insert(uri, values)
|
||||
resolver.insert(uri, values.toContentValues())
|
||||
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,20 @@ interface CalendarRepository {
|
||||
*/
|
||||
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||
|
||||
/**
|
||||
* Move an event (recurring: the whole series, with its exceptions) to
|
||||
* [targetCalendarId] and apply the [original]→[updated] field edits; returns
|
||||
* the new event's `Events._ID`. Copy+delete under the hood
|
||||
* (see [CalendarDataSource.moveEvent]) — `CALENDAR_ID` can't be updated in
|
||||
* place.
|
||||
*/
|
||||
suspend fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event (exception row with the
|
||||
* form's values); returns the exception's `Events._ID`.
|
||||
|
||||
@@ -157,6 +157,17 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
dataSource.deleteEvent(eventId)
|
||||
}
|
||||
|
||||
override suspend fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long = withContext(io) {
|
||||
dataSource.moveEvent(
|
||||
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
|
||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
@@ -36,15 +37,18 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||
|
||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
||||
// specific occurrence supply the real per-occurrence times from
|
||||
// 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).
|
||||
// DTEND is null — derive the length from DURATION (as SearchMapper and
|
||||
// IcsExportMapper do). Callers that opened a specific occurrence overwrite
|
||||
// both times with the per-occurrence values from CalendarContract.Instances;
|
||||
// a caller that names no occurrence (a bare content://.../events/<id> VIEW
|
||||
// intent, issue #48) keeps this row's own times, so the length has to be
|
||||
// right here or the series renders zero-length. 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).
|
||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||
begin
|
||||
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
|
||||
} else {
|
||||
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||
}
|
||||
|
||||
@@ -43,10 +43,19 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
||||
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||
* events, seconds otherwise.
|
||||
*/
|
||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
||||
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String =
|
||||
rfc2445Duration(dtEndMillis - dtStartMillis, isAllDay)
|
||||
|
||||
/**
|
||||
* RFC 2445 duration for a [spanMillis]-long event: whole days for all-day
|
||||
* events (the provider's convention), seconds otherwise. Shared by the write
|
||||
* paths that need a DURATION but start from a raw millisecond span (the series
|
||||
* copy and exception replay of a calendar move) rather than [EventWriteTimes].
|
||||
*/
|
||||
internal fun rfc2445Duration(spanMillis: Long, isAllDay: Boolean): String = if (isAllDay) {
|
||||
"P${spanMillis / MILLIS_PER_DAY}D"
|
||||
} else {
|
||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||
"P${spanMillis / 1_000L}S"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,6 +191,215 @@ internal fun buildOccurrenceExceptionValues(
|
||||
putAll(eventColorColumns(form.colorKey, form.color))
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
||||
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||
* is sync-adapter-owned and can't be updated in place). Recurring rows carry
|
||||
* [rrule]/[duration] (and any [rdate]/[exdate]) with a null [dtEndMillis];
|
||||
* one-off rows carry [dtEndMillis]. Colour is deliberately absent: a raw
|
||||
* `EVENT_COLOR` or account-scoped `EVENT_COLOR_KEY` may be invalid on the target
|
||||
* account, so the moved copy inherits the target calendar's colour instead.
|
||||
*/
|
||||
internal data class MasterEventSnapshot(
|
||||
val title: String,
|
||||
val isAllDay: Boolean,
|
||||
val dtStartMillis: Long,
|
||||
val dtEndMillis: Long?,
|
||||
val duration: String?,
|
||||
val rrule: String?,
|
||||
val rdate: String?,
|
||||
val exdate: String?,
|
||||
val timezone: String?,
|
||||
val availability: Int,
|
||||
val accessLevel: Int,
|
||||
val status: Int?,
|
||||
val location: String?,
|
||||
val description: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Column values re-creating [snapshot] as a fresh Events row on
|
||||
* [targetCalendarId], keeping its [uid] so `.ics` backup dedup and sync identity
|
||||
* survive the move. Preserves the recurrence skeleton (DTSTART/RRULE/DURATION,
|
||||
* RDATE/EXDATE) so the series' generated instances — and therefore the
|
||||
* ORIGINAL_INSTANCE_TIME of every copied exception — line up unchanged. The
|
||||
* caller layers the user's field edits on top with a normal series update.
|
||||
*/
|
||||
internal fun buildMovedMasterValues(
|
||||
snapshot: MasterEventSnapshot,
|
||||
targetCalendarId: Long,
|
||||
uid: String,
|
||||
): Map<String, Any?> = buildMap {
|
||||
put(CalendarContract.Events.CALENDAR_ID, targetCalendarId)
|
||||
put(CalendarContract.Events.UID_2445, uid)
|
||||
put(CalendarContract.Events.TITLE, snapshot.title)
|
||||
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
||||
if (snapshot.rrule != null) {
|
||||
put(CalendarContract.Events.RRULE, snapshot.rrule)
|
||||
snapshot.rdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.RDATE, it) }
|
||||
snapshot.exdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.EXDATE, it) }
|
||||
put(CalendarContract.Events.DURATION, snapshot.movedDuration())
|
||||
} else {
|
||||
snapshot.dtEndMillis?.let { put(CalendarContract.Events.DTEND, it) }
|
||||
}
|
||||
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||
}
|
||||
|
||||
/** The recurring copy's DURATION: its own if present, else derived from DTEND. */
|
||||
private fun MasterEventSnapshot.movedDuration(): String = duration?.takeIf { it.isNotBlank() }
|
||||
?: rfc2445Duration((dtEndMillis ?: dtStartMillis) - dtStartMillis, isAllDay)
|
||||
|
||||
/**
|
||||
* Raw provider snapshot of one exception row of a recurring series (a modified
|
||||
* or cancelled occurrence, `ORIGINAL_ID` = the series). [originalInstanceMillis]
|
||||
* ties it to the occurrence it overrides; a [isCancelled] row only needs that.
|
||||
*/
|
||||
internal data class ExceptionRowSnapshot(
|
||||
val exceptionEventId: Long,
|
||||
val originalInstanceMillis: Long,
|
||||
val isCancelled: Boolean,
|
||||
val status: Int?,
|
||||
val title: String,
|
||||
val isAllDay: Boolean,
|
||||
val dtStartMillis: Long,
|
||||
val dtEndMillis: Long?,
|
||||
val duration: String?,
|
||||
val timezone: String?,
|
||||
val availability: Int,
|
||||
val accessLevel: Int,
|
||||
val location: String?,
|
||||
val description: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Column values replaying a *modified* occurrence [snapshot] against the moved
|
||||
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
|
||||
* length travels as DURATION (the provider rejects DTEND on an exception). A
|
||||
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
|
||||
* STATUS_CANCELED) — this builder is only for the modified case.
|
||||
*/
|
||||
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
|
||||
buildMap {
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
|
||||
put(CalendarContract.Events.TITLE, snapshot.title)
|
||||
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
||||
put(
|
||||
CalendarContract.Events.DURATION,
|
||||
snapshot.duration?.takeIf { it.isNotBlank() }
|
||||
?: rfc2445Duration(
|
||||
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
|
||||
snapshot.isAllDay,
|
||||
),
|
||||
)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
||||
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Column values for a *cancelled*-occurrence exception row ("delete only this
|
||||
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
||||
* provider clone the series row and cancel exactly this one instance.
|
||||
*
|
||||
* As with [buildOccurrenceExceptionValues], the occurrence must be anchored with
|
||||
* DTSTART + DURATION so the provider derives a single instance and clears the
|
||||
* inherited RRULE. A STATUS-only cancel skips that: the clone keeps the RRULE, so
|
||||
* the *whole series* is cancelled and every other occurrence disappears
|
||||
* (Codeberg #47). The occurrence's length/zone come straight from the series row
|
||||
* — cancelling never changes them.
|
||||
*/
|
||||
internal fun buildOccurrenceCancelValues(
|
||||
originalInstanceMillis: Long,
|
||||
dtStartMillis: Long,
|
||||
duration: String?,
|
||||
timezone: String?,
|
||||
allDay: Int,
|
||||
): Map<String, Any?> = buildMap {
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
|
||||
put(CalendarContract.Events.DTSTART, dtStartMillis)
|
||||
put(CalendarContract.Events.DURATION, duration)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
|
||||
put(CalendarContract.Events.ALL_DAY, allDay)
|
||||
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
|
||||
* [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.SELF_ATTENDEE_STATUS,
|
||||
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
|
||||
@@ -104,6 +107,7 @@ internal object EventDetailProjection {
|
||||
const val IDX_EVENT_TIMEZONE = 15
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||
const val IDX_EVENT_COLOR_KEY = 17
|
||||
const val IDX_DURATION = 18
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,6 +190,86 @@ internal object SearchProjection {
|
||||
const val IDX_RDATE = 11
|
||||
}
|
||||
|
||||
/**
|
||||
* The master/one-off Events row of an event about to be moved to another
|
||||
* calendar, read for a verbatim re-insert (see [MasterEventSnapshot]). Carries
|
||||
* the full recurrence skeleton (RRULE/DURATION, RDATE/EXDATE) so the moved copy
|
||||
* generates the same instances, and `UID_2445` so identity survives the move.
|
||||
*/
|
||||
internal object MoveMasterProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Events.UID_2445,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.RDATE,
|
||||
CalendarContract.Events.EXDATE,
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.AVAILABILITY,
|
||||
CalendarContract.Events.ACCESS_LEVEL,
|
||||
CalendarContract.Events.STATUS,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
)
|
||||
|
||||
const val IDX_UID = 0
|
||||
const val IDX_TITLE = 1
|
||||
const val IDX_DTSTART = 2
|
||||
const val IDX_DTEND = 3
|
||||
const val IDX_DURATION = 4
|
||||
const val IDX_RRULE = 5
|
||||
const val IDX_RDATE = 6
|
||||
const val IDX_EXDATE = 7
|
||||
const val IDX_EVENT_TIMEZONE = 8
|
||||
const val IDX_ALL_DAY = 9
|
||||
const val IDX_AVAILABILITY = 10
|
||||
const val IDX_ACCESS_LEVEL = 11
|
||||
const val IDX_STATUS = 12
|
||||
const val IDX_LOCATION = 13
|
||||
const val IDX_DESCRIPTION = 14
|
||||
}
|
||||
|
||||
/**
|
||||
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
|
||||
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified
|
||||
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
|
||||
* `DELETED = 0` so provider tombstones aren't replayed.
|
||||
*/
|
||||
internal object ExceptionProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
|
||||
CalendarContract.Events.STATUS,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.AVAILABILITY,
|
||||
CalendarContract.Events.ACCESS_LEVEL,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
const val IDX_ORIGINAL_INSTANCE_TIME = 1
|
||||
const val IDX_STATUS = 2
|
||||
const val IDX_TITLE = 3
|
||||
const val IDX_ALL_DAY = 4
|
||||
const val IDX_DTSTART = 5
|
||||
const val IDX_DTEND = 6
|
||||
const val IDX_DURATION = 7
|
||||
const val IDX_EVENT_TIMEZONE = 8
|
||||
const val IDX_AVAILABILITY = 9
|
||||
const val IDX_ACCESS_LEVEL = 10
|
||||
const val IDX_LOCATION = 11
|
||||
const val IDX_DESCRIPTION = 12
|
||||
}
|
||||
|
||||
internal object AttendeeProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||
|
||||
@@ -114,6 +114,21 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether raw provider colours are softened toward theme-fitting pastels
|
||||
* before display (issue #36). Defaults to ON — the historical look, which
|
||||
* caps harsh sync colours and pins brightness so entries read on both
|
||||
* themes. Turning it off paints the calendar/event colour exactly as the
|
||||
* sync source (DAVx5/CalDAV) publishes it.
|
||||
*/
|
||||
val softenCalendarColors: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[SOFTEN_COLORS_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setSoftenCalendarColors(enabled: Boolean) {
|
||||
store.edit { it[SOFTEN_COLORS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
|
||||
* strings — "system", "custom", or a bundled font's token — resolved to a
|
||||
@@ -252,6 +267,20 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agenda (both the in-app screen and the home-screen widget)
|
||||
* always anchors today at the top with a "nothing left today" placeholder,
|
||||
* even once today has no remaining events (issue #35). Default ON — makes
|
||||
* today's events easy to tell apart from a future day's at a glance.
|
||||
*/
|
||||
val agendaShowToday: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[AGENDA_SHOW_TODAY_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setAgendaShowToday(enabled: Boolean) {
|
||||
store.edit { it[AGENDA_SHOW_TODAY_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
||||
* the historical hard-coded startup view — so existing users see no change
|
||||
@@ -665,6 +694,7 @@ class SettingsPrefs @Inject constructor(
|
||||
companion object {
|
||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||
internal val SOFTEN_COLORS_KEY = booleanPreferencesKey("soften_calendar_colors")
|
||||
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
|
||||
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
|
||||
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
|
||||
@@ -673,6 +703,8 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||
internal val AGENDA_SHOW_TODAY_KEY =
|
||||
booleanPreferencesKey("agenda_show_today")
|
||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||
|
||||
@@ -17,7 +17,11 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
@@ -55,13 +59,22 @@ class ReminderNotifier @Inject constructor(
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val zone = ZoneId.systemDefault()
|
||||
val locale = Locale.getDefault()
|
||||
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
|
||||
// its shared ISO number (1..7) for the date math in reminderTimeText.
|
||||
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
|
||||
val time = reminderTimeText(
|
||||
beginMillis = alert.beginMillis,
|
||||
endMillis = alert.endMillis,
|
||||
isAllDay = alert.isAllDay,
|
||||
zone = ZoneId.systemDefault(),
|
||||
locale = Locale.getDefault(),
|
||||
zone = zone,
|
||||
locale = locale,
|
||||
is24Hour = is24Hour,
|
||||
today = Instant.now().atZone(zone).toLocalDate(),
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||
)
|
||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The one line of time context in a reminder notification. Pure so it can be
|
||||
* JVM-tested:
|
||||
* JVM-tested.
|
||||
*
|
||||
* - timed, same day: "09:30 – 10:00"
|
||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time)
|
||||
* Timed events that fall on a day other than [today] are prefixed with that
|
||||
* day, so a reminder fired ahead of time no longer reads as if the event were
|
||||
* today (issue #46). The prefix prefers natural language and stays short:
|
||||
*
|
||||
* - today: "09:30 – 10:00" (no prefix)
|
||||
* - tomorrow / yesterday: "Tomorrow, 09:30 – 10:00" ([tomorrowLabel] / [yesterdayLabel])
|
||||
* - elsewhere this week: "Thu, 09:30 – 10:00" (localized short weekday)
|
||||
* - further out: "16 Jul, 09:30 – 10:00" (medium date — a weekday
|
||||
* alone would be ambiguous)
|
||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
||||
* already unambiguous)
|
||||
* - all-day, one day: "11 Jun 2026"
|
||||
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
||||
*
|
||||
* All-day instances store UTC midnights with an exclusive end, so they are
|
||||
* All-day instances already carry an explicit date, so they never gain a
|
||||
* relative prefix. They store UTC midnights with an exclusive end, so they are
|
||||
* read in UTC and the end day is the last *covered* day.
|
||||
*/
|
||||
fun reminderTimeText(
|
||||
@@ -27,6 +41,10 @@ fun reminderTimeText(
|
||||
zone: ZoneId,
|
||||
locale: Locale,
|
||||
is24Hour: Boolean,
|
||||
today: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String {
|
||||
if (isAllDay) {
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
@@ -43,18 +61,70 @@ fun reminderTimeText(
|
||||
}
|
||||
|
||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||
val range = timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||
val prefix = relativeDayPrefix(
|
||||
day = begin.toLocalDate(),
|
||||
today = today,
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
locale = locale,
|
||||
dateFormat = dateFormat,
|
||||
tomorrowLabel = tomorrowLabel,
|
||||
yesterdayLabel = yesterdayLabel,
|
||||
)
|
||||
if (prefix == null) range else "$prefix, $range"
|
||||
} else {
|
||||
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||
// applies to the time portion too.
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
// applies to the time portion too. The explicit dates already say which
|
||||
// day, so no relative prefix is layered on top.
|
||||
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
||||
dateTime(begin) + RANGE + dateTime(end)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A short label for [day] relative to [today], or `null` when it *is* today (the
|
||||
* common case, which needs no prefix). Weekday names are used only within the
|
||||
* current week — a "next Wednesday" would be indistinguishable from this one, so
|
||||
* anything past this week falls back to the exact date.
|
||||
*/
|
||||
private fun relativeDayPrefix(
|
||||
day: LocalDate,
|
||||
today: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
locale: Locale,
|
||||
dateFormat: DateTimeFormatter,
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String? = when (ChronoUnit.DAYS.between(today, day)) {
|
||||
0L -> null
|
||||
1L -> tomorrowLabel
|
||||
-1L -> yesterdayLabel
|
||||
else -> if (isSameWeek(day, today, firstDayOfWeek)) {
|
||||
day.dayOfWeek.getDisplayName(TextStyle.SHORT, locale)
|
||||
} else {
|
||||
dateFormat.format(day)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [day] and [today] share the same week. The week boundary honours the
|
||||
* user's *week starts on* setting (already resolved to a concrete [firstDayOfWeek],
|
||||
* with [firstDayOfWeek] falling back to the locale default upstream).
|
||||
*/
|
||||
private fun isSameWeek(day: LocalDate, today: LocalDate, firstDayOfWeek: DayOfWeek): Boolean {
|
||||
val startOfWeek = today.previousOrSame(firstDayOfWeek)
|
||||
return !day.isBefore(startOfWeek) && day.isBefore(startOfWeek.plusWeeks(1))
|
||||
}
|
||||
|
||||
/** The most recent [target] on or before this date (this date itself when it matches). */
|
||||
private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
|
||||
val backtrack = (dayOfWeek.value - target.value + 7) % 7
|
||||
return minusDays(backtrack.toLong())
|
||||
}
|
||||
|
||||
private const val RANGE = " – "
|
||||
|
||||
@@ -33,6 +33,7 @@ import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||
@@ -74,6 +75,8 @@ fun CalendarHost(
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: EventForm? = null,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Wait for the persisted default view before seeding the stack, so the app
|
||||
@@ -172,6 +175,9 @@ fun CalendarHost(
|
||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
||||
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
||||
var importFormSource by remember { mutableStateOf(ImportSource.File) }
|
||||
// A restore (in-app "Restore from .ics" button) always runs the full import
|
||||
// flow — picker + summary — even for a single-event file, because the intent
|
||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||
@@ -190,6 +196,7 @@ fun CalendarHost(
|
||||
// reveals on top of whatever was open without extra dismissal.
|
||||
LaunchedEffect(requestedInsertForm) {
|
||||
if (requestedInsertForm != null) {
|
||||
importFormSource = ImportSource.Insert
|
||||
importForm = requestedInsertForm
|
||||
onInsertConsumed()
|
||||
}
|
||||
@@ -207,6 +214,20 @@ fun CalendarHost(
|
||||
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
|
||||
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||
@@ -363,6 +384,14 @@ fun CalendarHost(
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
},
|
||||
onDuplicate = { form ->
|
||||
// Reuse the prefilled-create overlay: a duplicate is a
|
||||
// fresh event, so it applies the default reminder like an
|
||||
// in-app new event (#52).
|
||||
importFormSource = ImportSource.Insert
|
||||
importForm = form
|
||||
detailKey = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -435,6 +464,7 @@ fun CalendarHost(
|
||||
onClose = { importUri = null },
|
||||
onOpenSingle = { form ->
|
||||
importUri = null
|
||||
importFormSource = ImportSource.File
|
||||
importForm = form
|
||||
},
|
||||
)
|
||||
@@ -443,6 +473,7 @@ fun CalendarHost(
|
||||
EventEditScreen(
|
||||
initialDateIso = null,
|
||||
initialForm = form,
|
||||
initialFormSource = importFormSource,
|
||||
onClose = { importForm = null },
|
||||
onSaved = { importForm = null },
|
||||
)
|
||||
|
||||
@@ -37,6 +37,8 @@ fun RootScreen(
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
@@ -88,6 +90,8 @@ fun RootScreen(
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -87,10 +86,12 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
|
||||
|
||||
/**
|
||||
* The concrete span the range covers, starting at [start] through [end]
|
||||
* (inclusive), for a human-readable header:
|
||||
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
||||
* (inclusive), for a human-readable header. Both ends use the same day-month
|
||||
* order so a span never mixes "15 Jul" with "Aug 13, 2026":
|
||||
* - [AgendaRange.Day] → a single date ("27 Jun 2026")
|
||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026"), with the start's
|
||||
* year shown too only when it differs from the end's.
|
||||
*/
|
||||
fun agendaRangeWindowSummary(
|
||||
range: AgendaRange,
|
||||
@@ -100,11 +101,15 @@ fun agendaRangeWindowSummary(
|
||||
): String {
|
||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
||||
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||
return when (range) {
|
||||
AgendaRange.Day -> medium.format(javaStart)
|
||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
||||
AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||
else -> {
|
||||
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
|
||||
"${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,16 +15,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -58,6 +59,8 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
@@ -65,12 +68,12 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
@@ -83,7 +86,6 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
@@ -106,6 +108,7 @@ fun AgendaScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -170,9 +173,11 @@ fun AgendaScreen(
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// end aligns the selector's right edge with the top-bar view
|
||||
// switcher (its 8.dp margin + the app bar's 4.dp inset).
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
@@ -190,6 +195,7 @@ fun AgendaScreen(
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
showToday = showToday,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
@@ -213,9 +219,11 @@ fun AgendaScreen(
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
||||
* range picker as a session-only override. Filled with the primary container
|
||||
* while an override is active, so the temporary state is obvious.
|
||||
* The agenda's current range, tapped to open the range picker as a session-only
|
||||
* override. Shares the top-bar view switcher's button shape so the two read as a
|
||||
* family, but stays low-emphasis — a subtle neutral surface tint rather than the
|
||||
* switcher's secondary container, so it doesn't compete. Fills with the primary
|
||||
* container only while an override is active, to make that temporary state clear.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangePill(
|
||||
@@ -224,43 +232,34 @@ private fun AgendaRangePill(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val container = if (isOverride) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
val content = if (isOverride) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
color = container,
|
||||
contentColor = content,
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
FilledTonalButton(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = if (isOverride) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
},
|
||||
contentColor = if (isOverride) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DateRange,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = agendaRangeLabel(range),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = agendaRangeLabel(range),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A header naming the concrete window currently shown, e.g. "Showing all events
|
||||
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
||||
* A header naming the concrete window currently shown under a "showing …" label,
|
||||
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
||||
* lives on the selector button beside it, so it isn't repeated here.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangeBanner(
|
||||
@@ -277,8 +276,10 @@ private fun AgendaRangeBanner(
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Just the concrete dates — the range's name ("Next 30 days") already
|
||||
// sits on the selector button to the right, so repeating it here is noise.
|
||||
Text(
|
||||
text = "${agendaRangeLabel(range)}, $window",
|
||||
text = window,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
@@ -289,6 +290,7 @@ private fun AgendaRangeBanner(
|
||||
private fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
pastDisplay: PastEventDisplay,
|
||||
showToday: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
@@ -304,7 +306,7 @@ private fun AgendaContent(
|
||||
// Hiding drops finished events — and any day they leave empty; dimming
|
||||
// keeps them but fades the row. Recomputed each minute so events fall
|
||||
// away (or fade) as they end while the screen stays open.
|
||||
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
state.days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
@@ -312,6 +314,14 @@ private fun AgendaContent(
|
||||
} else {
|
||||
state.days
|
||||
}
|
||||
// Anchor today with a "nothing left today" placeholder — but only when
|
||||
// the window actually starts on today; a jumped-to date has no today in
|
||||
// it, so anchoring there would be misleading (#35).
|
||||
val days = anchorTodayIfMissing(
|
||||
days = filtered,
|
||||
today = state.today,
|
||||
enabled = showToday && state.anchor == state.today,
|
||||
)
|
||||
if (days.isEmpty()) {
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
@@ -349,17 +359,24 @@ private fun AgendaList(
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||
}
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
key = { _, event -> event.instanceId },
|
||||
) { index, event ->
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = animateItemMotion(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
if (day.events.isEmpty()) {
|
||||
// An anchored, event-less today (#35) — "nothing left today".
|
||||
item(key = "placeholder-${day.date}") {
|
||||
AgendaEmptyDayRow(onClick = { onOpenDay(day.date) })
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
key = { _, event -> event.instanceId },
|
||||
) { index, event ->
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = animateItemMotion(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
@@ -391,6 +408,44 @@ private fun AgendaDayHeader(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A card under an anchored, event-less today (#35) — the same coffee-cup motif
|
||||
* as the full-screen [AgendaEmpty] state, boxed into a card so today keeps a
|
||||
* visible slot when nothing is left rather than a bare header.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaEmptyDayRow(onClick: () -> Unit) {
|
||||
Card(
|
||||
// Match a single event row's resting corner radius (floret groupedShape).
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 18.dp, horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Coffee,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.agenda_no_more_today),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
@@ -400,6 +455,7 @@ private fun AgendaEventRow(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
@@ -412,7 +468,7 @@ private fun AgendaEventRow(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(pastelize(event.color, dark)),
|
||||
.background(eventFill(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
@@ -520,7 +576,7 @@ private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): Str
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
||||
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,24 @@ fun groupAgendaDays(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure [today] surfaces as the first agenda day even when it carries no
|
||||
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
|
||||
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
|
||||
* when today already has its own day in [days]. Keeps today anchored at the top
|
||||
* so a glance tells today's events apart from a future day's (issue #35).
|
||||
*/
|
||||
fun anchorTodayIfMissing(
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
enabled: Boolean,
|
||||
): List<AgendaDay> =
|
||||
if (enabled && days.none { it.date == today }) {
|
||||
listOf(AgendaDay(today, emptyList())) + days
|
||||
} else {
|
||||
days
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||
* grouped by day (only days that actually have events appear).
|
||||
|
||||
@@ -66,6 +66,19 @@ class AgendaViewModel @Inject constructor(
|
||||
initialValue = PastEventDisplay.SHOW,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether to keep today anchored at the top with a "nothing left today"
|
||||
* placeholder even once it has no remaining events (#35). A display concern
|
||||
* applied in the composition (after past-event filtering), so it rides
|
||||
* alongside the data state like [pastEventDisplay] rather than re-querying.
|
||||
*/
|
||||
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = true,
|
||||
)
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
|
||||
@@ -93,6 +93,8 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||
@@ -108,7 +110,6 @@ import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import java.time.LocalDate
|
||||
|
||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||
@@ -565,6 +566,7 @@ private fun CalendarEditor(
|
||||
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
@@ -624,7 +626,7 @@ private fun CalendarEditor(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = pastelize(color, dark)) {
|
||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
||||
InlineTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
|
||||
@@ -14,16 +14,17 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
|
||||
/**
|
||||
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
||||
* in the calendar's (pastelised) colour. Shared by the calendar manager and the
|
||||
* visibility filter so they read identically.
|
||||
* in the calendar's colour — softened to a pastel, or raw when the softener is
|
||||
* off (issue #36). Shared by the calendar manager and the visibility filter so
|
||||
* they read identically.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(40.dp)
|
||||
@@ -34,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||
Icon(
|
||||
Icons.Filled.CalendarMonth,
|
||||
contentDescription = null,
|
||||
tint = pastelize(color, dark),
|
||||
tint = eventFill(color, dark, soften),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,15 +18,14 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
|
||||
/**
|
||||
* A wrapping row of round colour swatches; the one matching [selected] is
|
||||
* ringed and checked. Shared by the calendar editor and the event-colour
|
||||
* picker so both pick a colour the same way. Swatches render through
|
||||
* [pastelize] — the softened colour the app actually paints, not the raw hue.
|
||||
* [eventFill] — the colour the app actually paints (softened, or raw when the
|
||||
* softener is off, issue #36), not necessarily the stored hue.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -37,16 +36,18 @@ fun ColorSwatchRow(
|
||||
dark: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val soften = LocalSoftenColors.current
|
||||
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
colors.forEach { argb ->
|
||||
val isSelected = argb == selected
|
||||
val fill = eventFill(argb, dark, soften)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(pastelize(argb, dark))
|
||||
.background(fill)
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||
@@ -60,7 +61,7 @@ fun ColorSwatchRow(
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color.Black.copy(alpha = 0.7f),
|
||||
tint = eventInk(fill, alpha = 0.7f),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
|
||||
/**
|
||||
* Whether calendar/event colours are softened toward theme-fitting pastels
|
||||
* before display (issue #36). Provided app-wide from the "soften colours"
|
||||
* setting; the default `true` keeps the historical look. When off, the raw
|
||||
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
|
||||
* and other calendar apps. Widgets live outside this composition and read the
|
||||
* preference directly, then pass the flag to [eventFill] / [eventInk].
|
||||
*/
|
||||
val LocalSoftenColors = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
|
||||
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
|
||||
* pastelize also returns an opaque colour).
|
||||
*/
|
||||
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
|
||||
|
||||
/**
|
||||
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
|
||||
* white on a dark fill, near-black on a light one (issue #21). Applies to both
|
||||
* softened and raw fills — a saturated hue (deep blue, purple, red) is
|
||||
* perceptually dark even after softening pins its HSV value, so black text on it
|
||||
* reads poorly. The choice is objective, not a tuned threshold: white wins only
|
||||
* when it out-contrasts black against the fill, which (by the WCAG contrast
|
||||
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
|
||||
* near-black text. [alpha] carries the caller's soft emphasis.
|
||||
*/
|
||||
fun eventInk(fill: Color, alpha: Float = 0.8f): Color {
|
||||
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
|
||||
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative-luminance crossover where white text starts to out-contrast black.
|
||||
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
|
||||
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
|
||||
*/
|
||||
private const val INK_LUMINANCE_CROSSOVER = 0.179f
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -18,3 +19,15 @@ fun currentLocale(): Locale {
|
||||
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [DateTimeFormatter] for [skeleton]'s fields laid out in [locale]'s own order
|
||||
* (via Android's best-pattern matching), so dates read naturally per locale
|
||||
* instead of a hardcoded day-month-year order. [skeleton] lists the wanted
|
||||
* fields, e.g. "dMMMy" (day, abbreviated month, year) or "EEEdMMM" (weekday too).
|
||||
*/
|
||||
fun localizedDateFormatter(locale: Locale, skeleton: String): DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern(
|
||||
android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton),
|
||||
locale,
|
||||
)
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -29,13 +17,12 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -195,56 +182,18 @@ private fun CustomReminderEditor(
|
||||
onConfirm: (Int) -> Unit,
|
||||
) {
|
||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Unit toggle first so it stays visible above the keyboard once the
|
||||
// amount field (the bottom row) is focused and scrolled into view.
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
ReminderUnit.entries.forEachIndexed { index, entry ->
|
||||
SegmentedButton(
|
||||
selected = unit == entry,
|
||||
onClick = { onUnitChange(entry) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
||||
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
// Amount, a live preview of the lead time it resolves to, and Set —
|
||||
// all on one row, sitting just above the keyboard.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(
|
||||
value = amountText,
|
||||
onValueChange = onAmountChange,
|
||||
placeholder = "10",
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||
?: stringResource(R.string.reminder_custom_amount),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||
enabled = amount != null,
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_custom_set))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CustomAmountEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = onAmountChange,
|
||||
unitLabels = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
||||
selectedUnit = unit.ordinal,
|
||||
onUnitChange = { onUnitChange(ReminderUnit.entries[it]) },
|
||||
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||
?: stringResource(R.string.reminder_custom_amount),
|
||||
setLabel = stringResource(R.string.reminder_custom_set),
|
||||
confirmEnabled = amount != null,
|
||||
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||
@@ -364,41 +313,136 @@ private fun CustomDaysEditor(
|
||||
) {
|
||||
val days = amountText.toIntOrNull()
|
||||
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
CustomAmountEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = onAmountChange,
|
||||
placeholder = "30",
|
||||
preview = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||
?: stringResource(R.string.agenda_range_custom_hint),
|
||||
setLabel = stringResource(R.string.reminder_custom_set),
|
||||
confirmEnabled = days != null,
|
||||
onConfirm = { days?.let(onConfirm) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snooze-duration picker, full-screen and **single-select**: the [presets]
|
||||
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
|
||||
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
|
||||
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
|
||||
* a preset or confirming a custom value applies via [onSelect] and closes.
|
||||
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
|
||||
* is reused for both the rows and the custom preview.
|
||||
*/
|
||||
@Composable
|
||||
fun SnoozeDurationPicker(
|
||||
title: String,
|
||||
presets: List<Int>,
|
||||
selected: Int,
|
||||
label: @Composable (Int) -> String,
|
||||
onSelect: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val customSelected = selected !in presets
|
||||
val rowCount = presets.size + 1 // + the custom row
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable {
|
||||
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
|
||||
}
|
||||
var unit by rememberSaveable {
|
||||
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||
presets.forEachIndexed { index, minute ->
|
||||
val isSelected = minute == selected
|
||||
GroupedRow(
|
||||
title = label(minute),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(minute)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
// The Custom row connects downward into the editor card when expanded, so
|
||||
// the two read as one grouped container (the shared custom-expand pattern).
|
||||
GroupedRow(
|
||||
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
|
||||
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
DialogAmountField(
|
||||
value = amountText,
|
||||
onValueChange = onAmountChange,
|
||||
placeholder = "30",
|
||||
CustomSnoozeEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = { amountText = it },
|
||||
unit = unit,
|
||||
onUnitChange = { unit = it },
|
||||
label = label,
|
||||
onConfirm = { minutes ->
|
||||
onSelect(minutes)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||
?: stringResource(R.string.agenda_range_custom_hint),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { days?.let(onConfirm) },
|
||||
enabled = days != null,
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_custom_set))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whole hours if the delay divides evenly, else minutes. */
|
||||
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
|
||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
||||
|
||||
private fun snoozeCustomAmount(minutes: Int): Int =
|
||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
||||
|
||||
/**
|
||||
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
|
||||
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
||||
* the delay it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||
* amount. [onConfirm] receives the final delay in minutes.
|
||||
*/
|
||||
@Composable
|
||||
private fun CustomSnoozeEditor(
|
||||
amountText: String,
|
||||
onAmountChange: (String) -> Unit,
|
||||
unit: ReminderUnit,
|
||||
onUnitChange: (ReminderUnit) -> Unit,
|
||||
label: @Composable (Int) -> String,
|
||||
onConfirm: (Int) -> Unit,
|
||||
) {
|
||||
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
|
||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
CustomAmountEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = onAmountChange,
|
||||
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
||||
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
|
||||
onUnitChange = { onUnitChange(units[it]) },
|
||||
preview = amount?.let { label(it * unit.minutesFactor) }
|
||||
?: stringResource(R.string.reminder_custom_amount),
|
||||
setLabel = stringResource(R.string.reminder_custom_set),
|
||||
confirmEnabled = amount != null,
|
||||
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
||||
@Composable
|
||||
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
||||
|
||||
@@ -79,7 +79,9 @@ import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
@@ -97,6 +99,10 @@ import kotlin.math.roundToInt
|
||||
|
||||
private val HOUR_HEIGHT = 56.dp
|
||||
private val GUTTER_WIDTH = 48.dp
|
||||
/** Start inset for the gutter's hour labels so they centre on the top bar's
|
||||
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
|
||||
* 4dp inset + 24dp half icon button), matching the week view. */
|
||||
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||
private val MIN_EVENT_HEIGHT = 24.dp
|
||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
@@ -448,9 +454,11 @@ private fun AllDayBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(event.color, dark, soften)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||
.background(fill, RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = title },
|
||||
@@ -461,7 +469,7 @@ private fun AllDayBar(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.8f),
|
||||
color = eventInk(fill),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -484,10 +492,12 @@ private fun Timeline(
|
||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||
// soft corners are permanent at any scroll position.
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// Hour gutter (scrolls in sync with the day column)
|
||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||
// labels centre on the top bar hamburger, matching the week view.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
@@ -610,9 +620,11 @@ private fun EventBlock(
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
val showTime = block.endMin - block.startMin >= 45
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(block.event.color, dark, soften)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||
.background(fill, RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
@@ -623,7 +635,7 @@ private fun EventBlock(
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.85f),
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
if (showTime) {
|
||||
Text(
|
||||
@@ -631,7 +643,7 @@ private fun EventBlock(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
color = eventInk(fill, alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
@@ -87,17 +88,19 @@ import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -116,7 +119,10 @@ import kotlin.time.Instant
|
||||
* top-bar arrow both return to the calendar. Events in writable calendars can
|
||||
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
|
||||
* event form for this occurrence — for recurring events the form asks how
|
||||
* far the change reaches when saving.
|
||||
* far the change reaches when saving. [onDuplicate] opens the same form
|
||||
* seeded with a copy of this event as a new, unsaved event (#52) — offered
|
||||
* for any event, including read-only ones, since the copy lands in an
|
||||
* editable calendar.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -126,6 +132,7 @@ fun EventDetailScreen(
|
||||
endMillis: Long,
|
||||
onBack: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDuplicate: (EventForm) -> Unit,
|
||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||
@@ -160,15 +167,14 @@ fun EventDetailScreen(
|
||||
}
|
||||
|
||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||
// upgrade in place. Granting continues straight into the tapped action.
|
||||
var pendingEdit by remember { mutableStateOf(false) }
|
||||
// upgrade in place. Granting continues straight into the tapped action —
|
||||
// edit, delete, or duplicate — held here until the result comes back.
|
||||
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
if (pendingEdit) onEdit() else showDeleteDialog = true
|
||||
}
|
||||
pendingEdit = false
|
||||
if (granted) pendingWrite?.invoke()
|
||||
pendingWrite = null
|
||||
}
|
||||
val hasWritePermission = {
|
||||
ContextCompat.checkSelfPermission(
|
||||
@@ -176,21 +182,20 @@ fun EventDetailScreen(
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
val onDeleteClick = {
|
||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||
if (hasWritePermission()) {
|
||||
showDeleteDialog = true
|
||||
action()
|
||||
} else {
|
||||
pendingEdit = false
|
||||
pendingWrite = action
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
val onEditClick = {
|
||||
if (hasWritePermission()) {
|
||||
onEdit()
|
||||
} else {
|
||||
pendingEdit = true
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||
val onEditClick = { requireWrite(onEdit) }
|
||||
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||
// create still needs WRITE_CALENDAR even when the source is read-only.
|
||||
val onDuplicateClick = {
|
||||
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||
}
|
||||
|
||||
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||
@@ -229,7 +234,8 @@ fun EventDetailScreen(
|
||||
},
|
||||
actions = {
|
||||
val s = state
|
||||
// Share works for any loaded event — it only reads the event.
|
||||
// Share and duplicate work for any loaded event — both only
|
||||
// read it; the duplicate is created into a writable calendar.
|
||||
if (s is EventDetailUiState.Success) {
|
||||
IconButton(onClick = onShareClick) {
|
||||
Icon(
|
||||
@@ -237,6 +243,15 @@ fun EventDetailScreen(
|
||||
contentDescription = stringResource(R.string.event_detail_share),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onDuplicateClick,
|
||||
enabled = deleteState != DeleteUiState.Deleting,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = stringResource(R.string.event_detail_duplicate),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Edit/delete need a writable calendar — WebCal subscriptions,
|
||||
// birthday calendars etc. are read-only at the provider level.
|
||||
@@ -367,7 +382,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
val instance = detail.instance
|
||||
val dark = isSystemInDarkTheme()
|
||||
val locale = currentDetailLocale()
|
||||
val accent = pastelize(instance.color, dark)
|
||||
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
|
||||
@@ -8,10 +8,12 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Clock
|
||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
@@ -138,16 +141,48 @@ class EventDetailViewModel @Inject constructor(
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a create form seeded from the open event so the user can save a
|
||||
* copy as a new, independent event (#52). Returns null when nothing is
|
||||
* loaded.
|
||||
*
|
||||
* The recurrence is dropped — a duplicate is a single event; anyone who
|
||||
* wants a series can re-add one in the edit form that opens. The source
|
||||
* calendar is kept only when it's writable, otherwise the id is cleared so
|
||||
* the create resolves to the last-used/first-writable calendar — which lets
|
||||
* a read-only event (a WebCal subscription, a birthday) be copied into an
|
||||
* editable calendar. The occurrence's own times carry over unchanged.
|
||||
*/
|
||||
fun duplicateForm(): EventForm? {
|
||||
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||
val detail = loaded.detail
|
||||
return detail.toEditForm(
|
||||
beginMillis = detail.instance.start.toEpochMilliseconds(),
|
||||
endMillis = detail.instance.end.toEpochMilliseconds(),
|
||||
zone = TimeZone.currentSystemDefault(),
|
||||
).copy(
|
||||
rrule = null,
|
||||
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||
val detail = repository.eventDetail(target.eventId)
|
||||
// The Events row holds the series start; replace it with this
|
||||
// occurrence's time so recurring events render correctly.
|
||||
val corrected = detail.copy(
|
||||
instance = detail.instance.copy(
|
||||
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||
),
|
||||
)
|
||||
// occurrence's time so recurring events render correctly. An external
|
||||
// "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a
|
||||
// bare content://.../events/<id> VIEW intent, issue #48) keeps the row's
|
||||
// own DTSTART/DTEND instead of overriding it to the epoch.
|
||||
val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) {
|
||||
detail
|
||||
} else {
|
||||
detail.copy(
|
||||
instance = detail.instance.copy(
|
||||
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||
),
|
||||
)
|
||||
}
|
||||
val calendar = repository.calendars().first()
|
||||
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||
EventDetailUiState.Success(
|
||||
@@ -168,6 +203,16 @@ class EventDetailViewModel @Inject constructor(
|
||||
|
||||
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Sentinel begin/end for an "open this event" that names no occurrence —
|
||||
* a bare `content://com.android.calendar/events/<id>` VIEW intent with no
|
||||
* `EXTRA_EVENT_BEGIN_TIME` (issue #48). [loadDetail] then keeps the event
|
||||
* row's own DTSTART/DTEND instead of overriding it to the epoch.
|
||||
*/
|
||||
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
||||
|
||||
@@ -89,6 +89,7 @@ import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -125,6 +126,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
@@ -145,7 +148,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -182,12 +184,14 @@ fun EventEditScreen(
|
||||
editKey: LongArray? = null,
|
||||
initialStartMinutes: Int? = null,
|
||||
initialForm: EventForm? = null,
|
||||
initialFormSource: ImportSource = ImportSource.File,
|
||||
viewModel: EventEditViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||
when {
|
||||
// Single-event .ics open: the form arrives prefilled for review.
|
||||
initialForm != null -> viewModel.openImported(initialForm)
|
||||
// A prefilled open: a single-event .ics for review, or an external
|
||||
// ACTION_INSERT intent. The source drives how reminders are seeded.
|
||||
initialForm != null -> viewModel.openImported(initialForm, initialFormSource)
|
||||
editKey != null -> viewModel.openForEdit(
|
||||
eventId = editKey[0],
|
||||
beginMillis = editKey[1],
|
||||
@@ -202,6 +206,7 @@ fun EventEditScreen(
|
||||
}
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
|
||||
val importReminderPrompt by viewModel.importReminderPrompt.collectAsStateWithLifecycle()
|
||||
|
||||
// The form is intentionally forgotten on every close (cancel or save) so
|
||||
// the next open starts clean; it survives rotation because openNew /
|
||||
@@ -332,6 +337,56 @@ fun EventEditScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// A .ics import respects the file's reminders, but offers to swap in the
|
||||
// configured default rather than silently deciding for the user (#49).
|
||||
importReminderPrompt?.let { prompt ->
|
||||
ImportReminderPromptDialog(
|
||||
currentReminderCount = prompt.currentReminderCount,
|
||||
onApply = viewModel::applyImportedReminderDefault,
|
||||
onKeep = viewModel::dismissImportedReminderPrompt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer to apply the settings default reminder to an event opened from a `.ics`
|
||||
* file. The file's own reminders are kept unless the user accepts. A plain
|
||||
* two-choice confirmation, so an [AlertDialog] (not a full-screen picker).
|
||||
*/
|
||||
@Composable
|
||||
private fun ImportReminderPromptDialog(
|
||||
currentReminderCount: Int,
|
||||
onApply: () -> Unit,
|
||||
onKeep: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onKeep,
|
||||
title = { Text(stringResource(R.string.import_reminder_prompt_title)) },
|
||||
text = {
|
||||
Text(
|
||||
if (currentReminderCount == 0) {
|
||||
stringResource(R.string.import_reminder_prompt_body_none)
|
||||
} else {
|
||||
pluralStringResource(
|
||||
R.plurals.import_reminder_prompt_body_existing,
|
||||
currentReminderCount,
|
||||
currentReminderCount,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onApply) {
|
||||
Text(stringResource(R.string.import_reminder_prompt_apply))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onKeep) {
|
||||
Text(stringResource(R.string.import_reminder_prompt_keep))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -487,7 +542,8 @@ private fun EventEditContent(
|
||||
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
||||
// The accent ties the form to the detail screen's design language: the
|
||||
// bar under the title takes the target calendar's colour.
|
||||
val accent = selectedCalendar?.let { pastelize(it.color, dark) }
|
||||
val soften = LocalSoftenColors.current
|
||||
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) }
|
||||
?: MaterialTheme.colorScheme.primary
|
||||
val gap = 12.dp
|
||||
|
||||
@@ -618,15 +674,16 @@ private fun EventEditContent(
|
||||
|
||||
Spacer(Modifier.height(gap))
|
||||
|
||||
// Calendar card — tap anywhere to pick the target calendar. Editing
|
||||
// keeps the owning calendar (moving events between calendars is a
|
||||
// sync-adapter minefield; every stock calendar app locks it too).
|
||||
// Calendar card — tap anywhere to pick the target calendar. Picking a
|
||||
// different one while editing *moves* the event (copy+delete on save,
|
||||
// since CALENDAR_ID can't be updated in place). Managed special-dates
|
||||
// events stay locked: the contact sync owns their calendar.
|
||||
EditCard(
|
||||
icon = Icons.Default.CalendarMonth,
|
||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||
iconTint = accent,
|
||||
onClick = { showCalendarPicker = true }
|
||||
.takeIf { state.calendars.isNotEmpty() && !state.isEditing },
|
||||
.takeIf { state.calendars.isNotEmpty() && !state.isManaged },
|
||||
) {
|
||||
Text(
|
||||
text = selectedCalendar?.displayName
|
||||
@@ -838,7 +895,7 @@ private fun EventEditContent(
|
||||
icon = Icons.Default.Palette,
|
||||
iconContentDescription = stringResource(R.string.event_edit_color),
|
||||
iconTint = if (colorSupported && swatch != null) {
|
||||
pastelize(swatch, dark)
|
||||
eventFill(swatch, dark, soften)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -50,6 +51,36 @@ import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
|
||||
* sources want different reminder handling (#49).
|
||||
*/
|
||||
enum class ImportSource {
|
||||
/**
|
||||
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
|
||||
* It carries no reminders of its own, so the settings default is applied
|
||||
* automatically, exactly like an in-app new event.
|
||||
*/
|
||||
Insert,
|
||||
|
||||
/**
|
||||
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
||||
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
||||
* rather than silently applied or suppressed.
|
||||
*/
|
||||
File,
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending offer to swap an imported `.ics` event's reminders for the settings
|
||||
* default (#49). [currentReminderCount] is what the file carried (0 or more);
|
||||
* [defaultReminders] is what accepting would set.
|
||||
*/
|
||||
data class ImportReminderPrompt(
|
||||
val currentReminderCount: Int,
|
||||
val defaultReminders: List<Int>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Holds the event form being composed. The form's calendar id resolves to
|
||||
* (user pick > last used > first writable); the resolved value is what the UI
|
||||
@@ -78,10 +109,16 @@ class EventEditViewModel @Inject constructor(
|
||||
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||
// their choice. Reset with the form.
|
||||
private val _remindersTouched = MutableStateFlow(false)
|
||||
// A one-time offer, raised when a .ics import opens, to replace the file's
|
||||
// reminders with the settings default (#49). Null while there's nothing to ask.
|
||||
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
||||
|
||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||
|
||||
/** Pending "apply your default reminder?" offer for a `.ics` import; null when none. */
|
||||
val importReminderPrompt: StateFlow<ImportReminderPrompt?> = _importReminderPrompt.asStateFlow()
|
||||
|
||||
/**
|
||||
* The event being edited plus everything the form saw at load time.
|
||||
* For recurring events the write scope is chosen at save time; the
|
||||
@@ -112,7 +149,17 @@ class EventEditViewModel @Inject constructor(
|
||||
val allDay: List<Int>,
|
||||
val timedOverrides: Map<Long, List<Int>>,
|
||||
val allDayOverrides: Map<Long, List<Int>>,
|
||||
)
|
||||
) {
|
||||
/** The default reminders for an event on [calendarId] of the given kind. */
|
||||
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
|
||||
timedGlobal = timed,
|
||||
allDayGlobal = allDay,
|
||||
timedOverrides = timedOverrides,
|
||||
allDayOverrides = allDayOverrides,
|
||||
calendarId = calendarId,
|
||||
isAllDay = isAllDay,
|
||||
)
|
||||
}
|
||||
|
||||
private data class ExternalInputs(
|
||||
val writable: List<CalendarSource>,
|
||||
@@ -241,18 +288,39 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
|
||||
* the create form" path). [form] already carries the file's fields; its
|
||||
* [EventForm.calendarId] is null so the calendar still resolves to the
|
||||
* last-used/first-writable one, and reminders are frozen as touched so the
|
||||
* settings default never overwrites what the file specified. No-op when a
|
||||
* form is already open, so the prefill survives configuration changes.
|
||||
* Seed a fresh event from a prefilled [form] — a parsed single-event `.ics`
|
||||
* file ([ImportSource.File]) or an external `ACTION_INSERT` intent (another
|
||||
* app/widget creating an event, e.g. Google Maps' "add to calendar";
|
||||
* [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the
|
||||
* calendar still resolves to the last-used/first-writable one.
|
||||
*
|
||||
* Reminders are handled per [source], because the two paths mean different
|
||||
* things by "no reminders":
|
||||
* - [ImportSource.Insert] carries no reminder semantics, so the settings
|
||||
* default is applied automatically like an in-app new event (a form that
|
||||
* somehow did carry reminders keeps them, frozen).
|
||||
* - [ImportSource.File] owns its reminders, so they're frozen as-is; if a
|
||||
* settings default is configured and differs, [importReminderPrompt] offers
|
||||
* to swap it in rather than silently deciding for the user.
|
||||
*
|
||||
* No-op when a form is already open, so the prefill survives configuration
|
||||
* changes.
|
||||
*/
|
||||
fun openImported(form: EventForm) {
|
||||
fun openImported(form: EventForm, source: ImportSource) {
|
||||
if (_form.value != null || _editTarget.value != null) return
|
||||
_remindersTouched.value = true
|
||||
_revealed.value = form.populatedFields()
|
||||
_form.value = form
|
||||
when (source) {
|
||||
ImportSource.Insert ->
|
||||
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
||||
else applyDefaultReminder()
|
||||
|
||||
ImportSource.File -> {
|
||||
// Respect the file's own reminders; never silently overwrite them.
|
||||
_remindersTouched.value = true
|
||||
maybePromptImportedReminderDefault(form)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,26 +334,12 @@ class EventEditViewModel @Inject constructor(
|
||||
private fun applyDefaultReminder(calendarId: Long? = null) {
|
||||
if (_editTarget.value != null || _remindersTouched.value) return
|
||||
viewModelScope.launch {
|
||||
val defaults = combine(
|
||||
settingsPrefs.defaultReminderMinutes,
|
||||
settingsPrefs.defaultAllDayReminderMinutes,
|
||||
settingsPrefs.perCalendarReminderOverride,
|
||||
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||
) { timed, allDay, timedOv, allDayOv ->
|
||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||
}.first()
|
||||
val defaults = reminderDefaults()
|
||||
val targetId = calendarId ?: resolvedCalendarId.first()
|
||||
// Re-check after suspending: bail if the form closed or the user edited.
|
||||
val form = _form.value ?: return@launch
|
||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||
val reminders = resolveDefaultReminder(
|
||||
timedGlobal = defaults.timed,
|
||||
allDayGlobal = defaults.allDay,
|
||||
timedOverrides = defaults.timedOverrides,
|
||||
allDayOverrides = defaults.allDayOverrides,
|
||||
calendarId = targetId,
|
||||
isAllDay = form.isAllDay,
|
||||
)
|
||||
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
||||
_form.value = form.copy(reminders = reminders)
|
||||
// Surface the section so an auto-applied default is visible and
|
||||
// removable, even when Reminders isn't a default-shown field.
|
||||
@@ -295,10 +349,60 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot the four settings-default reminder flows into one value. */
|
||||
private suspend fun reminderDefaults(): ReminderDefaults = combine(
|
||||
settingsPrefs.defaultReminderMinutes,
|
||||
settingsPrefs.defaultAllDayReminderMinutes,
|
||||
settingsPrefs.perCalendarReminderOverride,
|
||||
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||
) { timed, allDay, timedOv, allDayOv ->
|
||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||
}.first()
|
||||
|
||||
/**
|
||||
* A `.ics` import respects the file's reminders, but an event opened from a
|
||||
* file often has none while the user still expects their configured default.
|
||||
* Rather than silently deciding, raise a one-time offer to swap in the
|
||||
* settings default — but only when there's a real choice: a default is
|
||||
* configured and it isn't already exactly what the file carried.
|
||||
*/
|
||||
private fun maybePromptImportedReminderDefault(form: EventForm) {
|
||||
viewModelScope.launch {
|
||||
val targetId = resolvedCalendarId.first()
|
||||
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
|
||||
// Bail if the form closed or became an edit while we resolved.
|
||||
val current = _form.value ?: return@launch
|
||||
if (_editTarget.value != null) return@launch
|
||||
if (default.isEmpty() || default == current.reminders) return@launch
|
||||
_importReminderPrompt.value = ImportReminderPrompt(
|
||||
currentReminderCount = current.reminders.size,
|
||||
defaultReminders = default,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Accept the import prompt: replace the file's reminders with the default. */
|
||||
fun applyImportedReminderDefault() {
|
||||
val prompt = _importReminderPrompt.value ?: return
|
||||
_importReminderPrompt.value = null
|
||||
// Already frozen as touched by openImported; this just swaps the values.
|
||||
update { it.copy(reminders = prompt.defaultReminders) }
|
||||
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||
}
|
||||
|
||||
/** Decline the import prompt: keep the file's own reminders untouched. */
|
||||
fun dismissImportedReminderPrompt() {
|
||||
_importReminderPrompt.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
||||
* tapped occurrence's own times, like on the detail screen. No-op while a
|
||||
* form is open, so user edits survive configuration changes.
|
||||
* tapped occurrence's own times, like on the detail screen. An external
|
||||
* "edit this event" (`ACTION_EDIT`) that names no occurrence passes
|
||||
* [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) {
|
||||
if (_form.value != null || _editTarget.value != null) return
|
||||
@@ -312,8 +416,12 @@ class EventEditViewModel @Inject constructor(
|
||||
return@launch
|
||||
}
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
|
||||
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
|
||||
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||
?: detail.instance.start.toEpochMilliseconds()
|
||||
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.
|
||||
_revealed.value = snapshot.form.populatedFields()
|
||||
_form.value = snapshot.form
|
||||
@@ -329,6 +437,7 @@ class EventEditViewModel @Inject constructor(
|
||||
_editTarget.value = null
|
||||
_loadFailed.value = false
|
||||
_remindersTouched.value = false
|
||||
_importReminderPrompt.value = null
|
||||
}
|
||||
|
||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||
@@ -439,10 +548,15 @@ class EventEditViewModel @Inject constructor(
|
||||
_saveState.value = SaveUiState.Saved
|
||||
return
|
||||
}
|
||||
// Changing the calendar moves the event (copy+delete — CALENDAR_ID can't
|
||||
// be updated in place) and is inherently whole-series: an occurrence
|
||||
// can't live in a different calendar than its series, so a move skips the
|
||||
// scope dialog even for a recurring event.
|
||||
val movingCalendar = target != null && form.calendarId != target.original.calendarId
|
||||
// Managed events are a yearly series whose editable fields (reminders,
|
||||
// notes, location) live on the series row — never offer the scope dialog,
|
||||
// which would split the series into an exception the sync then reverts.
|
||||
if (target != null && target.original.rrule != null && !current.isManaged) {
|
||||
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) {
|
||||
_saveState.value = SaveUiState.AwaitingScope
|
||||
return
|
||||
}
|
||||
@@ -497,6 +611,17 @@ class EventEditViewModel @Inject constructor(
|
||||
if (target == null) {
|
||||
repository.createEvent(form)
|
||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||
} else if (form.calendarId != target.original.calendarId) {
|
||||
// Move to the picked calendar (copy+delete), carrying any
|
||||
// field edits made in the same save. The target becomes the
|
||||
// last-used calendar, like a create does.
|
||||
repository.moveEvent(
|
||||
eventId = target.eventId,
|
||||
targetCalendarId = requireNotNull(form.calendarId),
|
||||
original = target.original,
|
||||
updated = form,
|
||||
)
|
||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||
} else {
|
||||
when (scope) {
|
||||
RecurringWriteScope.ThisEvent ->
|
||||
|
||||
@@ -71,6 +71,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
@@ -79,7 +82,6 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -380,7 +382,10 @@ private fun MonthGrid(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
// Match the weekday header's 8dp inset so day cells sit under their
|
||||
// labels, and so the week-number gutter's centre lines up with the
|
||||
// top bar's hamburger (4dp bar inset + 24dp half icon button).
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
state.weeks.forEach { week ->
|
||||
@@ -627,7 +632,7 @@ private fun DayNumberCell(
|
||||
}
|
||||
}
|
||||
|
||||
/** A filled event pill/bar — pastelized fill, title clipped to one line. */
|
||||
/** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */
|
||||
@Composable
|
||||
private fun MonthBar(
|
||||
event: de.jeanlucmakiola.calendula.domain.EventInstance,
|
||||
@@ -639,6 +644,8 @@ private fun MonthBar(
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(event.color, dark, soften)
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
@@ -647,7 +654,7 @@ private fun MonthBar(
|
||||
)
|
||||
Box(
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(event.color, dark), shape)
|
||||
.background(fill, shape)
|
||||
.padding(horizontal = 4.dp)
|
||||
.semantics { contentDescription = title },
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
@@ -657,7 +664,7 @@ private fun MonthBar(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.8f),
|
||||
color = eventInk(fill),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -670,6 +677,7 @@ private fun OverflowDots(
|
||||
dark: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val soften = LocalSoftenColors.current
|
||||
Row(
|
||||
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
@@ -679,7 +687,7 @@ private fun OverflowDots(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.background(pastelize(argb, dark), CircleShape),
|
||||
.background(eventFill(argb, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
if (extra > 0) {
|
||||
|
||||
@@ -55,9 +55,10 @@ import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import java.time.Instant as JavaInstant
|
||||
import java.time.ZoneId
|
||||
@@ -192,6 +193,7 @@ private fun SearchResultRow(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
GroupedRow(
|
||||
modifier = modifier,
|
||||
title = event.title,
|
||||
@@ -203,7 +205,7 @@ private fun SearchResultRow(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(pastelize(event.color, dark)),
|
||||
.background(eventFill(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
|
||||
@@ -133,6 +133,7 @@ import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
@@ -516,7 +517,7 @@ private fun AppearanceScreen(
|
||||
} else {
|
||||
stringResource(R.string.settings_dynamic_color_unavailable)
|
||||
},
|
||||
position = Position.Bottom,
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.dynamicColor,
|
||||
@@ -530,6 +531,18 @@ private fun AppearanceScreen(
|
||||
null
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_soften_colors),
|
||||
summary = stringResource(R.string.settings_soften_colors_summary),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.softenColors,
|
||||
onCheckedChange = viewModel::setSoftenColors,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setSoftenColors(!state.softenColors) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
@@ -630,6 +643,18 @@ private fun AppearanceScreen(
|
||||
position = Position.Middle,
|
||||
onClick = { showAgendaWidgetRange = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_show_today),
|
||||
summary = stringResource(R.string.settings_agenda_show_today_hint),
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.agendaShowToday,
|
||||
onCheckedChange = viewModel::setAgendaShowToday,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_range_bar),
|
||||
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
||||
@@ -1174,10 +1199,9 @@ private fun NotificationsScreen(
|
||||
}
|
||||
|
||||
if (showSnooze) {
|
||||
OptionPicker(
|
||||
SnoozeDurationPicker(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
predictiveBack = true,
|
||||
options = SNOOZE_PRESETS,
|
||||
presets = SNOOZE_PRESETS,
|
||||
selected = state.snoozeMinutes,
|
||||
label = { snoozeDurationLabel(it) },
|
||||
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||
|
||||
@@ -24,6 +24,8 @@ data class SettingsUiState(
|
||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
val dynamicColor: Boolean = true,
|
||||
val dynamicColorAvailable: Boolean = true,
|
||||
/** Whether raw provider colours are softened to theme-fitting pastels (#36). */
|
||||
val softenColors: Boolean = true,
|
||||
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
||||
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||
@@ -39,6 +41,8 @@ data class SettingsUiState(
|
||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||
/** How far ahead the agenda widget shows events (v2.11). */
|
||||
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
||||
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
|
||||
val agendaShowToday: Boolean = true,
|
||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||
val agendaShowRangeBar: Boolean = true,
|
||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||
|
||||
@@ -37,6 +37,7 @@ import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -116,14 +117,23 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.agendaScreenRange,
|
||||
prefs.agendaWidgetRange,
|
||||
prefs.timeFormat,
|
||||
// Two grid-display toggles folded into one flow so they fit this
|
||||
// group — the outer combine is already at its five-arg limit.
|
||||
combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair),
|
||||
) { view, screenRange, widgetRange, timeFormat, gridToggles ->
|
||||
// Display toggles folded into one flow so they fit this group —
|
||||
// the outer combine is already at its five-arg limit.
|
||||
combine(
|
||||
prefs.showHourLines,
|
||||
prefs.showWeekNumbers,
|
||||
prefs.agendaShowToday,
|
||||
prefs.softenCalendarColors,
|
||||
) { hourLines, weekNumbers, showToday, soften ->
|
||||
DisplayToggles(hourLines, weekNumbers, showToday, soften)
|
||||
},
|
||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||
ViewSettings(
|
||||
view, screenRange, widgetRange, timeFormat,
|
||||
showHourLines = gridToggles.first,
|
||||
showWeekNumbers = gridToggles.second,
|
||||
showHourLines = toggles.showHourLines,
|
||||
showWeekNumbers = toggles.showWeekNumbers,
|
||||
agendaShowToday = toggles.agendaShowToday,
|
||||
softenColors = toggles.softenColors,
|
||||
)
|
||||
},
|
||||
combine(
|
||||
@@ -147,6 +157,8 @@ class SettingsViewModel @Inject constructor(
|
||||
timeFormat = views.timeFormat,
|
||||
showHourLines = views.showHourLines,
|
||||
showWeekNumbers = views.showWeekNumbers,
|
||||
agendaShowToday = views.agendaShowToday,
|
||||
softenColors = views.softenColors,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
@@ -220,6 +232,15 @@ class SettingsViewModel @Inject constructor(
|
||||
val timeFormat: TimeFormatPref,
|
||||
val showHourLines: Boolean,
|
||||
val showWeekNumbers: Boolean,
|
||||
val agendaShowToday: Boolean,
|
||||
val softenColors: Boolean,
|
||||
)
|
||||
|
||||
private data class DisplayToggles(
|
||||
val showHourLines: Boolean,
|
||||
val showWeekNumbers: Boolean,
|
||||
val agendaShowToday: Boolean,
|
||||
val softenColors: Boolean,
|
||||
)
|
||||
|
||||
private data class MiscSettings(
|
||||
@@ -332,6 +353,19 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||
}
|
||||
|
||||
fun setSoftenColors(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setSoftenCalendarColors(enabled)
|
||||
// Both widgets paint event colours through the same softener, so a
|
||||
// change has to redraw them too (they read the flag in their data
|
||||
// preamble, like is24Hour).
|
||||
widgetRefreshMutex.withLock {
|
||||
AgendaWidget().updateAll(appContext)
|
||||
MonthWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setFont(role: FontRole, token: String) {
|
||||
viewModelScope.launch {
|
||||
when (role) {
|
||||
@@ -398,6 +432,22 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
||||
}
|
||||
|
||||
fun setAgendaShowToday(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setAgendaShowToday(enabled)
|
||||
// The agenda widget reads this reactively, so push it into each
|
||||
// instance's Glance state and recompose — same reliable-update path as
|
||||
// setPastEventDisplay (updateAll alone won't re-run the data preamble).
|
||||
widgetRefreshMutex.withLock {
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setTimeFormat(pref: TimeFormatPref) {
|
||||
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||
}
|
||||
|
||||
@@ -84,6 +84,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
@@ -98,7 +101,6 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -114,6 +116,10 @@ import kotlin.math.roundToInt
|
||||
|
||||
private val HOUR_HEIGHT = 56.dp
|
||||
private val GUTTER_WIDTH = 48.dp
|
||||
/** Start inset for the gutter's content (week badge + hour labels) so it centres
|
||||
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
|
||||
* (the app bar's 4dp inset + 24dp half icon button). */
|
||||
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||
private val MIN_EVENT_HEIGHT = 24.dp
|
||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
@@ -448,9 +454,10 @@ private fun WeekDayHeader(
|
||||
.padding(top = 4.dp, bottom = 8.dp),
|
||||
) {
|
||||
// Mirror the day-column layout (empty weekday line + spacer) so the
|
||||
// badge lines up vertically with the date numbers.
|
||||
// badge lines up vertically with the date numbers. The start inset centres
|
||||
// the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET).
|
||||
Column(
|
||||
modifier = Modifier.width(GUTTER_WIDTH),
|
||||
modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = " ", style = MaterialTheme.typography.labelSmall)
|
||||
@@ -584,9 +591,11 @@ private fun AllDayBar(
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(event.color, dark, soften)
|
||||
Box(
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||
.background(fill, RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = title },
|
||||
@@ -597,7 +606,7 @@ private fun AllDayBar(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.8f),
|
||||
color = eventInk(fill),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -621,10 +630,12 @@ private fun Timeline(
|
||||
// soft corners are permanent at any scroll position (not just at the
|
||||
// day's start/end).
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// Hour gutter (scrolls in sync with the day columns)
|
||||
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
||||
// as the header badge so the labels sit under it and on the hamburger.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
@@ -774,9 +785,11 @@ private fun EventBlock(
|
||||
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(block.event.color, dark, soften)
|
||||
Box(
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||
.background(fill, RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
@@ -787,7 +800,7 @@ private fun EventBlock(
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.85f),
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
if (showTime) {
|
||||
Text(
|
||||
@@ -795,7 +808,7 @@ private fun EventBlock(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
color = eventInk(fill, alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,12 +60,20 @@ sealed interface AgendaWidgetData {
|
||||
val days: List<AgendaDay>,
|
||||
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||
val is24Hour: Boolean,
|
||||
/** Whether event colours are softened to pastels, or shown raw (#36). */
|
||||
val soften: Boolean,
|
||||
/** First day of the week, for the calendar-aligned "this week" range. */
|
||||
val weekStart: DayOfWeek,
|
||||
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
||||
val savedRange: AgendaRange,
|
||||
/** Saved past-event display pref — the fallback before Glance state is set. */
|
||||
val savedPastDisplay: PastEventDisplay,
|
||||
/**
|
||||
* Saved "always show today" pref (#35) — anchors today at the top with a
|
||||
* placeholder even when it has no events left. The fallback read reactively
|
||||
* from Glance state before an instance has its own state set.
|
||||
*/
|
||||
val savedShowToday: Boolean,
|
||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||
val now: Instant,
|
||||
) : AgendaWidgetData
|
||||
@@ -117,6 +125,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
val prefs = ep.settingsPrefs()
|
||||
val savedRange = prefs.agendaWidgetRange.first()
|
||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||
val showToday = prefs.agendaShowToday.first()
|
||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||
// Load the widest selectable window once; the displayed range is sliced in
|
||||
// the composition from Glance state, so changing the range is a plain
|
||||
@@ -125,13 +134,16 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
val instances = ep.calendarRepository().instances(window).first()
|
||||
val is24Hour = prefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||
val soften = prefs.softenCalendarColors.first()
|
||||
return AgendaWidgetData.Ready(
|
||||
today = anchor,
|
||||
days = groupAgendaDays(anchor, instances, zone),
|
||||
is24Hour = is24Hour,
|
||||
soften = soften,
|
||||
weekStart = weekStart,
|
||||
savedRange = savedRange,
|
||||
savedPastDisplay = savedPastDisplay,
|
||||
savedShowToday = showToday,
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
@@ -48,12 +49,14 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||
@@ -65,7 +68,6 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -91,6 +93,14 @@ internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
||||
*/
|
||||
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
||||
|
||||
/**
|
||||
* Per-instance Glance state key holding the "always show today" toggle (#35).
|
||||
* Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] —
|
||||
* so toggling the setting reflects on the live widget without depending on the
|
||||
* `provideGlance` preamble re-running.
|
||||
*/
|
||||
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
|
||||
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
@@ -118,6 +128,8 @@ class RefreshAgendaAction : ActionCallback {
|
||||
private sealed interface AgendaRow {
|
||||
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||
data class Event(val event: EventInstance) : AgendaRow
|
||||
/** "Nothing left today" line under an anchored, event-less today (#35). */
|
||||
data class Placeholder(val date: LocalDate) : AgendaRow
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -139,40 +151,53 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||
val pastDisplay =
|
||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
|
||||
val rangeEnd = data.today.plus(
|
||||
range.dayCount(data.today, data.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
// Slice to the chosen range, then drop finished events (and any day
|
||||
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
||||
val visibleDays = data.days
|
||||
.filter { it.date <= rangeEnd }
|
||||
.let { days ->
|
||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
// Finally re-anchor today (as an empty placeholder day) when the
|
||||
// "always show today" pref is on and today has no events left (#35).
|
||||
val visibleDays = anchorTodayIfMissing(
|
||||
days = data.days
|
||||
.filter { it.date <= rangeEnd }
|
||||
.let { days ->
|
||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
}
|
||||
} else {
|
||||
days
|
||||
}
|
||||
} else {
|
||||
days
|
||||
}
|
||||
}
|
||||
},
|
||||
today = data.today,
|
||||
enabled = showToday,
|
||||
)
|
||||
if (visibleDays.isEmpty()) {
|
||||
WidgetMessage(R.string.agenda_empty_title)
|
||||
} else {
|
||||
val rows = buildList {
|
||||
visibleDays.forEach { day ->
|
||||
add(AgendaRow.Header(day.date, data.today))
|
||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||
if (day.events.isEmpty()) {
|
||||
add(AgendaRow.Placeholder(day.date))
|
||||
} else {
|
||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(rows.size) { index ->
|
||||
when (val row = rows[index]) {
|
||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
|
||||
is AgendaRow.Event -> EventRow(
|
||||
event = row.event,
|
||||
dark = dark,
|
||||
soften = data.soften,
|
||||
is24Hour = data.is24Hour,
|
||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||
row.event.hasEnded(data.now),
|
||||
@@ -262,13 +287,41 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "No more events today" under an anchored, event-less today (#35). Kept plain —
|
||||
* a muted line indented to the event titles — since the widget's event rows are
|
||||
* plain too (a stripe + text, not cards), so a card here would look out of place.
|
||||
*/
|
||||
@Composable
|
||||
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
|
||||
private fun PlaceholderRow(date: LocalDate) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Text(
|
||||
text = context.getString(R.string.agenda_no_more_today),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(
|
||||
event: EventInstance,
|
||||
dark: Boolean,
|
||||
soften: Boolean,
|
||||
is24Hour: Boolean,
|
||||
dimmed: Boolean,
|
||||
) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||
val stripeColor = pastelize(event.color, dark).let {
|
||||
val stripeColor = eventFill(event.color, dark, soften).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||
@@ -337,9 +390,9 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
||||
}
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val formatted = "$weekday, ${date.day} $monthName"
|
||||
// Weekday + date in the locale's own field order (compact: no year), rather
|
||||
// than a hardcoded day-month layout.
|
||||
val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.widget.month
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
@@ -50,14 +49,17 @@ import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||
import kotlinx.coroutines.flow.first
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -76,9 +78,6 @@ private val LANE_HEIGHT = 14.dp
|
||||
private val DAY_NUMBER_HEIGHT = 18.dp
|
||||
private val GRID_HPADDING = 8.dp
|
||||
|
||||
/** Dark ink that reads on the pastelized event fills, like the in-app MonthBar. */
|
||||
private val EventInk = ColorProvider(Color(0xDE000000))
|
||||
|
||||
private fun currentMonthIndex(zone: TimeZone): Int {
|
||||
val t = today(zone)
|
||||
return t.year * 12 + t.month.ordinal
|
||||
@@ -107,9 +106,12 @@ class MonthWidget : GlanceAppWidget() {
|
||||
val source = context.loadMonthWidgetSource()
|
||||
val dark = (context.resources.configuration.uiMode and
|
||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
// Read fresh (not through the cached source) so toggling the softener
|
||||
// redraws with the new choice; it's one cheap DataStore read.
|
||||
val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first()
|
||||
provideContent {
|
||||
CalendulaGlanceTheme {
|
||||
MonthWidgetBody(source = source, dark = dark)
|
||||
MonthWidgetBody(source = source, dark = dark, soften = soften)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,7 +142,7 @@ class ResetMonthAction : ActionCallback {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
|
||||
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
@@ -169,6 +171,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
|
||||
currentMonth = ym.month,
|
||||
today = source.today,
|
||||
dark = dark,
|
||||
soften = soften,
|
||||
colW = colW,
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
)
|
||||
@@ -278,6 +281,7 @@ private fun WeekRow(
|
||||
currentMonth: Month,
|
||||
today: LocalDate,
|
||||
dark: Boolean,
|
||||
soften: Boolean,
|
||||
colW: Dp,
|
||||
modifier: GlanceModifier,
|
||||
) {
|
||||
@@ -297,7 +301,7 @@ private fun WeekRow(
|
||||
// One lane row per event row. A multi-day span is a single Box spanning
|
||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||
repeat(MAX_LANES) { lane ->
|
||||
LaneRow(week = week, lane = lane, dark = dark, colW = colW)
|
||||
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
||||
Spacer(GlanceModifier.height(1.dp))
|
||||
}
|
||||
OverflowRow(week = week, colW = colW)
|
||||
@@ -346,7 +350,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
var col = 0
|
||||
@@ -354,12 +358,12 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
if (span != null) {
|
||||
val cols = span.endCol - col + 1
|
||||
SpanBar(event = span.event, dark = dark, width = colW * cols)
|
||||
SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
|
||||
col = span.endCol + 1
|
||||
} else {
|
||||
val timed = timedEventAt(week, lane, col, week.days[col])
|
||||
if (timed != null) {
|
||||
SpanBar(event = timed, dark = dark, width = colW)
|
||||
SpanBar(event = timed, dark = dark, soften = soften, width = colW)
|
||||
} else {
|
||||
// Empty lane cell: a tap opens that day, so blank space in a
|
||||
// day column is a day-open target just like the number is.
|
||||
@@ -378,8 +382,9 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||
|
||||
/** A single connected, rounded event bar [width] wide with its clipped title. */
|
||||
@Composable
|
||||
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
||||
private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) {
|
||||
val context = LocalContext.current
|
||||
val fill = eventFill(event.color, dark, soften)
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(width)
|
||||
@@ -402,13 +407,13 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.cornerRadius(4.dp)
|
||||
.background(pastelize(event.color, dark)),
|
||||
.background(fill),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = EventInk, fontSize = 9.sp),
|
||||
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
|
||||
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -403,15 +403,15 @@
|
||||
<string name="shortcut_new_event_long">Crear un nuevo evento</string>
|
||||
<string name="settings_qs_tile">Añadir mosaico de ajustes rápidos</string>
|
||||
<string name="settings_qs_tile_hint">Añadir un recuadro de \"nuevo evento\" al panel de ajustes rápidos.</string>
|
||||
<string name="crash_dialog_title">Calendula ha fallado</string>
|
||||
<string name="crash_dialog_message">Calendula se cerro de forma inesperada la ultima vez. Puedes ayudar a solucionarlo enviando un reporte del problema. Este permanece en tu dispositivo hasta que decidas enviarlo, y no contiene información personal o contenido de tu calendario — solo los detalles técnicos de mas abajo.</string>
|
||||
<string name="crash_dialog_title">%1$s ha fallado</string>
|
||||
<string name="crash_dialog_message">%1$s se cerro de forma inesperada la ultima vez. Puedes ayudar a solucionarlo enviando un reporte del problema. Este permanece en tu dispositivo hasta que decidas enviarlo, y no contiene información personal o contenido de tu calendario — solo los detalles técnicos de mas abajo.</string>
|
||||
<string name="crash_dialog_report">Reportar</string>
|
||||
<string name="crash_dialog_dismiss">No por ahora</string>
|
||||
<string name="crash_report_issue_title">Reporte de error</string>
|
||||
<string name="crash_report_clip_label">Reporte de error de Calendula</string>
|
||||
<string name="crash_report_clip_label">Reporte de error de %1$s</string>
|
||||
<string name="crash_report_copied">Reporte copiado a tu porta papeles</string>
|
||||
<string name="crash_report_open_failed">No se pudo abrir el sistema de seguimiento de errores. El reporte es en tu portapapeles.</string>
|
||||
<string name="crash_report_body_template">Gracias por reportar un error en Calendula. Por favor añade todo lo que recuerdas sobre lo que estabas haciendo y luego publicalo.\n\n### que sucedió\n\n\n### reporte de error\n%1$s\n</string>
|
||||
<string name="crash_report_body_template">Gracias por reportar un error en %1$s. Por favor añade todo lo que recuerdas sobre lo que estabas haciendo y luego publicalo.\n\n### que sucedió\n\n\n### reporte de error\n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(el reporte es demasiado largo para este link — pegalo aquí desde su portapeles.)_</string>
|
||||
<string name="event_attendee_resource">Recurso</string>
|
||||
<string name="settings_section_about">Acerca de</string>
|
||||
@@ -466,4 +466,31 @@
|
||||
<string name="settings_special_dates_grant">Permitir acceso</string>
|
||||
<string name="dialog_save">Guardar</string>
|
||||
<string name="settings_font_system">Igual que el sistema</string>
|
||||
<string name="import_reminder_prompt_title">¿Aplicar tus recordatorios por defecto?</string>
|
||||
<string name="import_reminder_prompt_body_none">Este evento fue importado sin ningún recordatorio.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">Este evento fue importado con %1$d recordatorio.</item>
|
||||
<item quantity="many">Este evento fue portado con %1$d recordatorios.</item>
|
||||
<item quantity="other">Este evento fue importado con %1$d recordatorios.</item>
|
||||
</plurals>
|
||||
<string name="import_reminder_prompt_apply">Aplicar por defecto</string>
|
||||
<string name="import_reminder_prompt_keep">Mantener como esta</string>
|
||||
<string name="back">Atras</string>
|
||||
<string name="settings_week_numbers">Números de semana</string>
|
||||
<string name="settings_week_numbers_summary">Muestra los números de las semanas en la vista mensual</string>
|
||||
<string name="calendars_export_title">Exportar calendarios</string>
|
||||
<string name="calendars_export_hint">Escoge que calendarios incluir en el archivo .ics</string>
|
||||
<string name="calendars_export_action">Exportar</string>
|
||||
<string name="calendars_restore_header">Restaurar</string>
|
||||
<string name="calendars_restore_action">Restaurar por archivo .ics</string>
|
||||
<string name="calendars_restore_hint">Importar eventos desde un respaldo u otra aplicación de calendario.</string>
|
||||
<string name="import_done_dedup_note">Se omitieron los eventos ya presentes en el calendario.</string>
|
||||
<string name="import_done_added_label">Añadido</string>
|
||||
<string name="import_done_skipped_label">Duplicados</string>
|
||||
<string name="import_button">Importar</string>
|
||||
<plurals name="import_title_count">
|
||||
<item quantity="one">Importando %d evento</item>
|
||||
<item quantity="many">Importando %d eventos</item>
|
||||
<item quantity="other">Importando %d eventos</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,151 @@
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="app_name">Calendrier</string>
|
||||
<string name="app_tagline">Un calendrier moderne.</string>
|
||||
<string name="state_loading">Chargement…</string>
|
||||
<string name="state_retry">Réessayer</string>
|
||||
<string name="state_failure_unknown">Une erreur s\'est produit.</string>
|
||||
<string name="state_failure_permission">Un accès au Calendrier est nécessaire.</string>
|
||||
<string name="state_failure_permission_action">Autoriser l\'accès</string>
|
||||
<string name="state_failure_no_calendars">Aucun calendrier configuré.</string>
|
||||
<string name="state_failure_no_calendars_action">Ouvrir les paramètres du calendrier.</string>
|
||||
<string name="state_failure_provider">Ne peut pas lire le calendrier.</string>
|
||||
<string name="permission_rationale_title">Tous les évènements ont été trouvés.</string>
|
||||
<string name="permission_rationale_body">Calendula a besoin d\'avoir accès à votre calendrier pour afficher et accéder aux événements. C\'est tout ce dont nous avons besoin, aucune données ne sort de votre appareil.</string>
|
||||
<string name="permission_request_button">Autoriser l\'accès au calendrier</string>
|
||||
<string name="permission_denied_title">Accès au calendrier refusé.</string>
|
||||
<string name="permission_denied_body">Calendula ne peut pas afficher les évènements si elle ne peut pas accéder au calendrier. Vous pouvez l\'autoriser à tout moment dans les paramètres.</string>
|
||||
<string name="permission_open_settings_button">Ouvrir les paramètres.</string>
|
||||
<string name="permission_retry_button">Essayer à nouveau</string>
|
||||
<string name="permission_benefit_private_title">Reste sur votre appareil</string>
|
||||
<string name="permission_benefit_private_body">Vos calendriers sont lus localement et ne quittent jamais le téléphone.</string>
|
||||
<string name="permission_benefit_sync_title">Tous vos calendriers, réunis</string>
|
||||
<string name="permission_benefit_privacy_title">Aucun suivi, jamais</string>
|
||||
<string name="permission_benefit_privacy_body">Aucune collecte de données, aucune analyse, pas de pubs</string>
|
||||
<string name="permission_privacy_footnote">Reste sur votre appareil - Pas d\'utilisation d\'Internet</string>
|
||||
<string name="month_prev">Mois précédent</string>
|
||||
<string name="month_next">Prochain mois</string>
|
||||
<string name="month_today_action">Aujourd\'hui</string>
|
||||
<string name="month_more_actions">Plus d\'actions</string>
|
||||
<string name="month_open_menu">Ouvrir le menu</string>
|
||||
<string name="month_action_settings">Paramètres</string>
|
||||
<string name="month_a11y_today_prefix">Aujourd\'hui</string>
|
||||
<string name="week_today_action">Cette semaine</string>
|
||||
<string name="day_today_action">Aujourd\'hui</string>
|
||||
<string name="event_detail_back">Retour</string>
|
||||
<string name="event_detail_edit">Modifier</string>
|
||||
<string name="event_detail_delete">Supprimer</string>
|
||||
<string name="event_detail_share">Partager</string>
|
||||
<string name="event_share_chooser_title">Evénement partagé</string>
|
||||
<string name="event_share_failed">Impossible de partager cet événement.</string>
|
||||
<string name="event_delete_title">Evénement supprimé ?</string>
|
||||
<string name="event_delete_body">Cet événement est retiré de votre calendrier et de chaque appareil auquel il est synchronisé.</string>
|
||||
<string name="event_delete_recurring_title">Supprimer l\'événement récurrent</string>
|
||||
<string name="event_delete_option_occurrence">Seulement cet événement</string>
|
||||
<string name="event_delete_option_following">Cet événement et tous ceux qui suivent</string>
|
||||
<string name="event_delete_option_series">Tous les événements de la série</string>
|
||||
<string name="event_edit_recurring_title">Modifier l\'événement récurrent</string>
|
||||
<string name="event_delete_failed">Impossible de supprimer l\'événement</string>
|
||||
<string name="event_delete_write_denied">Calendula a besoin des droits d\'écriture pour supprimer des événements</string>
|
||||
<string name="dialog_cancel">Annuler</string>
|
||||
<string name="dialog_ok">OK</string>
|
||||
<string name="event_edit_new_title">Nouvel événement</string>
|
||||
<string name="event_edit_close">Fermer</string>
|
||||
<string name="event_edit_save">Sauvegarder</string>
|
||||
<string name="event_edit_title_hint">Ajouter un titre</string>
|
||||
<string name="event_edit_managed_hint">dirigé par \"%1$s\" - titre, date, et répétitions sont synchronisés avec les contacts. Pour rappel, vous pouvez modifier la localisation et les notes à tout instant.</string>
|
||||
<string name="event_edit_starts">Début</string>
|
||||
<string name="event_edit_ends">Fin</string>
|
||||
<string name="event_edit_error_end_before_start">La fin commence avant le début</string>
|
||||
<string name="event_edit_error_no_calendar">Aucun calendrier n\'est disponible en écriture</string>
|
||||
<string name="event_edit_save_failed">Impossible d\'enregistrer l\'événement</string>
|
||||
<string name="event_edit_write_denied">Calendula a besoin des droits d\'écriture pour créer un événement</string>
|
||||
<string name="event_edit_more_fields">Plus de champs</string>
|
||||
<string name="event_edit_add">Ajouter</string>
|
||||
<string name="event_edit_add_reminder">Ajouter un rappel</string>
|
||||
<string name="event_edit_remove_reminder">Supprimer le rappel</string>
|
||||
<string name="event_edit_attendees">Invités</string>
|
||||
<string name="event_edit_add_guest">Ajouter un invité</string>
|
||||
<string name="event_edit_add_guest_hint">Ajouter un invité à partir d\'un email…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Ajouter à partir des contacts</string>
|
||||
<string name="event_edit_location_from_contacts">Sélectionner une adresse dans les contacts</string>
|
||||
<string name="event_edit_remove_guest">Retirer un invité</string>
|
||||
<string name="event_edit_attendee_required">Requis</string>
|
||||
<string name="event_edit_attendee_optional">Optionnel</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula n\'envoie pas d\'invitations. Votre calendrier par défaut peut envoyer des e-mails aux invités lors de la synchronisation.</string>
|
||||
<string name="event_edit_attendees_note_local">Stocké sur cet appareil. Personne d\'autres n\'y a accès.</string>
|
||||
<string name="event_edit_reminder_custom">Personnaliser</string>
|
||||
<string name="reminder_unit_minutes">minutes</string>
|
||||
<string name="reminder_unit_hours">heures</string>
|
||||
<string name="reminder_unit_days">jours</string>
|
||||
<string name="reminder_unit_weeks">semaines</string>
|
||||
<string name="event_edit_availability">Disponibilité</string>
|
||||
<string name="event_edit_visibility">Visibilité</string>
|
||||
<string name="event_edit_color">Couleur</string>
|
||||
<string name="event_edit_color_default">Couleur du calendrier</string>
|
||||
<string name="event_edit_color_custom">Personnaliser la couleur</string>
|
||||
<string name="event_edit_color_reset">Réinitialiser</string>
|
||||
<string name="event_edit_color_unsupported">Non disponible pour ce calendrier</string>
|
||||
<string name="event_edit_color_unsupported_hint">Ce calendrier ne propose aucun ensemble de couleurs. Vous pouvez autoriser des couleurs personnalisées pour ces calendriers dans les paramètres.</string>
|
||||
<string name="event_edit_color_sync_warning">Ce calendrier pourrait retirer ou remplacer la couleur lors de sa prochaine synchronisation.</string>
|
||||
<string name="event_edit_conflict_title">L\'événement a changé ailleurs</string>
|
||||
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
|
||||
<string name="event_edit_conflict_overwrite">Enregistrer mes modifications</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Seuls les champs que vous modifiez remplacent l\'altération externe</string>
|
||||
<string name="event_edit_conflict_discard">Annuler mes modifications</string>
|
||||
<string name="event_edit_conflict_discard_hint">L’événement reste tel qu’il est maintenant</string>
|
||||
<string name="event_edit_gone_title">Evénement supprimé</string>
|
||||
<string name="event_edit_gone_body">Cet événement a été supprimé entre-temps, par exemple sur un autre appareil. Vos modifications ne peuvent plus être enregistrées.</string>
|
||||
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
|
||||
<string name="import_reminder_prompt_body_none">Cet événement a été importé sans aucun rappel.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">Cet événement a été importé avec %1$d rappel.</item>
|
||||
<item quantity="many">Cet événement a été importé avec %1$d rappels.</item>
|
||||
<item quantity="other">Cet événement a été importé avec %1$d rappels.</item>
|
||||
</plurals>
|
||||
<string name="import_reminder_prompt_apply">Appliquer par défaut</string>
|
||||
<string name="import_reminder_prompt_keep">Garder tel quel</string>
|
||||
<string name="event_edit_recurrence_none">Pas de répétitions</string>
|
||||
<string name="event_edit_recurrence_custom">Personnaliser</string>
|
||||
<string name="event_edit_recurrence_every">Chaque</string>
|
||||
<string name="recurrence_unit_days">jours</string>
|
||||
<string name="recurrence_unit_weeks">semaines</string>
|
||||
<string name="recurrence_unit_months">mois</string>
|
||||
<string name="recurrence_unit_years">années</string>
|
||||
<string name="event_edit_recurrence_ends">Fin</string>
|
||||
<string name="event_edit_recurrence_end_never">Jamais</string>
|
||||
<string name="event_edit_recurrence_end_until">A une date</string>
|
||||
<string name="event_edit_recurrence_end_count">Après un nombre de fois</string>
|
||||
<string name="event_edit_recurrence_times">fois</string>
|
||||
<string name="event_edit_error_recurrence_ends_before_start">Les répétitions s\'arrêtent avant le début de l\'événement</string>
|
||||
<string name="event_availability_busy">Occupé</string>
|
||||
<string name="event_access_default">Par défaut</string>
|
||||
<string name="event_access_public">Publique</string>
|
||||
<string name="event_detail_all_day">Journée entière</string>
|
||||
<string name="event_detail_calendar">Calendrier</string>
|
||||
<string name="event_detail_calendar_unknown">Calendrier non reconnu</string>
|
||||
<string name="event_detail_location">Lieux</string>
|
||||
<string name="event_detail_description">Description</string>
|
||||
<string name="event_detail_attendees">participants</string>
|
||||
<string name="event_detail_recurrence">Récurrence</string>
|
||||
<string name="event_detail_recurring">Evénement répété</string>
|
||||
<string name="recurrence_daily">Tous les jours</string>
|
||||
<string name="recurrence_weekly">Toutes les semaines</string>
|
||||
<string name="recurrence_monthly">Tous les mois</string>
|
||||
<string name="recurrence_yearly">Tous les ans</string>
|
||||
<string name="recurrence_every_n_days">Tous les %1$d jours</string>
|
||||
<string name="recurrence_every_n_weeks">Toutes les %1$d semaines</string>
|
||||
<string name="recurrence_every_n_months">Tous les %1$d mois</string>
|
||||
<string name="recurrence_every_n_years">Tous les %1$d ans</string>
|
||||
<string name="recurrence_on_days">%1$s sur %2$s</string>
|
||||
<string name="recurrence_with_until">%1$s jusqu\'à %2$s</string>
|
||||
<string name="recurrence_with_count">%1$s, %2$d fois</string>
|
||||
<string name="event_detail_not_found">Cet événement n\'existe plus.</string>
|
||||
<string name="event_attendee_accepted">Accepté</string>
|
||||
<string name="event_attendee_declined">Refusé</string>
|
||||
<string name="event_attendee_tentative">Provisoire</string>
|
||||
<string name="event_attendee_needs_action">Aucune réponse</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
<string name="event_detail_reminders">Rappels</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,4 +6,295 @@
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="app_tagline">Nowoczesny kalendarz.</string>
|
||||
<string name="state_loading">Ładowanie…</string>
|
||||
<string name="state_retry">Spróbuj ponownie</string>
|
||||
<string name="state_failure_unknown">Coś poszło nie tak.</string>
|
||||
<string name="state_failure_permission">Dostęp do kalendarza jest wymagany.</string>
|
||||
<string name="state_failure_permission_action">Przyznaj dostęp</string>
|
||||
<string name="state_failure_no_calendars">Brak skonfigurowanych kalendarzy.</string>
|
||||
<string name="state_failure_no_calendars_action">Otwórz systemowe ustawienia kalendarza</string>
|
||||
<string name="state_failure_provider">Nie udało się kalendarza.</string>
|
||||
<string name="permission_rationale_title">Zobacz wszystkie swoje wydarzenia w pięknej oprawie</string>
|
||||
<string name="permission_rationale_body">Calendula potrzebuje dostępu do Twojego kalendarza, aby wyświetlać wydarzenia i nimi zarządzać. To jedyne, o co prosi na wstępie — i żadne dane nigdy nie opuszczają Twojego urządzenia.</string>
|
||||
<string name="permission_request_button">Przyznaj dostęp do kalendarza</string>
|
||||
<string name="permission_denied_title">Brak dostępu do kalendarza</string>
|
||||
<string name="permission_denied_body">Calendula nie może wyświetlać wydarzeń bez dostępu do kalendarza. Możesz go ponownie przyznać w ustawieniach systemowych.</string>
|
||||
<string name="permission_open_settings_button">Otwórz ustawienia systemowe</string>
|
||||
<string name="permission_retry_button">Spróbuj ponownie</string>
|
||||
<string name="permission_benefit_private_title">Pozostaje na Twoim urządzeniu</string>
|
||||
<string name="permission_benefit_private_body">Twoje kalendarze są odczytywane lokalnie i nigdy nie opuszczają telefonu.</string>
|
||||
<string name="permission_benefit_sync_title">Wszystkie Twoje kalendarze w jednym miejscu</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, lokalne — wszystko, co jest zsynchronizowane z urządzeniem, po prostu się pojawia.</string>
|
||||
<string name="permission_benefit_privacy_title">Nigdy żadnego śledzenia</string>
|
||||
<string name="permission_benefit_privacy_body">Zero telemetrii, zero analityki, bez reklam.</string>
|
||||
<string name="permission_privacy_footnote">Pozostaje na Twoim urządzeniu - brak uprawnień do internetu</string>
|
||||
<string name="month_prev">Poprzedni miesiąc</string>
|
||||
<string name="month_next">Następny miesiąc</string>
|
||||
<string name="month_today_action">Dzisiaj</string>
|
||||
<string name="month_more_actions">Więcej akcji</string>
|
||||
<string name="month_open_menu">Otwórz menu</string>
|
||||
<string name="month_action_settings">Ustawienia</string>
|
||||
<string name="month_a11y_today_prefix">Dzisiaj</string>
|
||||
<string name="week_today_action">Bieżący tydzień</string>
|
||||
<string name="week_number_label">tydz.</string>
|
||||
<string name="day_today_action">Dzisiaj</string>
|
||||
<string name="event_detail_back">Wstecz</string>
|
||||
<string name="event_detail_edit">Edytuj</string>
|
||||
<string name="event_detail_delete">Usuń</string>
|
||||
<string name="event_detail_share">Udostępnij</string>
|
||||
<string name="event_share_chooser_title">Udostępnij wydarzenie</string>
|
||||
<string name="event_share_failed">Nie udało się udostępnić tego wydarzenia.</string>
|
||||
<string name="event_delete_title">Usunąć wydarzenie?</string>
|
||||
<string name="event_delete_body">Usunięto wydarzenie z Twojego kalendarza i wszystkich zsynchronizowanych urządzeń.</string>
|
||||
<string name="event_delete_recurring_title">Usuń powtarzające się wydarzenie</string>
|
||||
<string name="event_delete_option_occurrence">Tylko to wystąpienie</string>
|
||||
<string name="event_delete_option_following">To i wszystkie następne wystąpienia</string>
|
||||
<string name="event_delete_option_series">Wszystkie wystąpienia w serii</string>
|
||||
<string name="event_edit_recurring_title">Edytuj powtarzające się wydarzenie</string>
|
||||
<string name="event_delete_failed">Nie udało się usunąć tego wydarzenia</string>
|
||||
<string name="event_delete_write_denied">Calendula wymaga uprawnień do zapisu, aby móc usuwać wydarzenia</string>
|
||||
<string name="dialog_cancel">Anuluj</string>
|
||||
<string name="dialog_ok">OK</string>
|
||||
<string name="event_edit_new_title">Nowe wydarzenie</string>
|
||||
<string name="event_edit_close">Zamknij</string>
|
||||
<string name="event_edit_save">Zapisz</string>
|
||||
<string name="event_edit_title_hint">Dodaj tytuł</string>
|
||||
<string name="event_edit_managed_hint">Zarządzane przez „%1$s” — tytuł, data i powtarzanie są synchronizowane z Twoimi kontaktami. Możesz edytować przypomnienia, lokalizację oraz notatki.</string>
|
||||
<string name="event_edit_starts">Początek</string>
|
||||
<string name="event_edit_ends">Koniec</string>
|
||||
<string name="event_edit_error_end_before_start">Koniec wcześniejszy niż początek</string>
|
||||
<string name="event_edit_error_no_calendar">Brak dostępnych kalendarzy z uprawnieniami do zapisu</string>
|
||||
<string name="event_edit_save_failed">Nie udało się zapisać wydarzenia</string>
|
||||
<string name="event_edit_write_denied">Calendula potrzebuje uprawnień do zapisu by tworzyć wydarzenia</string>
|
||||
<string name="event_edit_more_fields">Więcej pól</string>
|
||||
<string name="event_edit_add">Dodaj</string>
|
||||
<string name="event_edit_add_reminder">Dodaj przypomnienie</string>
|
||||
<string name="event_edit_remove_reminder">Usuń przypomnienie</string>
|
||||
<string name="event_edit_attendees">Goście</string>
|
||||
<string name="event_edit_add_guest">Dodaj gościa</string>
|
||||
<string name="event_edit_add_guest_hint">Dodaj gościa poprzez adres e-mail…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Dodaj z kontaktów</string>
|
||||
<string name="event_edit_location_from_contacts">Wybierz adres z kontaktów</string>
|
||||
<string name="event_edit_remove_guest">Usuń gościa</string>
|
||||
<string name="event_edit_attendee_required">Wymagany</string>
|
||||
<string name="event_edit_attendee_optional">Opcjonalny</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula nie wysyła zaproszeń. Twoje konto kalendarza może wysyłać e-maile do gości podczas synchronizacji.</string>
|
||||
<string name="event_edit_attendees_note_local">Przechowywane na urządzeniu. Nikt nie jest powiadamiany.</string>
|
||||
<string name="event_edit_reminder_custom">Niestandardowy</string>
|
||||
<string name="reminder_unit_minutes">minuty</string>
|
||||
<string name="reminder_unit_hours">godziny</string>
|
||||
<string name="reminder_unit_days">dni</string>
|
||||
<string name="reminder_unit_weeks">tygodnie</string>
|
||||
<string name="event_edit_availability">Dostępność</string>
|
||||
<string name="event_edit_visibility">Widoczność</string>
|
||||
<string name="event_edit_color">Kolor</string>
|
||||
<string name="event_edit_color_default">Kolor kalendarza</string>
|
||||
<string name="event_edit_color_custom">Niestandardowy kolor</string>
|
||||
<string name="event_edit_color_reset">Resetuj</string>
|
||||
<string name="event_edit_color_unsupported">Niedostępne dla tego kalendarza</string>
|
||||
<string name="event_edit_color_unsupported_hint">Ten kalendarz nie udostępnia zestawu kolorów. Możesz zezwolić na niestandardowe kolory dla takich kalendarzy w Ustawieniach.</string>
|
||||
<string name="event_edit_color_sync_warning">Ten kalendarz może usunąć lub nadpisać kolor przy następnej synchronizacji.</string>
|
||||
<string name="event_edit_conflict_title">Wydarzenie zostało zmienione w innym miejscu</string>
|
||||
<string name="event_edit_conflict_body">Podczas edycji to wydarzenie zostało zmienione przez synchronizację lub inną aplikację. Co ma się stać z Twoimi zmianami?</string>
|
||||
<string name="event_edit_conflict_overwrite">Zapisz moje zmiany</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Tylko edytowane przez Ciebie pola nadpiszą zmiany zewnętrzne</string>
|
||||
<string name="event_edit_conflict_discard">Odrzuć moje zmiany</string>
|
||||
<string name="event_edit_conflict_discard_hint">Wydarzenie pozostaje bez zmian</string>
|
||||
<string name="event_edit_gone_title">Wydarzenie usunięte</string>
|
||||
<string name="event_edit_gone_body">To wydarzenie zostało w międzyczasie usunięte, na przykład na innym urządzeniu. Twoje zmiany nie mogą już zostać zapisane.</string>
|
||||
<string name="import_reminder_prompt_title">Użyć domyślnego przypomnienia?</string>
|
||||
<string name="import_reminder_prompt_body_none">To wydarzenie zostało zaimportowane bez żadnych przypomnień.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">To wydarzenie zostało zaimportowane z %1$d przypomnieniem.</item>
|
||||
<item quantity="few">To wydarzenie zostało zaimportowane z %1$d przypomnieniami.</item>
|
||||
<item quantity="many">To wydarzenie zostało zaimportowane z %1$d przypomnieniami.</item>
|
||||
<item quantity="other">To wydarzenie zostało zaimportowane z %1$d przypomnieniami.</item>
|
||||
</plurals>
|
||||
<string name="import_reminder_prompt_apply">Użyj domyślnego</string>
|
||||
<string name="import_reminder_prompt_keep">Pozostaw bez zmian</string>
|
||||
<string name="event_edit_recurrence_none">Nie powtarza się</string>
|
||||
<string name="event_edit_recurrence_custom">Własne</string>
|
||||
<string name="event_edit_recurrence_every">Co</string>
|
||||
<string name="recurrence_unit_days">dni</string>
|
||||
<string name="recurrence_unit_weeks">tygodnie</string>
|
||||
<string name="recurrence_unit_months">miesiące</string>
|
||||
<string name="recurrence_unit_years">lata</string>
|
||||
<string name="event_edit_recurrence_ends">Koniec</string>
|
||||
<string name="event_edit_recurrence_end_never">Nigdy</string>
|
||||
<string name="event_edit_recurrence_end_until">Wybrana data</string>
|
||||
<string name="event_edit_recurrence_end_count">Po wybranej liczbie powtórzeń</string>
|
||||
<string name="event_edit_recurrence_times">razy</string>
|
||||
<string name="event_edit_error_recurrence_ends_before_start">Koniec powtarzania przed początkiem wydarzenia</string>
|
||||
<string name="event_availability_busy">Zajęty</string>
|
||||
<string name="event_access_default">Domyślny</string>
|
||||
<string name="event_access_public">Publiczny</string>
|
||||
<string name="event_detail_all_day">Cały dzień</string>
|
||||
<string name="event_detail_calendar">Kalendarz</string>
|
||||
<string name="event_detail_calendar_unknown">Nieznany kalendarz</string>
|
||||
<string name="event_detail_location">Lokalizacja</string>
|
||||
<string name="event_detail_description">Opis</string>
|
||||
<string name="event_detail_attendees">Uczestnicy</string>
|
||||
<string name="event_detail_recurrence">Powtarzanie</string>
|
||||
<string name="event_detail_recurring">Powtarzające się wydarzenie</string>
|
||||
<string name="recurrence_daily">Codziennie</string>
|
||||
<string name="recurrence_weekly">Co tydzień</string>
|
||||
<string name="recurrence_monthly">Co miesiąc</string>
|
||||
<string name="recurrence_yearly">Co rok</string>
|
||||
<string name="recurrence_every_n_days">Co %1$d dni</string>
|
||||
<string name="recurrence_every_n_weeks">Co %1$d tygodni</string>
|
||||
<string name="recurrence_every_n_months">Co %1$d miesięcy</string>
|
||||
<string name="recurrence_every_n_years">Co %1$d lata</string>
|
||||
<string name="recurrence_on_days">%1$s w %2$s</string>
|
||||
<string name="recurrence_with_until">%1$s do %2$s</string>
|
||||
<string name="recurrence_with_count">%1$s, %2$d razy</string>
|
||||
<string name="event_detail_not_found">To wydarzenie już nie istnieje.</string>
|
||||
<string name="event_attendee_accepted">Zaakceptowano</string>
|
||||
<string name="event_attendee_declined">Odrzucono</string>
|
||||
<string name="event_attendee_tentative">Niepewny</string>
|
||||
<string name="event_attendee_needs_action">Brak odpowiedzi</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
<string name="event_detail_reminders">Przypomnienia</string>
|
||||
<string name="event_detail_timezone">Strefa czasowa</string>
|
||||
<string name="event_status_tentative">Niepewne</string>
|
||||
<string name="event_status_cancelled">Odwołane</string>
|
||||
<string name="event_availability_free">Dostępny</string>
|
||||
<string name="event_access_private">Prywatne</string>
|
||||
<string name="event_access_confidential">Poufne</string>
|
||||
<string name="event_attendee_organizer">Organizator</string>
|
||||
<string name="event_attendee_optional">Opcjonalny</string>
|
||||
<string name="event_attendee_resource">Zasób</string>
|
||||
<string name="event_detail_self_response">Twoja odpowiedź: %1$s</string>
|
||||
<string name="reminder_at_time">W momencie wydarzenia</string>
|
||||
<string name="reminder_default">Domyślne przypomnienie</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%d minuta przed</item>
|
||||
<item quantity="few">%d minuty przed</item>
|
||||
<item quantity="many">%d minut przed</item>
|
||||
<item quantity="other">%d minut przed</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours">
|
||||
<item quantity="one">%d godzina przed</item>
|
||||
<item quantity="few">%d godziny przed</item>
|
||||
<item quantity="many">%d godzin przed</item>
|
||||
<item quantity="other">%d godzin przed</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days">
|
||||
<item quantity="one">%d dzień przed</item>
|
||||
<item quantity="few">%d dni przed</item>
|
||||
<item quantity="many">%d dni przed</item>
|
||||
<item quantity="other">%d dni przed</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks">
|
||||
<item quantity="one">%d tydzień przed</item>
|
||||
<item quantity="few">%d tygodnie przed</item>
|
||||
<item quantity="many">%d tygodni przed</item>
|
||||
<item quantity="other">%d tygodni przed</item>
|
||||
</plurals>
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="one">%d minuta</item>
|
||||
<item quantity="few">%d minuty</item>
|
||||
<item quantity="many">%d minut</item>
|
||||
<item quantity="other">%d minut</item>
|
||||
</plurals>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="one">%d godzina</item>
|
||||
<item quantity="few">%d godziny</item>
|
||||
<item quantity="many">%d godzin</item>
|
||||
<item quantity="other">%d godzin</item>
|
||||
</plurals>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="one">%d dzień</item>
|
||||
<item quantity="few">%d dni</item>
|
||||
<item quantity="many">%d dni</item>
|
||||
<item quantity="other">%d dni</item>
|
||||
</plurals>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">%d tydzień</item>
|
||||
<item quantity="few">%d tygodnie</item>
|
||||
<item quantity="many">%d tygodni</item>
|
||||
<item quantity="other">%d tygodni</item>
|
||||
</plurals>
|
||||
<string name="event_untitled">(Bez tytułu)</string>
|
||||
<string name="reminder_channel_name">Przypomnienia o wydarzeniach</string>
|
||||
<string name="reminder_channel_description">Powiadomienia w czasie przypomnień o Twoich wydarzeniach</string>
|
||||
<string name="reminder_onboarding_title">Nigdy nie przegap wydarzenia</string>
|
||||
<string name="reminder_onboarding_body">Android sam nie wyświetla przypomnień o wydarzeniach — musi to robić aplikacja kalendarza. Pozwól na to Calenduli.</string>
|
||||
<string name="reminder_benefit_delivery_title">Niezawodne przypomnienia</string>
|
||||
<string name="reminder_benefit_delivery_body">Każde przypomnienie o Twoich wydarzeniach przychodzi jako powiadomienie, dokładnie na czas.</string>
|
||||
<string name="reminder_benefit_duplicates_title">Używasz innej aplikacji kalendarza?</string>
|
||||
<string name="reminder_benefit_duplicates_body">Jeśli inna aplikacja również wysyła przypomnienia, będą one widoczne podwójnie - wyłącz je tam lub tutaj.</string>
|
||||
<string name="reminder_benefit_reversible_title">Możesz to zmienić w dowolnej chwili</string>
|
||||
<string name="reminder_benefit_reversible_body">Przełącznik znajduje się w Ustawieniach, w sekcji Powiadomienia.</string>
|
||||
<string name="reminder_onboarding_enable_button">Włącz przypomnienia</string>
|
||||
<string name="reminder_onboarding_skip_button">Nie teraz</string>
|
||||
<string name="reminder_action_snooze">Drzemka</string>
|
||||
<string name="reminder_action_dismiss">Odrzuć</string>
|
||||
<string name="view_month">Miesiąc</string>
|
||||
<string name="view_week">Tydzień</string>
|
||||
<string name="view_day">Dzień</string>
|
||||
<string name="view_agenda">Agenda</string>
|
||||
<string name="view_section">Widok</string>
|
||||
<string name="drawer_jump_to_date">Skocz do daty</string>
|
||||
<string name="agenda_today_action">Dzisiaj</string>
|
||||
<string name="agenda_header_today">Dzisiaj</string>
|
||||
<string name="agenda_header_tomorrow">Jutro</string>
|
||||
<string name="agenda_empty_title">Jesteś na bieżąco</string>
|
||||
<string name="search_action">Szukaj</string>
|
||||
<string name="search_hint">Szukaj wydarzeń</string>
|
||||
<string name="search_back">Wstecz</string>
|
||||
<string name="search_clear">Wyczyść</string>
|
||||
<string name="search_idle_hint">Przeszukuj swoje wydarzenia według tytułu, lokalizacji lub notatek.</string>
|
||||
<string name="search_empty">Brak wydarzeń pasujących do „%1$s”.</string>
|
||||
<string name="widget_agenda_title">Nadchodzące wydarzenia</string>
|
||||
<string name="widget_agenda_label">Agenda Calendula</string>
|
||||
<string name="widget_month_label">Miesiąc Calendula</string>
|
||||
<string name="widget_refresh">Odśwież</string>
|
||||
<string name="widget_new_event">Nowe wydarzenie</string>
|
||||
<string name="widget_needs_permission">Otwórz Calendula aby przyznać dostęp do kalendarza</string>
|
||||
<string name="widget_prev_month">Poprzedni miesiąc</string>
|
||||
<string name="widget_next_month">Następny miesiąc</string>
|
||||
<string name="widget_today">Dzisiaj</string>
|
||||
<string name="filter_title">Kalendarze</string>
|
||||
<string name="settings_title">Ustawienia</string>
|
||||
<string name="settings_back">Wstecz</string>
|
||||
<string name="back">Wstecz</string>
|
||||
<string name="settings_section_appearance">Wygląd</string>
|
||||
<string name="settings_theme">Motyw</string>
|
||||
<string name="settings_theme_system">Systemowy</string>
|
||||
<string name="settings_theme_light">Jasny</string>
|
||||
<string name="settings_theme_dark">Ciemny</string>
|
||||
<string name="settings_default_view">Domyślny widok</string>
|
||||
<string name="settings_dynamic_color">Dynamiczne kolory</string>
|
||||
<string name="settings_dynamic_color_unavailable">Wymaga Androida 12 lub nowszego</string>
|
||||
<string name="settings_font_headings">Czcionka nagłówków</string>
|
||||
<string name="settings_font_body">Czcionka tekstu</string>
|
||||
<string name="settings_font_system">Domyślna systemu</string>
|
||||
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||
<string name="font_lora">Lora</string>
|
||||
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||
<string name="settings_font_choose_file">Wybierz plik…</string>
|
||||
<string name="settings_font_custom_selected">Niestandardowa czcionka</string>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="settings_font_import_failed">Nie udało się odczytać czcionki z pliku</string>
|
||||
<string name="settings_week_start">Pierwszy dzień tygodnia</string>
|
||||
<string name="settings_week_start_auto">Automatycznie</string>
|
||||
<string name="settings_week_numbers">Numery tygodni</string>
|
||||
<string name="settings_week_numbers_summary">Pokaż numery tygodni w widoku miesiąca</string>
|
||||
<string name="settings_time_format">Format czasu</string>
|
||||
<string name="settings_time_format_auto">Automatyczny</string>
|
||||
<string name="settings_time_format_12h">12-godzinny (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24-godzinny (14:00)</string>
|
||||
<string name="settings_hour_lines">Linie godzin</string>
|
||||
<string name="settings_hour_lines_summary">Pokaż linię oddzielającą przy każdej godzinie w widoku tygodnia i dnia</string>
|
||||
<string name="settings_dim_completed">Przyciemnij zakończone wydarzenia</string>
|
||||
<string name="settings_dim_completed_summary">Przyciemnij wydarzenia, które już się zakończyły w widoku miesiąca i tygodnia</string>
|
||||
<string name="settings_past_events">Przeszłe wydarzenia</string>
|
||||
<string name="settings_past_events_show">Pokaż</string>
|
||||
<string name="settings_past_events_dim">Przyciemnij</string>
|
||||
<string name="settings_past_events_hide">Ukryj</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Zakres agendy</string>
|
||||
<string name="settings_agenda_range_hint">Jak daleko w przód wyświetlane są wydarzenia na ekranie Agendy.</string>
|
||||
<string name="settings_agenda_widget_range">Zakres widżetu agendy</string>
|
||||
</resources>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
<string name="event_detail_edit">Edit</string>
|
||||
<string name="event_detail_delete">Delete</string>
|
||||
<string name="event_detail_share">Share</string>
|
||||
<string name="event_detail_duplicate">Duplicate</string>
|
||||
<string name="event_share_chooser_title">Share event</string>
|
||||
<string name="event_share_failed">Couldn\'t share this event.</string>
|
||||
<string name="event_delete_title">Delete event?</string>
|
||||
@@ -117,6 +118,16 @@
|
||||
<string name="event_edit_gone_title">Event deleted</string>
|
||||
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
|
||||
|
||||
<!-- Event form — apply default reminder to an imported .ics event (#49) -->
|
||||
<string name="import_reminder_prompt_title">Apply your default reminder?</string>
|
||||
<string name="import_reminder_prompt_body_none">This event was imported without any reminder.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">This event was imported with %1$d reminder.</item>
|
||||
<item quantity="other">This event was imported with %1$d reminders.</item>
|
||||
</plurals>
|
||||
<string name="import_reminder_prompt_apply">Apply default</string>
|
||||
<string name="import_reminder_prompt_keep">Keep as-is</string>
|
||||
|
||||
<!-- Event form — recurrence picker (v1.3) -->
|
||||
<string name="event_edit_recurrence_none">Does not repeat</string>
|
||||
<string name="event_edit_recurrence_custom">Custom</string>
|
||||
@@ -226,6 +237,9 @@
|
||||
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||
<string name="reminder_action_snooze">Snooze</string>
|
||||
<string name="reminder_action_dismiss">Dismiss</string>
|
||||
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
|
||||
<string name="reminder_day_tomorrow">Tomorrow</string>
|
||||
<string name="reminder_day_yesterday">Yesterday</string>
|
||||
|
||||
<!-- View switcher (M1) -->
|
||||
<string name="view_month">Month</string>
|
||||
@@ -242,6 +256,7 @@
|
||||
<string name="agenda_header_today">Today</string>
|
||||
<string name="agenda_header_tomorrow">Tomorrow</string>
|
||||
<string name="agenda_empty_title">You\'re all caught up</string>
|
||||
<string name="agenda_no_more_today">No more events today</string>
|
||||
|
||||
<!-- Event search -->
|
||||
<string name="search_action">Search</string>
|
||||
@@ -278,6 +293,8 @@
|
||||
<string name="settings_default_view">Default view</string>
|
||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||
<string name="settings_soften_colors">Soften calendar colours</string>
|
||||
<string name="settings_soften_colors_summary">Tone calendar and event colours down to fit the theme. Turn off to show the raw colours from the calendar source.</string>
|
||||
<string name="settings_font_headings">Headings font</string>
|
||||
<string name="settings_font_body">Body font</string>
|
||||
<string name="settings_font_system">System default</string>
|
||||
@@ -308,6 +325,8 @@
|
||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
||||
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
||||
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
|
||||
<string name="settings_agenda_show_today">Always show today</string>
|
||||
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
|
||||
<string name="settings_agenda_range_bar">Range bar</string>
|
||||
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
|
||||
<string name="agenda_range_day">Today</string>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<locale android:name="en" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="es" />
|
||||
<locale android:name="fr" />
|
||||
<locale android:name="it" />
|
||||
<locale android:name="pl" />
|
||||
<locale android:name="zh-CN" />
|
||||
</locale-config>
|
||||
|
||||
@@ -312,6 +312,46 @@ class CalendarRepositoryImplTest {
|
||||
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moveEvent forwards id, target calendar and both forms`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val original = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Stand-up",
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
|
||||
)
|
||||
val updated = original.copy(calendarId = 3L)
|
||||
|
||||
val newId = repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = original, updated = updated)
|
||||
|
||||
assertThat(newId).isEqualTo(77L)
|
||||
assertThat(fake.movedEvents).containsExactly(
|
||||
FakeCalendarDataSource.MovedEvent(42L, 3L, original, updated),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moveEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
writeError = WriteFailedException("insert moved event into calendar id=3")
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||
)
|
||||
|
||||
try {
|
||||
repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = form, updated = form.copy(calendarId = 3L))
|
||||
error("Expected WriteFailedException")
|
||||
} catch (expected: WriteFailedException) {
|
||||
assertThat(expected.message).contains("3")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
|
||||
@@ -32,6 +32,7 @@ class EventDetailMapperTest {
|
||||
accessLevel: Any? = null,
|
||||
timezone: String? = null,
|
||||
selfStatus: Any? = null,
|
||||
duration: String? = null,
|
||||
): MapColumnReader = MapColumnReader(
|
||||
EventDetailProjection.IDX_EVENT_ID to eventId,
|
||||
EventDetailProjection.IDX_TITLE to title,
|
||||
@@ -51,6 +52,7 @@ class EventDetailMapperTest {
|
||||
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
||||
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
||||
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
|
||||
EventDetailProjection.IDX_DURATION to duration,
|
||||
)
|
||||
|
||||
private fun attendeeReader(
|
||||
@@ -134,12 +136,32 @@ class EventDetailMapperTest {
|
||||
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.
|
||||
// no DTEND (they use DURATION), so the length comes from DURATION.
|
||||
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!!.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
|
||||
|
||||
@@ -220,6 +220,117 @@ class EventWriteMapperTest {
|
||||
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
||||
}
|
||||
|
||||
// --- buildOccurrenceCancelValues ("delete only this event") ---
|
||||
|
||||
@Test
|
||||
fun `occurrence cancel anchors a single instance and cancels only it`() {
|
||||
val values = buildOccurrenceCancelValues(
|
||||
originalInstanceMillis = 1_700_000_000_000L,
|
||||
dtStartMillis = 1_700_000_000_000L,
|
||||
duration = "P3600S",
|
||||
timezone = "Europe/Berlin",
|
||||
allDay = 0,
|
||||
)
|
||||
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
|
||||
.isEqualTo(1_700_000_000_000L)
|
||||
// DTSTART + DURATION make the provider derive a single instance and drop
|
||||
// the inherited RRULE, so only this occurrence is cancelled — not the
|
||||
// whole series (#47). DTEND is never sent (the provider rejects it).
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_700_000_000_000L)
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P3600S")
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||
assertThat(values[CalendarContract.Events.STATUS])
|
||||
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day occurrence cancel keeps the all-day flag and utc zone`() {
|
||||
val values = buildOccurrenceCancelValues(
|
||||
originalInstanceMillis = 1_700_000_000_000L,
|
||||
dtStartMillis = 1_700_000_000_000L,
|
||||
duration = "P1D",
|
||||
timezone = "UTC",
|
||||
allDay = 1,
|
||||
)
|
||||
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
||||
assertThat(values[CalendarContract.Events.STATUS])
|
||||
.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 ---
|
||||
|
||||
@Test
|
||||
@@ -298,4 +409,114 @@ class EventWriteMapperTest {
|
||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
|
||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
|
||||
}
|
||||
|
||||
// --- buildMovedMasterValues (calendar move: verbatim series copy) ---
|
||||
|
||||
private fun masterSnapshot(
|
||||
isAllDay: Boolean = false,
|
||||
dtStartMillis: Long = 1_781_164_800_000L,
|
||||
dtEndMillis: Long? = 1_781_164_800_000L + 5_400_000L,
|
||||
duration: String? = null,
|
||||
rrule: String? = null,
|
||||
rdate: String? = null,
|
||||
exdate: String? = null,
|
||||
) = MasterEventSnapshot(
|
||||
title = "Standup",
|
||||
isAllDay = isAllDay,
|
||||
dtStartMillis = dtStartMillis,
|
||||
dtEndMillis = dtEndMillis,
|
||||
duration = duration,
|
||||
rrule = rrule,
|
||||
rdate = rdate,
|
||||
exdate = exdate,
|
||||
timezone = "Europe/Berlin",
|
||||
availability = CalendarContract.Events.AVAILABILITY_BUSY,
|
||||
accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
|
||||
status = CalendarContract.Events.STATUS_CONFIRMED,
|
||||
location = "Room 1",
|
||||
description = "",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `moved one-off carries target calendar, uid and DTEND but no recurrence`() {
|
||||
val values = buildMovedMasterValues(masterSnapshot(), targetCalendarId = 9L, uid = "u@calendula")
|
||||
assertThat(values[CalendarContract.Events.CALENDAR_ID]).isEqualTo(9L)
|
||||
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("u@calendula")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_164_800_000L + 5_400_000L)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||
// Empty description clears explicitly; a raw/keyed colour is never copied
|
||||
// (may be invalid on the target account — the copy inherits its colour).
|
||||
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moved series preserves the recurrence skeleton as RRULE plus DURATION`() {
|
||||
val values = buildMovedMasterValues(
|
||||
masterSnapshot(dtEndMillis = null, duration = "P5400S", rrule = "FREQ=WEEKLY", exdate = "20260618T080000Z"),
|
||||
targetCalendarId = 9L,
|
||||
uid = "u@calendula",
|
||||
)
|
||||
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260618T080000Z")
|
||||
// Recurring rows never carry DTEND (the provider's invariant).
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moved series without a stored duration derives it from DTEND`() {
|
||||
val values = buildMovedMasterValues(
|
||||
masterSnapshot(duration = null, rrule = "FREQ=DAILY"),
|
||||
targetCalendarId = 9L,
|
||||
uid = "u@calendula",
|
||||
)
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
}
|
||||
|
||||
// --- buildCopiedExceptionValues (calendar move: exception replay) ---
|
||||
|
||||
private fun exceptionSnapshot(
|
||||
isCancelled: Boolean = false,
|
||||
duration: String? = null,
|
||||
// Occurrence starts one hour into the day and runs 30 minutes.
|
||||
dtEndMillis: Long? = 1_781_164_800_000L + 3_600_000L + 1_800_000L,
|
||||
) = ExceptionRowSnapshot(
|
||||
exceptionEventId = 42L,
|
||||
originalInstanceMillis = 1_781_164_800_000L,
|
||||
isCancelled = isCancelled,
|
||||
status = if (isCancelled) CalendarContract.Events.STATUS_CANCELED else CalendarContract.Events.STATUS_CONFIRMED,
|
||||
title = "Moved occurrence",
|
||||
isAllDay = false,
|
||||
dtStartMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
dtEndMillis = dtEndMillis,
|
||||
duration = duration,
|
||||
timezone = "Europe/Berlin",
|
||||
availability = CalendarContract.Events.AVAILABILITY_BUSY,
|
||||
accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
|
||||
location = "",
|
||||
description = "Notes",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `copied modified occurrence carries the original instance and DURATION not DTEND`() {
|
||||
val values = buildCopiedExceptionValues(exceptionSnapshot())
|
||||
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
|
||||
.isEqualTo(1_781_164_800_000L)
|
||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved occurrence")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L + 3_600_000L)
|
||||
// 30-minute occurrence derived from DTEND, written as DURATION only.
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P1800S")
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copied modified occurrence keeps a stored duration verbatim`() {
|
||||
val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null))
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,13 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
|
||||
val insertedForms = mutableListOf<EventForm>()
|
||||
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
|
||||
data class MovedEvent(
|
||||
val eventId: Long,
|
||||
val targetCalendarId: Long,
|
||||
val original: EventForm,
|
||||
val updated: EventForm,
|
||||
)
|
||||
val movedEvents = mutableListOf<MovedEvent>()
|
||||
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||
val deletedEventIds = mutableListOf<Long>()
|
||||
@@ -116,6 +123,19 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
}
|
||||
|
||||
override fun moveEvent(
|
||||
eventId: Long,
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
writeError?.let { throw it }
|
||||
movedEvents += MovedEvent(eventId, targetCalendarId, original, updated)
|
||||
allDayReminderTimes += allDayReminderTimeMinutes
|
||||
return nextInsertId
|
||||
}
|
||||
|
||||
override fun updateOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
@@ -18,41 +19,110 @@ class ReminderTimeTextTest {
|
||||
private fun utcMidnight(date: LocalDate): Long =
|
||||
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
|
||||
/** Wrapper defaulting `today` to the event's own begin day, so unrelated tests read "today". */
|
||||
private fun text(
|
||||
beginMillis: Long,
|
||||
endMillis: Long,
|
||||
isAllDay: Boolean = false,
|
||||
zone: ZoneId = berlin,
|
||||
locale: Locale = Locale.GERMANY,
|
||||
is24Hour: Boolean = true,
|
||||
today: LocalDate? = null,
|
||||
firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY,
|
||||
): String = reminderTimeText(
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
isAllDay = isAllDay,
|
||||
zone = zone,
|
||||
locale = locale,
|
||||
is24Hour = is24Hour,
|
||||
today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(),
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
tomorrowLabel = "Tomorrow",
|
||||
yesterdayLabel = "Yesterday",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `timed event on one day shows just the time range`() {
|
||||
val text = reminderTimeText(
|
||||
fun `timed event today shows just the time range`() {
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
|
||||
isAllDay = false,
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
)
|
||||
assertThat(text).isEqualTo("09:30 – 10:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `12-hour preference renders an am-pm time range`() {
|
||||
val text = reminderTimeText(
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin),
|
||||
isAllDay = false,
|
||||
zone = berlin,
|
||||
locale = Locale.US,
|
||||
is24Hour = false,
|
||||
)
|
||||
assertThat(text).isEqualTo("2:00 PM – 3:00 PM")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tomorrow's event is prefixed with the tomorrow label`() {
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 9, 30), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 10, 0), berlin),
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("Tomorrow, 09:30 – 10:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yesterday's event is prefixed with the yesterday label`() {
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 10, 9, 30), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 10, 10, 0), berlin),
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("Yesterday, 09:30 – 10:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event later this week is prefixed with the short weekday`() {
|
||||
// 2026-06-11 is a Thursday; +3 days lands on Sunday, still this (Mon-based) week.
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("So., 09:30 – 10:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the week boundary honours the week-start setting`() {
|
||||
// Today Thu 2026-06-11, event Sun 2026-06-14. With a Sunday-start week the
|
||||
// Thursday's week runs Sun 06-07..Sat 06-13, so 06-14 is already next week
|
||||
// and must render as a date — the opposite of the Monday-start case above.
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
firstDayOfWeek = DayOfWeek.SUNDAY,
|
||||
)
|
||||
assertThat(text).isEqualTo("14.06.2026, 09:30 – 10:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event next week falls back to the exact date, not a weekday`() {
|
||||
// 2026-06-15 is the following Monday — a weekday alone would be ambiguous.
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 15, 9, 30), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 15, 10, 0), berlin),
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("15.06.2026, 09:30 – 10:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timed event crossing midnight includes both dates`() {
|
||||
val text = reminderTimeText(
|
||||
val text = text(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
|
||||
isAllDay = false,
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
)
|
||||
assertThat(text).contains("11.06.2026")
|
||||
assertThat(text).contains("12.06.2026")
|
||||
@@ -61,28 +131,35 @@ class ReminderTimeTextTest {
|
||||
|
||||
@Test
|
||||
fun `all-day single day shows one date, read in UTC`() {
|
||||
val text = reminderTimeText(
|
||||
val text = text(
|
||||
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
||||
isAllDay = true,
|
||||
// Zone must not matter for all-day events: UTC midnight is
|
||||
// 02:00 in Berlin — naive local reading would shift the day.
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("11.06.2026")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day event tomorrow still shows the exact date, no relative prefix`() {
|
||||
val text = text(
|
||||
beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
||||
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
||||
isAllDay = true,
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("12.06.2026")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
|
||||
val text = reminderTimeText(
|
||||
val text = text(
|
||||
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
||||
isAllDay = true,
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
||||
}
|
||||
@@ -90,13 +167,11 @@ class ReminderTimeTextTest {
|
||||
@Test
|
||||
fun `degenerate all-day range never renders an inverted span`() {
|
||||
val day = utcMidnight(LocalDate.of(2026, 6, 11))
|
||||
val text = reminderTimeText(
|
||||
val text = text(
|
||||
beginMillis = day,
|
||||
endMillis = day,
|
||||
isAllDay = true,
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
today = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(text).isEqualTo("11.06.2026")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** Covers the agenda widget's "always show today" anchor logic (#35). */
|
||||
class AnchorTodayTest {
|
||||
|
||||
private val today = LocalDate(2026, 6, 17)
|
||||
private val tomorrow = LocalDate(2026, 6, 18)
|
||||
|
||||
@Test
|
||||
fun `disabled leaves the days untouched even when today is absent`() {
|
||||
val days = listOf(AgendaDay(tomorrow, emptyList()))
|
||||
assertThat(anchorTodayIfMissing(days, today, enabled = false)).isEqualTo(days)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled prepends an empty today when it is missing`() {
|
||||
val days = listOf(AgendaDay(tomorrow, emptyList()))
|
||||
val result = anchorTodayIfMissing(days, today, enabled = true)
|
||||
assertThat(result).hasSize(2)
|
||||
assertThat(result.first()).isEqualTo(AgendaDay(today, emptyList()))
|
||||
assertThat(result[1]).isEqualTo(days.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled anchors today into an otherwise empty list`() {
|
||||
val result = anchorTodayIfMissing(emptyList(), today, enabled = true)
|
||||
assertThat(result).containsExactly(AgendaDay(today, emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled is a no-op when today already has its own day`() {
|
||||
val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList()))
|
||||
assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package de.jeanlucmakiola.calendula.ui.edit
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Focuses on the save-time branch that decides between an in-place update and a
|
||||
* calendar *move* (copy+delete). The provider-level move itself is verified
|
||||
* on-device; here the fake records which repository call the ViewModel chose.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class EventEditViewModelTest {
|
||||
|
||||
private val dispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
|
||||
@AfterEach fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
private val beginMillis = 1_781_164_800_000L
|
||||
private val endMillis = beginMillis + 3_600_000L
|
||||
|
||||
private fun cal(id: Long): CalendarSource = CalendarSource(
|
||||
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
|
||||
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
|
||||
)
|
||||
|
||||
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
|
||||
instance = EventInstance(
|
||||
instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup",
|
||||
start = Instant.fromEpochMilliseconds(beginMillis),
|
||||
end = Instant.fromEpochMilliseconds(endMillis),
|
||||
isAllDay = false, color = 0xFF000000.toInt(), location = null,
|
||||
),
|
||||
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
|
||||
)
|
||||
|
||||
// Pin the DataStore's scope to the test dispatcher so a settings write (e.g.
|
||||
// last-used calendar) completes under advanceUntilIdle instead of on a real
|
||||
// IO thread the virtual clock can't observe.
|
||||
private fun prefs(tempDir: Path): CalendarPrefs = CalendarPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(dispatcher),
|
||||
produceFile = { tempDir.resolve("vm_prefs.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
|
||||
private fun settings(tempDir: Path): SettingsPrefs = SettingsPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(dispatcher),
|
||||
produceFile = { tempDir.resolve("vm_settings.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
|
||||
private fun viewModel(
|
||||
tempDir: Path,
|
||||
fake: FakeCalendarDataSource,
|
||||
): EventEditViewModel {
|
||||
val p = prefs(tempDir)
|
||||
val s = settings(tempDir)
|
||||
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
|
||||
return EventEditViewModel(repo, p, s, dispatcher)
|
||||
}
|
||||
|
||||
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
|
||||
private fun CoroutineScope.activate(vm: EventEditViewModel): Job = launch { vm.state.collect {} }
|
||||
|
||||
@Test
|
||||
fun `changing the calendar routes the save through a move, not an update`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L), cal(2L))
|
||||
eventDetailResult = { detail(calendarId = 1L) }
|
||||
nextInsertId = 99L
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
vm.setCalendar(2L)
|
||||
vm.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.movedEvents).hasSize(1)
|
||||
assertThat(fake.movedEvents.single().eventId).isEqualTo(42L)
|
||||
assertThat(fake.movedEvents.single().targetCalendarId).isEqualTo(2L)
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moving a recurring event skips the scope dialog`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L), cal(2L))
|
||||
eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
vm.setCalendar(2L)
|
||||
vm.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
// No AwaitingScope park: a move is inherently whole-series.
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
|
||||
assertThat(fake.movedEvents).hasSize(1)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing a recurring event without moving still asks for the scope`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L), cal(2L))
|
||||
eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
vm.setTitle("Renamed")
|
||||
vm.save()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.AwaitingScope)
|
||||
assertThat(fake.movedEvents).isEmpty()
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
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]).
|
||||
|
||||
74
fastlane/metadata/android/en-US/changelogs/21500.txt
Normal file
74
fastlane/metadata/android/en-US/changelogs/21500.txt
Normal file
@@ -0,0 +1,74 @@
|
||||
### Added
|
||||
- Show raw calendar colours. Calendula normally softens each calendar and event
|
||||
colour toward a theme-fitting pastel so harsh sync colours read well on both
|
||||
light and dark; a new **Soften calendar colours** setting (Settings → Design,
|
||||
on by default) lets you turn that off and paint the exact colours your calendar
|
||||
source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
|
||||
@leonp5 for the report ([#36]).
|
||||
- Readable titles on dark event colours. An event bar's title now shows in white
|
||||
on a dark colour and near-black on a light one, chosen automatically from the
|
||||
colour's brightness, so a deep blue or purple event is legible at a glance in
|
||||
the busy Week and Month views instead of dark-on-dark. This applies whether or
|
||||
not colours are softened. Thanks to @ptab for the suggestion ([#21]).
|
||||
- A custom snooze duration. The **Snooze duration** setting (Settings →
|
||||
Notifications) gains a **Custom…** option next to the minute presets: pick any
|
||||
amount and switch between minutes and hours, so a snoozed reminder comes back
|
||||
after exactly the delay you want instead of only a preset one ([#40]).
|
||||
- Move an event to another calendar. When editing an existing event, the
|
||||
calendar row is now tappable — pick a different calendar and saving moves the
|
||||
event across, instead of having to delete it and recreate it elsewhere.
|
||||
Recurring series move as a whole, keeping their individually-edited and
|
||||
cancelled occurrences, and any reminders and guests come along too. A calendar
|
||||
can't simply be reassigned underneath an event, so Calendula recreates it on
|
||||
the target and removes the original — the same approach other calendar apps
|
||||
take. Thanks to @prismplex for the suggestion ([#39]).
|
||||
- Open an event straight into the edit form from another app. Calendula already
|
||||
answered the "new event" and "open this event" hand-offs from other apps and
|
||||
widgets; it now also answers the "edit this event" one, so an assistant, task
|
||||
app, or widget can send an existing event to Calendula and land on its edit
|
||||
screen rather than the read-only details. A hand-off with no event attached
|
||||
opens the same prefilled create form as "new event". Calendula also recognises
|
||||
a couple more file labels the same calendar data arrives under (`.vcs`
|
||||
vCalendar files and the `application/ics` type), so opening or sharing those
|
||||
into Calendula works too.
|
||||
- Keep today at the top of the agenda. A new **Always show today** setting
|
||||
(Settings → Agenda, on by default) anchors today as the first entry in both the
|
||||
Agenda screen and its home-screen widget even once nothing is left today —
|
||||
under today's header a "No more events today" note appears — so the first
|
||||
events you see are clearly today's rather than a future day's. Turn it off to
|
||||
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
|
||||
- Duplicate an event. The event details now carry a **Duplicate** action that
|
||||
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
|
||||
a one-off like a shift or an appointment can be recreated by just changing the
|
||||
day and time instead of re-typing every field. The copy keeps the original's
|
||||
time, and its title, location, notes, colour, guests and reminders come along;
|
||||
it's saved as its own single event (any repeat is left off — add one in the
|
||||
editor if you want it). Duplicate works from read-only calendars too, dropping
|
||||
the copy into a writable one. Thanks to @internet-rando for the suggestion
|
||||
([#52]).
|
||||
- French and Polish, in early form. Calendula has started speaking French and
|
||||
Polish, both contributed as community translations through
|
||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
They are partway there, so untranslated parts still show in English until they
|
||||
fill out — you can already pick either under Settings → Language or in Android's
|
||||
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
|
||||
(Polish) for getting them started; help finishing them is very welcome.
|
||||
|
||||
### Fixed
|
||||
- Reminders for events on another day no longer read as if they were today. A
|
||||
reminder fired ahead of time — say, the day before — used to show only the
|
||||
event's time, making it look like it was happening now. The notification now
|
||||
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
|
||||
week, or the date for anything further out. Thanks to @moonj for the report
|
||||
([#46]).
|
||||
- Agenda dates now read in your locale's format. The agenda's range bar and its
|
||||
day headers used a fixed day-month-year layout — and the range span even mixed
|
||||
two orders (e.g. "15 Jul – Aug 13, 2026") — instead of following your language's
|
||||
conventions. Dates across the agenda and its widget now use your locale's own
|
||||
field order, matching the rest of the app. The range bar also no longer repeats
|
||||
the range's name from the selector button beside it, showing just the dates.
|
||||
- Calendar gutters line up with the menu button. The Month view's week-number
|
||||
column, and the Week and Day views' hour labels, sat a few pixels left of the
|
||||
hamburger menu above them; they now line up with it. In Month view the day
|
||||
cells also sit squarely under their weekday letters.
|
||||
|
||||
Submodule floret-kit updated: 78c5fd1fd4...2124227a7f
Reference in New Issue
Block a user