Compare commits
73 Commits
69460bc35a
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 79ad70e0c0 | |||
| f168021f51 | |||
| a2b1e7792b | |||
| c6ce0830b1 | |||
| be13f97802 | |||
| f6d40de832 | |||
| fdee311658 | |||
| 1ff41153e9 | |||
| 49d04bcee0 | |||
| 5e470baf4e | |||
| c8a4f90404 | |||
| 0a89ff9d0e | |||
| 0718afd5a6 | |||
| 84e6402031 | |||
| 7fe59b36c3 | |||
| 8881559e67 | |||
| 9f7e93d5f8 | |||
| 3863e74857 | |||
| 44d056f31f | |||
| 5c513a1b19 | |||
| 037d05b171 | |||
| 86d4e11584 | |||
| 67a07a9346 | |||
| b8a420bdab | |||
| 442697d57c | |||
| 8e3a17fdc3 | |||
| e6dd627c85 | |||
|
|
5844539db2 | ||
|
|
2cc83d7714 | ||
|
|
dada1bbe72 | ||
|
|
e061cbe127 | ||
| 262b2b8273 | |||
| efca0c86c4 | |||
| 08cd95cfba | |||
| bfc9a0db39 | |||
| 08905990cc | |||
| b5895b190e | |||
| 37f0e22911 | |||
| 4e125e58d5 | |||
| 4c26450ad4 | |||
| 52816b327a | |||
| cbb241bc4d | |||
| e5aa90bdd6 | |||
| 580dc5a669 | |||
| b9e800e9fc | |||
| 40d70d9f2b | |||
| 14b19cd902 | |||
| 0fd579b19b | |||
| 030c3e6ce1 | |||
| e67960d352 | |||
| 22bbfef3e2 | |||
| 4da9abe05b | |||
| 4540faf2a0 | |||
| 3aecb19a0c | |||
| f7c0b8a957 | |||
| 9a8d9b2177 | |||
| 93ca38d974 | |||
| a38093cae3 | |||
|
|
67511ce9be | ||
|
|
2e90682874 | ||
|
|
2a4cb6a896 | ||
| 8d530c5681 | |||
| 225f4c3491 | |||
| 11578a5588 | |||
| 5d887524b1 | |||
| 405ff16233 | |||
| 048f407ba1 | |||
| a89560953d | |||
| 4e498de051 | |||
| f2fb3d6279 | |||
| c2d88e744e | |||
| 2ae4c818ba | |||
| 25ff7ce7ca |
81
CHANGELOG.md
81
CHANGELOG.md
@@ -5,6 +5,78 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.13.0] — 2026-07-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Calendula now speaks Spanish and Italian. Both arrived as community
|
||||||
|
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
|
||||||
|
— a **huge thank you** to
|
||||||
|
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
|
||||||
|
translation and to
|
||||||
|
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
|
||||||
|
one! Pick your language under Settings → Language or in Android's per-app
|
||||||
|
language settings. Strings added in this very release may still show in
|
||||||
|
English until the translations catch up. Want Calendula in your language?
|
||||||
|
Translating happens entirely in the browser — every contribution is welcome.
|
||||||
|
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
||||||
|
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
||||||
|
birthdays, anniversaries and other dates into local "Birthdays",
|
||||||
|
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
|
||||||
|
change. Each is a normal local calendar, so you set its colour, visibility and
|
||||||
|
reminders the usual way — new birthdays even start with a reminder a week before
|
||||||
|
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
|
||||||
|
This is the first feature to use the contacts permission: it is requested only
|
||||||
|
when you turn the feature on, everything stays on your device (Calendula has no
|
||||||
|
internet access), and your contacts are only ever read, never changed. Thanks
|
||||||
|
to @moonj for the suggestion ([#15]).
|
||||||
|
- Set more than one default reminder per calendar. A calendar's default
|
||||||
|
reminders — and the global defaults under Settings → Notifications — can now
|
||||||
|
hold several lead times instead of just one, so new events can start with, say,
|
||||||
|
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||||
|
multi-select; per-calendar overrides can still inherit the global default or
|
||||||
|
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||||
|
- Widget headers now open the app. Tapping the month/year title on the month
|
||||||
|
widget opens the app on the month view, and tapping the "Upcoming" title on
|
||||||
|
the agenda widget opens it on your default view — so there's a one-tap way
|
||||||
|
back into the app that lands where you'd expect, instead of only through a day
|
||||||
|
or event. On the month widget, tapping anywhere on a day — not just the small
|
||||||
|
date number — now opens that day, and the "today" button snaps the grid back
|
||||||
|
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
||||||
|
suggestions ([#18], [#20]).
|
||||||
|
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
|
||||||
|
and **Body font** pickers: keep the system default, choose a bundled face
|
||||||
|
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
|
||||||
|
face), or load any `.ttf`/`.otf` file from your device, independently per
|
||||||
|
role. Font size and colour stay with Android's accessibility scaling and the
|
||||||
|
app theme, as discussed on the issue. Thanks to @abrossimow for the
|
||||||
|
suggestion ([#19]).
|
||||||
|
- Choose what the view-switch button cycles through. Settings → Appearance now
|
||||||
|
lets you pick which views the top-right quick-switch button rotates between
|
||||||
|
and drag them into your preferred order; views you switch off stay reachable
|
||||||
|
from the navigation menu, which can be reordered the same way. Thanks to
|
||||||
|
@abrossimow for the suggestion ([#24]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Month widget arrows and "today" button work again. On release builds the
|
||||||
|
prev/next-month arrows and the jump-to-today control on the month widget did
|
||||||
|
nothing when tapped — code shrinking had stripped the tap handlers behind
|
||||||
|
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
|
||||||
|
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
||||||
|
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||||
|
popping up — matching how a disabled calendar's events already stay hidden
|
||||||
|
everywhere else in the app ([#17]).
|
||||||
|
- Editing a single occurrence of a recurring event works again. Choosing **Only
|
||||||
|
this event** and saving a change to one event in a repeating series silently
|
||||||
|
did nothing — the change was rejected and the edit form simply reappeared with
|
||||||
|
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||||
|
lands on just that one event and leaves the rest of the series untouched
|
||||||
|
([#16]).
|
||||||
|
- Every calendar can be picked when creating an event. The event editor's
|
||||||
|
calendar picker was a fixed-height dialog, so with many calendars anything
|
||||||
|
past the first nine or so was simply unreachable. It is now a full-screen,
|
||||||
|
scrollable list grouped by account, with each calendar's colour shown.
|
||||||
|
Thanks to @dschuermann for the report ([#29]).
|
||||||
|
|
||||||
## [2.12.0] — 2026-06-28
|
## [2.12.0] — 2026-06-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -729,3 +801,12 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
|
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
|
||||||
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||||
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
||||||
|
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
||||||
|
[#15]: https://codeberg.org/jlmakiola/calendula/issues/15
|
||||||
|
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||||
|
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||||
|
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||||
|
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||||
|
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||||
|
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
|
||||||
|
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21200
|
versionCode = 21300
|
||||||
versionName = "2.12.0"
|
versionName = "2.13.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -90,6 +90,8 @@ android {
|
|||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
// BuildConfig.DEBUG gates the in-app debug ribbon (see DebugRibbon).
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
||||||
|
|||||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -16,6 +16,18 @@
|
|||||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||||
-dontwarn androidx.room.paging.**
|
-dontwarn androidx.room.paging.**
|
||||||
|
|
||||||
|
# Glance runs an @Composable's `actionRunCallback<T>()` by persisting the
|
||||||
|
# callback's fully-qualified class name into the click PendingIntent, then
|
||||||
|
# reflectively instantiating it (Class.forName(name).newInstance()) when the tap
|
||||||
|
# fires. Under R8 full mode (AGP 9 default) these ActionCallback classes — only
|
||||||
|
# ever referenced reflectively — get renamed or have their no-arg constructor
|
||||||
|
# stripped, so the lookup fails silently and the tap does nothing. In the month
|
||||||
|
# and agenda widgets that broke every run-callback control (the prev/next/today
|
||||||
|
# month arrows and the agenda refresh) in release builds while actionStartActivity
|
||||||
|
# taps, which ride a PendingIntent and need no reflection, kept working. Keep
|
||||||
|
# every ActionCallback's name and constructor intact.
|
||||||
|
-keep class * implements androidx.glance.appwidget.action.ActionCallback { <init>(...); }
|
||||||
|
|
||||||
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
|
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
|
||||||
# the fully-qualified class name persisted in the WorkSpec, so the class must
|
# the fully-qualified class name persisted in the WorkSpec, so the class must
|
||||||
# keep both its name and a no-arg constructor. Glance renders every widget
|
# keep both its name and a no-arg constructor. Glance renders every widget
|
||||||
|
|||||||
10
app/src/debug/res/values/colors.xml
Normal file
10
app/src/debug/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Debug-only launcher-icon background. Production is slate (#5C6B7A); the
|
||||||
|
debug build paints the adaptive-icon background burnt orange instead, so the
|
||||||
|
debug icon reads at a glance as "not the real app" on the home screen. The
|
||||||
|
off-white foreground mark contrasts on both. See drawable/ic_launcher_background.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFB23B00</color>
|
||||||
|
</resources>
|
||||||
10
app/src/debug/res/values/strings.xml
Normal file
10
app/src/debug/res/values/strings.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Debug-build resource overrides. Merged on top of src/main for the `debug`
|
||||||
|
build type only (release/releaseTest keep the production values), so the
|
||||||
|
debug app is unmistakable on the launcher: its own label, alongside the
|
||||||
|
real app thanks to the `.debug` applicationId suffix.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Calendula Debug</string>
|
||||||
|
</resources>
|
||||||
@@ -5,6 +5,15 @@
|
|||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!--
|
||||||
|
Optional and feature-gated: only the "Contact special dates" feature reads
|
||||||
|
contacts, and only after the user enables it and grants this at runtime
|
||||||
|
(never requested at startup). Everything stays offline — birthdays and
|
||||||
|
other contact dates are mirrored one-way into local calendars; contacts
|
||||||
|
are never written and nothing leaves the device (the app has no INTERNET
|
||||||
|
permission). See docs/design/contact-special-dates.md.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||||
<!--
|
<!--
|
||||||
Lets the "Reliable delivery" setting open the direct system dialog to
|
Lets the "Reliable delivery" setting open the direct system dialog to
|
||||||
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import dagger.hilt.android.EntryPointAccessors
|
|||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -25,6 +27,7 @@ class CalendulaApp : Application() {
|
|||||||
// respecting, on-device; the user submits the report by hand).
|
// respecting, on-device; the user submits the report by hand).
|
||||||
CrashReporter.install(this)
|
CrashReporter.install(this)
|
||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
|
reconcileSpecialDates()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,4 +47,21 @@ class CalendulaApp : Application() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
||||||
|
* settings on every launch — like [reconcileAutoBackup], this cancels
|
||||||
|
* orphaned work after the feature is turned off and re-schedules after a
|
||||||
|
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
|
||||||
|
* refresh, so no immediate run is needed here.
|
||||||
|
*/
|
||||||
|
private fun reconcileSpecialDates() {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
|
SpecialDatesScheduler.apply(
|
||||||
|
context = this@CalendulaApp,
|
||||||
|
enabled = deps.settingsPrefs().specialDatesEnabled.first(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -29,11 +30,15 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
@@ -97,23 +102,41 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val use24Hour = remember(settings.timeFormat, context) {
|
val use24Hour = remember(settings.timeFormat, context) {
|
||||||
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||||
}
|
}
|
||||||
|
// The user's custom-font choice, resolved to a Material typography
|
||||||
|
// (issue #19). Recomputed only when a token — or the custom-font
|
||||||
|
// re-import stamp AppFontSettings carries, so replacing the file
|
||||||
|
// behind an active "custom" token still refreshes — changes;
|
||||||
|
// "system for both" returns the default scale untouched.
|
||||||
|
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||||
|
val typography = remember(fonts, context) {
|
||||||
|
calendulaTypography(
|
||||||
|
brand = resolveFontFamily(fonts.brand, FontRole.BRAND, context),
|
||||||
|
plain = resolveFontFamily(fonts.plain, FontRole.PLAIN, context),
|
||||||
|
)
|
||||||
|
}
|
||||||
CalendulaTheme(
|
CalendulaTheme(
|
||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = settings.dynamicColor,
|
||||||
|
typography = typography,
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
LocalUse24HourFormat provides use24Hour,
|
CompositionLocalProvider(
|
||||||
LocalShowHourLines provides settings.showHourLines,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
) {
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
RootScreen(
|
) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
RootScreen(
|
||||||
requestedDetailKey = requestedDetailKey,
|
modifier = Modifier.fillMaxSize(),
|
||||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
requestedDetailKey = requestedDetailKey,
|
||||||
widgetNavRequest = requestedNav,
|
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||||
onWidgetNavConsumed = { requestedNav = null },
|
widgetNavRequest = requestedNav,
|
||||||
requestedImportUri = requestedImportUri,
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
onImportConsumed = { requestedImportUri = null },
|
requestedImportUri = requestedImportUri,
|
||||||
)
|
onImportConsumed = { requestedImportUri = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// A persistent corner marker so a debug build is never
|
||||||
|
// mistaken for the production app; compiled out of release.
|
||||||
|
if (BuildConfig.DEBUG) DebugRibbon()
|
||||||
}
|
}
|
||||||
pendingCrashReport?.let { report ->
|
pendingCrashReport?.let { report ->
|
||||||
CrashReportDialog(
|
CrashReportDialog(
|
||||||
@@ -186,6 +209,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// An external date tap (launcher/clock) has no widget source, so it opens
|
// An external date tap (launcher/clock) has no widget source, so it opens
|
||||||
// the day view rooted over the default home view (OpenDate source = null).
|
// the day view rooted over the default home view (OpenDate source = null).
|
||||||
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
||||||
|
// A widget header tap: open a top-level view with no date drill-in. The
|
||||||
|
// empty string carried by [openViewIntent] means "the default home view".
|
||||||
|
if (hasExtra(EXTRA_OPEN_VIEW)) {
|
||||||
|
val name = getStringExtra(EXTRA_OPEN_VIEW).orEmpty()
|
||||||
|
return WidgetNavRequest.OpenView(CalendarView.entries.firstOrNull { it.name == name })
|
||||||
|
}
|
||||||
val source = sourceViewOrNull()
|
val source = sourceViewOrNull()
|
||||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
return when {
|
return when {
|
||||||
@@ -237,6 +266,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||||
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
||||||
|
|
||||||
|
// A widget header tap asking to open a top-level view (no date drill-in).
|
||||||
|
// Its value is the target [CalendarView] name, or "" for the default view.
|
||||||
|
private const val EXTRA_OPEN_VIEW = "de.jeanlucmakiola.calendula.extra.OPEN_VIEW"
|
||||||
|
|
||||||
// The [CalendarView] (by name) of the widget a launch came from. Roots the
|
// The [CalendarView] (by name) of the widget a launch came from. Roots the
|
||||||
// in-app back stack in that view; absent for non-widget launches (reminders).
|
// in-app back stack in that view; absent for non-widget launches (reminders).
|
||||||
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
|
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
|
||||||
@@ -300,5 +333,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the app on a top-level [view] with no date drill-in — a widget
|
||||||
|
* header tap. A null [view] opens the user's default home view (the agenda
|
||||||
|
* widget's "Upcoming" title); a concrete view roots there over the default
|
||||||
|
* home (the month widget's month/year title → [CalendarView.Month]). The
|
||||||
|
* per-view data URI keeps distinct headers' PendingIntents from collapsing.
|
||||||
|
*/
|
||||||
|
fun openViewIntent(context: Context, view: CalendarView?): Intent =
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = "calendula://view/${view?.name ?: "default"}".toUri()
|
||||||
|
putExtra(EXTRA_OPEN_VIEW, view?.name ?: "")
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,27 @@ internal fun toProviderAllDayMinutes(
|
|||||||
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next date on or after [today] carrying [month]/[day] — the date to sample
|
||||||
|
* a yearly all-day reminder's UTC offset at. A managed birthday's DTSTART is an
|
||||||
|
* ancient anchor (1972 for year-less dates), a year whose timezone rules
|
||||||
|
* (pre-DST offsets) differ from today's; sampling the offset there skews every
|
||||||
|
* modern occurrence by hours. Sampling at the upcoming occurrence makes the
|
||||||
|
* stored offset correct for it and its neighbours (only ±1h DST drift remains,
|
||||||
|
* the inherent limit). Feb-29 skips forward to the next leap year.
|
||||||
|
*/
|
||||||
|
internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): LocalDate {
|
||||||
|
var year = today.year
|
||||||
|
// A Feb-29 date is valid only every ~4 years; 8 tries always reaches one.
|
||||||
|
repeat(8) {
|
||||||
|
val candidate = runCatching { LocalDate.of(year, month, day) }.getOrNull()
|
||||||
|
if (candidate != null && !candidate.isBefore(today)) return candidate
|
||||||
|
year++
|
||||||
|
}
|
||||||
|
// Unreachable for real month/day inputs; fall back to the raw anchor.
|
||||||
|
return LocalDate.of(today.year, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||||
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
||||||
|
* `EVENT_COLOR`. The named entries exist for callers that need one specific
|
||||||
|
* hue (the managed special-dates calendars), so they can't drift from the
|
||||||
|
* swatches offered in the colour picker.
|
||||||
|
*/
|
||||||
|
object CalendarColorPalette {
|
||||||
|
val Red = 0xFFD50000.toInt()
|
||||||
|
val Orange = 0xFFE67C00.toInt()
|
||||||
|
val Amber = 0xFFF6BF26.toInt()
|
||||||
|
val Green = 0xFF33B679.toInt()
|
||||||
|
val DarkGreen = 0xFF0B8043.toInt()
|
||||||
|
val Blue = 0xFF039BE5.toInt()
|
||||||
|
val Indigo = 0xFF3F51B5.toInt()
|
||||||
|
val Purple = 0xFF8E24AA.toInt()
|
||||||
|
val Graphite = 0xFF616161.toInt()
|
||||||
|
|
||||||
|
/** The full palette, in swatch-row order. */
|
||||||
|
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
||||||
|
}
|
||||||
@@ -24,10 +24,14 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -97,6 +101,55 @@ interface CalendarDataSource {
|
|||||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
fun deleteCalendar(id: Long)
|
fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a local calendar tagged as the special-dates mirror for [type]
|
||||||
|
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
||||||
|
* local calendar, so it inherits per-calendar colour/visibility/reminders.
|
||||||
|
*/
|
||||||
|
fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's special-dates calendars, found by their `CAL_SYNC2` marker.
|
||||||
|
* Lets the mirror re-adopt its calendars after the stored ids are lost, and
|
||||||
|
* detect when the user deleted one in system settings.
|
||||||
|
*/
|
||||||
|
fun findManagedCalendars(): List<ManagedCalendarRow>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The managed events in [calendarId] (those carrying a `UID_2445`, not
|
||||||
|
* soft-deleted) as [ManagedEventRow]s — the existing side of the sync diff.
|
||||||
|
*/
|
||||||
|
fun queryManagedEvents(calendarId: Long): List<ManagedEventRow>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a managed event carrying the deterministic [uid] in `UID_2445`;
|
||||||
|
* seeds its reminder rows from [form] (never touched again by sync). Returns
|
||||||
|
* the new `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
|
*/
|
||||||
|
fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite exactly the given managed columns on [eventId] (keys are
|
||||||
|
* `CalendarContract.Events` columns; a null value clears the column). A
|
||||||
|
* bare, targeted update — it never reconciles reminders or attendees, so
|
||||||
|
* user-owned data on the event survives every sync. No-ops on an empty map.
|
||||||
|
*/
|
||||||
|
fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the reminders on **every managed** event in [calendarId] with
|
||||||
|
* [minutes] (all-day lead times), each encoded so it fires at
|
||||||
|
* [allDayReminderTimeMinutes] on its upcoming occurrence. Used by the
|
||||||
|
* special-dates section so a reminder change applies to existing events too,
|
||||||
|
* not just future ones — the managed calendars treat reminders as a
|
||||||
|
* calendar-level setting. Skips any non-managed (user) event in the calendar.
|
||||||
|
*/
|
||||||
|
fun applyManagedCalendarReminders(
|
||||||
|
calendarId: Long,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
minutes: List<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
||||||
* (minutes from local midnight) is the wall-clock time all-day reminders
|
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||||
@@ -292,6 +345,140 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||||
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
|
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
|
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
|
||||||
|
put(CalendarContract.Calendars.NAME, name)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
|
||||||
|
put(
|
||||||
|
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
||||||
|
CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||||
|
)
|
||||||
|
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||||
|
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||||
|
// The marker that identifies this as our special-dates calendar,
|
||||||
|
// independent of the (user-editable) display name. CAL_SYNC1 already
|
||||||
|
// holds the description, so the marker lives in CAL_SYNC2.
|
||||||
|
put(MANAGED_MARKER_COLUMN, markerFor(type))
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(localCalendarsUri(), values)
|
||||||
|
?: throw WriteFailedException("create managed calendar")
|
||||||
|
return ContentUris.parseId(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
|
||||||
|
CalendarContract.Calendars.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Calendars._ID, MANAGED_MARKER_COLUMN),
|
||||||
|
"${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " +
|
||||||
|
"${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " +
|
||||||
|
"$MANAGED_MARKER_COLUMN IS NOT NULL",
|
||||||
|
arrayOf(CalendarContract.ACCOUNT_TYPE_LOCAL, LOCAL_ACCOUNT_NAME),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val type = typeForMarker(c.getString(1)) ?: continue
|
||||||
|
add(ManagedCalendarRow(id = c.getLong(0), type = type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> = resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.Events._ID,
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
CalendarContract.Events.TITLE,
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
),
|
||||||
|
// Only our own mirror events (UID prefix), so a user event that somehow
|
||||||
|
// landed in this calendar (e.g. an .ics import) is never seen as
|
||||||
|
// "existing but not desired" and deleted by the diff.
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
|
"${CalendarContract.Events.UID_2445} LIKE ?",
|
||||||
|
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val uid = c.getString(1)?.takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
add(
|
||||||
|
ManagedEventRow(
|
||||||
|
eventId = c.getLong(0),
|
||||||
|
uid = uid,
|
||||||
|
title = c.getString(2).orEmpty(),
|
||||||
|
dtStartMillis = c.getLong(3),
|
||||||
|
rrule = c.getString(4)?.takeIf { it.isNotEmpty() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
|
||||||
|
// Deterministic UID = the reconciliation key. A re-sync reads it back
|
||||||
|
// (queryManagedEvents) and diffs on it, so a contact never doubles.
|
||||||
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
|
val values = buildEventInsertValues(form, uid, times)
|
||||||
|
val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||||
|
?.let(ContentUris::parseId)
|
||||||
|
?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}")
|
||||||
|
// Seeded once, then never re-touched by sync — so a user who moves a
|
||||||
|
// birthday reminder keeps their change. Encoded against the upcoming
|
||||||
|
// occurrence, not the ancient DTSTART anchor (wrong timezone offset).
|
||||||
|
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
insertReminderRows(
|
||||||
|
eventId,
|
||||||
|
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes),
|
||||||
|
)
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
||||||
|
if (values.isEmpty()) return
|
||||||
|
val rows = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
values.toContentValues(),
|
||||||
|
null, null,
|
||||||
|
)
|
||||||
|
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyManagedCalendarReminders(
|
||||||
|
calendarId: Long,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
minutes: List<Int>,
|
||||||
|
) {
|
||||||
|
resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
|
||||||
|
// Only our own mirror events, so a stray user event in this calendar
|
||||||
|
// isn't all-day-re-encoded or stripped of its own reminders.
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
|
"${CalendarContract.Events.UID_2445} LIKE ?",
|
||||||
|
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val eventId = c.getLong(0)
|
||||||
|
// Managed events are all-day: DTSTART is a UTC midnight anchor.
|
||||||
|
val anchor = Instant.ofEpochMilli(c.getLong(1))
|
||||||
|
.atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
reconcileReminders(
|
||||||
|
eventId,
|
||||||
|
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||||
ensureObserversRegistered()
|
ensureObserversRegistered()
|
||||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
@@ -575,35 +762,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
.distinct()
|
.distinct()
|
||||||
|
|
||||||
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
||||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
// A globally-unique UID so a later .ics backup/restore can identify
|
||||||
val values = ContentValues().apply {
|
// the event and not duplicate it on re-import (the provider leaves
|
||||||
put(
|
// this null for events it didn't sync). Older rows without one fall
|
||||||
CalendarContract.Events.CALENDAR_ID,
|
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||||
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
val values = buildEventInsertValues(
|
||||||
)
|
form,
|
||||||
// A globally-unique UID so a later .ics backup/restore can identify
|
uid = "${UUID.randomUUID()}@calendula",
|
||||||
// the event and not duplicate it on re-import (the provider leaves
|
times = form.toWriteTimes(ZoneId.systemDefault()),
|
||||||
// this null for events it didn't sync). Older rows without one fall
|
).toContentValues().apply {
|
||||||
// back to a stable synthesised UID at export time (deriveIcsUid).
|
|
||||||
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
|
|
||||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
|
||||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
|
||||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
|
||||||
// The provider's invariant: recurring rows carry RRULE+DURATION
|
|
||||||
// (and no DTEND), one-off rows carry DTEND.
|
|
||||||
if (form.rrule == null) {
|
|
||||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
|
||||||
} else {
|
|
||||||
put(CalendarContract.Events.RRULE, form.rrule)
|
|
||||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
|
||||||
}
|
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
|
||||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
|
||||||
form.location.trim().takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
|
||||||
form.description.trim().takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
|
||||||
// A null colour just leaves both columns unset (the event inherits
|
// A null colour just leaves both columns unset (the event inherits
|
||||||
// its calendar's colour), so only the key/raw cases are written.
|
// its calendar's colour), so only the key/raw cases are written.
|
||||||
when {
|
when {
|
||||||
@@ -615,19 +782,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||||
val eventId = ContentUris.parseId(uri)
|
val eventId = ContentUris.parseId(uri)
|
||||||
// Best effort (spec §8): the event exists at this point — a reminder
|
seedReminders(eventId, form, allDayReminderTimeMinutes)
|
||||||
// that fails to attach is logged, not surfaced as a failed create.
|
|
||||||
encodedReminders(form, allDayReminderTimeMinutes)
|
|
||||||
.forEach { minutes ->
|
|
||||||
val reminder = ContentValues().apply {
|
|
||||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
|
||||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
|
||||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
|
||||||
}
|
|
||||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
|
||||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Guests are best-effort like reminders: a row that fails to attach is
|
// Guests are best-effort like reminders: a row that fails to attach is
|
||||||
// logged, not surfaced as a failed create. Calendula never sends an
|
// logged, not surfaced as a failed create. Calendula never sends an
|
||||||
// invitation — it only writes the rows; the backend decides delivery.
|
// invitation — it only writes the rows; the backend decides delivery.
|
||||||
@@ -635,6 +790,47 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach [form]'s reminders to a freshly inserted event. Best effort
|
||||||
|
* (spec §8): the event exists at this point — a reminder that fails to
|
||||||
|
* attach is logged, not surfaced as a failed create.
|
||||||
|
*/
|
||||||
|
private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) =
|
||||||
|
insertReminderRows(eventId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||||
|
|
||||||
|
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
|
||||||
|
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
|
||||||
|
encodedMinutes.forEach { minutes ->
|
||||||
|
val reminder = ContentValues().apply {
|
||||||
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
|
}
|
||||||
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||||
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A managed all-day event's reminders, encoded so they fire at
|
||||||
|
* [allDayReminderTimeMinutes] on its **upcoming** occurrence. The event's
|
||||||
|
* DTSTART is an ancient recurrence anchor (see [nextYearlyOccurrence]); using
|
||||||
|
* it directly would sample a stale timezone offset, so the offset is sampled
|
||||||
|
* at the next occurrence of the anchor's month/day instead.
|
||||||
|
*/
|
||||||
|
private fun managedAllDayReminderMinutes(
|
||||||
|
anchor: LocalDate,
|
||||||
|
reminders: List<Int>,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): List<Int> {
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val onDate = nextYearlyOccurrence(anchor.monthValue, anchor.dayOfMonth, LocalDate.now(zone))
|
||||||
|
return reminders
|
||||||
|
.map { toProviderAllDayMinutes(it, onDate, zone, allDayReminderTimeMinutes) }
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
override fun updateEvent(
|
override fun updateEvent(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
@@ -1038,6 +1234,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
|
||||||
|
private fun markerFor(type: SpecialDateType): String = when (type) {
|
||||||
|
SpecialDateType.Birthday -> "$MANAGED_MARKER_PREFIX:birthday"
|
||||||
|
SpecialDateType.Anniversary -> "$MANAGED_MARKER_PREFIX:anniversary"
|
||||||
|
SpecialDateType.Custom -> "$MANAGED_MARKER_PREFIX:custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of [markerFor]; null for a value that isn't one of ours. */
|
||||||
|
private fun typeForMarker(marker: String?): SpecialDateType? =
|
||||||
|
SpecialDateType.entries.firstOrNull { markerFor(it) == marker }
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "CalendarDataSource"
|
const val TAG = "CalendarDataSource"
|
||||||
|
|
||||||
@@ -1047,6 +1254,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column and namespace for the special-dates marker (shared with the
|
||||||
|
* calendar projection, which reads it into [CalendarSource.isManaged]).
|
||||||
|
* CAL_SYNC1 already holds the local-calendar description, so it uses CAL_SYNC2.
|
||||||
|
*/
|
||||||
|
val MANAGED_MARKER_COLUMN: String = CalendarProjection.MANAGED_MARKER_COLUMN
|
||||||
|
const val MANAGED_MARKER_PREFIX = CalendarProjection.MANAGED_MARKER_PREFIX
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far ahead/behind a search looks for a recurring event's nearest
|
* How far ahead/behind a search looks for a recurring event's nearest
|
||||||
* occurrence (~2 years). Wide enough for everyday series; a series that
|
* occurrence (~2 years). Wide enough for everyday series; a series that
|
||||||
|
|||||||
@@ -24,5 +24,12 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
// A special-dates mirror calendar, recognised by its durable CAL_SYNC2
|
||||||
|
// marker (only meaningful on the local calendars the app owns). This is
|
||||||
|
// the source of truth for the editor lock — independent of any stored
|
||||||
|
// preference that a backup restore could have wiped.
|
||||||
|
isManaged = isLocal &&
|
||||||
|
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
||||||
|
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,42 @@ internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (
|
|||||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column values for a brand-new Events row: identity, times (the provider's
|
||||||
|
* invariant — recurring rows carry RRULE+DURATION and no DTEND, one-off rows
|
||||||
|
* carry DTEND), availability/access level and trimmed optional text. Shared by
|
||||||
|
* the user-event and managed-event insert paths, which differ only in the
|
||||||
|
* [uid] they stamp; colour and attendees are user-event concerns the caller
|
||||||
|
* layers on top.
|
||||||
|
*/
|
||||||
|
internal fun buildEventInsertValues(
|
||||||
|
form: EventForm,
|
||||||
|
uid: String,
|
||||||
|
times: EventWriteTimes,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||||
|
)
|
||||||
|
put(CalendarContract.Events.UID_2445, uid)
|
||||||
|
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
|
if (form.rrule == null) {
|
||||||
|
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||||
|
} else {
|
||||||
|
put(CalendarContract.Events.RRULE, form.rrule)
|
||||||
|
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||||
|
}
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
|
form.location.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
form.description.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dirty-checked column values for updating an existing Events row: only what
|
* Dirty-checked column values for updating an existing Events row: only what
|
||||||
* the user actually changed is written, so untouched fields can't stomp
|
* the user actually changed is written, so untouched fields can't stomp
|
||||||
@@ -118,8 +154,14 @@ internal fun buildEventUpdateValues(
|
|||||||
* provider clone the series row and apply these on top. Unlike the series
|
* provider clone the series row and apply these on top. Unlike the series
|
||||||
* update there is no dirty check — the exception is a fresh row, so every
|
* update there is no dirty check — the exception is a fresh row, so every
|
||||||
* form-backed column is written (empty optionals as explicit NULLs, since the
|
* form-backed column is written (empty optionals as explicit NULLs, since the
|
||||||
* clone starts from the parent's values). An exception is a single event:
|
* clone starts from the parent's values).
|
||||||
* DTEND, never RRULE/DURATION.
|
*
|
||||||
|
* The occurrence's length travels as DURATION, never DTEND: the provider
|
||||||
|
* rejects DTEND on an exception outright (`CalendarProvider2`:
|
||||||
|
* "Exceptions can't overwrite dtend") and derives the instance end from
|
||||||
|
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
|
||||||
|
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
|
||||||
|
* "only this event" fail on-device (Codeberg #16).
|
||||||
*/
|
*/
|
||||||
internal fun buildOccurrenceExceptionValues(
|
internal fun buildOccurrenceExceptionValues(
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
@@ -131,7 +173,7 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of the app's special-dates calendars, discovered by the marker stamped in
|
||||||
|
* its `CAL_SYNC2` column — so the mirror can re-adopt its calendars even if the
|
||||||
|
* stored ids in preferences were lost (e.g. an app-data wipe).
|
||||||
|
*/
|
||||||
|
data class ManagedCalendarRow(val id: Long, val type: SpecialDateType)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A managed event as read back for the sync diff. [uid] is the deterministic
|
||||||
|
* `Events.UID_2445` (`contact-<type>:<lookupKey>@calendula`) the mirror keys on;
|
||||||
|
* [title], [dtStartMillis] and [rrule] are the managed columns compared against
|
||||||
|
* the desired state to decide whether a targeted update is needed.
|
||||||
|
*/
|
||||||
|
data class ManagedEventRow(
|
||||||
|
val eventId: Long,
|
||||||
|
val uid: String,
|
||||||
|
val title: String,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val rrule: String?,
|
||||||
|
)
|
||||||
@@ -15,9 +15,17 @@ internal object CalendarProjection {
|
|||||||
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
|
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
|
||||||
// so the mapper only reads it for local calendars).
|
// so the mapper only reads it for local calendars).
|
||||||
DESCRIPTION_COLUMN,
|
DESCRIPTION_COLUMN,
|
||||||
|
// The special-dates marker (CAL_SYNC2) — the durable identity the app
|
||||||
|
// uses to recognise its own managed calendars, independent of any
|
||||||
|
// stored preference id (which a backup restore / data wipe can lose).
|
||||||
|
MANAGED_MARKER_COLUMN,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
||||||
|
const val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
|
||||||
|
|
||||||
|
/** Namespace prefix of every managed-calendar [MANAGED_MARKER_COLUMN] value. */
|
||||||
|
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
const val IDX_DISPLAY_NAME = 1
|
const val IDX_DISPLAY_NAME = 1
|
||||||
@@ -27,6 +35,7 @@ internal object CalendarProjection {
|
|||||||
const val IDX_VISIBLE = 5
|
const val IDX_VISIBLE = 5
|
||||||
const val IDX_ACCESS_LEVEL = 6
|
const val IDX_ACCESS_LEVEL = 6
|
||||||
const val IDX_DESCRIPTION = 7
|
const val IDX_DESCRIPTION = 7
|
||||||
|
const val IDX_MANAGED_MARKER = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object InstanceProjection {
|
internal object InstanceProjection {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the localized calendar names/templates and per-type colours the
|
||||||
|
* mirror creates its calendars with. The colours are picked from the shared
|
||||||
|
* palette so managed calendars look native alongside user calendars.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AndroidSpecialDatesCalendarSpec @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : SpecialDatesCalendarSpec {
|
||||||
|
|
||||||
|
override fun displayName(type: SpecialDateType): String = context.getString(
|
||||||
|
when (type) {
|
||||||
|
SpecialDateType.Birthday -> R.string.special_dates_calendar_birthday
|
||||||
|
SpecialDateType.Anniversary -> R.string.special_dates_calendar_anniversary
|
||||||
|
SpecialDateType.Custom -> R.string.special_dates_calendar_custom
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun defaultTitleTemplate(type: SpecialDateType): String = context.getString(
|
||||||
|
when (type) {
|
||||||
|
SpecialDateType.Birthday -> R.string.special_dates_default_title_birthday
|
||||||
|
SpecialDateType.Anniversary -> R.string.special_dates_default_title_anniversary
|
||||||
|
SpecialDateType.Custom -> R.string.special_dates_default_title_custom
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun color(type: SpecialDateType): Int = when (type) {
|
||||||
|
SpecialDateType.Birthday -> CalendarColorPalette.Purple
|
||||||
|
SpecialDateType.Anniversary -> CalendarColorPalette.Red
|
||||||
|
SpecialDateType.Custom -> CalendarColorPalette.Blue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import android.provider.ContactsContract.CommonDataKinds.Event
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the dated `Event` rows (birthdays, anniversaries, custom dates) of the
|
||||||
|
* device's contacts. Read-only and offline — the one-way source for the
|
||||||
|
* special-dates mirror. Requires `READ_CONTACTS`; returns an empty list when
|
||||||
|
* the permission is absent so the sync can degrade to a stalled state rather
|
||||||
|
* than crash.
|
||||||
|
*/
|
||||||
|
interface ContactSpecialDatesDataSource {
|
||||||
|
fun hasPermission(): Boolean
|
||||||
|
|
||||||
|
/** All usable contact special-dates, deduplicated per contact and type. */
|
||||||
|
fun readSpecialDates(): List<ContactSpecialDate>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
|
||||||
|
fun Context.hasContactsPermission(): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
||||||
|
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
||||||
|
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
|
||||||
|
Event.TYPE_ANNIVERSARY -> SpecialDateType.Anniversary
|
||||||
|
else -> SpecialDateType.Custom
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AndroidContactSpecialDatesDataSource @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ContactSpecialDatesDataSource {
|
||||||
|
|
||||||
|
override fun hasPermission(): Boolean = context.hasContactsPermission()
|
||||||
|
|
||||||
|
override fun readSpecialDates(): List<ContactSpecialDate> {
|
||||||
|
if (!hasPermission()) return emptyList()
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
// A contact can carry the same date more than once (multiple raw contacts
|
||||||
|
// under one aggregate); dedup on the mirror's reconciliation key so exact
|
||||||
|
// duplicates collapse while genuinely distinct dates (two custom events on
|
||||||
|
// one contact) are all kept. Ordered by Data._ID so which of two truly
|
||||||
|
// conflicting rows wins is stable across syncs (no event ping-pong).
|
||||||
|
val seen = HashSet<String>()
|
||||||
|
val result = ArrayList<ContactSpecialDate>()
|
||||||
|
runCatching {
|
||||||
|
resolver.query(
|
||||||
|
ContactsContract.Data.CONTENT_URI,
|
||||||
|
PROJECTION,
|
||||||
|
"${ContactsContract.Data.MIMETYPE} = ?",
|
||||||
|
arrayOf(Event.CONTENT_ITEM_TYPE),
|
||||||
|
"${ContactsContract.Data._ID} ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
|
||||||
|
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
|
||||||
|
val idxLabel = c.getColumnIndexOrThrow(Event.LABEL)
|
||||||
|
val idxLookup = c.getColumnIndexOrThrow(ContactsContract.Data.LOOKUP_KEY)
|
||||||
|
val idxName = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME_PRIMARY)
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
|
||||||
|
val type = specialDateTypeForRawEventType(c.getInt(idxType))
|
||||||
|
val date = ContactSpecialDate(
|
||||||
|
lookupKey = lookup,
|
||||||
|
displayName = c.getString(idxName)?.trim().orEmpty(),
|
||||||
|
type = type,
|
||||||
|
month = parts.month,
|
||||||
|
day = parts.day,
|
||||||
|
year = parts.year,
|
||||||
|
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
if (seen.add(date.managedUid())) result += date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ContactSpecialDates"
|
||||||
|
|
||||||
|
val PROJECTION = arrayOf(
|
||||||
|
ContactsContract.Data.LOOKUP_KEY,
|
||||||
|
ContactsContract.Data.DISPLAY_NAME_PRIMARY,
|
||||||
|
Event.START_DATE,
|
||||||
|
Event.TYPE,
|
||||||
|
Event.LABEL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.Data
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules the contact special-dates mirror. Birthdays change rarely, so this
|
||||||
|
* is deliberately cheap: a **daily** periodic reconcile, plus an immediate run
|
||||||
|
* on enable / "Sync now" and a debounced one when the app is foregrounded. No
|
||||||
|
* ContentObserver — it would need the process alive and buys almost nothing for
|
||||||
|
* once-a-year events. Everything stays offline (local contacts → local calendar).
|
||||||
|
*/
|
||||||
|
object SpecialDatesScheduler {
|
||||||
|
|
||||||
|
private const val WORK_NAME = "special-dates-sync"
|
||||||
|
private const val WORK_NAME_NOW = "special-dates-sync-now"
|
||||||
|
private const val WORK_NAME_FOREGROUND = "special-dates-sync-foreground"
|
||||||
|
private const val KEY_FOREGROUND = "foreground"
|
||||||
|
|
||||||
|
/** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */
|
||||||
|
fun apply(context: Context, enabled: Boolean) {
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
if (!enabled) {
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME)
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME_FOREGROUND)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val request = PeriodicWorkRequestBuilder<SpecialDatesSyncWorker>(1, TimeUnit.DAYS)
|
||||||
|
// Delay the first periodic run so it never overlaps an immediate run.
|
||||||
|
.setInitialDelay(1, TimeUnit.DAYS)
|
||||||
|
.build()
|
||||||
|
workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one reconcile immediately. [foreground] runs (the app resuming) are
|
||||||
|
* debounced inside the worker and use their own work name, so a frequent
|
||||||
|
* foreground resync can never REPLACE — and swallow — a pending enable /
|
||||||
|
* "Sync now" run, which always syncs. The engine serializes the two if they
|
||||||
|
* overlap.
|
||||||
|
*/
|
||||||
|
fun runNow(context: Context, foreground: Boolean = false) {
|
||||||
|
val request = OneTimeWorkRequestBuilder<SpecialDatesSyncWorker>()
|
||||||
|
.setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build())
|
||||||
|
.build()
|
||||||
|
val name = if (foreground) WORK_NAME_FOREGROUND else WORK_NAME_NOW
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniqueWork(name, ExistingWorkPolicy.REPLACE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val INPUT_FOREGROUND = KEY_FOREGROUND
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the [SpecialDatesSyncEngine]. Pulls its collaborators through a Hilt
|
||||||
|
* [EntryPoint] so it works under WorkManager's default worker factory. Records
|
||||||
|
* the run for the settings status line; a missing permission parks the feature
|
||||||
|
* in a stalled state (surfaced in settings) rather than retrying forever.
|
||||||
|
*/
|
||||||
|
class SpecialDatesSyncWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface Deps {
|
||||||
|
fun settingsPrefs(): SettingsPrefs
|
||||||
|
fun syncEngine(): SpecialDatesSyncEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||||
|
val prefs = deps.settingsPrefs()
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
// A foreground resume can fire often — skip if we synced recently.
|
||||||
|
val foreground = inputData.getBoolean(SpecialDatesScheduler.INPUT_FOREGROUND, false)
|
||||||
|
if (foreground && now - prefs.specialDatesLastForegroundSync.first() < FOREGROUND_DEBOUNCE_MILLIS) {
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
when (val result = deps.syncEngine().sync()) {
|
||||||
|
// The engine re-checks the toggle, so already-queued work never
|
||||||
|
// revives a disabled feature; don't record a run either.
|
||||||
|
SpecialDatesSyncResult.Disabled -> return Result.success()
|
||||||
|
else -> {
|
||||||
|
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
|
||||||
|
SpecialDatesStalledReason.PermissionRevoked
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
prefs.recordSpecialDatesRun(now, stalled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foreground) prefs.setSpecialDatesLastForegroundSync(now)
|
||||||
|
Result.success()
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// A revoked calendar/contacts permission won't fix itself on retry —
|
||||||
|
// park the feature in a stalled state (surfaced in settings) instead
|
||||||
|
// of retrying with backoff forever.
|
||||||
|
Log.w(TAG, "Special-dates sync lacks a required permission", e)
|
||||||
|
prefs.recordSpecialDatesRun(now, SpecialDatesStalledReason.PermissionRevoked)
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Special-dates sync failed", e)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SpecialDatesSync"
|
||||||
|
|
||||||
|
/** Skip a foreground-triggered sync if one ran within this window (4h). */
|
||||||
|
private const val FOREGROUND_DEBOUNCE_MILLIS = 4L * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Outcome of one mirror reconcile, reported back so the worker can record status. */
|
||||||
|
enum class SpecialDatesSyncResult { Success, Disabled, PermissionMissing }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localized, per-type presentation the engine needs but can't derive purely
|
||||||
|
* (calendar display name, colour, and the default title template). Kept behind
|
||||||
|
* an interface so the engine's diff stays unit-testable without Android
|
||||||
|
* resources.
|
||||||
|
*/
|
||||||
|
interface SpecialDatesCalendarSpec {
|
||||||
|
fun displayName(type: SpecialDateType): String
|
||||||
|
fun color(type: SpecialDateType): Int
|
||||||
|
fun defaultTitleTemplate(type: SpecialDateType): String
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored title template for [type], falling back to the localized default
|
||||||
|
* when blank/absent — the one resolution rule shared by the sync (what gets
|
||||||
|
* written) and the settings editor (what gets shown).
|
||||||
|
*/
|
||||||
|
fun SpecialDatesCalendarSpec.resolveTitleTemplate(
|
||||||
|
type: SpecialDateType,
|
||||||
|
stored: Map<SpecialDateType, String>,
|
||||||
|
): String = stored[type]?.takeIf { it.isNotBlank() } ?: defaultTitleTemplate(type)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors contact birthdays/anniversaries/custom dates into local calendars,
|
||||||
|
* one per type. Every reconcile is an idempotent diff keyed on the deterministic
|
||||||
|
* `UID_2445` ([managedEventUid]): new contacts are inserted (seeding user-owned
|
||||||
|
* fields once), changed contacts get a *targeted* managed-column update, and
|
||||||
|
* removed contacts are deleted — so a user's own edits (reminders, location,
|
||||||
|
* notes) are never clobbered. See docs/design/contact-special-dates.md.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SpecialDatesSyncEngine @Inject constructor(
|
||||||
|
private val contacts: ContactSpecialDatesDataSource,
|
||||||
|
private val calendars: CalendarDataSource,
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
private val spec: SpecialDatesCalendarSpec,
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Serializes calendar-lifecycle work: two overlapping syncs (the daily job
|
||||||
|
// racing a "Sync now"/foreground run) would each see no managed calendar and
|
||||||
|
// both create one; a teardown racing an in-flight sync would delete calendars
|
||||||
|
// the sync then recreates. Holding this for the whole of sync()/teardown()
|
||||||
|
// makes those check-then-act sequences atomic, and — because sync() re-reads
|
||||||
|
// the enabled flag inside the lock — a teardown always wins the race.
|
||||||
|
private val lifecycleMutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile every enabled type against the device's contacts. Returns why it
|
||||||
|
* stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
|
||||||
|
*/
|
||||||
|
suspend fun sync(): SpecialDatesSyncResult = lifecycleMutex.withLock {
|
||||||
|
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
|
||||||
|
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
|
||||||
|
|
||||||
|
val enabledTypes = prefs.specialDatesTypes.first()
|
||||||
|
val calendarByType = reconcileCalendars(enabledTypes)
|
||||||
|
|
||||||
|
val desiredByType = contacts.readSpecialDates().groupBy { it.type }
|
||||||
|
val reminderCtx = readReminderContext()
|
||||||
|
val templates = prefs.specialDatesTitleTemplates.first()
|
||||||
|
val showYear = prefs.specialDatesShowYear.first()
|
||||||
|
|
||||||
|
calendarByType.forEach { (type, calendarId) ->
|
||||||
|
val template = spec.resolveTitleTemplate(type, templates)
|
||||||
|
syncType(
|
||||||
|
calendarId = calendarId,
|
||||||
|
type = type,
|
||||||
|
contactsOfType = desiredByType[type].orEmpty(),
|
||||||
|
template = template,
|
||||||
|
showYear = showYear,
|
||||||
|
reminderCtx = reminderCtx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SpecialDatesSyncResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the reminder default for a type's managed calendar and apply it to
|
||||||
|
* **all** its existing events too (not just future ones) — for managed
|
||||||
|
* calendars the reminder is a calendar-level setting. Persists the per-calendar
|
||||||
|
* all-day override so new events keep matching. No-op if the calendar for
|
||||||
|
* [type] doesn't exist yet.
|
||||||
|
*/
|
||||||
|
suspend fun applyReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
||||||
|
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
||||||
|
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
||||||
|
val minutes = when (override) {
|
||||||
|
CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||||
|
CalendarReminderOverride.None -> emptyList()
|
||||||
|
is CalendarReminderOverride.Minutes -> override.minutes
|
||||||
|
}
|
||||||
|
calendars.applyManagedCalendarReminders(
|
||||||
|
calendarId = calendarId,
|
||||||
|
allDayReminderTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||||
|
minutes = minutes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
|
||||||
|
suspend fun teardown() = lifecycleMutex.withLock {
|
||||||
|
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }
|
||||||
|
SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure each enabled type has exactly one managed calendar (adopting an
|
||||||
|
* existing one, or the stored id if it still exists, else creating one) and
|
||||||
|
* that disabled types have none. Returns the calendar id per enabled type.
|
||||||
|
*/
|
||||||
|
private suspend fun reconcileCalendars(enabledTypes: Set<SpecialDateType>): Map<SpecialDateType, Long> {
|
||||||
|
val foundByType = calendars.findManagedCalendars()
|
||||||
|
.groupBy({ it.type }, { it.id })
|
||||||
|
.mapValues { it.value.first() }
|
||||||
|
val stored = prefs.specialDatesCalendars.first()
|
||||||
|
val result = LinkedHashMap<SpecialDateType, Long>()
|
||||||
|
for (type in SpecialDateType.entries) {
|
||||||
|
// A stored id counts only if the calendar still exists (the user may
|
||||||
|
// have deleted it in system settings); otherwise adopt a found one.
|
||||||
|
val existingId = stored[type]?.takeIf { id -> foundByType.containsValue(id) }
|
||||||
|
?: foundByType[type]
|
||||||
|
if (type in enabledTypes) {
|
||||||
|
val id = existingId ?: createCalendar(type)
|
||||||
|
if (stored[type] != id) prefs.setSpecialDatesCalendarId(type, id)
|
||||||
|
result[type] = id
|
||||||
|
} else {
|
||||||
|
if (existingId != null) calendars.deleteCalendar(existingId)
|
||||||
|
if (stored.containsKey(type)) prefs.setSpecialDatesCalendarId(type, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createCalendar(type: SpecialDateType): Long {
|
||||||
|
// reconcileCalendars persists the id — the single place ids are recorded.
|
||||||
|
val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type)
|
||||||
|
// Seed a useful all-day reminder default (on the day + a week before), so
|
||||||
|
// birthdays get lead time out of the box. Per-calendar, user-adjustable —
|
||||||
|
// only set when the user hasn't already configured this calendar.
|
||||||
|
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||||
|
prefs.setCalendarAllDayReminderOverride(
|
||||||
|
id,
|
||||||
|
CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncType(
|
||||||
|
calendarId: Long,
|
||||||
|
type: SpecialDateType,
|
||||||
|
contactsOfType: List<ContactSpecialDate>,
|
||||||
|
template: String,
|
||||||
|
showYear: Boolean,
|
||||||
|
reminderCtx: ReminderContext,
|
||||||
|
) {
|
||||||
|
val built = contactsOfType.map { sd ->
|
||||||
|
buildManagedEvent(calendarId, type, sd, template, showYear, reminderCtx)
|
||||||
|
}
|
||||||
|
val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
|
||||||
|
val formByUid = built.associate { it.desired.uid to it.form }
|
||||||
|
diff.insertUids.forEach { uid ->
|
||||||
|
calendars.insertManagedEvent(formByUid.getValue(uid), uid, reminderCtx.allDayTimeMinutes)
|
||||||
|
}
|
||||||
|
diff.updates.forEach { calendars.updateManagedFields(it.eventId, it.columns) }
|
||||||
|
diff.deleteEventIds.forEach { calendars.deleteEvent(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildManagedEvent(
|
||||||
|
calendarId: Long,
|
||||||
|
type: SpecialDateType,
|
||||||
|
sd: ContactSpecialDate,
|
||||||
|
template: String,
|
||||||
|
showYear: Boolean,
|
||||||
|
reminderCtx: ReminderContext,
|
||||||
|
): BuiltManagedEvent {
|
||||||
|
val anchor = sd.anchorDate()
|
||||||
|
val start = LocalDateTime(anchor, LocalTime(0, 0))
|
||||||
|
// The source year is static and correct on every occurrence (unlike age).
|
||||||
|
val year = if (showYear) sd.year else null
|
||||||
|
val title = renderSpecialDateTitle(template, sd.displayName, year)
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = calendarId,
|
||||||
|
title = title,
|
||||||
|
isAllDay = true,
|
||||||
|
start = start,
|
||||||
|
end = start,
|
||||||
|
reminders = reminderCtx.resolveAllDay(calendarId),
|
||||||
|
availability = Availability.Free,
|
||||||
|
rrule = YEARLY_RRULE,
|
||||||
|
)
|
||||||
|
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
|
||||||
|
return BuiltManagedEvent(
|
||||||
|
desired = ManagedEventDesired(
|
||||||
|
uid = sd.managedUid(),
|
||||||
|
title = title,
|
||||||
|
dtStartMillis = dtStartMillis,
|
||||||
|
rrule = YEARLY_RRULE,
|
||||||
|
),
|
||||||
|
form = form,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Managed events are always all-day, so only the all-day defaults apply.
|
||||||
|
private suspend fun readReminderContext(): ReminderContext = ReminderContext(
|
||||||
|
allDayGlobal = prefs.defaultAllDayReminderMinutes.first(),
|
||||||
|
allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(),
|
||||||
|
allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ReminderContext(
|
||||||
|
val allDayGlobal: List<Int>,
|
||||||
|
val allDayOverrides: Map<Long, List<Int>>,
|
||||||
|
val allDayTimeMinutes: Int,
|
||||||
|
) {
|
||||||
|
/** Same semantics as `resolveDefaultReminder`: present-empty = explicit none. */
|
||||||
|
fun resolveAllDay(calendarId: Long): List<Int> =
|
||||||
|
allDayOverrides[calendarId] ?: allDayGlobal
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** "On the day" (0) + one week before (7 days), as all-day lead minutes. */
|
||||||
|
val DEFAULT_REMINDER_MINUTES = listOf(0, 7 * 24 * 60)
|
||||||
|
val YEARLY_RRULE = SimpleRecurrence(freq = RecurrenceFreq.Yearly).toRRule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The managed columns of a desired event, compared against the existing row. */
|
||||||
|
internal data class ManagedEventDesired(
|
||||||
|
val uid: String,
|
||||||
|
val title: String,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val rrule: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class ManagedFieldUpdate(val eventId: Long, val columns: Map<String, Any?>)
|
||||||
|
|
||||||
|
internal data class ManagedDiff(
|
||||||
|
val insertUids: List<String>,
|
||||||
|
val updates: List<ManagedFieldUpdate>,
|
||||||
|
val deleteEventIds: List<Long>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The idempotent diff at the heart of the mirror, keyed on `UID_2445`:
|
||||||
|
* desired-not-existing → insert, existing-not-desired → delete, and for events
|
||||||
|
* in both only the *changed* managed columns (title/dtstart/rrule) are emitted —
|
||||||
|
* so a re-run with no contact changes produces nothing, and a managed update
|
||||||
|
* never touches user-owned columns or reminder rows. Pure, so it's unit-tested.
|
||||||
|
*/
|
||||||
|
internal fun diffManagedEvents(
|
||||||
|
desired: List<ManagedEventDesired>,
|
||||||
|
existing: List<ManagedEventRow>,
|
||||||
|
): ManagedDiff {
|
||||||
|
val desiredByUid = desired.associateBy { it.uid }
|
||||||
|
val existingByUid = existing.associateBy { it.uid }
|
||||||
|
val insertUids = desired.filter { it.uid !in existingByUid }.map { it.uid }
|
||||||
|
val deleteEventIds = existing.filter { it.uid !in desiredByUid }.map { it.eventId }
|
||||||
|
val updates = buildList {
|
||||||
|
for (d in desired) {
|
||||||
|
val row = existingByUid[d.uid] ?: continue
|
||||||
|
val columns = buildMap<String, Any?> {
|
||||||
|
if (row.title != d.title) put(CalendarContract.Events.TITLE, d.title)
|
||||||
|
if (row.dtStartMillis != d.dtStartMillis) {
|
||||||
|
put(CalendarContract.Events.DTSTART, d.dtStartMillis)
|
||||||
|
}
|
||||||
|
if (row.rrule != d.rrule) put(CalendarContract.Events.RRULE, d.rrule)
|
||||||
|
}
|
||||||
|
if (columns.isNotEmpty()) add(ManagedFieldUpdate(row.eventId, columns))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ManagedDiff(insertUids, updates, deleteEventIds)
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -45,6 +49,18 @@ abstract class DataBindModule {
|
|||||||
abstract fun bindReminderAlertStore(
|
abstract fun bindReminderAlertStore(
|
||||||
impl: AndroidReminderAlertStore,
|
impl: AndroidReminderAlertStore,
|
||||||
): ReminderAlertStore
|
): ReminderAlertStore
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindContactSpecialDatesDataSource(
|
||||||
|
impl: AndroidContactSpecialDatesDataSource,
|
||||||
|
): ContactSpecialDatesDataSource
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSpecialDatesCalendarSpec(
|
||||||
|
impl: AndroidSpecialDatesCalendarSpec,
|
||||||
|
): SpecialDatesCalendarSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.fonts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
|
import android.graphics.fonts.Font as PlatformFont
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage for user-loaded custom fonts (issue #19). A font a user picks via the
|
||||||
|
* system file picker is copied into the app's private storage — one file per
|
||||||
|
* [FontRole] — so the selection survives even if the original is later moved or
|
||||||
|
* deleted, and never leaves the app. The chosen file is validated as a real font
|
||||||
|
* before it replaces the previous one, so a bad pick can't wedge the app-wide
|
||||||
|
* typography.
|
||||||
|
*/
|
||||||
|
object CustomFontStore {
|
||||||
|
|
||||||
|
private fun dir(context: Context): File = File(context.filesDir, "fonts")
|
||||||
|
|
||||||
|
/** The stored font file for [role] (may not exist yet). */
|
||||||
|
fun file(context: Context, role: FontRole): File =
|
||||||
|
File(dir(context), "${role.name.lowercase(Locale.ROOT)}.ttf")
|
||||||
|
|
||||||
|
fun exists(context: Context, role: FontRole): Boolean =
|
||||||
|
file(context, role).let { it.exists() && it.length() > 0 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy [uri] into per-role storage, first validating that it parses as a
|
||||||
|
* font. Returns true on success; on any failure the previous file is left
|
||||||
|
* untouched and false is returned (the caller keeps the old selection).
|
||||||
|
*/
|
||||||
|
fun import(context: Context, role: FontRole, uri: Uri): Boolean {
|
||||||
|
val target = file(context, role)
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
val tmp = File(target.parentFile, "${role.name.lowercase(Locale.ROOT)}.tmp")
|
||||||
|
return try {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
tmp.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
} ?: return false
|
||||||
|
// Font.Builder throws IOException for anything that isn't a valid
|
||||||
|
// font file (API 29+, which is our minSdk) — a cheap, reliable check.
|
||||||
|
PlatformFont.Builder(tmp).build()
|
||||||
|
if (target.exists() && !target.delete()) return false
|
||||||
|
tmp.renameTo(target)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
tmp.delete()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the custom font for [role] (used when switching away from it). */
|
||||||
|
fun clear(context: Context, role: FontRole) {
|
||||||
|
file(context, role).delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,15 @@ import androidx.datastore.preferences.core.intPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -104,6 +109,47 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
store.edit { it[DYNAMIC_COLOR_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
|
||||||
|
* FontFamily at the theme layer; an unknown token degrades to the default.
|
||||||
|
*/
|
||||||
|
val brandFont: Flow<String> = store.data.map { prefs ->
|
||||||
|
prefs[BRAND_FONT_KEY] ?: FONT_SYSTEM_TOKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
val plainFont: Flow<String> = store.data.map { prefs ->
|
||||||
|
prefs[PLAIN_FONT_KEY] ?: FONT_SYSTEM_TOKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setBrandFont(token: String) {
|
||||||
|
store.edit { it[BRAND_FONT_KEY] = token }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setPlainFont(token: String) {
|
||||||
|
store.edit { it[PLAIN_FONT_KEY] = token }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A per-role bump counter for the user-loaded custom font. Re-importing
|
||||||
|
* overwrites the same file under the same "custom" token, so the token alone
|
||||||
|
* can't signal the change; this stamp — carried into AppFontSettings — breaks
|
||||||
|
* value equality so the resolved FontFamily (and its previews) refresh. A
|
||||||
|
* missing key is 0 (existing installs); the first import bumps it to 1.
|
||||||
|
*/
|
||||||
|
val brandFontStamp: Flow<Int> = store.data.map { prefs -> prefs[BRAND_FONT_STAMP_KEY] ?: 0 }
|
||||||
|
|
||||||
|
val plainFontStamp: Flow<Int> = store.data.map { prefs -> prefs[PLAIN_FONT_STAMP_KEY] ?: 0 }
|
||||||
|
|
||||||
|
/** Bump [role]'s custom-font stamp after a re-import so equality-keyed caches refresh. */
|
||||||
|
suspend fun bumpCustomFontStamp(role: FontRole) {
|
||||||
|
val key = when (role) {
|
||||||
|
FontRole.BRAND -> BRAND_FONT_STAMP_KEY
|
||||||
|
FontRole.PLAIN -> PLAIN_FONT_STAMP_KEY
|
||||||
|
}
|
||||||
|
store.edit { it[key] = (it[key] ?: 0) + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setWeekStart(pref: WeekStartPref) {
|
suspend fun setWeekStart(pref: WeekStartPref) {
|
||||||
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
|
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
|
||||||
}
|
}
|
||||||
@@ -202,6 +248,47 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
|
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick-switch button customisation (#24): the ordered full view list plus
|
||||||
|
* which views are enabled in the cycle. Stored comma-joined by enum name, a
|
||||||
|
* "!" prefix marking a disabled view (e.g. "Month,Week,!Day,Agenda"). Missing
|
||||||
|
* views are appended enabled and unknown names dropped, so a future view
|
||||||
|
* defaults into the cycle. An absent key means [QuickSwitchConfig.Default].
|
||||||
|
*/
|
||||||
|
val quickSwitchConfig: Flow<QuickSwitchConfig> = store.data.map { prefs ->
|
||||||
|
parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setQuickSwitchConfig(config: QuickSwitchConfig) {
|
||||||
|
store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic read-modify-write of the quick-switch config: [transform] sees the
|
||||||
|
* value currently stored, parsed inside the edit block, not the async-echoed
|
||||||
|
* UI snapshot. Two rapid intents (a toggle then a drag) therefore compose
|
||||||
|
* instead of each re-serialising a stale copy of the other field over it.
|
||||||
|
*/
|
||||||
|
suspend fun updateQuickSwitch(transform: (QuickSwitchConfig) -> QuickSwitchConfig) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
||||||
|
prefs[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(transform(current))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation-drawer view order (#24). Comma-joined enum names; missing views
|
||||||
|
* are appended in default order and unknown names dropped. Absent key means
|
||||||
|
* [IMPLEMENTED_VIEWS] (the historical fixed order).
|
||||||
|
*/
|
||||||
|
val drawerViewOrder: Flow<List<CalendarView>> = store.data.map { prefs ->
|
||||||
|
parseViewOrder(prefs[DRAWER_VIEW_ORDER_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
|
store.edit { it[DRAWER_VIEW_ORDER_KEY] = order.joinToString(",") { view -> view.name } }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional event-form fields shown by default (the rest hide behind
|
* Optional event-form fields shown by default (the rest hide behind
|
||||||
* "more fields"). Stored comma-joined by enum name: an absent key means
|
* "more fields"). Stored comma-joined by enum name: an absent key means
|
||||||
@@ -276,35 +363,36 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default reminder lead time (minutes before start) prefilled on new
|
* The default reminder lead times (minutes before start) prefilled on new
|
||||||
* **timed** events. `null` = no default reminder — the prior behaviour, kept
|
* **timed** events. The empty list = no default reminder — the prior
|
||||||
* as the factory default so existing users aren't surprised by reminders they
|
* behaviour, kept as the factory default so existing users aren't surprised by
|
||||||
* never asked for. Stored as a string so "none" is distinct from a numeric
|
* reminders they never asked for. Stored as a comma-joined list of minutes, or
|
||||||
* value (and from an unset key, which is also "none"). Per-calendar overrides
|
* "none"/empty for no reminder (also the unset state). A legacy single value
|
||||||
* in [perCalendarReminderOverride] take precedence; all-day events instead use
|
* ("30") parses transparently to a one-element list. Per-calendar overrides in
|
||||||
|
* [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||||
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
||||||
*/
|
*/
|
||||||
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
val defaultReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||||
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
|
prefs[DEFAULT_REMINDER_KEY].toReminderList()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setDefaultReminderMinutes(minutes: Int?) {
|
suspend fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||||
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default reminder lead time prefilled on new **all-day** events, in
|
* The default reminder lead times prefilled on new **all-day** events, in
|
||||||
* minutes before the start of the day. All-day events want day-scale lead
|
* minutes before the start of the day. All-day events want day-scale lead
|
||||||
* times ("1 day before"), so they have their own default rather than reusing
|
* times ("1 day before"), so they have their own default rather than reusing
|
||||||
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
|
* the timed one. Empty list = no default. Per-calendar overrides do **not**
|
||||||
* to all-day events — they always use this global value.
|
* apply to all-day events — they always use this global value.
|
||||||
*/
|
*/
|
||||||
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
val defaultAllDayReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||||
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
|
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||||
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -341,13 +429,13 @@ class SettingsPrefs @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
||||||
* keyed by calendar id. A calendar **present** in the map overrides the global
|
* keyed by calendar id. A calendar **present** in the map overrides the global
|
||||||
* timed default for its new events: a `null` value means "no reminder", an int
|
* timed default for its new events: an **empty** list means "no reminder", a
|
||||||
* means that lead time. A calendar **absent** from the map inherits the global
|
* non-empty list means those lead times. A calendar **absent** from the map
|
||||||
* default. Serialised as `id=value;id=value`, with `none` for an explicit
|
* inherits the global default. Serialised as `id=value;id=value`, where a value
|
||||||
* no-reminder override. (All-day events ignore this and use
|
* is a comma-joined minute list or `none` for an explicit no-reminder override.
|
||||||
* [defaultAllDayReminderMinutes].)
|
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
||||||
*/
|
*/
|
||||||
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||||
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,7 +452,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
||||||
* inherit the global all-day default; present null = no reminder).
|
* inherit the global all-day default; present null = no reminder).
|
||||||
*/
|
*/
|
||||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +514,95 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Contact special dates (issue #15) ------------------------------
|
||||||
|
|
||||||
|
/** Master switch for the contact special-dates mirror. Default OFF (opt-in). */
|
||||||
|
val specialDatesEnabled: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_ENABLED_KEY] ?: false }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesEnabled(enabled: Boolean) {
|
||||||
|
store.edit { it[SPECIAL_DATES_ENABLED_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The date types the user wants mirrored (default: all three). */
|
||||||
|
val specialDatesTypes: Flow<Set<SpecialDateType>> = store.data.map { prefs ->
|
||||||
|
SpecialDateType.entries.filterTo(mutableSetOf()) { prefs[typeEnabledKey(it)] ?: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
|
||||||
|
store.edit { it[typeEnabledKey(type)] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The managed calendar id per type, for the ones that currently exist. */
|
||||||
|
val specialDatesCalendars: Flow<Map<SpecialDateType, Long>> = store.data.map { prefs ->
|
||||||
|
SpecialDateType.entries.mapNotNull { type ->
|
||||||
|
prefs[calendarIdKey(type)]?.let { type to it }
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every managed calendar id — the editor locks title/date/recurrence for their events. */
|
||||||
|
val managedCalendarIds: Flow<Set<Long>> = specialDatesCalendars.map { it.values.toSet() }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesCalendarId(type: SpecialDateType, calendarId: Long?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (calendarId == null) prefs.remove(calendarIdKey(type)) else prefs[calendarIdKey(type)] = calendarId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title template per type ("{name}'s birthday"); an empty string means
|
||||||
|
* unset, so the settings screen can seed the localized default. `{name}` is
|
||||||
|
* the contact name, `{age}` the age at the upcoming date (empty when the
|
||||||
|
* birth year is unknown or [specialDatesShowAge] is off).
|
||||||
|
*/
|
||||||
|
val specialDatesTitleTemplates: Flow<Map<SpecialDateType, String>> = store.data.map { prefs ->
|
||||||
|
SpecialDateType.entries.associateWith { prefs[titleTemplateKey(it)].orEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
|
||||||
|
store.edit { it[titleTemplateKey(type)] = template }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `{year}` resolves in the title (only meaningful when the year is known). Default ON. */
|
||||||
|
val specialDatesShowYear: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_YEAR_KEY] ?: true }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesShowYear(enabled: Boolean) {
|
||||||
|
store.edit { it[SPECIAL_DATES_SHOW_YEAR_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of the last mirror sync, for the settings status line. */
|
||||||
|
val specialDatesStatus: Flow<SpecialDatesStatus> = store.data.map { prefs ->
|
||||||
|
SpecialDatesStatus(
|
||||||
|
lastRun = prefs[SPECIAL_DATES_LAST_RUN_KEY] ?: 0L,
|
||||||
|
stalled = prefs[SPECIAL_DATES_STALLED_KEY]
|
||||||
|
?.let { name -> SpecialDatesStalledReason.entries.firstOrNull { it.name == name } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a sync outcome; [stalled] non-null marks the feature paused (e.g. permission revoked). */
|
||||||
|
suspend fun recordSpecialDatesRun(atMillis: Long, stalled: SpecialDatesStalledReason?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[SPECIAL_DATES_LAST_RUN_KEY] = atMillis
|
||||||
|
if (stalled == null) prefs.remove(SPECIAL_DATES_STALLED_KEY) else prefs[SPECIAL_DATES_STALLED_KEY] = stalled.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Epoch millis of the last foreground-triggered sync, to debounce ON_RESUME. */
|
||||||
|
val specialDatesLastForegroundSync: Flow<Long> =
|
||||||
|
store.data.map { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] ?: 0L }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesLastForegroundSync(atMillis: Long) {
|
||||||
|
store.edit { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] = atMillis }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun typeEnabledKey(type: SpecialDateType) =
|
||||||
|
booleanPreferencesKey("special_dates_type_${type.name}")
|
||||||
|
|
||||||
|
private fun calendarIdKey(type: SpecialDateType) =
|
||||||
|
longPreferencesKey("special_dates_calendar_${type.name}")
|
||||||
|
|
||||||
|
private fun titleTemplateKey(type: SpecialDateType) =
|
||||||
|
stringPreferencesKey("special_dates_title_${type.name}")
|
||||||
|
|
||||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -433,9 +610,47 @@ class SettingsPrefs @Inject constructor(
|
|||||||
.toSet()
|
.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse a plain comma-joined view order, completed to every implemented view. */
|
||||||
|
private fun parseViewOrder(stored: String?): List<CalendarView> =
|
||||||
|
completeViewOrder(
|
||||||
|
stored?.split(',').orEmpty()
|
||||||
|
.mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Parse the quick-switch config; "!"-prefixed names are disabled. */
|
||||||
|
private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
|
||||||
|
if (stored == null) return QuickSwitchConfig.Default
|
||||||
|
val parsed = stored.split(',').mapNotNull { raw ->
|
||||||
|
val token = raw.trim()
|
||||||
|
val disabled = token.startsWith("!")
|
||||||
|
val name = if (disabled) token.drop(1) else token
|
||||||
|
CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled }
|
||||||
|
}
|
||||||
|
val order = completeViewOrder(parsed.map { it.first })
|
||||||
|
// Only views explicitly stored disabled are excluded; anything appended
|
||||||
|
// (a view added in a later release) defaults into the cycle.
|
||||||
|
val disabled = parsed.filter { it.second }.map { it.first }.toSet()
|
||||||
|
return QuickSwitchConfig(order, order.filterNot { it in disabled }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeQuickSwitch(config: QuickSwitchConfig): String =
|
||||||
|
completeViewOrder(config.order).joinToString(",") { view ->
|
||||||
|
if (view in config.enabled) view.name else "!${view.name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the given order (de-duplicated), then append any views it omits. */
|
||||||
|
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
||||||
|
val ordered = seen.distinct()
|
||||||
|
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
|
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")
|
||||||
|
internal val PLAIN_FONT_STAMP_KEY = intPreferencesKey("plain_font_stamp")
|
||||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||||
@@ -445,6 +660,8 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
|
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||||
|
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
@@ -478,9 +695,30 @@ class SettingsPrefs @Inject constructor(
|
|||||||
const val DEFAULT_BACKUP_INTERVAL = 1_440L
|
const val DEFAULT_BACKUP_INTERVAL = 1_440L
|
||||||
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
|
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
|
||||||
const val MIN_BACKUP_INTERVAL = 30L
|
const val MIN_BACKUP_INTERVAL = 30L
|
||||||
|
|
||||||
|
internal val SPECIAL_DATES_ENABLED_KEY = booleanPreferencesKey("special_dates_enabled")
|
||||||
|
internal val SPECIAL_DATES_SHOW_YEAR_KEY = booleanPreferencesKey("special_dates_show_year")
|
||||||
|
internal val SPECIAL_DATES_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run")
|
||||||
|
internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason")
|
||||||
|
internal val SPECIAL_DATES_LAST_FG_SYNC_KEY =
|
||||||
|
longPreferencesKey("special_dates_last_foreground_sync")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot of the special-dates mirror's last outcome (see [SettingsPrefs.specialDatesStatus]). */
|
||||||
|
data class SpecialDatesStatus(
|
||||||
|
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||||
|
val lastRun: Long,
|
||||||
|
/** Non-null when the mirror is paused and why; null when healthy. */
|
||||||
|
val stalled: SpecialDatesStalledReason?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Why the special-dates mirror is paused. */
|
||||||
|
enum class SpecialDatesStalledReason {
|
||||||
|
/** READ_CONTACTS was revoked in system settings after the feature was enabled. */
|
||||||
|
PermissionRevoked,
|
||||||
|
}
|
||||||
|
|
||||||
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
|
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
|
||||||
data class BackupStatus(
|
data class BackupStatus(
|
||||||
/** Epoch millis of the last run, or 0 if it has never run. */
|
/** Epoch millis of the last run, or 0 if it has never run. */
|
||||||
@@ -495,43 +733,58 @@ sealed interface CalendarReminderOverride {
|
|||||||
data object Inherit : CalendarReminderOverride
|
data object Inherit : CalendarReminderOverride
|
||||||
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
||||||
data object None : CalendarReminderOverride
|
data object None : CalendarReminderOverride
|
||||||
/** A specific lead time in minutes before the event start. */
|
/** Specific lead times in minutes before the event start (non-empty). */
|
||||||
data class Minutes(val minutes: Int) : CalendarReminderOverride
|
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The lead time to prefill on a new event: the matching per-calendar override
|
* The stored override for [calendarId] in an override map, as a
|
||||||
|
* [CalendarReminderOverride] (absent → [CalendarReminderOverride.Inherit],
|
||||||
|
* empty → [CalendarReminderOverride.None]) — the inverse of how
|
||||||
|
* [SettingsPrefs.setCalendarReminderOverride] stores a choice.
|
||||||
|
*/
|
||||||
|
fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||||
|
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||||
|
getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
||||||
|
else -> CalendarReminderOverride.Minutes(getValue(calendarId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lead times to prefill on a new event: the matching per-calendar override
|
||||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||||
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
||||||
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
|
* events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder.
|
||||||
* it can be unit-tested.
|
* Pure so it can be unit-tested.
|
||||||
*/
|
*/
|
||||||
fun resolveDefaultReminder(
|
fun resolveDefaultReminder(
|
||||||
timedGlobal: Int?,
|
timedGlobal: List<Int>,
|
||||||
allDayGlobal: Int?,
|
allDayGlobal: List<Int>,
|
||||||
timedOverrides: Map<Long, Int?>,
|
timedOverrides: Map<Long, List<Int>>,
|
||||||
allDayOverrides: Map<Long, Int?>,
|
allDayOverrides: Map<Long, List<Int>>,
|
||||||
calendarId: Long?,
|
calendarId: Long?,
|
||||||
isAllDay: Boolean,
|
isAllDay: Boolean,
|
||||||
): Int? {
|
): List<Int> {
|
||||||
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
||||||
val global = if (isAllDay) allDayGlobal else timedGlobal
|
val global = if (isAllDay) allDayGlobal else timedGlobal
|
||||||
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
||||||
overrides[calendarId]
|
overrides.getValue(calendarId)
|
||||||
} else {
|
} else {
|
||||||
global
|
global
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
|
/**
|
||||||
private fun MutableMap<Long, Int?>.applyOverride(
|
* Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
|
||||||
|
* key; [None] and an empty [Minutes] both store the empty list).
|
||||||
|
*/
|
||||||
|
private fun MutableMap<Long, List<Int>>.applyOverride(
|
||||||
calendarId: Long,
|
calendarId: Long,
|
||||||
override: CalendarReminderOverride,
|
override: CalendarReminderOverride,
|
||||||
) {
|
) {
|
||||||
when (override) {
|
when (override) {
|
||||||
CalendarReminderOverride.Inherit -> remove(calendarId)
|
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||||
CalendarReminderOverride.None -> put(calendarId, null)
|
CalendarReminderOverride.None -> put(calendarId, emptyList())
|
||||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
|
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,24 +810,44 @@ private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) {
|
|||||||
private const val NONE = "none"
|
private const val NONE = "none"
|
||||||
private const val ENTRY_SEP = ";"
|
private const val ENTRY_SEP = ";"
|
||||||
private const val KEY_VALUE_SEP = "="
|
private const val KEY_VALUE_SEP = "="
|
||||||
|
private const val LIST_SEP = ","
|
||||||
|
|
||||||
private fun String?.toReminderMinutes(): Int? = when (this) {
|
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
||||||
null, "", NONE -> null
|
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||||
else -> toIntOrNull()
|
|
||||||
|
/**
|
||||||
|
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
||||||
|
* list; a comma-joined list → its minutes; a legacy single value ("30") → a
|
||||||
|
* one-element list. Non-numeric parts are dropped defensively.
|
||||||
|
*/
|
||||||
|
private fun String?.toReminderList(): List<Int> = when {
|
||||||
|
this == null || isEmpty() || this == NONE -> emptyList()
|
||||||
|
else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
/** Serialise lead times for storage: "none" when empty, else comma-joined. */
|
||||||
|
private fun List<Int>.toStoredReminders(): String =
|
||||||
|
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||||
|
|
||||||
|
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
|
||||||
if (stored.isNullOrBlank()) return emptyMap()
|
if (stored.isNullOrBlank()) return emptyMap()
|
||||||
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||||
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||||
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
|
// Only the deliberate "none" sentinel means an explicit no-reminder
|
||||||
id to value
|
// override (empty list); a non-sentinel value that parses to no valid
|
||||||
|
// minutes is garbage and drops the entry, so the calendar inherits the
|
||||||
|
// global default rather than silently reading as "no reminder".
|
||||||
|
when (val value = parts[1]) {
|
||||||
|
NONE -> id to emptyList()
|
||||||
|
else -> value.toReminderList().takeIf { it.isNotEmpty() }?.let { id to it }
|
||||||
|
?: return@mapNotNull null
|
||||||
|
}
|
||||||
}.toMap()
|
}.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
|
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
|
||||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
|
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
|
||||||
|
|
||||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.content.pm.PackageManager
|
|||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -16,6 +17,29 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [this] alert belongs to a calendar the user disabled in-app, so its
|
||||||
|
* reminder must be suppressed (mirroring the event filtering in
|
||||||
|
* CalendarRepositoryImpl). Alerts whose calendar is unknown (id 0L — e.g. a
|
||||||
|
* pre-upgrade snooze PendingIntent minted before EXTRA_CALENDAR_ID existed) are
|
||||||
|
* never treated as disabled. This is the one predicate the disabled-calendar
|
||||||
|
* gate is built from: [postableAlerts] here and the choke point in
|
||||||
|
* [ReminderNotifier.post] both use it.
|
||||||
|
*/
|
||||||
|
internal fun ReminderAlert.isForDisabledCalendar(disabledCalendarIds: Set<Long>): Boolean =
|
||||||
|
calendarId != 0L && calendarId in disabledCalendarIds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The due alerts that should actually surface as notifications: everything
|
||||||
|
* except alerts whose calendar the user has disabled in-app. The caller still
|
||||||
|
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
|
||||||
|
* provider.
|
||||||
|
*/
|
||||||
|
internal fun postableAlerts(
|
||||||
|
due: List<ReminderAlert>,
|
||||||
|
disabledCalendarIds: Set<Long>,
|
||||||
|
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||||
* visible notifications (the Etar model — the provider broadcasts
|
* visible notifications (the Etar model — the provider broadcasts
|
||||||
@@ -32,6 +56,8 @@ class EventReminderReceiver : BroadcastReceiver() {
|
|||||||
@Inject lateinit var alertStore: ReminderAlertStore
|
@Inject lateinit var alertStore: ReminderAlertStore
|
||||||
@Inject lateinit var notifier: ReminderNotifier
|
@Inject lateinit var notifier: ReminderNotifier
|
||||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||||
|
@Inject lateinit var suppressedStore: SuppressedReminderStore
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||||
@@ -46,8 +72,17 @@ class EventReminderReceiver : BroadcastReceiver() {
|
|||||||
if (settingsPrefs.remindersEnabled.first()) {
|
if (settingsPrefs.remindersEnabled.first()) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val due = alertStore.dueAlerts(now)
|
val due = alertStore.dueAlerts(now)
|
||||||
due.forEach { notifier.post(it) }
|
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||||
|
val postable = postableAlerts(due, disabled)
|
||||||
|
// Suppress reminders for disabled calendars, but still mark
|
||||||
|
// every due alert fired so the provider stops re-broadcasting
|
||||||
|
// the suppressed ones. Stash those suppressed alerts so
|
||||||
|
// re-enabling their calendar can recover them (they would
|
||||||
|
// otherwise stay STATE_FIRED forever with no re-scan).
|
||||||
|
postable.forEach { notifier.post(it) }
|
||||||
alertStore.markFired(due.map { it.alertId }, now)
|
alertStore.markFired(due.map { it.alertId }, now)
|
||||||
|
suppressedStore.stash(due - postable.toSet(), now)
|
||||||
|
suppressedStore.purgeExpired(now)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pendingResult.finish()
|
pendingResult.finish()
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
private const val EXTRA_ALERT_ID = "alert_id"
|
private const val EXTRA_ALERT_ID = "alert_id"
|
||||||
private const val EXTRA_EVENT_ID = "event_id"
|
private const val EXTRA_EVENT_ID = "event_id"
|
||||||
|
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||||
private const val EXTRA_BEGIN = "begin"
|
private const val EXTRA_BEGIN = "begin"
|
||||||
private const val EXTRA_END = "end"
|
private const val EXTRA_END = "end"
|
||||||
private const val EXTRA_TITLE = "title"
|
private const val EXTRA_TITLE = "title"
|
||||||
@@ -87,6 +88,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
this.action = action
|
this.action = action
|
||||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||||
|
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||||
putExtra(EXTRA_END, alert.endMillis)
|
putExtra(EXTRA_END, alert.endMillis)
|
||||||
putExtra(EXTRA_TITLE, alert.title)
|
putExtra(EXTRA_TITLE, alert.title)
|
||||||
@@ -113,6 +115,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
return ReminderAlert(
|
return ReminderAlert(
|
||||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||||
|
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||||
endMillis = intent.getLongExtra(EXTRA_END, 0L),
|
endMillis = intent.getLongExtra(EXTRA_END, 0L),
|
||||||
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import javax.inject.Singleton
|
|||||||
data class ReminderAlert(
|
data class ReminderAlert(
|
||||||
val alertId: Long,
|
val alertId: Long,
|
||||||
val eventId: Long,
|
val eventId: Long,
|
||||||
|
val calendarId: Long,
|
||||||
val beginMillis: Long,
|
val beginMillis: Long,
|
||||||
val endMillis: Long,
|
val endMillis: Long,
|
||||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||||
@@ -66,11 +67,12 @@ class AndroidReminderAlertStore @Inject constructor(
|
|||||||
ReminderAlert(
|
ReminderAlert(
|
||||||
alertId = c.getLong(0),
|
alertId = c.getLong(0),
|
||||||
eventId = c.getLong(1),
|
eventId = c.getLong(1),
|
||||||
beginMillis = c.getLong(2),
|
calendarId = c.getLong(2),
|
||||||
endMillis = c.getLong(3),
|
beginMillis = c.getLong(3),
|
||||||
title = c.getString(4).orEmpty(),
|
endMillis = c.getLong(4),
|
||||||
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
title = c.getString(5).orEmpty(),
|
||||||
isAllDay = c.getInt(6) == 1,
|
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
||||||
|
isAllDay = c.getInt(7) == 1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -102,6 +104,7 @@ class AndroidReminderAlertStore @Inject constructor(
|
|||||||
val PROJECTION = arrayOf(
|
val PROJECTION = arrayOf(
|
||||||
CalendarContract.CalendarAlerts._ID,
|
CalendarContract.CalendarAlerts._ID,
|
||||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||||
|
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
||||||
CalendarContract.CalendarAlerts.BEGIN,
|
CalendarContract.CalendarAlerts.BEGIN,
|
||||||
CalendarContract.CalendarAlerts.END,
|
CalendarContract.CalendarAlerts.END,
|
||||||
CalendarContract.CalendarAlerts.TITLE,
|
CalendarContract.CalendarAlerts.TITLE,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
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.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -33,6 +34,7 @@ import javax.inject.Singleton
|
|||||||
class ReminderNotifier @Inject constructor(
|
class ReminderNotifier @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
private val calendarPrefs: CalendarPrefs,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||||
@@ -44,6 +46,11 @@ class ReminderNotifier @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun post(alert: ReminderAlert) {
|
suspend fun post(alert: ReminderAlert) {
|
||||||
|
// The single choke point for the disabled-calendar gate: it covers both
|
||||||
|
// the provider broadcast (EventReminderReceiver) and a snoozed re-show
|
||||||
|
// (ReminderActionReceiver), so a calendar disabled after a snooze no
|
||||||
|
// longer notifies — without either receiver duplicating the check.
|
||||||
|
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return
|
||||||
ensureChannel()
|
ensureChannel()
|
||||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
val is24Hour = settingsPrefs.timeFormat.first()
|
val is24Hour = settingsPrefs.timeFormat.first()
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Still relevant while the event has not ended: a reminder for an event that is
|
||||||
|
* already over is pointless to re-surface. Falls back to the begin time when the
|
||||||
|
* end is unknown (0L). Used both to decide what to re-post and to purge the stash.
|
||||||
|
*/
|
||||||
|
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
|
||||||
|
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local stash of reminder alerts that fired while their calendar was disabled
|
||||||
|
* in-app. [EventReminderReceiver] marks every due alert `STATE_FIRED` regardless
|
||||||
|
* (so the provider stops re-broadcasting the suppressed ones), which would
|
||||||
|
* otherwise lose those reminders forever — there is no re-scan. Stashing lets
|
||||||
|
* [de.jeanlucmakiola.calendula.ui.calendars.CalendarsViewModel] re-post them if
|
||||||
|
* the user re-enables the calendar before the event is over.
|
||||||
|
*
|
||||||
|
* Persisted in the shared preferences DataStore as a set of self-describing
|
||||||
|
* strings (one per alert); the stash never reaches a screen, so there is no
|
||||||
|
* domain model. Entries whose event has already ended are dropped on the next
|
||||||
|
* stash/recover/purge, so the stash only ever holds a handful of pending alerts.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SuppressedReminderStore @Inject constructor(
|
||||||
|
private val store: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Add [alerts] to the stash, replacing any existing entry with the same id. */
|
||||||
|
suspend fun stash(alerts: List<ReminderAlert>, nowMillis: Long) {
|
||||||
|
if (alerts.isEmpty()) return
|
||||||
|
store.edit { prefs ->
|
||||||
|
val byId = decodeAll(prefs).associateByTo(mutableMapOf()) { it.alertId }
|
||||||
|
alerts.forEach { byId[it.alertId] = it }
|
||||||
|
prefs.putStash(byId.values.filter { it.isRelevantAt(nowMillis) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove and return the still-relevant stashed alerts belonging to any of
|
||||||
|
* [calendarIds]; drops expired entries for every calendar in passing.
|
||||||
|
*/
|
||||||
|
suspend fun recoverFor(calendarIds: Set<Long>, nowMillis: Long): List<ReminderAlert> {
|
||||||
|
val recovered = mutableListOf<ReminderAlert>()
|
||||||
|
store.edit { prefs ->
|
||||||
|
val kept = decodeAll(prefs).filter { alert ->
|
||||||
|
when {
|
||||||
|
!alert.isRelevantAt(nowMillis) -> false // expired: drop
|
||||||
|
alert.calendarId in calendarIds -> { recovered += alert; false }
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs.putStash(kept)
|
||||||
|
}
|
||||||
|
return recovered
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop entries whose event has already ended — cheap opportunistic cleanup. */
|
||||||
|
suspend fun purgeExpired(nowMillis: Long) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs.putStash(decodeAll(prefs).filter { it.isRelevantAt(nowMillis) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeAll(prefs: Preferences): List<ReminderAlert> =
|
||||||
|
prefs[KEY].orEmpty().mapNotNull { decodeStashEntry(it) }
|
||||||
|
|
||||||
|
private fun MutablePreferences.putStash(alerts: List<ReminderAlert>) {
|
||||||
|
val encoded = alerts.map { encodeStashEntry(it) }.toSet()
|
||||||
|
if (encoded.isEmpty()) remove(KEY) else set(KEY, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val KEY = stringSetPreferencesKey("suppressed_reminders")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One stash entry as a delimited string. The '|' separator is safe because every
|
||||||
|
// free-text field is Base64-encoded first (that alphabet never contains '|'), and
|
||||||
|
// a null location is stored as a distinct sentinel that Base64 also never yields.
|
||||||
|
private const val FIELD_SEP = "|"
|
||||||
|
private const val NULL_LOCATION = "-"
|
||||||
|
|
||||||
|
internal fun encodeStashEntry(alert: ReminderAlert): String = listOf(
|
||||||
|
alert.alertId.toString(),
|
||||||
|
alert.eventId.toString(),
|
||||||
|
alert.calendarId.toString(),
|
||||||
|
alert.beginMillis.toString(),
|
||||||
|
alert.endMillis.toString(),
|
||||||
|
if (alert.isAllDay) "1" else "0",
|
||||||
|
alert.title.toBase64(),
|
||||||
|
alert.location?.toBase64() ?: NULL_LOCATION,
|
||||||
|
).joinToString(FIELD_SEP)
|
||||||
|
|
||||||
|
/** Reverse of [encodeStashEntry]; returns null for a malformed entry (dropped). */
|
||||||
|
internal fun decodeStashEntry(raw: String): ReminderAlert? {
|
||||||
|
val parts = raw.split(FIELD_SEP)
|
||||||
|
if (parts.size != 8) return null
|
||||||
|
return try {
|
||||||
|
ReminderAlert(
|
||||||
|
alertId = parts[0].toLong(),
|
||||||
|
eventId = parts[1].toLong(),
|
||||||
|
calendarId = parts[2].toLong(),
|
||||||
|
beginMillis = parts[3].toLong(),
|
||||||
|
endMillis = parts[4].toLong(),
|
||||||
|
title = parts[6].fromBase64(),
|
||||||
|
location = parts[7].takeIf { it != NULL_LOCATION }?.fromBase64(),
|
||||||
|
isAllDay = parts[5] == "1",
|
||||||
|
)
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
null
|
||||||
|
} catch (e: IllegalArgumentException) { // bad Base64
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toBase64(): String =
|
||||||
|
Base64.getEncoder().encodeToString(toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
|
private fun String.fromBase64(): String =
|
||||||
|
String(Base64.getDecoder().decode(this), Charsets.UTF_8)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two Material 3 typeface roles a user can set independently (issue #19):
|
||||||
|
* [BRAND] drives the display/headline styles (expression), [PLAIN] the
|
||||||
|
* title/body/label styles (readability). Each defaults to the system typeface.
|
||||||
|
*/
|
||||||
|
enum class FontRole { BRAND, PLAIN }
|
||||||
@@ -26,6 +26,14 @@ data class CalendarSource(
|
|||||||
* owns for its own calendars). Always null for synced calendars.
|
* owns for its own calendars). Always null for synced calendars.
|
||||||
*/
|
*/
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
|
/**
|
||||||
|
* A special-dates mirror calendar the app manages (birthdays/anniversaries
|
||||||
|
* from contacts). Its events' title/date/recurrence are owned by the sync,
|
||||||
|
* so it's hidden from the new-event calendar picker and its events lock those
|
||||||
|
* fields in the editor. Recognised by a durable provider marker, so it holds
|
||||||
|
* even after a backup restore clears the app's stored ids.
|
||||||
|
*/
|
||||||
|
val isManaged: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EventInstance(
|
data class EventInstance(
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kinds of contact "special date" Calendula mirrors into local calendars.
|
||||||
|
* Each kind gets its own local calendar, so it inherits per-calendar colour,
|
||||||
|
* visibility and reminder defaults for free. See
|
||||||
|
* docs/design/contact-special-dates.md.
|
||||||
|
*/
|
||||||
|
enum class SpecialDateType {
|
||||||
|
Birthday,
|
||||||
|
Anniversary,
|
||||||
|
|
||||||
|
/** Everything else — a contact "Event" that is neither birthday nor anniversary. */
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One dated event read from a device contact (a `ContactsContract` `Event`
|
||||||
|
* row). The [lookupKey] is the stable contact identity used to reconcile the
|
||||||
|
* mirror without duplicating; [year] is null when the contact stored the date
|
||||||
|
* without one (`--MM-dd`), in which case age can't be shown.
|
||||||
|
*/
|
||||||
|
data class ContactSpecialDate(
|
||||||
|
val lookupKey: String,
|
||||||
|
val displayName: String,
|
||||||
|
val type: SpecialDateType,
|
||||||
|
val month: Int,
|
||||||
|
val day: Int,
|
||||||
|
val year: Int?,
|
||||||
|
/** The contact's custom label for a [SpecialDateType.Custom] date, if any. */
|
||||||
|
val label: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The parsed month/day (+ optional year) of a contact date. */
|
||||||
|
data class ContactDateParts(val year: Int?, val month: Int, val day: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fixed leap year to validate and anchor year-less dates against, so that a
|
||||||
|
* `--02-29` birthday is representable (and, once anchored, only recurs in leap
|
||||||
|
* years — matching how the calendar provider expands a yearly Feb-29 series).
|
||||||
|
*/
|
||||||
|
const val YEARLESS_ANCHOR_YEAR = 1972
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `ContactsContract.CommonDataKinds.Event.START_DATE` value into its
|
||||||
|
* calendar parts, or null if it isn't a usable date. Handles the three shapes
|
||||||
|
* seen in the wild:
|
||||||
|
* - full `yyyy-MM-dd` (year known),
|
||||||
|
* - year-less `--MM-dd` (year null),
|
||||||
|
* - compact `yyyyMMdd`.
|
||||||
|
*
|
||||||
|
* A date that names an impossible day (e.g. `1999-02-29`) is rejected. Pure, so
|
||||||
|
* it's unit-tested without a device.
|
||||||
|
*/
|
||||||
|
fun parseContactEventDate(raw: String?): ContactDateParts? {
|
||||||
|
val s = raw?.trim().orEmpty()
|
||||||
|
if (s.isEmpty()) return null
|
||||||
|
|
||||||
|
// Year-less: "--MM-dd" or "--MMdd".
|
||||||
|
if (s.startsWith("--")) {
|
||||||
|
val (m, d) = parseMonthDay(s.substring(2)) ?: return null
|
||||||
|
return validated(null, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.contains('-')) {
|
||||||
|
val parts = s.split('-').filter { it.isNotEmpty() }
|
||||||
|
return when (parts.size) {
|
||||||
|
// "yyyy-MM-dd" — but a leading '-' would have dropped the empty
|
||||||
|
// first part, so require the first token to look like a year.
|
||||||
|
3 -> if (s.startsWith('-')) null else validated(
|
||||||
|
year = parts[0].toIntOrNull() ?: return null,
|
||||||
|
month = parts[1].toIntOrNull() ?: return null,
|
||||||
|
day = parts[2].toIntOrNull() ?: return null,
|
||||||
|
)
|
||||||
|
// Year-less "MM-dd" without the "--" prefix.
|
||||||
|
2 -> validated(
|
||||||
|
year = null,
|
||||||
|
month = parts[0].toIntOrNull() ?: return null,
|
||||||
|
day = parts[1].toIntOrNull() ?: return null,
|
||||||
|
)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact "yyyyMMdd".
|
||||||
|
if (s.length == 8 && s.all { it.isDigit() }) {
|
||||||
|
return validated(
|
||||||
|
year = s.substring(0, 4).toInt(),
|
||||||
|
month = s.substring(4, 6).toInt(),
|
||||||
|
day = s.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "MM-dd" or compact "MMdd". */
|
||||||
|
private fun parseMonthDay(rest: String): Pair<Int, Int>? {
|
||||||
|
if (rest.contains('-')) {
|
||||||
|
val p = rest.split('-').filter { it.isNotEmpty() }
|
||||||
|
if (p.size != 2) return null
|
||||||
|
return (p[0].toIntOrNull() ?: return null) to (p[1].toIntOrNull() ?: return null)
|
||||||
|
}
|
||||||
|
if (rest.length == 4 && rest.all { it.isDigit() }) {
|
||||||
|
return rest.substring(0, 2).toInt() to rest.substring(2, 4).toInt()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm month/day form a real calendar date (validated against the actual
|
||||||
|
* year when known, otherwise the leap anchor so `02-29` survives).
|
||||||
|
*/
|
||||||
|
private fun validated(year: Int?, month: Int, day: Int): ContactDateParts? {
|
||||||
|
if (month !in 1..12 || day !in 1..31) return null
|
||||||
|
val checkYear = year ?: YEARLESS_ANCHOR_YEAR
|
||||||
|
return runCatching { LocalDate.of(checkYear, month, day) }
|
||||||
|
.map { ContactDateParts(year, month, day) }
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix of every managed-event `UID_2445`. Distinguishes mirror events from
|
||||||
|
* user-created ones (which carry a random `<uuid>@calendula` UID), so the sync
|
||||||
|
* only ever reconciles — and never deletes — events it actually owns.
|
||||||
|
*/
|
||||||
|
const val MANAGED_UID_PREFIX = "contact-"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
|
||||||
|
* contact date. Stable across syncs (the reconciliation key), namespaced by
|
||||||
|
* type so the same contact's birthday and anniversary never collide, and — when
|
||||||
|
* a [discriminator] is given — by it too, so two Custom dates on one contact
|
||||||
|
* (e.g. "Wedding" and "Graduation") get distinct events instead of clobbering
|
||||||
|
* each other.
|
||||||
|
*/
|
||||||
|
fun managedEventUid(type: SpecialDateType, lookupKey: String, discriminator: String? = null): String {
|
||||||
|
val disc = discriminator?.takeIf { it.isNotBlank() }?.let { ":$it" }.orEmpty()
|
||||||
|
return "$MANAGED_UID_PREFIX${type.name.lowercase()}:$lookupKey$disc@calendula"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reconciliation key for this date's mirrored event. Birthdays/anniversaries
|
||||||
|
* are one-per-contact, so they key on the contact alone; a [SpecialDateType.Custom]
|
||||||
|
* date adds a discriminator (its label, else its month-day) so distinct custom
|
||||||
|
* dates on one contact don't collapse into a single event.
|
||||||
|
*/
|
||||||
|
fun ContactSpecialDate.managedUid(): String =
|
||||||
|
managedEventUid(type, lookupKey, customDiscriminator())
|
||||||
|
|
||||||
|
private fun ContactSpecialDate.customDiscriminator(): String? =
|
||||||
|
if (type == SpecialDateType.Custom) {
|
||||||
|
label?.trim()?.lowercase()?.ifBlank { null } ?: "%02d-%02d".format(month, day)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real
|
||||||
|
* date when the year is known (so age can be derived), otherwise the month/day
|
||||||
|
* on a fixed leap anchor year so a `--02-29` date stays representable and the
|
||||||
|
* anchor never drifts between syncs.
|
||||||
|
*/
|
||||||
|
fun ContactSpecialDate.anchorDate(): LocalDate =
|
||||||
|
LocalDate(year ?: YEARLESS_ANCHOR_YEAR, month, day)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a title [template] for a contact, substituting `{name}` and `{year}`
|
||||||
|
* (the source year — a birthday's birth year or an anniversary's start year;
|
||||||
|
* empty when [year] is null). Unlike an age, the year is static and correct on
|
||||||
|
* every occurrence of the yearly event. Collapses the whitespace an empty
|
||||||
|
* `{year}` may leave behind, so "{name}'s birthday ({year})" degrades cleanly to
|
||||||
|
* "Jane's birthday" when no year is known.
|
||||||
|
*/
|
||||||
|
fun renderSpecialDateTitle(template: String, name: String, year: Int?): String =
|
||||||
|
template
|
||||||
|
.replace("{name}", name)
|
||||||
|
.replace("{year}", year?.toString().orEmpty())
|
||||||
|
// Drop an empty "()" left by an unresolved {year}, then tidy spacing.
|
||||||
|
.replace(Regex("""\(\s*\)"""), "")
|
||||||
|
.replace(Regex("""\s+"""), " ")
|
||||||
|
.trim()
|
||||||
@@ -79,6 +79,11 @@ fun CalendarHost(
|
|||||||
// correcting it. Brief blank first frame, matching the onboarding gate above.
|
// correcting it. Brief blank first frame, matching the onboarding gate above.
|
||||||
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
|
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
|
||||||
|
|
||||||
|
// View customisation (#24): the quick-switch cycle and drawer order. Both have
|
||||||
|
// sensible non-empty initial values, so they're ready before the first frame.
|
||||||
|
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
||||||
|
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
|
||||||
|
|
||||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||||
mutableStateOf(listOf(defaultView))
|
mutableStateOf(listOf(defaultView))
|
||||||
}
|
}
|
||||||
@@ -212,6 +217,17 @@ fun CalendarHost(
|
|||||||
detailKey = key
|
detailKey = key
|
||||||
onWidgetNavConsumed()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
|
is WidgetNavRequest.OpenView -> {
|
||||||
|
// A widget header tap: land on a top-level view with no date
|
||||||
|
// drill-in. Reveal it by dropping any covering overlay, then root
|
||||||
|
// the stack on the target (null → the default home) over the
|
||||||
|
// default home — so backing out returns to the default, then exits.
|
||||||
|
dismissCoveringOverlays()
|
||||||
|
createDateIso = null
|
||||||
|
pendingDayIso = null
|
||||||
|
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||||
|
onWidgetNavConsumed()
|
||||||
|
}
|
||||||
is WidgetNavRequest.Create -> {
|
is WidgetNavRequest.Create -> {
|
||||||
// External "new event" entries (QS tile / launcher shortcut /
|
// External "new event" entries (QS tile / launcher shortcut /
|
||||||
// widget) must land on top of whatever is open — the form overlay
|
// widget) must land on top of whatever is open — the form overlay
|
||||||
@@ -260,6 +276,8 @@ fun CalendarHost(
|
|||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -269,6 +287,8 @@ fun CalendarHost(
|
|||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Month -> MonthScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -277,6 +297,8 @@ fun CalendarHost(
|
|||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -285,6 +307,8 @@ fun CalendarHost(
|
|||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -26,4 +28,21 @@ class CalendarHostViewModel @Inject constructor(
|
|||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = null,
|
initialValue = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
|
||||||
|
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
|
||||||
|
.map { it.cycle }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = IMPLEMENTED_VIEWS,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Order of the views in the navigation drawer (#24); every view always shown. */
|
||||||
|
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = IMPLEMENTED_VIEWS,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
@@ -49,6 +51,12 @@ fun RootScreen(
|
|||||||
hasPermission = ContextCompat.checkSelfPermission(
|
hasPermission = ContextCompat.checkSelfPermission(
|
||||||
context, Manifest.permission.READ_CALENDAR
|
context, Manifest.permission.READ_CALENDAR
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
// Refresh the contact special-dates mirror on foreground (the
|
||||||
|
// worker is debounced and no-ops when the feature is off). Gated
|
||||||
|
// on the permission so users without the opt-in never enqueue it.
|
||||||
|
if (context.hasContactsPermission()) {
|
||||||
|
SpecialDatesScheduler.runNow(context, foreground = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lifecycle.addObserver(obs)
|
lifecycle.addObserver(obs)
|
||||||
|
|||||||
@@ -31,4 +31,13 @@ sealed interface WidgetNavRequest {
|
|||||||
|
|
||||||
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||||
data class Create(val dateIso: String?) : WidgetNavRequest
|
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the app rooted on a top-level [view] with no date drill-in — a widget
|
||||||
|
* header tap. A null [view] means "the user's default home view" (the agenda
|
||||||
|
* widget's "Upcoming" title, issue #20); a concrete view roots there over the
|
||||||
|
* default home (the month widget's month/year title → [CalendarView.Month],
|
||||||
|
* issue #18), so backing out returns to the default view, then exits.
|
||||||
|
*/
|
||||||
|
data class OpenView(val view: CalendarView?) : WidgetNavRequest
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
@@ -96,6 +97,8 @@ fun AgendaScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AgendaViewModel = hiltViewModel(),
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -120,6 +123,7 @@ fun AgendaScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = anchor,
|
currentDate = anchor,
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -140,7 +144,7 @@ fun AgendaScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
AgendaTopBar(
|
AgendaTopBar(
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||||
@@ -142,7 +142,7 @@ fun CalendarsScreen(
|
|||||||
sessionKey = editorSession,
|
sessionKey = editorSession,
|
||||||
isNew = editorId == NEW_CALENDAR_ID,
|
isNew = editorId == NEW_CALENDAR_ID,
|
||||||
initialName = editing?.displayName.orEmpty(),
|
initialName = editing?.displayName.orEmpty(),
|
||||||
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
|
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||||
initialDescription = editing?.description.orEmpty(),
|
initialDescription = editing?.description.orEmpty(),
|
||||||
onSave = { name, color, description ->
|
onSave = { name, color, description ->
|
||||||
val id = editorId
|
val id = editorId
|
||||||
@@ -507,7 +507,7 @@ private fun CalendarEditor(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
ColorSwatchRow(
|
ColorSwatchRow(
|
||||||
colors = CALENDAR_COLOR_PALETTE,
|
colors = CalendarColorPalette.all,
|
||||||
selected = color,
|
selected = color,
|
||||||
onSelect = { color = it },
|
onSelect = { color = it },
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -45,6 +47,8 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
private val icsExporter: IcsExporter,
|
private val icsExporter: IcsExporter,
|
||||||
private val prefs: CalendarPrefs,
|
private val prefs: CalendarPrefs,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
private val suppressedStore: SuppressedReminderStore,
|
||||||
|
private val notifier: ReminderNotifier,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -144,7 +148,10 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val current = prefs.disabledCalendarIds.first()
|
val current = prefs.disabledCalendarIds.first()
|
||||||
val next = if (disabled) current + id else current - id
|
val next = if (disabled) current + id else current - id
|
||||||
if (next != current) prefs.setDisabledCalendarIds(next)
|
if (next != current) {
|
||||||
|
prefs.setDisabledCalendarIds(next)
|
||||||
|
if (!disabled) recoverReminders(setOf(id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +164,29 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val current = prefs.disabledCalendarIds.first()
|
val current = prefs.disabledCalendarIds.first()
|
||||||
val next = if (disabled) current + ids else current - ids.toSet()
|
val next = if (disabled) current + ids else current - ids.toSet()
|
||||||
if (next != current) prefs.setDisabledCalendarIds(next)
|
if (next != current) {
|
||||||
|
prefs.setDisabledCalendarIds(next)
|
||||||
|
if (!disabled) recoverReminders(current intersect ids.toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-post the reminders that fired while [reEnabledIds] were disabled and are
|
||||||
|
* still relevant (event not yet over), then drop them from the stash. Runs
|
||||||
|
* after the disabled set is written, so the notifier's own disabled gate lets
|
||||||
|
* them through. Best-effort at re-enable time: it mirrors the receiver gates
|
||||||
|
* (reminders on + postable), and there is no later re-scan, so alerts left
|
||||||
|
* unposted because those gates are closed are simply released.
|
||||||
|
*/
|
||||||
|
private suspend fun recoverReminders(reEnabledIds: Set<Long>) {
|
||||||
|
if (reEnabledIds.isEmpty()) return
|
||||||
|
val recovered = suppressedStore.recoverFor(reEnabledIds, System.currentTimeMillis())
|
||||||
|
if (recovered.isNotEmpty() &&
|
||||||
|
settingsPrefs.remindersEnabled.first() &&
|
||||||
|
notifier.canPost()
|
||||||
|
) {
|
||||||
|
recovered.forEach { notifier.post(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ fun CalendarDrawer(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
|
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
) {
|
) {
|
||||||
var showDatePicker by remember { mutableStateOf(false) }
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -73,10 +74,10 @@ fun CalendarDrawer(
|
|||||||
DrawerHeader()
|
DrawerHeader()
|
||||||
|
|
||||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
viewOrder.forEachIndexed { index, view ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(view.labelRes),
|
title = stringResource(view.labelRes),
|
||||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
position = positionOf(index, viewOrder.size),
|
||||||
selected = view == currentView,
|
selected = view == currentView,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { Icon(view.icon, contentDescription = null) },
|
leading = { Icon(view.icon, contentDescription = null) },
|
||||||
|
|||||||
@@ -45,11 +45,39 @@ val IMPLEMENTED_VIEWS: List<CalendarView> =
|
|||||||
|
|
||||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||||
|
if (available.isEmpty()) return this
|
||||||
val i = available.indexOf(this)
|
val i = available.indexOf(this)
|
||||||
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
if (i < 0) return available.first()
|
||||||
return available[(i + 1) % available.size]
|
return available[(i + 1) % available.size]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's customisation of the top-bar quick-switch button (#24): which views
|
||||||
|
* it cycles through ([enabled]) and in what [order]. [order] always lists every
|
||||||
|
* implemented view — the settings screen reorders the whole set — while [cycle]
|
||||||
|
* is the subset the pill actually steps through, in [order]. The navigation
|
||||||
|
* drawer keeps its own separate order and always lists every view, so a view
|
||||||
|
* disabled here stays reachable there.
|
||||||
|
*/
|
||||||
|
data class QuickSwitchConfig(
|
||||||
|
val order: List<CalendarView>,
|
||||||
|
val enabled: Set<CalendarView>,
|
||||||
|
) {
|
||||||
|
/** Views the pill steps through, in [order]. */
|
||||||
|
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** All views, in default order, all enabled. */
|
||||||
|
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fewest views that keep the switch meaningful — a "switch" needs at
|
||||||
|
* least two targets, so the settings screen blocks disabling below this.
|
||||||
|
*/
|
||||||
|
const val MIN_ENABLED = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The top-level view back stack (bottom → top): the [default] home view always
|
* The top-level view back stack (bottom → top): the [default] home view always
|
||||||
* sits at the bottom. Pressing back pops one level until only the home view
|
* sits at the bottom. Pressing back pops one level until only the home view
|
||||||
|
|||||||
@@ -67,16 +67,3 @@ fun ColorSwatchRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / `EVENT_COLOR`. */
|
|
||||||
val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
|
|
||||||
0xFFD50000, // red
|
|
||||||
0xFFE67C00, // orange
|
|
||||||
0xFFF6BF26, // amber
|
|
||||||
0xFF33B679, // green
|
|
||||||
0xFF0B8043, // dark green
|
|
||||||
0xFF039BE5, // blue
|
|
||||||
0xFF3F51B5, // indigo
|
|
||||||
0xFF8E24AA, // purple
|
|
||||||
0xFF616161, // graphite
|
|
||||||
).map { it.toInt() }
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
|
||||||
|
* the app. Deliberately a stark, un-themed marker (not a product component) so a
|
||||||
|
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
|
||||||
|
* call site so it never reaches a release build. Non-interactive: it's a plain
|
||||||
|
* label with no pointer handler, so taps fall through to whatever is beneath it.
|
||||||
|
*
|
||||||
|
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
|
||||||
|
* so it overlays the UI.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BoxScope.DebugRibbon() {
|
||||||
|
Text(
|
||||||
|
text = "DEBUG",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = 1.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.zIndex(1f)
|
||||||
|
// Push the band out so its midline crosses the very corner, then
|
||||||
|
// rotate it to the classic 45° ribbon.
|
||||||
|
.offset(x = 36.dp, y = 24.dp)
|
||||||
|
.rotate(45f)
|
||||||
|
.background(Color(0xFFB23B00))
|
||||||
|
.width(140.dp)
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -57,10 +58,16 @@ fun positionOf(index: Int, count: Int): Position = when {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar]
|
* The app's standard full-screen list scaffold. By default a collapsing
|
||||||
* whose title shrinks into the bar (next to the back button) as the content
|
* [LargeTopAppBar] whose title shrinks into the bar (next to the back button) as
|
||||||
* scrolls. Content is a scrollable column that feeds the toolbar via nested
|
* the content scrolls — used by Settings and the calendar manager, where the
|
||||||
* scroll. Used by Settings and the calendar manager so they share one shell.
|
* large header sets the page. Content is a scrollable column that feeds the
|
||||||
|
* toolbar via nested scroll.
|
||||||
|
*
|
||||||
|
* Set [largeTopBar] to false for a pinned, single-line [TopAppBar] instead: the
|
||||||
|
* title sits in the bar from the start, with no expanded header to scroll past.
|
||||||
|
* Preferred for selection pickers, where the tall header is just wasted space
|
||||||
|
* above the options.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -68,12 +75,16 @@ fun CollapsingScaffold(
|
|||||||
title: String,
|
title: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
largeTopBar: Boolean = true,
|
||||||
snackbarHost: @Composable () -> Unit = {},
|
snackbarHost: @Composable () -> Unit = {},
|
||||||
actions: @Composable RowScope.() -> Unit = {},
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val scrollBehavior =
|
val scrollBehavior = if (largeTopBar) {
|
||||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
|
} else {
|
||||||
|
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
|
}
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.predictiveBack(onBack = onBack)
|
.predictiveBack(onBack = onBack)
|
||||||
@@ -81,22 +92,34 @@ fun CollapsingScaffold(
|
|||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
LargeTopAppBar(
|
val navigationIcon = @Composable {
|
||||||
title = { Text(title) },
|
IconButton(onClick = onBack) {
|
||||||
navigationIcon = {
|
Icon(
|
||||||
IconButton(onClick = onBack) {
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
Icon(
|
contentDescription = stringResource(R.string.settings_back),
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
)
|
||||||
contentDescription = stringResource(R.string.settings_back),
|
}
|
||||||
)
|
}
|
||||||
}
|
val colors = TopAppBarDefaults.topAppBarColors(
|
||||||
},
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
actions = actions,
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
if (largeTopBar) {
|
||||||
|
LargeTopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
actions = actions,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = colors,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
actions = actions,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = colors,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
snackbarHost = snackbarHost,
|
snackbarHost = snackbarHost,
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
@@ -142,6 +165,9 @@ fun GroupedRow(
|
|||||||
dimmed: Boolean = false,
|
dimmed: Boolean = false,
|
||||||
container: Color? = null,
|
container: Color? = null,
|
||||||
minHeight: Dp = 72.dp,
|
minHeight: Dp = 72.dp,
|
||||||
|
// The 2.dp separation between rows in a run. Suppressed by the reorderable
|
||||||
|
// list, which owns uniform spacing itself so every slot has the same pitch.
|
||||||
|
gapBelow: Boolean = true,
|
||||||
leading: @Composable (() -> Unit)? = null,
|
leading: @Composable (() -> Unit)? = null,
|
||||||
trailing: @Composable (() -> Unit)? = null,
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
@@ -160,9 +186,10 @@ fun GroupedRow(
|
|||||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val gap = when (position) {
|
val gap = when {
|
||||||
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
!gapBelow -> Modifier
|
||||||
Position.Bottom, Position.Alone -> Modifier
|
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||||
|
else -> Modifier
|
||||||
}
|
}
|
||||||
val itemColors = if (selected) {
|
val itemColors = if (selected) {
|
||||||
ListItemDefaults.colors(
|
ListItemDefaults.colors(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ fun InlineTextField(
|
|||||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||||
singleLine: Boolean = true,
|
singleLine: Boolean = true,
|
||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
|
enabled: Boolean = true,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
||||||
imeAction: ImeAction = ImeAction.Default,
|
imeAction: ImeAction = ImeAction.Default,
|
||||||
@@ -43,15 +44,17 @@ fun InlineTextField(
|
|||||||
onImeAction: (() -> Unit)? = null,
|
onImeAction: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val resolvedStyle = textStyle.copy(
|
val resolvedStyle = textStyle.copy(
|
||||||
color = if (textStyle.color.isSpecified) {
|
color = when {
|
||||||
textStyle.color
|
// A disabled field reads as dimmed, like a locked value.
|
||||||
} else {
|
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
MaterialTheme.colorScheme.onSurface
|
textStyle.color.isSpecified -> textStyle.color
|
||||||
|
else -> MaterialTheme.colorScheme.onSurface
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
|
enabled = enabled,
|
||||||
textStyle = resolvedStyle,
|
textStyle = resolvedStyle,
|
||||||
singleLine = singleLine,
|
singleLine = singleLine,
|
||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -23,9 +24,11 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -43,8 +46,9 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||||
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a
|
* reuses the app's [CollapsingScaffold] (back button + full width), but with a
|
||||||
* picker is visually identical to a Settings sub-page and uses the full width.
|
* pinned single-line title rather than the large collapsing header — a picker is
|
||||||
|
* a short list, so the tall header would only be empty space to scroll past.
|
||||||
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -69,7 +73,12 @@ fun FullScreenPicker(
|
|||||||
(view.parent as? DialogWindowProvider)?.window
|
(view.parent as? DialogWindowProvider)?.window
|
||||||
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
}
|
}
|
||||||
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
|
CollapsingScaffold(
|
||||||
|
title = title,
|
||||||
|
onBack = onDismiss,
|
||||||
|
largeTopBar = false,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,10 +120,19 @@ fun <T> OptionPicker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
|
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||||
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
|
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
||||||
* a "Custom" row that expands an inline number field plus a segmented unit
|
* independently, so a default can carry several reminders ("1 week before" *and*
|
||||||
* selector. Returns the choice as a [CalendarReminderOverride].
|
* "on the day"). Two exclusive checkmark rows sit above the list: an explicit
|
||||||
|
* "No reminder" (both pickers, so the empty state stays visible and reachable)
|
||||||
|
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
|
||||||
|
* defers to the global default. Clearing the last checked time reverts to "Use
|
||||||
|
* default" on a per-calendar picker (so an accidental toggle-undo can't silently
|
||||||
|
* wipe the calendar's default) and to explicit [CalendarReminderOverride.None]
|
||||||
|
* on the global default (where empty legitimately means no reminder). A "Custom"
|
||||||
|
* row expands an inline number field plus a unit selector to add an arbitrary
|
||||||
|
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
||||||
|
* back.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderDefaultPicker(
|
fun ReminderDefaultPicker(
|
||||||
@@ -125,57 +143,91 @@ fun ReminderDefaultPicker(
|
|||||||
onSelect: (CalendarReminderOverride) -> Unit,
|
onSelect: (CalendarReminderOverride) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
|
// Optimistic local state: once the user edits, the chosen override is
|
||||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
// authoritative while the picker is open, so quick successive toggles compose
|
||||||
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
// on each other instead of racing the round-trip through the settings flow
|
||||||
|
// (which would drop a toggle made before the previous write echoes back).
|
||||||
|
var current by remember { mutableStateOf(selected) }
|
||||||
|
// Whether the user has toggled anything yet. Until then, [current] keeps
|
||||||
|
// mirroring [selected]: a picker that first composed against the settings
|
||||||
|
// flow's initialValue (empty — reachable after process-death restore straight
|
||||||
|
// onto the Notifications page, before the CalendarProvider-gated combine
|
||||||
|
// resolves) then adopts the real stored default instead of persisting empty
|
||||||
|
// on the first tap. After the first edit, local state is authoritative.
|
||||||
|
var userEdited by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(selected) {
|
||||||
|
if (!userEdited) current = selected
|
||||||
|
}
|
||||||
|
val inherits = current is CalendarReminderOverride.Inherit
|
||||||
|
val isNone = current is CalendarReminderOverride.None
|
||||||
|
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
||||||
|
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
||||||
|
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
|
||||||
|
// which would strand a hand-entered value with no way to re-check it.
|
||||||
|
val seenCustom = remember { mutableSetOf<Int>() }
|
||||||
|
seenCustom += selectedMinutes.filter { it !in presets }
|
||||||
|
// Presets plus every custom value seen this session, each as its own row.
|
||||||
|
val rows = presets + seenCustom.sorted()
|
||||||
|
|
||||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
var amountText by rememberSaveable { mutableStateOf(seed.first) }
|
var amountText by rememberSaveable { mutableStateOf("") }
|
||||||
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||||
|
|
||||||
val options = buildList {
|
fun apply(override: CalendarReminderOverride) {
|
||||||
if (allowInherit) add(CalendarReminderOverride.Inherit)
|
userEdited = true
|
||||||
add(CalendarReminderOverride.None)
|
current = override
|
||||||
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
|
onSelect(override)
|
||||||
}
|
}
|
||||||
val rowCount = options.size + 1 // + the custom row
|
fun emit(minutes: List<Int>) = apply(reminderOverrideForMinutes(minutes, allowInherit))
|
||||||
|
fun toggle(minute: Int) =
|
||||||
|
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
options.forEachIndexed { index, option ->
|
// Exclusive choices in their own group above the multi-select list, since
|
||||||
val isSelected = option == selected
|
// each is mutually exclusive with picking specific times: "use default"
|
||||||
|
// (per-calendar only) and an explicit "no reminder" (both pickers, so the
|
||||||
|
// empty state stays visible and deliberately reachable).
|
||||||
|
if (allowInherit) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = reminderOverrideLabel(option),
|
title = stringResource(R.string.reminder_use_default),
|
||||||
position = positionOf(index, rowCount),
|
position = Position.Top,
|
||||||
selected = isSelected,
|
selected = inherits,
|
||||||
trailing = if (isSelected) {
|
trailing = if (inherits) {
|
||||||
{ SelectedCheck() }
|
{ SelectedCheck() }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = { apply(CalendarReminderOverride.Inherit) },
|
||||||
onSelect(option)
|
)
|
||||||
onDismiss()
|
}
|
||||||
},
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.reminder_none),
|
||||||
|
position = if (allowInherit) Position.Bottom else Position.Alone,
|
||||||
|
selected = isNone,
|
||||||
|
trailing = if (isNone) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { apply(CalendarReminderOverride.None) },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
val rowCount = rows.size + 1 // + the custom row
|
||||||
|
rows.forEachIndexed { index, minute ->
|
||||||
|
val checked = minute in selectedMinutes
|
||||||
|
GroupedRow(
|
||||||
|
title = reminderLeadTimeLabel(minute),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
|
selected = checked,
|
||||||
|
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||||
|
onClick = { toggle(minute) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// When expanded, the Custom row connects downward into the editor card
|
// When expanded, the Custom row connects downward into the editor card
|
||||||
// so the two read as one grouped container (the per-calendar pattern).
|
// so the two read as one grouped container (the per-calendar pattern).
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = if (customSelected) {
|
title = stringResource(R.string.event_edit_reminder_custom),
|
||||||
stringResource(
|
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
|
||||||
R.string.reminder_custom_with_value,
|
|
||||||
reminderLeadTimeLabel(selectedMinutes!!),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.event_edit_reminder_custom)
|
|
||||||
},
|
|
||||||
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
|
|
||||||
selected = customSelected,
|
|
||||||
trailing = if (customSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { customExpanded = !customExpanded },
|
onClick = { customExpanded = !customExpanded },
|
||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -189,14 +241,36 @@ fun ReminderDefaultPicker(
|
|||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
onSelect(CalendarReminderOverride.Minutes(minutes))
|
emit(selectedMinutes + minutes)
|
||||||
onDismiss()
|
amountText = ""
|
||||||
|
customExpanded = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The override a lead-time set maps to when emitted from [ReminderDefaultPicker].
|
||||||
|
* A non-empty set is [CalendarReminderOverride.Minutes]; an empty set (the last
|
||||||
|
* time was unchecked) reverts to [CalendarReminderOverride.Inherit] on a
|
||||||
|
* per-calendar picker ([allowInherit]) so an accidental toggle-undo can't
|
||||||
|
* silently wipe the calendar's default, and to explicit
|
||||||
|
* [CalendarReminderOverride.None] on the global default (where empty legitimately
|
||||||
|
* means no reminder). Pure so it can be unit-tested.
|
||||||
|
*/
|
||||||
|
internal fun reminderOverrideForMinutes(
|
||||||
|
minutes: List<Int>,
|
||||||
|
allowInherit: Boolean,
|
||||||
|
): CalendarReminderOverride {
|
||||||
|
val norm = minutes.distinct().sorted()
|
||||||
|
return when {
|
||||||
|
norm.isNotEmpty() -> CalendarReminderOverride.Minutes(norm)
|
||||||
|
allowInherit -> CalendarReminderOverride.Inherit
|
||||||
|
else -> CalendarReminderOverride.None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
||||||
* row above it (matching the grouped-row system, so the two read as one
|
* row above it (matching the grouped-row system, so the two read as one
|
||||||
@@ -428,18 +502,3 @@ fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
|||||||
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
|
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
|
||||||
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
|
||||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
|
||||||
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
|
|
||||||
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
|
|
||||||
minutes == null -> "" to ReminderUnit.Minutes
|
|
||||||
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
|
|
||||||
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
|
|
||||||
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
|
|
||||||
else -> minutes.toString() to ReminderUnit.Minutes
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
|
||||||
|
val ReorderableRowHeight: Dp = 64.dp
|
||||||
|
private val RowGap: Dp = 2.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A vertical list whose rows can be dragged into a new order by their handle.
|
||||||
|
*
|
||||||
|
* Built for the short, fixed grouped-card lists in Settings (#24) — no external
|
||||||
|
* dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings
|
||||||
|
* screens are a single [androidx.compose.foundation.verticalScroll] column, which
|
||||||
|
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
|
||||||
|
* uniform gap, so a row's target slot is simply how many whole pitches it has
|
||||||
|
* been dragged. The held row follows the finger while the others slide out of
|
||||||
|
* its way (animated); on release the order is committed immediately with a
|
||||||
|
* single [onReorder] call, then the held row eases into its new slot as a
|
||||||
|
* purely visual settle — safe to interrupt with another drag, since the
|
||||||
|
* commit itself never waits on it.
|
||||||
|
*
|
||||||
|
* [rowContent] receives the [Position] for the row's place in the order (to reuse
|
||||||
|
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
|
||||||
|
* and a `dragHandle` [Modifier] to attach to the element that starts a drag.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> ReorderableColumn(
|
||||||
|
items: List<T>,
|
||||||
|
keyOf: (T) -> Any,
|
||||||
|
onReorder: (List<T>) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Local working copy; re-seeded when the incoming list changes (including the
|
||||||
|
// echo of our own committed order).
|
||||||
|
var order by remember(items) { mutableStateOf(items) }
|
||||||
|
var draggedKey by remember { mutableStateOf<Any?>(null) }
|
||||||
|
// Live translation of the held row from its slot (px); also drives the
|
||||||
|
// release settle — re-based onto the row's new slot once the order commits
|
||||||
|
// (see onDragEnd), then eased down to zero.
|
||||||
|
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||||
|
// The running release/cancel settle, cancelled if a new drag pre-empts it.
|
||||||
|
// Purely visual — the order commit (see onDragEnd) never depends on it.
|
||||||
|
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
|
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||||
|
// Whole slots dragged → the slot the held row currently hovers over. Derived
|
||||||
|
// so recomposition only fires when the *target slot* actually changes, not on
|
||||||
|
// every dragged pixel: dragOffset moves every frame during a drag, but the
|
||||||
|
// held row's own translation is already applied in the draw phase via
|
||||||
|
// graphicsLayer below, so only the neighbours' shift (which depends on this)
|
||||||
|
// needs to recompose, and only when a slot boundary is actually crossed.
|
||||||
|
// (Read as a plain val, not `by`, below: a delegated property has a custom
|
||||||
|
// getter and Kotlin won't smart-cast it in the `when` over `targetIndex` in
|
||||||
|
// the loop, so the derived value is captured into a real local instead.)
|
||||||
|
val targetIndex = remember(items, pitchPx) {
|
||||||
|
derivedStateOf {
|
||||||
|
val idx = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||||
|
idx?.let { (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) }
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
|
||||||
|
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
|
||||||
|
order.forEachIndexed { index, item ->
|
||||||
|
val key = keyOf(item)
|
||||||
|
val isDragged = key == draggedKey
|
||||||
|
|
||||||
|
// Slide neighbours by one pitch to open the gap the held row will drop
|
||||||
|
// into. Snap (not animate) once idle, so committing the new order — which
|
||||||
|
// moves each row's slot — doesn't visibly fight a lingering animation.
|
||||||
|
val shift = when {
|
||||||
|
draggedIndex == null || targetIndex == null || isDragged -> 0f
|
||||||
|
index in (draggedIndex + 1)..targetIndex -> -pitchPx
|
||||||
|
index in targetIndex until draggedIndex -> pitchPx
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
val animatedShift by animateFloatAsState(
|
||||||
|
targetValue = shift,
|
||||||
|
animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(),
|
||||||
|
label = "reorderShift",
|
||||||
|
)
|
||||||
|
|
||||||
|
val dragHandle = Modifier.pointerInput(key) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = {
|
||||||
|
settleJob?.cancel()
|
||||||
|
draggedKey = key
|
||||||
|
dragOffset = 0f
|
||||||
|
},
|
||||||
|
onDrag = { change, amount ->
|
||||||
|
change.consume()
|
||||||
|
dragOffset += amount.y
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
val from = order.indexOfFirst { keyOf(it) == key }
|
||||||
|
if (from < 0) return@detectDragGestures
|
||||||
|
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
||||||
|
if (to != from) {
|
||||||
|
// Commit synchronously and unconditionally, before the settle
|
||||||
|
// animation below runs — the commit must not depend on that
|
||||||
|
// coroutine reaching its end, or a new drag starting within the
|
||||||
|
// ~160ms settle window would cancel it and silently revert an
|
||||||
|
// already-finished reorder.
|
||||||
|
order = order.toMutableList().apply { add(to, removeAt(from)) }
|
||||||
|
onReorder(order)
|
||||||
|
// The row now lays out at slot `to` instead of `from`; re-base
|
||||||
|
// the live offset onto that new slot (same visual position,
|
||||||
|
// expressed relative to the new one) so the settle below eases
|
||||||
|
// it the rest of the way instead of jumping.
|
||||||
|
dragOffset -= (to - from) * pitchPx
|
||||||
|
}
|
||||||
|
settleJob = scope.launch {
|
||||||
|
// Purely visual from here: ease the held row onto its slot, then
|
||||||
|
// release the drag state. Safe to cancel — the order was already
|
||||||
|
// committed above.
|
||||||
|
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
||||||
|
draggedKey = null
|
||||||
|
dragOffset = 0f
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
settleJob = scope.launch {
|
||||||
|
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
||||||
|
draggedKey = null
|
||||||
|
dragOffset = 0f
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.height(ReorderableRowHeight)
|
||||||
|
.zIndex(if (isDragged) 1f else 0f)
|
||||||
|
.graphicsLayer {
|
||||||
|
translationY = if (isDragged) dragOffset else animatedShift
|
||||||
|
if (isDragged) {
|
||||||
|
scaleX = 1.02f
|
||||||
|
scaleY = 1.02f
|
||||||
|
shadowElevation = 8.dp.toPx()
|
||||||
|
shape = RoundedCornerShape(20.dp)
|
||||||
|
clip = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
rowContent(item, positionOf(index, order.size), dragHandle, isDragged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
@@ -116,6 +117,8 @@ fun DayScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
@@ -175,6 +178,7 @@ fun DayScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = date,
|
currentDate = date,
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -196,7 +200,7 @@ fun DayScreen(
|
|||||||
DayTopBar(
|
DayTopBar(
|
||||||
date = date,
|
date = date,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
|
|||||||
@@ -250,14 +250,18 @@ fun EventDetailScreen(
|
|||||||
contentDescription = stringResource(R.string.event_detail_edit),
|
contentDescription = stringResource(R.string.event_detail_edit),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(
|
// Managed special-dates events have no delete — the sync
|
||||||
onClick = onDeleteClick,
|
// would resurrect them; the date is removed at the contact.
|
||||||
enabled = deleteState != DeleteUiState.Deleting,
|
if (!s.isManaged) {
|
||||||
) {
|
IconButton(
|
||||||
Icon(
|
onClick = onDeleteClick,
|
||||||
imageVector = Icons.Default.Delete,
|
enabled = deleteState != DeleteUiState.Deleting,
|
||||||
contentDescription = stringResource(R.string.event_detail_delete),
|
) {
|
||||||
)
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_delete),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ sealed interface EventDetailUiState {
|
|||||||
val calendarName: String?,
|
val calendarName: String?,
|
||||||
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
||||||
val canModify: Boolean = false,
|
val canModify: Boolean = false,
|
||||||
|
/**
|
||||||
|
* The event belongs to a managed special-dates calendar: its editable
|
||||||
|
* fields (reminders, notes) can still be edited, but delete is hidden —
|
||||||
|
* the sync would just resurrect it. Contact dates are removed at the source.
|
||||||
|
*/
|
||||||
|
val isManaged: Boolean = false,
|
||||||
) : EventDetailUiState
|
) : EventDetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
detail = corrected,
|
detail = corrected,
|
||||||
calendarName = calendar?.displayName,
|
calendarName = calendar?.displayName,
|
||||||
canModify = calendar?.canModifyContents == true,
|
canModify = calendar?.canModifyContents == true,
|
||||||
|
isManaged = calendar?.isManaged == true,
|
||||||
)
|
)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import androidx.compose.material.icons.automirrored.filled.Notes
|
|||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Contacts
|
import androidx.compose.material.icons.filled.Contacts
|
||||||
import androidx.compose.material.icons.filled.EventAvailable
|
import androidx.compose.material.icons.filled.EventAvailable
|
||||||
@@ -119,13 +120,16 @@ import de.jeanlucmakiola.calendula.domain.toRRule
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
@@ -435,6 +439,10 @@ private fun EventEditContent(
|
|||||||
val form = state.form
|
val form = state.form
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
// The title, date and recurrence are managed by the special-dates sync, so
|
||||||
|
// they're locked here; everything else (reminders, location, notes) is the
|
||||||
|
// user's to edit.
|
||||||
|
val locked = state.isManaged
|
||||||
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -519,6 +527,7 @@ private fun EventEditContent(
|
|||||||
placeholder = stringResource(R.string.event_edit_title_hint),
|
placeholder = stringResource(R.string.event_edit_title_hint),
|
||||||
textStyle = MaterialTheme.typography.headlineMedium
|
textStyle = MaterialTheme.typography.headlineMedium
|
||||||
.copy(fontWeight = FontWeight.SemiBold),
|
.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
enabled = !locked,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
@@ -531,6 +540,17 @@ private fun EventEditContent(
|
|||||||
.height(3.dp)
|
.height(3.dp)
|
||||||
.background(accent, RoundedCornerShape(2.dp)),
|
.background(accent, RoundedCornerShape(2.dp)),
|
||||||
)
|
)
|
||||||
|
if (locked) {
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.event_edit_managed_hint,
|
||||||
|
selectedCalendar?.displayName.orEmpty(),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
@@ -549,7 +569,11 @@ private fun EventEditContent(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay)
|
Switch(
|
||||||
|
checked = form.isAllDay,
|
||||||
|
onCheckedChange = viewModel::setAllDay,
|
||||||
|
enabled = !locked,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -561,6 +585,7 @@ private fun EventEditContent(
|
|||||||
locale = locale,
|
locale = locale,
|
||||||
onDateClick = { picker = PickerTarget.StartDate },
|
onDateClick = { picker = PickerTarget.StartDate },
|
||||||
onTimeClick = { picker = PickerTarget.StartTime },
|
onTimeClick = { picker = PickerTarget.StartTime },
|
||||||
|
enabled = !locked,
|
||||||
)
|
)
|
||||||
ScheduleRow(
|
ScheduleRow(
|
||||||
label = stringResource(R.string.event_edit_ends),
|
label = stringResource(R.string.event_edit_ends),
|
||||||
@@ -570,6 +595,7 @@ private fun EventEditContent(
|
|||||||
onDateClick = { picker = PickerTarget.EndDate },
|
onDateClick = { picker = PickerTarget.EndDate },
|
||||||
onTimeClick = { picker = PickerTarget.EndTime },
|
onTimeClick = { picker = PickerTarget.EndTime },
|
||||||
isError = EventFormProblem.EndBeforeStart in state.problems,
|
isError = EventFormProblem.EndBeforeStart in state.problems,
|
||||||
|
enabled = !locked,
|
||||||
)
|
)
|
||||||
if (EventFormProblem.EndBeforeStart in state.problems) {
|
if (EventFormProblem.EndBeforeStart in state.problems) {
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
@@ -700,7 +726,7 @@ private fun EventEditContent(
|
|||||||
EditCard(
|
EditCard(
|
||||||
icon = Icons.Default.Repeat,
|
icon = Icons.Default.Repeat,
|
||||||
iconContentDescription = null,
|
iconContentDescription = null,
|
||||||
onClick = { showRecurrencePicker = true },
|
onClick = if (locked) null else ({ showRecurrencePicker = true }),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -943,7 +969,7 @@ private fun EventEditContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showCalendarPicker) {
|
if (showCalendarPicker) {
|
||||||
CalendarPickerDialog(
|
CalendarPicker(
|
||||||
calendars = state.calendars,
|
calendars = state.calendars,
|
||||||
selectedId = form.calendarId,
|
selectedId = form.calendarId,
|
||||||
onSelect = {
|
onSelect = {
|
||||||
@@ -1723,7 +1749,7 @@ private fun ColorPickerDialog(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ColorSwatchRow(
|
ColorSwatchRow(
|
||||||
colors = CALENDAR_COLOR_PALETTE,
|
colors = CalendarColorPalette.all,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
onSelect = onPickRaw,
|
onSelect = onPickRaw,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
@@ -1858,6 +1884,7 @@ private fun InlineField(
|
|||||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||||
singleLine: Boolean = true,
|
singleLine: Boolean = true,
|
||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
|
enabled: Boolean = true,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -1871,6 +1898,7 @@ private fun InlineField(
|
|||||||
textStyle = textStyle,
|
textStyle = textStyle,
|
||||||
singleLine = singleLine,
|
singleLine = singleLine,
|
||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
|
enabled = enabled,
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1885,6 +1913,7 @@ private fun ScheduleRow(
|
|||||||
onDateClick: () -> Unit,
|
onDateClick: () -> Unit,
|
||||||
onTimeClick: () -> Unit,
|
onTimeClick: () -> Unit,
|
||||||
isError: Boolean = false,
|
isError: Boolean = false,
|
||||||
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val dateFormat = remember(locale) {
|
val dateFormat = remember(locale) {
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
@@ -1894,11 +1923,12 @@ private fun ScheduleRow(
|
|||||||
timeOfDayFormatter(use24Hour, locale)
|
timeOfDayFormatter(use24Hour, locale)
|
||||||
}
|
}
|
||||||
// Tappable values read as links (primary), like the location on the
|
// Tappable values read as links (primary), like the location on the
|
||||||
// detail screen; errors flip them to the error colour.
|
// detail screen; errors flip them to the error colour; a locked (managed)
|
||||||
val valueColor = if (isError) {
|
// row dims to a plain, non-tappable value.
|
||||||
MaterialTheme.colorScheme.error
|
val valueColor = when {
|
||||||
} else {
|
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
MaterialTheme.colorScheme.primary
|
isError -> MaterialTheme.colorScheme.error
|
||||||
|
else -> MaterialTheme.colorScheme.primary
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -1915,7 +1945,7 @@ private fun ScheduleRow(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = valueColor,
|
color = valueColor,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clickable(onClick = onDateClick)
|
.clickable(enabled = enabled, onClick = onDateClick)
|
||||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||||
)
|
)
|
||||||
if (!isAllDay) {
|
if (!isAllDay) {
|
||||||
@@ -1924,41 +1954,71 @@ private fun ScheduleRow(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = valueColor,
|
color = valueColor,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clickable(onClick = onTimeClick)
|
.clickable(enabled = enabled, onClick = onTimeClick)
|
||||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen calendar picker: a scrollable list of every writable calendar,
|
||||||
|
* grouped under its owning account like the visibility filter and calendar
|
||||||
|
* manager. Replaces the former fixed-height [AlertDialog], which clipped the
|
||||||
|
* list past ~9 rows so accounts with many calendars had unreachable entries
|
||||||
|
* (#29). Selecting a row calls [onSelect]; the caller closes the picker.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun CalendarPickerDialog(
|
private fun CalendarPicker(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
selectedId: Long?,
|
selectedId: Long?,
|
||||||
onSelect: (Long) -> Unit,
|
onSelect: (Long) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
// Group by owning account (name, else type, else the calendar's own name),
|
||||||
AlertDialog(
|
// preserving the provider's order within and across groups — the same
|
||||||
onDismissRequest = onDismiss,
|
// grouping [groupByAccount] applies for the drawer filter.
|
||||||
title = { Text(stringResource(R.string.event_detail_calendar)) },
|
val groups = remember(calendars) {
|
||||||
text = {
|
calendars
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
.groupBy {
|
||||||
calendars.forEach { calendar ->
|
it.accountName.takeIf(String::isNotBlank)
|
||||||
OptionCard(
|
?: it.accountType.takeIf(String::isNotBlank)
|
||||||
label = calendar.displayName,
|
?: it.displayName
|
||||||
onClick = { onSelect(calendar.id) },
|
|
||||||
icon = Icons.Default.CalendarMonth,
|
|
||||||
// The calendar's own colour carries its identity.
|
|
||||||
iconTint = pastelize(calendar.color, dark),
|
|
||||||
supportingText = calendar.accountName.takeIf { it.isNotBlank() },
|
|
||||||
selected = calendar.id == selectedId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
.toList()
|
||||||
confirmButton = {
|
}
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
FullScreenPicker(
|
||||||
},
|
title = stringResource(R.string.event_detail_calendar),
|
||||||
)
|
onDismiss = onDismiss,
|
||||||
|
) {
|
||||||
|
groups.forEach { (account, cals) ->
|
||||||
|
Text(
|
||||||
|
text = account,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
cals.forEachIndexed { index, calendar ->
|
||||||
|
val isSelected = calendar.id == selectedId
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
position = positionOf(index, cals.size),
|
||||||
|
selected = isSelected,
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { onSelect(calendar.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ data class EventEditUiState(
|
|||||||
val hiddenFields: List<EventFormField> = emptyList(),
|
val hiddenFields: List<EventFormField> = emptyList(),
|
||||||
/** True while editing an existing event (the calendar is then fixed). */
|
/** True while editing an existing event (the calendar is then fixed). */
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
|
/**
|
||||||
|
* True when the event's calendar is a contact special-dates calendar the app
|
||||||
|
* manages. While editing such an event the title, date and recurrence are
|
||||||
|
* locked (sync overwrites them); reminders, location and notes stay editable.
|
||||||
|
*/
|
||||||
|
val isManaged: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* Whether to focus the title and raise the keyboard when this is a fresh
|
* Whether to focus the title and raise the keyboard when this is a fresh
|
||||||
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
||||||
|
|||||||
@@ -108,10 +108,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private data class ReminderDefaults(
|
private data class ReminderDefaults(
|
||||||
val timed: Int?,
|
val timed: List<Int>,
|
||||||
val allDay: Int?,
|
val allDay: List<Int>,
|
||||||
val timedOverrides: Map<Long, Int?>,
|
val timedOverrides: Map<Long, List<Int>>,
|
||||||
val allDayOverrides: Map<Long, Int?>,
|
val allDayOverrides: Map<Long, List<Int>>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ExternalInputs(
|
private data class ExternalInputs(
|
||||||
@@ -122,18 +122,24 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val autofocusTitle: Boolean,
|
val autofocusTitle: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Every calendar the provider exposes; the source for both lists below. */
|
||||||
|
private val allCalendars: Flow<List<CalendarSource>> =
|
||||||
|
repository.calendars().catch { emit(emptyList()) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writable calendars — the only valid event targets. Disabled calendars are
|
* Writable calendars — the only valid event targets. Disabled calendars are
|
||||||
* excluded, so you can't create into a calendar you've removed from the app;
|
* excluded, so you can't create into a calendar you've removed from the app;
|
||||||
* a last-used preselect landing on a now-disabled calendar falls back to the
|
* a last-used preselect landing on a now-disabled calendar falls back to the
|
||||||
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
||||||
|
* Managed special-dates calendars are excluded too: their events are owned by
|
||||||
|
* the contact sync, which would delete any user event created there.
|
||||||
*/
|
*/
|
||||||
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
||||||
repository.calendars(),
|
allCalendars,
|
||||||
prefs.disabledCalendarIds,
|
prefs.disabledCalendarIds,
|
||||||
) { calendars, disabled ->
|
) { calendars, disabled ->
|
||||||
calendars.filter { it.canModifyContents && it.id !in disabled }
|
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
|
||||||
}.catch { emit(emptyList()) }
|
}
|
||||||
|
|
||||||
/** The target calendar id, resolved exactly as the form shows it. */
|
/** The target calendar id, resolved exactly as the form shows it. */
|
||||||
private val resolvedCalendarId: Flow<Long?> = combine(
|
private val resolvedCalendarId: Flow<Long?> = combine(
|
||||||
@@ -165,21 +171,33 @@ class EventEditViewModel @Inject constructor(
|
|||||||
::ExternalInputs,
|
::ExternalInputs,
|
||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
colorPalette,
|
colorPalette,
|
||||||
) { local, external, palette ->
|
allCalendars,
|
||||||
|
) { local, external, palette, allCalendars ->
|
||||||
val form = local.form ?: return@combine null
|
val form = local.form ?: return@combine null
|
||||||
val resolvedId = form.calendarId
|
val resolvedId = form.calendarId
|
||||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||||
?: external.writable.firstOrNull()?.id
|
?: external.writable.firstOrNull()?.id
|
||||||
val resolved = form.copy(calendarId = resolvedId)
|
val resolved = form.copy(calendarId = resolvedId)
|
||||||
|
val resolvedCalendar = allCalendars.firstOrNull { it.id == resolvedId }
|
||||||
|
// Editing an event in a managed calendar locks its synced fields. Keyed
|
||||||
|
// off the calendar's durable marker, not a stored id, so it holds after a
|
||||||
|
// backup restore too.
|
||||||
|
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
|
||||||
|
// The picker offers writable calendars only; when editing a managed event
|
||||||
|
// its own (excluded) calendar is added back so the row still names it.
|
||||||
|
val pickerCalendars =
|
||||||
|
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
||||||
|
else external.writable
|
||||||
val visibleFields = external.defaultFields + local.revealed
|
val visibleFields = external.defaultFields + local.revealed
|
||||||
EventEditUiState(
|
EventEditUiState(
|
||||||
form = resolved,
|
form = resolved,
|
||||||
calendars = external.writable,
|
calendars = pickerCalendars,
|
||||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||||
saveState = local.saveState,
|
saveState = local.saveState,
|
||||||
visibleFields = visibleFields,
|
visibleFields = visibleFields,
|
||||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||||
isEditing = local.editTarget != null,
|
isEditing = local.editTarget != null,
|
||||||
|
isManaged = isManaged,
|
||||||
autofocusTitle = external.autofocusTitle,
|
autofocusTitle = external.autofocusTitle,
|
||||||
// A modified-occurrence exception can't carry its own rule, so
|
// A modified-occurrence exception can't carry its own rule, so
|
||||||
// the scope dialog drops "only this event" after a rule change.
|
// the scope dialog drops "only this event" after a rule change.
|
||||||
@@ -260,7 +278,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// Re-check after suspending: bail if the form closed or the user edited.
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
val form = _form.value ?: return@launch
|
val form = _form.value ?: return@launch
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
val default = resolveDefaultReminder(
|
val reminders = resolveDefaultReminder(
|
||||||
timedGlobal = defaults.timed,
|
timedGlobal = defaults.timed,
|
||||||
allDayGlobal = defaults.allDay,
|
allDayGlobal = defaults.allDay,
|
||||||
timedOverrides = defaults.timedOverrides,
|
timedOverrides = defaults.timedOverrides,
|
||||||
@@ -268,7 +286,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
calendarId = targetId,
|
calendarId = targetId,
|
||||||
isAllDay = form.isAllDay,
|
isAllDay = form.isAllDay,
|
||||||
)
|
)
|
||||||
val reminders = listOfNotNull(default)
|
|
||||||
_form.value = form.copy(reminders = reminders)
|
_form.value = form.copy(reminders = reminders)
|
||||||
// Surface the section so an auto-applied default is visible and
|
// Surface the section so an auto-applied default is visible and
|
||||||
// removable, even when Reminders isn't a default-shown field.
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
@@ -418,7 +435,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_saveState.value = SaveUiState.Saved
|
_saveState.value = SaveUiState.Saved
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (target != null && target.original.rrule != null) {
|
// 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) {
|
||||||
_saveState.value = SaveUiState.AwaitingScope
|
_saveState.value = SaveUiState.AwaitingScope
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
@@ -98,6 +99,8 @@ fun MonthScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MonthViewModel = hiltViewModel(),
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -157,6 +160,7 @@ fun MonthScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = LocalDate(month.year, month.month, 1),
|
currentDate = LocalDate(month.year, month.month, 1),
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -178,7 +182,7 @@ fun MonthScreen(
|
|||||||
MonthTopBar(
|
MonthTopBar(
|
||||||
month = month,
|
month = month,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.drawable.Icon
|
import android.graphics.drawable.Icon
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import android.text.format.DateFormat
|
import android.text.format.DateFormat
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
@@ -37,10 +39,14 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.BugReport
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
|
import androidx.compose.material.icons.filled.Cake
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Dashboard
|
import androidx.compose.material.icons.filled.Dashboard
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.DragHandle
|
||||||
|
import androidx.compose.material.icons.filled.SwapVert
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
@@ -51,6 +57,7 @@ import androidx.compose.material.icons.filled.Notifications
|
|||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.Translate
|
import androidx.compose.material.icons.filled.Translate
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material.icons.filled.UploadFile
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -60,6 +67,7 @@ import androidx.compose.material3.Switch
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -74,6 +82,7 @@ import androidx.compose.ui.res.colorResource
|
|||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -84,24 +93,35 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ReorderableRowHeight
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
||||||
@@ -115,13 +135,17 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
private enum class SettingsSection { Appearance, EventForm, Notifications }
|
private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token-based accent for a leading icon chip (container / on-container pair).
|
* Token-based accent for a leading icon chip (container / on-container pair).
|
||||||
@@ -166,6 +190,13 @@ fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||||
}
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = section == SettingsSection.Views,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||||
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.EventForm,
|
visible = section == SettingsSection.EventForm,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
@@ -178,7 +209,19 @@ fun SettingsScreen(
|
|||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
NotificationsScreen(
|
||||||
|
state = state,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onBack = { section = null },
|
||||||
|
onOpenSpecialDates = { section = SettingsSection.SpecialDates },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = section == SettingsSection.SpecialDates,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,6 +247,13 @@ private fun SettingsHub(
|
|||||||
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
|
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
|
||||||
onClick = { onOpenSection(SettingsSection.Appearance) },
|
onClick = { onOpenSection(SettingsSection.Appearance) },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_section_views),
|
||||||
|
summary = stringResource(R.string.settings_views_subtitle),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { CategoryIcon(Icons.Default.SwapVert, ChipAccent.Neutral) },
|
||||||
|
onClick = { onOpenSection(SettingsSection.Views) },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_section_event_form),
|
title = stringResource(R.string.settings_section_event_form),
|
||||||
summary = stringResource(R.string.settings_event_form_subtitle),
|
summary = stringResource(R.string.settings_event_form_subtitle),
|
||||||
@@ -225,6 +275,13 @@ private fun SettingsHub(
|
|||||||
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
||||||
onClick = onManageCalendars,
|
onClick = onManageCalendars,
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_section_special_dates),
|
||||||
|
summary = stringResource(R.string.settings_special_dates_subtitle),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Cake, ChipAccent.Tertiary) },
|
||||||
|
onClick = { onOpenSection(SettingsSection.SpecialDates) },
|
||||||
|
)
|
||||||
LanguageRow(position = Position.Middle)
|
LanguageRow(position = Position.Middle)
|
||||||
// One-tap add of the "New event" Quick Settings tile. The system prompt
|
// One-tap add of the "New event" Quick Settings tile. The system prompt
|
||||||
// is API 33+; on older versions the tile is still addable manually from
|
// is API 33+; on older versions the tile is still addable manually from
|
||||||
@@ -477,6 +534,18 @@ private fun AppearanceScreen(
|
|||||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||||
var showPastEvents by remember { mutableStateOf(false) }
|
var showPastEvents by remember { mutableStateOf(false) }
|
||||||
|
var showBrandFont by remember { mutableStateOf(false) }
|
||||||
|
var showPlainFont by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
||||||
|
// A picked file that didn't parse as a font: tell the user and keep the old choice.
|
||||||
|
val context = LocalContext.current
|
||||||
|
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.fontImportFailed.collect {
|
||||||
|
Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_appearance),
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
@@ -513,6 +582,24 @@ private fun AppearanceScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Fonts — the two Material typeface roles, each independently choosable
|
||||||
|
// (issue #19). Headings = brand (display/headline); body = plain
|
||||||
|
// (title/body/label). Both default to the system typeface.
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_font_headings),
|
||||||
|
summary = fontLabel(fonts.brand),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showBrandFont = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_font_body),
|
||||||
|
summary = fontLabel(fonts.plain),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showPlainFont = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// Calendar — view, week, and timeline formatting
|
// Calendar — view, week, and timeline formatting
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_default_view),
|
title = stringResource(R.string.settings_default_view),
|
||||||
@@ -604,6 +691,28 @@ private fun AppearanceScreen(
|
|||||||
onDismiss = { showTheme = false },
|
onDismiss = { showTheme = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showBrandFont) {
|
||||||
|
FontPicker(
|
||||||
|
title = stringResource(R.string.settings_font_headings),
|
||||||
|
role = FontRole.BRAND,
|
||||||
|
selected = fonts.brand,
|
||||||
|
stamp = fonts.brandStamp,
|
||||||
|
onSelect = { viewModel.setFont(FontRole.BRAND, it) },
|
||||||
|
onImport = { viewModel.importCustomFont(FontRole.BRAND, it) },
|
||||||
|
onDismiss = { showBrandFont = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showPlainFont) {
|
||||||
|
FontPicker(
|
||||||
|
title = stringResource(R.string.settings_font_body),
|
||||||
|
role = FontRole.PLAIN,
|
||||||
|
selected = fonts.plain,
|
||||||
|
stamp = fonts.plainStamp,
|
||||||
|
onSelect = { viewModel.setFont(FontRole.PLAIN, it) },
|
||||||
|
onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) },
|
||||||
|
onDismiss = { showPlainFont = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showWeekStart) {
|
if (showWeekStart) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
@@ -664,6 +773,128 @@ private fun AppearanceScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Views (#24): reorder the top-bar quick-switch cycle and choose which views it
|
||||||
|
* steps through, plus reorder the navigation-drawer list. Two independent lists —
|
||||||
|
* a view disabled in the quick-switch cycle is still reachable from the drawer,
|
||||||
|
* which always lists every view. The switch needs at least two targets, so the
|
||||||
|
* last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ViewsScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_views),
|
||||||
|
onBack = onBack,
|
||||||
|
) {
|
||||||
|
val config = state.quickSwitchConfig
|
||||||
|
|
||||||
|
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||||
|
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// Turning a view off is blocked once only the minimum remain enabled.
|
||||||
|
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
|
||||||
|
ReorderableColumn(
|
||||||
|
items = config.order,
|
||||||
|
keyOf = { it },
|
||||||
|
onReorder = { viewModel.setQuickSwitchOrder(it) },
|
||||||
|
) { view, position, dragHandle, isDragging ->
|
||||||
|
val checked = view in config.enabled
|
||||||
|
ViewRow(
|
||||||
|
view = view,
|
||||||
|
position = position,
|
||||||
|
isDragging = isDragging,
|
||||||
|
dragHandle = dragHandle,
|
||||||
|
dimmed = !checked,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
// Keep the last two on: with fewer, the pill can't switch.
|
||||||
|
enabled = !checked || canDisable,
|
||||||
|
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_drawer_order_header))
|
||||||
|
SettingsHint(stringResource(R.string.settings_drawer_order_hint))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
ReorderableColumn(
|
||||||
|
items = state.drawerViewOrder,
|
||||||
|
keyOf = { it },
|
||||||
|
onReorder = { viewModel.setDrawerViewOrder(it) },
|
||||||
|
) { view, position, dragHandle, isDragging ->
|
||||||
|
ViewRow(
|
||||||
|
view = view,
|
||||||
|
position = position,
|
||||||
|
isDragging = isDragging,
|
||||||
|
dragHandle = dragHandle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One reorderable view row: the view's icon and name, an optional [trailing]
|
||||||
|
* control, and a drag handle carrying the [dragHandle] gesture modifier. */
|
||||||
|
@Composable
|
||||||
|
private fun ViewRow(
|
||||||
|
view: CalendarView,
|
||||||
|
position: Position,
|
||||||
|
isDragging: Boolean,
|
||||||
|
dragHandle: Modifier,
|
||||||
|
dimmed: Boolean = false,
|
||||||
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(view.labelRes),
|
||||||
|
position = position,
|
||||||
|
dimmed = dimmed,
|
||||||
|
minHeight = ReorderableRowHeight,
|
||||||
|
// The reorderable column owns the inter-row spacing (uniform pitch).
|
||||||
|
gapBelow = false,
|
||||||
|
container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null,
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = view.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailing = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
trailing?.invoke()
|
||||||
|
if (trailing != null) Spacer(Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier = dragHandle.size(48.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.DragHandle,
|
||||||
|
contentDescription = stringResource(R.string.reorder_drag_handle),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingsHint(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventFormScreen(
|
private fun EventFormScreen(
|
||||||
state: SettingsUiState,
|
state: SettingsUiState,
|
||||||
@@ -779,6 +1010,7 @@ private fun NotificationsScreen(
|
|||||||
state: SettingsUiState,
|
state: SettingsUiState,
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onOpenSpecialDates: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
@@ -903,6 +1135,25 @@ private fun NotificationsScreen(
|
|||||||
Column {
|
Column {
|
||||||
state.writableCalendars.forEach { calendar ->
|
state.writableCalendars.forEach { calendar ->
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
// A contact special-dates calendar owns its reminders in
|
||||||
|
// its own section — link there instead of an override.
|
||||||
|
if (calendar.id in state.managedCalendarIds) {
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onOpenSpecialDates,
|
||||||
|
)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
val expanded = calendar.id in expandedCalendars
|
val expanded = calendar.id in expandedCalendars
|
||||||
// Calendar card; tapping expands it into a grouped list
|
// Calendar card; tapping expands it into a grouped list
|
||||||
// of three (the card + the timed and all-day rows).
|
// of three (the card + the timed and all-day rows).
|
||||||
@@ -969,7 +1220,7 @@ private fun NotificationsScreen(
|
|||||||
presets = REMINDER_PRESETS,
|
presets = REMINDER_PRESETS,
|
||||||
selected = state.defaultReminderMinutes.toReminderChoice(),
|
selected = state.defaultReminderMinutes.toReminderChoice(),
|
||||||
allowInherit = false,
|
allowInherit = false,
|
||||||
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) },
|
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
|
||||||
onDismiss = { showDefaultReminder = false },
|
onDismiss = { showDefaultReminder = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -979,7 +1230,7 @@ private fun NotificationsScreen(
|
|||||||
presets = ALLDAY_REMINDER_PRESETS,
|
presets = ALLDAY_REMINDER_PRESETS,
|
||||||
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
||||||
allowInherit = false,
|
allowInherit = false,
|
||||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) },
|
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
|
||||||
onDismiss = { showAllDayReminder = false },
|
onDismiss = { showAllDayReminder = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1025,16 +1276,297 @@ private fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Contact special dates (issue #15)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SpecialDatesScreen(
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// READ_CONTACTS is requested only here, on enable — never at startup. On a
|
||||||
|
// grant we either enable (if turning on) or just re-sync (clearing a stalled
|
||||||
|
// banner after the permission was re-granted).
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
if (!state.enabled) viewModel.setSpecialDatesEnabled(true) else viewModel.syncSpecialDatesNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val requestOrEnable: () -> Unit = {
|
||||||
|
if (context.hasContactsPermission()) {
|
||||||
|
viewModel.setSpecialDatesEnabled(true)
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmDisableAll by remember { mutableStateOf(false) }
|
||||||
|
var confirmDisableType by remember { mutableStateOf<SpecialDateType?>(null) }
|
||||||
|
var editTemplate by remember { mutableStateOf<SpecialDateType?>(null) }
|
||||||
|
var reminderPickerType by remember { mutableStateOf<SpecialDateType?>(null) }
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_special_dates),
|
||||||
|
onBack = onBack,
|
||||||
|
) {
|
||||||
|
// Paused banner: the permission was revoked after enabling.
|
||||||
|
if (state.enabled && state.stalledPermission) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_special_dates_paused_title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_special_dates_paused_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { permissionLauncher.launch(Manifest.permission.READ_CONTACTS) },
|
||||||
|
) { Text(stringResource(R.string.settings_special_dates_grant)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_enable),
|
||||||
|
summary = stringResource(R.string.settings_special_dates_enable_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.enabled,
|
||||||
|
onCheckedChange = { want -> if (want) requestOrEnable() else confirmDisableAll = true },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { if (state.enabled) confirmDisableAll = true else requestOrEnable() },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.enabled) {
|
||||||
|
// Per-type card: the toggle, and while on, its title format and its
|
||||||
|
// calendar-wide reminder.
|
||||||
|
SpecialDateType.entries.forEachIndexed { index, type ->
|
||||||
|
Spacer(Modifier.height(if (index == 0) 24.dp else 16.dp))
|
||||||
|
val on = type in state.types
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(specialDateTypeLabel(type)),
|
||||||
|
position = if (on) Position.Top else Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = on,
|
||||||
|
onCheckedChange = { want ->
|
||||||
|
if (want) viewModel.setSpecialDateTypeEnabled(type, true)
|
||||||
|
else confirmDisableType = type
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
if (on) confirmDisableType = type else viewModel.setSpecialDateTypeEnabled(type, true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (on) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_template),
|
||||||
|
summary = state.titleTemplates[type].orEmpty(),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { editTemplate = type },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_reminders),
|
||||||
|
summary = reminderChoiceLabel(specialDatesReminderMinutes(state.reminderChoices[type])),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { reminderPickerType = type },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_show_year),
|
||||||
|
summary = stringResource(R.string.settings_special_dates_show_year_hint),
|
||||||
|
position = Position.Top,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.showYear,
|
||||||
|
onCheckedChange = viewModel::setSpecialDatesShowYear,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_special_dates_sync_now),
|
||||||
|
summary = specialDatesLastRunLabel(context, state.lastRun),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = viewModel::syncSpecialDatesNow,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_special_dates_calendar_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmDisableAll) {
|
||||||
|
SpecialDatesDisableDialog(
|
||||||
|
message = stringResource(R.string.settings_special_dates_disable_all_message),
|
||||||
|
onConfirm = { viewModel.setSpecialDatesEnabled(false); confirmDisableAll = false },
|
||||||
|
onDismiss = { confirmDisableAll = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
confirmDisableType?.let { type ->
|
||||||
|
SpecialDatesDisableDialog(
|
||||||
|
message = stringResource(
|
||||||
|
R.string.settings_special_dates_disable_type_message,
|
||||||
|
stringResource(specialDateTypeLabel(type)),
|
||||||
|
),
|
||||||
|
onConfirm = { viewModel.setSpecialDateTypeEnabled(type, false); confirmDisableType = null },
|
||||||
|
onDismiss = { confirmDisableType = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
editTemplate?.let { type ->
|
||||||
|
SpecialDatesTemplateDialog(
|
||||||
|
initial = state.titleTemplates[type].orEmpty(),
|
||||||
|
onConfirm = { viewModel.setSpecialDatesTitleTemplate(type, it); editTemplate = null },
|
||||||
|
onDismiss = { editTemplate = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
reminderPickerType?.let { type ->
|
||||||
|
ReminderDefaultPicker(
|
||||||
|
title = stringResource(R.string.settings_special_dates_reminders),
|
||||||
|
presets = ALLDAY_REMINDER_PRESETS,
|
||||||
|
selected = state.reminderChoices[type] ?: CalendarReminderOverride.None,
|
||||||
|
// Managed calendars own their reminders outright — no "inherit global".
|
||||||
|
allowInherit = false,
|
||||||
|
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
|
||||||
|
onDismiss = { reminderPickerType = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
|
||||||
|
private fun specialDatesReminderMinutes(choice: CalendarReminderOverride?): List<Int> =
|
||||||
|
(choice as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SpecialDatesDisableDialog(
|
||||||
|
message: String,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_special_dates_disable_title)) },
|
||||||
|
text = { Text(message) },
|
||||||
|
confirmButton = {
|
||||||
|
androidx.compose.material3.TextButton(onClick = onConfirm) {
|
||||||
|
Text(stringResource(R.string.settings_special_dates_disable_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
androidx.compose.material3.TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SpecialDatesTemplateDialog(
|
||||||
|
initial: String,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var text by rememberSaveable { mutableStateOf(initial) }
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_special_dates_template)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
// The app's borderless input over a tonal surface (the dialog
|
||||||
|
// convention — see DialogControls), not Material's outlined field.
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
InlineTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
placeholder = stringResource(R.string.settings_special_dates_template),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_special_dates_template_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
androidx.compose.material3.TextButton(
|
||||||
|
onClick = { onConfirm(text) },
|
||||||
|
enabled = text.isNotBlank(),
|
||||||
|
) { Text(stringResource(R.string.dialog_save)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
androidx.compose.material3.TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) {
|
||||||
|
SpecialDateType.Birthday -> R.string.settings_special_dates_type_birthday
|
||||||
|
SpecialDateType.Anniversary -> R.string.settings_special_dates_type_anniversary
|
||||||
|
SpecialDateType.Custom -> R.string.settings_special_dates_type_custom
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
|
||||||
|
if (lastRun <= 0L) {
|
||||||
|
stringResource(R.string.settings_special_dates_never_synced)
|
||||||
|
} else {
|
||||||
|
stringResource(
|
||||||
|
R.string.settings_special_dates_last_synced,
|
||||||
|
android.text.format.DateUtils.getRelativeTimeSpanString(
|
||||||
|
lastRun,
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
android.text.format.DateUtils.MINUTE_IN_MILLIS,
|
||||||
|
).toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||||
|
|
||||||
/** A global default (null = none) as a picker choice for selection highlighting. */
|
/** A global default (empty = none) as a picker choice for selection highlighting. */
|
||||||
private fun Int?.toReminderChoice(): CalendarReminderOverride =
|
private fun List<Int>.toReminderChoice(): CalendarReminderOverride =
|
||||||
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
||||||
|
|
||||||
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
||||||
private fun CalendarReminderOverride.toMinutesOrNull(): Int? =
|
private fun CalendarReminderOverride.toMinutesList(): List<Int> =
|
||||||
(this as? CalendarReminderOverride.Minutes)?.minutes
|
(this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||||
@@ -1108,28 +1640,23 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
|||||||
return DateFormat.getTimeFormat(context).format(time)
|
return DateFormat.getTimeFormat(context).format(time)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
|
/** Label for a global-default choice: empty → "None", else the lead times joined. */
|
||||||
private fun Map<Long, Int?>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
|
||||||
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
|
||||||
this[calendarId] == null -> CalendarReminderOverride.None
|
|
||||||
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Label for a global-default choice: null → "None", else the lead time. */
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderChoiceLabel(minutes: Int?): String =
|
private fun reminderChoiceLabel(minutes: List<Int>): String {
|
||||||
if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes)
|
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
|
||||||
|
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
/** Row summary for a calendar: its override, or the inherited global default. */
|
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun calendarOverrideSummary(
|
private fun calendarOverrideSummary(
|
||||||
choice: CalendarReminderOverride,
|
choice: CalendarReminderOverride,
|
||||||
globalDefault: Int?,
|
globalDefault: List<Int>,
|
||||||
): String = when (choice) {
|
): String = when (choice) {
|
||||||
CalendarReminderOverride.Inherit ->
|
CalendarReminderOverride.Inherit ->
|
||||||
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
is CalendarReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1180,6 +1707,152 @@ private fun themeLabel(mode: ThemeMode): String = stringResource(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** The summary label for a stored font token (issue #19). */
|
||||||
|
@Composable
|
||||||
|
private fun fontLabel(token: String): String = when (token) {
|
||||||
|
FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system)
|
||||||
|
FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected)
|
||||||
|
else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) }
|
||||||
|
?: stringResource(R.string.settings_font_system)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME types offered to the document picker so it lists only font files. Covers
|
||||||
|
* the modern `font/` types plus the legacy `application/` font aliases some
|
||||||
|
* providers still report. Anything that slips through is still validated by
|
||||||
|
* [de.jeanlucmakiola.calendula.data.fonts.CustomFontStore] before use.
|
||||||
|
*/
|
||||||
|
private val FONT_PICKER_MIME_TYPES = arrayOf(
|
||||||
|
"font/ttf",
|
||||||
|
"font/otf",
|
||||||
|
"font/sfnt",
|
||||||
|
"font/collection",
|
||||||
|
"application/x-font-ttf",
|
||||||
|
"application/x-font-otf",
|
||||||
|
"application/font-sfnt",
|
||||||
|
"application/vnd.ms-opentype",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen font chooser for one [FontRole]: the system default, each bundled
|
||||||
|
* font (previewed in its own face), and "Choose file…" which opens the system
|
||||||
|
* picker to load a .ttf/.otf. Selecting a system/bundled option applies at once;
|
||||||
|
* a file is validated and imported by the caller, switching to the custom font
|
||||||
|
* on success.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FontPicker(
|
||||||
|
title: String,
|
||||||
|
role: FontRole,
|
||||||
|
selected: String,
|
||||||
|
stamp: Int,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
onImport: (Uri) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
onImport(uri)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// System default + the bundled fonts + the "Choose file…" row.
|
||||||
|
val rowCount = BundledFont.entries.size + 2
|
||||||
|
val isCustom = selected == FONT_CUSTOM_TOKEN
|
||||||
|
// Resolving the custom face stats the disk and builds a fresh FontFamily, so
|
||||||
|
// memoise it; re-keyed on [stamp] (bumped on re-import) so a replaced file
|
||||||
|
// refreshes the preview while plain recompositions reuse the cached family.
|
||||||
|
val customPreview = remember(role, isCustom, stamp) {
|
||||||
|
if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
FontOptionRow(
|
||||||
|
label = stringResource(R.string.settings_font_system),
|
||||||
|
preview = FontFamily.Default,
|
||||||
|
selected = selected == FONT_SYSTEM_TOKEN,
|
||||||
|
position = positionOf(0, rowCount),
|
||||||
|
onClick = {
|
||||||
|
onSelect(FONT_SYSTEM_TOKEN)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
BundledFont.entries.forEachIndexed { index, font ->
|
||||||
|
FontOptionRow(
|
||||||
|
label = stringResource(font.labelRes),
|
||||||
|
preview = font.family,
|
||||||
|
selected = selected == font.token,
|
||||||
|
position = positionOf(index + 1, rowCount),
|
||||||
|
onClick = {
|
||||||
|
onSelect(font.token)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FontOptionRow(
|
||||||
|
label = if (isCustom) {
|
||||||
|
stringResource(R.string.settings_font_custom_selected)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.settings_font_choose_file)
|
||||||
|
},
|
||||||
|
// A loaded font previews in its own face; otherwise show an upload cue.
|
||||||
|
preview = customPreview,
|
||||||
|
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
|
||||||
|
selected = isCustom,
|
||||||
|
position = positionOf(rowCount - 1, rowCount),
|
||||||
|
onClick = { launcher.launch(FONT_PICKER_MIME_TYPES) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in
|
||||||
|
* the option's own [preview] face (or an [leadingIcon] cue when there's nothing
|
||||||
|
* to preview), and a check when it's the current selection.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FontOptionRow(
|
||||||
|
label: String,
|
||||||
|
preview: FontFamily?,
|
||||||
|
selected: Boolean,
|
||||||
|
position: Position,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
leadingIcon: ImageVector? = null,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = label,
|
||||||
|
position = position,
|
||||||
|
selected = selected,
|
||||||
|
leading = {
|
||||||
|
if (preview != null) {
|
||||||
|
Text(
|
||||||
|
text = "Ag",
|
||||||
|
fontFamily = preview,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
} else if (leadingIcon != null) {
|
||||||
|
Icon(imageVector = leadingIcon, contentDescription = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailing = if (selected) {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
|
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
|
||||||
private val WEEK_START_OPTIONS: List<WeekStartPref> =
|
private val WEEK_START_OPTIONS: List<WeekStartPref> =
|
||||||
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
|
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
@@ -7,8 +8,11 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
@@ -37,6 +41,10 @@ data class SettingsUiState(
|
|||||||
val agendaShowRangeBar: Boolean = true,
|
val agendaShowRangeBar: Boolean = true,
|
||||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||||
val defaultView: CalendarView = CalendarView.Week,
|
val defaultView: CalendarView = CalendarView.Week,
|
||||||
|
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
||||||
|
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
||||||
|
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||||
|
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||||
@@ -44,24 +52,24 @@ data class SettingsUiState(
|
|||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
/**
|
/**
|
||||||
* The default reminder lead time (minutes) prefilled on new timed events;
|
* The default reminder lead times (minutes) prefilled on new timed events;
|
||||||
* null = no default reminder. Per-calendar overrides take precedence.
|
* empty = no default reminder. Per-calendar overrides take precedence.
|
||||||
*/
|
*/
|
||||||
val defaultReminderMinutes: Int? = null,
|
val defaultReminderMinutes: List<Int> = emptyList(),
|
||||||
/** The default reminder lead time prefilled on new all-day events; null = none. */
|
/** The default reminder lead times prefilled on new all-day events; empty = none. */
|
||||||
val defaultAllDayReminderMinutes: Int? = null,
|
val defaultAllDayReminderMinutes: List<Int> = emptyList(),
|
||||||
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
||||||
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
||||||
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
|
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
|
||||||
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
|
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
|
||||||
/**
|
/**
|
||||||
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
||||||
* calendar present in the map overrides the global default (null value = no
|
* calendar present in the map overrides the global default (empty value = no
|
||||||
* reminder); absent = inherit the global default.
|
* reminder); absent = inherit the global default.
|
||||||
*/
|
*/
|
||||||
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
|
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||||
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||||
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
|
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||||
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||||
val writableCalendars: List<CalendarSource> = emptyList(),
|
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||||
/**
|
/**
|
||||||
@@ -69,4 +77,30 @@ data class SettingsUiState(
|
|||||||
* colour palette (the colour may then not survive their next sync).
|
* colour palette (the colour may then not survive their next sync).
|
||||||
*/
|
*/
|
||||||
val allowColorOnUnsupportedCalendars: Boolean = false,
|
val allowColorOnUnsupportedCalendars: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Ids of the contact special-dates calendars. Their reminders are owned by
|
||||||
|
* the Contact special dates section, so the per-calendar override list links
|
||||||
|
* out to it instead of offering an override here.
|
||||||
|
*/
|
||||||
|
val managedCalendarIds: Set<Long> = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the contact special-dates sub-page (issue #15). Exposed as its own
|
||||||
|
* flow rather than folded into [SettingsUiState] (whose combine is already full).
|
||||||
|
* [titleTemplates] is always populated — a blank stored template resolves to the
|
||||||
|
* localized default so the editor never shows an empty field.
|
||||||
|
*/
|
||||||
|
data class SpecialDatesUiState(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
|
||||||
|
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
|
||||||
|
/** The reminder choice per type's managed calendar (applied to all its events). */
|
||||||
|
val reminderChoices: Map<SpecialDateType, CalendarReminderOverride> = emptyMap(),
|
||||||
|
/** Whether {year} resolves in titles (the source year is static and always correct). */
|
||||||
|
val showYear: Boolean = true,
|
||||||
|
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
|
||||||
|
val stalledPermission: Boolean = false,
|
||||||
|
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||||
|
val lastRun: Long = 0L,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
@@ -10,22 +11,38 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
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_PAST_DISPLAY_KEY
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
@@ -35,12 +52,16 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
repository: CalendarRepository,
|
repository: CalendarRepository,
|
||||||
|
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||||
|
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -86,8 +107,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.perCalendarReminderOverride,
|
prefs.perCalendarReminderOverride,
|
||||||
prefs.perCalendarAllDayReminderOverride,
|
prefs.perCalendarAllDayReminderOverride,
|
||||||
writableCalendars,
|
writableCalendars,
|
||||||
) { overrides, allDayOverrides, calendars ->
|
prefs.managedCalendarIds,
|
||||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
) { overrides, allDayOverrides, calendars, managedIds ->
|
||||||
|
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
prefs.defaultView,
|
prefs.defaultView,
|
||||||
@@ -103,8 +125,14 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.autofocusEventTitle,
|
prefs.autofocusEventTitle,
|
||||||
prefs.pastEventDisplay,
|
prefs.pastEventDisplay,
|
||||||
prefs.dimCompletedEvents,
|
prefs.dimCompletedEvents,
|
||||||
::MiscSettings,
|
// View customisation (#24) folded into one flow so it fits this
|
||||||
),
|
// group — the outer combine is already at its five-arg limit.
|
||||||
|
combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer ->
|
||||||
|
ViewCustomization(quickSwitch, drawer)
|
||||||
|
},
|
||||||
|
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||||
|
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||||
|
},
|
||||||
) { base, defaults, overrides, views, misc ->
|
) { base, defaults, overrides, views, misc ->
|
||||||
base.copy(
|
base.copy(
|
||||||
defaultView = views.defaultView,
|
defaultView = views.defaultView,
|
||||||
@@ -116,6 +144,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
autofocusEventTitle = misc.autofocusEventTitle,
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
pastEventDisplay = misc.pastEventDisplay,
|
pastEventDisplay = misc.pastEventDisplay,
|
||||||
dimCompletedEvents = misc.dimCompletedEvents,
|
dimCompletedEvents = misc.dimCompletedEvents,
|
||||||
|
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
||||||
|
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -124,6 +154,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
perCalendarReminderOverride = overrides.timed,
|
perCalendarReminderOverride = overrides.timed,
|
||||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||||
writableCalendars = overrides.calendars,
|
writableCalendars = overrides.calendars,
|
||||||
|
managedCalendarIds = overrides.managedIds,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -131,6 +162,29 @@ class SettingsViewModel @Inject constructor(
|
|||||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app-wide custom-font choice (issue #19). Its own flow — both this
|
||||||
|
* screen and [MainActivity] (which builds the typography) read it
|
||||||
|
* independently of the main settings combine.
|
||||||
|
*/
|
||||||
|
val fontState: StateFlow<AppFontSettings> = combine(
|
||||||
|
prefs.brandFont,
|
||||||
|
prefs.plainFont,
|
||||||
|
prefs.brandFontStamp,
|
||||||
|
prefs.plainFontStamp,
|
||||||
|
) { brand, plain, brandStamp, plainStamp ->
|
||||||
|
AppFontSettings(brand = brand, plain = plain, brandStamp = brandStamp, plainStamp = plainStamp)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = AppFontSettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Emitted when a picked font file couldn't be read as a font; the screen
|
||||||
|
// surfaces it and the previous selection stays put.
|
||||||
|
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
|
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
|
||||||
|
|
||||||
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
|
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
|
||||||
// updateAll calls at once — concurrent calls coalesce around a stale read
|
// updateAll calls at once — concurrent calls coalesce around a stale read
|
||||||
// and can leave the widget on the intermediate value. Held sequentially,
|
// and can leave the widget on the intermediate value. Held sequentially,
|
||||||
@@ -139,16 +193,17 @@ class SettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
private data class ReminderDefaults(
|
private data class ReminderDefaults(
|
||||||
val allowColor: Boolean,
|
val allowColor: Boolean,
|
||||||
val defaultReminder: Int?,
|
val defaultReminder: List<Int>,
|
||||||
val allDayReminder: Int?,
|
val allDayReminder: List<Int>,
|
||||||
val allDayReminderTime: Int,
|
val allDayReminderTime: Int,
|
||||||
val snoozeMinutes: Int,
|
val snoozeMinutes: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ReminderOverrides(
|
private data class ReminderOverrides(
|
||||||
val timed: Map<Long, Int?>,
|
val timed: Map<Long, List<Int>>,
|
||||||
val allDay: Map<Long, Int?>,
|
val allDay: Map<Long, List<Int>>,
|
||||||
val calendars: List<CalendarSource>,
|
val calendars: List<CalendarSource>,
|
||||||
|
val managedIds: Set<Long>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ViewSettings(
|
private data class ViewSettings(
|
||||||
@@ -164,8 +219,103 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val autofocusEventTitle: Boolean,
|
val autofocusEventTitle: Boolean,
|
||||||
val pastEventDisplay: PastEventDisplay,
|
val pastEventDisplay: PastEventDisplay,
|
||||||
val dimCompletedEvents: Boolean,
|
val dimCompletedEvents: Boolean,
|
||||||
|
val viewCustomization: ViewCustomization,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ViewCustomization(
|
||||||
|
val quickSwitch: QuickSwitchConfig,
|
||||||
|
val drawerOrder: List<CalendarView>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||||
|
val specialDatesState: StateFlow<SpecialDatesUiState> = combine(
|
||||||
|
combine(
|
||||||
|
prefs.specialDatesEnabled,
|
||||||
|
prefs.specialDatesTypes,
|
||||||
|
prefs.specialDatesTitleTemplates,
|
||||||
|
prefs.specialDatesShowYear,
|
||||||
|
prefs.specialDatesStatus,
|
||||||
|
) { enabled, types, templates, showYear, status ->
|
||||||
|
SpecialDatesUiState(
|
||||||
|
enabled = enabled,
|
||||||
|
types = types,
|
||||||
|
// A blank stored template resolves to the localized default so the
|
||||||
|
// editor always shows something sensible.
|
||||||
|
titleTemplates = SpecialDateType.entries.associateWith { type ->
|
||||||
|
specialDatesSpec.resolveTitleTemplate(type, templates)
|
||||||
|
},
|
||||||
|
showYear = showYear,
|
||||||
|
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
|
||||||
|
lastRun = status.lastRun,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
prefs.specialDatesCalendars,
|
||||||
|
prefs.perCalendarAllDayReminderOverride,
|
||||||
|
) { base, calendars, allDayOverrides ->
|
||||||
|
base.copy(
|
||||||
|
// An override is always seeded on creation; present-empty = None.
|
||||||
|
reminderChoices = calendars.mapValues { (_, calendarId) ->
|
||||||
|
allDayOverrides.choiceFor(calendarId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = SpecialDatesUiState(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the mirror on or off. Enabling schedules the daily job and runs an
|
||||||
|
* immediate sync (which creates the calendars); disabling cancels the job and
|
||||||
|
* tears the managed calendars down. The caller must have READ_CONTACTS granted
|
||||||
|
* before enabling.
|
||||||
|
*/
|
||||||
|
fun setSpecialDatesEnabled(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setSpecialDatesEnabled(enabled)
|
||||||
|
if (enabled) {
|
||||||
|
SpecialDatesScheduler.apply(appContext, enabled = true)
|
||||||
|
SpecialDatesScheduler.runNow(appContext)
|
||||||
|
} else {
|
||||||
|
SpecialDatesScheduler.apply(appContext, enabled = false)
|
||||||
|
withContext(io) { specialDatesEngine.teardown() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setSpecialDateTypeEnabled(type, enabled)
|
||||||
|
// Reconcile now: create the new type's calendar, or delete a disabled one.
|
||||||
|
SpecialDatesScheduler.runNow(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setSpecialDatesTitleTemplate(type, template.trim())
|
||||||
|
SpecialDatesScheduler.runNow(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpecialDatesShowYear(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setSpecialDatesShowYear(enabled)
|
||||||
|
SpecialDatesScheduler.runNow(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a type's managed-calendar reminder default and apply it to all its
|
||||||
|
* existing events (managed calendars own their reminders calendar-wide).
|
||||||
|
*/
|
||||||
|
fun setSpecialDatesReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
||||||
|
viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kick off an immediate reconcile (the "Sync now" action). */
|
||||||
|
fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext)
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
}
|
}
|
||||||
@@ -174,6 +324,35 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setFont(role: FontRole, token: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (role) {
|
||||||
|
FontRole.BRAND -> prefs.setBrandFont(token)
|
||||||
|
FontRole.PLAIN -> prefs.setPlainFont(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a user-picked font file for [role]. Copies and validates it off the
|
||||||
|
* main thread; on success the role switches to the custom font, otherwise the
|
||||||
|
* previous choice is kept and [fontImportFailed] fires.
|
||||||
|
*/
|
||||||
|
fun importCustomFont(role: FontRole, uri: Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val ok = withContext(io) { CustomFontStore.import(appContext, role, uri) }
|
||||||
|
if (ok) {
|
||||||
|
// Bump the stamp so replacing an already-active custom font (same
|
||||||
|
// file, same "custom" token) still breaks font-settings equality
|
||||||
|
// and refreshes the resolved typeface.
|
||||||
|
prefs.bumpCustomFontStamp(role)
|
||||||
|
setFont(role, FONT_CUSTOM_TOKEN)
|
||||||
|
} else {
|
||||||
|
_fontImportFailed.emit(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setWeekStart(pref: WeekStartPref) {
|
fun setWeekStart(pref: WeekStartPref) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setWeekStart(pref)
|
prefs.setWeekStart(pref)
|
||||||
@@ -247,6 +426,32 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable [view] in the quick-switch cycle. Goes through an atomic
|
||||||
|
* read-modify-write so a concurrent reorder can't clobber this toggle (and
|
||||||
|
* vice versa) by re-serialising a stale snapshot. The MIN_ENABLED floor is
|
||||||
|
* re-checked inside the transform: the screen's own guard reads the
|
||||||
|
* async-echoed snapshot, so two quick disable-taps could both look allowed
|
||||||
|
* yet compose to a below-minimum cycle.
|
||||||
|
*/
|
||||||
|
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.updateQuickSwitch { config ->
|
||||||
|
val next = if (enabled) config.enabled + view else config.enabled - view
|
||||||
|
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reorder the quick-switch views (atomic read-modify-write; keeps the enabled set). */
|
||||||
|
fun setQuickSwitchOrder(order: List<CalendarView>) {
|
||||||
|
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setRemindersEnabled(enabled: Boolean) {
|
fun setRemindersEnabled(enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
}
|
}
|
||||||
@@ -255,11 +460,11 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDefaultReminderMinutes(minutes: Int?) {
|
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||||
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||||
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Fonts.kt
Normal file
129
app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Fonts.kt
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.theme
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontVariation
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Stored token for "use the device default typeface" — the default for both roles. */
|
||||||
|
const val FONT_SYSTEM_TOKEN = "system"
|
||||||
|
|
||||||
|
/** Stored token for "use the font the user loaded from a file" (per role). */
|
||||||
|
const val FONT_CUSTOM_TOKEN = "custom"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A font checked into the app that a user can pick without providing a file
|
||||||
|
* (issue #19). [token] is the stable value persisted in preferences; [family] is
|
||||||
|
* the resolved Compose family. Kept deliberately small and readability-focused.
|
||||||
|
*/
|
||||||
|
enum class BundledFont(
|
||||||
|
val token: String,
|
||||||
|
@StringRes val labelRes: Int,
|
||||||
|
val family: FontFamily,
|
||||||
|
) {
|
||||||
|
/** Purpose-built for low-vision legibility (Braille Institute). */
|
||||||
|
AtkinsonHyperlegible(
|
||||||
|
token = "atkinson_hyperlegible",
|
||||||
|
labelRes = R.string.font_atkinson_hyperlegible,
|
||||||
|
family = FontFamily(
|
||||||
|
Font(R.font.atkinson_hyperlegible_regular, FontWeight.Normal),
|
||||||
|
Font(R.font.atkinson_hyperlegible_bold, FontWeight.Bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
/** A serif alternative; shipped as a variable font, weights via its wght axis. */
|
||||||
|
Lora(
|
||||||
|
token = "lora",
|
||||||
|
labelRes = R.string.font_lora,
|
||||||
|
family = FontFamily(
|
||||||
|
Font(R.font.lora, FontWeight.Normal, variationSettings = weightAxis(400)),
|
||||||
|
Font(R.font.lora, FontWeight.Medium, variationSettings = weightAxis(500)),
|
||||||
|
Font(R.font.lora, FontWeight.Bold, variationSettings = weightAxis(700)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
/** A monospace option. */
|
||||||
|
JetBrainsMono(
|
||||||
|
token = "jetbrains_mono",
|
||||||
|
labelRes = R.string.font_jetbrains_mono,
|
||||||
|
family = FontFamily(
|
||||||
|
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
|
||||||
|
Font(R.font.jetbrains_mono_bold, FontWeight.Bold),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromToken(token: String?): BundledFont? = entries.firstOrNull { it.token == token }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun weightAxis(weight: Int) = FontVariation.Settings(FontVariation.weight(weight))
|
||||||
|
|
||||||
|
/** The two roles' current font tokens (see [FontRole]); the app-wide typeface choice. */
|
||||||
|
data class AppFontSettings(
|
||||||
|
val brand: String = FONT_SYSTEM_TOKEN,
|
||||||
|
val plain: String = FONT_SYSTEM_TOKEN,
|
||||||
|
// Per-role custom-font re-import stamp: bumped when the "custom" file is
|
||||||
|
// replaced, so a same-token re-import still breaks equality and refreshes the
|
||||||
|
// resolved family. 0 for existing installs / no import yet (issue #19).
|
||||||
|
val brandStamp: Int = 0,
|
||||||
|
val plainStamp: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a stored [token] for [role] to a Compose [FontFamily], or null to mean
|
||||||
|
* "leave the Material default" (the system typeface). A custom token whose file
|
||||||
|
* is missing or unreadable also resolves to null, so a deleted file degrades to
|
||||||
|
* the system font rather than crashing text layout.
|
||||||
|
*/
|
||||||
|
fun resolveFontFamily(token: String, role: FontRole, context: Context): FontFamily? = when (token) {
|
||||||
|
FONT_SYSTEM_TOKEN -> null
|
||||||
|
FONT_CUSTOM_TOKEN -> customFontFamily(CustomFontStore.file(context, role))
|
||||||
|
else -> BundledFont.fromToken(token)?.family
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun customFontFamily(file: File): FontFamily? =
|
||||||
|
if (file.exists() && file.length() > 0) {
|
||||||
|
runCatching { FontFamily(Font(file)) }.getOrNull()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the app typography from the chosen [brand] and [plain] families. A null
|
||||||
|
* family leaves that role's Material default (system typeface) in place, so the
|
||||||
|
* common "system for both" case returns [CalendulaTypography] untouched. Brand
|
||||||
|
* drives display + headline; plain drives title + body + label (M3's two
|
||||||
|
* typeface roles).
|
||||||
|
*/
|
||||||
|
fun calendulaTypography(brand: FontFamily?, plain: FontFamily?): Typography {
|
||||||
|
if (brand == null && plain == null) return CalendulaTypography
|
||||||
|
val base = CalendulaTypography
|
||||||
|
fun TextStyle.withFamily(family: FontFamily?): TextStyle =
|
||||||
|
if (family != null) copy(fontFamily = family) else this
|
||||||
|
return base.copy(
|
||||||
|
displayLarge = base.displayLarge.withFamily(brand),
|
||||||
|
displayMedium = base.displayMedium.withFamily(brand),
|
||||||
|
displaySmall = base.displaySmall.withFamily(brand),
|
||||||
|
headlineLarge = base.headlineLarge.withFamily(brand),
|
||||||
|
headlineMedium = base.headlineMedium.withFamily(brand),
|
||||||
|
headlineSmall = base.headlineSmall.withFamily(brand),
|
||||||
|
titleLarge = base.titleLarge.withFamily(plain),
|
||||||
|
titleMedium = base.titleMedium.withFamily(plain),
|
||||||
|
titleSmall = base.titleSmall.withFamily(plain),
|
||||||
|
bodyLarge = base.bodyLarge.withFamily(plain),
|
||||||
|
bodyMedium = base.bodyMedium.withFamily(plain),
|
||||||
|
bodySmall = base.bodySmall.withFamily(plain),
|
||||||
|
labelLarge = base.labelLarge.withFamily(plain),
|
||||||
|
labelMedium = base.labelMedium.withFamily(plain),
|
||||||
|
labelSmall = base.labelSmall.withFamily(plain),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialExpressiveTheme
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
import androidx.compose.material3.MotionScheme
|
import androidx.compose.material3.MotionScheme
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -15,15 +16,18 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
* - System light/dark.
|
* - System light/dark.
|
||||||
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
|
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
|
||||||
* derived from [CalendulaSeed].
|
* derived from [CalendulaSeed].
|
||||||
|
* - A user-chosen [typography] (custom fonts, issue #19), defaulting to the
|
||||||
|
* Material scale on the system typeface.
|
||||||
*
|
*
|
||||||
* The Settings screen (later) can override useDynamicColor and themePreference,
|
* The Settings screen overrides darkTheme, dynamicColor, and typography; the
|
||||||
* but the V1 foundation just follows the system.
|
* bare defaults (used by the crash screen) just follow the system.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendulaTheme(
|
fun CalendulaTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
|
typography: Typography = CalendulaTypography,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
@@ -42,7 +46,7 @@ fun CalendulaTheme(
|
|||||||
// the bouncy variant felt overdone in review (2026-06-11).
|
// the bouncy variant felt overdone in review (2026-06-11).
|
||||||
MaterialExpressiveTheme(
|
MaterialExpressiveTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = CalendulaTypography,
|
typography = typography,
|
||||||
motionScheme = MotionScheme.standard(),
|
motionScheme = MotionScheme.standard(),
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
@@ -131,6 +132,8 @@ fun WeekScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -195,6 +198,7 @@ fun WeekScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = weekStart,
|
currentDate = weekStart,
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -216,7 +220,7 @@ fun WeekScreen(
|
|||||||
WeekTopBar(
|
WeekTopBar(
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
|
|||||||
@@ -193,6 +193,8 @@ private fun AgendaHeader() {
|
|||||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
// Tap the "Upcoming" title to open the app on the user's default view
|
||||||
|
// (issue #20) — a null target resolves to the default home in the host.
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(R.string.widget_agenda_title),
|
text = context.getString(R.string.widget_agenda_title),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -200,7 +202,11 @@ private fun AgendaHeader() {
|
|||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier.defaultWeight(),
|
modifier = GlanceModifier
|
||||||
|
.defaultWeight()
|
||||||
|
.clickable(
|
||||||
|
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
resId = R.drawable.ic_widget_refresh,
|
resId = R.drawable.ic_widget_refresh,
|
||||||
|
|||||||
@@ -194,6 +194,8 @@ private fun MonthHeader(label: String) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// Tap the month/year title to open the app on the month view (issue #18),
|
||||||
|
// rooted over the default home so backing out returns there.
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -204,16 +206,17 @@ private fun MonthHeader(label: String) {
|
|||||||
),
|
),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.defaultWeight()
|
.defaultWeight()
|
||||||
.clickable(actionRunCallback<ResetMonthAction>()),
|
.clickable(
|
||||||
|
actionStartActivity(
|
||||||
|
MainActivity.openViewIntent(context, CalendarView.Month),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
// The "today" button snaps the grid back to the current month in place.
|
||||||
HeaderIcon(
|
HeaderIcon(
|
||||||
resId = R.drawable.ic_widget_today,
|
resId = R.drawable.ic_widget_today,
|
||||||
contentDescription = context.getString(R.string.widget_today),
|
contentDescription = context.getString(R.string.widget_today),
|
||||||
onClick = GlanceModifier.clickable(
|
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
|
||||||
actionStartActivity(
|
|
||||||
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
HeaderIcon(
|
HeaderIcon(
|
||||||
resId = R.drawable.ic_widget_chevron_right,
|
resId = R.drawable.ic_widget_chevron_right,
|
||||||
@@ -301,6 +304,15 @@ private fun WeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open [date]'s day view rooted in the month view (so back returns to the month
|
||||||
|
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
||||||
|
* Shared by every tappable part of a day column so, as in the app, a tap anywhere
|
||||||
|
* on a day opens it; only an event bar on top opts out to open its own detail.
|
||||||
|
*/
|
||||||
|
private fun openDayAction(context: Context, date: LocalDate) =
|
||||||
|
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -308,11 +320,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
|||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.width(colW)
|
.width(colW)
|
||||||
.height(DAY_NUMBER_HEIGHT)
|
.height(DAY_NUMBER_HEIGHT)
|
||||||
// Tap a day number to open that day, rooted in the month view so back
|
.clickable(openDayAction(context, date)),
|
||||||
// returns to the month grid.
|
|
||||||
.clickable(
|
|
||||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -339,6 +347,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||||
|
val context = LocalContext.current
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
var col = 0
|
var col = 0
|
||||||
while (col < 7) {
|
while (col < 7) {
|
||||||
@@ -352,7 +361,14 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
|||||||
if (timed != null) {
|
if (timed != null) {
|
||||||
SpanBar(event = timed, dark = dark, width = colW)
|
SpanBar(event = timed, dark = dark, width = colW)
|
||||||
} else {
|
} else {
|
||||||
Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {}
|
// 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.
|
||||||
|
Box(
|
||||||
|
GlanceModifier
|
||||||
|
.width(colW)
|
||||||
|
.height(LANE_HEIGHT)
|
||||||
|
.clickable(openDayAction(context, week.days[col])),
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
col += 1
|
col += 1
|
||||||
}
|
}
|
||||||
@@ -401,14 +417,20 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||||
|
val context = LocalContext.current
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
week.days.forEachIndexed { col, date ->
|
week.days.forEachIndexed { col, date ->
|
||||||
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||||
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
||||||
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
||||||
|
// The overflow row is part of the day column too: tapping it (whether
|
||||||
|
// it shows "+N" or is blank) opens that day, same as the app.
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier.width(colW).height(LANE_HEIGHT),
|
modifier = GlanceModifier
|
||||||
|
.width(colW)
|
||||||
|
.height(LANE_HEIGHT)
|
||||||
|
.clickable(openDayAction(context, date)),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
if (hidden > 0) {
|
if (hidden > 0) {
|
||||||
|
|||||||
BIN
app/src/main/res/font/atkinson_hyperlegible_bold.ttf
Normal file
BIN
app/src/main/res/font/atkinson_hyperlegible_bold.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/atkinson_hyperlegible_regular.ttf
Normal file
BIN
app/src/main/res/font/atkinson_hyperlegible_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/jetbrains_mono_bold.ttf
Normal file
BIN
app/src/main/res/font/jetbrains_mono_bold.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/jetbrains_mono_regular.ttf
Normal file
BIN
app/src/main/res/font/jetbrains_mono_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/lora.ttf
Normal file
BIN
app/src/main/res/font/lora.ttf
Normal file
Binary file not shown.
@@ -295,7 +295,6 @@
|
|||||||
<string name="reminder_none">Keine</string>
|
<string name="reminder_none">Keine</string>
|
||||||
<string name="reminder_use_default">Standard-Erinnerung verwenden</string>
|
<string name="reminder_use_default">Standard-Erinnerung verwenden</string>
|
||||||
<string name="reminder_custom_amount">Anzahl</string>
|
<string name="reminder_custom_amount">Anzahl</string>
|
||||||
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
|
|
||||||
<string name="reminder_custom_set">Übernehmen</string>
|
<string name="reminder_custom_set">Übernehmen</string>
|
||||||
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
|
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
|
||||||
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>
|
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>
|
||||||
|
|||||||
@@ -6,4 +6,416 @@
|
|||||||
<item>Days</item>
|
<item>Days</item>
|
||||||
<item>Weeks</item>
|
<item>Weeks</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
<string name="app_name">Calendula</string>
|
||||||
|
<string name="app_tagline">Un calendario moderno.</string>
|
||||||
|
<string name="state_loading">Cargando…</string>
|
||||||
|
<string name="state_retry">Vuelve a intentarlo</string>
|
||||||
|
<string name="state_failure_unknown">Algo salio mal.</string>
|
||||||
|
<string name="state_failure_permission">Se requiere acceso al calendario.</string>
|
||||||
|
<string name="state_failure_permission_action">Permite el acceso</string>
|
||||||
|
<string name="state_failure_no_calendars">No se han configurado calendarios.</string>
|
||||||
|
<string name="state_failure_no_calendars_action">Abrir los ajustes del calendario del sistema</string>
|
||||||
|
<string name="state_failure_provider">No se pudo leer el calendario.</string>
|
||||||
|
<string name="permission_rationale_title">Mira todos sus eventos, a la perfección</string>
|
||||||
|
<string name="permission_rationale_body">Calendula necesita acceso a tu calendario para mostrarte y administrar eventos. Ese es el único permiso que pedirá.</string>
|
||||||
|
<string name="permission_request_button">Conceder el acceso al calendario</string>
|
||||||
|
<string name="permission_denied_title">Acceso a calendario denegado</string>
|
||||||
|
<string name="permission_denied_body">Calendula no puede mostrar eventos sin acceso al calendario. Puedes concederlo en ajustes del sistema.</string>
|
||||||
|
<string name="permission_open_settings_button">Abrir ajustes del sistema</string>
|
||||||
|
<string name="permission_retry_button">Intentarlo de nuevo</string>
|
||||||
|
<string name="permission_benefit_private_title">Permanece en tu dispositivo</string>
|
||||||
|
<string name="permission_benefit_private_body">Tus calendarios se leen localmente y nunca abandonan tu celular.</string>
|
||||||
|
<string name="permission_benefit_sync_title">Todos tus calendarios, juntos</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, local — todo lo sincronizado en el dispositivo aparecerá.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">Sin rastreo, jamás</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Sin telemetría, sin analíticas, sin anuncios.</string>
|
||||||
|
<string name="permission_privacy_footnote">Permanece en tu dispositivo, no necesita acceso a internet</string>
|
||||||
|
<string name="month_prev">Mes anterior</string>
|
||||||
|
<string name="month_next">Siguiente mes</string>
|
||||||
|
<string name="month_today_action">Hoy</string>
|
||||||
|
<string name="month_more_actions">Mas acciones</string>
|
||||||
|
<string name="month_open_menu">Abrir menu</string>
|
||||||
|
<string name="month_action_settings">Ajustes</string>
|
||||||
|
<string name="month_a11y_today_prefix">Hoy</string>
|
||||||
|
<string name="week_today_action">Esta semana</string>
|
||||||
|
<string name="week_number_label">Sem</string>
|
||||||
|
<string name="day_today_action">Hoy</string>
|
||||||
|
<string name="event_detail_back">Atras</string>
|
||||||
|
<string name="event_detail_edit">Editar</string>
|
||||||
|
<string name="event_detail_delete">Eliminar</string>
|
||||||
|
<string name="event_detail_share">Compartir</string>
|
||||||
|
<string name="event_share_chooser_title">Compartir evento</string>
|
||||||
|
<string name="event_share_failed">No se pudo compartir el evento.</string>
|
||||||
|
<string name="event_delete_title">¿Eliminar evento?</string>
|
||||||
|
<string name="event_delete_body">El evento se elimino de tu calendario y de todos los dispositivos con los cuales se sincroniza.</string>
|
||||||
|
<string name="event_delete_recurring_title">Eliminar evento recurrente</string>
|
||||||
|
<string name="event_delete_option_occurrence">Solo este evento</string>
|
||||||
|
<string name="event_delete_option_following">Este y todos los eventos siguientes</string>
|
||||||
|
<string name="event_delete_option_series">Todos los eventos en la serie</string>
|
||||||
|
<string name="event_edit_recurring_title">Editar evento recurrente</string>
|
||||||
|
<string name="event_delete_failed">No se pudo eliminar el evento</string>
|
||||||
|
<string name="event_delete_write_denied">Calendula necesita acceso de escritura para eliminar eventos</string>
|
||||||
|
<string name="dialog_cancel">Cancelar</string>
|
||||||
|
<string name="dialog_ok">Okey</string>
|
||||||
|
<string name="event_edit_new_title">Nuevo evento</string>
|
||||||
|
<string name="event_edit_close">Cerrar</string>
|
||||||
|
<string name="event_edit_save">Guardar</string>
|
||||||
|
<string name="event_edit_title_hint">Añadir título</string>
|
||||||
|
<string name="event_edit_starts">Inicio</string>
|
||||||
|
<string name="event_edit_ends">Final</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Termina antes de comenzar</string>
|
||||||
|
<string name="event_edit_error_no_calendar">No hay ningún calendario en donde se pueda escribir disponible</string>
|
||||||
|
<string name="event_edit_save_failed">No se pudo guardar el evento</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula necesita acceso de escritura para crear eventos</string>
|
||||||
|
<string name="event_edit_more_fields">Mas campos</string>
|
||||||
|
<string name="event_edit_add">Añadir</string>
|
||||||
|
<string name="event_edit_add_reminder">Añadir recordatorio</string>
|
||||||
|
<string name="event_edit_remove_reminder">Eliminar recordatorio</string>
|
||||||
|
<string name="event_edit_attendees">Invitados</string>
|
||||||
|
<string name="event_edit_add_guest">Añadir invitados</string>
|
||||||
|
<string name="event_edit_add_guest_hint">Añadir invitados por correo…</string>
|
||||||
|
<string name="event_edit_add_guest_from_contacts">Añadir desde contactos</string>
|
||||||
|
<string name="event_edit_location_from_contacts">Seleccionar dirección desde contactos</string>
|
||||||
|
<string name="event_edit_remove_guest">Eliminar invitado</string>
|
||||||
|
<string name="event_edit_attendee_required">Obligatorio</string>
|
||||||
|
<string name="event_edit_attendee_optional">Opcional</string>
|
||||||
|
<string name="event_edit_attendees_note_synced">Calendula no envia invitaciones. Tu cuenta de calendario puede enviar correos a los invitados cuando se sincronice.</string>
|
||||||
|
<string name="event_edit_attendees_note_local">Guardado en el dispositivo. No se avisa a nadie.</string>
|
||||||
|
<string name="event_edit_reminder_custom">Personalizado</string>
|
||||||
|
<string name="reminder_unit_minutes">minutos</string>
|
||||||
|
<string name="reminder_unit_hours">horas</string>
|
||||||
|
<string name="reminder_unit_days">días</string>
|
||||||
|
<string name="reminder_unit_weeks">semanas</string>
|
||||||
|
<string name="event_edit_availability">Disponibilidad</string>
|
||||||
|
<string name="event_edit_visibility">Visibilidad</string>
|
||||||
|
<string name="event_edit_color">Color</string>
|
||||||
|
<string name="event_edit_color_default">Color del calendario</string>
|
||||||
|
<string name="event_edit_color_custom">Color personalizado</string>
|
||||||
|
<string name="event_edit_color_reset">Reiniciar</string>
|
||||||
|
<string name="event_edit_color_unsupported">No disponible para este calendario</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">Este calendario no admite un ser de colores. Puedes permitir colores personalizados para este tipo de calendarios en ajustes.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">Este calendario puede eliminar o cambiar el color la próxima vez que se sincronice.</string>
|
||||||
|
<string name="event_edit_conflict_title">El evento fue cambiado por otra fuente</string>
|
||||||
|
<string name="event_edit_conflict_body">Mientras lo editabas, este evento fue cambiado — debido a la sincronización u otra aplicación ¿Que deberia le ocurrir a tus cambios?</string>
|
||||||
|
<string name="event_edit_conflict_overwrite">Guardar mis cambios</string>
|
||||||
|
<string name="event_edit_conflict_overwrite_hint">Sobreescribir el cambio externo en solo los aspectos que he cambiado</string>
|
||||||
|
<string name="event_edit_conflict_discard">Descartar todos mis cambios</string>
|
||||||
|
<string name="event_edit_conflict_discard_hint">El evento permanece tal y como esta ahora</string>
|
||||||
|
<string name="event_edit_gone_title">Evento eliminado</string>
|
||||||
|
<string name="event_edit_gone_body">Este evento fue eliminado durante el proceso, en otro dispositivo o aplicación por ejemplo. Tus cambios no pudieron ser guardados.</string>
|
||||||
|
<string name="event_edit_recurrence_none">No se repite</string>
|
||||||
|
<string name="event_edit_recurrence_custom">Personalizado</string>
|
||||||
|
<string name="event_edit_recurrence_every">Cada</string>
|
||||||
|
<string name="recurrence_unit_days">días</string>
|
||||||
|
<string name="recurrence_unit_weeks">semanas</string>
|
||||||
|
<string name="recurrence_unit_months">meses</string>
|
||||||
|
<string name="recurrence_unit_years">años</string>
|
||||||
|
<string name="event_edit_recurrence_ends">Finaliza</string>
|
||||||
|
<string name="event_edit_recurrence_end_never">Nunca</string>
|
||||||
|
<string name="event_edit_recurrence_end_until">En una fecha</string>
|
||||||
|
<string name="event_edit_recurrence_end_count">Después de cierto numero de veces</string>
|
||||||
|
<string name="event_edit_recurrence_times">veces</string>
|
||||||
|
<string name="event_edit_error_recurrence_ends_before_start">El evento se deja de repetir antes de que este comience</string>
|
||||||
|
<string name="event_availability_busy">Ocupado</string>
|
||||||
|
<string name="event_access_default">Por defecto</string>
|
||||||
|
<string name="event_access_public">Público</string>
|
||||||
|
<string name="event_detail_all_day">Todo el día</string>
|
||||||
|
<string name="event_detail_calendar">Calendario</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Calendario desconocido</string>
|
||||||
|
<string name="event_detail_location">Ubicación</string>
|
||||||
|
<string name="event_detail_description">Descripción</string>
|
||||||
|
<string name="event_detail_attendees">Participantes</string>
|
||||||
|
<string name="event_detail_recurrence">Recurrencia</string>
|
||||||
|
<string name="agenda_today_action">Hoy</string>
|
||||||
|
<string name="agenda_header_today">Hoy</string>
|
||||||
|
<string name="search_back">Atras</string>
|
||||||
|
<string name="widget_new_event">Nuevo evento</string>
|
||||||
|
<string name="widget_prev_month">Mes anterior</string>
|
||||||
|
<string name="widget_next_month">Siguiente mes</string>
|
||||||
|
<string name="widget_today">Hoy</string>
|
||||||
|
<string name="filter_title">Calendarios</string>
|
||||||
|
<string name="settings_title">Ajustes</string>
|
||||||
|
<string name="settings_back">Atras</string>
|
||||||
|
<string name="agenda_range_day">Hoy</string>
|
||||||
|
<string name="agenda_range_this_week">Esta semana</string>
|
||||||
|
<string name="settings_section_calendars">Calendarios</string>
|
||||||
|
<string name="calendars_title">Calendarios</string>
|
||||||
|
<string name="calendars_color_label">Color</string>
|
||||||
|
<string name="import_close">Cerrar</string>
|
||||||
|
<string name="shortcut_new_event_short">Nuevo evento</string>
|
||||||
|
<string name="qs_tile_new_event_label">Nuevo evento</string>
|
||||||
|
<string name="event_detail_recurring">Repetir el evento</string>
|
||||||
|
<string name="recurrence_daily">Todos los días</string>
|
||||||
|
<string name="recurrence_weekly">Todas las semanas</string>
|
||||||
|
<string name="recurrence_monthly">Todos los meses</string>
|
||||||
|
<string name="recurrence_yearly">Todos los años</string>
|
||||||
|
<string name="recurrence_every_n_days">Cada %1$d días</string>
|
||||||
|
<string name="recurrence_every_n_weeks">Cada %1$d semanas</string>
|
||||||
|
<string name="recurrence_every_n_months">Cada %1$d meses</string>
|
||||||
|
<string name="recurrence_every_n_years">Cada %1$d años</string>
|
||||||
|
<string name="event_detail_not_found">Este evento ya no existe.</string>
|
||||||
|
<string name="event_attendee_accepted">Aceptado</string>
|
||||||
|
<string name="event_attendee_declined">Rechazado</string>
|
||||||
|
<string name="event_attendee_tentative">Tentativo</string>
|
||||||
|
<string name="event_attendee_needs_action">Sin respuesta</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
<string name="event_detail_reminders">Recordatorios</string>
|
||||||
|
<string name="event_detail_timezone">Zona horaria</string>
|
||||||
|
<string name="event_status_tentative">Tentativo</string>
|
||||||
|
<string name="event_status_cancelled">Cancelado</string>
|
||||||
|
<string name="event_availability_free">Disponible</string>
|
||||||
|
<string name="event_access_private">Privado</string>
|
||||||
|
<string name="event_access_confidential">Confidencial</string>
|
||||||
|
<string name="event_attendee_organizer">Organizador</string>
|
||||||
|
<string name="event_attendee_optional">Opcional</string>
|
||||||
|
<string name="agenda_range_custom">Personalizado…</string>
|
||||||
|
<string name="calendars_backup_header">Respaldo</string>
|
||||||
|
<string name="backup_channel_name">Respaldo</string>
|
||||||
|
<string name="event_detail_self_response">Tu respuesta: %1$s</string>
|
||||||
|
<string name="reminder_at_time">Al la hora del evento</string>
|
||||||
|
<string name="reminder_default">Recordatorio por defecto</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d minuto antes</item>
|
||||||
|
<item quantity="many">%d minutos antes</item>
|
||||||
|
<item quantity="other">%d minutos antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d hora antes</item>
|
||||||
|
<item quantity="many">%d horas antes</item>
|
||||||
|
<item quantity="other">%d horas antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d día antes</item>
|
||||||
|
<item quantity="many">%d días antes</item>
|
||||||
|
<item quantity="other">%d días antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d semana antes</item>
|
||||||
|
<item quantity="many">%d semanas antes</item>
|
||||||
|
<item quantity="other">%d semanas antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d minuto</item>
|
||||||
|
<item quantity="many">%d minutos</item>
|
||||||
|
<item quantity="other">%d minutos</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d hora</item>
|
||||||
|
<item quantity="many">%d horas</item>
|
||||||
|
<item quantity="other">%d horas</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="one">%d dia</item>
|
||||||
|
<item quantity="many">%d días</item>
|
||||||
|
<item quantity="other">%d días</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="one">%d semana</item>
|
||||||
|
<item quantity="many">%d semanas</item>
|
||||||
|
<item quantity="other">%d semanas</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="event_untitled">(Sin título)</string>
|
||||||
|
<string name="reminder_channel_name">Recordatorios del evento</string>
|
||||||
|
<string name="reminder_channel_description">Notificaciones a la hora de los recordatorios de tus eventos</string>
|
||||||
|
<string name="reminder_onboarding_title">Nunca te pierdas un evento</string>
|
||||||
|
<string name="reminder_onboarding_body">Android no muestra recordatorios de eventos por su cuenta — una app de calendario tiene que hacerlo. Deja que calendula cumpla ese trabajo.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Recordatorios, enviados</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Cada recordatorio de tus eventos aparece como notificación, justo a tiempo.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">¿Utilizas otra aplicación de calendario?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">Si otra aplicación tambien muestra recordatorios, los veras dos veces — desactivalos en esta o la otra aplicación.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Cambialo en cualquier momento</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">El interruptor se encuentra en Ajustes, en la sección de Notificaciones.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Activar recordatorios</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Mas tarde</string>
|
||||||
|
<string name="reminder_action_snooze">Posponer</string>
|
||||||
|
<string name="reminder_action_dismiss">Descartar</string>
|
||||||
|
<string name="view_month">Mes</string>
|
||||||
|
<string name="view_week">Semana</string>
|
||||||
|
<string name="view_day">Día</string>
|
||||||
|
<string name="view_agenda">Agenda</string>
|
||||||
|
<string name="view_section">Vista</string>
|
||||||
|
<string name="drawer_jump_to_date">Ir a la fecha</string>
|
||||||
|
<string name="agenda_header_tomorrow">Mañana</string>
|
||||||
|
<string name="agenda_empty_title">Ya estas al día</string>
|
||||||
|
<string name="search_action">Buscar</string>
|
||||||
|
<string name="search_hint">Buscar eventos</string>
|
||||||
|
<string name="search_clear">Limpiar</string>
|
||||||
|
<string name="search_idle_hint">Buscar eventos por titulo, ubicación o notas.</string>
|
||||||
|
<string name="search_empty">No evento coincide con “%1$s”.</string>
|
||||||
|
<string name="widget_agenda_title">Próximamente</string>
|
||||||
|
<string name="widget_agenda_label">Calendula agenda</string>
|
||||||
|
<string name="widget_month_label">Calendula mes</string>
|
||||||
|
<string name="widget_refresh">Actualizar</string>
|
||||||
|
<string name="widget_needs_permission">Abre Calendula para conceder acceso al calendario</string>
|
||||||
|
<string name="settings_section_appearance">Apariencia</string>
|
||||||
|
<string name="settings_theme">Tema</string>
|
||||||
|
<string name="settings_theme_system">Igual que el sistema</string>
|
||||||
|
<string name="settings_theme_light">Claro</string>
|
||||||
|
<string name="settings_theme_dark">Oscuro</string>
|
||||||
|
<string name="settings_default_view">Vista por defecto</string>
|
||||||
|
<string name="settings_dynamic_color">Color dinámico</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Requiere android 12 o una versión posterior</string>
|
||||||
|
<string name="settings_week_start">La semana empieza en</string>
|
||||||
|
<string name="settings_week_start_auto">Automático</string>
|
||||||
|
<string name="settings_time_format">Formato de la hora</string>
|
||||||
|
<string name="settings_time_format_auto">Automático</string>
|
||||||
|
<string name="settings_time_format_12h">12-horas (2:00 PM)</string>
|
||||||
|
<string name="settings_time_format_24h">24-horas (14:00)</string>
|
||||||
|
<string name="settings_hour_lines">Lineas entre horas</string>
|
||||||
|
<string name="settings_hour_lines_summary">Muestra una linea que separa cada hora en la vista diaria y semanal</string>
|
||||||
|
<string name="settings_dim_completed">Oscurecer eventos completados</string>
|
||||||
|
<string name="settings_dim_completed_summary">Oscurece eventos que ya han terminado en vista semanal y mensual</string>
|
||||||
|
<string name="settings_past_events">Eventos pasados</string>
|
||||||
|
<string name="settings_past_events_show">Mostrar</string>
|
||||||
|
<string name="settings_past_events_dim">Oscurecer</string>
|
||||||
|
<string name="settings_past_events_hide">Ocultar</string>
|
||||||
|
<string name="settings_agenda_header">Agenda</string>
|
||||||
|
<string name="settings_agenda_range">Rango de agenda</string>
|
||||||
|
<string name="settings_agenda_range_hint">Cuanto tiempo abarca la agenda al mostrar eventos.</string>
|
||||||
|
<string name="settings_agenda_widget_range">Rango del widget de agenda</string>
|
||||||
|
<string name="settings_agenda_widget_range_hint">Cuanto tiempo abarca el widget de agenda en la pantalla principal al mostrar eventos.</string>
|
||||||
|
<string name="settings_agenda_range_bar">Barra de tiempo</string>
|
||||||
|
<string name="settings_agenda_range_bar_hint">Muestra una barra en la parte superior de la agenda con las fechas mostradas, con un botón para cambiar el rango durante la sesión</string>
|
||||||
|
<string name="agenda_range_this_month">Este mes</string>
|
||||||
|
<string name="agenda_range_week">Próximos 7 días</string>
|
||||||
|
<string name="agenda_range_month">Próximos 30 días</string>
|
||||||
|
<string name="agenda_range_custom_hint">Días</string>
|
||||||
|
<string name="agenda_range_override_hint">Solo por ahora — reinicia tu rango guardado cuando reabras calendula.</string>
|
||||||
|
<string name="agenda_range_showing_label">Mostrando todos los próximos eventos por</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="one">%d dia</item>
|
||||||
|
<item quantity="many">%d dias</item>
|
||||||
|
<item quantity="other">%d dias</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_section_event_form">Nueva formulario de evento</string>
|
||||||
|
<string name="settings_form_fields_hint">Campos mostrados por defecto — todo lo demás se encuentra bajo \"mas campos\"</string>
|
||||||
|
<string name="settings_autofocus_title">Enfocar titulo en nuevos eventos</string>
|
||||||
|
<string name="settings_autofocus_title_hint">Cuando comienzas un nuevo evento, coloca el cursor en el titulo y abre el teclado inmediatamente.</string>
|
||||||
|
<string name="settings_color_unsupported">Permitir colores en calendarios no admitidos</string>
|
||||||
|
<string name="settings_color_unsupported_hint">Algunas calendarios (como algunos CalDAV) no publican un set de colores; un color customizado puede ser eliminado o sobreescrito por su servicio de sincronización. Eso es una limitacion de estos calendarios, no algo que calendula pueda solucionar.</string>
|
||||||
|
<string name="settings_section_notifications">Notificaciones</string>
|
||||||
|
<string name="settings_reminders">Recordatorios de eventos</string>
|
||||||
|
<string name="settings_reminders_hint">Los recordatorios aparecen dos veces? Otra aplicación de calendario tambien los esta generando — desactivarlo en una de las dos apps.</string>
|
||||||
|
<string name="settings_default_reminder">Recordatorio por defecto</string>
|
||||||
|
<string name="settings_default_reminder_allday">Eventos que duran todo el dia</string>
|
||||||
|
<string name="settings_allday_reminder_time">Hora del recordatorio de todo el día</string>
|
||||||
|
<string name="settings_allday_reminder_time_hint">Los recordatorios para eventos de todo el día se generaran a las %1$s</string>
|
||||||
|
<string name="reminder_none">Ninguno</string>
|
||||||
|
<string name="reminder_use_default">Usar recordatorio por defecto</string>
|
||||||
|
<string name="reminder_custom_amount">Cantidad</string>
|
||||||
|
<string name="reminder_custom_set">Guardar</string>
|
||||||
|
<string name="settings_calendar_reminders_title">Recordatorios por calendario</string>
|
||||||
|
<string name="settings_calendar_reminders_hint">Sobreescribe el por recordatorio defecto por calendario — De forma separada para eventos con duración fija o que duran todo el dia. Un calendario puede mantener el por defecto o tener su propia configuracion.</string>
|
||||||
|
<string name="settings_calendar_reminder_inherits">Por defecto (%1$s)</string>
|
||||||
|
<string name="settings_reliable_delivery">Notificaciones confiables</string>
|
||||||
|
<string name="settings_reliable_delivery_hint">Android puede atrasar los recordatorios para guardar batería. Excluye calendula de la optimización de batería para que se puedan generar a tiempo.</string>
|
||||||
|
<string name="settings_reliable_delivery_exempt">Excluido de la optimización de batería — los recordatorios llegaran a tiempo.</string>
|
||||||
|
<string name="settings_snooze_duration">Duración al posponer</string>
|
||||||
|
<string name="settings_manage_calendars">Administrar calendarios</string>
|
||||||
|
<string name="settings_manage_calendars_hint">Crear calendarios locales; administrar calendarios sincronizados</string>
|
||||||
|
<string name="settings_section_language">Idioma</string>
|
||||||
|
<string name="settings_language">Idioma de la aplicación</string>
|
||||||
|
<string name="settings_language_auto">Igual que el sistema</string>
|
||||||
|
<string name="settings_translate">Ayuda a traducir</string>
|
||||||
|
<string name="settings_translate_hint">Añade o mejora un idioma en Weblate</string>
|
||||||
|
<string name="settings_appearance_subtitle">Tema, vista por defecto, inicio de semana</string>
|
||||||
|
<string name="settings_event_form_subtitle">Campos por defecto en nuevos eventos</string>
|
||||||
|
<string name="settings_notifications_subtitle">Recordatorios de eventos</string>
|
||||||
|
<string name="settings_license">Licencia</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_about_author">Por Jean-Luc Makiola</string>
|
||||||
|
<string name="settings_about_source">Fuente</string>
|
||||||
|
<string name="settings_about_support">Ayuda al desarrollo</string>
|
||||||
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Icono de la aplicación calendula</string>
|
||||||
|
<string name="settings_report_problem">Reportar un problema</string>
|
||||||
|
<string name="settings_report_problem_hint">Enviar un reporte de error o abre el sistema de seguimiento de errores</string>
|
||||||
|
<string name="calendars_local_header">Tus calendarios</string>
|
||||||
|
<string name="calendars_local_empty">Sin calendarios locales todavia. Crea uno para mantener eventos solo en este dispositivo.</string>
|
||||||
|
<string name="calendars_add">Añadir calendario</string>
|
||||||
|
<string name="calendars_disable_hint">Desactiva un calendario para removerlo de la aplicación — sus eventos, filtros y selectores. Nada se eliminara, y puedes reactivarlo en cualquier momento.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">Mostrar \"%1$s\" en la aplicación</string>
|
||||||
|
<string name="calendars_synced_header">Calendarios sincronizados</string>
|
||||||
|
<string name="calendars_synced_hint">Estos provienen de cuentas en tu dispositivo. Crea o editalos en su propia aplicación.</string>
|
||||||
|
<string name="calendars_manage_in_app">Gestionar en aplicación</string>
|
||||||
|
<string name="calendars_account_menu_a11y">Mas opciones para %1$s</string>
|
||||||
|
<string name="calendars_enable_all">Habilitar todos</string>
|
||||||
|
<string name="calendars_disable_all">Desactivar todos</string>
|
||||||
|
<string name="calendars_add_account">Añadir cuenta</string>
|
||||||
|
<string name="calendars_new_title">Nuevo calendario</string>
|
||||||
|
<string name="calendars_edit_title">Editar calendario</string>
|
||||||
|
<string name="calendars_name_label">Nombre</string>
|
||||||
|
<string name="calendars_description_hint">Añadir descripción</string>
|
||||||
|
<string name="calendars_delete_confirm_title">¿Eliminar calendario?</string>
|
||||||
|
<string name="calendars_delete_confirm_message">\"%1$s\" y todos sus eventos serán eliminados permanentemente de este dispositivo.</string>
|
||||||
|
<string name="calendars_write_error">No se pudieron guardar los cambios.</string>
|
||||||
|
<string name="calendars_backup_hint">Los calendarios locales no se sincronizan, exportalos como un archivo .ics para mantener una copia.</string>
|
||||||
|
<string name="calendars_backup_action">Exportar como archivo .ics</string>
|
||||||
|
<string name="calendars_auto_backup">Respaldo automático</string>
|
||||||
|
<string name="calendars_auto_backup_hint">Exporta tus calendarios a una carpeta como un archivo .ics de forma periodica.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">Carpeta de respaldo</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">Toca para seleccionar una carpeta</string>
|
||||||
|
<string name="calendars_auto_backup_interval">Intervalo</string>
|
||||||
|
<string name="calendars_auto_backup_every">Cada %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">Mínimo 30 minutos.</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">Sin respaldos automáticos todavía</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">Ultimo respaldo: %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">Ultimo respaldo fallido: %1$s</string>
|
||||||
|
<string name="backup_channel_description">Advierte si los respaldos automáticos fallan repetidamente.</string>
|
||||||
|
<string name="backup_failed_title">El respaldo automático fallo</string>
|
||||||
|
<string name="backup_failed_text">Calendula no pudo generar el archivo de respaldo, comprueba la carpeta de respaldo en ajustes.</string>
|
||||||
|
<string name="calendars_backup_failed">No se pudo exportar el respaldo.</string>
|
||||||
|
<plurals name="calendars_backup_done">
|
||||||
|
<item quantity="one">Se exporto %d evento.</item>
|
||||||
|
<item quantity="many">Se exportaron %d eventos.</item>
|
||||||
|
<item quantity="other">Se exportaron %d eventos.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_title">Importar eventos</string>
|
||||||
|
<string name="import_target_header">Añadir a calendario</string>
|
||||||
|
<string name="import_empty">No se encontraron eventos en este archivo.</string>
|
||||||
|
<string name="import_failed">No se pudo leer el archivo.</string>
|
||||||
|
<string name="import_no_calendar">No hay ningún calendario editable en el cual importar los eventos. Crea un calendario local primero.</string>
|
||||||
|
<string name="import_done_title">Importación completa</string>
|
||||||
|
<string name="import_warning_recurrence">Se omitieron algunas ocurrencias editadas de eventos recurrentes.</string>
|
||||||
|
<string name="import_warning_no_start">Se omitió un evento sin hora de inicio.</string>
|
||||||
|
<string name="import_warning_attendees">No se importaron las listas de invitados.</string>
|
||||||
|
<string name="import_warning_timezone">Se reemplazo una zona horaria desconocida con la de tu dispositivo.</string>
|
||||||
|
<plurals name="import_event_count">
|
||||||
|
<item quantity="one">%d evento en este archivo.</item>
|
||||||
|
<item quantity="many">%d eventos en este archivo.</item>
|
||||||
|
<item quantity="other">%d eventos en este archivo.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_action">
|
||||||
|
<item quantity="one">Importar %d evento</item>
|
||||||
|
<item quantity="many">Importar %d eventos</item>
|
||||||
|
<item quantity="other">Importar %d eventos</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_imported">
|
||||||
|
<item quantity="one">%d evento importado.</item>
|
||||||
|
<item quantity="many">%d eventos importados.</item>
|
||||||
|
<item quantity="other">%d eventos importados.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_skipped">
|
||||||
|
<item quantity="one">Se omitió %d archivo ya presente en este calendario.</item>
|
||||||
|
<item quantity="many">Se omitieron %d archivos ya presentes en este calendario.</item>
|
||||||
|
<item quantity="other">Se omitieron %d archivos ya presentes en este calendario.</item>
|
||||||
|
</plurals>
|
||||||
|
<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_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_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_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>
|
||||||
|
<string name="recurrence_with_count">%1$s, %2$d veces</string>
|
||||||
|
<string name="recurrence_with_until">%1$s hasta %2$s</string>
|
||||||
|
<string name="recurrence_on_days">%1$s los %2$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -6,4 +6,416 @@
|
|||||||
<item>Days</item>
|
<item>Days</item>
|
||||||
<item>Weeks</item>
|
<item>Weeks</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
<string name="app_name">Calendula</string>
|
||||||
|
<string name="app_tagline">Un calendario moderno.</string>
|
||||||
|
<string name="state_loading">Caricamento…</string>
|
||||||
|
<string name="state_retry">Riprova</string>
|
||||||
|
<string name="state_failure_unknown">Qualcosa è andato storto.</string>
|
||||||
|
<string name="state_failure_permission">È richiesto l\'accesso al calendario.</string>
|
||||||
|
<string name="state_failure_permission_action">Concedi l\'accesso</string>
|
||||||
|
<string name="state_failure_no_calendars">Nessun calendario configurato.</string>
|
||||||
|
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
||||||
|
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
|
||||||
|
<string name="permission_rationale_title">Guarda tutti i tuoi eventi, magnificamente</string>
|
||||||
|
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa che ti chiederà.</string>
|
||||||
|
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
|
||||||
|
<string name="permission_denied_title">Accesso al calendario negato</string>
|
||||||
|
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
|
||||||
|
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
|
||||||
|
<string name="permission_retry_button">Riprova</string>
|
||||||
|
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
|
||||||
|
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo telefono.</string>
|
||||||
|
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo semplicemente compare.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Zero telemetria, zero analytics, zero pubblicità.</string>
|
||||||
|
<string name="permission_privacy_footnote">Rimane sul tuo dispositivo - nessun accesso a internet</string>
|
||||||
|
<string name="month_prev">Mese precedente</string>
|
||||||
|
<string name="month_next">Mese prossimo</string>
|
||||||
|
<string name="month_today_action">Oggi</string>
|
||||||
|
<string name="month_more_actions">Altre azioni</string>
|
||||||
|
<string name="month_open_menu">Apri il menù</string>
|
||||||
|
<string name="month_action_settings">Impostazioni</string>
|
||||||
|
<string name="month_a11y_today_prefix">Oggi</string>
|
||||||
|
<string name="week_today_action">Questa settimana</string>
|
||||||
|
<string name="week_number_label">Wk</string>
|
||||||
|
<string name="day_today_action">Oggi</string>
|
||||||
|
<string name="event_detail_back">Indietro</string>
|
||||||
|
<string name="event_detail_edit">Modifica</string>
|
||||||
|
<string name="event_detail_delete">Elimina</string>
|
||||||
|
<string name="event_detail_share">Condividi</string>
|
||||||
|
<string name="event_share_chooser_title">Condividi evento</string>
|
||||||
|
<string name="event_share_failed">Impossibile condividere questo evento.</string>
|
||||||
|
<string name="event_delete_title">Eliminare l\'evento?</string>
|
||||||
|
<string name="event_delete_body">Questo evento viene rimosso dal tuo calendario e da tutti i dispositivi sincronizzati.</string>
|
||||||
|
<string name="event_delete_recurring_title">Elimina ricorrenza</string>
|
||||||
|
<string name="event_delete_option_occurrence">Solo questo evento</string>
|
||||||
|
<string name="event_delete_option_following">Questo e gli eventi successivi</string>
|
||||||
|
<string name="event_delete_option_series">Tutti gli eventi della serie</string>
|
||||||
|
<string name="event_edit_recurring_title">Modifica ricorrenza</string>
|
||||||
|
<string name="event_delete_failed">Impossibile eliminare l\'evento</string>
|
||||||
|
<string name="event_delete_write_denied">Calendula ha bisogno dei permessi di scrittura per eliminare gli eventi</string>
|
||||||
|
<string name="dialog_cancel">Annulla</string>
|
||||||
|
<string name="dialog_ok">Ok</string>
|
||||||
|
<string name="event_edit_new_title">Nuovo evento</string>
|
||||||
|
<string name="event_edit_close">Chiudi</string>
|
||||||
|
<string name="event_edit_save">Salva</string>
|
||||||
|
<string name="event_edit_title_hint">Aggiungi titolo</string>
|
||||||
|
<string name="event_edit_starts">Inizio</string>
|
||||||
|
<string name="event_edit_ends">Fine</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Fine antecedente l\'inizio</string>
|
||||||
|
<string name="event_edit_error_no_calendar">Non sono disponibili calendari</string>
|
||||||
|
<string name="event_edit_save_failed">Impossibile salvare l\'evento</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula ha bisogno dei permessi di scrittura per creare gli eventi</string>
|
||||||
|
<string name="event_edit_more_fields">Altri campi</string>
|
||||||
|
<string name="event_edit_add">Aggiungi</string>
|
||||||
|
<string name="event_edit_add_reminder">Aggiungi promemoria</string>
|
||||||
|
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
|
||||||
|
<string name="event_edit_attendees">Partecipanti</string>
|
||||||
|
<string name="event_edit_add_guest">Aggiungi partecipante</string>
|
||||||
|
<string name="event_edit_add_guest_hint">Aggiungi un partecipante via email…</string>
|
||||||
|
<string name="event_edit_add_guest_from_contacts">Aggiungi dai contatti</string>
|
||||||
|
<string name="event_edit_location_from_contacts">Scegli un indirizzo dai contatti</string>
|
||||||
|
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
|
||||||
|
<string name="event_edit_attendee_required">Obbligatorio</string>
|
||||||
|
<string name="event_edit_attendee_optional">Facoltativo</string>
|
||||||
|
<string name="event_edit_attendees_note_synced">Calendula non manda gli inviti agli eventi. Il tuo account calendario potrà inviare le email ai partecipanti al momento della sincronizzazione.</string>
|
||||||
|
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno ha ricevuto notifiche.</string>
|
||||||
|
<string name="event_edit_reminder_custom">Personalizza</string>
|
||||||
|
<string name="reminder_unit_minutes">minuti</string>
|
||||||
|
<string name="reminder_unit_hours">ore</string>
|
||||||
|
<string name="reminder_unit_days">giorni</string>
|
||||||
|
<string name="reminder_unit_weeks">settimane</string>
|
||||||
|
<string name="event_edit_availability">Disponibilità</string>
|
||||||
|
<string name="event_edit_visibility">Visibilità</string>
|
||||||
|
<string name="event_edit_color">Colore</string>
|
||||||
|
<string name="event_edit_color_default">Colore calendario</string>
|
||||||
|
<string name="event_edit_color_custom">Colore personalizzato</string>
|
||||||
|
<string name="event_edit_color_reset">Reset</string>
|
||||||
|
<string name="event_edit_color_unsupported">Non disponibile per questo calendario</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">I colori non sono ammessi per questo calendario. Puoi abilitare i colori personalizzati per questo tipo di calendario dalle Impostazioni.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">Il colore per questo calendario potrà andare perso o essere sovrascritto alla prossima sincronizzazione.</string>
|
||||||
|
<string name="event_edit_conflict_title">Evento modificato altrove</string>
|
||||||
|
<string name="event_edit_conflict_body">Durante la modifica l\'evento è stato cambiato, tramite sincronizzazione o tramite altra app. Cosa fare con le modifiche in corso?</string>
|
||||||
|
<string name="event_edit_conflict_overwrite">Salva le modifiche</string>
|
||||||
|
<string name="event_edit_conflict_overwrite_hint">Sovrascrivi solo i campi modificati</string>
|
||||||
|
<string name="event_edit_conflict_discard">Annulla le mie modifiche</string>
|
||||||
|
<string name="event_edit_conflict_discard_hint">Mantieni l\'evento com\'è ora</string>
|
||||||
|
<string name="event_edit_gone_title">Evento eliminato</string>
|
||||||
|
<string name="event_edit_gone_body">Nel frattempo, questo evento è stato eliminato, ad esempio su un altro dispositivo. Le modifiche non possono più essere salvate.</string>
|
||||||
|
<string name="event_edit_recurrence_none">Non si ripete</string>
|
||||||
|
<string name="event_edit_recurrence_custom">Personalizza</string>
|
||||||
|
<string name="event_edit_recurrence_every">La serie</string>
|
||||||
|
<string name="recurrence_unit_days">giorni</string>
|
||||||
|
<string name="recurrence_unit_weeks">settimane</string>
|
||||||
|
<string name="recurrence_unit_months">mesi</string>
|
||||||
|
<string name="recurrence_unit_years">anni</string>
|
||||||
|
<string name="event_edit_recurrence_ends">Fine</string>
|
||||||
|
<string name="event_edit_recurrence_end_never">Mai</string>
|
||||||
|
<string name="event_edit_recurrence_end_until">Fino alla data</string>
|
||||||
|
<string name="event_edit_recurrence_end_count">Dopo un certo numero di occorrenze</string>
|
||||||
|
<string name="event_edit_recurrence_times">occorrenze</string>
|
||||||
|
<string name="event_edit_error_recurrence_ends_before_start">Fine antecedente l\'inizio</string>
|
||||||
|
<string name="event_availability_busy">Occupato</string>
|
||||||
|
<string name="event_access_default">Default</string>
|
||||||
|
<string name="event_access_public">Pubblico</string>
|
||||||
|
<string name="event_detail_all_day">Tutto il giorno</string>
|
||||||
|
<string name="event_detail_calendar">Calendario</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Calendario sconosciuto</string>
|
||||||
|
<string name="event_detail_location">Luogo</string>
|
||||||
|
<string name="event_detail_description">Descrizione</string>
|
||||||
|
<string name="event_detail_attendees">Partecipanti</string>
|
||||||
|
<string name="recurrence_daily">Ogni giorni</string>
|
||||||
|
<string name="recurrence_weekly">Ogni settimana</string>
|
||||||
|
<string name="recurrence_monthly">Ogni mese</string>
|
||||||
|
<string name="recurrence_yearly">Ogni anno</string>
|
||||||
|
<string name="recurrence_every_n_days">Ogni %1$d giorni</string>
|
||||||
|
<string name="recurrence_every_n_weeks">Ogni %1$d settimane</string>
|
||||||
|
<string name="recurrence_every_n_months">Ogni %1$d mesi</string>
|
||||||
|
<string name="recurrence_every_n_years">Ogni %1$d anni</string>
|
||||||
|
<string name="event_detail_not_found">Questo evento è stato eliminato.</string>
|
||||||
|
<string name="event_attendee_accepted">Accettato</string>
|
||||||
|
<string name="event_attendee_declined">Rifiutato</string>
|
||||||
|
<string name="event_attendee_tentative">Provvisorio</string>
|
||||||
|
<string name="event_attendee_needs_action">Nessuna risposta</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
<string name="event_detail_reminders">Promemoria</string>
|
||||||
|
<string name="event_detail_timezone">Fuso orario</string>
|
||||||
|
<string name="event_status_tentative">Provvisorio</string>
|
||||||
|
<string name="event_status_cancelled">Annullato</string>
|
||||||
|
<string name="event_availability_free">Disponibile</string>
|
||||||
|
<string name="event_access_private">Privato</string>
|
||||||
|
<string name="event_access_confidential">Confidenziale</string>
|
||||||
|
<string name="event_attendee_organizer">Organizzatore</string>
|
||||||
|
<string name="event_attendee_optional">Facoltativo</string>
|
||||||
|
<string name="event_detail_self_response">La tua risposta: %1$s</string>
|
||||||
|
<string name="reminder_at_time">All\'ora dell\'evento</string>
|
||||||
|
<string name="reminder_default">Promemoria standard</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d minuto prima</item>
|
||||||
|
<item quantity="many">%d minuti prima</item>
|
||||||
|
<item quantity="other">%d minuti prima</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d ora prima</item>
|
||||||
|
<item quantity="many">%d ore prima</item>
|
||||||
|
<item quantity="other">%d ore prima</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d giorno prima</item>
|
||||||
|
<item quantity="many">%d giorni prima</item>
|
||||||
|
<item quantity="other">%d giorni prima</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d settimana prima</item>
|
||||||
|
<item quantity="many">%d settimanw prima</item>
|
||||||
|
<item quantity="other">%d settimane prima</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d minuto</item>
|
||||||
|
<item quantity="many">%d minuti</item>
|
||||||
|
<item quantity="other">%d minuti</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d ora</item>
|
||||||
|
<item quantity="many">%d ore</item>
|
||||||
|
<item quantity="other">%d ore</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="one">%d giorno</item>
|
||||||
|
<item quantity="many">%d giorni</item>
|
||||||
|
<item quantity="other">%d giorni</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="one">%d settimana</item>
|
||||||
|
<item quantity="many">%d settimane</item>
|
||||||
|
<item quantity="other">%d settimane</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="event_untitled">(nessun titolo)</string>
|
||||||
|
<string name="reminder_channel_name">Promemoria evento</string>
|
||||||
|
<string name="event_attendee_resource">Risorsa</string>
|
||||||
|
<string name="reminder_onboarding_title">Non perderti nessun evento</string>
|
||||||
|
<string name="reminder_onboarding_body">Android non mostra i promemoria per gli eventi autonomamente, serve un\'app. Lascia che sia Calendula a farlo.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Promemoria, eccoli</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Tutti i promemoria dei tuoi eventi arrivano tramite notifica, proprio al momento giusto.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">Stai usando un\'altra app calendario?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">Se un\'altra app manda promemoria li vedrai due volte. Disattivali qui o nell\'altra app.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Cambialo in ogni momento</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">Lo switch è nelle Impostazioni, sezione Notifiche.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Attiva i promemoria</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Non ora</string>
|
||||||
|
<string name="reminder_action_snooze">Posponi</string>
|
||||||
|
<string name="reminder_action_dismiss">Ignora</string>
|
||||||
|
<string name="view_month">Mese</string>
|
||||||
|
<string name="view_week">Settimana</string>
|
||||||
|
<string name="view_day">Giorno</string>
|
||||||
|
<string name="view_agenda">Agenda</string>
|
||||||
|
<string name="view_section">Vista</string>
|
||||||
|
<string name="drawer_jump_to_date">Vai alla data</string>
|
||||||
|
<string name="agenda_today_action">Oggi</string>
|
||||||
|
<string name="agenda_header_tomorrow">Domani</string>
|
||||||
|
<string name="agenda_empty_title">Hai fatto tutto</string>
|
||||||
|
<string name="search_action">Cerca</string>
|
||||||
|
<string name="search_hint">Cerca eventi</string>
|
||||||
|
<string name="search_back">Indietro</string>
|
||||||
|
<string name="search_clear">Pulisci</string>
|
||||||
|
<string name="search_idle_hint">Cerca gli eventi per titolo, luogo o note.</string>
|
||||||
|
<string name="search_empty">Nessun risultato per \"%1$s\".</string>
|
||||||
|
<string name="widget_agenda_title">Prossimamente</string>
|
||||||
|
<string name="widget_agenda_label">Agenda Calendula</string>
|
||||||
|
<string name="widget_month_label">Mese Calendula</string>
|
||||||
|
<string name="widget_refresh">Aggiorna</string>
|
||||||
|
<string name="widget_new_event">Nuovo evento</string>
|
||||||
|
<string name="widget_needs_permission">Apri Calendula per concedere l\'accesso al calendario</string>
|
||||||
|
<string name="widget_prev_month">Mese precedente</string>
|
||||||
|
<string name="filter_title">Calendari</string>
|
||||||
|
<string name="settings_section_appearance">Aspetto</string>
|
||||||
|
<string name="settings_theme">Tema</string>
|
||||||
|
<string name="settings_theme_system">Sistema</string>
|
||||||
|
<string name="settings_theme_light">Chiaro</string>
|
||||||
|
<string name="settings_theme_dark">Scuro</string>
|
||||||
|
<string name="settings_default_view">Vista di default</string>
|
||||||
|
<string name="settings_dynamic_color">Colori dinamici</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Richiede Android 12 o superiore</string>
|
||||||
|
<string name="settings_week_start">La settimana comincia di</string>
|
||||||
|
<string name="settings_week_start_auto">Automatico</string>
|
||||||
|
<string name="settings_time_format">Formato ora</string>
|
||||||
|
<string name="settings_time_format_auto">Automatico</string>
|
||||||
|
<string name="settings_time_format_12h">12 ore</string>
|
||||||
|
<string name="settings_time_format_24h">24 ore</string>
|
||||||
|
<string name="settings_hour_lines">Righe per le ore</string>
|
||||||
|
<string name="settings_hour_lines_summary">Mostra una riga separatrice per ogni ora nella vista Settimana e Giorno</string>
|
||||||
|
<string name="settings_dim_completed">Attenua gli eventi completati</string>
|
||||||
|
<string name="settings_dim_completed_summary">Attenua gli eventi conclusi nella vista Mese e Settimana</string>
|
||||||
|
<string name="settings_past_events">Eventi passati</string>
|
||||||
|
<string name="settings_past_events_show">Mostra</string>
|
||||||
|
<string name="settings_past_events_dim">Attenua</string>
|
||||||
|
<string name="settings_past_events_hide">Nascondi</string>
|
||||||
|
<string name="settings_agenda_header">Agenda</string>
|
||||||
|
<string name="settings_agenda_range">Intervallo dell\'Agenda</string>
|
||||||
|
<string name="settings_agenda_range_hint">Anticipo con cui vengono visualizzati gli eventi nell\'Agenda.</string>
|
||||||
|
<string name="settings_agenda_widget_range">Intervallo del widget Agenda</string>
|
||||||
|
<string name="settings_agenda_widget_range_hint">Anticipo con cui vengono visualizzati gli eventi nel widget Agenda.</string>
|
||||||
|
<string name="settings_agenda_range_bar">Barra dell\'intervallo</string>
|
||||||
|
<string name="settings_agenda_range_bar_hint">Mostra una barra sopra l\'agenda che indica le date mostrate, con un pulsante per cambiare l\'intervallo scelto per la sessione</string>
|
||||||
|
<string name="agenda_range_day">Oggi</string>
|
||||||
|
<string name="agenda_range_this_month">Questo mese</string>
|
||||||
|
<string name="agenda_range_week">Prossimi 7 giorni</string>
|
||||||
|
<string name="agenda_range_month">Prossimi 30 giorni</string>
|
||||||
|
<string name="agenda_range_custom">Personalizza…</string>
|
||||||
|
<string name="agenda_range_custom_hint">Giorni</string>
|
||||||
|
<string name="agenda_range_override_hint">Solo questa volta, ripristina l\'intervallo salvato al riavvio di Calendula.</string>
|
||||||
|
<string name="agenda_range_showing_label">Mostra tutti gli eventi in arrivo per</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="one">%d giorno</item>
|
||||||
|
<item quantity="many">%d giorni</item>
|
||||||
|
<item quantity="other">%d giorni</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_section_event_form">Scheda Nuovo Evento</string>
|
||||||
|
<string name="settings_form_fields_hint">Campi mostrati di default, tutti gli altri saranno in \"Altri campi\"</string>
|
||||||
|
<string name="settings_autofocus_title">Subito al titolo su un nuovo evento</string>
|
||||||
|
<string name="settings_autofocus_title_hint">Quando crei un nuovo evento, posiziona subito il cursore sul campo Titolo e mostra la tastiera.</string>
|
||||||
|
<string name="settings_color_unsupported">Abilita i colori sui calendari non supportati</string>
|
||||||
|
<string name="settings_color_unsupported_hint">Alcuni calendari (e.g. alcuni CalDAV) non pubblicano il set di colori di cui sono dotati; i colori personalizzati assegnati a un evento possono essere persi o sovrascritti alla prossima sincronizzazione. È una limitazione di questo tipo di calendari, Calendula non può farci nulla.</string>
|
||||||
|
<string name="settings_section_notifications">Notifiche</string>
|
||||||
|
<string name="settings_reminders_hint">Vedi promemoria duplicati? Vuol dire che li sta mostrando anche un\'altra app. Disattivali qui o nell\'altra app.</string>
|
||||||
|
<string name="settings_default_reminder_allday">Eventi giornalieri</string>
|
||||||
|
<string name="settings_allday_reminder_time">Ora per i promemoria degli eventi giornalieri</string>
|
||||||
|
<string name="settings_allday_reminder_time_hint">I promemoria per gli eventi giornalieri vengono mandati alle %1$s</string>
|
||||||
|
<string name="reminder_none">Nessuno</string>
|
||||||
|
<string name="reminder_use_default">Usa il promemoria standard</string>
|
||||||
|
<string name="reminder_custom_amount">Valore</string>
|
||||||
|
<string name="reminder_custom_set">Imposta</string>
|
||||||
|
<string name="settings_calendar_reminders_title">Promemoria per calendario</string>
|
||||||
|
<string name="settings_calendar_reminders_hint">Sovrascrivi i reminder standard con reminder specifici per calendario, separati per eventi giornalieri ed eventi ad ore. Ogni calendario può usare i promemoria di default, ignorarli o avere i suoi personalizzati.</string>
|
||||||
|
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||||
|
<string name="settings_reliable_delivery">Notifiche affidabili</string>
|
||||||
|
<string name="settings_reliable_delivery_hint">Android potrebbe posticipare le notifiche per risparmiare batteria. Escludi Calendula così che le notifiche possano arrivare puntuali.</string>
|
||||||
|
<string name="settings_reliable_delivery_exempt">Escludi dall\'ottimizzazione batteria. I promemoria arriveranno puntuali.</string>
|
||||||
|
<string name="settings_snooze_duration">Durata del posticipo</string>
|
||||||
|
<string name="settings_section_calendars">Calendari</string>
|
||||||
|
<string name="settings_manage_calendars">Gestisci calendari</string>
|
||||||
|
<string name="settings_manage_calendars_hint">Crea calendari locali; gestisci quelli sincronizzati</string>
|
||||||
|
<string name="settings_section_language">Lingua</string>
|
||||||
|
<string name="settings_language">Lingua dell\'app</string>
|
||||||
|
<string name="settings_language_auto">Lingua di sistema</string>
|
||||||
|
<string name="settings_translate">Aiuta a tradurre</string>
|
||||||
|
<string name="settings_translate_hint">Aggiungi o migliora una traduzione su Weblate</string>
|
||||||
|
<string name="settings_appearance_subtitle">Tema, vista di default, inizio settimana</string>
|
||||||
|
<string name="settings_event_form_subtitle">Campi di default per nuovi eventi</string>
|
||||||
|
<string name="settings_section_about">A proposito</string>
|
||||||
|
<string name="settings_license">Licenza</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_about_author">da Jean-Luc Makiola</string>
|
||||||
|
<string name="settings_about_source">Sorgente</string>
|
||||||
|
<string name="settings_about_support">Sostieni lo sviluppo</string>
|
||||||
|
<string name="settings_about_version">Versione %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Icona dell\'app</string>
|
||||||
|
<string name="settings_report_problem">Segnala un problema</string>
|
||||||
|
<string name="settings_report_problem_hint">Invia un crash report o apri il gestore dei problemi</string>
|
||||||
|
<string name="calendars_title">Calendari</string>
|
||||||
|
<string name="calendars_local_header">I tuoi calendari</string>
|
||||||
|
<string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno per avere gli eventi solo su questo dispositivo.</string>
|
||||||
|
<string name="calendars_add">Aggiungi calendario</string>
|
||||||
|
<string name="calendars_disable_hint">.Disattiva un calendario per non vederlo più sull\'app. Non viene cancellato nulla, puoi riattivarlo in qualsiasi momento.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">Mostra \"%1$s\" nell\'app</string>
|
||||||
|
<string name="calendars_synced_header">Calendari sincronizzati</string>
|
||||||
|
<string name="calendars_synced_hint">Questi vengono dagli account presenti sul tuo dispositivo. Creali e modificali nella loro app.</string>
|
||||||
|
<string name="calendars_manage_in_app">Gestisci in app</string>
|
||||||
|
<string name="calendars_account_menu_a11y">Altre opzioni per %1$s</string>
|
||||||
|
<string name="calendars_enable_all">Attiva tutti</string>
|
||||||
|
<string name="calendars_disable_all">Disattiva tutti</string>
|
||||||
|
<string name="calendars_add_account">Aggiungi account</string>
|
||||||
|
<string name="calendars_new_title">Nuovo calendario</string>
|
||||||
|
<string name="calendars_edit_title">Modifica calendario</string>
|
||||||
|
<string name="calendars_name_label">Nome</string>
|
||||||
|
<string name="calendars_color_label">Colore</string>
|
||||||
|
<string name="calendars_description_hint">Aggiungi descrizione</string>
|
||||||
|
<string name="calendars_delete_confirm_title">Eliminare calendario?</string>
|
||||||
|
<string name="calendars_delete_confirm_message">\"%1$s\" e tutti i suoi eventi saranno definitivamente rimossi da questo dispositivo.</string>
|
||||||
|
<string name="calendars_write_error">Impossibile salvare le modifiche.</string>
|
||||||
|
<string name="calendars_backup_header">Backup</string>
|
||||||
|
<string name="calendars_backup_hint">I calendari locali non vengono sincronizzati da nessuna parte, esportali in un file .ics per conservarne una copia.</string>
|
||||||
|
<string name="calendars_backup_action">Esporta come file .ics</string>
|
||||||
|
<string name="calendars_auto_backup">Backup automatico</string>
|
||||||
|
<string name="calendars_auto_backup_hint">Esporta periodicamente in una cartella i tuoi calendari in formato .ics.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">Cartella di backup</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">Scegli una cartella</string>
|
||||||
|
<string name="calendars_auto_backup_interval">Intervallo</string>
|
||||||
|
<string name="calendars_auto_backup_every">Ogni %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">Minimo 30 minuti.</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">Backup automatico non attivo</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">Ultimo backup: %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">Ultimo backup fallito: %1$s</string>
|
||||||
|
<string name="backup_channel_name">Backup</string>
|
||||||
|
<string name="backup_channel_description">Avvisa se i backup automatici falliscono ripetutamente.</string>
|
||||||
|
<string name="backup_failed_title">Backup automatico fallito</string>
|
||||||
|
<string name="backup_failed_text">Calendula non è riuscito a esportare il file di backup. Controlla la cartella di backup nelle Impostazioni.</string>
|
||||||
|
<string name="calendars_backup_failed">Impossibile esportare il backup.</string>
|
||||||
|
<plurals name="calendars_backup_done">
|
||||||
|
<item quantity="one">Esportato %d evento.</item>
|
||||||
|
<item quantity="many">Esportati %d eventi.</item>
|
||||||
|
<item quantity="other">Esportati %d eventi.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_title">Importa eventi</string>
|
||||||
|
<string name="import_target_header">Aggiungi al calendario</string>
|
||||||
|
<string name="import_empty">Non ci sono eventi in questo file.</string>
|
||||||
|
<string name="import_failed">Impossibile leggere il file.</string>
|
||||||
|
<string name="import_no_calendar">Non ci sono calendari su cui importare. Crea prima un calendario locale.</string>
|
||||||
|
<string name="import_done_title">Import completato</string>
|
||||||
|
<string name="import_close">Chiudi</string>
|
||||||
|
<string name="import_warning_recurrence">Alcune occorrenze degli eventi ricorrenti, che erano state modificate, sono state saltate.</string>
|
||||||
|
<string name="import_warning_no_start">Un evento senza orario di inizio è stato saltato.</string>
|
||||||
|
<string name="import_warning_attendees">Gli elenchi dei partecipanti non sono stati importati.</string>
|
||||||
|
<string name="import_warning_timezone">Un fuso orario sconosciuto è tornato sul tuo dispositivo.</string>
|
||||||
|
<plurals name="import_event_count">
|
||||||
|
<item quantity="one">%d evento in questo file.</item>
|
||||||
|
<item quantity="many">%d eventi in questo file.</item>
|
||||||
|
<item quantity="other">%d eventi in questo file.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_action">
|
||||||
|
<item quantity="one">Importa %d evento</item>
|
||||||
|
<item quantity="many">Importa %d eventi</item>
|
||||||
|
<item quantity="other">Importa %d eventi</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_imported">
|
||||||
|
<item quantity="one">Importato %d evento.</item>
|
||||||
|
<item quantity="many">Importati %d eventi.</item>
|
||||||
|
<item quantity="other">Importati %d eventi.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_skipped">
|
||||||
|
<item quantity="one">Saltato %d evento già presente nel calendario.</item>
|
||||||
|
<item quantity="many">SaltatI %d eventi già presentI nel calendario.</item>
|
||||||
|
<item quantity="other">SaltatI %d eventi già presentI nel calendario.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="shortcut_new_event_long">Crea un nuovo evento</string>
|
||||||
|
<string name="qs_tile_new_event_label">Nuovo evento</string>
|
||||||
|
<string name="settings_qs_tile">Aggiungi un toggle nelle Impostazioni Rapide</string>
|
||||||
|
<string name="settings_qs_tile_hint">Aggiungi un toggle \"Nuovo evento\" nel pannello delle Impostazioni Rapide.</string>
|
||||||
|
<string name="crash_dialog_title">Calendula è crashato</string>
|
||||||
|
<string name="crash_dialog_message">Calendula si è chiuso inaspettatamente l\'ultima volta. Puoi contribuire allo sviluppo inviando questo report, che rimane sul tuo dispositivo finchè non scegli di inviarlo e non include informazioni personali o sui tuoi calendari:solo le informazioni tecniche qui sotto.</string>
|
||||||
|
<string name="crash_dialog_report">Report</string>
|
||||||
|
<string name="crash_dialog_dismiss">Non ora</string>
|
||||||
|
<string name="crash_report_issue_title">Crash report</string>
|
||||||
|
<string name="crash_report_clip_label">Calendula crash report</string>
|
||||||
|
<string name="crash_report_copied">Report copiato negli Appunti</string>
|
||||||
|
<string name="crash_report_open_failed">Impossibile aprire il gestore dei problemi. Il report è sugli Appunti.</string>
|
||||||
|
<string name="crash_report_body_template">Grazie per riportare un crash di Calendula. Per favore, aggiungi ogni informazione che ricordi in merito a quello che stavi facendo, quindi invia\n\n### Cosa è successo\n\n\n###Crash report\n%1$s\n</string>
|
||||||
|
<string name="crash_report_body_paste">_(Il report era troppo lungo per questo link - copialo qui dai tuoi Appunti.)_</string>
|
||||||
|
<string name="event_detail_recurrence">Ricorrenza</string>
|
||||||
|
<string name="event_detail_recurring">Evento ricorrente</string>
|
||||||
|
<string name="recurrence_on_days">%1$s da %2$s</string>
|
||||||
|
<string name="recurrence_with_until">%1$s fino a %2$s</string>
|
||||||
|
<string name="recurrence_with_count">%1$s, %2$d volte</string>
|
||||||
|
<string name="reminder_channel_description">Notifiche al momento del promemoria dei tuoi eventi</string>
|
||||||
|
<string name="agenda_header_today">Oggi</string>
|
||||||
|
<string name="widget_next_month">Mese prossimo</string>
|
||||||
|
<string name="widget_today">Oggi</string>
|
||||||
|
<string name="settings_title">Impostazioni</string>
|
||||||
|
<string name="settings_back">Indietro</string>
|
||||||
|
<string name="agenda_range_this_week">Questa settimana</string>
|
||||||
|
<string name="settings_reminders">Promemoria evento</string>
|
||||||
|
<string name="settings_default_reminder">Promemoria standard</string>
|
||||||
|
<string name="settings_notifications_subtitle">Promemoria evento</string>
|
||||||
|
<string name="shortcut_new_event_short">Nuovo evento</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<!-- Permission flow (F1) -->
|
<!-- Permission flow (F1) -->
|
||||||
<string name="permission_rationale_title">See all your events, beautifully</string>
|
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.</string>
|
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s all it asks for up front — and nothing ever leaves your device.</string>
|
||||||
<string name="permission_request_button">Grant calendar access</string>
|
<string name="permission_request_button">Grant calendar access</string>
|
||||||
<string name="permission_denied_title">Calendar access denied</string>
|
<string name="permission_denied_title">Calendar access denied</string>
|
||||||
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
||||||
@@ -68,6 +68,8 @@
|
|||||||
<string name="event_edit_close">Close</string>
|
<string name="event_edit_close">Close</string>
|
||||||
<string name="event_edit_save">Save</string>
|
<string name="event_edit_save">Save</string>
|
||||||
<string name="event_edit_title_hint">Add title</string>
|
<string name="event_edit_title_hint">Add title</string>
|
||||||
|
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
|
||||||
|
<string name="event_edit_managed_hint">Managed by “%1$s” — the title, date and repeat are kept in sync from your contacts. Reminders, location and notes are yours to edit.</string>
|
||||||
<string name="event_edit_starts">Starts</string>
|
<string name="event_edit_starts">Starts</string>
|
||||||
<string name="event_edit_ends">Ends</string>
|
<string name="event_edit_ends">Ends</string>
|
||||||
<string name="event_edit_error_end_before_start">Ends before it starts</string>
|
<string name="event_edit_error_end_before_start">Ends before it starts</string>
|
||||||
@@ -274,6 +276,15 @@
|
|||||||
<string name="settings_default_view">Default view</string>
|
<string name="settings_default_view">Default view</string>
|
||||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</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>
|
||||||
|
<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">Choose file…</string>
|
||||||
|
<string name="settings_font_custom_selected">Custom font</string>
|
||||||
|
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
|
||||||
<string name="settings_week_start">Week starts on</string>
|
<string name="settings_week_start">Week starts on</string>
|
||||||
<string name="settings_week_start_auto">Automatic</string>
|
<string name="settings_week_start_auto">Automatic</string>
|
||||||
<string name="settings_time_format">Time format</string>
|
<string name="settings_time_format">Time format</string>
|
||||||
@@ -308,6 +319,12 @@
|
|||||||
<item quantity="one">%d day</item>
|
<item quantity="one">%d day</item>
|
||||||
<item quantity="other">%d days</item>
|
<item quantity="other">%d days</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="settings_section_views">Views</string>
|
||||||
|
<string name="settings_quick_switch_header">Quick-switch button</string>
|
||||||
|
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
|
||||||
|
<string name="settings_drawer_order_header">Navigation menu</string>
|
||||||
|
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
|
||||||
|
<string name="reorder_drag_handle">Drag to reorder</string>
|
||||||
<string name="settings_section_event_form">New event form</string>
|
<string name="settings_section_event_form">New event form</string>
|
||||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||||
<string name="settings_autofocus_title">Focus title on new event</string>
|
<string name="settings_autofocus_title">Focus title on new event</string>
|
||||||
@@ -324,7 +341,6 @@
|
|||||||
<string name="reminder_none">None</string>
|
<string name="reminder_none">None</string>
|
||||||
<string name="reminder_use_default">Use default reminder</string>
|
<string name="reminder_use_default">Use default reminder</string>
|
||||||
<string name="reminder_custom_amount">Amount</string>
|
<string name="reminder_custom_amount">Amount</string>
|
||||||
<string name="reminder_custom_with_value">Custom (%1$s)</string>
|
|
||||||
<string name="reminder_custom_set">Set</string>
|
<string name="reminder_custom_set">Set</string>
|
||||||
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
|
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
|
||||||
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
|
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
|
||||||
@@ -343,8 +359,38 @@
|
|||||||
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||||
|
<string name="settings_views_subtitle">Quick-switch button and menu order</string>
|
||||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||||
|
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||||
|
|
||||||
|
<!-- Contact special dates (issue #15) -->
|
||||||
|
<string name="settings_section_special_dates">Contact special dates</string>
|
||||||
|
<string name="settings_special_dates_enable">Show contact dates</string>
|
||||||
|
<string name="settings_special_dates_enable_hint">Mirror your contacts\' birthdays and other dates into local calendars. Reads contacts on this device only — nothing is uploaded, and your contacts are never changed.</string>
|
||||||
|
<string name="settings_special_dates_type_birthday">Birthdays</string>
|
||||||
|
<string name="settings_special_dates_type_anniversary">Anniversaries</string>
|
||||||
|
<string name="settings_special_dates_type_custom">Other dates</string>
|
||||||
|
<string name="settings_special_dates_template">Title format</string>
|
||||||
|
<string name="settings_special_dates_template_hint">Use {name} for the contact and {year} for the year (the birth year, or an anniversary\'s start year; hidden when unknown).</string>
|
||||||
|
<string name="settings_special_dates_reminders">Reminders</string>
|
||||||
|
<string name="settings_special_dates_show_year">Show year</string>
|
||||||
|
<string name="settings_special_dates_show_year_hint">Include {year} in titles when it is known</string>
|
||||||
|
<string name="settings_special_dates_sync_now">Sync now</string>
|
||||||
|
<string name="settings_special_dates_never_synced">Not synced yet</string>
|
||||||
|
<!-- %1$s is a relative time, e.g. "5 minutes ago". -->
|
||||||
|
<string name="settings_special_dates_last_synced">Last synced %1$s</string>
|
||||||
|
<string name="settings_special_dates_calendar_hint">Set each calendar\'s colour and visibility in Calendars settings.</string>
|
||||||
|
<string name="settings_calendar_reminders_managed_hint">Set in Contact special dates</string>
|
||||||
|
<string name="settings_special_dates_paused_title">Paused</string>
|
||||||
|
<string name="settings_special_dates_paused_hint">Calendula can no longer read your contacts, so these calendars aren\'t updating.</string>
|
||||||
|
<string name="settings_special_dates_grant">Grant access</string>
|
||||||
|
<string name="settings_special_dates_disable_title">Turn off contact dates?</string>
|
||||||
|
<string name="settings_special_dates_disable_all_message">This deletes the contact-date calendars and their events. Any reminders or notes you added to them will be lost.</string>
|
||||||
|
<!-- %1$s is the date type, e.g. "Birthdays". -->
|
||||||
|
<string name="settings_special_dates_disable_type_message">This deletes the “%1$s” calendar and its events. Any reminders or notes you added to them will be lost.</string>
|
||||||
|
<string name="settings_special_dates_disable_confirm">Turn off</string>
|
||||||
|
<string name="dialog_save">Save</string>
|
||||||
<string name="settings_section_about">About</string>
|
<string name="settings_section_about">About</string>
|
||||||
<string name="settings_license">License</string>
|
<string name="settings_license">License</string>
|
||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
@@ -463,4 +509,13 @@
|
|||||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||||
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
||||||
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
||||||
|
|
||||||
|
<!-- Contact special dates (issue #15). {name} is the contact, {age} the age
|
||||||
|
at the upcoming date (dropped when the year is unknown or Show age is off). -->
|
||||||
|
<string name="special_dates_calendar_birthday">Birthdays</string>
|
||||||
|
<string name="special_dates_calendar_anniversary">Anniversaries</string>
|
||||||
|
<string name="special_dates_calendar_custom">Special dates</string>
|
||||||
|
<string name="special_dates_default_title_birthday">{name}\'s birthday ({year})</string>
|
||||||
|
<string name="special_dates_default_title_anniversary">{name}\'s anniversary ({year})</string>
|
||||||
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -10,4 +10,6 @@
|
|||||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<locale android:name="en" />
|
<locale android:name="en" />
|
||||||
<locale android:name="de" />
|
<locale android:name="de" />
|
||||||
|
<locale android:name="es" />
|
||||||
|
<locale android:name="it" />
|
||||||
</locale-config>
|
</locale-config>
|
||||||
|
|||||||
@@ -83,6 +83,43 @@ class AllDayReminderEncodingTest {
|
|||||||
assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1)
|
assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `next yearly occurrence wraps to next year once this year's date has passed`() {
|
||||||
|
val today = LocalDate.of(2026, 8, 1)
|
||||||
|
assertThat(nextYearlyOccurrence(5, 14, today)).isEqualTo(LocalDate.of(2027, 5, 14))
|
||||||
|
assertThat(nextYearlyOccurrence(12, 25, today)).isEqualTo(LocalDate.of(2026, 12, 25))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `next yearly occurrence returns today when the date is today`() {
|
||||||
|
val today = LocalDate.of(2026, 3, 10)
|
||||||
|
assertThat(nextYearlyOccurrence(3, 10, today)).isEqualTo(today)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `next yearly occurrence skips non-leap years for a Feb-29 date`() {
|
||||||
|
assertThat(nextYearlyOccurrence(2, 29, LocalDate.of(2026, 6, 1)))
|
||||||
|
.isEqualTo(LocalDate.of(2028, 2, 29))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sampling the offset at the next occurrence, not a 1972 anchor, fixes the fire hour`() {
|
||||||
|
// A year-less birthday's DTSTART anchor is 1972 (Berlin had no DST then,
|
||||||
|
// UTC+1). Sampling the offset there skews a modern CEST occurrence by an
|
||||||
|
// hour; sampling at the upcoming occurrence fires at the intended time.
|
||||||
|
val anchor1972 = LocalDate.of(1972, 7, 15)
|
||||||
|
val nextOccurrence = nextYearlyOccurrence(7, 15, LocalDate.of(2026, 6, 1)) // 2026-07-15, CEST
|
||||||
|
val rawFromAnchor = toProviderAllDayMinutes(0, anchor1972, berlin, nineAm)
|
||||||
|
val rawFromNext = toProviderAllDayMinutes(0, nextOccurrence, berlin, nineAm)
|
||||||
|
|
||||||
|
val fireFromAnchor = actualFire(rawFromAnchor, nextOccurrence)
|
||||||
|
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
|
||||||
|
val fireFromNext = actualFire(rawFromNext, nextOccurrence)
|
||||||
|
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
|
||||||
|
assertThat(fireFromAnchor).isEqualTo(LocalTime.of(10, 0)) // skewed +1h
|
||||||
|
assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
|
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
|
||||||
// Known limitation: one fixed MINUTES per series can't track DST. An
|
// Known limitation: one fixed MINUTES per series can't track DST. An
|
||||||
|
|||||||
@@ -199,11 +199,12 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
||||||
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
|
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
|
||||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||||
assertThat(values[CalendarContract.Events.DTEND])
|
// The occurrence's length travels as DURATION, never DTEND — the provider
|
||||||
.isEqualTo(1_781_164_800_000L + 5_400_000L)
|
// rejects DTEND on an exception ("Exceptions can't overwrite dtend") and
|
||||||
// A single occurrence never carries its own rule.
|
// derives the end from DTSTART + DURATION, clearing the inherited RRULE.
|
||||||
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
|
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
|
||||||
@@ -83,6 +86,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun deleteCalendar(id: Long) {
|
override fun deleteCalendar(id: Long) {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
deletedCalendarIds += id
|
deletedCalendarIds += id
|
||||||
|
managedCalendarRows.removeAll { it.id == id }
|
||||||
|
managedEvents.remove(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All-day reminder fire-time minute-of-day passed into the last write. */
|
/** All-day reminder fire-time minute-of-day passed into the last write. */
|
||||||
@@ -139,6 +144,80 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun deleteEvent(eventId: Long) {
|
override fun deleteEvent(eventId: Long) {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
deletedEventIds += eventId
|
deletedEventIds += eventId
|
||||||
|
managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Managed special-dates surface (stateful, so a re-sync sees inserts) ---
|
||||||
|
|
||||||
|
var nextManagedEventId: Long = 1_000L
|
||||||
|
private var managedCalendarSeq: Long = 700L
|
||||||
|
private val managedCalendarRows = mutableListOf<ManagedCalendarRow>()
|
||||||
|
private val managedEvents = mutableMapOf<Long, MutableList<ManagedEventRow>>()
|
||||||
|
|
||||||
|
data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType)
|
||||||
|
val createdManagedCalendars = mutableListOf<CreatedManagedCalendar>()
|
||||||
|
val insertedManagedEvents = mutableListOf<Pair<EventForm, String>>()
|
||||||
|
val updatedManagedFields = mutableListOf<Pair<Long, Map<String, Any?>>>()
|
||||||
|
|
||||||
|
/** Preset an orphan managed calendar (e.g. to test adoption after a prefs wipe). */
|
||||||
|
fun presetManagedCalendar(id: Long, type: SpecialDateType) {
|
||||||
|
managedCalendarRows += ManagedCalendarRow(id, type)
|
||||||
|
managedEvents.getOrPut(id) { mutableListOf() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
createdManagedCalendars += CreatedManagedCalendar(displayName, color, type)
|
||||||
|
val id = managedCalendarSeq++
|
||||||
|
managedCalendarRows += ManagedCalendarRow(id, type)
|
||||||
|
managedEvents.getOrPut(id) { mutableListOf() }
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findManagedCalendars(): List<ManagedCalendarRow> = managedCalendarRows.toList()
|
||||||
|
|
||||||
|
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> =
|
||||||
|
managedEvents[calendarId]?.toList() ?: emptyList()
|
||||||
|
|
||||||
|
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
insertedManagedEvents += form to uid
|
||||||
|
allDayReminderTimes += allDayReminderTimeMinutes
|
||||||
|
val id = nextManagedEventId++
|
||||||
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
|
managedEvents.getOrPut(requireNotNull(form.calendarId)) { mutableListOf() } +=
|
||||||
|
ManagedEventRow(id, uid, form.title.trim(), times.dtStartMillis, form.rrule)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** (calendarId, allDayTime, minutes) recorded from applyManagedCalendarReminders. */
|
||||||
|
val appliedManagedReminders = mutableListOf<Triple<Long, Int, List<Int>>>()
|
||||||
|
|
||||||
|
override fun applyManagedCalendarReminders(
|
||||||
|
calendarId: Long,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
minutes: List<Int>,
|
||||||
|
) {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
appliedManagedReminders += Triple(calendarId, allDayReminderTimeMinutes, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
if (values.isEmpty()) return
|
||||||
|
updatedManagedFields += eventId to values
|
||||||
|
managedEvents.values.forEach { rows ->
|
||||||
|
val idx = rows.indexOfFirst { it.eventId == eventId }
|
||||||
|
if (idx >= 0) {
|
||||||
|
var row = rows[idx]
|
||||||
|
(values[CalendarContract.Events.TITLE] as? String)?.let { row = row.copy(title = it) }
|
||||||
|
(values[CalendarContract.Events.DTSTART] as? Long)?.let { row = row.copy(dtStartMillis = it) }
|
||||||
|
if (values.containsKey(CalendarContract.Events.RRULE)) {
|
||||||
|
row = row.copy(rrule = values[CalendarContract.Events.RRULE] as? String)
|
||||||
|
}
|
||||||
|
rows[idx] = row
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
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.FakeCalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
class SpecialDatesSyncEngineTest {
|
||||||
|
|
||||||
|
private class FakeContacts(
|
||||||
|
var permission: Boolean = true,
|
||||||
|
var dates: List<ContactSpecialDate> = emptyList(),
|
||||||
|
) : ContactSpecialDatesDataSource {
|
||||||
|
override fun hasPermission() = permission
|
||||||
|
override fun readSpecialDates() = dates
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeSpec : SpecialDatesCalendarSpec {
|
||||||
|
override fun displayName(type: SpecialDateType) = type.name
|
||||||
|
override fun color(type: SpecialDateType) = 0xFF112233.toInt()
|
||||||
|
override fun defaultTitleTemplate(type: SpecialDateType) = when (type) {
|
||||||
|
SpecialDateType.Birthday -> "{name}'s birthday ({year})"
|
||||||
|
SpecialDateType.Anniversary -> "{name}'s anniversary"
|
||||||
|
SpecialDateType.Custom -> "{name}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prefs(tempDir: Path): SettingsPrefs {
|
||||||
|
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
|
||||||
|
)
|
||||||
|
return SettingsPrefs(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun birthday(key: String, name: String, month: Int, day: Int, year: Int?) =
|
||||||
|
ContactSpecialDate(key, name, SpecialDateType.Birthday, month, day, year)
|
||||||
|
|
||||||
|
private suspend fun engineWith(
|
||||||
|
tempDir: Path,
|
||||||
|
contacts: FakeContacts,
|
||||||
|
calendars: FakeCalendarDataSource = FakeCalendarDataSource(),
|
||||||
|
enabled: Boolean = true,
|
||||||
|
): Pair<SpecialDatesSyncEngine, SettingsPrefs> {
|
||||||
|
val settings = prefs(tempDir)
|
||||||
|
if (enabled) settings.setSpecialDatesEnabled(true)
|
||||||
|
return SpecialDatesSyncEngine(contacts, calendars, settings, FakeSpec()) to settings
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabled feature is a no-op`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val (engine, _) = engineWith(tempDir, FakeContacts(), calendars, enabled = false)
|
||||||
|
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.Disabled)
|
||||||
|
assertThat(calendars.createdManagedCalendars).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing permission reports stalled without touching calendars`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val (engine, _) = engineWith(tempDir, FakeContacts(permission = false), calendars)
|
||||||
|
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.PermissionMissing)
|
||||||
|
assertThat(calendars.createdManagedCalendars).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `first sync creates calendars and inserts one event per contact`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val contacts = FakeContacts(
|
||||||
|
dates = listOf(
|
||||||
|
birthday("a", "Jane", 5, 14, 1990),
|
||||||
|
birthday("b", "Bob", 12, 25, null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val (engine, settings) = engineWith(tempDir, contacts, calendars)
|
||||||
|
|
||||||
|
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.Success)
|
||||||
|
// All three type calendars exist; the birthday one holds both contacts.
|
||||||
|
assertThat(calendars.createdManagedCalendars.map { it.type })
|
||||||
|
.containsExactlyElementsIn(SpecialDateType.entries)
|
||||||
|
assertThat(calendars.insertedManagedEvents).hasSize(2)
|
||||||
|
assertThat(calendars.insertedManagedEvents.map { it.second })
|
||||||
|
.containsExactly("contact-birthday:a@calendula", "contact-birthday:b@calendula")
|
||||||
|
// Year-less contact drops the year; year-known keeps it.
|
||||||
|
val titles = calendars.insertedManagedEvents.map { it.first.title }
|
||||||
|
assertThat(titles).contains("Jane's birthday (1990)")
|
||||||
|
assertThat(titles).contains("Bob's birthday")
|
||||||
|
// Calendar ids were persisted for the editor / next sync.
|
||||||
|
assertThat(settings.specialDatesCalendars.first()).containsKey(SpecialDateType.Birthday)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a second sync with no changes writes nothing`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||||
|
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||||
|
|
||||||
|
engine.sync()
|
||||||
|
val insertsAfterFirst = calendars.insertedManagedEvents.size
|
||||||
|
val calendarsAfterFirst = calendars.createdManagedCalendars.size
|
||||||
|
engine.sync()
|
||||||
|
|
||||||
|
assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst)
|
||||||
|
assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst)
|
||||||
|
assertThat(calendars.updatedManagedFields).isEmpty()
|
||||||
|
assertThat(calendars.deletedEventIds).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a renamed contact triggers a title-only managed update`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||||
|
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||||
|
engine.sync()
|
||||||
|
|
||||||
|
contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990))
|
||||||
|
engine.sync()
|
||||||
|
|
||||||
|
assertThat(calendars.updatedManagedFields).hasSize(1)
|
||||||
|
val (_, columns) = calendars.updatedManagedFields.single()
|
||||||
|
// Only managed columns are ever written — never reminders/location.
|
||||||
|
assertThat(columns.keys).containsExactly(CalendarContract.Events.TITLE)
|
||||||
|
assertThat(columns[CalendarContract.Events.TITLE]).isEqualTo("Janet's birthday (1990)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a removed contact deletes its event`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||||
|
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||||
|
engine.sync()
|
||||||
|
|
||||||
|
contacts.dates = emptyList()
|
||||||
|
engine.sync()
|
||||||
|
|
||||||
|
assertThat(calendars.deletedEventIds).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabling a type deletes its calendar on the next sync`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
|
||||||
|
engine.sync()
|
||||||
|
val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary)
|
||||||
|
|
||||||
|
settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
|
||||||
|
engine.sync()
|
||||||
|
|
||||||
|
assertThat(calendars.deletedCalendarIds).contains(anniversaryId)
|
||||||
|
assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applyReminders persists the override and applies it to all events`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||||
|
val (engine, settings) = engineWith(tempDir, contacts, calendars)
|
||||||
|
engine.sync()
|
||||||
|
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
|
||||||
|
|
||||||
|
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.Minutes(listOf(0, 1440)))
|
||||||
|
|
||||||
|
// Persisted as the per-calendar all-day override (so new events match)...
|
||||||
|
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal])
|
||||||
|
.isEqualTo(listOf(0, 1440))
|
||||||
|
// ...and applied to the existing events in that calendar.
|
||||||
|
assertThat(calendars.appliedManagedReminders).contains(
|
||||||
|
Triple(birthdayCal, settings.allDayReminderTimeMinutes.first(), listOf(0, 1440)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `applyReminders with None clears the reminders`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
|
||||||
|
engine.sync()
|
||||||
|
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
|
||||||
|
|
||||||
|
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None)
|
||||||
|
|
||||||
|
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal]).isEmpty()
|
||||||
|
assertThat(calendars.appliedManagedReminders.last().third).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pure diff ---
|
||||||
|
|
||||||
|
private fun desired(uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
|
||||||
|
ManagedEventDesired(uid, title, dt, rrule)
|
||||||
|
|
||||||
|
private fun row(id: Long, uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
|
||||||
|
ManagedEventRow(id, uid, title, dt, rrule)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `diff inserts new, deletes gone, and leaves unchanged alone`() {
|
||||||
|
val diff = diffManagedEvents(
|
||||||
|
desired = listOf(desired("keep", "Jane", 100), desired("new", "Ada", 200)),
|
||||||
|
existing = listOf(row(1, "keep", "Jane", 100), row(2, "gone", "Old", 300)),
|
||||||
|
)
|
||||||
|
assertThat(diff.insertUids).containsExactly("new")
|
||||||
|
assertThat(diff.deleteEventIds).containsExactly(2L)
|
||||||
|
assertThat(diff.updates).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `diff emits only the changed managed columns`() {
|
||||||
|
val diff = diffManagedEvents(
|
||||||
|
desired = listOf(desired("a", "Janet", 150)),
|
||||||
|
existing = listOf(row(9, "a", "Jane", 100)),
|
||||||
|
)
|
||||||
|
val update = diff.updates.single()
|
||||||
|
assertThat(update.eventId).isEqualTo(9L)
|
||||||
|
assertThat(update.columns.keys)
|
||||||
|
.containsExactly(CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,12 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
|||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -149,6 +154,94 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.defaultFormFields.first()).isEmpty()
|
assertThat(prefs.defaultFormFields.first()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `quick-switch defaults to every view enabled in default order`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
val config = prefs.quickSwitchConfig.first()
|
||||||
|
assertThat(config.order).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
|
||||||
|
assertThat(config.enabled).containsExactlyElementsIn(IMPLEMENTED_VIEWS)
|
||||||
|
assertThat(config.cycle).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `quick-switch config round-trips order and disabled views`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
val config = QuickSwitchConfig(
|
||||||
|
order = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day),
|
||||||
|
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
|
||||||
|
)
|
||||||
|
prefs.setQuickSwitchConfig(config)
|
||||||
|
val loaded = prefs.quickSwitchConfig.first()
|
||||||
|
assertThat(loaded.order).containsExactly(
|
||||||
|
CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day,
|
||||||
|
).inOrder()
|
||||||
|
assertThat(loaded.enabled).containsExactly(CalendarView.Agenda, CalendarView.Month)
|
||||||
|
assertThat(loaded.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `quick-switch parse appends missing views enabled and drops unknowns`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = SettingsPrefs(store)
|
||||||
|
// Only Day (disabled) and Week stored, plus a bogus name; Month & Agenda absent.
|
||||||
|
store.updateData { p ->
|
||||||
|
val m = p.toMutablePreferences()
|
||||||
|
m[SettingsPrefs.QUICK_SWITCH_VIEWS_KEY] = "!Day,Week,Hologram"
|
||||||
|
m
|
||||||
|
}
|
||||||
|
val config = prefs.quickSwitchConfig.first()
|
||||||
|
// Stored order first (Day, Week), then the omitted views in default order.
|
||||||
|
assertThat(config.order).containsExactly(
|
||||||
|
CalendarView.Day, CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
|
||||||
|
).inOrder()
|
||||||
|
// Day was explicitly disabled; the appended Month & Agenda default enabled.
|
||||||
|
assertThat(config.enabled).containsExactly(
|
||||||
|
CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateQuickSwitch transforms the stored value, so intents compose`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
// A toggle then a reorder, each expressed against the *stored* config —
|
||||||
|
// the reorder must not resurrect the just-disabled view (the stale-UI-
|
||||||
|
// snapshot race this API exists to prevent).
|
||||||
|
prefs.updateQuickSwitch { it.copy(enabled = it.enabled - CalendarView.Day) }
|
||||||
|
val newOrder = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day)
|
||||||
|
prefs.updateQuickSwitch { it.copy(order = newOrder) }
|
||||||
|
val config = prefs.quickSwitchConfig.first()
|
||||||
|
assertThat(config.order).containsExactlyElementsIn(newOrder).inOrder()
|
||||||
|
assertThat(config.enabled).containsExactly(
|
||||||
|
CalendarView.Agenda, CalendarView.Month, CalendarView.Week,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.drawerViewOrder.first()).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
|
||||||
|
prefs.setDrawerViewOrder(
|
||||||
|
listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month),
|
||||||
|
)
|
||||||
|
assertThat(prefs.drawerViewOrder.first()).containsExactly(
|
||||||
|
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `drawer order parse appends missing views and drops unknowns`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = SettingsPrefs(store)
|
||||||
|
store.updateData { p ->
|
||||||
|
val m = p.toMutablePreferences()
|
||||||
|
m[SettingsPrefs.DRAWER_VIEW_ORDER_KEY] = "Agenda,Nope,Week"
|
||||||
|
m
|
||||||
|
}
|
||||||
|
assertThat(prefs.drawerViewOrder.first()).containsExactly(
|
||||||
|
CalendarView.Agenda, CalendarView.Week, CalendarView.Month, CalendarView.Day,
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
|
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
|
||||||
val store = newDataStore(tempDir)
|
val store = newDataStore(tempDir)
|
||||||
@@ -183,22 +276,38 @@ class SettingsPrefsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest {
|
fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
fun `default reminder round-trips none, single, and multiple lead times`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
prefs.setDefaultReminderMinutes(30)
|
prefs.setDefaultReminderMinutes(listOf(30))
|
||||||
assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30)
|
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
|
||||||
prefs.setDefaultReminderMinutes(null)
|
// Several lead times persist, normalised to distinct ascending order.
|
||||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
prefs.setDefaultReminderMinutes(listOf(10_080, 0, 30, 30))
|
||||||
|
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(0, 30, 10_080).inOrder()
|
||||||
|
prefs.setDefaultReminderMinutes(emptyList())
|
||||||
|
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `garbage stored default reminder reads as none`(@TempDir tempDir: Path) = runTest {
|
fun `legacy single-value default reminder parses as a one-element list`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = SettingsPrefs(store)
|
||||||
|
// The pre-multi-reminder format stored a bare integer string.
|
||||||
|
store.updateData { p ->
|
||||||
|
val m = p.toMutablePreferences()
|
||||||
|
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "30"
|
||||||
|
m
|
||||||
|
}
|
||||||
|
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage stored default reminder reads as empty`(@TempDir tempDir: Path) = runTest {
|
||||||
val store = newDataStore(tempDir)
|
val store = newDataStore(tempDir)
|
||||||
val prefs = SettingsPrefs(store)
|
val prefs = SettingsPrefs(store)
|
||||||
store.updateData { p ->
|
store.updateData { p ->
|
||||||
@@ -206,7 +315,7 @@ class SettingsPrefsTest {
|
|||||||
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
|
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
|
||||||
m
|
m
|
||||||
}
|
}
|
||||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -214,65 +323,85 @@ class SettingsPrefsTest {
|
|||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
|
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
|
||||||
|
|
||||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
|
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15, 10_080)))
|
||||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
|
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
|
||||||
prefs.perCalendarReminderOverride.first().let { map ->
|
prefs.perCalendarReminderOverride.first().let { map ->
|
||||||
assertThat(map).containsExactly(7L, 15, 9L, null)
|
assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList<Int>())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inherit drops the override entirely (absent != null value).
|
// Inherit drops the override entirely (absent != an empty-list value).
|
||||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
|
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
|
||||||
prefs.perCalendarReminderOverride.first().let { map ->
|
prefs.perCalendarReminderOverride.first().let { map ->
|
||||||
assertThat(map).containsExactly(7L, 15)
|
assertThat(map).containsExactly(7L, listOf(15, 10_080))
|
||||||
assertThat(map.containsKey(9L)).isFalse()
|
assertThat(map.containsKey(9L)).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage per-calendar override values drop the entry, the none sentinel stays explicit`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = SettingsPrefs(store)
|
||||||
|
// Only "none" deliberately means an explicit no-reminder override; a
|
||||||
|
// value with no valid minutes is garbage and must drop its entry so the
|
||||||
|
// calendar inherits the global default instead of silently losing
|
||||||
|
// reminders. Partially-valid values keep their salvageable minutes.
|
||||||
|
store.updateData { p ->
|
||||||
|
val m = p.toMutablePreferences()
|
||||||
|
m[SettingsPrefs.CALENDAR_REMINDER_OVERRIDE_KEY] = "5=none;7=abc;8=;9=30,abc;11=15"
|
||||||
|
m
|
||||||
|
}
|
||||||
|
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(
|
||||||
|
5L, emptyList<Int>(), 9L, listOf(30), 11L, listOf(15),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
|
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
|
||||||
prefs.setDefaultAllDayReminderMinutes(1_440)
|
prefs.setDefaultAllDayReminderMinutes(listOf(1_440))
|
||||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440)
|
assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(1_440)
|
||||||
prefs.setDefaultAllDayReminderMinutes(null)
|
prefs.setDefaultAllDayReminderMinutes(emptyList())
|
||||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
|
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
|
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
|
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15)))
|
||||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440))
|
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440)))
|
||||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
|
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
||||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440)
|
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440))
|
||||||
// Clearing the all-day override leaves the timed one untouched.
|
// Clearing the all-day override leaves the timed one untouched.
|
||||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
|
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
|
||||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
|
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
|
||||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
|
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `resolveDefaultReminder picks the kind-matching override or global`() {
|
fun `resolveDefaultReminder picks the kind-matching override or global`() {
|
||||||
val timed = mapOf(7L to 15, 9L to null)
|
val timed = mapOf(7L to listOf(15, 10_080), 9L to emptyList<Int>())
|
||||||
val allDay = mapOf(7L to 2_880)
|
val allDay = mapOf(7L to listOf(2_880))
|
||||||
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
|
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
|
||||||
timedGlobal = 30,
|
timedGlobal = listOf(30),
|
||||||
allDayGlobal = 1_440,
|
allDayGlobal = listOf(1_440),
|
||||||
timedOverrides = timed,
|
timedOverrides = timed,
|
||||||
allDayOverrides = allDay,
|
allDayOverrides = allDay,
|
||||||
calendarId = calendarId,
|
calendarId = calendarId,
|
||||||
isAllDay = isAllDay,
|
isAllDay = isAllDay,
|
||||||
)
|
)
|
||||||
// Timed: minutes override, explicit none, inherit global, no calendar.
|
// Timed: multi-value override, explicit none, inherit global, no calendar.
|
||||||
assertThat(resolve(7L, isAllDay = false)).isEqualTo(15)
|
assertThat(resolve(7L, isAllDay = false)).containsExactly(15, 10_080).inOrder()
|
||||||
assertThat(resolve(9L, isAllDay = false)).isNull()
|
assertThat(resolve(9L, isAllDay = false)).isEmpty()
|
||||||
assertThat(resolve(5L, isAllDay = false)).isEqualTo(30)
|
assertThat(resolve(5L, isAllDay = false)).containsExactly(30)
|
||||||
assertThat(resolve(null, isAllDay = false)).isEqualTo(30)
|
assertThat(resolve(null, isAllDay = false)).containsExactly(30)
|
||||||
// All-day: its own override wins; absent → all-day global; a timed-only
|
// All-day: its own override wins; absent → all-day global; a timed-only
|
||||||
// override (cal 9) does not bleed into all-day.
|
// override (cal 9) does not bleed into all-day.
|
||||||
assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880)
|
assertThat(resolve(7L, isAllDay = true)).containsExactly(2_880)
|
||||||
assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440)
|
assertThat(resolve(9L, isAllDay = true)).containsExactly(1_440)
|
||||||
assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440)
|
assertThat(resolve(5L, isAllDay = true)).containsExactly(1_440)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -311,6 +440,19 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
|
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `custom-font stamps default to zero and bump per role independently`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
// Existing installs have no stamp key — that must read as 0.
|
||||||
|
assertThat(prefs.brandFontStamp.first()).isEqualTo(0)
|
||||||
|
assertThat(prefs.plainFontStamp.first()).isEqualTo(0)
|
||||||
|
prefs.bumpCustomFontStamp(FontRole.BRAND)
|
||||||
|
prefs.bumpCustomFontStamp(FontRole.BRAND)
|
||||||
|
prefs.bumpCustomFontStamp(FontRole.PLAIN)
|
||||||
|
assertThat(prefs.brandFontStamp.first()).isEqualTo(2)
|
||||||
|
assertThat(prefs.plainFontStamp.first()).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||||
assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US))
|
assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US))
|
||||||
@@ -324,4 +466,59 @@ class SettingsPrefsTest {
|
|||||||
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `special dates feature is off with all types on by default`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.specialDatesEnabled.first()).isFalse()
|
||||||
|
assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet())
|
||||||
|
assertThat(prefs.specialDatesCalendars.first()).isEmpty()
|
||||||
|
assertThat(prefs.managedCalendarIds.first()).isEmpty()
|
||||||
|
assertThat(prefs.specialDatesShowYear.first()).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `special date type toggles round-trip`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
|
||||||
|
assertThat(prefs.specialDatesTypes.first())
|
||||||
|
.isEqualTo(setOf(SpecialDateType.Birthday, SpecialDateType.Custom))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `managed calendar ids round-trip and drive managedCalendarIds`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, 7L)
|
||||||
|
prefs.setSpecialDatesCalendarId(SpecialDateType.Anniversary, 9L)
|
||||||
|
assertThat(prefs.specialDatesCalendars.first())
|
||||||
|
.isEqualTo(mapOf(SpecialDateType.Birthday to 7L, SpecialDateType.Anniversary to 9L))
|
||||||
|
assertThat(prefs.managedCalendarIds.first()).isEqualTo(setOf(7L, 9L))
|
||||||
|
|
||||||
|
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, null)
|
||||||
|
assertThat(prefs.specialDatesCalendars.first())
|
||||||
|
.isEqualTo(mapOf(SpecialDateType.Anniversary to 9L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `title template round-trips per type`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]).isEmpty()
|
||||||
|
prefs.setSpecialDatesTitleTemplate(SpecialDateType.Birthday, "{name}'s birthday")
|
||||||
|
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday])
|
||||||
|
.isEqualTo("{name}'s birthday")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sync status records run and stalled reason`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.specialDatesStatus.first())
|
||||||
|
.isEqualTo(SpecialDatesStatus(lastRun = 0L, stalled = null))
|
||||||
|
|
||||||
|
prefs.recordSpecialDatesRun(1000L, SpecialDatesStalledReason.PermissionRevoked)
|
||||||
|
assertThat(prefs.specialDatesStatus.first())
|
||||||
|
.isEqualTo(SpecialDatesStatus(1000L, SpecialDatesStalledReason.PermissionRevoked))
|
||||||
|
|
||||||
|
prefs.recordSpecialDatesRun(2000L, null)
|
||||||
|
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class PostableAlertsTest {
|
||||||
|
|
||||||
|
private fun alert(alertId: Long, calendarId: Long) = ReminderAlert(
|
||||||
|
alertId = alertId,
|
||||||
|
eventId = alertId * 10,
|
||||||
|
calendarId = calendarId,
|
||||||
|
beginMillis = 0L,
|
||||||
|
endMillis = 0L,
|
||||||
|
title = "Event $alertId",
|
||||||
|
location = null,
|
||||||
|
isAllDay = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps alerts when no calendar is disabled`() {
|
||||||
|
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 200))
|
||||||
|
|
||||||
|
val postable = postableAlerts(due, disabledCalendarIds = emptySet())
|
||||||
|
|
||||||
|
assertThat(postable).isEqualTo(due)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `drops alerts for a disabled calendar`() {
|
||||||
|
val keep = alert(1, calendarId = 100)
|
||||||
|
val drop = alert(2, calendarId = 200)
|
||||||
|
|
||||||
|
val postable = postableAlerts(listOf(keep, drop), disabledCalendarIds = setOf(200))
|
||||||
|
|
||||||
|
assertThat(postable).containsExactly(keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `drops every alert when all their calendars are disabled`() {
|
||||||
|
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
|
||||||
|
|
||||||
|
val postable = postableAlerts(due, disabledCalendarIds = setOf(100))
|
||||||
|
|
||||||
|
assertThat(postable).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps multiple alerts from the same enabled calendar`() {
|
||||||
|
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
|
||||||
|
|
||||||
|
val postable = postableAlerts(due, disabledCalendarIds = setOf(999))
|
||||||
|
|
||||||
|
assertThat(postable).isEqualTo(due)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `alert with unknown calendar id 0 is never treated as disabled`() {
|
||||||
|
// Pre-upgrade snooze PendingIntents carry no calendar id (defaults to 0L).
|
||||||
|
val preUpgrade = alert(1, calendarId = 0L)
|
||||||
|
|
||||||
|
assertThat(preUpgrade.isForDisabledCalendar(disabledCalendarIds = setOf(0L))).isFalse()
|
||||||
|
assertThat(postableAlerts(listOf(preUpgrade), disabledCalendarIds = setOf(0L)))
|
||||||
|
.containsExactly(preUpgrade)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isForDisabledCalendar matches only the disabled ids`() {
|
||||||
|
assertThat(alert(1, calendarId = 200).isForDisabledCalendar(setOf(200))).isTrue()
|
||||||
|
assertThat(alert(1, calendarId = 100).isForDisabledCalendar(setOf(200))).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
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 kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
class SuppressedReminderStoreTest {
|
||||||
|
|
||||||
|
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { tempDir.resolve("test_prefs.preferences_pb").toFile() },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun alert(
|
||||||
|
alertId: Long,
|
||||||
|
calendarId: Long,
|
||||||
|
endMillis: Long = Long.MAX_VALUE,
|
||||||
|
title: String = "Event $alertId",
|
||||||
|
location: String? = null,
|
||||||
|
) = ReminderAlert(
|
||||||
|
alertId = alertId,
|
||||||
|
eventId = alertId * 10,
|
||||||
|
calendarId = calendarId,
|
||||||
|
beginMillis = 0L,
|
||||||
|
endMillis = endMillis,
|
||||||
|
title = title,
|
||||||
|
location = location,
|
||||||
|
isAllDay = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encode then decode round-trips every field including delimiters`() {
|
||||||
|
val original = alert(
|
||||||
|
alertId = 7,
|
||||||
|
calendarId = 42,
|
||||||
|
endMillis = 123_456_789L,
|
||||||
|
// Free-text with the field separator and other awkward characters.
|
||||||
|
title = "Lunch | with | Alice",
|
||||||
|
location = "Café, 3rd floor | room B",
|
||||||
|
).copy(beginMillis = 100L, isAllDay = true)
|
||||||
|
|
||||||
|
val decoded = decodeStashEntry(encodeStashEntry(original))
|
||||||
|
|
||||||
|
assertThat(decoded).isEqualTo(original)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decode returns null for a malformed entry`() {
|
||||||
|
assertThat(decodeStashEntry("not-a-valid-entry")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `null location round-trips`() {
|
||||||
|
val original = alert(1, calendarId = 1, location = null)
|
||||||
|
assertThat(decodeStashEntry(encodeStashEntry(original))).isEqualTo(original)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recoverFor returns and removes only the re-enabled calendars`() = runTest {
|
||||||
|
val store = SuppressedReminderStore(newDataStore(tempDir))
|
||||||
|
val keep = alert(1, calendarId = 100)
|
||||||
|
val recoverA = alert(2, calendarId = 200)
|
||||||
|
val recoverB = alert(3, calendarId = 200)
|
||||||
|
store.stash(listOf(keep, recoverA, recoverB), nowMillis = 0L)
|
||||||
|
|
||||||
|
val recovered = store.recoverFor(setOf(200L), nowMillis = 0L)
|
||||||
|
|
||||||
|
assertThat(recovered).containsExactly(recoverA, recoverB)
|
||||||
|
// The still-disabled calendar's alert stays stashed; the recovered ones are gone.
|
||||||
|
assertThat(store.recoverFor(setOf(100L, 200L), nowMillis = 0L)).containsExactly(keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stash drops alerts whose event already ended`() = runTest {
|
||||||
|
val store = SuppressedReminderStore(newDataStore(tempDir))
|
||||||
|
val past = alert(1, calendarId = 100, endMillis = 500L)
|
||||||
|
val future = alert(2, calendarId = 100, endMillis = 2_000L)
|
||||||
|
|
||||||
|
store.stash(listOf(past, future), nowMillis = 1_000L)
|
||||||
|
|
||||||
|
assertThat(store.recoverFor(setOf(100L), nowMillis = 1_000L)).containsExactly(future)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `purgeExpired removes only entries past their event end`() = runTest {
|
||||||
|
val store = SuppressedReminderStore(newDataStore(tempDir))
|
||||||
|
// Stash both before "now" so neither is dropped on write, then advance time.
|
||||||
|
store.stash(
|
||||||
|
listOf(
|
||||||
|
alert(1, calendarId = 100, endMillis = 500L),
|
||||||
|
alert(2, calendarId = 100, endMillis = 2_000L),
|
||||||
|
),
|
||||||
|
nowMillis = 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
store.purgeExpired(nowMillis = 1_000L)
|
||||||
|
|
||||||
|
assertThat(store.recoverFor(setOf(100L), nowMillis = 0L).map { it.alertId })
|
||||||
|
.containsExactly(2L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stash replaces an existing entry with the same alert id`() = runTest {
|
||||||
|
val store = SuppressedReminderStore(newDataStore(tempDir))
|
||||||
|
store.stash(listOf(alert(1, calendarId = 100, title = "old")), nowMillis = 0L)
|
||||||
|
store.stash(listOf(alert(1, calendarId = 100, title = "new")), nowMillis = 0L)
|
||||||
|
|
||||||
|
val recovered = store.recoverFor(setOf(100L), nowMillis = 0L)
|
||||||
|
|
||||||
|
assertThat(recovered).hasSize(1)
|
||||||
|
assertThat(recovered.single().title).isEqualTo("new")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isRelevantAt is true up to the event end and false after`() {
|
||||||
|
val a = alert(1, calendarId = 1, endMillis = 1_000L)
|
||||||
|
assertThat(a.isRelevantAt(999L)).isTrue()
|
||||||
|
assertThat(a.isRelevantAt(1_000L)).isTrue()
|
||||||
|
assertThat(a.isRelevantAt(1_001L)).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
lateinit var tempDir: Path
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class ContactSpecialDateTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a full yyyy-MM-dd date with year`() {
|
||||||
|
val parts = parseContactEventDate("1990-05-14")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a year-less --MM-dd date`() {
|
||||||
|
val parts = parseContactEventDate("--05-14")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 5, day = 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a compact yyyyMMdd date`() {
|
||||||
|
val parts = parseContactEventDate("19900514")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parses a year-less MM-dd date without the double-dash prefix`() {
|
||||||
|
val parts = parseContactEventDate("12-25")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 12, day = 25))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps a year-less Feb 29 by validating against a leap anchor`() {
|
||||||
|
val parts = parseContactEventDate("--02-29")
|
||||||
|
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 2, day = 29))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps Feb 29 in a real leap year`() {
|
||||||
|
assertThat(parseContactEventDate("2000-02-29"))
|
||||||
|
.isEqualTo(ContactDateParts(year = 2000, month = 2, day = 29))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects Feb 29 in a non-leap year`() {
|
||||||
|
assertThat(parseContactEventDate("1999-02-29")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects an impossible month or day`() {
|
||||||
|
assertThat(parseContactEventDate("2020-13-01")).isNull()
|
||||||
|
assertThat(parseContactEventDate("2020-06-31")).isNull()
|
||||||
|
assertThat(parseContactEventDate("--00-10")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects blank, null and garbage input`() {
|
||||||
|
assertThat(parseContactEventDate(null)).isNull()
|
||||||
|
assertThat(parseContactEventDate("")).isNull()
|
||||||
|
assertThat(parseContactEventDate(" ")).isNull()
|
||||||
|
assertThat(parseContactEventDate("not-a-date")).isNull()
|
||||||
|
assertThat(parseContactEventDate("2020")).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class SpecialDateFormattingTest {
|
||||||
|
|
||||||
|
private fun birthday(month: Int, day: Int, year: Int?) = ContactSpecialDate(
|
||||||
|
lookupKey = "k",
|
||||||
|
displayName = "Jane",
|
||||||
|
type = SpecialDateType.Birthday,
|
||||||
|
month = month,
|
||||||
|
day = day,
|
||||||
|
year = year,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `uid is deterministic and namespaced by type`() {
|
||||||
|
assertThat(managedEventUid(SpecialDateType.Birthday, "abc"))
|
||||||
|
.isEqualTo("contact-birthday:abc@calendula")
|
||||||
|
assertThat(managedEventUid(SpecialDateType.Anniversary, "abc"))
|
||||||
|
.isEqualTo("contact-anniversary:abc@calendula")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun custom(label: String?, month: Int, day: Int) = ContactSpecialDate(
|
||||||
|
lookupKey = "k",
|
||||||
|
displayName = "Jane",
|
||||||
|
type = SpecialDateType.Custom,
|
||||||
|
month = month,
|
||||||
|
day = day,
|
||||||
|
year = null,
|
||||||
|
label = label,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `birthday and anniversary uids ignore the discriminator`() {
|
||||||
|
assertThat(birthday(5, 14, 1990).managedUid()).isEqualTo("contact-birthday:k@calendula")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two custom dates on one contact get distinct uids by label`() {
|
||||||
|
assertThat(custom("Wedding", 6, 10).managedUid())
|
||||||
|
.isEqualTo("contact-custom:k:wedding@calendula")
|
||||||
|
assertThat(custom("Graduation", 9, 1).managedUid())
|
||||||
|
.isNotEqualTo(custom("Wedding", 6, 10).managedUid())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `label-less custom dates fall back to month-day so distinct dates survive`() {
|
||||||
|
assertThat(custom(null, 6, 10).managedUid())
|
||||||
|
.isEqualTo("contact-custom:k:06-10@calendula")
|
||||||
|
assertThat(custom(null, 9, 1).managedUid())
|
||||||
|
.isNotEqualTo(custom(null, 6, 10).managedUid())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `anchor date uses the known year`() {
|
||||||
|
assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `anchor date falls back to a leap year when year-less`() {
|
||||||
|
assertThat(birthday(2, 29, null).anchorDate()).isEqualTo(LocalDate(YEARLESS_ANCHOR_YEAR, 2, 29))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `title substitutes name and year`() {
|
||||||
|
assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", 1990))
|
||||||
|
.isEqualTo("Jane's birthday (1990)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `title drops an empty year parenthesis and tidies spacing`() {
|
||||||
|
assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", null))
|
||||||
|
.isEqualTo("Jane's birthday")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pure emit rule behind [ReminderDefaultPicker]: a non-empty selection is
|
||||||
|
* [CalendarReminderOverride.Minutes] (normalised), while clearing the last time
|
||||||
|
* reverts to [CalendarReminderOverride.Inherit] on a per-calendar picker (so an
|
||||||
|
* accidental toggle-undo can't silently wipe the calendar's default) and to
|
||||||
|
* explicit [CalendarReminderOverride.None] on the global default.
|
||||||
|
*/
|
||||||
|
class ReminderOverrideForMinutesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty selection on a per-calendar picker reverts to inherit`() {
|
||||||
|
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = true))
|
||||||
|
.isEqualTo(CalendarReminderOverride.Inherit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty selection on the global default is explicit none`() {
|
||||||
|
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = false))
|
||||||
|
.isEqualTo(CalendarReminderOverride.None)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a non-empty selection is normalised minutes regardless of scope`() {
|
||||||
|
assertThat(reminderOverrideForMinutes(listOf(30, 10, 10, 60), allowInherit = true))
|
||||||
|
.isEqualTo(CalendarReminderOverride.Minutes(listOf(10, 30, 60)))
|
||||||
|
assertThat(reminderOverrideForMinutes(listOf(30, 10, 10, 60), allowInherit = false))
|
||||||
|
.isEqualTo(CalendarReminderOverride.Minutes(listOf(10, 30, 60)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,4 +91,33 @@ class ViewBackStackTest {
|
|||||||
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
|
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
|
||||||
.containsExactly(CalendarView.Agenda)
|
.containsExactly(CalendarView.Agenda)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `next cycles through the configured views in order, wrapping around`() {
|
||||||
|
val cycle = listOf(CalendarView.Month, CalendarView.Agenda)
|
||||||
|
assertThat(CalendarView.Month.next(cycle)).isEqualTo(CalendarView.Agenda)
|
||||||
|
// Wraps back to the first from the last.
|
||||||
|
assertThat(CalendarView.Agenda.next(cycle)).isEqualTo(CalendarView.Month)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `next from a view outside the cycle lands on the first enabled view`() {
|
||||||
|
// Day was disabled in the quick-switch cycle but is still the current view.
|
||||||
|
val cycle = listOf(CalendarView.Week, CalendarView.Month)
|
||||||
|
assertThat(CalendarView.Day.next(cycle)).isEqualTo(CalendarView.Week)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `next on an empty cycle stays put`() {
|
||||||
|
assertThat(CalendarView.Week.next(emptyList())).isEqualTo(CalendarView.Week)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `quick-switch cycle keeps enabled views in their configured order`() {
|
||||||
|
val config = QuickSwitchConfig(
|
||||||
|
order = listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Month, CalendarView.Week),
|
||||||
|
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
|
||||||
|
)
|
||||||
|
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.theme
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class FontsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromToken round-trips every bundled font`() {
|
||||||
|
BundledFont.entries.forEach { font ->
|
||||||
|
assertThat(BundledFont.fromToken(font.token)).isEqualTo(font)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fromToken is null for system, custom, and unknown tokens`() {
|
||||||
|
assertThat(BundledFont.fromToken(FONT_SYSTEM_TOKEN)).isNull()
|
||||||
|
assertThat(BundledFont.fromToken(FONT_CUSTOM_TOKEN)).isNull()
|
||||||
|
assertThat(BundledFont.fromToken("not-a-font")).isNull()
|
||||||
|
assertThat(BundledFont.fromToken(null)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bumped custom-font stamp breaks font-settings equality`() {
|
||||||
|
// Re-importing keeps the same "custom" token, so only the stamp can make
|
||||||
|
// the settings re-emit and invalidate remember() keys downstream.
|
||||||
|
val before = AppFontSettings(brand = FONT_CUSTOM_TOKEN, brandStamp = 1)
|
||||||
|
assertThat(before.copy(brandStamp = 2)).isNotEqualTo(before)
|
||||||
|
assertThat(before.copy(plainStamp = 1)).isNotEqualTo(before)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `system for both roles returns the untouched default typography`() {
|
||||||
|
assertThat(calendulaTypography(brand = null, plain = null))
|
||||||
|
.isSameInstanceAs(CalendulaTypography)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `brand drives display and headline, plain drives title, body, and label`() {
|
||||||
|
val brand = BundledFont.Lora.family
|
||||||
|
val plain = BundledFont.JetBrainsMono.family
|
||||||
|
val typography = calendulaTypography(brand = brand, plain = plain)
|
||||||
|
|
||||||
|
assertThat(typography.displayLarge.fontFamily).isEqualTo(brand)
|
||||||
|
assertThat(typography.headlineSmall.fontFamily).isEqualTo(brand)
|
||||||
|
assertThat(typography.titleLarge.fontFamily).isEqualTo(plain)
|
||||||
|
assertThat(typography.bodyMedium.fontFamily).isEqualTo(plain)
|
||||||
|
assertThat(typography.labelSmall.fontFamily).isEqualTo(plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a null role keeps that role's default family while the other is applied`() {
|
||||||
|
val plain = BundledFont.Lora.family
|
||||||
|
val typography = calendulaTypography(brand = null, plain = plain)
|
||||||
|
|
||||||
|
// Brand styles keep the Material default; plain styles switch.
|
||||||
|
assertThat(typography.displayLarge.fontFamily)
|
||||||
|
.isEqualTo(CalendulaTypography.displayLarge.fontFamily)
|
||||||
|
assertThat(typography.bodyLarge.fontFamily).isEqualTo(plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
248
docs/design/contact-special-dates.md
Normal file
248
docs/design/contact-special-dates.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# Design: Contact special-dates calendars (+ per-calendar multiple reminders)
|
||||||
|
|
||||||
|
Status: **implemented** on `feat/contact-special-dates` (#14 prerequisite merged in)
|
||||||
|
Date: 2026-06-30 (implemented 2026-07-01)
|
||||||
|
Tracking: Codeberg #15 (feature), Codeberg #14 (prerequisite)
|
||||||
|
|
||||||
|
Implementation notes / deviations from this design:
|
||||||
|
|
||||||
|
- **Event identity key is a deterministic `Events.UID_2445`**
|
||||||
|
(`contact-<type>:<lookupKey>@calendula`), not `SYNC_DATA1`. On a LOCAL calendar
|
||||||
|
the provider drops `_sync` columns unless written through a sync-adapter URI;
|
||||||
|
`UID_2445` is written/read through the normal event URIs the app already uses
|
||||||
|
(and doubles as a stable iCal UID). The managed **calendar** still carries a
|
||||||
|
`CAL_SYNC2` marker for re-adoption (calendar-row writes already use the
|
||||||
|
sync-adapter URI).
|
||||||
|
- **Year, not age.** A `FREQ=YEARLY` row has one static title, so a per-occurrence
|
||||||
|
age is impossible without per-year exception rows (rejected as too heavy). The
|
||||||
|
template instead offers `{year}` — the birth year (or an anniversary's start
|
||||||
|
year) — which is static and correct on every occurrence. A "Show year" toggle
|
||||||
|
gates it; it's in the default templates ("{name}'s birthday ({year})").
|
||||||
|
- New managed calendars seed a per-calendar all-day reminder default of
|
||||||
|
on-the-day + one week before, so birthdays get lead time out of the box.
|
||||||
|
- Title template is user-editable per type from day one (`{name}`, `{year}`).
|
||||||
|
|
||||||
|
This document captures the full design for surfacing contact birthdays —
|
||||||
|
and, by extension, anniversaries and custom dates — as auto-updating local
|
||||||
|
calendars in Calendula. It also specifies #14 (per-calendar *multiple*
|
||||||
|
default reminders), which is a hard prerequisite and ships first.
|
||||||
|
|
||||||
|
The two pieces are deliberately split across **two branches / two
|
||||||
|
shipments**:
|
||||||
|
|
||||||
|
- `feat/per-calendar-multi-reminders` — #14, ships first.
|
||||||
|
- `feat/contact-special-dates` — #15, builds on #14.
|
||||||
|
|
||||||
|
They *may* release together depending on timing, but #14 is independently
|
||||||
|
useful and is the foundation the special-dates calendars lean on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this is worth doing carefully
|
||||||
|
|
||||||
|
Calendula's identity is privacy: **no INTERNET permission**, and historically
|
||||||
|
**no `READ_CONTACTS`** — guest-picking uses a one-shot `ACTION_PICK` so the app
|
||||||
|
never holds the contacts permission (`EventEditScreen.kt:446-460`).
|
||||||
|
|
||||||
|
This feature **declares `READ_CONTACTS`** for the first time. That is a
|
||||||
|
deliberate, owner-approved shift, mitigated by:
|
||||||
|
|
||||||
|
- The permission is **opt-in and feature-gated** — requested only when the user
|
||||||
|
enables the feature, never at startup.
|
||||||
|
- Everything stays **offline**. Contacts are read locally and mirrored into a
|
||||||
|
local calendar; nothing leaves the device.
|
||||||
|
- The mirror is **one-way** (contacts → calendar). Calendula never writes to
|
||||||
|
Contacts.
|
||||||
|
|
||||||
|
Store listing / About copy will need to explain the optional permission.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisite — #14: per-calendar *multiple* default reminders
|
||||||
|
|
||||||
|
### Today
|
||||||
|
|
||||||
|
- Events already support **multiple reminders** — `EventForm.reminders:
|
||||||
|
List<Int>` (`EventForm.kt:24`), each written as a separate
|
||||||
|
`CalendarContract.Reminders` row (`CalendarDataSource.kt:620-630`), each
|
||||||
|
firing independently. The runtime/notification path is done.
|
||||||
|
- The **defaults** layer is single-valued, though:
|
||||||
|
- Global: `defaultReminderMinutes` / `defaultAllDayReminderMinutes` — single
|
||||||
|
`Int` (`SettingsPrefs.kt:287,302`).
|
||||||
|
- Per-calendar override: `perCalendarReminderOverride: Map<Long, Int?>` and
|
||||||
|
the all-day variant (`SettingsPrefs.kt:350-381`), resolved by
|
||||||
|
`resolveDefaultReminder()` (`SettingsPrefs.kt:509-524`).
|
||||||
|
- UI: expandable per-calendar override section
|
||||||
|
(`SettingsScreen.kt:879-953`); `ReminderDefaultPicker` (`:956+`).
|
||||||
|
|
||||||
|
### The change
|
||||||
|
|
||||||
|
Widen the **defaults**, not the runtime, from one reminder to a list:
|
||||||
|
|
||||||
|
1. Resolution model `Int` → `List<Int>` in global defaults + per-calendar
|
||||||
|
override + `resolveDefaultReminder()`. The DataStore string format already
|
||||||
|
parses `id=value;id=value` (`SettingsPrefs.kt:576-577`); `value` becomes a
|
||||||
|
comma-separated list. Keep the `Inherit / None / Minutes` override states
|
||||||
|
(`CalendarReminderOverride`, `SettingsPrefs.kt:493-500`) — `Minutes` carries
|
||||||
|
a list.
|
||||||
|
2. `ReminderDefaultPicker` → multi-select. Mirror the event-form multi-reminder
|
||||||
|
UI rather than inventing new interaction.
|
||||||
|
3. New events seed `EventForm.reminders` from the **resolved list** instead of
|
||||||
|
a single value.
|
||||||
|
|
||||||
|
No new permissions, no provider changes, no scheduling changes. Type widening
|
||||||
|
plus one picker upgrade.
|
||||||
|
|
||||||
|
### Why it gates #15
|
||||||
|
|
||||||
|
Each special-dates calendar is a normal local calendar, so it inherits the
|
||||||
|
**per-calendar reminder default** for free. A birthdays calendar is useless
|
||||||
|
without lead time (the reporter literally asked for "a week before *and* on the
|
||||||
|
day") — that requires *multiple* defaults, i.e. #14.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #15: Contact special-dates calendars
|
||||||
|
|
||||||
|
### Core model: one-way mirror, one local calendar per type
|
||||||
|
|
||||||
|
Contacts → **separate local calendars per type**:
|
||||||
|
|
||||||
|
- "Birthdays"
|
||||||
|
- "Anniversaries"
|
||||||
|
- "Custom dates" (everything else / `TYPE_OTHER` / `TYPE_CUSTOM`)
|
||||||
|
|
||||||
|
Separate-per-type is the key architectural decision: each is a normal local
|
||||||
|
calendar (`createLocalCalendar()`, `CalendarDataSource.kt:238-260`), so the
|
||||||
|
**existing per-calendar infra applies for free**:
|
||||||
|
|
||||||
|
- **Color** → existing per-calendar color.
|
||||||
|
- **Show/hide** → existing calendar visibility.
|
||||||
|
- **Default reminders** → per-calendar override from #14.
|
||||||
|
|
||||||
|
This shrinks the new surface dramatically: anniversaries and custom dates are
|
||||||
|
near copy-paste of birthdays (same engine, different `ContactsContract` data
|
||||||
|
kind, one extra local calendar each), and the settings sub-page does **not**
|
||||||
|
need to reinvent color/visibility/reminders.
|
||||||
|
|
||||||
|
### Events: field-level managed, not read-only
|
||||||
|
|
||||||
|
Calendula manages a few fields; the user owns the rest.
|
||||||
|
|
||||||
|
- **Managed** (overwritten on every sync, disabled in the editor for these
|
||||||
|
events): title, start date, recurrence (`FREQ=YEARLY`), existence.
|
||||||
|
- **User-owned** (seeded once on insert, never touched again by sync):
|
||||||
|
location, reminders, notes, busy/free.
|
||||||
|
|
||||||
|
The rule that makes this safe: sync performs a **targeted column update**
|
||||||
|
(managed columns only), **never delete-and-reinsert**. Reminders live in
|
||||||
|
separate rows, so once seeded they are simply never re-touched. A user can move
|
||||||
|
a birthday reminder to "2 weeks before" or add a location and neither is
|
||||||
|
clobbered.
|
||||||
|
|
||||||
|
In the editor, when an event belongs to a managed calendar: disable the
|
||||||
|
title/date/recurrence fields; allow the rest.
|
||||||
|
|
||||||
|
### Reconciliation identity
|
||||||
|
|
||||||
|
Stamp each event with the contact's `LOOKUP_KEY` in `Events.SYNC_DATA1` (ours,
|
||||||
|
since it's a local calendar). Sync becomes an idempotent diff keyed on it:
|
||||||
|
|
||||||
|
- contact added → insert
|
||||||
|
- birthday/date changed → targeted update of managed columns
|
||||||
|
- contact or date removed → delete the event
|
||||||
|
|
||||||
|
Getting this wrong = duplicate birthdays on every sync, so the identity key is
|
||||||
|
non-negotiable.
|
||||||
|
|
||||||
|
### Data source
|
||||||
|
|
||||||
|
`ContactsContract.Data` rows of `Event.CONTENT_ITEM_TYPE`, split by
|
||||||
|
`Event.TYPE`:
|
||||||
|
|
||||||
|
- `TYPE_BIRTHDAY` → Birthdays calendar
|
||||||
|
- `TYPE_ANNIVERSARY` → Anniversaries calendar
|
||||||
|
- `TYPE_OTHER` / `TYPE_CUSTOM` (+ label) → Custom dates calendar
|
||||||
|
|
||||||
|
### Dates: year-less + age
|
||||||
|
|
||||||
|
Many contacts store `--MM-DD` (no year). Handle both:
|
||||||
|
|
||||||
|
- **Year present:** anchor `DTSTART` at that year; `FREQ=YEARLY`. We can show
|
||||||
|
age ("Jane turns 30") when the show-age option is on.
|
||||||
|
- **Year absent:** `FREQ=YEARLY` from an arbitrary anchor year; no age shown.
|
||||||
|
|
||||||
|
Recurrence support is already there: `FREQ=YEARLY` + `UNTIL`/`COUNT`/`INTERVAL`
|
||||||
|
(`Recurrence.kt:32-174`).
|
||||||
|
|
||||||
|
### Auto-update cadence
|
||||||
|
|
||||||
|
Birthdays change rarely, so nothing live/expensive:
|
||||||
|
|
||||||
|
- Sync **on enable**, **on app foreground**, and a **daily WorkManager** job
|
||||||
|
(catches new/edited/deleted contacts even if the app isn't opened).
|
||||||
|
- **No `ContentObserver`** — needs the process alive and buys almost nothing
|
||||||
|
for once-a-year events.
|
||||||
|
|
||||||
|
### Permission + lifecycle
|
||||||
|
|
||||||
|
- **Enable:** request `READ_CONTACTS` (feature-gated, contextual — mirror the
|
||||||
|
contextual `WRITE_CALENDAR` pattern at `EventEditScreen.kt:216-231`). On
|
||||||
|
grant, create the enabled calendars and run the first sync.
|
||||||
|
- **Disable:** delete the managed calendars + their events (with a confirm).
|
||||||
|
- **Permission revoked in system settings:** detect on next sync; surface that
|
||||||
|
the feature is stalled rather than failing silently.
|
||||||
|
|
||||||
|
### Configurable settings sub-page
|
||||||
|
|
||||||
|
Enabling the feature reveals a **dedicated sub-page** (not inline rows). Because
|
||||||
|
color/visibility/reminders already live in normal calendar settings, the
|
||||||
|
sub-page only carries the genuinely new knobs:
|
||||||
|
|
||||||
|
- Master enable toggle.
|
||||||
|
- Per-type toggles: Birthdays / Anniversaries / Custom dates.
|
||||||
|
- Title/display template, e.g. `"{name}'s birthday"` (new translatable string).
|
||||||
|
- Show-age toggle (only meaningful when the year is known).
|
||||||
|
- (Possibly) sync-cadence / "sync now".
|
||||||
|
- Pointer to the normal calendar settings for color, visibility, reminders.
|
||||||
|
|
||||||
|
The owner wants this broadly configurable — keep the sub-page the home for
|
||||||
|
display/behaviour options, and lean on existing per-calendar settings for the
|
||||||
|
rest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions / deferred
|
||||||
|
|
||||||
|
- Title template: free-form vs a small set of presets. Start with one good
|
||||||
|
localized default; revisit configurability.
|
||||||
|
- Whether "Custom dates" is one bucket or split further by label. Start: one
|
||||||
|
bucket.
|
||||||
|
- Contact scope: all contacts vs per-account/group filtering. Start: all;
|
||||||
|
filtering is a later refinement.
|
||||||
|
|
||||||
|
## Task outline
|
||||||
|
|
||||||
|
### #14 — per-calendar multiple default reminders (`feat/per-calendar-multi-reminders`)
|
||||||
|
- [x] Widen global + per-calendar reminder defaults `Int` → `List<Int>`
|
||||||
|
(`SettingsPrefs.kt`), keep `Inherit/None/Minutes(list)`.
|
||||||
|
- [x] `resolveDefaultReminder()` returns a list.
|
||||||
|
- [x] `ReminderDefaultPicker` → multi-select (mirror event-form UI).
|
||||||
|
- [x] Seed new events' `EventForm.reminders` from the resolved list.
|
||||||
|
- [x] Migration / parse compatibility for existing single-value stored prefs.
|
||||||
|
- [x] Tests: resolution, parse round-trip, all-day variant.
|
||||||
|
|
||||||
|
### #15 — contact special-dates (`feat/contact-special-dates`)
|
||||||
|
- [x] Declare `READ_CONTACTS`; feature-gated contextual request on enable.
|
||||||
|
- [x] Per-type local calendars (create on enable, delete on disable+confirm).
|
||||||
|
- [x] Contacts reader: `Event` rows by `TYPE`, year-less handling.
|
||||||
|
- [x] Sync engine: idempotent diff keyed on the deterministic `UID_2445`
|
||||||
|
(see notes; not `SYNC_DATA1`); targeted managed-column updates; seed
|
||||||
|
user-owned fields on insert only.
|
||||||
|
- [x] `FREQ=YEARLY` event generation + `{year}` in titles (static, always correct).
|
||||||
|
- [x] Auto-update: on enable, on foreground, daily WorkManager.
|
||||||
|
- [x] Editor: disable managed fields for managed-calendar events.
|
||||||
|
- [x] Revoked-permission detection + stalled-state surface.
|
||||||
|
- [x] Settings sub-page: enable, per-type toggles, title template, show-age.
|
||||||
|
- [x] Translatable strings (titles, sub-page).
|
||||||
|
- [x] About / store copy for the optional `READ_CONTACTS` permission.
|
||||||
@@ -10,6 +10,12 @@ Serie) und einem einfachen Wiederholungs-Picker. Erinnerungen stellt
|
|||||||
Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den
|
Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den
|
||||||
Termin.
|
Termin.
|
||||||
|
|
||||||
|
Optional lassen sich Geburtstage, Jahrestage und weitere besondere Tage
|
||||||
|
deiner Kontakte im Kalender anzeigen. Du schaltest das selbst ein; Kontakte
|
||||||
|
werden nur auf deinem Gerät gelesen, nie hochgeladen und nie verändert —
|
||||||
|
Calendula spiegelt die Daten lediglich in lokale Kalender, die du voll
|
||||||
|
kontrollierst.
|
||||||
|
|
||||||
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
|
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
|
||||||
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
|
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.
|
||||||
|
|
||||||
|
|||||||
70
fastlane/metadata/android/en-US/changelogs/21300.txt
Normal file
70
fastlane/metadata/android/en-US/changelogs/21300.txt
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
### Added
|
||||||
|
- Calendula now speaks Spanish and Italian. Both arrived as community
|
||||||
|
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
|
||||||
|
— a **huge thank you** to
|
||||||
|
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
|
||||||
|
translation and to
|
||||||
|
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
|
||||||
|
one! Pick your language under Settings → Language or in Android's per-app
|
||||||
|
language settings. Strings added in this very release may still show in
|
||||||
|
English until the translations catch up. Want Calendula in your language?
|
||||||
|
Translating happens entirely in the browser — every contribution is welcome.
|
||||||
|
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
||||||
|
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
||||||
|
birthdays, anniversaries and other dates into local "Birthdays",
|
||||||
|
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
|
||||||
|
change. Each is a normal local calendar, so you set its colour, visibility and
|
||||||
|
reminders the usual way — new birthdays even start with a reminder a week before
|
||||||
|
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
|
||||||
|
This is the first feature to use the contacts permission: it is requested only
|
||||||
|
when you turn the feature on, everything stays on your device (Calendula has no
|
||||||
|
internet access), and your contacts are only ever read, never changed. Thanks
|
||||||
|
to @moonj for the suggestion ([#15]).
|
||||||
|
- Set more than one default reminder per calendar. A calendar's default
|
||||||
|
reminders — and the global defaults under Settings → Notifications — can now
|
||||||
|
hold several lead times instead of just one, so new events can start with, say,
|
||||||
|
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||||
|
multi-select; per-calendar overrides can still inherit the global default or
|
||||||
|
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||||
|
- Widget headers now open the app. Tapping the month/year title on the month
|
||||||
|
widget opens the app on the month view, and tapping the "Upcoming" title on
|
||||||
|
the agenda widget opens it on your default view — so there's a one-tap way
|
||||||
|
back into the app that lands where you'd expect, instead of only through a day
|
||||||
|
or event. On the month widget, tapping anywhere on a day — not just the small
|
||||||
|
date number — now opens that day, and the "today" button snaps the grid back
|
||||||
|
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
||||||
|
suggestions ([#18], [#20]).
|
||||||
|
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
|
||||||
|
and **Body font** pickers: keep the system default, choose a bundled face
|
||||||
|
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
|
||||||
|
face), or load any `.ttf`/`.otf` file from your device, independently per
|
||||||
|
role. Font size and colour stay with Android's accessibility scaling and the
|
||||||
|
app theme, as discussed on the issue. Thanks to @abrossimow for the
|
||||||
|
suggestion ([#19]).
|
||||||
|
- Choose what the view-switch button cycles through. Settings → Appearance now
|
||||||
|
lets you pick which views the top-right quick-switch button rotates between
|
||||||
|
and drag them into your preferred order; views you switch off stay reachable
|
||||||
|
from the navigation menu, which can be reordered the same way. Thanks to
|
||||||
|
@abrossimow for the suggestion ([#24]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Month widget arrows and "today" button work again. On release builds the
|
||||||
|
prev/next-month arrows and the jump-to-today control on the month widget did
|
||||||
|
nothing when tapped — code shrinking had stripped the tap handlers behind
|
||||||
|
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
|
||||||
|
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
||||||
|
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||||
|
popping up — matching how a disabled calendar's events already stay hidden
|
||||||
|
everywhere else in the app ([#17]).
|
||||||
|
- Editing a single occurrence of a recurring event works again. Choosing **Only
|
||||||
|
this event** and saving a change to one event in a repeating series silently
|
||||||
|
did nothing — the change was rejected and the edit form simply reappeared with
|
||||||
|
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||||
|
lands on just that one event and leaves the rest of the series untouched
|
||||||
|
([#16]).
|
||||||
|
- Every calendar can be picked when creating an event. The event editor's
|
||||||
|
calendar picker was a fixed-height dialog, so with many calendars anything
|
||||||
|
past the first nine or so was simply unreachable. It is now a full-screen,
|
||||||
|
scrollable list grouped by account, with each calendar's colour shown.
|
||||||
|
Thanks to @dschuermann for the report ([#29]).
|
||||||
|
|
||||||
@@ -8,6 +8,11 @@ changes (only this event / this and all following / the whole series) and a
|
|||||||
simple repeat picker. Calendula also delivers your event reminders as
|
simple repeat picker. Calendula also delivers your event reminders as
|
||||||
notifications — tap one and you're on the event.
|
notifications — tap one and you're on the event.
|
||||||
|
|
||||||
|
Optionally, show your contacts' birthdays, anniversaries and other special
|
||||||
|
dates in your calendar. You turn this on yourself; contacts are read only on
|
||||||
|
your device, are never uploaded, and are never changed — Calendula only
|
||||||
|
mirrors the dates into local calendars you fully control.
|
||||||
|
|
||||||
The differentiator is the design: real Material 3 Expressive throughout,
|
The differentiator is the design: real Material 3 Expressive throughout,
|
||||||
with dynamic color, expressive motion, and expressive shapes.
|
with dynamic color, expressive motion, and expressive shapes.
|
||||||
|
|
||||||
|
|||||||
92
licenses/fonts/AtkinsonHyperlegible-OFL.txt
Normal file
92
licenses/fonts/AtkinsonHyperlegible-OFL.txt
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright 2020 Braille Institute of America, Inc.
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
licenses/fonts/JetBrainsMono-OFL.txt
Normal file
93
licenses/fonts/JetBrainsMono-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
licenses/fonts/Lora-OFL.txt
Normal file
93
licenses/fonts/Lora-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
17
licenses/fonts/README.md
Normal file
17
licenses/fonts/README.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Bundled fonts
|
||||||
|
|
||||||
|
Calendula bundles the following fonts as optional user-selectable typefaces
|
||||||
|
(Settings → Appearance → Fonts). Each is licensed under the SIL Open Font
|
||||||
|
License 1.1; the full license text is included alongside this file.
|
||||||
|
|
||||||
|
| Font | Files (`app/src/main/res/font/`) | Copyright | License |
|
||||||
|
|------|----------------------------------|-----------|---------|
|
||||||
|
| Atkinson Hyperlegible | `atkinson_hyperlegible_regular.ttf`, `atkinson_hyperlegible_bold.ttf` | © 2020 Braille Institute of America, Inc. | [OFL](AtkinsonHyperlegible-OFL.txt) |
|
||||||
|
| Lora (variable) | `lora.ttf` | © 2011 Cyreal | [OFL](Lora-OFL.txt) |
|
||||||
|
| JetBrains Mono | `jetbrains_mono_regular.ttf`, `jetbrains_mono_bold.ttf` | © 2020 The JetBrains Mono Project Authors | [OFL](JetBrainsMono-OFL.txt) |
|
||||||
|
|
||||||
|
The device's default typeface (Roboto on most Android builds) remains the
|
||||||
|
default and is not bundled.
|
||||||
|
|
||||||
|
Fonts a user loads from their own device via "Choose file…" are copied into the
|
||||||
|
app's private storage and are not redistributed.
|
||||||
Reference in New Issue
Block a user