Compare commits
17 Commits
renovate/k
...
ca2069da0d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca2069da0d | ||
|
|
02cf32b537 | ||
|
|
e44d70e18b | ||
|
|
401e067945 | ||
|
|
a0827202b3 | ||
|
|
36bbb3ff2e | ||
|
|
1d07b64a28 | ||
| d037492cf6 | |||
| 8e2109d073 | |||
| bf6415c023 | |||
| 7aef01d95e | |||
| ce4d6bc4d1 | |||
| 4ad805e747 | |||
| bb6e3ad336 | |||
| edbeadfa30 | |||
| ef48717e2c | |||
| 41593a0e9d |
104
CHANGELOG.md
104
CHANGELOG.md
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Settings → Calendars now says what is different about a calendar instead of
|
||||
leaving you to guess. Ones you can only view — a subscribed calendar, a
|
||||
calendar shared with you read-only — are marked **Read-only** ([#76]).
|
||||
- Calendars your device isn't syncing are marked **Not synced**, moved to the
|
||||
bottom of their account and left without a switch. None of their events are on
|
||||
the device, so the switch they used to have could not have shown you anything
|
||||
— the calendar simply looked broken. They are no longer offered when you pick
|
||||
a calendar for a new or an imported event either: an event saved there would
|
||||
never reach the account. Whether an account syncs a calendar stays that
|
||||
account's own app's decision ([#78]).
|
||||
- The birthday and anniversary calendars Calendula fills from your contacts are
|
||||
marked **Filled from your contacts**, which is why they can't be picked for a
|
||||
new event: anything you put there would be removed again on the next sync.
|
||||
Deleting one is held back while special dates are switched on — Calendula
|
||||
would simply create it again — and the calendar's editor says so; turn the
|
||||
feature off under Settings → Special dates and the delete works as usual
|
||||
([#76]).
|
||||
- The calendar picker in the event form and in the .ics import screen now ends
|
||||
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
|
||||
those marks then explain why a calendar isn't offered ([#76]).
|
||||
- The agenda widget's text size is yours to set. **Settings → Agenda → Agenda
|
||||
widget size** offers Small, Medium, Large and Extra large, replacing the guess
|
||||
the widget used to make from its own measurements. Small is what it looks like
|
||||
today, so nothing changes until you turn it up ([#51]).
|
||||
|
||||
### Changed
|
||||
- Calendula's source code now lives on **Codeberg**, where its issues already
|
||||
were. The **Source code** and **License** links in Settings → About point
|
||||
@@ -14,6 +40,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
sites. Nothing about the app itself changes, and the F-Droid repository is
|
||||
unaffected.
|
||||
|
||||
### Fixed
|
||||
- A month-grid widget stays a month grid, and draws all seven days again. Since
|
||||
2.16.0 a placed month widget could redraw itself as the agenda widget a little
|
||||
after any change to your events, and could draw only about four day columns
|
||||
with the last one cut off part-way through. Both came from the release build
|
||||
merging the two widgets into a single class, so Android could no longer tell
|
||||
which of them a widget on your home screen was — and the month grid was handed
|
||||
the wrong widget's measurements to lay its columns out against ([#89], [#103]).
|
||||
- The back gesture on **Settings → Views** returns to Settings instead of
|
||||
leaving Settings altogether and dropping you on the calendar. Special dates
|
||||
did the same ([#81]).
|
||||
- The dots standing in for the events that didn't fit a day in the month view
|
||||
now dim with everything else when **Dim completed events** is on. A past day
|
||||
with four or more events kept its last events at full strength while the rest
|
||||
faded ([#79]).
|
||||
- Two accounts that happen to share a name — a Google account and a DAVx5
|
||||
account for the same address, say — are no longer merged into one group.
|
||||
They were listed together in Settings → Calendars and in the drawer's filter,
|
||||
which also meant the group's source icon and its "manage in app" button could
|
||||
send you to the wrong app, "toggle all" spanned both accounts at once, and
|
||||
collapsing one collapsed the other. Where a name really is shared, each group
|
||||
now names the app it comes from ([#77]).
|
||||
- In the month view's **Split** style, the new-event button now starts on the
|
||||
day you have selected. It always started on today, whichever day was selected
|
||||
and listed below the grid ([#87]).
|
||||
- Search results now show an all-day event's real date. West of UTC — anywhere in
|
||||
the Americas, say — a search hit was dated one day early, disagreeing with the
|
||||
day the month, week and agenda views file the same event under ([#82]).
|
||||
- Reminders no longer depend on Android telling Calendula when they are due.
|
||||
Calendula now works out each reminder's time itself and sets its own alarm for
|
||||
it. On some phones — Samsung's among them — the system's calendar storage never
|
||||
sends the signal a calendar app is meant to wake up on, and no amount of
|
||||
battery or notification settings helps: the reminder is simply never announced.
|
||||
None of that is visible from inside an app that waits to be told, which is why
|
||||
it took a second pass to find ([#75]).
|
||||
|
||||
Reminders also survive things that used to lose them quietly. After a restart
|
||||
or an app update Calendula re-arms its alarms, and a reminder whose moment
|
||||
passed while the phone was off still arrives, as long as the event has not
|
||||
ended yet.
|
||||
|
||||
- All-day reminders now arrive at the time you chose in **Settings →
|
||||
Notifications**, on every occurrence. A yearly birthday could drift an hour
|
||||
either way depending on daylight saving, and all-day reminders on calendars
|
||||
from an account fired in the middle of the night instead of in the morning
|
||||
([#75]).
|
||||
|
||||
- Reminders now arrive for every calendar you have switched on. A calendar that
|
||||
was hidden at system level — switched off in another calendar app, or never
|
||||
switched on after being added — still showed its events and listed their
|
||||
reminders in Calendula, but never notified: Android only schedules reminder
|
||||
alarms for calendars marked visible, and Calendula kept its own separate
|
||||
on/off list that had no say in it. There is now one switch: **Settings →
|
||||
Calendars** turns a calendar on or off for the whole device, so what you see
|
||||
and what reminds you can no longer disagree ([#75]).
|
||||
|
||||
Calendars you had switched off in Calendula are switched off here too on first
|
||||
launch. Calendars that were already off — hidden in another calendar app, or
|
||||
never switched on after being added — stay off, and Calendula says so once
|
||||
rather than quietly switching them on for every app on your device; you can
|
||||
turn any of them back on in Settings → Calendars.
|
||||
|
||||
If you gave Calendula read-only access to your calendars, the switch still
|
||||
works: your choice is kept in the app until it can be written.
|
||||
|
||||
The drawer's filter is unchanged and still app-only: hiding a calendar there
|
||||
tidies your view without silencing its reminders.
|
||||
|
||||
## [2.16.0] — 2026-07-24
|
||||
|
||||
### Added
|
||||
@@ -1119,3 +1213,13 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#42]: https://codeberg.org/jlmakiola/calendula/issues/42
|
||||
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
|
||||
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
|
||||
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
|
||||
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76
|
||||
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78
|
||||
[#77]: https://codeberg.org/jlmakiola/calendula/issues/77
|
||||
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
|
||||
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
|
||||
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
||||
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
||||
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
||||
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
||||
|
||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -38,3 +38,15 @@
|
||||
# SessionWorker never ran, and widgets were stuck on their loading layout
|
||||
# (a blank spinner) in release builds. Keep every InputMerger's name + ctor.
|
||||
-keep class * extends androidx.work.InputMerger { <init>(...); }
|
||||
|
||||
# Glance identifies a widget by its GlanceAppWidget subclass's *canonical name*:
|
||||
# GlanceAppWidgetManager persists a providerName -> receivers map under that
|
||||
# string, and `updateAll` looks the widget's app-widget ids up through it. Under
|
||||
# R8 full mode (AGP 9 default) MonthWidget and AgendaWidget — same supertype,
|
||||
# same overrides, no distinguishing members — were horizontally merged into one
|
||||
# class, so both receivers registered under the *same* provider name and
|
||||
# `AgendaWidget().updateAll()` resolved the month widget's id too, redrawing a
|
||||
# placed month widget as the agenda one on the next data change (#89). Keeping
|
||||
# the real names also survives app updates, which would otherwise renumber the
|
||||
# obfuscated name and orphan the stored mapping.
|
||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
||||
|
||||
@@ -33,6 +33,14 @@
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
|
||||
<!--
|
||||
A reboot clears every pending alarm, including the one holding the next
|
||||
reminder. Now that the app schedules that alarm itself (#75) rather than
|
||||
leaning on the provider's, it has to hear about the reboot to re-arm it —
|
||||
otherwise reminders simply stop after a restart.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||
returns null and the calendar manager's per-account "manage" button can't
|
||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||
@@ -270,17 +278,22 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||
no notification itself — a calendar app must (v1.4, Etar model).
|
||||
Exported: the broadcast arrives from the provider's process. -->
|
||||
<!-- Reminder delivery is the app's own (#75): it plans the alarms from
|
||||
Instances + Reminders instead of waiting for the provider's
|
||||
EVENT_REMINDER broadcast, which OEM-modified providers demonstrably
|
||||
retarget or never send. This receiver takes our scan alarm plus
|
||||
every outside event that invalidates it — boot and package-replace
|
||||
wipe pending alarms, and a clock or timezone change moves every
|
||||
reminder relative to the one that is armed.
|
||||
Exported: the system broadcasts arrive from outside the app. -->
|
||||
<receiver
|
||||
android:name=".data.reminders.EventReminderReceiver"
|
||||
android:name=".data.reminders.ReminderScheduleReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.EVENT_REMINDER" />
|
||||
<data
|
||||
android:host="com.android.calendar"
|
||||
android:scheme="content" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import android.app.Application
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||
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.reminders.ReminderMaintenanceScheduler
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -39,6 +42,43 @@ class CalendulaApp : Application() {
|
||||
)
|
||||
reconcileAutoBackup()
|
||||
reconcileSpecialDates()
|
||||
reconcileCalendarVisibility()
|
||||
startReminderDelivery()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring reminder delivery up with the process (#75). The app plans and arms
|
||||
* its own reminder alarms now, so launch is one of the moments that has to
|
||||
* re-check them: a scan re-arms whatever the system dropped, posts anything
|
||||
* a missed alarm still owes, and starts watching the provider so an edit
|
||||
* re-plans without waiting for the next pass. The daily worker is the
|
||||
* backstop for a device that drops the alarm with no reboot to announce it.
|
||||
*/
|
||||
private fun startReminderDelivery() {
|
||||
val deps = EntryPointAccessors.fromApplication(
|
||||
this, ReminderMaintenanceWorker.Deps::class.java,
|
||||
)
|
||||
val scanner = deps.reminderScanner()
|
||||
scanner.startWatchingProvider()
|
||||
scanner.scanInBackground()
|
||||
ReminderMaintenanceScheduler.apply(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any calendar switch-off the app hasn't been allowed to write into
|
||||
* the system's `Calendars.VISIBLE` yet — including the retired app-local
|
||||
* "disabled calendars" set the upgrade inherits (#75). A no-op on a fresh
|
||||
* install and in the steady state; a launch without the calendar permission
|
||||
* leaves the set pending, and `RootScreen` runs it again once the app comes
|
||||
* up holding it — whichever way it was granted.
|
||||
*/
|
||||
private fun reconcileCalendarVisibility() {
|
||||
val deps = EntryPointAccessors.fromApplication(
|
||||
this, CalendarVisibilityReconciler.Deps::class.java,
|
||||
)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
deps.calendarVisibilityReconciler().run()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import java.time.Instant
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.allDayLeadDays
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Translates an all-day reminder between the **semantic** lead time the UI
|
||||
@@ -73,19 +72,14 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
|
||||
|
||||
/**
|
||||
* 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
|
||||
* returns the right day count regardless of which [timeOfDayMinutes] wrote the
|
||||
* row — including pre-feature rows (raw multiples of 1440, fired at UTC midnight)
|
||||
* and rows written under a different timezone. A negative [rawMinutes] (fire
|
||||
* after DTSTART) folds to day 0.
|
||||
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
||||
* detail screen. Delegates to [allDayLeadDays] so what is displayed is the day
|
||||
* the reminder actually fires on; see there for how an encoded row is told from
|
||||
* a plain one written by another calendar app.
|
||||
*/
|
||||
internal fun fromProviderAllDayMinutes(
|
||||
rawMinutes: Int,
|
||||
startDate: LocalDate,
|
||||
zone: ZoneId,
|
||||
): Int {
|
||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val fireLocalDate = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE)
|
||||
.atZone(zone).toLocalDate()
|
||||
return ChronoUnit.DAYS.between(fireLocalDate, startDate).toInt() * MINUTES_PER_DAY
|
||||
}
|
||||
timeOfDayMinutes: Int,
|
||||
): Int = allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
|
||||
|
||||
@@ -54,7 +54,12 @@ import javax.inject.Singleton
|
||||
interface CalendarDataSource {
|
||||
fun calendars(): List<CalendarSource>
|
||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||
fun eventDetail(eventId: Long): EventDetail?
|
||||
/**
|
||||
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
|
||||
* decode their stored offsets back to whole-day lead times (see
|
||||
* [fromProviderAllDayMinutes]).
|
||||
*/
|
||||
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
||||
|
||||
/**
|
||||
* Master/one-off events whose title, description or location contains
|
||||
@@ -110,6 +115,32 @@ interface CalendarDataSource {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
fun deleteCalendar(id: Long)
|
||||
|
||||
/**
|
||||
* Show or hide the calendar device-wide by writing `Calendars.VISIBLE` — the
|
||||
* app's one visibility model (#75). `VISIBLE` also gates the provider's own
|
||||
* reminder scheduling, so switching a calendar off here is what actually
|
||||
* stops its notifications; switching it on is what brings them back.
|
||||
* Writable by a plain app (one of the three columns the platform documents
|
||||
* as such) and device-local — no sync adapter pushes it anywhere.
|
||||
*/
|
||||
fun setCalendarVisible(id: Long, visible: Boolean)
|
||||
|
||||
/**
|
||||
* Whether one calendar is currently switched on at system level, without
|
||||
* reading every row — for the reminder gate, which sees a calendar id and
|
||||
* nothing else. Null when the answer can't be had: no row (the calendar was
|
||||
* deleted) or no read permission.
|
||||
*/
|
||||
fun isCalendarVisible(id: Long): Boolean?
|
||||
|
||||
/**
|
||||
* Whether the app holds `WRITE_CALENDAR`, i.e. may write
|
||||
* [setCalendarVisible] at all. Read-only users (READ granted, WRITE denied)
|
||||
* keep their calendar switches app-side instead — see
|
||||
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds].
|
||||
*/
|
||||
fun canWriteCalendars(): Boolean
|
||||
|
||||
/**
|
||||
* Create a local calendar tagged as the special-dates mirror for [type]
|
||||
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
||||
@@ -373,6 +404,40 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
||||
}
|
||||
|
||||
/**
|
||||
* Addressed by appended id on the plain (non-sync-adapter) Calendars URI,
|
||||
* one calendar per call. Both parts are load-bearing:
|
||||
* `CalendarProvider2.updateInTransaction` short-circuits to a raw database
|
||||
* update unless the selection is `_id=…`, skipping the dirty marking *and*
|
||||
* the `checkNextAlarm()` reschedule — i.e. an `_id IN (…)` batch would write
|
||||
* the flag but never re-arm the reminder alarms this write exists to
|
||||
* trigger. The sync-adapter URI is avoided so the write also applies to
|
||||
* synced calendars, which is where the bug bites.
|
||||
*/
|
||||
override fun setCalendarVisible(id: Long, visible: Boolean) {
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.VISIBLE, if (visible) 1 else 0)
|
||||
}
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
|
||||
values, null, null,
|
||||
)
|
||||
if (rows == 0) throw WriteFailedException("set calendar visibility id=$id")
|
||||
}
|
||||
|
||||
override fun isCalendarVisible(id: Long): Boolean? {
|
||||
if (!hasCalendarPermission()) return null
|
||||
return resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, id),
|
||||
arrayOf(CalendarContract.Calendars.VISIBLE),
|
||||
null, null, null,
|
||||
)?.use { if (it.moveToFirst()) it.getInt(0) != 0 else null }
|
||||
}
|
||||
|
||||
override fun canWriteCalendars(): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val values = ContentValues().apply {
|
||||
@@ -610,7 +675,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventDetail(eventId: Long): EventDetail? {
|
||||
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? {
|
||||
val attendees = queryAttendees(eventId)
|
||||
val reminders = queryReminders(eventId)
|
||||
return resolver.query(
|
||||
@@ -619,7 +684,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
if (!c.moveToFirst()) null
|
||||
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
|
||||
else CursorColumnReader(c).toEventDetailCore(
|
||||
attendees, reminders, allDayReminderTimeMinutes,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,5 +31,10 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
||||
isManaged = isLocal &&
|
||||
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
||||
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
||||
// A provider that leaves the column NULL is treated as syncing — the
|
||||
// harmless default, since this flag only ever holds the one-shot
|
||||
// visibility migration back from switching a calendar on.
|
||||
syncsEvents = isNull(CalendarProjection.IDX_SYNC_EVENTS) ||
|
||||
getInt(CalendarProjection.IDX_SYNC_EVENTS) != 0,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,20 @@ interface CalendarRepository {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
suspend fun deleteCalendar(id: Long)
|
||||
|
||||
/**
|
||||
* Show or hide [ids] device-wide (`Calendars.VISIBLE`), which is also what
|
||||
* turns the provider's reminder scheduling for them on or off — see
|
||||
* [CalendarDataSource.setCalendarVisible]. Each calendar is written on its
|
||||
* own, in order; a failure part-way leaves the earlier writes standing (the
|
||||
* observer reports whatever actually landed).
|
||||
*
|
||||
* Without `WRITE_CALENDAR` the choice is kept app-side instead (see
|
||||
* [de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs.pendingDisabledCalendarIds]),
|
||||
* where it filters events and reminders just the same until it can be
|
||||
* written.
|
||||
*/
|
||||
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)
|
||||
|
||||
/**
|
||||
* Every event of the writable local calendars, ready to serialise into a
|
||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||
|
||||
@@ -16,11 +16,17 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -47,52 +53,141 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
extraBufferCapacity = 1,
|
||||
)
|
||||
|
||||
/**
|
||||
* Bumped on every provider notification, so one tick's calendar read can be
|
||||
* shared by everything that needs it (see [calendarsSnapshot]).
|
||||
*/
|
||||
private val generation = AtomicLong(0L)
|
||||
|
||||
init {
|
||||
dataSource.registerChangeListener { ticks.tryEmit(Unit) }
|
||||
dataSource.registerChangeListener {
|
||||
generation.incrementAndGet()
|
||||
ticks.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
override fun calendars(): Flow<List<CalendarSource>> =
|
||||
ticks
|
||||
.onStart { emit(Unit) }
|
||||
.reQuery { dataSource.calendars() }
|
||||
.flowOn(io)
|
||||
/**
|
||||
* Re-query signal for everything filtered by visibility: the provider's own
|
||||
* notifications, plus every change to the pending switch-off set (an id
|
||||
* leaves it as its `VISIBLE` write lands, which changes what is shown).
|
||||
* [calendarsSnapshot] keeps the two in step.
|
||||
*/
|
||||
private fun visibilityTicks(): Flow<Unit> = merge(
|
||||
ticks.onStart { emit(Unit) },
|
||||
// The current value is already covered by the tick above; only later
|
||||
// changes re-query (the set is deduped, so an unrelated DataStore write
|
||||
// doesn't).
|
||||
prefs.pendingDisabledCalendarIds.drop(1).map {},
|
||||
)
|
||||
|
||||
// Instances are filtered by the app-side hidden ∪ disabled calendar sets
|
||||
// (M3): an event is dropped whenever the user has hidden *or* disabled its
|
||||
// calendar. Re-runs when the provider ticks *or* either set changes —
|
||||
// toggling a calendar in the filter sheet or the calendar manager updates
|
||||
// every view immediately. [calendars] stays unfiltered so those screens can
|
||||
// list and re-enable hidden/disabled calendars.
|
||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||
combine(
|
||||
ticks
|
||||
.onStart { emit(Unit) }
|
||||
.reQuery {
|
||||
dataSource.instances(
|
||||
beginMillis = range.start.toEpochMillis(),
|
||||
endMillis = range.endInclusive.toEpochMillis(),
|
||||
)
|
||||
},
|
||||
prefs.hiddenCalendarIds,
|
||||
prefs.disabledCalendarIds,
|
||||
) { instances, hidden, disabled ->
|
||||
val excluded = hidden + disabled
|
||||
if (excluded.isEmpty()) instances
|
||||
else instances.filterNot { it.calendarId in excluded }
|
||||
// A switch-off the app hasn't been allowed to write yet is folded into the
|
||||
// flag itself, so every consumer — the Settings switch, the filter sheet,
|
||||
// the form and import pickers, the widgets — reads one visibility and can't
|
||||
// disagree with what the user just tapped. The reconciler reads the data
|
||||
// source directly, because it needs the provider's own answer.
|
||||
override fun calendars(): Flow<List<CalendarSource>> =
|
||||
visibilityTicks().reQuery {
|
||||
val calendars = calendarsSnapshot()
|
||||
val pendingDisabled = prefs.pendingDisabledCalendarIds.first()
|
||||
if (pendingDisabled.isEmpty()) calendars
|
||||
else calendars.map {
|
||||
if (it.id in pendingDisabled) it.copy(isVisibleInSystem = false) else it
|
||||
}
|
||||
}
|
||||
// hidden and disabled both derive from one DataStore, so toggling
|
||||
// either makes both re-emit and combine briefly surfaces the same
|
||||
// list twice — collapse the duplicate so views don't re-render for it.
|
||||
// Collapse re-emissions that carry an identical list (see
|
||||
// [instances]).
|
||||
.distinctUntilChanged()
|
||||
.flowOn(io)
|
||||
|
||||
// Instances are filtered by the system's per-calendar VISIBLE flag ∪ the
|
||||
// switch-offs still waiting to be written to it ∪ the app-side hidden set:
|
||||
// an event is dropped when the user switched its calendar off in Settings →
|
||||
// Calendars (which also stops the provider scheduling its reminders) *or*
|
||||
// hid it in the filter sheet. Re-runs when the provider ticks — writing
|
||||
// VISIBLE notifies, so switching a calendar updates every view — or when
|
||||
// either set changes. [calendars] stays unfiltered so those screens can list
|
||||
// and re-enable invisible calendars.
|
||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||
combine(
|
||||
visibilityTicks().reQuery {
|
||||
// All three reads in one pass, so a list of instances is never
|
||||
// filtered against a visibility snapshot from another tick.
|
||||
QueriedInstances(
|
||||
instances = dataSource.instances(
|
||||
beginMillis = range.start.toEpochMillis(),
|
||||
endMillis = range.endInclusive.toEpochMillis(),
|
||||
),
|
||||
switchedOffCalendarIds = invisibleCalendarIds() +
|
||||
prefs.pendingDisabledCalendarIds.first(),
|
||||
)
|
||||
},
|
||||
prefs.hiddenCalendarIds,
|
||||
) { queried, hidden ->
|
||||
val excluded = hidden + queried.switchedOffCalendarIds
|
||||
if (excluded.isEmpty()) queried.instances
|
||||
else queried.instances.filterNot { it.calendarId in excluded }
|
||||
}
|
||||
// Any DataStore edit re-emits the hidden set even when it is
|
||||
// unchanged (e.g. writing the last-used calendar), which would
|
||||
// re-surface an identical list — collapse those so views don't
|
||||
// re-render for them.
|
||||
.distinctUntilChanged()
|
||||
.flowOn(io)
|
||||
|
||||
/** One instances query plus the visibility it must be filtered against. */
|
||||
private data class QueriedInstances(
|
||||
val instances: List<EventInstance>,
|
||||
val switchedOffCalendarIds: Set<Long>,
|
||||
)
|
||||
|
||||
/** Calendars switched off at system level — hidden, and never reminded about. */
|
||||
private suspend fun invisibleCalendarIds(): Set<Long> = calendarsSnapshot()
|
||||
.filterNot { it.isVisibleInSystem }
|
||||
.mapTo(mutableSetOf()) { it.id }
|
||||
|
||||
private val calendarsLock = Mutex()
|
||||
private var cachedGeneration = -1L
|
||||
private var cachedPending: Set<Long>? = null
|
||||
private var cachedCalendars: List<CalendarSource> = emptyList()
|
||||
|
||||
/**
|
||||
* The calendar list for the current tick, queried once and shared. Every
|
||||
* open view collects [calendars] *and* filters its instances by visibility,
|
||||
* which used to cost one full `Calendars` query each per tick. Reusing a
|
||||
* single read also keeps them consistent: within a tick, what a screen lists
|
||||
* and what its events are filtered against can't come from two snapshots.
|
||||
*
|
||||
* An empty result is never cached — it is what a read without the calendar
|
||||
* permission returns, and the grant itself doesn't notify the provider.
|
||||
*
|
||||
* The pending switch-off set keys the cache alongside the tick. An id leaves
|
||||
* that set the moment its `VISIBLE` write lands, while the observer that
|
||||
* would invalidate the snapshot is only dispatched through the main looper
|
||||
* afterwards — so a snapshot taken while the id was still pending, read
|
||||
* against the set that no longer holds it, would report the calendar as *on*
|
||||
* again and re-admit exactly the events being hidden.
|
||||
*/
|
||||
private suspend fun calendarsSnapshot(): List<CalendarSource> = calendarsLock.withLock {
|
||||
val current = generation.get()
|
||||
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||
if (current != cachedGeneration || pending != cachedPending || cachedCalendars.isEmpty()) {
|
||||
cachedCalendars = dataSource.calendars()
|
||||
cachedGeneration = current
|
||||
cachedPending = pending
|
||||
}
|
||||
cachedCalendars
|
||||
}
|
||||
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
|
||||
?: throw NoSuchEventException(eventId)
|
||||
}
|
||||
|
||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||
if (query.isBlank()) return@withContext emptyList()
|
||||
val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
|
||||
val excluded = prefs.hiddenCalendarIds.first() +
|
||||
prefs.pendingDisabledCalendarIds.first() +
|
||||
invisibleCalendarIds()
|
||||
dataSource.searchEvents(query)
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||
}
|
||||
@@ -118,6 +213,22 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
override suspend fun deleteCalendar(id: Long) =
|
||||
withContext(io) { dataSource.deleteCalendar(id) }
|
||||
|
||||
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
||||
withContext(io) {
|
||||
if (dataSource.canWriteCalendars()) {
|
||||
ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
||||
// Nothing of ours is left waiting for the provider once the
|
||||
// write lands (and switching one back on retires its entry).
|
||||
prefs.removePendingDisabledCalendarIds(ids)
|
||||
} else if (visible) {
|
||||
prefs.removePendingDisabledCalendarIds(ids)
|
||||
} else {
|
||||
// Read-only permission: the switch still works, app-side, and
|
||||
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
|
||||
prefs.addPendingDisabledCalendarIds(ids)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
|
||||
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Keeps the app's pending "switched off" set (see
|
||||
* [CalendarPrefs.pendingDisabledCalendarIds]) and the system's
|
||||
* `Calendars.VISIBLE` in step — the fold-in of the retired app-local visibility
|
||||
* model (#75), and the standing drain for switch-offs made without
|
||||
* `WRITE_CALENDAR`.
|
||||
*
|
||||
* Runs on every launch, and again whenever the app comes up holding the calendar
|
||||
* permission — a grant made on Android's own app-settings screen never reaches
|
||||
* the permission screen's callback. It is a no-op whenever the pending set is
|
||||
* empty and the notice has been settled, which
|
||||
* is the steady state: each entry is written and dropped individually, so a run
|
||||
* that dies part-way resumes exactly where it stopped and never re-applies a
|
||||
* write the user has since undone by hand.
|
||||
*
|
||||
* The reconciliation only hides (see [calendarVisibilityPlan]). Calendars hidden
|
||||
* at system level stay hidden, and on an *upgraded* install the first run that
|
||||
* sees one arms the one-time notice explaining why Calendula no longer lists
|
||||
* their events. A fresh install never had the old behaviour, so it retires that
|
||||
* notice unshown — every other device ships with something hidden.
|
||||
*/
|
||||
@Singleton
|
||||
class CalendarVisibilityReconciler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val dataSource: CalendarDataSource,
|
||||
private val prefs: CalendarPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
suspend fun run() = withContext(io) {
|
||||
// Everything, the DataStore reads included, sits inside the guard: this
|
||||
// runs in a bare application-scope coroutine with no exception handler,
|
||||
// so an IOException from a damaged preferences file would otherwise take
|
||||
// the process down on every launch.
|
||||
try {
|
||||
// A fresh install has no retired model behind it — nothing to
|
||||
// migrate, and nothing to explain. Settled ahead of the permission
|
||||
// gate so an update installed before the first grant can't make a
|
||||
// first run look like an upgrade afterwards.
|
||||
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
|
||||
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
|
||||
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||
val noticeSettled = prefs.visibilityNoticePending.first() != null
|
||||
// The steady state, and every run after the first: nothing left to
|
||||
// drain and nothing left to decide, so don't pay for the query.
|
||||
if (pending.isEmpty() && noticeSettled) return@withContext
|
||||
val calendars = dataSource.calendars()
|
||||
// An empty read means "couldn't read", not "no calendars": the data
|
||||
// source turns a null cursor — a provider momentarily unavailable —
|
||||
// into an empty list. Both decisions below are one-way, so taking
|
||||
// that reading as the truth would drop the whole pending set without
|
||||
// ever writing VISIBLE = 0 (switching the user's calendars back on,
|
||||
// events and reminders with them) and settle the notice as "nothing
|
||||
// to explain". Leave both to the next run.
|
||||
if (calendars.isEmpty()) return@withContext
|
||||
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
|
||||
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||
return@withContext
|
||||
}
|
||||
val plan = calendarVisibilityPlan(calendars, pending)
|
||||
// Already off, or gone from the device — nothing to write, so let
|
||||
// those ids leave the pending set with the rest.
|
||||
prefs.removePendingDisabledCalendarIds(plan.settled)
|
||||
// One calendar per write: the provider skips its reminder-alarm
|
||||
// reschedule for anything but a single-id update (see
|
||||
// [CalendarDataSource.setCalendarVisible]). Dropping each id as it
|
||||
// lands keeps a part-applied run resumable.
|
||||
for (id in plan.hide) {
|
||||
dataSource.setCalendarVisible(id, false)
|
||||
prefs.removePendingDisabledCalendarIds(setOf(id))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Calendar visibility reconcile failed; will retry", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle the one-time notice: [pending] arms it, false retires it unshown.
|
||||
* Answered once, by whichever run can answer it first; the answer is stored
|
||||
* either way, so the notice can't resurface later, when the same state would
|
||||
* no longer be news to the user.
|
||||
*/
|
||||
private suspend fun settleNoticeOnce(pending: Boolean) {
|
||||
if (prefs.visibilityNoticePending.first() != null) return
|
||||
prefs.setVisibilityNoticePending(pending)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this install has ever run an earlier version. The notice explains
|
||||
* a change to behaviour the user has already seen, so a first install has
|
||||
* nothing to announce — and hidden calendars are the *norm* on a fresh
|
||||
* device (a second account's, "Holidays in …", a subscribed calendar), which
|
||||
* would otherwise put a changelog dialog in front of a first-run user.
|
||||
*/
|
||||
private fun isUpgradeInstall(): Boolean = try {
|
||||
@Suppress("DEPRECATION")
|
||||
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
info.lastUpdateTime > info.firstInstallTime
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Log.w(TAG, "Own package info unavailable; treating as a fresh install", e)
|
||||
false
|
||||
}
|
||||
|
||||
private fun hasPermission(permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Lets non-injectable entry points (the Application) reach the reconciler. */
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun calendarVisibilityReconciler(): CalendarVisibilityReconciler
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CalendarVisibility"
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ private const val TAG = "EventDetailMapper"
|
||||
internal fun ColumnReader.toEventDetailCore(
|
||||
attendees: List<Attendee>,
|
||||
reminders: List<Reminder>,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): EventDetail? {
|
||||
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||
@@ -89,7 +90,13 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
val displayReminders = if (isAllDay) {
|
||||
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val zone = ZoneId.systemDefault()
|
||||
reminders.map { it.copy(minutes = fromProviderAllDayMinutes(it.minutes, startDate, zone)) }
|
||||
reminders.map {
|
||||
it.copy(
|
||||
minutes = fromProviderAllDayMinutes(
|
||||
it.minutes, startDate, zone, allDayReminderTimeMinutes,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
reminders
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ internal object CalendarProjection {
|
||||
// uses to recognise its own managed calendars, independent of any
|
||||
// stored preference id (which a backup restore / data wipe can lose).
|
||||
MANAGED_MARKER_COLUMN,
|
||||
CalendarContract.Calendars.SYNC_EVENTS,
|
||||
)
|
||||
|
||||
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
||||
@@ -36,6 +37,7 @@ internal object CalendarProjection {
|
||||
const val IDX_ACCESS_LEVEL = 6
|
||||
const val IDX_DESCRIPTION = 7
|
||||
const val IDX_MANAGED_MARKER = 8
|
||||
const val IDX_SYNC_EVENTS = 9
|
||||
}
|
||||
|
||||
internal object InstanceProjection {
|
||||
|
||||
@@ -18,8 +18,8 @@ import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataS
|
||||
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.ReminderAlertStore
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import javax.inject.Singleton
|
||||
@@ -46,9 +46,9 @@ abstract class DataBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindReminderAlertStore(
|
||||
impl: AndroidReminderAlertStore,
|
||||
): ReminderAlertStore
|
||||
abstract fun bindReminderInstanceSource(
|
||||
impl: ProviderReminderInstanceSource,
|
||||
): ReminderInstanceSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* App-side preference for "calendars the user has hidden in this app",
|
||||
* separate from the system's per-calendar VISIBLE flag.
|
||||
* App-side calendar preferences. [hiddenCalendarIds] is the drawer's filter
|
||||
* sheet — a purely in-app declutter that deliberately does *not* suppress
|
||||
* reminders. Switching a calendar off entirely is the system's
|
||||
* `Calendars.VISIBLE` flag, written straight to the provider (#75);
|
||||
* [pendingDisabledCalendarIds] only holds those switch-offs the app has not been
|
||||
* allowed to write yet.
|
||||
*
|
||||
* Persisted as a comma-separated string of Long ids; non-numeric tokens are
|
||||
* silently dropped (defensive — see CalendarPrefsTest).
|
||||
@@ -22,46 +29,63 @@ class CalendarPrefs @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||
prefs[HIDDEN_IDS_KEY].orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
}
|
||||
// Both id sets are deduped: the store is shared with SettingsPrefs, so every
|
||||
// unrelated write (a settings toggle, the last-used calendar) re-emits an
|
||||
// identical set otherwise — and a change to the pending set now costs a
|
||||
// fresh provider read in CalendarRepositoryImpl.
|
||||
val hiddenCalendarIds: Flow<Set<Long>> = store.data
|
||||
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
|
||||
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendars switched off in Settings → Calendars that the provider does not
|
||||
* know about yet. That switch writes the system's `Calendars.VISIBLE` (#75),
|
||||
* which needs `WRITE_CALENDAR` — a user who granted read-only keeps their
|
||||
* choice here instead, and so does everyone upgrading from the retired
|
||||
* app-local model, whose set is read straight back out of the same key.
|
||||
*
|
||||
* Honoured as a display and reminder filter for as long as it is non-empty,
|
||||
* so an un-flushable switch still does what the user asked. Not a second
|
||||
* visibility model: `CalendarVisibilityReconciler` drains it into the
|
||||
* provider entry by entry the moment the app may write, and nothing ever
|
||||
* adds to it while it may.
|
||||
*/
|
||||
val pendingDisabledCalendarIds: Flow<Set<Long>> = store.data
|
||||
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) =
|
||||
editPendingDisabled { it + ids }
|
||||
|
||||
/**
|
||||
* Drop [ids] from the pending set — one id at a time as the reconciler
|
||||
* flushes it, so a run that fails part-way never re-applies what already
|
||||
* landed (and can't undo a switch the user has since flipped by hand).
|
||||
*/
|
||||
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) =
|
||||
editPendingDisabled { it - ids.toSet() }
|
||||
|
||||
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
|
||||
store.edit { prefs ->
|
||||
if (ids.isEmpty()) {
|
||||
prefs.remove(HIDDEN_IDS_KEY)
|
||||
} else {
|
||||
prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
|
||||
}
|
||||
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App-side preference for "calendars the user has disabled in this app" — a
|
||||
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
|
||||
* every surface (drawer filter, event-form picker, import picker) and its
|
||||
* events never appear; it stays listed only in Settings → Calendars so it can
|
||||
* be re-enabled. Stored exactly like the hidden set; never touches the
|
||||
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
|
||||
* Whether the one-time "visibility follows this device" notice is still
|
||||
* owed. Null until the reconciler has evaluated it (which needs the calendar
|
||||
* permission), false once it has been shown or was never needed.
|
||||
*/
|
||||
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||
prefs[DISABLED_IDS_KEY].orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs ->
|
||||
prefs[VISIBILITY_NOTICE_KEY]
|
||||
}
|
||||
|
||||
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
|
||||
store.edit { prefs ->
|
||||
if (ids.isEmpty()) {
|
||||
prefs.remove(DISABLED_IDS_KEY)
|
||||
} else {
|
||||
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
|
||||
}
|
||||
}
|
||||
suspend fun setVisibilityNoticePending(pending: Boolean) {
|
||||
store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,6 +103,16 @@ class CalendarPrefs @Inject constructor(
|
||||
companion object {
|
||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
|
||||
internal val VISIBILITY_NOTICE_KEY = booleanPreferencesKey("visibility_notice_pending")
|
||||
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.parseIds(): Set<Long> = orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
|
||||
private fun MutablePreferences.writeIds(key: Preferences.Key<String>, ids: Set<Long>) {
|
||||
if (ids.isEmpty()) remove(key) else set(key, ids.sorted().joinToString(","))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* How far reminder delivery has got. One number, and it replaces everything the
|
||||
* provider's `CalendarAlerts.STATE` used to do for us (#75).
|
||||
*
|
||||
* A scan posts the reminders whose moment falls after this watermark and up to
|
||||
* now, then moves it to now. That single rule gives both halves of what the
|
||||
* retired path got from the provider: a scan that runs twice cannot post the
|
||||
* same reminder again, and a scan that runs *late* — after a reboot, an app
|
||||
* update or a doze window swallowed the alarm — still posts everything the
|
||||
* missed alarm would have.
|
||||
*
|
||||
* Unset means "never scanned". It is deliberately not treated as zero: the first
|
||||
* scan after an install or an upgrade would otherwise consider every reminder
|
||||
* since the epoch overdue and bury the user in notifications.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderStatePrefs @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
/** The watermark, or `null` before the first scan has ever run. */
|
||||
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first()
|
||||
|
||||
suspend fun setLastScanMillis(millis: Long) {
|
||||
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis")
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import java.time.ZoneId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -306,6 +307,23 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The size step the agenda widget draws its text at (#51). Defaults to
|
||||
* [WidgetSize.SMALL], which reproduces its original metrics, so an existing
|
||||
* widget is unchanged until its owner turns the size up.
|
||||
*
|
||||
* This replaced deriving a size tier from the widget's measured size, which
|
||||
* the launcher does not report reliably. The month widget takes no size
|
||||
* setting — it divides the width it is given by seven (#103).
|
||||
*/
|
||||
val widgetSize: Flow<WidgetSize> = store.data.map { prefs ->
|
||||
prefs[WIDGET_SIZE_KEY].toEnum(WidgetSize.SMALL)
|
||||
}
|
||||
|
||||
suspend fun setWidgetSize(size: WidgetSize) {
|
||||
store.edit { it[WIDGET_SIZE_KEY] = size.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agenda shows its top range bar — the "showing …" header and
|
||||
* the session range switcher (v2.11). Default ON.
|
||||
@@ -781,6 +799,7 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||
internal val WIDGET_SIZE_KEY = stringPreferencesKey("widget_size")
|
||||
internal val AGENDA_SHOW_TODAY_KEY =
|
||||
booleanPreferencesKey("agenda_show_today")
|
||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
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
|
||||
* visible notifications (the Etar model — the provider broadcasts
|
||||
* `EVENT_REMINDER` at reminder time but posts nothing itself).
|
||||
*
|
||||
* The broadcast's data URI only carries the alarm time, so it is ignored:
|
||||
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
|
||||
* them, and mark them fired. Posting happens before marking — a crash in
|
||||
* between re-posts silently (same tag) rather than losing the reminder.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class EventReminderReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var alertStore: ReminderAlertStore
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||
@Inject lateinit var suppressedStore: SuppressedReminderStore
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||
val readGranted = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (!readGranted || !notifier.canPost()) return
|
||||
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
if (settingsPrefs.remindersEnabled.first()) {
|
||||
val now = System.currentTimeMillis()
|
||||
val due = alertStore.dueAlerts(now)
|
||||
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)
|
||||
suppressedStore.stash(due - postable.toSet(), now)
|
||||
suppressedStore.purgeExpired(now)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.reminders
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -18,12 +19,14 @@ import javax.inject.Inject
|
||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||
* alarm), so the receiver is not exported.
|
||||
*
|
||||
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
||||
* already fired, so nothing re-posts it.
|
||||
* - **Dismiss** just cancels the notification — the scan's watermark has moved
|
||||
* past this reminder, so nothing re-posts it.
|
||||
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||
* it after the user's snooze delay.
|
||||
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
||||
* or dismiss it again.
|
||||
* or dismiss it again — unless the calendar was switched off during the
|
||||
* snooze, which [ReminderNotifier.post] catches — this alarm is its own
|
||||
* trigger, outside the ordinary scan.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderActionReceiver : BroadcastReceiver() {
|
||||
@@ -73,7 +76,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
||||
|
||||
private const val EXTRA_ALERT_ID = "alert_id"
|
||||
/**
|
||||
* Not handled here — the notification body opens the detail screen
|
||||
* directly. It only claims a slot in [requestCode] so that intent stays
|
||||
* distinct from the three this receiver does handle.
|
||||
*/
|
||||
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
|
||||
|
||||
private const val EXTRA_ALERT_KEY = "alert_key"
|
||||
private const val EXTRA_EVENT_ID = "event_id"
|
||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||
private const val EXTRA_BEGIN = "begin"
|
||||
@@ -82,11 +92,20 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
private const val EXTRA_LOCATION = "location"
|
||||
private const val EXTRA_ALL_DAY = "all_day"
|
||||
|
||||
/** An explicit intent to this receiver carrying [alert] as extras. */
|
||||
/**
|
||||
* An explicit intent to this receiver carrying [alert] as extras.
|
||||
*
|
||||
* The data URI names the reminder and carries no information the extras
|
||||
* don't. It is what actually keeps two reminders' `PendingIntent`s
|
||||
* apart: `filterEquals` compares action, component, data, type and
|
||||
* categories — never extras — so without it a request-code collision
|
||||
* would have them share one alarm and one payload.
|
||||
*/
|
||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||
data = "calendula://reminder/${alert.key}".toUri()
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
@@ -97,23 +116,29 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable request code per (alert, action) so the three PendingIntents
|
||||
* of one notification stay distinct and don't clobber each other.
|
||||
* A stable request code per (alert, action) so one notification's
|
||||
* PendingIntents stay distinct and don't clobber each other.
|
||||
*
|
||||
* The key is a hash now rather than a small row id, so the shift is what
|
||||
* keeps the action slot intact; the top three bits it drops can make two
|
||||
* *different* reminders share a code, which the per-reminder data URI in
|
||||
* [intent] separates.
|
||||
*/
|
||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||
val actionOffset = when (action) {
|
||||
ACTION_SNOOZE -> 1
|
||||
ACTION_DISMISS -> 2
|
||||
ACTION_SHOW -> 3
|
||||
ACTION_OPEN -> 4
|
||||
else -> 0
|
||||
}
|
||||
return alert.alertId.toInt() * 8 + actionOffset
|
||||
return (alert.key.toInt() shl 3) + actionOffset
|
||||
}
|
||||
|
||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
||||
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||
return ReminderAlert(
|
||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
|
||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
|
||||
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
|
||||
* calendar app), user-revocable on 31–32.
|
||||
*/
|
||||
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
|
||||
|
||||
/**
|
||||
* Holds the app's own wake-up for the next reminder — the half of delivery that
|
||||
* used to be the provider's (#75).
|
||||
*
|
||||
* Exactly **one** alarm exists at a time, for the earliest reminder still ahead.
|
||||
* Every firing re-scans and re-arms, so a reminder added, moved or deleted in
|
||||
* between is picked up on the next pass instead of needing an alarm per reminder
|
||||
* to be kept in sync with the provider's tables.
|
||||
*
|
||||
* A reminder that lands late is a broken reminder, hence an *exact* alarm; the
|
||||
* inexact allow-while-idle fallback only applies where the OS withholds the
|
||||
* capability (API 31–32 with the user's permission revoked).
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderAlarmScheduler @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
fun scheduleScan(triggerAtMillis: Long) {
|
||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
if (alarmManager.canScheduleExactCompat()) {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
} else {
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
|
||||
fun cancelScan() {
|
||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||
alarmManager.cancel(scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
}
|
||||
|
||||
private fun scanPendingIntent(flags: Int): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
SCAN_REQUEST_CODE,
|
||||
Intent(context, ReminderScheduleReceiver::class.java)
|
||||
.setAction(ReminderScheduleReceiver.ACTION_SCAN),
|
||||
flags or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
// Fixed: there is only ever one scan alarm, and re-arming must replace it.
|
||||
const val SCAN_REQUEST_CODE = 0x5CA1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
|
||||
|
||||
/**
|
||||
* One reminder as the notification layer needs it: what to show, and the stable
|
||||
* [key] that identifies it across a reboot, a re-scan and a reinstall.
|
||||
*
|
||||
* [key] used to be the `CalendarAlerts` row id. It is now derived from the
|
||||
* reminder itself (see [PlannedReminder.key]) because there is no row any more —
|
||||
* in-house delivery reads `Instances` and `Reminders` and owns the alarm (#75).
|
||||
* Everything downstream only ever needed it to be stable and unique, which it
|
||||
* still is: it keys the notification tag, so a reminder posted twice replaces
|
||||
* itself instead of stacking, and it keys the snooze/dismiss `PendingIntent`s.
|
||||
*/
|
||||
data class ReminderAlert(
|
||||
val key: Long,
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||
val title: String,
|
||||
val location: String?,
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
|
||||
key = key,
|
||||
eventId = instance.eventId,
|
||||
calendarId = instance.calendarId,
|
||||
beginMillis = instance.beginMillis,
|
||||
endMillis = instance.endMillis,
|
||||
title = instance.title,
|
||||
location = instance.location,
|
||||
isAllDay = instance.isAllDay,
|
||||
)
|
||||
@@ -1,115 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* One due row of the provider's `CalendarAlerts` table (a join with Events).
|
||||
* Stays in the data layer: alerts feed the notification path only and never
|
||||
* reach a screen, so there is no domain model for them.
|
||||
*/
|
||||
data class ReminderAlert(
|
||||
val alertId: Long,
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||
val title: String,
|
||||
val location: String?,
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
|
||||
* without a ContentResolver. The provider creates these rows itself — only
|
||||
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
|
||||
* email reminders never show up here.
|
||||
*/
|
||||
interface ReminderAlertStore {
|
||||
|
||||
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
|
||||
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
|
||||
|
||||
/**
|
||||
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
|
||||
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
|
||||
* which the user may have declined — then re-broadcasts silently replace
|
||||
* the already-posted notifications instead (same tag, alert-once).
|
||||
*/
|
||||
fun markFired(alertIds: List<Long>, nowMillis: Long)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class AndroidReminderAlertStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ReminderAlertStore {
|
||||
|
||||
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
|
||||
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||
PROJECTION,
|
||||
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
|
||||
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
|
||||
arrayOf(
|
||||
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
|
||||
nowMillis.toString(),
|
||||
),
|
||||
CalendarContract.CalendarAlerts.BEGIN + " ASC",
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
add(
|
||||
ReminderAlert(
|
||||
alertId = c.getLong(0),
|
||||
eventId = c.getLong(1),
|
||||
calendarId = c.getLong(2),
|
||||
beginMillis = c.getLong(3),
|
||||
endMillis = c.getLong(4),
|
||||
title = c.getString(5).orEmpty(),
|
||||
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(7) == 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
|
||||
if (alertIds.isEmpty()) return
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
|
||||
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
|
||||
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
|
||||
}
|
||||
try {
|
||||
context.contentResolver.update(
|
||||
CalendarContract.CalendarAlerts.CONTENT_URI,
|
||||
values,
|
||||
CalendarContract.CalendarAlerts._ID +
|
||||
" IN (" + alertIds.joinToString(",") + ")",
|
||||
null,
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderAlertStore"
|
||||
val PROJECTION = arrayOf(
|
||||
CalendarContract.CalendarAlerts._ID,
|
||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
||||
CalendarContract.CalendarAlerts.BEGIN,
|
||||
CalendarContract.CalendarAlerts.END,
|
||||
CalendarContract.CalendarAlerts.TITLE,
|
||||
CalendarContract.CalendarAlerts.EVENT_LOCATION,
|
||||
CalendarContract.CalendarAlerts.ALL_DAY,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContentUris
|
||||
import android.provider.CalendarContract
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The read side of in-house reminder delivery: occurrences and the reminder
|
||||
* offsets hanging off them, straight out of the provider's own tables.
|
||||
*
|
||||
* Deliberately *not* `CalendarAlerts`. That table is the provider's own working
|
||||
* copy of this same information, and the whole point of #75's second round is
|
||||
* that we can no longer assume it gets written. `Instances` and `Reminders` are
|
||||
* plain data the app already reads everywhere else.
|
||||
*
|
||||
* An interface so [ReminderScanner] can be exercised on the JVM without a
|
||||
* ContentResolver, in the shape the rest of `data/calendar` uses.
|
||||
*/
|
||||
interface ReminderInstanceSource {
|
||||
|
||||
/** Occurrences overlapping `[fromMillis, toMillis]`, of switched-on calendars. */
|
||||
fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance>
|
||||
|
||||
/** `METHOD_ALERT` reminder offsets per event id, for the given events. */
|
||||
fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>>
|
||||
|
||||
/**
|
||||
* The largest `METHOD_ALERT` offset anywhere in the table, so the query
|
||||
* window can be stretched to cover it and a long-lead reminder is planned
|
||||
* before it comes due rather than firing late.
|
||||
*/
|
||||
fun longestReminderMinutes(): Int
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class ProviderReminderInstanceSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ReminderInstanceSource {
|
||||
|
||||
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
|
||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||
ContentUris.appendId(this, fromMillis)
|
||||
ContentUris.appendId(this, toMillis)
|
||||
}.build()
|
||||
// `visible` is the calendar's system flag, which the app's one visibility
|
||||
// model writes (#75) — a switched-off calendar must not plan reminders.
|
||||
// The status clause mirrors CalendarDataSource.instances: a cancelled
|
||||
// single occurrence of a series is a real row, and NULL means "normal",
|
||||
// so a bare `!= CANCELED` would drop every ordinary event.
|
||||
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
|
||||
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
|
||||
return context.contentResolver.query(
|
||||
uri, OCCURRENCE_PROJECTION, selection, null, null,
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
add(
|
||||
ReminderEventInstance(
|
||||
eventId = c.getLong(0),
|
||||
calendarId = c.getLong(1),
|
||||
beginMillis = c.getLong(2),
|
||||
endMillis = if (c.isNull(3)) 0L else c.getLong(3),
|
||||
title = c.getString(4).orEmpty(),
|
||||
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(6) == 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
|
||||
if (eventIds.isEmpty()) return emptyMap()
|
||||
val out = mutableMapOf<Long, MutableList<Int>>()
|
||||
// Batched because the ids go into the selection literally; an unbounded
|
||||
// `IN (...)` on a busy calendar would grow the SQL past what SQLite takes.
|
||||
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
|
||||
context.contentResolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
REMINDER_PROJECTION,
|
||||
"${CalendarContract.Reminders.METHOD} = " +
|
||||
"${CalendarContract.Reminders.METHOD_ALERT} AND " +
|
||||
"${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
|
||||
null,
|
||||
null,
|
||||
)?.use { c ->
|
||||
while (c.moveToNext()) {
|
||||
out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
override fun longestReminderMinutes(): Int = context.contentResolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Reminders.MINUTES),
|
||||
"${CalendarContract.Reminders.METHOD} = ${CalendarContract.Reminders.METHOD_ALERT}",
|
||||
null,
|
||||
// One row is enough: the provider passes the sort order to SQLite.
|
||||
"${CalendarContract.Reminders.MINUTES} DESC",
|
||||
)?.use { c -> if (c.moveToFirst()) c.getInt(0) else 0 } ?: 0
|
||||
|
||||
private companion object {
|
||||
const val EVENT_ID_BATCH = 50
|
||||
|
||||
val OCCURRENCE_PROJECTION = arrayOf(
|
||||
CalendarContract.Instances.EVENT_ID,
|
||||
CalendarContract.Instances.CALENDAR_ID,
|
||||
CalendarContract.Instances.BEGIN,
|
||||
CalendarContract.Instances.END,
|
||||
CalendarContract.Instances.TITLE,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.ALL_DAY,
|
||||
)
|
||||
|
||||
val REMINDER_PROJECTION = arrayOf(
|
||||
CalendarContract.Reminders.EVENT_ID,
|
||||
CalendarContract.Reminders.MINUTES,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
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 java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The backstop under the alarm: a daily scan that runs whether or not the alarm
|
||||
* survived.
|
||||
*
|
||||
* The scan alarm re-arms itself at most a day out, so in the steady state this
|
||||
* finds nothing to do. It exists for the case the whole feature is about — a
|
||||
* device that quietly drops the alarm without a reboot to announce it. The
|
||||
* scheduling half of reminder delivery must not have a single point of failure,
|
||||
* which is exactly what the provider's broadcast turned out to be.
|
||||
*/
|
||||
object ReminderMaintenanceScheduler {
|
||||
|
||||
private const val WORK_NAME = "reminder-scan-maintenance"
|
||||
|
||||
/** Enqueue the daily backstop; idempotent, so every launch may call it. */
|
||||
fun apply(context: Context) {
|
||||
val request = PeriodicWorkRequestBuilder<ReminderMaintenanceWorker>(1, TimeUnit.DAYS)
|
||||
// The launch scan covers now; let the first periodic run wait.
|
||||
.setInitialDelay(1, TimeUnit.DAYS)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
}
|
||||
}
|
||||
|
||||
class ReminderMaintenanceWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun reminderScanner(): ReminderScanner
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result = try {
|
||||
EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||
.reminderScanner()
|
||||
.scan()
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
// The scan swallows its own failures; anything reaching here is the
|
||||
// entry point itself, which a retry will not mend. Never fail the chain.
|
||||
Log.w(TAG, "Reminder maintenance scan failed", e)
|
||||
Result.success()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderMaintenance"
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
@@ -28,17 +29,20 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Posts one notification per due reminder alert on a dedicated channel.
|
||||
* Tapping opens the event's detail screen; the tag is the alert id, so a
|
||||
* re-broadcast of an alert we couldn't mark fired replaces its notification
|
||||
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
|
||||
* duplicating it.
|
||||
* Posts one notification per due reminder on a dedicated channel. Tapping opens
|
||||
* the event's detail screen.
|
||||
*
|
||||
* The tag is the reminder's stable key, so a scan that posts the same reminder
|
||||
* again — a catch-up pass overlapping the alarm that already fired — replaces
|
||||
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
|
||||
* instead of stacking a second one.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderNotifier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -49,12 +53,25 @@ class ReminderNotifier @Inject constructor(
|
||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
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
|
||||
/**
|
||||
* The single choke point for "this calendar is switched off". The scan
|
||||
* already filters on `Calendars.VISIBLE`, but two paths reach [post] around
|
||||
* it: a snooze re-shown from its own alarm, armed before the calendar was
|
||||
* switched off, and a read-only install whose switch lives app-side
|
||||
* ([CalendarPrefs]) because it may not write the flag. Both are covered here
|
||||
* rather than in either receiver.
|
||||
*/
|
||||
private suspend fun isSilenced(calendarId: Long): Boolean =
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
calendarDataSource.isCalendarVisible(calendarId) == false
|
||||
|
||||
/**
|
||||
* Post [alert], unless its calendar is switched off. Returns whether the
|
||||
* notification was put up, which the snooze re-show path uses to tell a
|
||||
* silenced reminder from a delivered one.
|
||||
*/
|
||||
suspend fun post(alert: ReminderAlert): Boolean {
|
||||
if (isSilenced(alert.calendarId)) return false
|
||||
ensureChannel()
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
@@ -101,16 +118,18 @@ class ReminderNotifier @Inject constructor(
|
||||
.build()
|
||||
try {
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
|
||||
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
|
||||
} catch (e: SecurityException) {
|
||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||
}
|
||||
// Handled either way: re-running it would hit the same revoked permission.
|
||||
return true
|
||||
}
|
||||
|
||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||
fun cancel(alert: ReminderAlert) {
|
||||
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
|
||||
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||
@@ -123,7 +142,11 @@ class ReminderNotifier @Inject constructor(
|
||||
|
||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
/* requestCode = */ alert.alertId.toInt(),
|
||||
// Shares the per-(alert, action) request-code scheme with the buttons, so
|
||||
// the key's wider value range can't collide one notification's intents.
|
||||
/* requestCode = */ ReminderActionReceiver.requestCode(
|
||||
alert, ReminderActionReceiver.ACTION_OPEN,
|
||||
),
|
||||
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* One pass of in-house reminder delivery: read what is planned, post what has
|
||||
* come due, and arm the next wake-up.
|
||||
*
|
||||
* This is the whole loop. Every trigger — the alarm firing, boot, a clock or
|
||||
* timezone change, an edit landing in the provider, the app starting, the daily
|
||||
* safety net — runs the same [scan], so there is a single path to reason about
|
||||
* and no ordering between triggers to get wrong. Re-running it is always safe:
|
||||
* the watermark in [ReminderStatePrefs] decides what is owed, not the trigger.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderScanner @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val source: ReminderInstanceSource,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val notifier: ReminderNotifier,
|
||||
private val alarms: ReminderAlarmScheduler,
|
||||
private val state: ReminderStatePrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: kotlinx.coroutines.CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
// Triggers overlap freely (an alarm during a burst of edits); serialize so
|
||||
// two passes can't both read the same watermark and post the same reminder.
|
||||
private val scanLock = Mutex()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + io)
|
||||
private val providerChanges = MutableSharedFlow<Unit>(
|
||||
replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
private var watching = false
|
||||
|
||||
suspend fun scan() = withContext(io) {
|
||||
scanLock.withLock {
|
||||
try {
|
||||
runScan()
|
||||
} catch (e: SecurityException) {
|
||||
// The calendar permission was revoked mid-flight. Nothing to
|
||||
// re-arm and nothing to recover from — the next grant re-scans.
|
||||
Log.w(TAG, "Reminder scan lacks the calendar permission", e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reminder scan failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runScan() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!hasReadCalendar()) return
|
||||
|
||||
if (!settingsPrefs.remindersEnabled.first()) {
|
||||
// Reminders off: drop the wake-up, but keep the watermark moving so
|
||||
// switching them back on doesn't replay everything missed meanwhile.
|
||||
alarms.cancelScan()
|
||||
state.setLastScanMillis(now)
|
||||
return
|
||||
}
|
||||
|
||||
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
|
||||
// Reach a little into the past as well: an event already under way can
|
||||
// still owe a reminder (an all-day "at time of event" encodes to a
|
||||
// negative offset, which fires after begin), and a catch-up pass needs
|
||||
// to see the occurrences whose moment it missed.
|
||||
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
||||
val planned = planReminders(
|
||||
instances = occurrences,
|
||||
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
|
||||
zone = ZoneId.systemDefault(),
|
||||
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
|
||||
)
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned = planned,
|
||||
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
|
||||
nowMillis = now,
|
||||
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
|
||||
)
|
||||
|
||||
if (notifier.canPost()) {
|
||||
schedule.due.forEach { notifier.post(it.toAlert()) }
|
||||
}
|
||||
// Advance regardless of whether anything could be posted: a user who
|
||||
// muted notifications is not owed a backlog when they unmute.
|
||||
state.setLastScanMillis(now)
|
||||
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||
}
|
||||
|
||||
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/**
|
||||
* Re-scan when the provider changes, so an event saved or deleted in the app
|
||||
* re-arms the alarm immediately rather than waiting for the next pass.
|
||||
*
|
||||
* Debounced: a single save writes the event, its reminders and its
|
||||
* attendees, and each lands as its own notification. Only useful while the
|
||||
* process is alive — every other trigger covers the rest, which is why
|
||||
* nothing here needs to survive it.
|
||||
*/
|
||||
fun startWatchingProvider() {
|
||||
if (watching) return
|
||||
watching = true
|
||||
providerChanges
|
||||
.debounce(PROVIDER_CHANGE_DEBOUNCE_MILLIS)
|
||||
.onEach { scan() }
|
||||
.launchIn(scope)
|
||||
calendarDataSource.registerChangeListener { providerChanges.tryEmit(Unit) }
|
||||
}
|
||||
|
||||
/** Fire-and-forget scan for callers that are not in a coroutine already. */
|
||||
fun scanInBackground() {
|
||||
scope.launch(start = CoroutineStart.DEFAULT) { scan() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderScanner"
|
||||
|
||||
/**
|
||||
* How far ahead occurrences are read. Stretched further by the longest
|
||||
* reminder offset in the table, so this is only the floor.
|
||||
*/
|
||||
const val LOOKAHEAD_MILLIS = 7L * 24 * 60 * 60 * 1000
|
||||
|
||||
/** How far back to look for occurrences that may still owe a reminder. */
|
||||
const val PAST_WINDOW_MILLIS = 24L * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Never wait longer than a day for the next pass, even with nothing
|
||||
* pending: it rolls the lookahead window forward and re-arms an alarm
|
||||
* the system may have dropped.
|
||||
*/
|
||||
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
|
||||
|
||||
const val PROVIDER_CHANGE_DEBOUNCE_MILLIS = 2_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Every reason to re-run a reminder scan that arrives from outside the process.
|
||||
*
|
||||
* All of them do the same thing, because [ReminderScanner.scan] is idempotent
|
||||
* and works out what is owed from its watermark rather than from why it was
|
||||
* called:
|
||||
*
|
||||
* - **our own alarm** ([ACTION_SCAN]) — the ordinary case, a reminder is due;
|
||||
* - **boot** and **package replaced** — both wipe pending alarms, so the app has
|
||||
* to re-arm or reminders stop silently, which is the failure #75 is about;
|
||||
* - **time and timezone changes** — they move every reminder relative to the
|
||||
* armed alarm, and an all-day reminder's fire hour is recomposed in the
|
||||
* current zone, so both need a fresh plan.
|
||||
*
|
||||
* Exported because the system broadcasts arrive from outside. [ACTION_SCAN] is
|
||||
* ours and always sent as an explicit intent; another app triggering a scan
|
||||
* early would only make it re-read the provider and re-arm, which is harmless.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderScheduleReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var scanner: ReminderScanner
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
// Every action here does the same thing, but the filter still has to be
|
||||
// checked: the receiver is exported, and the system broadcasts it takes
|
||||
// are protected, so an intent arriving with any other action did not
|
||||
// come from where it claims to.
|
||||
if (intent.action !in HANDLED_ACTIONS) return
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
scanner.scan()
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_SCAN = "de.jeanlucmakiola.calendula.reminders.SCAN"
|
||||
|
||||
private val HANDLED_ACTIONS = setOf(
|
||||
ACTION_SCAN,
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
Intent.ACTION_TIME_CHANGED,
|
||||
Intent.ACTION_TIMEZONE_CHANGED,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,12 @@ import javax.inject.Singleton
|
||||
/**
|
||||
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
||||
*
|
||||
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
||||
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
||||
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
||||
* Separate from [ReminderAlarmScheduler]'s scan alarm, and deliberately so: a
|
||||
* snooze is pinned to one reminder at a time the user chose, while the scan
|
||||
* alarm is a single moving wake-up for whatever comes next. A re-show also has
|
||||
* to outlive the scan's watermark moving past that reminder, so it carries the
|
||||
* reminder in its own intent rather than re-deriving it.
|
||||
*
|
||||
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
||||
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
||||
* exact-alarm capability (API 31–32 where the user revoked it).
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
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,76 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
/**
|
||||
* The ways a calendar can behave unlike a plain, writable one — each of them a
|
||||
* reason it is missing from the event and import pickers, and each of them
|
||||
* something the app knows and used to keep to itself (#76).
|
||||
*/
|
||||
enum class CalendarStateLabel {
|
||||
/**
|
||||
* A special-dates mirror the app fills from contacts. Writable and visible,
|
||||
* yet no event target: anything authored here is deleted by the next sync,
|
||||
* which is why it is the one exclusion with nothing else to give it away.
|
||||
*/
|
||||
MANAGED,
|
||||
|
||||
/** Contents can't be modified: a WebCal subscription, a read-only share. */
|
||||
READ_ONLY,
|
||||
|
||||
/** The account holds the events, but this device isn't syncing them down. */
|
||||
NOT_SYNCED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account this calendar belongs to keeps its events off the device
|
||||
* (`Calendars.SYNC_EVENTS = 0`) — an "empty by construction" calendar: the rows
|
||||
* simply aren't here, so nothing can display them and no reminder can fire.
|
||||
*
|
||||
* Device-local calendars are excluded deliberately. Nothing syncs them by
|
||||
* definition, so the flag says nothing about them, and a local calendar from
|
||||
* another app can hold real events at `sync_events = 0` — the same unsoundness
|
||||
* that made the #75 migration guard wrong.
|
||||
*/
|
||||
val CalendarSource.isNotSynced: Boolean
|
||||
get() = !syncsEvents && !isLocal
|
||||
|
||||
/**
|
||||
* Whether a visibility switch on this calendar can change anything the user
|
||||
* would see. It can't for a non-syncing one: there are no events on the device
|
||||
* to reveal, so the switch would be a control that does nothing.
|
||||
*/
|
||||
val CalendarSource.hasVisibilitySwitch: Boolean
|
||||
get() = !isNotSynced
|
||||
|
||||
/**
|
||||
* Whether this calendar can be offered as a target for a new or imported event.
|
||||
* The one predicate behind both pickers, so the states [CalendarStateLabel]
|
||||
* names on a manager row are exactly the states that keep a calendar out of
|
||||
* them (#76):
|
||||
*
|
||||
* - read-only has nowhere to write;
|
||||
* - switched off would hide the event the moment it was saved;
|
||||
* - a managed mirror has the next contact sync delete it;
|
||||
* - a non-syncing one never carries the event up to the account, and
|
||||
* `CalendarProvider2` wipes the calendar's rows outright when the
|
||||
* subscription is switched back on — a saved event is a dead end either way.
|
||||
*
|
||||
* This is the test for *targets*. An event already living in an excluded
|
||||
* calendar keeps it; the editor adds that calendar back to its picker.
|
||||
*/
|
||||
val CalendarSource.isEventTarget: Boolean
|
||||
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
||||
|
||||
/** Every state worth naming on this calendar's row, in reading order. */
|
||||
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
||||
if (isManaged) add(CalendarStateLabel.MANAGED)
|
||||
if (!canModifyContents) add(CalendarStateLabel.READ_ONLY)
|
||||
if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar-manager order within one group: the ones you can actually act on
|
||||
* first, the non-syncing ones after them. Stable otherwise, so the provider's
|
||||
* display-name ordering survives.
|
||||
*/
|
||||
fun List<CalendarSource>.orderedForManager(): List<CalendarSource> =
|
||||
sortedBy { it.isNotSynced }
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
/**
|
||||
* The `Calendars.VISIBLE` writes that flush the app's pending "switched off"
|
||||
* set into the provider, plus the ids that need no write at all.
|
||||
*/
|
||||
data class CalendarVisibilityPlan(
|
||||
val hide: Set<Long> = emptySet(),
|
||||
val settled: Set<Long> = emptySet(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = hide.isEmpty() && settled.isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile [pendingDisabledIds] — calendars switched off in Settings →
|
||||
* Calendars while the app could not write `Calendars.VISIBLE`, plus whatever
|
||||
* the retired app-local visibility model left behind (#75) — against the
|
||||
* calendars actually on the device.
|
||||
*
|
||||
* The plan only ever *hides*. Switching a calendar off is intent the user
|
||||
* expressed in Calendula, so carrying it into the provider is fair. The other
|
||||
* direction is deliberately absent: a calendar hidden at system level was hidden
|
||||
* somewhere else (another calendar app, the account's own settings), and
|
||||
* switching it back on would un-hide it there too *and* start firing reminders
|
||||
* nobody asked for. Calendula follows that flag instead and explains itself once
|
||||
* (see [hasSystemHiddenCalendars]).
|
||||
*
|
||||
* [CalendarVisibilityPlan.settled] carries the ids that need no write — already
|
||||
* hidden, or gone from the device. They leave the pending set exactly as a
|
||||
* successful write would.
|
||||
*/
|
||||
fun calendarVisibilityPlan(
|
||||
calendars: List<CalendarSource>,
|
||||
pendingDisabledIds: Set<Long>,
|
||||
): CalendarVisibilityPlan {
|
||||
val byId = calendars.associateBy { it.id }
|
||||
val hide = mutableSetOf<Long>()
|
||||
val settled = mutableSetOf<Long>()
|
||||
for (id in pendingDisabledIds) {
|
||||
val calendar = byId[id]
|
||||
// No row means the calendar is gone; already invisible means someone
|
||||
// (us, on an earlier run) got there first. Either way: nothing to write.
|
||||
if (calendar != null && calendar.isVisibleInSystem) hide += id else settled += id
|
||||
}
|
||||
return CalendarVisibilityPlan(hide = hide, settled = settled)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any calendar is switched off at system level without Calendula having
|
||||
* asked for it. Those calendars showed their events before the app adopted
|
||||
* `Calendars.VISIBLE` as its one visibility model and no longer do, which is
|
||||
* what the one-time notice explains — the alternative, switching them on, would
|
||||
* reach into every other calendar app on the device.
|
||||
*/
|
||||
fun hasSystemHiddenCalendars(
|
||||
calendars: List<CalendarSource>,
|
||||
pendingDisabledIds: Set<Long>,
|
||||
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
||||
@@ -1,5 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
data class CalendarSource(
|
||||
@@ -8,6 +12,13 @@ data class CalendarSource(
|
||||
val accountName: String,
|
||||
val accountType: String,
|
||||
val color: Int,
|
||||
/**
|
||||
* The system's per-calendar `Calendars.VISIBLE` flag — the single visibility
|
||||
* model: it decides both what Calendula shows and whether the provider
|
||||
* schedules this calendar's reminder alarms at all (#75). Settings →
|
||||
* Calendars writes it; the drawer's filter sheet is a separate, purely
|
||||
* in-app declutter that leaves reminders alone.
|
||||
*/
|
||||
val isVisibleInSystem: Boolean,
|
||||
/**
|
||||
* Whether events in this calendar can be created/edited/deleted
|
||||
@@ -34,6 +45,15 @@ data class CalendarSource(
|
||||
* even after a backup restore clears the app's stored ids.
|
||||
*/
|
||||
val isManaged: Boolean = false,
|
||||
/**
|
||||
* Whether the provider keeps this calendar's events on the device
|
||||
* (`Calendars.SYNC_EVENTS`). Independent of [isVisibleInSystem]. For a
|
||||
* synced account it means the events aren't stored locally at all, so the
|
||||
* calendar reads as permanently empty; a device-local calendar another app
|
||||
* created can hold events with the flag off, so it says nothing there.
|
||||
* Read for the "not synced" row label (#76).
|
||||
*/
|
||||
val syncsEvents: Boolean = true,
|
||||
)
|
||||
|
||||
data class EventInstance(
|
||||
@@ -56,6 +76,35 @@ data class EventInstance(
|
||||
*/
|
||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||
|
||||
/**
|
||||
* The zone this event's calendar dates live in. Timed events are resolved in the
|
||||
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
|
||||
* resolving them anywhere else shifts the day boundaries — east of UTC the end
|
||||
* leaks onto the following day (#65), west of UTC the start pulls back onto the
|
||||
* previous one (#82). Every surface that has to name an all-day event's date
|
||||
* goes through here, so grid, agenda, detail and search cannot disagree.
|
||||
*/
|
||||
fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||
if (isAllDay) TimeZone.UTC else zone
|
||||
|
||||
/** The first calendar day this event occupies. */
|
||||
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||
start.toLocalDateTime(dateZone(zone)).date
|
||||
|
||||
/**
|
||||
* The last calendar day this event actually occupies. An event ending exactly at
|
||||
* midnight (all-day events end at the exclusive next-midnight) does not reach
|
||||
* into that boundary day, so resolve the instant just before [EventInstance.end].
|
||||
*/
|
||||
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||
}
|
||||
|
||||
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||
spanFirstDay(zone) != spanLastDay(zone)
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/**
|
||||
* The first [limit] dates a [SimpleRecurrence] fires on, starting at [start]
|
||||
* (the event's own start date, i.e. DTSTART), for showing a rule as dates
|
||||
* instead of as words.
|
||||
*
|
||||
* A recurrence rule is the one place in the editor where a correct-sounding
|
||||
* phrase can still mean something the user didn't intend — "every 2 weeks on
|
||||
* Mon, Fri", "monthly" on the 31st — and the only honest answer is the dates
|
||||
* themselves. This is a *preview*, deliberately kept to the shapes the picker
|
||||
* can build; the provider remains the authority on what actually gets stored
|
||||
* and expanded.
|
||||
*
|
||||
* The rules it mirrors, all from RFC 5545 (the spec the platform's recurrence
|
||||
* engine implements):
|
||||
* - a monthly or yearly rule **skips** a period the start day doesn't exist in,
|
||||
* rather than sliding to the month's last day: the 31st recurring monthly
|
||||
* lands only in 31-day months, 29 February yearly only in leap years.
|
||||
* - a weekly rule with weekday picks repeats in blocks of `interval` weeks,
|
||||
* with the week beginning on **Monday** — the RFC's default WKST, and the one
|
||||
* that applies here because [toRRule] never writes a WKST part. The
|
||||
* display-only "first day of week" preference deliberately doesn't enter into
|
||||
* it, or the preview would disagree with the stored rule.
|
||||
* - [RecurrenceEnd.Count] counts real occurrences (skipped periods don't
|
||||
* consume one), [RecurrenceEnd.Until] is inclusive of its date.
|
||||
*
|
||||
* Returns fewer than [limit] dates when the series ends first, and an empty
|
||||
* list when the rule yields nothing at all (an UNTIL before the start).
|
||||
*/
|
||||
fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List<LocalDate> {
|
||||
if (limit <= 0) return emptyList()
|
||||
val until = (end as? RecurrenceEnd.Until)?.date
|
||||
val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE
|
||||
val wanted = minOf(limit, maxCount)
|
||||
if (wanted <= 0) return emptyList()
|
||||
|
||||
val result = mutableListOf<LocalDate>()
|
||||
var period = 0
|
||||
// Periods that produce nothing (a skipped 31st, a weekday block whose picks
|
||||
// all fall before the start) must not stall the walk, so the cap is on
|
||||
// periods examined rather than on dates found.
|
||||
while (result.size < wanted && period < MAX_PERIODS) {
|
||||
for (date in occurrencesInPeriod(period, start)) {
|
||||
if (date < start) continue
|
||||
if (until != null && date > until) return result
|
||||
result += date
|
||||
if (result.size == wanted) return result
|
||||
}
|
||||
period++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** The dates this rule's [period]-th repetition yields (empty when skipped). */
|
||||
private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List<LocalDate> =
|
||||
when (freq) {
|
||||
RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY))
|
||||
RecurrenceFreq.Weekly -> weeklyOccurrences(period, start)
|
||||
RecurrenceFreq.Monthly -> {
|
||||
val month = start.plus(period * interval, DateTimeUnit.MONTH)
|
||||
// plus() clamps the day into the shorter month; the rule instead
|
||||
// skips such a period, so a clamped date means "not this month".
|
||||
listOfNotNull(dateOrNull(month.year, month.month.number, start.day))
|
||||
}
|
||||
RecurrenceFreq.Yearly ->
|
||||
listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day))
|
||||
}
|
||||
|
||||
/**
|
||||
* One weekly block: every picked weekday inside the week that begins
|
||||
* `period * interval` weeks after the start's own week, in weekday order. With
|
||||
* no picks the rule simply repeats the start's weekday.
|
||||
*/
|
||||
private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List<LocalDate> {
|
||||
if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK))
|
||||
val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) %
|
||||
DAYS_PER_WEEK
|
||||
val weekStart = start
|
||||
.minus(daysIntoWeek, DateTimeUnit.DAY)
|
||||
.plus(period * interval, DateTimeUnit.WEEK)
|
||||
return byDays.sortedBy { it.isoDayNumber }.map { day ->
|
||||
weekStart.plus(
|
||||
(day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a run of [occurrences] starting at [start] leaves its starting year —
|
||||
* i.e. whether showing them without a year would be ambiguous.
|
||||
*
|
||||
* Day and month alone are enough for a rule that stays inside one year, and
|
||||
* dropping the year keeps the line short. They are actively misleading
|
||||
* otherwise: a yearly rule reads as the same date three times over, and a
|
||||
* monthly one rolling past December gives no hint that it has.
|
||||
*/
|
||||
fun occurrencesSpanYears(occurrences: List<LocalDate>, start: LocalDate): Boolean =
|
||||
occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1
|
||||
|
||||
/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */
|
||||
private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? =
|
||||
runCatching { LocalDate(year, month, day) }.getOrNull()
|
||||
|
||||
private const val DAYS_PER_WEEK = 7
|
||||
|
||||
/**
|
||||
* How many repetitions to examine before giving up. Generous enough for the
|
||||
* sparsest rule the picker can build (29 February yearly with a 999-year
|
||||
* interval is nonsense; 31st monthly needs at most a handful), and bounded so a
|
||||
* rule whose occurrences all fall outside its own UNTIL can't spin.
|
||||
*/
|
||||
private const val MAX_PERIODS = 2_000
|
||||
@@ -0,0 +1,258 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Works out *when* each reminder has to fire and *which* ones are due, with no
|
||||
* provider and no clock of its own — the whole decision layer of in-house
|
||||
* reminder delivery (#75).
|
||||
*
|
||||
* Calendula used to leave both halves to the calendar provider: it scheduled the
|
||||
* alarms, wrote the `CalendarAlerts` rows, and broadcast `EVENT_REMINDER` at the
|
||||
* right moment. That chain is intact on stock Android but demonstrably not on
|
||||
* every device — AOSP's own unbundled calendar carries three separate
|
||||
* workarounds for OEMs that retarget the broadcast, or that only write the alert
|
||||
* row at alert time. An app that can only *react* to that broadcast has no way
|
||||
* to notice it never came.
|
||||
*
|
||||
* So the offsets in `CalendarContract.Reminders` are now read as data and turned
|
||||
* into alarms we own. Everything here is pure: instances and reminder offsets in,
|
||||
* fire instants out.
|
||||
*/
|
||||
|
||||
/** An occurrence that reminders can hang off, flattened out of `Instances`. */
|
||||
data class ReminderEventInstance(
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
val title: String,
|
||||
val location: String?,
|
||||
val isAllDay: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* One occurrence paired with one of its reminder offsets, and the instant that
|
||||
* pairing has to fire at.
|
||||
*/
|
||||
data class PlannedReminder(
|
||||
val instance: ReminderEventInstance,
|
||||
val minutes: Int,
|
||||
val alarmMillis: Long,
|
||||
) {
|
||||
/**
|
||||
* Stable identity of this reminder, derived from what defines it rather
|
||||
* than from a provider row id (there is none any more). It keys the
|
||||
* notification tag and the snooze/dismiss `PendingIntent`s, so it has to
|
||||
* survive a reboot, a re-scan and a reinstall — the same reminder must land
|
||||
* on the same notification instead of stacking a second one.
|
||||
*/
|
||||
val key: Long = key(instance.eventId, instance.beginMillis, minutes)
|
||||
|
||||
private companion object {
|
||||
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long {
|
||||
var h = eventId * 1_000_003L
|
||||
h = (h xor beginMillis) * 31L
|
||||
return h + minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
|
||||
data class ReminderSchedule(
|
||||
val due: List<PlannedReminder>,
|
||||
val nextAlarmMillis: Long,
|
||||
)
|
||||
|
||||
private const val MILLIS_PER_MINUTE = 60_000L
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
|
||||
/**
|
||||
* Pair every instance with each of its event's reminder offsets.
|
||||
*
|
||||
* A **timed** occurrence is trivial: `begin` is an absolute instant, so
|
||||
* `begin − minutes` is exact by construction, in any timezone, across any DST
|
||||
* boundary.
|
||||
*
|
||||
* An **all-day** occurrence is not, and taking the offset at face value is what
|
||||
* makes reminders land at the wrong hour. Its `begin` is UTC midnight, and the
|
||||
* stored offset is not a plain lead time — `AllDayReminderEncoding` folds the
|
||||
* wanted wall-clock hour into it, sampled against *one* date's UTC offset. Fire
|
||||
* at `begin − minutes` and every occurrence in a different DST phase than the one
|
||||
* that was sampled drifts by the offset delta, an hour early in one direction and
|
||||
* an hour late in the other. Rows written by other apps carry no wall-clock at
|
||||
* all — a conventional `1440` fires at UTC midnight, which is 01:00 or 02:00
|
||||
* local in Berlin and the wrong day west of UTC.
|
||||
*
|
||||
* So the offset is only read for *which day* it means, via [allDayLeadDays], and
|
||||
* the hour comes from [allDayTimeMinutes] — the one global "show all-day
|
||||
* reminders at" setting — recomposed against each occurrence's own date in
|
||||
* [zone]. 09:00 Berlin is then 09:00 Berlin on every occurrence, whatever the
|
||||
* offset was when the row was written.
|
||||
*
|
||||
* [minutesByEvent] may hold duplicate offsets (two identical reminder rows on one
|
||||
* event); they collapse, because they would otherwise fight over one notification.
|
||||
*/
|
||||
fun planReminders(
|
||||
instances: List<ReminderEventInstance>,
|
||||
minutesByEvent: Map<Long, List<Int>>,
|
||||
zone: ZoneId,
|
||||
allDayTimeMinutes: Int,
|
||||
): List<PlannedReminder> = instances.flatMap { instance ->
|
||||
minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
|
||||
PlannedReminder(
|
||||
instance = instance,
|
||||
minutes = minutes,
|
||||
alarmMillis = if (instance.isAllDay) {
|
||||
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
|
||||
} else {
|
||||
instance.beginMillis - minutes * MILLIS_PER_MINUTE
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
|
||||
private fun allDayDate(beginMillis: Long): LocalDate =
|
||||
Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
|
||||
/**
|
||||
* How many whole days before its occurrence a raw all-day offset means.
|
||||
*
|
||||
* Normally the local date of the encoded fire instant answers it: our own rows
|
||||
* fold the wanted hour into the offset, so that date *is* the day the reminder
|
||||
* belongs to, whatever the offset was when it was written.
|
||||
*
|
||||
* A plain multiple of 1440 is the exception. Those come from calendar apps that
|
||||
* store a bare lead time against UTC midnight, and east of UTC both readings
|
||||
* agree anyway — but west of UTC the instant falls on the previous local date,
|
||||
* so the day count has to be taken at face value or it comes out one too many.
|
||||
*
|
||||
* Except when the row is one of ours after all: our offset lands on a multiple
|
||||
* whenever the all-day hour equals the zone's UTC offset (20:00 in New York,
|
||||
* 19:00 an hour further west, and so on), and reading those at face value fired
|
||||
* them a day late — a "1 day before" arriving on the morning of the event. So
|
||||
* the hour decides, not the shape of the number: an instant landing on the hour
|
||||
* the setting names is ours. Within an hour or so of it counts, because a fixed
|
||||
* offset written in one DST phase drifts by the offset delta in the other.
|
||||
*
|
||||
* The two are genuinely indistinguishable in that band — the encodings collide
|
||||
* exactly there — so the tie goes to the reading that honours the lead time the
|
||||
* user chose in this app.
|
||||
*
|
||||
* [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes] calls
|
||||
* this for display, so the notification arrives on the day the event screen says
|
||||
* it will.
|
||||
*/
|
||||
internal fun allDayLeadDays(
|
||||
rawMinutes: Int,
|
||||
startDate: LocalDate,
|
||||
zone: ZoneId,
|
||||
allDayTimeMinutes: Int,
|
||||
): Long {
|
||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val encoded = Instant.ofEpochMilli(utcMidnight - rawMinutes * MILLIS_PER_MINUTE).atZone(zone)
|
||||
val minutesFromNamedHour = encoded.toLocalTime().let {
|
||||
val delta = (it.hour * 60 + it.minute - allDayTimeMinutes).mod(MINUTES_PER_DAY)
|
||||
minOf(delta, MINUTES_PER_DAY - delta)
|
||||
}
|
||||
if (rawMinutes % MINUTES_PER_DAY == 0 && minutesFromNamedHour > NAMED_HOUR_TOLERANCE_MINUTES) {
|
||||
return (rawMinutes / MINUTES_PER_DAY).toLong()
|
||||
}
|
||||
return ChronoUnit.DAYS.between(encoded.toLocalDate(), startDate)
|
||||
}
|
||||
|
||||
/** DST drift a row written in the other phase carries, rounded up past Lord Howe's half hour. */
|
||||
private const val NAMED_HOUR_TOLERANCE_MINUTES = 90
|
||||
|
||||
private fun allDayAlarmMillis(
|
||||
beginMillis: Long,
|
||||
rawMinutes: Int,
|
||||
zone: ZoneId,
|
||||
allDayTimeMinutes: Int,
|
||||
): Long = allDayDate(beginMillis)
|
||||
.minusDays(allDayLeadDays(rawMinutes, allDayDate(beginMillis), zone, allDayTimeMinutes))
|
||||
.atTime(LocalTime.of(allDayTimeMinutes / 60, allDayTimeMinutes % 60))
|
||||
.atZone(zone)
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
|
||||
/**
|
||||
* Split [planned] into what is due now and when to wake up next.
|
||||
*
|
||||
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]` — a
|
||||
* half-open watermark, so a scan triggered twice cannot post the same reminder
|
||||
* twice, while a scan that runs late still catches everything the missed alarm
|
||||
* would have posted. That catch-up is the point: an alarm dropped by a reboot,
|
||||
* an app update or a doze window is recovered by the next scan rather than lost.
|
||||
*
|
||||
* A reminder whose event has already ended is dropped rather than posted late —
|
||||
* see [isStillRelevant].
|
||||
*
|
||||
* [nextAlarmMillis] is capped at [horizonMillis] even when nothing is pending, so
|
||||
* the scan re-runs at least that often and the lookahead window rolls forward.
|
||||
*/
|
||||
fun scheduleReminders(
|
||||
planned: List<PlannedReminder>,
|
||||
lastFiredMillis: Long,
|
||||
nowMillis: Long,
|
||||
horizonMillis: Long,
|
||||
): ReminderSchedule {
|
||||
val due = planned
|
||||
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
|
||||
.filter { it.instance.isStillRelevant(nowMillis) }
|
||||
.distinctBy { it.key }
|
||||
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
|
||||
val nextPending = planned
|
||||
.filter { it.alarmMillis > nowMillis }
|
||||
.minOfOrNull { it.alarmMillis }
|
||||
return ReminderSchedule(
|
||||
due = due,
|
||||
nextAlarmMillis = minOf(nextPending ?: horizonMillis, horizonMillis),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Still worth showing while the occurrence has not ended. Falls back to the
|
||||
* begin time when the end is unknown (0L).
|
||||
*/
|
||||
fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
|
||||
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
||||
|
||||
/**
|
||||
* The watermark a scan at [nowMillis] should measure against, given what the
|
||||
* last one recorded.
|
||||
*
|
||||
* A first-ever scan ([lastScanMillis] `null`) claims the present, so an install
|
||||
* or an upgrade onto in-house delivery does not treat every reminder since the
|
||||
* epoch as overdue and bury the user in notifications. A watermark in the
|
||||
* *future* — the clock was moved back, or the user travelled across the date
|
||||
* line — is clamped for the mirror-image reason: left alone it would silence
|
||||
* every reminder until real time caught up with it.
|
||||
*/
|
||||
fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
|
||||
lastScanMillis?.coerceAtMost(nowMillis) ?: nowMillis
|
||||
|
||||
/**
|
||||
* How far ahead instances must be queried for [scheduleReminders] to see every
|
||||
* reminder in time: the plain lookahead plus the longest offset any reminder row
|
||||
* carries, so a "two weeks before" reminder is planned before it comes due
|
||||
* instead of firing late (the limitation Etar's equivalent documents).
|
||||
*
|
||||
* The stretch is capped at [MAX_REMINDER_LEAD_MILLIS]. `maxReminderMinutes` is
|
||||
* whatever the largest row in the whole provider says, across every calendar and
|
||||
* whoever wrote it — an imported `TRIGGER:-P100W`, or a sync adapter writing
|
||||
* nonsense, would otherwise make every scan (launch, every provider change, every
|
||||
* alarm) expand every recurring series over years. A lead beyond the cap is not
|
||||
* delivered; a year is far past anything the picker composes.
|
||||
*/
|
||||
fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long =
|
||||
lookaheadMillis + (maxReminderMinutes * MILLIS_PER_MINUTE).coerceIn(0L, MAX_REMINDER_LEAD_MILLIS)
|
||||
|
||||
/** Longest reminder offset a scan stretches its query window for — one year. */
|
||||
const val MAX_REMINDER_LEAD_MILLIS = 365L * 24 * 60 * 60 * 1000
|
||||
@@ -23,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
@@ -148,6 +149,11 @@ fun CalendarHost(
|
||||
// over Settings and survives view switches.
|
||||
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Backup & restore (#69) — like the manager, driven by the calendar list
|
||||
// rather than by preferences, so it is hoisted here instead of living as a
|
||||
// Settings sub-section. Reached from Settings and from the manager.
|
||||
var showBackup by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
||||
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
||||
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
||||
@@ -210,6 +216,7 @@ fun CalendarHost(
|
||||
fun dismissCoveringOverlays() {
|
||||
showSettings = false
|
||||
showCalendars = false
|
||||
showBackup = false
|
||||
detailKey = null
|
||||
editKey = null
|
||||
importUri = null
|
||||
@@ -293,8 +300,8 @@ fun CalendarHost(
|
||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||
// so back there falls through to the system and exits the app.
|
||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||
editKey != null || showSettings || showCalendars || importUri != null ||
|
||||
importForm != null
|
||||
editKey != null || showSettings || showCalendars || showBackup ||
|
||||
importUri != null || importForm != null
|
||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
||||
viewStack = viewStack.dropLast(1)
|
||||
}
|
||||
@@ -415,6 +422,7 @@ fun CalendarHost(
|
||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -434,6 +442,7 @@ fun CalendarHost(
|
||||
editKey = null
|
||||
detailKey = null
|
||||
},
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -447,18 +456,7 @@ fun CalendarHost(
|
||||
SettingsScreen(
|
||||
onBack = { showSettings = false },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Calendar manager — slides over Settings.
|
||||
AnimatedVisibility(
|
||||
visible = showCalendars,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
onImport = { importUri = it; importForceMany = true },
|
||||
onOpenBackup = { showBackup = true },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -469,6 +467,7 @@ fun CalendarHost(
|
||||
uri = uri,
|
||||
forceMany = importForceMany,
|
||||
onClose = { importUri = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onOpenSingle = { form ->
|
||||
importUri = null
|
||||
importFormSource = ImportSource.File
|
||||
@@ -483,6 +482,39 @@ fun CalendarHost(
|
||||
initialFormSource = importFormSource,
|
||||
onClose = { importForm = null },
|
||||
onSaved = { importForm = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Calendar manager — declared last so it covers every overlay that can
|
||||
// open it: Settings, both event forms, and the .ics import picker (#76).
|
||||
// Coming back from it leaves the caller exactly as it was, with the
|
||||
// calendar list already refreshed by the provider's notification.
|
||||
AnimatedVisibility(
|
||||
visible = showCalendars,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
onOpenBackup = { showBackup = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Backup & restore — over the manager, since the manager links into it.
|
||||
AnimatedVisibility(
|
||||
visible = showBackup,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
BackupScreen(
|
||||
onBack = { showBackup = false },
|
||||
// Restoring runs the normal .ics import, and that way round this
|
||||
// screen has to step aside: declared above the import overlays,
|
||||
// it would otherwise cover the screen it just asked for. Closing
|
||||
// it hands the user back to whatever opened Backup once the
|
||||
// import is done.
|
||||
onImport = { importUri = it; importForceMany = true; showBackup = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -21,6 +22,8 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
@@ -48,6 +51,10 @@ fun RootScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Whether the app came up already holding it — a launch scan has covered
|
||||
// that case, so only a grant made during this session owes a re-scan.
|
||||
val grantedAtLaunch = remember { hasPermission }
|
||||
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
@@ -78,6 +85,20 @@ fun RootScreen(
|
||||
// frame instead of flashing the wrong screen.
|
||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||
// One-time explainer for the switch to the device's own calendar
|
||||
// visibility (#75); armed by the reconciler, shown over the app.
|
||||
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
|
||||
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
|
||||
// Runs on entry however the permission was granted — including from
|
||||
// Android's app-settings screen, which only comes back through the
|
||||
// ON_RESUME check above. Cheap once there is nothing left to do.
|
||||
LaunchedEffect(Unit) {
|
||||
visibilityNotice.reconcile()
|
||||
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
|
||||
}
|
||||
if (onboardingDone == true && noticePending) {
|
||||
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
||||
}
|
||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||
when (done) {
|
||||
true -> CalendarHost(
|
||||
|
||||
@@ -93,6 +93,7 @@ fun AgendaScreen(
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -198,6 +199,7 @@ fun AgendaScreen(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
description = stringResource(R.string.agenda_range_override_hint),
|
||||
selected = successState?.range ?: AgendaRange.Month,
|
||||
weekStart = weekStart,
|
||||
onSelect = viewModel::setRangeOverride,
|
||||
onDismiss = { showRangePicker = false },
|
||||
)
|
||||
|
||||
@@ -2,41 +2,14 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The zone the event's calendar dates live in. Timed events are resolved in the
|
||||
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
|
||||
* resolving them anywhere but UTC shifts the boundaries — east of UTC that leaks
|
||||
* a one-day event onto its next day. Matches the Week view and detail card.
|
||||
*/
|
||||
private fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||
if (isAllDay) TimeZone.UTC else zone
|
||||
|
||||
/** The first calendar day this event occupies. */
|
||||
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||
start.toLocalDateTime(dateZone(zone)).date
|
||||
|
||||
/**
|
||||
* The last calendar day this event actually occupies. An event ending exactly at
|
||||
* midnight (all-day events end at the exclusive next-midnight) does not reach
|
||||
* into that boundary day, so resolve the instant just before [end].
|
||||
*/
|
||||
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||
}
|
||||
|
||||
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||
spanFirstDay(zone) != spanLastDay(zone)
|
||||
|
||||
/**
|
||||
* What an agenda row's time line should convey for an event on a given day —
|
||||
* the part of a multi-day span that [day] falls in. Pure and shared so the
|
||||
|
||||
@@ -48,8 +48,12 @@ class AgendaViewModel @Inject constructor(
|
||||
settingsPrefs.agendaShowRangeBar,
|
||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||
|
||||
// First day of the week, for the calendar-aligned "this week" range.
|
||||
private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||
/**
|
||||
* First day of the week, for the calendar-aligned "this week" range. Public
|
||||
* because the range picker resolves each option to real dates, which needs
|
||||
* the same week start the window is built from.
|
||||
*/
|
||||
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||
|
||||
/**
|
||||
* How to treat events that already ended today (show / dim / hide). A display
|
||||
@@ -90,7 +94,7 @@ class AgendaViewModel @Inject constructor(
|
||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||
|
||||
val state: StateFlow<AgendaUiState> =
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
|
||||
AgendaParams(
|
||||
anchor = anchor,
|
||||
range = override ?: settings.range,
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import android.net.Uri
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import java.time.LocalDate
|
||||
|
||||
// SAF mime filter for the restore picker. `.ics` files reach us under several
|
||||
// mimes depending on the source app (our own export uses text/calendar; others
|
||||
// hand them out as octet-stream or text/plain), so accept the common set rather
|
||||
// than hide valid backups behind an over-tight filter.
|
||||
private val RESTORE_MIME_TYPES = arrayOf(
|
||||
"text/calendar",
|
||||
"application/octet-stream",
|
||||
"text/plain",
|
||||
)
|
||||
|
||||
/**
|
||||
* Backup & restore (#69). Local calendars aren't synced anywhere, so a `.ics`
|
||||
* export is their only safety net — that made it a data-safety feature hiding in
|
||||
* the calendar manager, where nobody went looking. It now has its own Settings
|
||||
* entry, sharing [CalendarsViewModel] with the manager (both are driven by the
|
||||
* same calendar list).
|
||||
*
|
||||
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
|
||||
* [onImport] hands a picked file to the app's normal .ics import flow.
|
||||
*/
|
||||
@Composable
|
||||
fun BackupScreen(
|
||||
onBack: () -> Unit,
|
||||
onImport: (Uri) -> Unit,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Export covers the user's own local calendars (managed special-dates
|
||||
// mirrors don't count — they are rebuilt from contacts). Restore can target
|
||||
// any calendar the import picker would offer, so its availability is broader.
|
||||
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||
val canImport = calendars.any { it.isEventTarget }
|
||||
|
||||
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||
// to the VM to stream the .ics into. This launcher exports everything
|
||||
// eligible (null); the per-calendar selector owns its own launcher.
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
|
||||
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// SAF "open document" picker for restoring events from a .ics file. The
|
||||
// picked Uri is handed up to the host, which runs it through the same import
|
||||
// flow as an externally opened .ics (parse, dedup by UID, target picker).
|
||||
val openBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri -> uri?.let(onImport) }
|
||||
|
||||
// SAF folder picker for the automatic-backup destination; the VM persists the
|
||||
// write grant so background runs can keep writing to it.
|
||||
val pickFolder = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
|
||||
var showInterval by remember { mutableStateOf(false) }
|
||||
|
||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||
LaunchedEffect(backupResult) {
|
||||
when (val r = backupResult) {
|
||||
is BackupResult.Success -> {
|
||||
snackbarHostState.showSnackbar(
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||
),
|
||||
)
|
||||
viewModel.consumeBackupResult()
|
||||
}
|
||||
BackupResult.Failure -> {
|
||||
snackbarHostState.showSnackbar(backupFailedText)
|
||||
viewModel.consumeBackupResult()
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_backup),
|
||||
onBack = onBack,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
|
||||
if (exportable.isNotEmpty()) {
|
||||
// One connected card: the one-time export on top, restore under it,
|
||||
// then automatic backup (and its folder/interval rows when on).
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
// With more than one exportable calendar, let the user choose
|
||||
// which to include; a single one exports straight away.
|
||||
if (exportable.size == 1) {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
} else {
|
||||
showExportPicker = true
|
||||
}
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
summary = stringResource(R.string.calendars_restore_hint),
|
||||
position = Position.Middle,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup),
|
||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||
trailing = {
|
||||
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
||||
},
|
||||
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
||||
)
|
||||
if (autoBackup.enabled) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||
summary = rememberFolderName(autoBackup.folderUri)
|
||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||
position = Position.Middle,
|
||||
onClick = { runCatching { pickFolder.launch(null) } },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { showInterval = true },
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
}
|
||||
} else if (canImport) {
|
||||
// Nothing to back up (no writable local calendar), but events can
|
||||
// still be restored into a writable calendar — offer restore on its
|
||||
// own so it isn't hidden behind export eligibility.
|
||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||
HintText(stringResource(R.string.calendars_restore_hint))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showExportPicker) {
|
||||
ExportCalendarPicker(
|
||||
calendars = exportable,
|
||||
onExport = viewModel::exportBackup,
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
}
|
||||
if (showInterval) {
|
||||
BackupIntervalDialog(
|
||||
currentMinutes = autoBackup.intervalMinutes,
|
||||
onConfirm = viewModel::setAutoBackupIntervalMinutes,
|
||||
onDismiss = { showInterval = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||
* the picked file with the chosen calendar ids.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExportCalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
onExport: (Uri, Set<Long>?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Seed once with everything selected and hold it across recomposition and
|
||||
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
|
||||
// it would silently reset the user's de-selections whenever the provider
|
||||
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
|
||||
// — the data layer intersects the chosen set with the eligible calendars.
|
||||
var selected by rememberSaveable(
|
||||
stateSaver = listSaver(
|
||||
save = { it.toList() },
|
||||
restore = { it.toSet() },
|
||||
),
|
||||
) {
|
||||
mutableStateOf(calendars.map { it.id }.toSet())
|
||||
}
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
onExport(uri, selected)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_export_title),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
HintText(stringResource(R.string.calendars_export_hint))
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id in selected
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, calendars.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { checked ->
|
||||
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||
},
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
},
|
||||
enabled = selected.isNotEmpty(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.calendars_export_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||
@Composable
|
||||
private fun rememberFolderName(uriString: String?): String? {
|
||||
val context = LocalContext.current
|
||||
return remember(uriString) {
|
||||
uriString?.let {
|
||||
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||
@Composable
|
||||
private fun backupIntervalLabel(minutes: Long): String {
|
||||
val duration = when {
|
||||
minutes % MINUTES_PER_WEEK == 0L ->
|
||||
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||
minutes % MINUTES_PER_DAY == 0L ->
|
||||
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||
minutes % 60L == 0L ->
|
||||
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||
else ->
|
||||
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||
}
|
||||
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||
}
|
||||
|
||||
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||
@Composable
|
||||
private fun backupStatusText(status: BackupStatus): String {
|
||||
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||
val relative = DateUtils.getRelativeTimeSpanString(
|
||||
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
return if (status.lastSuccess) {
|
||||
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||
} else {
|
||||
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||
}
|
||||
}
|
||||
|
||||
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||
@Composable
|
||||
private fun BackupIntervalDialog(
|
||||
currentMinutes: Long,
|
||||
onConfirm: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
|
||||
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||
onDismiss()
|
||||
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440L
|
||||
private const val MINUTES_PER_WEEK = 10_080L
|
||||
@@ -0,0 +1,76 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The one-time notice that Calendula now follows the device's per-calendar
|
||||
* visibility (#75). Armed by `CalendarVisibilityReconciler` on the first launch
|
||||
* that finds a calendar switched off outside the app — those used to show their
|
||||
* events here and no longer do, and the app deliberately does not switch them
|
||||
* back on, because that would un-hide them in every other calendar app too.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CalendarVisibilityNoticeViewModel @Inject constructor(
|
||||
private val prefs: CalendarPrefs,
|
||||
private val reconciler: CalendarVisibilityReconciler,
|
||||
) : ViewModel() {
|
||||
|
||||
/**
|
||||
* Reconcile whenever the app comes up with the calendar permission held.
|
||||
* The launch itself is covered by `CalendulaApp`, but a permission granted
|
||||
* on Android's app-settings screen comes back through `RootScreen`'s
|
||||
* ON_RESUME and never touches the permission screen's callback — so the
|
||||
* trigger hangs off "we are showing the app", not off one grant route.
|
||||
* Settled runs cost two DataStore reads and stop there.
|
||||
*/
|
||||
fun reconcile() {
|
||||
viewModelScope.launch { reconciler.run() }
|
||||
}
|
||||
|
||||
val pending: StateFlow<Boolean> = prefs.visibilityNoticePending
|
||||
.map { it == true }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
fun dismiss() {
|
||||
viewModelScope.launch { prefs.setVisibilityNoticePending(false) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain informational dialog — one acknowledgement, nothing to decide. */
|
||||
@Composable
|
||||
fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) },
|
||||
title = { Text(stringResource(R.string.calendars_visibility_notice_title)) },
|
||||
text = { Text(stringResource(R.string.calendars_visibility_notice_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_ok))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -4,9 +4,6 @@ import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -26,25 +23,22 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Backup
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -66,83 +60,71 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarStateLabel
|
||||
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.domain.isNotSynced
|
||||
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
||||
import de.jeanlucmakiola.calendula.domain.stateLabels
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.AccountKey
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import java.time.LocalDate
|
||||
|
||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||
|
||||
// SAF mime filter for the restore picker. `.ics` files reach us under several
|
||||
// mimes depending on the source app (our own export uses text/calendar; others
|
||||
// hand them out as octet-stream or text/plain), so accept the common set rather
|
||||
// than hide valid backups behind an over-tight filter.
|
||||
private val RESTORE_MIME_TYPES = arrayOf(
|
||||
"text/calendar",
|
||||
"application/octet-stream",
|
||||
"text/plain",
|
||||
)
|
||||
|
||||
/**
|
||||
* Calendar manager (reached from Settings). Lists the app's own device-only
|
||||
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
||||
* and lists synced calendars read-only with a per-account "manage in the source
|
||||
* app" deep-link — the app never touches a synced calendar's server. A
|
||||
* full-screen destination; [onBack] pops it.
|
||||
* app" deep-link — the app never touches a synced calendar's server.
|
||||
*
|
||||
* Export/import lives in its own Settings entry ([BackupScreen], #69); this
|
||||
* screen only points at it, because the two are looked for separately: "which
|
||||
* calendars do I have" versus "keep a copy of them". A full-screen destination;
|
||||
* [onBack] pops it.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarsScreen(
|
||||
onBack: () -> Unit,
|
||||
onImport: (android.net.Uri) -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
|
||||
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||
|
||||
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||
// [editorSession] bumps on every open so the editor's field state resets for
|
||||
@@ -158,6 +140,7 @@ fun CalendarsScreen(
|
||||
initialName = editing?.displayName.orEmpty(),
|
||||
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||
initialDescription = editing?.description.orEmpty(),
|
||||
deleteLocked = editing != null && editing.id in deleteLockedIds,
|
||||
onSave = { name, color, description ->
|
||||
val id = editorId
|
||||
if (id == null || id == NEW_CALENDAR_ID) {
|
||||
@@ -177,22 +160,14 @@ fun CalendarsScreen(
|
||||
CalendarsList(
|
||||
local = calendars.filter { it.isLocal },
|
||||
synced = calendars.filterNot { it.isLocal },
|
||||
disabledIds = disabledIds,
|
||||
error = error,
|
||||
onConsumeError = viewModel::consumeError,
|
||||
backupResult = backupResult,
|
||||
onExportBackup = viewModel::exportBackup,
|
||||
onImport = onImport,
|
||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||
autoBackup = autoBackup,
|
||||
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
||||
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
|
||||
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
|
||||
onOpenBackup = onOpenBackup,
|
||||
onBack = onBack,
|
||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||
onSetDisabled = viewModel::setDisabled,
|
||||
onSetAccountDisabled = viewModel::setAccountDisabled,
|
||||
onSetVisible = viewModel::setCalendarVisible,
|
||||
onSetAccountVisible = viewModel::setAccountVisible,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -201,28 +176,20 @@ fun CalendarsScreen(
|
||||
private fun CalendarsList(
|
||||
local: List<CalendarSource>,
|
||||
synced: List<CalendarSource>,
|
||||
disabledIds: Set<Long>,
|
||||
error: Boolean,
|
||||
onConsumeError: () -> Unit,
|
||||
backupResult: BackupResult?,
|
||||
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
|
||||
onImport: (android.net.Uri) -> Unit,
|
||||
onConsumeBackupResult: () -> Unit,
|
||||
autoBackup: AutoBackupUiState,
|
||||
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
||||
onSetAutoBackupInterval: (Long) -> Unit,
|
||||
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onEdit: (CalendarSource) -> Unit,
|
||||
onSetDisabled: (Long, Boolean) -> Unit,
|
||||
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit,
|
||||
onSetVisible: (Long, Boolean) -> Unit,
|
||||
onSetAccountVisible: (Collection<Long>, Boolean) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
// Accounts the user has folded shut; empty = all expanded (keeps every
|
||||
// calendar visible by default, the section is collapsible for tidiness).
|
||||
var collapsedAccounts by remember { mutableStateOf(emptySet<String>()) }
|
||||
var collapsedAccounts by remember { mutableStateOf(emptySet<AccountKey>()) }
|
||||
var localExpanded by remember { mutableStateOf(true) }
|
||||
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||
|
||||
@@ -233,47 +200,6 @@ private fun CalendarsList(
|
||||
}
|
||||
}
|
||||
|
||||
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||
// to the VM to stream the .ics into. This launcher exports everything
|
||||
// eligible (null); the per-calendar selector owns its own launcher.
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let { onExportBackup(it, null) } }
|
||||
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// SAF "open document" picker for restoring events from a .ics file. The
|
||||
// picked Uri is handed up to the host, which runs it through the same import
|
||||
// flow as an externally opened .ics (parse, dedup by UID, target picker).
|
||||
val openBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri -> uri?.let(onImport) }
|
||||
|
||||
// SAF folder picker for the automatic-backup destination; the VM persists the
|
||||
// write grant so background runs can keep writing to it.
|
||||
val pickFolder = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri -> uri?.let(onSetAutoBackupFolder) }
|
||||
var showInterval by remember { mutableStateOf(false) }
|
||||
|
||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||
LaunchedEffect(backupResult) {
|
||||
when (val r = backupResult) {
|
||||
is BackupResult.Success -> {
|
||||
snackbarHostState.showSnackbar(
|
||||
context.resources.getQuantityString(
|
||||
R.plurals.calendars_backup_done, r.eventCount, r.eventCount,
|
||||
),
|
||||
)
|
||||
onConsumeBackupResult()
|
||||
}
|
||||
BackupResult.Failure -> {
|
||||
snackbarHostState.showSnackbar(backupFailedText)
|
||||
onConsumeBackupResult()
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.calendars_title),
|
||||
onBack = onBack,
|
||||
@@ -281,12 +207,12 @@ private fun CalendarsList(
|
||||
predictiveBack = true,
|
||||
) {
|
||||
// What the per-calendar / per-account switches below actually do.
|
||||
HintText(stringResource(R.string.calendars_disable_hint))
|
||||
HintText(stringResource(R.string.calendars_visibility_hint))
|
||||
|
||||
// Local (device-only) calendars — one collapsible group. The header's
|
||||
// "+" adds a calendar; the switch enables/disables them all at once;
|
||||
// tapping a calendar row opens its editor.
|
||||
val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds }
|
||||
val localDisabled = local.isNotEmpty() && local.none { it.isVisibleInSystem }
|
||||
CalendarGroup(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
expanded = localExpanded,
|
||||
@@ -298,17 +224,17 @@ private fun CalendarsList(
|
||||
onManage = onAdd,
|
||||
onToggleExpand = { localExpanded = !localExpanded },
|
||||
showToggleAll = local.isNotEmpty(),
|
||||
allEnabled = local.none { it.id in disabledIds },
|
||||
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) },
|
||||
allEnabled = local.all { it.isVisibleInSystem },
|
||||
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
|
||||
) {
|
||||
if (local.isEmpty()) {
|
||||
HintText(stringResource(R.string.calendars_local_empty))
|
||||
} else {
|
||||
local.forEachIndexed { index, calendar ->
|
||||
val disabled = calendar.id in disabledIds
|
||||
val disabled = !calendar.isVisibleInSystem
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
@@ -317,7 +243,7 @@ private fun CalendarsList(
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||
)
|
||||
},
|
||||
onClick = { onEdit(calendar) },
|
||||
@@ -326,82 +252,24 @@ private fun CalendarsList(
|
||||
}
|
||||
}
|
||||
|
||||
// Backup — local calendars have no sync, so a .ics export is their only
|
||||
// safety net. Offered only when there is something exportable: the user's
|
||||
// own local calendars (managed special-dates mirrors don't count).
|
||||
val exportable = local.filter { it.canModifyContents && !it.isManaged }
|
||||
// Restore/import can target any writable, non-managed calendar (local or
|
||||
// synced), so its availability is broader than export's.
|
||||
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
|
||||
if (exportable.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
// One connected card: the one-time export on top, then automatic
|
||||
// backup (and its folder/interval rows when on).
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
// With more than one exportable calendar, let the user choose
|
||||
// which to include; a single one exports straight away.
|
||||
if (exportable.size == 1) {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
} else {
|
||||
showExportPicker = true
|
||||
}
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
summary = stringResource(R.string.calendars_restore_hint),
|
||||
position = Position.Middle,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = {
|
||||
runCatching { openBackup.launch(RESTORE_MIME_TYPES) }
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup),
|
||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||
trailing = {
|
||||
Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled)
|
||||
},
|
||||
onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) },
|
||||
)
|
||||
if (autoBackup.enabled) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||
summary = rememberFolderName(autoBackup.folderUri)
|
||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||
position = Position.Middle,
|
||||
onClick = { runCatching { pickFolder.launch(null) } },
|
||||
// Backup lives in its own Settings entry now (#69) — this row is the
|
||||
// pointer, so someone looking at their local calendars still finds the
|
||||
// way to keep a copy of them.
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_backup),
|
||||
summary = stringResource(R.string.settings_backup_subtitle),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.Backup) },
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { showInterval = true },
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
}
|
||||
} else if (canImport) {
|
||||
// Nothing to back up (no writable local calendar), but events can
|
||||
// still be restored into a writable calendar — offer restore on its
|
||||
// own so it isn't hidden behind export eligibility.
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||
HintText(stringResource(R.string.calendars_restore_hint))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = onOpenBackup,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
@@ -411,14 +279,19 @@ private fun CalendarsList(
|
||||
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||
HintText(stringResource(R.string.calendars_synced_hint))
|
||||
synced
|
||||
.groupBy { it.accountName.ifBlank { it.accountType } }
|
||||
.forEach { (account, cals) ->
|
||||
val expanded = account !in collapsedAccounts
|
||||
val accountType = cals.first().accountType
|
||||
val accountDisabled = cals.all { it.id in disabledIds }
|
||||
.groupByAccount()
|
||||
.forEach { group ->
|
||||
val cals = group.calendars
|
||||
val expanded = group.key !in collapsedAccounts
|
||||
val accountType = group.accountType
|
||||
// A non-syncing calendar has no switch, so it neither counts
|
||||
// towards "the whole account is off" nor moves with toggle-all.
|
||||
val switchable = cals.filter { it.hasVisibilitySwitch }
|
||||
val accountDisabled = switchable.isNotEmpty() &&
|
||||
switchable.none { it.isVisibleInSystem }
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CalendarGroup(
|
||||
title = account,
|
||||
title = accountGroupTitle(group),
|
||||
expanded = expanded,
|
||||
bodyHasRows = true,
|
||||
headerDisabled = accountDisabled,
|
||||
@@ -430,29 +303,41 @@ private fun CalendarsList(
|
||||
},
|
||||
onToggleExpand = {
|
||||
collapsedAccounts = if (expanded) {
|
||||
collapsedAccounts + account
|
||||
collapsedAccounts + group.key
|
||||
} else {
|
||||
collapsedAccounts - account
|
||||
collapsedAccounts - group.key
|
||||
}
|
||||
},
|
||||
showToggleAll = true,
|
||||
allEnabled = cals.none { it.id in disabledIds },
|
||||
onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) },
|
||||
showToggleAll = switchable.isNotEmpty(),
|
||||
allEnabled = switchable.all { it.isVisibleInSystem },
|
||||
onToggleAll = { enabled ->
|
||||
onSetAccountVisible(switchable.map { it.id }, enabled)
|
||||
},
|
||||
) {
|
||||
cals.forEachIndexed { index, calendar ->
|
||||
val disabled = calendar.id in disabledIds
|
||||
// Calendars you can act on first; the ones this device isn't
|
||||
// syncing sit at the bottom, dimmed and switchless.
|
||||
val ordered = cals.orderedForManager()
|
||||
ordered.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||
)
|
||||
trailing = if (calendar.hasVisibilitySwitch) {
|
||||
{
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = calendar.isVisibleInSystem,
|
||||
onToggle = { enabled ->
|
||||
onSetVisible(calendar.id, enabled)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -460,93 +345,6 @@ private fun CalendarsList(
|
||||
}
|
||||
}
|
||||
|
||||
if (showInterval) {
|
||||
BackupIntervalDialog(
|
||||
currentMinutes = autoBackup.intervalMinutes,
|
||||
onConfirm = onSetAutoBackupInterval,
|
||||
onDismiss = { showInterval = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showExportPicker) {
|
||||
ExportCalendarPicker(
|
||||
calendars = local.filter { it.canModifyContents && !it.isManaged },
|
||||
onExport = onExportBackup,
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||
* the picked file with the chosen calendar ids.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExportCalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
onExport: (android.net.Uri, Set<Long>?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Seed once with everything selected and hold it across recomposition and
|
||||
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
|
||||
// it would silently reset the user's de-selections whenever the provider
|
||||
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
|
||||
// — the data layer intersects the chosen set with the eligible calendars.
|
||||
var selected by rememberSaveable(
|
||||
stateSaver = listSaver(
|
||||
save = { it.toList() },
|
||||
restore = { it.toSet() },
|
||||
),
|
||||
) {
|
||||
mutableStateOf(calendars.map { it.id }.toSet())
|
||||
}
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
onExport(uri, selected)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_export_title),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
HintText(stringResource(R.string.calendars_export_hint))
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id in selected
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, calendars.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { checked ->
|
||||
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||
},
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
},
|
||||
enabled = selected.isNotEmpty(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.calendars_export_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -560,6 +358,7 @@ private fun CalendarEditor(
|
||||
onSave: (name: String, color: Int, description: String?) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
deleteLocked: Boolean = false,
|
||||
) {
|
||||
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||
@@ -593,11 +392,23 @@ private fun CalendarEditor(
|
||||
},
|
||||
actions = {
|
||||
if (!isNew) {
|
||||
IconButton(onClick = { confirmDelete = true }) {
|
||||
// Kept in place while the special-dates sync owns this
|
||||
// calendar, rather than hidden: the button is where you
|
||||
// expect it, disabled, with the card below saying why —
|
||||
// and it comes back to life the moment the feature is
|
||||
// off, when the delete would actually stick.
|
||||
IconButton(
|
||||
onClick = { confirmDelete = true },
|
||||
enabled = !deleteLocked,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.event_detail_delete),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
tint = if (deleteLocked) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -626,6 +437,19 @@ private fun CalendarEditor(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (deleteLocked) {
|
||||
EditorCard(
|
||||
icon = Icons.Default.Info,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
iconAtTop = true,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_managed_delete_locked),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
||||
InlineTextField(
|
||||
value = name,
|
||||
@@ -698,10 +522,33 @@ private fun CalendarEditor(
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-row enable/disable control. Checked = the calendar is shown in the
|
||||
* app; unchecking disables it (events, filters and pickers all drop it) without
|
||||
* touching any provider data. Carries its own content description so the toggle
|
||||
* is self-describing to screen readers even on a dimmed row.
|
||||
* The row's supporting line: the states that make this calendar behave unlike a
|
||||
* plain writable one (#76), then its own description. Text rather than badges —
|
||||
* a row can carry several of these at once next to a switch, which is exactly
|
||||
* what M3 supporting text composes and a row of static chips doesn't.
|
||||
*/
|
||||
@Composable
|
||||
private fun calendarRowSummary(calendar: CalendarSource): String? {
|
||||
val states = calendar.stateLabels().map { label ->
|
||||
stringResource(
|
||||
when (label) {
|
||||
CalendarStateLabel.MANAGED -> R.string.calendars_state_managed
|
||||
CalendarStateLabel.READ_ONLY -> R.string.calendars_state_read_only
|
||||
CalendarStateLabel.NOT_SYNCED -> R.string.calendars_state_not_synced
|
||||
},
|
||||
)
|
||||
}
|
||||
val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() })
|
||||
return parts.joinToString(" · ").ifEmpty { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-row on/off control, writing the system's `Calendars.VISIBLE`: checked
|
||||
* = the calendar is shown, unchecked = it drops out of every surface (events,
|
||||
* filters, pickers) and the provider stops scheduling its reminders. The flag is
|
||||
* device-local — nothing is deleted and nothing is synced anywhere. Carries its
|
||||
* own content description so the toggle is self-describing to screen readers
|
||||
* even on a dimmed row.
|
||||
*/
|
||||
@Composable
|
||||
private fun EnableSwitch(
|
||||
@@ -709,7 +556,7 @@ private fun EnableSwitch(
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName)
|
||||
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
@@ -883,113 +730,31 @@ private fun CalendarGroupMenu(
|
||||
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
internal fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
// The cards' own edge, so header and group share a left margin.
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HintText(text: String) {
|
||||
internal fun HintText(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||
@Composable
|
||||
private fun rememberFolderName(uriString: String?): String? {
|
||||
val context = LocalContext.current
|
||||
return remember(uriString) {
|
||||
uriString?.let {
|
||||
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||
@Composable
|
||||
private fun backupIntervalLabel(minutes: Long): String {
|
||||
val duration = when {
|
||||
minutes % MINUTES_PER_WEEK == 0L ->
|
||||
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||
minutes % MINUTES_PER_DAY == 0L ->
|
||||
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||
minutes % 60L == 0L ->
|
||||
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||
else ->
|
||||
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||
}
|
||||
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||
}
|
||||
|
||||
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||
@Composable
|
||||
private fun backupStatusText(status: BackupStatus): String {
|
||||
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||
val relative = DateUtils.getRelativeTimeSpanString(
|
||||
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
return if (status.lastSuccess) {
|
||||
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||
} else {
|
||||
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||
}
|
||||
}
|
||||
|
||||
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||
@Composable
|
||||
private fun BackupIntervalDialog(
|
||||
currentMinutes: Long,
|
||||
onConfirm: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
|
||||
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||
onDismiss()
|
||||
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440L
|
||||
private const val MINUTES_PER_WEEK = 10_080L
|
||||
|
||||
/**
|
||||
* Pick the app to open for managing a synced calendar's account. The account's
|
||||
|
||||
@@ -12,10 +12,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
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.ics.IcsWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -45,10 +42,7 @@ class CalendarsViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
private val prefs: CalendarPrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val suppressedStore: SuppressedReminderStore,
|
||||
private val notifier: ReminderNotifier,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -62,20 +56,6 @@ class CalendarsViewModel @Inject constructor(
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Calendars the user has disabled in the app. This screen is the only
|
||||
* surface that lists them, so it both reads the set (to dim the rows) and
|
||||
* toggles it. Every other surface simply excludes these ids.
|
||||
*/
|
||||
val disabledCalendarIds: StateFlow<Set<Long>> =
|
||||
prefs.disabledCalendarIds
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
@@ -92,6 +72,33 @@ class CalendarsViewModel @Inject constructor(
|
||||
initialValue = AutoBackupUiState(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Managed special-dates calendars whose deletion would not stick. While the
|
||||
* feature is on, the sync owns every mirror: it recreates a missing one for
|
||||
* an enabled type on the next pass and deletes the leftover of a disabled
|
||||
* one (`SpecialDatesSyncEngine.reconcileCalendars`), so either way the
|
||||
* delete would appear to work and then undo itself. Turning special dates
|
||||
* off empties this set, and deleting a leftover mirror is a real delete from
|
||||
* then on.
|
||||
*
|
||||
* Read off each calendar's own durable marker ([CalendarSource.isManaged],
|
||||
* the `CAL_SYNC2` one the editor lock already trusts) rather than the stored
|
||||
* ids, which are only rewritten on the next sync pass — a preferences loss
|
||||
* would otherwise unlock a live mirror until then.
|
||||
*/
|
||||
val deleteLockedCalendarIds: StateFlow<Set<Long>> = combine(
|
||||
calendars,
|
||||
settingsPrefs.specialDatesEnabled,
|
||||
) { sources, enabled ->
|
||||
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
private val _error = MutableStateFlow(false)
|
||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||
|
||||
@@ -140,54 +147,30 @@ class CalendarsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable a calendar app-side. Disabling removes it from every
|
||||
* surface but Settings → Calendars (and hides its events) without touching
|
||||
* provider data — purely a reversible Calendula-local view choice.
|
||||
* Switch a calendar on or off. This is the app's one visibility model: it
|
||||
* writes the system's `Calendars.VISIBLE`, so the calendar disappears from
|
||||
* every surface *and* the provider stops (or resumes) scheduling its
|
||||
* reminders. Nothing is patched by hand — the provider notifies and the
|
||||
* observer re-queries.
|
||||
*
|
||||
* A reminder that came due while the calendar was off stays gone when it is
|
||||
* switched back on. Delivery plans from `Instances` and `Reminders` on every
|
||||
* scan (#75), but the watermark has already moved past that moment, so no
|
||||
* scan returns it again — and on a read-only install the switch never
|
||||
* reaches the provider at all, so nothing even triggers one. Deliberate: the
|
||||
* reminder was silenced on purpose, and the event itself is back in view.
|
||||
*/
|
||||
fun setDisabled(id: Long, disabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + id else current - id
|
||||
if (next != current) {
|
||||
prefs.setDisabledCalendarIds(next)
|
||||
if (!disabled) recoverReminders(setOf(id))
|
||||
}
|
||||
}
|
||||
fun setCalendarVisible(id: Long, visible: Boolean) = write {
|
||||
repository.setCalendarsVisible(listOf(id), visible)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable every calendar of one account in a single write — the
|
||||
* "toggle all" affordance on an account header. Done as one set update so the
|
||||
* per-calendar [setDisabled] calls can't race each other.
|
||||
* Switch every calendar of one account on or off — the "toggle all"
|
||||
* affordance on an account header. Each row is written on its own, in one
|
||||
* coroutine so the writes can't race each other.
|
||||
*/
|
||||
fun setAccountDisabled(ids: Collection<Long>, disabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + ids else current - ids.toSet()
|
||||
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) }
|
||||
}
|
||||
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
|
||||
repository.setCalendarsVisible(ids, visible)
|
||||
}
|
||||
|
||||
// --- Automatic backup (issue #8) ------------------------------------
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
|
||||
/**
|
||||
* One account's calendars, as every surface that lists calendars by account
|
||||
* shows them.
|
||||
*
|
||||
* An account is identified by name **and** type (#77). A Google account and a
|
||||
* DAVx5 account can carry the same address and still be two separate accounts,
|
||||
* from separate apps: merging them mixed their calendars into one group whose
|
||||
* header — source logo, "manage in app", toggle-all, collapsed state — was
|
||||
* derived from whichever calendar happened to sort first.
|
||||
*/
|
||||
data class CalendarAccountGroup(
|
||||
/** Stable identity: what makes two calendars belong to the same account. */
|
||||
val key: AccountKey,
|
||||
/** The account's own name, as shown when it is unambiguous. */
|
||||
val label: String,
|
||||
/** True when another group shows the same [label] under a different type. */
|
||||
val ambiguous: Boolean,
|
||||
val calendars: List<CalendarSource>,
|
||||
) {
|
||||
val accountType: String get() = key.type
|
||||
}
|
||||
|
||||
/** The pair a group is keyed on. */
|
||||
data class AccountKey(val name: String, val type: String)
|
||||
|
||||
/**
|
||||
* Group [calendars] under their owning account, preserving the provider's order
|
||||
* within each group and ordering groups by first appearance.
|
||||
*
|
||||
* The label falls back through name → type → the first calendar's own name, so
|
||||
* a calendar with no account still lands somewhere sensible.
|
||||
*/
|
||||
fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
||||
val grouped = groupBy { AccountKey(it.accountName, it.accountType) }
|
||||
val labels = grouped.mapValues { (key, cals) ->
|
||||
key.name.ifBlank { key.type }.ifBlank { cals.first().displayName }
|
||||
}
|
||||
val shared = labels.values.groupingBy { it }.eachCount()
|
||||
return grouped.map { (key, cals) ->
|
||||
val label = labels.getValue(key)
|
||||
CalendarAccountGroup(
|
||||
key = key,
|
||||
label = label,
|
||||
ambiguous = shared.getValue(label) > 1,
|
||||
calendars = cals,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to write above a group: its account name, qualified with the app the
|
||||
* account comes from when another account shares the name (#77).
|
||||
*/
|
||||
@Composable
|
||||
fun accountGroupTitle(group: CalendarAccountGroup): String =
|
||||
if (!group.ambiguous) {
|
||||
group.label
|
||||
} else {
|
||||
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
|
||||
}
|
||||
|
||||
/**
|
||||
* The human name of the app backing [accountType] — the same app whose icon
|
||||
* [SourceLogo] draws. Falls back to the raw account type, which is at least
|
||||
* unique, when no installed app resolves for it.
|
||||
*/
|
||||
@Composable
|
||||
fun sourceAppName(accountType: String): String {
|
||||
val context = LocalContext.current
|
||||
return remember(accountType) {
|
||||
val pm = context.packageManager
|
||||
val packages = sourceAppPackages(context, accountType)
|
||||
packages.firstNotNullOfOrNull { pkg ->
|
||||
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||
} ?: accountType
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -42,18 +44,23 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
* account — with the calendars beneath it as a connected card, a colour chip on
|
||||
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
||||
* (a scrolling column), so the caller owns the surrounding chrome.
|
||||
*
|
||||
* The list holds event *targets* only, so a calendar that is switched off,
|
||||
* read-only or managed is silently absent — which reads as a missing calendar
|
||||
* rather than an excluded one (#76). [onManageCalendars], when given, adds the
|
||||
* footer row that names the possible reasons and opens the calendar manager,
|
||||
* where each row then says which one applies.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.CalendarPickerGroups(
|
||||
calendars: List<CalendarSource>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
) {
|
||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||
val syncedGroups = remember(calendars) {
|
||||
calendars.filterNot { it.isLocal }
|
||||
.groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } }
|
||||
.toList()
|
||||
calendars.filterNot { it.isLocal }.groupByAccount()
|
||||
}
|
||||
|
||||
if (local.isNotEmpty()) {
|
||||
@@ -65,16 +72,33 @@ fun ColumnScope.CalendarPickerGroups(
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
syncedGroups.forEachIndexed { index, (account, cals) ->
|
||||
syncedGroups.forEachIndexed { index, group ->
|
||||
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||
CalendarPickerGroup(
|
||||
title = account,
|
||||
leading = { SourceLogo(cals.first().accountType) },
|
||||
calendars = cals,
|
||||
title = accountGroupTitle(group),
|
||||
leading = { SourceLogo(group.accountType) },
|
||||
calendars = group.calendars,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
if (onManageCalendars != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendar_picker_missing_title),
|
||||
summary = stringResource(R.string.calendar_picker_missing_summary),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.VisibilityOff) },
|
||||
trailing = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = onManageCalendars,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||
@@ -155,20 +179,22 @@ fun LeadingAvatar(icon: ImageVector) {
|
||||
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
||||
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
||||
val pm = context.packageManager
|
||||
val candidates = buildList {
|
||||
curatedSourcePackage(accountType)?.let { add(it) }
|
||||
AccountManager.get(context).authenticatorTypes
|
||||
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||
?.packageName
|
||||
?.let { add(it) }
|
||||
}
|
||||
for (pkg in candidates) {
|
||||
for (pkg in sourceAppPackages(context, accountType)) {
|
||||
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
||||
if (bitmap != null) return bitmap.asImageBitmap()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Apps that could stand for [accountType], best candidate first. */
|
||||
internal fun sourceAppPackages(context: Context, accountType: String): List<String> = buildList {
|
||||
curatedSourcePackage(accountType)?.let { add(it) }
|
||||
AccountManager.get(context).authenticatorTypes
|
||||
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||
?.packageName
|
||||
?.let { add(it) }
|
||||
}
|
||||
|
||||
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||
internal fun curatedSourcePackage(accountType: String): String? = when {
|
||||
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||
|
||||
@@ -23,16 +23,26 @@ import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||
@@ -48,6 +58,11 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
* 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.
|
||||
*
|
||||
* [leadTimeSummary] adds a second line to each lead-time row. It exists for the
|
||||
* all-day pickers, where the lead time alone doesn't say when anything happens:
|
||||
* the hour comes from the separate "show all-day reminders at" setting, so
|
||||
* "1 day before" is only half the answer until the row spells out the time.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderDefaultPicker(
|
||||
@@ -57,6 +72,7 @@ fun ReminderDefaultPicker(
|
||||
allowInherit: Boolean,
|
||||
onSelect: (ReminderOverride) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
leadTimeSummary: (@Composable (Int) -> String?)? = null,
|
||||
) {
|
||||
// Optimistic local state: once the user edits, the chosen override is
|
||||
// authoritative while the picker is open, so quick successive toggles compose
|
||||
@@ -132,6 +148,7 @@ fun ReminderDefaultPicker(
|
||||
val checked = minute in selectedMinutes
|
||||
GroupedRow(
|
||||
title = reminderLeadTimeLabel(minute),
|
||||
summary = leadTimeSummary?.invoke(minute),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = checked,
|
||||
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||
@@ -196,14 +213,15 @@ private fun CustomReminderEditor(
|
||||
)
|
||||
}
|
||||
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows,
|
||||
* on the same left margin as the rows themselves. */
|
||||
@Composable
|
||||
internal fun PickerDescription(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -213,12 +231,19 @@ internal fun PickerDescription(text: String) {
|
||||
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
||||
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
||||
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
||||
*
|
||||
* Every option carries the dates it resolves to today as its summary, computed
|
||||
* through the same [dayCount] the agenda windows by (hence [weekStart], which
|
||||
* "This week" depends on). Without it the two groups are near-indistinguishable
|
||||
* — "This week" and "Next 7 days" name different windows in the same words, and
|
||||
* only the concrete span tells them apart.
|
||||
*/
|
||||
@Composable
|
||||
fun AgendaRangePicker(
|
||||
title: String,
|
||||
description: String,
|
||||
selected: AgendaRange,
|
||||
weekStart: DayOfWeek,
|
||||
onSelect: (AgendaRange) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@@ -232,10 +257,21 @@ fun AgendaRangePicker(
|
||||
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
||||
}
|
||||
|
||||
val locale = currentLocale()
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
// The window each option would open right now — the agenda's own end-day
|
||||
// arithmetic (anchor + dayCount - 1), so the dates match what it will show.
|
||||
val windowSummary: (AgendaRange) -> String = { range ->
|
||||
val end = today.plus(range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
|
||||
agendaRangeWindowSummary(range, today, end, locale)
|
||||
}
|
||||
|
||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = agendaRangeLabel(option),
|
||||
summary = windowSummary(option),
|
||||
position = position,
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
@@ -270,6 +306,9 @@ fun AgendaRangePicker(
|
||||
} else {
|
||||
stringResource(R.string.agenda_range_custom)
|
||||
},
|
||||
// Only a chosen custom window has dates to show; an unset one has no
|
||||
// day count yet, so the row stays single-line until it does.
|
||||
summary = if (customSelected) windowSummary(selected) else null,
|
||||
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
|
||||
@@ -8,6 +8,11 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
|
||||
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
@@ -98,6 +103,43 @@ fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule's first few dates as one line — "Next: 30 Jul, 6 Aug, 13 Aug" — for
|
||||
* the recurrence picker.
|
||||
*
|
||||
* The phrase [recurrenceText] renders says what the rule *is*; this says what
|
||||
* it *does*, which is the part a monthly rule on the 31st or an every-other-week
|
||||
* rule with weekday picks gets wrong in people's heads. It expands the rule
|
||||
* with [upcomingOccurrences], so it inherits that function's RFC reading — and
|
||||
* its limits: only the shapes the picker itself can build.
|
||||
*
|
||||
* A rule that yields nothing at all (an end date before the start) says so
|
||||
* rather than showing an empty list, since that is a mistake worth catching
|
||||
* before saving.
|
||||
*/
|
||||
@Composable
|
||||
fun nextOccurrencesText(
|
||||
rule: SimpleRecurrence,
|
||||
// Spelled out: in this file, the bare LocalDate is java.time's.
|
||||
start: kotlinx.datetime.LocalDate,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val dates = rule.upcomingOccurrences(start, limit = NEXT_OCCURRENCE_COUNT)
|
||||
if (dates.isEmpty()) return stringResource(R.string.event_edit_recurrence_next_none)
|
||||
// Years only once they carry information — a yearly rule is otherwise the
|
||||
// same date repeated, and a monthly one crossing New Year hides that it did.
|
||||
val pattern = if (occurrencesSpanYears(dates, start)) "dMMMy" else "dMMM"
|
||||
val formatter = localizedDateFormatter(locale, pattern)
|
||||
val formatted = dates.map { formatter.format(it.toJavaLocalDate()) }
|
||||
return stringResource(
|
||||
R.string.event_edit_recurrence_next,
|
||||
ListFormatter.getInstance(locale).format(formatted),
|
||||
)
|
||||
}
|
||||
|
||||
/** Enough dates to show a rhythm (and a skipped month), few enough for one line. */
|
||||
private const val NEXT_OCCURRENCE_COUNT = 3
|
||||
|
||||
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||
val dow = when (token.takeLast(2).uppercase()) {
|
||||
|
||||
@@ -39,6 +39,7 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -240,7 +241,12 @@ private fun SectionHeader(text: String) {
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,8 @@ import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
@@ -155,6 +157,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.nextOccurrencesText
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -193,6 +196,7 @@ fun EventEditScreen(
|
||||
initialStartMinutes: Int? = null,
|
||||
initialForm: EventForm? = null,
|
||||
initialFormSource: ImportSource = ImportSource.File,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
viewModel: EventEditViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||
@@ -309,6 +313,7 @@ fun EventEditScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
onManageCalendars = onManageCalendars,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -500,6 +505,7 @@ private fun EventEditContent(
|
||||
state: EventEditUiState,
|
||||
viewModel: EventEditViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
) {
|
||||
val form = state.form
|
||||
val locale = currentLocale()
|
||||
@@ -509,6 +515,10 @@ private fun EventEditContent(
|
||||
// they're locked here; everything else (reminders, location, notes) is the
|
||||
// user's to edit.
|
||||
val locked = state.isManaged
|
||||
// Read in the form's own window, not the picker's: the field holding focus
|
||||
// lives here, so this is the controller that can put its keyboard away.
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -1114,6 +1124,17 @@ private fun EventEditContent(
|
||||
null -> Unit
|
||||
}
|
||||
|
||||
// A full-screen picker over the form is a change of place, so the form's
|
||||
// keyboard has no business following it there — least of all onto the
|
||||
// calendar manager, which the picker can hand off to. The form's own field
|
||||
// keeps its text; only focus and the IME go.
|
||||
LaunchedEffect(showCalendarPicker) {
|
||||
if (showCalendarPicker) {
|
||||
focusManager.clearFocus(force = true)
|
||||
keyboardController?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
if (showCalendarPicker) {
|
||||
CalendarPicker(
|
||||
calendars = state.calendars,
|
||||
@@ -1122,6 +1143,17 @@ private fun EventEditContent(
|
||||
viewModel.setCalendar(it)
|
||||
showCalendarPicker = false
|
||||
},
|
||||
// Close the picker on the way out. It is a Compose Dialog — its own
|
||||
// window, always above the activity's content — so the manager would
|
||||
// otherwise open behind it and the tap would look dead. The form
|
||||
// stays standing underneath, its calendar row one tap from a picker
|
||||
// that re-queries on open.
|
||||
onManageCalendars = onManageCalendars?.let { openManager ->
|
||||
{
|
||||
showCalendarPicker = false
|
||||
openManager()
|
||||
}
|
||||
},
|
||||
onDismiss = { showCalendarPicker = false },
|
||||
)
|
||||
}
|
||||
@@ -1141,7 +1173,7 @@ private fun EventEditContent(
|
||||
if (showRecurrencePicker) {
|
||||
RecurrencePickerDialog(
|
||||
current = form.rrule,
|
||||
startDay = form.start.date.dayOfWeek,
|
||||
startDate = form.start.date,
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
onSelect = { rrule ->
|
||||
viewModel.setRecurrence(rrule)
|
||||
@@ -1309,15 +1341,23 @@ private enum class RecurrenceEndMode { Never, Until, Count }
|
||||
* and an end condition — only that step needs an OK button. A rule the
|
||||
* simple shape can't express (ordinal BYDAY etc.) stays untouched unless the
|
||||
* user picks something here.
|
||||
*
|
||||
* Both steps show the rule as *dates* as well as words: every preset row
|
||||
* carries the next few occurrences it would produce from [startDate], and the
|
||||
* custom step repeats that under its live read-out. It is where a rule most
|
||||
* easily means something other than it sounds like — "monthly" on the 31st
|
||||
* skips February, an every-other-week rule lands a fortnight from the start's
|
||||
* own week — and only the dates say so.
|
||||
*/
|
||||
@Composable
|
||||
private fun RecurrencePickerDialog(
|
||||
current: String?,
|
||||
startDay: DayOfWeek,
|
||||
startDate: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
onSelect: (String?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val startDay = startDate.dayOfWeek
|
||||
val parsed = remember(current) { current?.let(::parseSimpleRecurrence) }
|
||||
val isPlainPreset = parsed != null && parsed.interval == 1 &&
|
||||
parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty()
|
||||
@@ -1369,16 +1409,20 @@ private fun RecurrencePickerDialog(
|
||||
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
||||
RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) }
|
||||
}
|
||||
val customResult: String? = if (interval != null && customEnd != null) {
|
||||
// Kept as the rule object, not just its RRULE text: the read-out renders the
|
||||
// string, the date list expands the rule, and both must describe the one
|
||||
// thing OK would save.
|
||||
val customRule: SimpleRecurrence? = if (interval != null && customEnd != null) {
|
||||
SimpleRecurrence(
|
||||
freq = freq,
|
||||
interval = interval,
|
||||
end = customEnd,
|
||||
byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(),
|
||||
).toRRule()
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val customResult: String? = customRule?.toRRule()
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_detail_recurrence),
|
||||
@@ -1409,6 +1453,7 @@ private fun RecurrencePickerDialog(
|
||||
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||
GroupedRow(
|
||||
title = stringResource(recurrencePresetLabel(entry)),
|
||||
summary = nextOccurrencesText(SimpleRecurrence(entry), startDate, locale),
|
||||
position = positionOf(index + 1, rowCount),
|
||||
selected = isPlainPreset && parsed?.freq == entry,
|
||||
trailing = if (isPlainPreset && parsed?.freq == entry) {
|
||||
@@ -1460,6 +1505,18 @@ private fun RecurrencePickerDialog(
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
// The same rule as dates. Rendered even while the form is
|
||||
// incomplete (as an empty line) so the controls below don't
|
||||
// shift as it comes and goes.
|
||||
Text(
|
||||
text = customRule?.let { nextOccurrencesText(it, startDate, locale) }.orEmpty(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
// How often: an interval amount plus a frequency segmented row —
|
||||
// all four units on show, no unit hidden behind a dropdown.
|
||||
GroupedSurface(
|
||||
@@ -1976,6 +2033,11 @@ private fun readContactAddress(context: Context, uri: Uri): String? =
|
||||
/**
|
||||
* Visibility selector: one card per level, each with its own icon; the
|
||||
* current level is highlighted. Tap picks and closes.
|
||||
*
|
||||
* Every level carries a line saying who this affects, because the four words
|
||||
* alone don't: visibility is about what *other people on a shared calendar*
|
||||
* see, which is exactly the part the label leaves out — and "Confidential" has
|
||||
* no meaning at all until you know the server decides what it does with it.
|
||||
*/
|
||||
@Composable
|
||||
private fun VisibilityPickerDialog(
|
||||
@@ -1988,6 +2050,7 @@ private fun VisibilityPickerDialog(
|
||||
options = AccessLevel.entries.toList(),
|
||||
selected = selected,
|
||||
label = { stringResource(accessLevelLabel(it)) },
|
||||
summary = { stringResource(accessLevelSummary(it)) },
|
||||
onSelect = onSelect,
|
||||
onDismiss = onDismiss,
|
||||
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
|
||||
@@ -2080,6 +2143,14 @@ private fun accessLevelLabel(level: AccessLevel): Int = when (level) {
|
||||
AccessLevel.Confidential -> R.string.event_access_confidential
|
||||
}
|
||||
|
||||
/** What each level means for people the calendar is shared with. */
|
||||
private fun accessLevelSummary(level: AccessLevel): Int = when (level) {
|
||||
AccessLevel.Default -> R.string.event_access_default_summary
|
||||
AccessLevel.Public -> R.string.event_access_public_summary
|
||||
AccessLevel.Private -> R.string.event_access_private_summary
|
||||
AccessLevel.Confidential -> R.string.event_access_confidential_summary
|
||||
}
|
||||
|
||||
/** Humanise a reminder lead time, mirroring the detail screen's rendering. */
|
||||
@Composable
|
||||
private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes)
|
||||
@@ -2277,6 +2348,7 @@ private fun CalendarPicker(
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
) {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_detail_calendar),
|
||||
@@ -2286,6 +2358,7 @@ private fun CalendarPicker(
|
||||
calendars = calendars,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
onManageCalendars = onManageCalendars,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||
@@ -177,18 +178,16 @@ class EventEditViewModel @Inject constructor(
|
||||
repository.calendars().catch { emit(emptyList()) }
|
||||
|
||||
/**
|
||||
* 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;
|
||||
* a last-used preselect landing on a now-disabled calendar falls back to the
|
||||
* 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.
|
||||
* The calendars a new event can be saved to ([isEventTarget]): writable,
|
||||
* switched on, not a contact-filled mirror, not a non-syncing subscription.
|
||||
* A last-used preselect landing on an excluded calendar falls back to the
|
||||
* first remaining one (handled by [resolvedCalendarId] and [state]).
|
||||
*
|
||||
* This is the list of *targets*. An event already living in an excluded
|
||||
* calendar keeps it — [state] adds it back to the picker.
|
||||
*/
|
||||
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
||||
allCalendars,
|
||||
prefs.disabledCalendarIds,
|
||||
) { calendars, disabled ->
|
||||
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
|
||||
private val writableCalendars: Flow<List<CalendarSource>> = allCalendars.map { calendars ->
|
||||
calendars.filter { it.isEventTarget }
|
||||
}
|
||||
|
||||
/** The target calendar id, resolved exactly as the form shows it. */
|
||||
@@ -234,11 +233,16 @@ class EventEditViewModel @Inject constructor(
|
||||
// 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.
|
||||
// The picker offers writable calendars only; the event's own calendar is
|
||||
// added back whenever it isn't among them — a managed special-dates one,
|
||||
// or one switched off on this device — so the row keeps naming it instead
|
||||
// of reading as the "no calendar" error, and saving can leave the event
|
||||
// where it is. A calendar the app may not write to is still no target.
|
||||
val ownCalendar = resolvedCalendar?.takeIf { own ->
|
||||
own.canModifyContents && external.writable.none { it.id == own.id }
|
||||
}
|
||||
val pickerCalendars =
|
||||
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
||||
else external.writable
|
||||
if (ownCalendar != null) external.writable + ownCalendar else external.writable
|
||||
// An all-day event is date-anchored, so a zone is meaningless on it —
|
||||
// the field is withheld from both lists rather than shown as a no-op.
|
||||
val offerableFields = EventFormField.entries.toSet() -
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
@@ -61,7 +62,15 @@ private fun FilterList(
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
groups.forEach { group ->
|
||||
Text(
|
||||
text = group.account,
|
||||
text = if (group.ambiguous) {
|
||||
stringResource(
|
||||
R.string.calendars_account_from_source,
|
||||
group.account,
|
||||
sourceAppName(group.accountType),
|
||||
)
|
||||
} else {
|
||||
group.account
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||
|
||||
@@ -13,9 +13,17 @@ sealed interface FilterUiState {
|
||||
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
||||
}
|
||||
|
||||
/** Calendars grouped under the account that owns them (Nextcloud / Local / …). */
|
||||
/**
|
||||
* Calendars grouped under the account that owns them (Nextcloud / Local / …).
|
||||
*
|
||||
* [accountType] and [ambiguous] carry what the header needs to tell two
|
||||
* same-named accounts from different apps apart (#77); the label itself is
|
||||
* built in the UI layer, which is where the source app's name can be looked up.
|
||||
*/
|
||||
data class AccountGroup(
|
||||
val account: String,
|
||||
val accountType: String,
|
||||
val ambiguous: Boolean,
|
||||
val calendars: List<CalendarRow>,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -30,16 +31,15 @@ class FilterViewModel @Inject constructor(
|
||||
combine(
|
||||
repository.calendars(),
|
||||
prefs.hiddenCalendarIds,
|
||||
prefs.disabledCalendarIds,
|
||||
) { calendars, hidden, disabled ->
|
||||
// Disabled calendars are gone from the app entirely — they don't
|
||||
// belong in the drawer's hide/show list (you can't hide what's
|
||||
// already disabled). They live only in Settings → Calendars.
|
||||
val enabled = calendars.filterNot { it.id in disabled }
|
||||
) { calendars, hidden ->
|
||||
// Calendars switched off in Settings → Calendars are off device-wide
|
||||
// and don't belong in the drawer's hide/show list (you can't hide
|
||||
// what is already off). They live only in Settings → Calendars.
|
||||
val enabled = calendars.filter { it.isVisibleInSystem }
|
||||
if (enabled.isEmpty()) {
|
||||
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
} else {
|
||||
FilterUiState.Success(groupByAccount(enabled, hidden))
|
||||
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
|
||||
}
|
||||
}
|
||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
@@ -61,30 +61,27 @@ class FilterViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Group calendars under their owning account, preserving the provider's order
|
||||
* within each group and ordering groups by first appearance. A calendar is
|
||||
* "visible" when its id is *not* in [hidden].
|
||||
* Group calendars under their owning account — by name *and* type, so two
|
||||
* accounts that merely share a name stay apart (#77) — preserving the
|
||||
* provider's order within each group and ordering groups by first appearance.
|
||||
* A calendar is "visible" when its id is *not* in [hidden].
|
||||
*/
|
||||
internal fun groupByAccount(
|
||||
internal fun groupCalendarsForFilter(
|
||||
calendars: List<CalendarSource>,
|
||||
hidden: Set<Long>,
|
||||
): List<AccountGroup> =
|
||||
calendars
|
||||
.groupBy { it.accountLabel() }
|
||||
.map { (account, cals) ->
|
||||
AccountGroup(
|
||||
account = account,
|
||||
calendars = cals.map { c ->
|
||||
CalendarRow(
|
||||
id = c.id,
|
||||
displayName = c.displayName,
|
||||
color = c.color,
|
||||
visible = c.id !in hidden,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Account header text: the account name, falling back to its type. */
|
||||
private fun CalendarSource.accountLabel(): String =
|
||||
accountName.takeIf { it.isNotBlank() } ?: accountType.takeIf { it.isNotBlank() } ?: displayName
|
||||
calendars.groupByAccount().map { group ->
|
||||
AccountGroup(
|
||||
account = group.label,
|
||||
accountType = group.accountType,
|
||||
ambiguous = group.ambiguous,
|
||||
calendars = group.calendars.map { c ->
|
||||
CalendarRow(
|
||||
id = c.id,
|
||||
displayName = c.displayName,
|
||||
color = c.color,
|
||||
visible = c.id !in hidden,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ fun ImportScreen(
|
||||
onClose: () -> Unit,
|
||||
onOpenSingle: (EventForm) -> Unit,
|
||||
forceMany: Boolean = false,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
// Key the VM by the file uri. This screen has no nav backstack, so an
|
||||
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
||||
// across imports — its one-shot `load` guard would then show the *previous*
|
||||
@@ -155,7 +156,12 @@ fun ImportScreen(
|
||||
|
||||
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
|
||||
is ImportUiState.Many -> ManyContent(
|
||||
state = s,
|
||||
selected = selected,
|
||||
onSelect = { selected = it },
|
||||
onManageCalendars = onManageCalendars,
|
||||
)
|
||||
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||
}
|
||||
}
|
||||
@@ -163,10 +169,24 @@ fun ImportScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
|
||||
// No writable calendar to import into — tell the user honestly.
|
||||
private fun ManyContent(
|
||||
state: ImportUiState.Many,
|
||||
selected: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
) {
|
||||
// No calendar to import into — tell the user honestly, and carry the same
|
||||
// way out the picker's footer offers below. This is the state that footer
|
||||
// exists for: every writable calendar being switched off, read-only or
|
||||
// contact-filled is exactly what empties this list (#76).
|
||||
if (state.calendars.isEmpty()) {
|
||||
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
||||
CenteredMessage(
|
||||
message = stringResource(R.string.import_no_calendar),
|
||||
onClose = null,
|
||||
actionLabel = stringResource(R.string.settings_manage_calendars)
|
||||
.takeIf { onManageCalendars != null },
|
||||
onAction = onManageCalendars,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -178,6 +198,7 @@ private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (L
|
||||
calendars = state.calendars,
|
||||
selectedId = selected,
|
||||
onSelect = onSelect,
|
||||
onManageCalendars = onManageCalendars,
|
||||
)
|
||||
if (state.warnings.isNotEmpty()) {
|
||||
Column(
|
||||
@@ -333,7 +354,12 @@ private fun WarningText(warning: IcsParseWarning) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
||||
private fun CenteredMessage(
|
||||
message: String,
|
||||
onClose: (() -> Unit)?,
|
||||
actionLabel: String? = null,
|
||||
onAction: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
Modifier.padding(24.dp),
|
||||
@@ -344,6 +370,9 @@ private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
||||
if (onClose != null) {
|
||||
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
||||
}
|
||||
if (actionLabel != null && onAction != null) {
|
||||
Button(onClick = onAction) { Text(actionLabel) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
@@ -15,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -57,7 +57,6 @@ sealed interface ImportUiState {
|
||||
class ImportViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val importer: IcsImporter,
|
||||
private val prefs: CalendarPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -87,16 +86,14 @@ class ImportViewModel @Inject constructor(
|
||||
warnings = parsed.warnings,
|
||||
)
|
||||
else -> {
|
||||
// A disabled calendar is removed from the app, so it can't be
|
||||
// an import target — exclude it alongside the read-only ones.
|
||||
// Managed special-dates calendars are contact-derived and
|
||||
// editor-locked, so they're not a valid destination either.
|
||||
val disabled = prefs.disabledCalendarIds.first()
|
||||
// The same targets the event form offers ([isEventTarget]):
|
||||
// an import is a bulk create, so a calendar that can't hold
|
||||
// one event can't hold thirty.
|
||||
ImportUiState.Many(
|
||||
events = parsed.events,
|
||||
warnings = parsed.warnings,
|
||||
calendars = repository.calendars().first()
|
||||
.filter { it.canModifyContents && !it.isManaged && it.id !in disabled },
|
||||
.filter { it.isEventTarget },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.abs
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
@@ -364,10 +365,16 @@ fun MonthScreen(
|
||||
todayText = stringResource(R.string.month_today_action),
|
||||
onToday = jumpToToday,
|
||||
onCreate = {
|
||||
// Anchor on today when its month is shown, else the 1st.
|
||||
// Split has a selected day and lists it below the grid, so
|
||||
// that is the day the new event belongs to. The other
|
||||
// styles have no selection: anchor on today when its month
|
||||
// is shown, else the 1st.
|
||||
onCreateEvent(
|
||||
if (isOnCurrentMonth) today
|
||||
else LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||
when {
|
||||
viewStyle == MonthViewStyle.Split -> selectedDate
|
||||
isOnCurrentMonth -> today
|
||||
else -> LocalDate(titleMonth.year, titleMonth.month, 1)
|
||||
},
|
||||
null,
|
||||
)
|
||||
},
|
||||
@@ -1277,7 +1284,7 @@ internal fun SplitMonthGrid(
|
||||
SplitDayCell(
|
||||
date = day,
|
||||
events = seated,
|
||||
hidden = (week.countByDay[day] ?: 0) - seated.size,
|
||||
hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS),
|
||||
isToday = day == state.today,
|
||||
// A page marks only the days its own month owns. Paging
|
||||
// moves the selection before this month's replacement
|
||||
@@ -1318,7 +1325,7 @@ private fun SplitDayCell(
|
||||
date: LocalDate,
|
||||
events: List<EventInstance>,
|
||||
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
|
||||
hidden: Int,
|
||||
hidden: List<EventInstance>,
|
||||
isToday: Boolean,
|
||||
isSelected: Boolean,
|
||||
inMonth: Boolean,
|
||||
@@ -1436,9 +1443,15 @@ private fun SplitDayCell(
|
||||
* bar with no dot to grow out of.
|
||||
*/
|
||||
@Composable
|
||||
private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int, dark: Boolean) {
|
||||
private fun SplitDots(
|
||||
date: LocalDate,
|
||||
events: List<EventInstance>,
|
||||
hidden: List<EventInstance>,
|
||||
dark: Boolean,
|
||||
) {
|
||||
if (events.isEmpty()) return
|
||||
val soften = LocalSoftenColors.current
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -1461,18 +1474,21 @@ private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int,
|
||||
modifier = Modifier
|
||||
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||
.size(SPLIT_DOT_SIZE)
|
||||
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
||||
.background(eventFill(event.color, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
if (hidden > 0) {
|
||||
if (hidden.isNotEmpty()) {
|
||||
// Tagged, not lifted: this count and the expanded grid's dot row are
|
||||
// the same marker on the same day, so it travels with its cell like
|
||||
// everything else rather than riding above the grid on its own layer.
|
||||
Text(
|
||||
text = "+$hidden",
|
||||
text = "+${hidden.size}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.morphBounds(MonthMorphKey.Overflow(date)),
|
||||
modifier = Modifier
|
||||
.morphBounds(MonthMorphKey.Overflow(date))
|
||||
.alpha(if (allEnded(hidden, dimCutoff)) EventDimAlpha else 1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1870,15 +1886,15 @@ private fun MonthWeekRow(
|
||||
}
|
||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||
if (hidden > 0) {
|
||||
val hiddenColors = buildList {
|
||||
val hiddenEvents = buildList {
|
||||
week.spans
|
||||
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
||||
.forEach { add(it.event.color) }
|
||||
timed.drop(pillsShown.size).forEach { add(it.color) }
|
||||
}.distinct().take(3)
|
||||
.forEach { add(it.event) }
|
||||
addAll(timed.drop(pillsShown.size))
|
||||
}
|
||||
OverflowDots(
|
||||
colors = hiddenColors,
|
||||
extra = hidden - hiddenColors.size,
|
||||
events = hiddenEvents,
|
||||
total = hidden,
|
||||
dark = dark,
|
||||
modifier = Modifier
|
||||
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
||||
@@ -2058,37 +2074,52 @@ private fun MonthBar(
|
||||
}
|
||||
}
|
||||
|
||||
/** Overflow row: a dot per hidden event (up to three) plus "+N" for the rest. */
|
||||
/**
|
||||
* Overflow row: a dot per hidden colour (up to three) plus "+N" for the rest.
|
||||
*
|
||||
* A dot stands for every hidden event sharing its colour, so it dims only once
|
||||
* all of them have ended; the "+N" dims once the whole overflow has (#79).
|
||||
*/
|
||||
@Composable
|
||||
private fun OverflowDots(
|
||||
colors: List<Int>,
|
||||
extra: Int,
|
||||
events: List<EventInstance>,
|
||||
total: Int,
|
||||
dark: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val soften = LocalSoftenColors.current
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val byColor = events.groupBy { it.color }
|
||||
val dots = byColor.keys.take(3)
|
||||
Row(
|
||||
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
colors.forEach { argb ->
|
||||
dots.forEach { argb ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
||||
.background(eventFill(argb, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
val extra = total - dots.size
|
||||
if (extra > 0) {
|
||||
Text(
|
||||
text = "+$extra",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.alpha(if (allEnded(events, dimCutoff)) EventDimAlpha else 1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True when dimming is on and every one of [events] is already over. */
|
||||
private fun allEnded(events: List<EventInstance>, dimCutoff: Instant?): Boolean =
|
||||
dimCutoff != null && events.isNotEmpty() && events.all { it.hasEnded(dimCutoff) }
|
||||
|
||||
@Composable
|
||||
private fun MonthGridLoading() {
|
||||
val shape = MaterialTheme.shapes.medium
|
||||
|
||||
@@ -67,6 +67,24 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
||||
return byLane.filterNotNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The events on [day] that [laneEvents] had no lane left for — the exact
|
||||
* complement of what it seats, in the same bars-then-pills order. Together the
|
||||
* two partition the day, so their sizes add up to [countByDay].
|
||||
*
|
||||
* The "+N" marker needs the events themselves, not just how many there are:
|
||||
* dimming a completed event is a per-event question (#79).
|
||||
*/
|
||||
fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||
val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
return buildList {
|
||||
spans.forEach { span ->
|
||||
if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event)
|
||||
}
|
||||
addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
||||
|
||||
@@ -13,6 +13,8 @@ class PermissionViewModel @Inject constructor() : ViewModel() {
|
||||
private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale)
|
||||
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
|
||||
|
||||
// The visibility reconcile a grant owes (#75) hangs off RootScreen showing
|
||||
// the app instead: it has to cover the grants made outside it too.
|
||||
fun onGranted() {
|
||||
_state.value = PermissionUiState.Granted
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -19,6 +20,7 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class ReminderOnboardingViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
private val scanner: ReminderScanner,
|
||||
) : ViewModel() {
|
||||
|
||||
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
||||
@@ -34,6 +36,19 @@ class ReminderOnboardingViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(remindersEnabled)
|
||||
prefs.setReminderOnboardingDone()
|
||||
// Nothing else re-arms the scan: turning reminders off cancels the
|
||||
// alarm, so a later "on" would sit without one until a provider
|
||||
// change or the daily worker happened to run (#75).
|
||||
scanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-scan after the calendar permission is granted. The launch scan runs
|
||||
* before the grant and bails out without arming anything, so without this
|
||||
* the first alarm waits on the daily worker.
|
||||
*/
|
||||
fun rearmAfterGrant() {
|
||||
scanner.scanInBackground()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +57,12 @@ import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.Instant as JavaInstant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -220,9 +223,14 @@ private fun searchSummary(event: EventInstance): String {
|
||||
val start = remember(event.start, zone) {
|
||||
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
||||
}
|
||||
val dateText = remember(locale) {
|
||||
// The date comes from the shared span rule, not from [start]: an all-day
|
||||
// event sits at UTC midnight, so reading its date in the device zone names
|
||||
// the day before west of UTC (#82). The clock time below stays in the device
|
||||
// zone — it is only ever rendered for timed events.
|
||||
val dateText = remember(event.start, event.end, event.isAllDay, locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
}.format(start)
|
||||
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
||||
}
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val timeText = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.UploadFile
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
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 de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/**
|
||||
* Appearance: how the app itself looks — theme and colour, the two typeface
|
||||
* roles, and the launcher name. Anything that only changes how a *calendar view*
|
||||
* reads (week start, time format, grid options) lives in [ViewsScreen] instead,
|
||||
* and the widget-only settings in [WidgetsScreen] (#69).
|
||||
*/
|
||||
@Composable
|
||||
internal fun AppearanceScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var showTheme by remember { mutableStateOf(false) }
|
||||
var showBrandFont by remember { mutableStateOf(false) }
|
||||
var showPlainFont by remember { mutableStateOf(false) }
|
||||
var showAppName by remember { mutableStateOf(false) }
|
||||
|
||||
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
||||
val launcherName by viewModel.launcherName.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(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
// Theme & colour
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
summary = themeLabel(state.themeMode),
|
||||
position = Position.Top,
|
||||
onClick = { showTheme = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_dynamic_color),
|
||||
// Says what it does when on; below Android 12 that is replaced by
|
||||
// the reason the switch is dead.
|
||||
summary = if (state.dynamicColorAvailable) {
|
||||
stringResource(R.string.settings_dynamic_color_summary)
|
||||
} else {
|
||||
stringResource(R.string.settings_dynamic_color_unavailable)
|
||||
},
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.dynamicColor,
|
||||
onCheckedChange = viewModel::setDynamicColor,
|
||||
enabled = state.dynamicColorAvailable,
|
||||
)
|
||||
},
|
||||
onClick = if (state.dynamicColorAvailable) {
|
||||
{ viewModel.setDynamicColor(!state.dynamicColor) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_soften_colors),
|
||||
summary = stringResource(R.string.settings_soften_colors_summary),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.softenColors,
|
||||
onCheckedChange = viewModel::setSoftenColors,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setSoftenColors(!state.softenColors) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// 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))
|
||||
|
||||
// App name — chooses the launcher label between "Calendula" and "Calendar"
|
||||
// (issue #44). Own group: it's a launcher/system concern, not app styling.
|
||||
// A sub-page chooser (not a switch), matching the app's other "choose one"
|
||||
// settings and leaving room for more names later.
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_app_name),
|
||||
summary = launcherNameLabel(launcherName),
|
||||
position = Position.Alone,
|
||||
onClick = { showAppName = true },
|
||||
)
|
||||
}
|
||||
|
||||
if (showAppName) {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_app_name),
|
||||
onDismiss = { showAppName = false },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
// Show both names as launcher-mark previews so the user sees what
|
||||
// they'd switch to, not just the current state. Tapping applies
|
||||
// immediately and highlights — the picker stays open so the change is
|
||||
// visible; back exits.
|
||||
Text(
|
||||
text = stringResource(R.string.settings_app_name_summary),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
LauncherName.entries.forEach { option ->
|
||||
AppNameOptionCard(
|
||||
name = option,
|
||||
selected = launcherName == option,
|
||||
onClick = { viewModel.setLauncherName(option) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showTheme) {
|
||||
// No preview here on purpose: picking a theme repaints the app itself,
|
||||
// which is a better demonstration than any thumbnail. What the list
|
||||
// *can't* show is which way "Follow the system" currently falls, so that
|
||||
// one option says so.
|
||||
val systemDark = isSystemInDarkTheme()
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
header = { PickerDescription(stringResource(R.string.settings_theme_hint)) },
|
||||
predictiveBack = true,
|
||||
options = ThemeMode.entries,
|
||||
selected = state.themeMode,
|
||||
label = { themeLabel(it) },
|
||||
summary = { mode ->
|
||||
if (mode == ThemeMode.SYSTEM) {
|
||||
stringResource(
|
||||
R.string.settings_theme_system_summary,
|
||||
themeLabel(if (systemDark) ThemeMode.DARK else ThemeMode.LIGHT),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
onSelect = viewModel::setThemeMode,
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||
when (mode) {
|
||||
ThemeMode.SYSTEM -> R.string.settings_theme_system
|
||||
ThemeMode.LIGHT -> R.string.settings_theme_light
|
||||
ThemeMode.DARK -> R.string.settings_theme_dark
|
||||
},
|
||||
)
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
/** The display name for a launcher-label choice (issue #44). */
|
||||
@Composable
|
||||
private fun launcherNameLabel(name: LauncherName): String = stringResource(
|
||||
when (name) {
|
||||
LauncherName.CALENDULA -> R.string.app_name
|
||||
LauncherName.CALENDAR -> R.string.app_name_calendar_alias
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Above the rows sits a specimen of the *current* choice, set in the type role
|
||||
* this picker governs — headline for [FontRole.BRAND], body for
|
||||
* [FontRole.PLAIN]. The per-row "Ag" says what a face looks like; only a full
|
||||
* line at the real size says whether it works for the role, and it is the one
|
||||
* thing that makes the two font settings distinguishable from each other.
|
||||
*
|
||||
* Being a preview picker, it stays open on selection (see `FullScreenPicker`):
|
||||
* the specimen re-renders instead, which is the whole point of the screen.
|
||||
*/
|
||||
@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 ->
|
||||
// Stay open on a successful import too: the specimen switches to the
|
||||
// imported face, which is the only look the user gets before committing.
|
||||
if (uri != null) onImport(uri)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// The face the specimen is set in: the same resolution the theme performs,
|
||||
// so what is shown here is what the app will use.
|
||||
val selectedFamily = remember(role, selected, stamp) {
|
||||
resolveFontFamily(selected, role, context)
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||
FontSpecimen(role = role, family = selectedFamily)
|
||||
FontOptionRow(
|
||||
label = stringResource(R.string.settings_font_system),
|
||||
preview = FontFamily.Default,
|
||||
selected = selected == FONT_SYSTEM_TOKEN,
|
||||
position = positionOf(0, rowCount),
|
||||
// Applies straight away and stays open — the specimen above is the
|
||||
// answer to "what does this one look like".
|
||||
onClick = { onSelect(FONT_SYSTEM_TOKEN) },
|
||||
)
|
||||
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) },
|
||||
)
|
||||
}
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A specimen of [family] set in the type role [role] governs: a headline for
|
||||
* the brand role, a paragraph for the plain one — the same styles the app draws
|
||||
* with, only the family swapped, so nothing here can flatter a face the app
|
||||
* won't reproduce. A null [family] is the system typeface, i.e. the Material
|
||||
* default the styles already carry.
|
||||
*/
|
||||
@Composable
|
||||
private fun FontSpecimen(role: FontRole, family: FontFamily?) {
|
||||
val isBrand = role == FontRole.BRAND
|
||||
val style = if (isBrand) {
|
||||
MaterialTheme.typography.headlineMedium
|
||||
} else {
|
||||
MaterialTheme.typography.bodyLarge
|
||||
}
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (isBrand) R.string.settings_font_specimen_heading else R.string.settings_font_specimen_body,
|
||||
),
|
||||
style = style.copy(fontFamily = family ?: style.fontFamily),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
// Two lines up front so switching between a wide and a narrow face
|
||||
// doesn't shuffle the option list up and down under it.
|
||||
minLines = 2,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One selectable launcher-name preview in the App name picker (issue #44): the
|
||||
* app's launcher mark over the name, framed as a card. The active one carries a
|
||||
* primary border, a tinted container and a check; tapping selects it. The mark
|
||||
* is the same for both — only the label changes — so the card previews exactly
|
||||
* what the home screen will read.
|
||||
*/
|
||||
@Composable
|
||||
private fun AppNameOptionCard(
|
||||
name: LauncherName,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
val borderColor = if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
}
|
||||
val containerColor = if (selected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(containerColor)
|
||||
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 20.dp, horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// The adaptive launcher mark, reconstructed as a squircle (as in the
|
||||
// onboarding BrandHero) so it renders identically everywhere.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(colorResource(R.color.ic_launcher_background)),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = launcherNameLabel(name),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
)
|
||||
// Selection indicator: a filled check when active, an empty ring otherwise.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||
.then(
|
||||
if (selected) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
|
||||
},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/** New event form: which fields it opens with, and how it behaves. */
|
||||
@Composable
|
||||
internal fun EventFormScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_event_form),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
SettingsHint(stringResource(R.string.settings_form_fields_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val fields = EventFormField.entries
|
||||
fields.forEachIndexed { index, field ->
|
||||
val checked = field in state.defaultFormFields
|
||||
GroupedRow(
|
||||
title = stringResource(eventFormFieldLabel(field)),
|
||||
position = positionOf(index, fields.size),
|
||||
// Same icon the field carries in the new-event form, so a toggle
|
||||
// is easy to match to the field it controls.
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = eventFormFieldIcon(field),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setFormFieldDefault(field, !checked) },
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-focus the title on a new event (issue #10) — on by default, since
|
||||
// most events get a title; raising the keyboard saves a tap. Off lets you
|
||||
// set the time/calendar first without the keyboard in the way.
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_autofocus_title),
|
||||
summary = stringResource(R.string.settings_autofocus_title_hint),
|
||||
position = Position.Alone,
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Keyboard,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.autofocusEventTitle,
|
||||
onCheckedChange = { viewModel.setAutofocusEventTitle(it) },
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
||||
)
|
||||
|
||||
// Per-event colour on calendars that publish no colour set (some
|
||||
// CalDAV) — off by default, with the honest caveat that the colour may
|
||||
// not survive their next sync. Local and palette calendars ignore it.
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_color_unsupported),
|
||||
summary = stringResource(R.string.settings_color_unsupported_hint),
|
||||
position = Position.Alone,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.allowColorOnUnsupportedCalendars,
|
||||
onCheckedChange = { viewModel.setAllowColorOnUnsupportedCalendars(it) },
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.setAllowColorOnUnsupportedCalendars(
|
||||
!state.allowColorOnUnsupportedCalendars,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import kotlinx.datetime.LocalTime
|
||||
|
||||
/**
|
||||
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
|
||||
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
|
||||
* the pref is set either way; the OS permission is the real gate.
|
||||
*/
|
||||
@Composable
|
||||
internal fun NotificationsScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
onOpenSpecialDates: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
|
||||
val toggleReminders: (Boolean) -> Unit = { enabled ->
|
||||
viewModel.setRemindersEnabled(enabled)
|
||||
val needsPermission = enabled &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.POST_NOTIFICATIONS,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
if (needsPermission) {
|
||||
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
var showDefaultReminder by remember { mutableStateOf(false) }
|
||||
var showAllDayReminder by remember { mutableStateOf(false) }
|
||||
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||
var showSnooze by remember { mutableStateOf(false) }
|
||||
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||
var calendarSectionExpanded by remember { mutableStateOf(false) }
|
||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_notifications),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_reminders),
|
||||
summary = stringResource(R.string.settings_reminders_hint),
|
||||
position = Position.Top,
|
||||
trailing = {
|
||||
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
|
||||
},
|
||||
onClick = { toggleReminders(!state.remindersEnabled) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = reminderChoiceLabel(state.defaultReminderMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = { showDefaultReminder = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = { showAllDayReminder = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_allday_reminder_time),
|
||||
summary = stringResource(
|
||||
R.string.settings_allday_reminder_time_hint,
|
||||
settingsTimeOfDay(state.allDayReminderTimeMinutes),
|
||||
),
|
||||
position = Position.Bottom,
|
||||
onClick = { showAllDayReminderTime = true },
|
||||
)
|
||||
|
||||
// Delivery reliability + snooze: both are global reminder-delivery
|
||||
// settings, so they sit with the defaults above rather than below the
|
||||
// long per-calendar list. Reliability is a soft, optional battery-
|
||||
// optimisation exemption (system-settings deep-link, no special
|
||||
// permission); shown as live status, reversible at any time.
|
||||
Spacer(Modifier.height(24.dp))
|
||||
val batteryExempt = rememberBatteryOptimizationExempt()
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_reliable_delivery),
|
||||
summary = if (batteryExempt) {
|
||||
stringResource(R.string.settings_reliable_delivery_exempt)
|
||||
} else {
|
||||
stringResource(R.string.settings_reliable_delivery_hint)
|
||||
},
|
||||
position = Position.Top,
|
||||
trailing = if (batteryExempt) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { openBatteryOptimizationSettings(context) },
|
||||
)
|
||||
|
||||
// Snooze: how long the notification's "Snooze" action defers a reminder.
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
summary = snoozeDurationLabel(state.snoozeMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { showSnooze = true },
|
||||
)
|
||||
|
||||
// Per-calendar overrides: the whole section folds behind one header to
|
||||
// keep the screen tidy. Expanded, each writable calendar gets its own
|
||||
// expandable card that may keep, drop, or replace the global default —
|
||||
// separately for timed and all-day events.
|
||||
if (state.writableCalendars.isNotEmpty()) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_calendar_reminders_title),
|
||||
summary = stringResource(R.string.settings_calendar_reminders_hint),
|
||||
position = Position.Alone,
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = { calendarSectionExpanded = !calendarSectionExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = calendarSectionExpanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column {
|
||||
state.writableCalendars.forEach { calendar ->
|
||||
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
|
||||
// Calendar card; tapping expands it into a grouped list
|
||||
// of three (the card + the timed and all-day rows).
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = if (expanded) Position.Top else Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
expandedCalendars = if (expanded) {
|
||||
expandedCalendars - calendar.id
|
||||
} else {
|
||||
expandedCalendars + calendar.id
|
||||
}
|
||||
},
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column {
|
||||
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||
)
|
||||
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSnooze) {
|
||||
SnoozeDurationPicker(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
presets = SNOOZE_PRESETS,
|
||||
selected = state.snoozeMinutes,
|
||||
label = { snoozeDurationLabel(it) },
|
||||
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||
onDismiss = { showSnooze = false },
|
||||
)
|
||||
}
|
||||
if (showDefaultReminder) {
|
||||
ReminderDefaultPicker(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
presets = REMINDER_PRESETS,
|
||||
selected = state.defaultReminderMinutes.toReminderChoice(),
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
|
||||
onDismiss = { showDefaultReminder = false },
|
||||
)
|
||||
}
|
||||
if (showAllDayReminder) {
|
||||
ReminderDefaultPicker(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
presets = ALLDAY_REMINDER_PRESETS,
|
||||
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
|
||||
onDismiss = { showAllDayReminder = false },
|
||||
leadTimeSummary = allDayFiringTimeSummary(state.allDayReminderTimeMinutes),
|
||||
)
|
||||
}
|
||||
if (showAllDayReminderTime) {
|
||||
TimePickerAlert(
|
||||
initial = LocalTime(
|
||||
state.allDayReminderTimeMinutes / 60,
|
||||
state.allDayReminderTimeMinutes % 60,
|
||||
),
|
||||
onConfirm = {
|
||||
viewModel.setAllDayReminderTimeMinutes(it.hour * 60 + it.minute)
|
||||
showAllDayReminderTime = false
|
||||
},
|
||||
onDismiss = { showAllDayReminderTime = false },
|
||||
)
|
||||
}
|
||||
overrideDialog?.let { target ->
|
||||
val map = if (target.isAllDay) {
|
||||
state.perCalendarAllDayReminderOverride
|
||||
} else {
|
||||
state.perCalendarReminderOverride
|
||||
}
|
||||
ReminderDefaultPicker(
|
||||
title = stringResource(
|
||||
if (target.isAllDay) {
|
||||
R.string.settings_default_reminder_allday
|
||||
} else {
|
||||
R.string.settings_default_reminder
|
||||
},
|
||||
),
|
||||
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
||||
selected = map.reminderOverrideFor(target.calendarId),
|
||||
allowInherit = true,
|
||||
onSelect = {
|
||||
if (target.isAllDay) {
|
||||
viewModel.setCalendarAllDayReminderOverride(target.calendarId, it)
|
||||
} else {
|
||||
viewModel.setCalendarReminderOverride(target.calendarId, it)
|
||||
}
|
||||
},
|
||||
onDismiss = { overrideDialog = null },
|
||||
leadTimeSummary = if (target.isAllDay) {
|
||||
allDayFiringTimeSummary(state.allDayReminderTimeMinutes)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||
|
||||
/** A global default (empty = none) as a picker choice for selection highlighting. */
|
||||
private fun List<Int>.toReminderChoice(): ReminderOverride =
|
||||
if (isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(this)
|
||||
|
||||
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
||||
private fun ReminderOverride.toMinutesList(): List<Int> =
|
||||
(this as? ReminderOverride.Minutes)?.minutes ?: emptyList()
|
||||
|
||||
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||
@Composable
|
||||
private fun calendarOverrideSummary(
|
||||
choice: ReminderOverride,
|
||||
globalDefault: List<Int>,
|
||||
): String = when (choice) {
|
||||
ReminderOverride.Inherit ->
|
||||
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||
}
|
||||
|
||||
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||
|
||||
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
|
||||
@Composable
|
||||
private fun snoozeDurationLabel(minutes: Int): String =
|
||||
if (minutes % 60 == 0) {
|
||||
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||
* `ON_RESUME` so the row reflects a change the user just made in system
|
||||
* settings without needing to leave and re-enter the screen.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberBatteryOptimizationExempt(): Boolean {
|
||||
val context = LocalContext.current
|
||||
var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
exempt = isIgnoringBatteryOptimizations(context)
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
return exempt
|
||||
}
|
||||
|
||||
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||
val power = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return power.isIgnoringBatteryOptimizations(context.packageName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the user straight to Calendula's exemption: the direct
|
||||
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` dialog ("Allow Calendula to ignore
|
||||
* battery optimisation?") rather than the full app list they'd have to scroll.
|
||||
* Falls back to the optimisation list if the OS refuses the direct intent.
|
||||
*/
|
||||
private fun openBatteryOptimizationSettings(context: Context) {
|
||||
val direct = Intent(
|
||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
"package:${context.packageName}".toUri(),
|
||||
)
|
||||
if (runCatching { context.startActivity(direct) }.isFailure) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDayHeader
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* The "past events" chooser for the Agenda (#69 follow-up).
|
||||
*
|
||||
* "Show / dim / hide" is the case where the word cannot carry the choice —
|
||||
* *how far* does dimmed fade, and what is left behind when finished events are
|
||||
* hidden? So a stand-in agenda day sits above the options and re-renders as you
|
||||
* pick one, using the very rows the Agenda draws: dimming is
|
||||
* [AgendaEventRow]'s own `dimmed` flag, and hiding drops the row exactly as
|
||||
* [de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen] filters it — so the
|
||||
* preview cannot claim an effect the screen doesn't have.
|
||||
*
|
||||
* Like the Month style and App name pickers, selecting applies immediately and
|
||||
* leaves the picker open: closing would hide the thing the screen is for. Back
|
||||
* exits.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PastEventsPicker(
|
||||
selected: PastEventDisplay,
|
||||
onSelect: (PastEventDisplay) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val options = PastEventDisplay.entries
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_past_events),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.height(PREVIEW_HEIGHT),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = selected,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = "past-events-preview",
|
||||
) { shown ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PREVIEW_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
PastEventsPreview(mode = shown)
|
||||
}
|
||||
}
|
||||
}
|
||||
PickerDescription(stringResource(R.string.settings_past_events_hint))
|
||||
options.forEachIndexed { index, mode ->
|
||||
val isSelected = mode == selected
|
||||
GroupedRow(
|
||||
title = stringResource(pastEventDisplayLabelRes(mode)),
|
||||
position = positionOf(index, options.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// Applies straight away; the preview above is the confirmation.
|
||||
onClick = { onSelect(mode) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in agenda day in a given [mode]: three events on today, the first two
|
||||
* already finished. The times are fixed rather than derived from the clock, so
|
||||
* the preview reads the same at any hour — what varies is only what [mode] does
|
||||
* to the two finished rows.
|
||||
*/
|
||||
@Composable
|
||||
private fun PastEventsPreview(mode: PastEventDisplay, modifier: Modifier = Modifier) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val titles = listOf(
|
||||
stringResource(R.string.settings_past_events_sample_morning),
|
||||
stringResource(R.string.settings_past_events_sample_midday),
|
||||
stringResource(R.string.settings_past_events_sample_evening),
|
||||
)
|
||||
val sample = remember(today, zone, titles) { samplePastDay(today, zone, titles) }
|
||||
// Hiding drops the finished rows outright; showing and dimming keep them,
|
||||
// and only dimming fades them — the same three-way split AgendaContent makes.
|
||||
val visible = if (mode == PastEventDisplay.HIDE) sample.filterNot { it.hasPassed } else sample
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clipToBounds()
|
||||
// A preview is a picture, not a control: swallow touches before the
|
||||
// rows' own clickables see them, and keep it out of the reading order —
|
||||
// the options below already say what is selected.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics { },
|
||||
) {
|
||||
AgendaDayHeader(date = today, today = today, onOpenDay = {})
|
||||
visible.forEachIndexed { index, row ->
|
||||
AgendaEventRow(
|
||||
event = row.event,
|
||||
day = today,
|
||||
zone = zone,
|
||||
position = positionOf(index, visible.size),
|
||||
dimmed = mode == PastEventDisplay.DIM && row.hasPassed,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One stand-in row plus whether it counts as finished. */
|
||||
private class SampleRow(val event: EventInstance, val hasPassed: Boolean)
|
||||
|
||||
/**
|
||||
* Today's stand-in events, [titles] in order: two finished (morning, midday) and
|
||||
* one still to come.
|
||||
*
|
||||
* Colours are raw ARGB on purpose — that is what the provider hands out for an
|
||||
* event, so a theme token here would misrepresent what the agenda renders.
|
||||
*/
|
||||
private fun samplePastDay(today: LocalDate, zone: TimeZone, titles: List<String>): List<SampleRow> =
|
||||
listOf(
|
||||
sampleRow(1, titles[0], today, zone, 9, 10, 0xFF3F7BD4.toInt(), hasPassed = true),
|
||||
sampleRow(2, titles[1], today, zone, 12, 13, 0xFFCE5B4C.toInt(), hasPassed = true),
|
||||
sampleRow(3, titles[2], today, zone, 18, 19, 0xFF4E9A6A.toInt(), hasPassed = false),
|
||||
)
|
||||
|
||||
private fun sampleRow(
|
||||
id: Long,
|
||||
title: String,
|
||||
day: LocalDate,
|
||||
zone: TimeZone,
|
||||
startHour: Int,
|
||||
endHour: Int,
|
||||
color: Int,
|
||||
hasPassed: Boolean,
|
||||
): SampleRow = SampleRow(
|
||||
event = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = title,
|
||||
start = day.atTime(startHour, 0).toInstant(zone),
|
||||
end = day.atTime(endHour, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = color,
|
||||
location = null,
|
||||
),
|
||||
hasPassed = hasPassed,
|
||||
)
|
||||
|
||||
/** Room for the day header plus its three two-line rows, so hiding shortens the
|
||||
* list inside a box that keeps its height instead of the page jumping a row. */
|
||||
private val PREVIEW_HEIGHT = 288.dp
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
@@ -0,0 +1,156 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/**
|
||||
* Pieces shared by the settings hub and its sub-screens (`*Settings.kt`). Each
|
||||
* sub-screen owns whatever only it uses; anything two of them need lives here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Token-based accent for a leading icon chip (container / on-container pair).
|
||||
* Neutral chips stay grey; accents are drawn from the M3 scheme so they adapt
|
||||
* to theme, dark mode and dynamic colour.
|
||||
*
|
||||
* The chips are a scanning aid, so the rule is **no two rows in one group share
|
||||
* an accent** — a group in a single colour carries no more information than no
|
||||
* colour at all. [Neutral] is not a fourth colour to rotate through but a
|
||||
* deliberate step back, for rows that are reference or last-resort rather than
|
||||
* somewhere you routinely go.
|
||||
*/
|
||||
internal enum class ChipAccent { Neutral, Primary, Secondary, Tertiary }
|
||||
|
||||
/**
|
||||
* Leading circular icon chip. Colours come from the M3 scheme via a container /
|
||||
* on-container token pair, so each accent stays correctly paired across theme,
|
||||
* dark mode and dynamic colour.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val (background, iconColor) = when (accent) {
|
||||
ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant
|
||||
ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer
|
||||
ChipAccent.Secondary -> scheme.secondaryContainer to scheme.onSecondaryContainer
|
||||
ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = iconColor,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small primary-coloured group label, matching the Calendars settings screen.
|
||||
*
|
||||
* Starts on [GroupedListInset] — the cards' own edge — so a header, the hint
|
||||
* under it and the group it names all share one left margin.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */
|
||||
@Composable
|
||||
internal fun SettingsHint(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun openUrl(context: Context, url: String) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
runCatching { context.startActivity(intent) }
|
||||
}
|
||||
|
||||
/** Label for a global-default choice: empty → "None", else the lead times joined. */
|
||||
@Composable
|
||||
internal fun reminderChoiceLabel(minutes: List<Int>): String {
|
||||
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
|
||||
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Lead times offered for the all-day default — day-scale, since a "minutes
|
||||
* before midnight" reminder on an all-day event is rarely what's wanted. Shared
|
||||
* with the contact special-dates calendars, whose events are all all-day.
|
||||
*/
|
||||
internal val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
|
||||
|
||||
/**
|
||||
* A minute-of-day ("09:00") in the app's *own* 12/24-hour convention — the same
|
||||
* [LocalUse24HourFormat] the agenda and event rows read, so a time shown in
|
||||
* settings can't disagree with the times shown everywhere else.
|
||||
*/
|
||||
@Composable
|
||||
internal fun settingsTimeOfDay(minutesOfDay: Int): String =
|
||||
formatMinuteOfDay(minutesOfDay, LocalUse24HourFormat.current, currentLocale())
|
||||
|
||||
/**
|
||||
* Second line for an all-day lead-time row: the clock time it fires at.
|
||||
*
|
||||
* An all-day occurrence has no time of its own, so a reminder's stored offset
|
||||
* only decides *which day* it belongs to — the hour always comes from the
|
||||
* global "show all-day reminders at" setting (see
|
||||
* [de.jeanlucmakiola.calendula.domain.reminders.planReminders]). Naming it on
|
||||
* every row is what makes "At time of event" readable on an all-day default at
|
||||
* all: it means that day at 09:00, not midnight.
|
||||
*
|
||||
* Shared by the notification defaults, the per-calendar overrides and the
|
||||
* contact special-dates calendars — all three read the same one setting.
|
||||
*/
|
||||
@Composable
|
||||
internal fun allDayFiringTimeSummary(allDayReminderTimeMinutes: Int): @Composable (Int) -> String? {
|
||||
val summary = stringResource(
|
||||
R.string.settings_allday_reminder_fires_at,
|
||||
settingsTimeOfDay(allDayReminderTimeMinutes),
|
||||
)
|
||||
return { summary }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ 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.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
|
||||
/**
|
||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||
@@ -48,6 +49,8 @@ data class SettingsUiState(
|
||||
val agendaShowToday: Boolean = true,
|
||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||
val agendaShowRangeBar: Boolean = true,
|
||||
/** The size step the agenda widget draws its text at (#51). */
|
||||
val widgetSize: WidgetSize = WidgetSize.SMALL,
|
||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||
val defaultView: CalendarView = CalendarView.Week,
|
||||
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
||||
|
||||
@@ -27,6 +27,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
@@ -38,9 +39,11 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SIZE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -68,15 +71,21 @@ class SettingsViewModel @Inject constructor(
|
||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||
private val launcherNameManager: LauncherNameManager,
|
||||
private val reminderScanner: ReminderScanner,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
||||
/** Writable calendars — the only ones that take a per-calendar reminder override. */
|
||||
/**
|
||||
* Writable calendars that are switched on — the only ones that take a
|
||||
* per-calendar reminder override. A calendar switched off in Settings →
|
||||
* Calendars is `VISIBLE = 0`, so the provider schedules no alarms for it and
|
||||
* a default reminder configured there could never fire (#75).
|
||||
*/
|
||||
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||
.map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
|
||||
.catch { emit(emptyList()) }
|
||||
|
||||
val state: StateFlow<SettingsUiState> =
|
||||
@@ -155,8 +164,9 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.quickSwitchConfig,
|
||||
prefs.drawerViewOrder,
|
||||
prefs.monthViewStyle,
|
||||
) { quickSwitch, drawer, monthStyle ->
|
||||
ViewCustomization(quickSwitch, drawer, monthStyle)
|
||||
prefs.widgetSize,
|
||||
) { quickSwitch, drawer, monthStyle, widgetSize ->
|
||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize)
|
||||
},
|
||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||
@@ -179,6 +189,7 @@ class SettingsViewModel @Inject constructor(
|
||||
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||
widgetSize = misc.viewCustomization.widgetSize,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
@@ -293,6 +304,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val quickSwitch: QuickSwitchConfig,
|
||||
val drawerOrder: List<CalendarView>,
|
||||
val monthViewStyle: MonthViewStyle,
|
||||
val widgetSize: WidgetSize,
|
||||
)
|
||||
|
||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||
@@ -471,6 +483,28 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the size the agenda widget draws its text at (#51). Pushed into every
|
||||
* instance's Glance state and recomposed — the same reliable-update path as
|
||||
* [setAgendaWidgetRange], since `updateAll` alone won't re-run the data
|
||||
* preamble a live session already ran.
|
||||
*
|
||||
* Agenda-only on purpose: the month widget sizes itself from the space it is
|
||||
* given and always has, so it takes no size setting (#103).
|
||||
*/
|
||||
fun setWidgetSize(size: WidgetSize) {
|
||||
viewModelScope.launch {
|
||||
prefs.setWidgetSize(size)
|
||||
widgetRefreshMutex.withLock {
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_SIZE_KEY] = size.name }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAgendaShowToday(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setAgendaShowToday(enabled)
|
||||
@@ -577,8 +611,16 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A scan follows the write, in that order. Switching reminders off cancels
|
||||
* the scan alarm, so switching them back on has to arm a new one — nothing
|
||||
* else would until a provider change or the daily worker came along (#75).
|
||||
*/
|
||||
fun setRemindersEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(enabled)
|
||||
reminderScanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutofocusEventTitle(enabled: Boolean) {
|
||||
@@ -593,8 +635,12 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||
}
|
||||
|
||||
/** The armed alarm was planned for the old hour, so re-plan against the new one. */
|
||||
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
||||
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
|
||||
viewModelScope.launch {
|
||||
prefs.setAllDayReminderTimeMinutes(minutesOfDay)
|
||||
reminderScanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
fun setSnoozeMinutes(minutes: Int) {
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contact special dates (issue #15)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
internal fun SpecialDatesScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
||||
// The all-day reminder hour lives in the general settings state; the reminder
|
||||
// picker below names it, since that is when these reminders actually fire.
|
||||
val settings by viewModel.state.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,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
// 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] ?: ReminderOverride.None,
|
||||
// Managed calendars own their reminders outright — no "inherit global".
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
|
||||
onDismiss = { reminderPickerType = null },
|
||||
// Special dates are all-day events, so their reminders fire at the
|
||||
// one global all-day hour like any other all-day reminder.
|
||||
leadTimeSummary = allDayFiringTimeSummary(settings.allDayReminderTimeMinutes),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
|
||||
private fun specialDatesReminderMinutes(choice: ReminderOverride?): List<Int> =
|
||||
(choice as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
|
||||
@Composable
|
||||
private fun SpecialDatesDisableDialog(
|
||||
message: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_special_dates_disable_title)) },
|
||||
text = { Text(message) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(stringResource(R.string.settings_special_dates_disable_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
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) }
|
||||
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 = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(text) },
|
||||
enabled = text.isNotBlank(),
|
||||
) { Text(stringResource(R.string.dialog_save)) }
|
||||
},
|
||||
dismissButton = {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.ReorderableColumn
|
||||
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
|
||||
/**
|
||||
* Views: everything that changes how a calendar view reads, grouped by the view
|
||||
* it belongs to, plus the two cross-view ordering lists (#24, #69).
|
||||
*
|
||||
* The first group holds what applies everywhere (default view, week start, time
|
||||
* format); the rest are per-view. Anything that styles the *app* rather than a
|
||||
* view (theme, fonts) is in [AppearanceScreen]; the widget's own copies of the
|
||||
* agenda settings are in [WidgetsScreen].
|
||||
*
|
||||
* The quick-switch cycle and the navigation-drawer list are two independent
|
||||
* orders — 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
|
||||
internal fun ViewsScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var showMonthStyle by remember { mutableStateOf(false) }
|
||||
var showDefaultView by remember { mutableStateOf(false) }
|
||||
var showWeekStart by remember { mutableStateOf(false) }
|
||||
var showTimeFormat by remember { mutableStateOf(false) }
|
||||
var showPastEvents by remember { mutableStateOf(false) }
|
||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_views),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
val config = state.quickSwitchConfig
|
||||
|
||||
// What holds for every view, above the per-view groups.
|
||||
SectionHeader(stringResource(R.string.settings_views_all_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
summary = stringResource(state.defaultView.labelRes),
|
||||
position = Position.Top,
|
||||
onClick = { showDefaultView = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
summary = weekStartLabel(state.weekStart),
|
||||
position = Position.Middle,
|
||||
onClick = { showWeekStart = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_time_format),
|
||||
summary = timeFormatLabel(state.timeFormat),
|
||||
position = Position.Middle,
|
||||
onClick = { showTimeFormat = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_today_toolbar),
|
||||
summary = stringResource(R.string.settings_today_toolbar_summary),
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.todayButtonInToolbar,
|
||||
onCheckedChange = viewModel::setTodayButtonInToolbar,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setTodayButtonInToolbar(!state.todayButtonInToolbar) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_dim_completed),
|
||||
summary = stringResource(R.string.settings_dim_completed_summary),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.dimCompletedEvents,
|
||||
onCheckedChange = viewModel::setDimCompletedEvents,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_month_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_month_view_style),
|
||||
summary = stringResource(state.monthViewStyle.labelRes),
|
||||
position = Position.Top,
|
||||
onClick = { showMonthStyle = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_week_numbers),
|
||||
summary = stringResource(R.string.settings_week_numbers_summary),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showWeekNumbers,
|
||||
onCheckedChange = viewModel::setShowWeekNumbers,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setShowWeekNumbers(!state.showWeekNumbers) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_week_day_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_hour_lines),
|
||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||
position = Position.Alone,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showHourLines,
|
||||
onCheckedChange = viewModel::setShowHourLines,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_agenda_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
summary = agendaRangeLabel(state.agendaScreenRange),
|
||||
position = Position.Top,
|
||||
onClick = { showAgendaScreenRange = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_past_events),
|
||||
summary = pastEventDisplayLabel(state.pastEventDisplay),
|
||||
position = Position.Middle,
|
||||
onClick = { showPastEvents = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_show_today),
|
||||
summary = stringResource(R.string.settings_agenda_show_today_hint),
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.agendaShowToday,
|
||||
onCheckedChange = viewModel::setAgendaShowToday,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_range_bar),
|
||||
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.agendaShowRangeBar,
|
||||
onCheckedChange = viewModel::setAgendaShowRangeBar,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showMonthStyle) {
|
||||
MonthViewStylePicker(
|
||||
selected = state.monthViewStyle,
|
||||
// The preview is a real grid, so it uses the real week start too.
|
||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||
onSelect = viewModel::setMonthViewStyle,
|
||||
onDismiss = { showMonthStyle = false },
|
||||
)
|
||||
}
|
||||
if (showDefaultView) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
header = { PickerDescription(stringResource(R.string.settings_default_view_hint)) },
|
||||
predictiveBack = true,
|
||||
options = IMPLEMENTED_VIEWS,
|
||||
selected = state.defaultView,
|
||||
label = { stringResource(it.labelRes) },
|
||||
// The same icon each view carries in the drawer and the switcher
|
||||
// pill, so the row is matched to the view by eye, not by reading.
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = it.icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onSelect = viewModel::setDefaultView,
|
||||
onDismiss = { showDefaultView = false },
|
||||
)
|
||||
}
|
||||
if (showWeekStart) {
|
||||
WeekStartPicker(
|
||||
selected = state.weekStart,
|
||||
// The preview is a real grid, so it uses the user's own month style.
|
||||
monthStyle = state.monthViewStyle,
|
||||
options = WEEK_START_OPTIONS,
|
||||
onSelect = viewModel::setWeekStart,
|
||||
onDismiss = { showWeekStart = false },
|
||||
)
|
||||
}
|
||||
if (showTimeFormat) {
|
||||
val locale = currentLocale()
|
||||
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_time_format),
|
||||
header = { PickerDescription(stringResource(R.string.settings_time_format_hint)) },
|
||||
predictiveBack = true,
|
||||
options = TimeFormatPref.entries,
|
||||
selected = state.timeFormat,
|
||||
label = { timeFormatLabel(it) },
|
||||
// Each option renders the same sample time the way it would write
|
||||
// it — an afternoon one, since that is where 12h and 24h diverge.
|
||||
// "Automatic" additionally says which of the two it resolves to now.
|
||||
summary = { pref ->
|
||||
val sample = formatTimeOfDay(
|
||||
hour = SAMPLE_HOUR,
|
||||
minute = SAMPLE_MINUTE,
|
||||
is24Hour = when (pref) {
|
||||
TimeFormatPref.AUTO -> systemIs24Hour
|
||||
TimeFormatPref.TWELVE_HOUR -> false
|
||||
TimeFormatPref.TWENTY_FOUR_HOUR -> true
|
||||
},
|
||||
locale = locale,
|
||||
)
|
||||
if (pref == TimeFormatPref.AUTO) {
|
||||
stringResource(R.string.settings_time_format_auto_summary, sample)
|
||||
} else {
|
||||
sample
|
||||
}
|
||||
},
|
||||
onSelect = viewModel::setTimeFormat,
|
||||
onDismiss = { showTimeFormat = false },
|
||||
)
|
||||
}
|
||||
if (showPastEvents) {
|
||||
PastEventsPicker(
|
||||
selected = state.pastEventDisplay,
|
||||
onSelect = viewModel::setPastEventDisplay,
|
||||
onDismiss = { showPastEvents = false },
|
||||
)
|
||||
}
|
||||
if (showAgendaScreenRange) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
description = stringResource(R.string.settings_agenda_range_hint),
|
||||
selected = state.agendaScreenRange,
|
||||
// Resolving each option to real dates needs the same week start the
|
||||
// agenda itself windows by.
|
||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||
onSelect = viewModel::setAgendaScreenRange,
|
||||
onDismiss = { showAgendaScreenRange = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample time the format options render: afternoon, where 12h and 24h differ. */
|
||||
private const val SAMPLE_HOUR = 14
|
||||
private const val SAMPLE_MINUTE = 30
|
||||
|
||||
/** 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
|
||||
private val WEEK_START_OPTIONS: List<WeekStartPref> =
|
||||
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
|
||||
|
||||
@Composable
|
||||
internal fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
|
||||
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
|
||||
// Localised full weekday name, so any of the seven days reads naturally
|
||||
// without a per-day string resource.
|
||||
is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1)
|
||||
.getDisplayName(JavaTextStyle.FULL, currentLocale())
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
|
||||
when (pref) {
|
||||
TimeFormatPref.AUTO -> R.string.settings_time_format_auto
|
||||
TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h
|
||||
TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun pastEventDisplayLabel(mode: PastEventDisplay): String =
|
||||
stringResource(pastEventDisplayLabelRes(mode))
|
||||
|
||||
/** Shared with [PastEventsPicker], which needs the id rather than the string. */
|
||||
@StringRes
|
||||
internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode) {
|
||||
PastEventDisplay.SHOW -> R.string.settings_past_events_show
|
||||
PastEventDisplay.DIM -> R.string.settings_past_events_dim
|
||||
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/**
|
||||
* The week-start chooser, with the grid it rearranges shown above it.
|
||||
*
|
||||
* Which column a week opens on is the most visual setting in the app, and the
|
||||
* one hardest to hold in your head from a weekday name alone. The preview is
|
||||
* the same [MonthStylePreview] the Month style picker uses, drawn in the user's
|
||||
* *own* month style, so what moves is exactly what will move on the real
|
||||
* screen. It is short here (the option list runs to eight rows), which the
|
||||
* preview supports natively by scaling.
|
||||
*
|
||||
* "Follow the system" carries the day it currently resolves to as its summary —
|
||||
* otherwise the one automatic option is the only one whose effect is invisible.
|
||||
*
|
||||
* A preview picker, so selecting applies at once and keeps the picker open;
|
||||
* back exits (see `FullScreenPicker`).
|
||||
*/
|
||||
@Composable
|
||||
internal fun WeekStartPicker(
|
||||
selected: WeekStartPref,
|
||||
monthStyle: MonthViewStyle,
|
||||
options: List<WeekStartPref>,
|
||||
onSelect: (WeekStartPref) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val resolved = selected.resolveFirstDay(locale)
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.height(PREVIEW_HEIGHT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = resolved,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = "week-start-preview",
|
||||
) { day ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PREVIEW_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
MonthStylePreview(
|
||||
style = monthStyle,
|
||||
weekStart = day,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
PickerDescription(stringResource(R.string.settings_week_start_hint))
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = weekStartLabel(option),
|
||||
// Only the automatic option needs a second line: it is the one
|
||||
// whose effect the label doesn't name.
|
||||
summary = if (option == WeekStartPref.Auto) {
|
||||
stringResource(
|
||||
R.string.settings_week_start_auto_summary,
|
||||
weekStartLabel(WeekStartPref.Day(option.resolveFirstDay(locale))),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
position = positionOf(index, options.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(option) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Short enough to leave the first options on screen under it. */
|
||||
private val PREVIEW_HEIGHT = 200.dp
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
@@ -0,0 +1,160 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.app.StatusBarManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Widgets & tiles (#69): the home-screen widgets' own settings and the Quick
|
||||
* Settings tile shortcut — the app's system surfaces, collected in one place.
|
||||
*
|
||||
* The agenda widget keeps its own range and text size, deliberately separate
|
||||
* from the Agenda *screen*'s range in [ViewsScreen]: a widget is glanced at, a
|
||||
* screen is browsed, and having the two rows sit next to each other (as they did
|
||||
* before) made them easy to mistake for one another.
|
||||
*/
|
||||
@Composable
|
||||
internal fun WidgetsScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||
var showWidgetSize by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_widgets),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
SettingsHint(stringResource(R.string.settings_widgets_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_widget_range),
|
||||
summary = agendaRangeLabel(state.agendaWidgetRange),
|
||||
position = Position.Top,
|
||||
onClick = { showAgendaWidgetRange = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_widget_size),
|
||||
summary = widgetSizeLabel(state.widgetSize),
|
||||
position = Position.Bottom,
|
||||
onClick = { showWidgetSize = true },
|
||||
)
|
||||
|
||||
// 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
|
||||
// the QS editor, so the row simply doesn't appear here.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
QuickSettingsTileRow()
|
||||
}
|
||||
}
|
||||
|
||||
if (showAgendaWidgetRange) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_widget_range),
|
||||
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
||||
selected = state.agendaWidgetRange,
|
||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||
onSelect = viewModel::setAgendaWidgetRange,
|
||||
onDismiss = { showAgendaWidgetRange = false },
|
||||
)
|
||||
}
|
||||
if (showWidgetSize) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_widget_size),
|
||||
header = { PickerDescription(stringResource(R.string.settings_widget_size_hint)) },
|
||||
predictiveBack = true,
|
||||
options = WidgetSize.entries,
|
||||
selected = state.widgetSize,
|
||||
label = { widgetSizeLabel(it) },
|
||||
// A size name says nothing about what you get; the text scale it
|
||||
// draws at does. No live preview here on purpose: the widget is
|
||||
// Glance/RemoteViews, so a Compose mock-up would be a second
|
||||
// implementation free to drift from the real thing.
|
||||
summary = { widgetSizeSummary(it) },
|
||||
onSelect = viewModel::setWidgetSize,
|
||||
onDismiss = { showWidgetSize = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
@Composable
|
||||
private fun QuickSettingsTileRow() {
|
||||
val context = LocalContext.current
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_qs_tile),
|
||||
summary = stringResource(R.string.settings_qs_tile_hint),
|
||||
position = Position.Alone,
|
||||
onClick = { requestAddQsTile(context) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the system to add the "New event" Quick Settings tile (API 33+). The OS
|
||||
* shows its own confirmation dialog and handles the already-added case, so no
|
||||
* result handling is needed here.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
private fun requestAddQsTile(context: Context) {
|
||||
val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return
|
||||
statusBar.requestAddTileService(
|
||||
ComponentName(context, NewEventTileService::class.java),
|
||||
context.getString(R.string.qs_tile_new_event_label),
|
||||
Icon.createWithResource(context, R.drawable.ic_qs_new_event),
|
||||
context.mainExecutor,
|
||||
) { /* result code unused — the system surfaces its own feedback */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* What a size step actually does, as a percentage of the smallest one's event
|
||||
* text. Derived from the widget's own [metricsFor] table rather than written
|
||||
* out here, so a retuned step can't leave this line claiming the old number.
|
||||
*/
|
||||
@Composable
|
||||
private fun widgetSizeSummary(size: WidgetSize): String {
|
||||
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
|
||||
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
|
||||
return stringResource(R.string.settings_widget_size_summary, percent)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
|
||||
when (size) {
|
||||
WidgetSize.SMALL -> R.string.settings_widget_size_small
|
||||
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
|
||||
WidgetSize.LARGE -> R.string.settings_widget_size_large
|
||||
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
|
||||
},
|
||||
)
|
||||
@@ -74,6 +74,8 @@ sealed interface AgendaWidgetData {
|
||||
* from Glance state before an instance has its own state set.
|
||||
*/
|
||||
val savedShowToday: Boolean,
|
||||
/** Saved widget size step (#103) — the fallback before Glance state is set. */
|
||||
val savedWidgetSize: WidgetSize,
|
||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||
val now: Instant,
|
||||
) : AgendaWidgetData
|
||||
@@ -145,6 +147,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
savedRange = savedRange,
|
||||
savedPastDisplay = savedPastDisplay,
|
||||
savedShowToday = showToday,
|
||||
savedWidgetSize = prefs.widgetSize.first(),
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Size tiers a widget scales its typography and metrics across (#51).
|
||||
*
|
||||
* Shared by every Glance widget so the bucketing rule can't drift between them:
|
||||
* each widget keeps its own metrics table, but they all agree on *when* a widget
|
||||
* counts as compact, regular, large or extra-large. Both widgets already declare
|
||||
* [androidx.glance.appwidget.SizeMode.Exact], so the composition sees the live
|
||||
* size via `LocalSize.current` and passes it to [scaleFor].
|
||||
*
|
||||
* Kept in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing is
|
||||
* covered by plain JVM tests.
|
||||
*/
|
||||
internal enum class WidgetScale { COMPACT, REGULAR, LARGE, XLARGE }
|
||||
|
||||
/**
|
||||
* Chrome a widget spends before its first content row: outer vertical padding
|
||||
* plus a header row and its spacer. Subtracted from the raw height so the height
|
||||
* thresholds below talk about *usable* space rather than gross widget height.
|
||||
*/
|
||||
private val CHROME_HEIGHT = 60.dp
|
||||
|
||||
/**
|
||||
* Buckets a live widget size into a [WidgetScale] by **width**.
|
||||
*
|
||||
* Width is the right axis: it governs how much of a title fits on a row, so it's
|
||||
* what should drive type size. Height only decides how many rows are visible — a
|
||||
* tall, narrow widget wants *more events*, not bigger text — so it never raises
|
||||
* the tier. It does act as a **cap**, though: a genuinely squashed widget is
|
||||
* stepped back down so it can't keep oversized type in a sliver of space.
|
||||
*
|
||||
* The width thresholds are spread across the range a phone can actually produce
|
||||
* (~180dp up to roughly the screen width) rather than over a theoretical range,
|
||||
* so the tiers are reachable in practice. Calibrated on-device (Pixel / Nova): a
|
||||
* compact 222dp-wide widget stays COMPACT (the app's baseline, unchanged) and a
|
||||
* full-width 378dp one reaches LARGE. XLARGE is reserved for genuinely wide
|
||||
* surfaces — tablets, foldables, landscape — where the extra size reads well.
|
||||
*
|
||||
* The height cap is deliberately generous: it exists to catch a widget squashed
|
||||
* to one or two rows, **not** to gate ordinary placements. A full-width widget at
|
||||
* the usual three cells tall (~270dp) must still reach the tier its width earned
|
||||
* — that is exactly the resize #51 reports, and an aggressive cap would make the
|
||||
* whole feature a no-op for it.
|
||||
*/
|
||||
internal fun scaleFor(size: DpSize): WidgetScale {
|
||||
val byWidth = when {
|
||||
size.width < 260.dp -> WidgetScale.COMPACT
|
||||
size.width < 330.dp -> WidgetScale.REGULAR
|
||||
size.width < 420.dp -> WidgetScale.LARGE
|
||||
else -> WidgetScale.XLARGE
|
||||
}
|
||||
// Height can only ever pull the tier *down*, never push it up: a squashed
|
||||
// widget would otherwise keep the big type its width earned and look absurd
|
||||
// in the little space left. Keeping this a cap (rather than a second scaling
|
||||
// axis) is what preserves "tall and narrow shows more events, not bigger
|
||||
// text". Thresholds are usable height — roughly one, two and three rows of
|
||||
// breathing room once the header is paid for.
|
||||
val usable = size.height - CHROME_HEIGHT
|
||||
val heightCap = when {
|
||||
usable < 70.dp -> WidgetScale.COMPACT
|
||||
usable < 130.dp -> WidgetScale.REGULAR
|
||||
usable < 200.dp -> WidgetScale.LARGE
|
||||
else -> WidgetScale.XLARGE
|
||||
}
|
||||
return minOf(byWidth, heightCap)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
/**
|
||||
* The size step the **agenda** widget draws its text and rows at — a user
|
||||
* setting, not something derived from the widget's measured size (#51).
|
||||
*
|
||||
* The agenda widget used to bucket its live size (`SizeMode.Exact` +
|
||||
* `LocalSize.current`) into a tier and scale from that. The size a launcher
|
||||
* reports is not the size the widget is drawn into, so the tier it picked could
|
||||
* disagree with what was on screen; a size the user sets is predictable and is
|
||||
* what the #51 thread actually asked for.
|
||||
*
|
||||
* The month widget deliberately has no size setting: its grid divides whatever
|
||||
* width it is given by seven and always has, which is the behaviour to keep
|
||||
* (#103).
|
||||
*
|
||||
* [SMALL] is the default and reproduces the widget's original constants, so an
|
||||
* existing widget looks as it did until its owner turns the size up.
|
||||
*/
|
||||
enum class WidgetSize { SMALL, MEDIUM, LARGE, EXTRA_LARGE }
|
||||
@@ -4,12 +4,12 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetScale
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
|
||||
/**
|
||||
* Horizontal layout constants for an agenda event row. These don't scale with the
|
||||
* tier — a wider stripe or gap would eat title width, which is the thing the row
|
||||
* is short of — but [TEXT_INDENT] is *derived* from them so the day header and
|
||||
* size step — a wider stripe or gap would eat title width, which is the thing the
|
||||
* row is short of — but [TEXT_INDENT] is *derived* from them so the day header and
|
||||
* the "nothing left today" line can never drift out of alignment with the event
|
||||
* title column the way a hardcoded 19dp could.
|
||||
*/
|
||||
@@ -21,23 +21,9 @@ internal val STRIPE_GAP = 10.dp
|
||||
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
|
||||
|
||||
/**
|
||||
* The width band a *default* agenda placement can land in, per
|
||||
* `app/src/main/res/xml/appwidget_info_agenda.xml` (`android:minWidth="180dp"`,
|
||||
* `android:targetCellWidth="3"`). Measured at 222dp on a Pixel running Nova, but
|
||||
* launcher cell grids vary, so the whole band — not one measured point — has to
|
||||
* stay [WidgetScale.COMPACT] for the "default size is unchanged" promise of #51
|
||||
* to hold. A test pins that.
|
||||
*
|
||||
* If the provider's `targetCellWidth` ever changes, this band and the first
|
||||
* width threshold in [de.jeanlucmakiola.calendula.widget.scaleFor] must be
|
||||
* revisited together.
|
||||
*/
|
||||
internal val AGENDA_DEFAULT_WIDTH_BAND = 180.dp..255.dp
|
||||
|
||||
/**
|
||||
* Every size the agenda widget varies by tier. Values that genuinely shouldn't
|
||||
* grow (the horizontal row constants above, corner radii) stay constants rather
|
||||
* than routing through here.
|
||||
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely
|
||||
* shouldn't grow (the horizontal row constants above, corner radii) stay
|
||||
* constants rather than routing through here.
|
||||
*/
|
||||
internal data class AgendaMetrics(
|
||||
val title: TextUnit, // header "Upcoming"
|
||||
@@ -59,7 +45,7 @@ internal data class AgendaMetrics(
|
||||
* they scale with the accessibility font setting and the stripe does not —
|
||||
* at "Largest" the text outgrows the stripe and it visibly under-runs the row
|
||||
* it is supposed to mark. Multiplying by the same factor keeps them locked,
|
||||
* and at the default scale of 1.0 reproduces the tier's value exactly.
|
||||
* and at the default scale of 1.0 reproduces the step's value exactly.
|
||||
*/
|
||||
fun scaledForFont(fontScale: Float): AgendaMetrics =
|
||||
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
|
||||
@@ -72,18 +58,18 @@ internal data class AgendaMetrics(
|
||||
*
|
||||
* Two documented deviations:
|
||||
*
|
||||
* ‡ COMPACT's 13sp day header is off-scale. It is held there deliberately —
|
||||
* COMPACT reproduces the widget's original constants verbatim so a
|
||||
* default-sized widget looks exactly as it did (#51), and snapping it to
|
||||
* Title Small (14sp) would break that promise for a 1sp gain.
|
||||
* ‡ SMALL's 13sp day header is off-scale. It is held there deliberately —
|
||||
* SMALL reproduces the widget's original constants verbatim so a widget whose
|
||||
* owner never touches the size setting looks exactly as it did (#51), and
|
||||
* snapping it to Title Small (14sp) would break that promise for a 1sp gain.
|
||||
*
|
||||
* † Above Title Medium the M3 scale jumps 16 → 22 → 24 with nothing in between,
|
||||
* which is far too coarse for four widget tiers. Where a role would force a
|
||||
* ≥1.4x step between adjacent tiers we hold an interpolated value instead and
|
||||
* which is far too coarse for four size steps. Where a role would force a
|
||||
* ≥1.4x step between adjacent steps we hold an interpolated value instead and
|
||||
* mark it. The endpoints stay on real roles.
|
||||
*/
|
||||
|
||||
private val COMPACT_METRICS = AgendaMetrics(
|
||||
private val SMALL_METRICS = AgendaMetrics(
|
||||
title = 16.sp, // M3 Title Medium
|
||||
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
|
||||
eventTitle = 14.sp, // M3 Body Medium
|
||||
@@ -97,7 +83,7 @@ private val COMPACT_METRICS = AgendaMetrics(
|
||||
dayHeaderTopPad = 10.dp,
|
||||
)
|
||||
|
||||
private val REGULAR_METRICS = AgendaMetrics(
|
||||
private val MEDIUM_METRICS = AgendaMetrics(
|
||||
title = 18.sp, // †
|
||||
dayHeader = 14.sp, // M3 Title Small
|
||||
eventTitle = 16.sp, // M3 Body Large
|
||||
@@ -125,7 +111,7 @@ private val LARGE_METRICS = AgendaMetrics(
|
||||
dayHeaderTopPad = 12.dp,
|
||||
)
|
||||
|
||||
private val XLARGE_METRICS = AgendaMetrics(
|
||||
private val EXTRA_LARGE_METRICS = AgendaMetrics(
|
||||
title = 22.sp, // M3 Title Large
|
||||
dayHeader = 18.sp, // †
|
||||
eventTitle = 20.sp, // †
|
||||
@@ -139,12 +125,12 @@ private val XLARGE_METRICS = AgendaMetrics(
|
||||
dayHeaderTopPad = 14.dp,
|
||||
)
|
||||
|
||||
/** Indexed by [WidgetScale.ordinal] so lookup allocates nothing per recomposition. */
|
||||
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
|
||||
private val AGENDA_METRICS = listOf(
|
||||
COMPACT_METRICS,
|
||||
REGULAR_METRICS,
|
||||
SMALL_METRICS,
|
||||
MEDIUM_METRICS,
|
||||
LARGE_METRICS,
|
||||
XLARGE_METRICS,
|
||||
EXTRA_LARGE_METRICS,
|
||||
)
|
||||
|
||||
internal fun metricsFor(scale: WidgetScale): AgendaMetrics = AGENDA_METRICS[scale.ordinal]
|
||||
internal fun metricsFor(size: WidgetSize): AgendaMetrics = AGENDA_METRICS[size.ordinal]
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
@@ -62,7 +61,7 @@ import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
import de.jeanlucmakiola.calendula.widget.scaleFor
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
@@ -105,22 +104,27 @@ internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display
|
||||
*/
|
||||
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
|
||||
|
||||
/**
|
||||
* Per-instance Glance state key holding the chosen [WidgetSize]. Read reactively
|
||||
* in the composition for the same reason as [AGENDA_RANGE_KEY] — so changing the
|
||||
* setting reflects on the live widget without depending on the `provideGlance`
|
||||
* preamble re-running.
|
||||
*/
|
||||
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
|
||||
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
// Exact so the composition sees the widget's live size and can scale type/rows
|
||||
// from it ([scaleFor]/[metricsFor]); at the default size that resolves to
|
||||
// COMPACT, i.e. the layout is unchanged (#51). MonthWidget already does the
|
||||
// same.
|
||||
//
|
||||
// Note Exact still asks Glance for one RemoteViews per host size (typically
|
||||
// portrait + landscape) where the old SizeMode.Single produced exactly one —
|
||||
// so the serialized payload roughly doubles. That is why the row list below is
|
||||
// capped: an uncapped agenda (the range goes up to AgendaRange.MAX_CUSTOM_DAYS
|
||||
// = 365) could otherwise push the RemoteViews past the binder transaction
|
||||
// limit and the host would just show "Problem loading widget".
|
||||
override val sizeMode = SizeMode.Exact
|
||||
// Single: type and row metrics come from the user's chosen WidgetSize, not
|
||||
// from the widget's measured size (#103, #51), so there is nothing to gain
|
||||
// from Glance building one RemoteViews per host size bucket — and plenty to
|
||||
// lose, since that roughly doubles the serialized payload. The row list is
|
||||
// still capped below: an uncapped agenda (the range goes up to
|
||||
// AgendaRange.MAX_CUSTOM_DAYS = 365) could push the RemoteViews past the
|
||||
// binder transaction limit and the host would just show "Problem loading
|
||||
// widget".
|
||||
override val sizeMode = SizeMode.Single
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val data = context.loadAgendaWidgetData()
|
||||
@@ -160,12 +164,18 @@ private sealed interface AgendaRow {
|
||||
|
||||
@Composable
|
||||
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
// Type and row metrics scale with the widget's live size (SizeMode.Exact); a
|
||||
// short/compact widget resolves to COMPACT, leaving the layout unchanged (#51).
|
||||
// Type and row metrics come from the user's chosen size step, read reactively
|
||||
// from per-instance Glance state and falling back to the saved pref for a
|
||||
// freshly placed widget (#103, #51). The permission screen has no loaded prefs
|
||||
// to fall back to, so it takes the default.
|
||||
// The stripe is then re-resolved against the system font scale so it tracks the
|
||||
// sp-sized text beside it instead of drifting at large accessibility settings.
|
||||
val savedSize = (data as? AgendaWidgetData.Ready)?.savedWidgetSize ?: WidgetSize.SMALL
|
||||
val size = currentState(AGENDA_SIZE_KEY)
|
||||
?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } }
|
||||
?: savedSize
|
||||
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
|
||||
val metrics = metricsFor(scaleFor(LocalSize.current)).scaledForFont(fontScale)
|
||||
val metrics = metricsFor(size).scaledForFont(fontScale)
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
|
||||
@@ -449,8 +449,6 @@
|
||||
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Deaktivieren</string>
|
||||
<string name="dialog_save">Speichern</string>
|
||||
<string name="calendars_disable_hint">Deaktiviere einen Kalender, um ihn aus der App zu entfernen – seine Ereignisse, Filter und Auswahlmöglichkeiten. Es wird nichts gelöscht und du kannst ihn hier jederzeit wieder aktivieren.</string>
|
||||
<string name="calendars_show_in_app_a11y">„%1$s“ in der App anzeigen</string>
|
||||
<string name="calendars_account_menu_a11y">Weitere Optionen für %1$s</string>
|
||||
<string name="calendars_enable_all">Alle aktivieren</string>
|
||||
<string name="calendars_disable_all">Alle deaktivieren</string>
|
||||
|
||||
@@ -333,8 +333,6 @@
|
||||
<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>
|
||||
|
||||
@@ -400,8 +400,6 @@
|
||||
<string name="calendars_local_header">Vos calendriers</string>
|
||||
<string name="calendars_local_empty">Pas encore de calendrier local. Créez-en un pour conserver les événements uniquement sur cet appareil.</string>
|
||||
<string name="calendars_add">Ajouter un calendrier</string>
|
||||
<string name="calendars_disable_hint">Désactivez un calendrier pour le retirer de l’application, ses événements, ses filtres et ses sélecteurs. Rien n’est supprimé et vous pouvez le réactiver à tout moment ici.</string>
|
||||
<string name="calendars_show_in_app_a11y">Afficher « %1$s » dans l’application</string>
|
||||
<string name="calendars_synced_header">Calendriers synchronisés</string>
|
||||
<string name="calendars_synced_hint">Ils proviennent de comptes sur votre appareil. Créez et modifiez-les dans leur propre application.</string>
|
||||
<string name="calendars_manage_in_app">Gérer dans l’application</string>
|
||||
|
||||
@@ -317,8 +317,6 @@
|
||||
<string name="calendars_local_header">Calendari locali</string>
|
||||
<string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno con eventi salvati 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 calendari vengono dagli account sincronizzati sul tuo dispositivo. Puoi modificarli o crearne di nuovi dall\'app di sincronizzazione.</string>
|
||||
<string name="calendars_manage_in_app">Gestisci in app</string>
|
||||
|
||||
@@ -396,8 +396,6 @@
|
||||
<string name="calendars_local_header">Twoje kalendarze</string>
|
||||
<string name="calendars_local_empty">Brak lokalnych kalendarzy. Utwórz kalendarz, aby zapisywać wydarzenia tylko na tym urządzeniu.</string>
|
||||
<string name="calendars_add">Dodaj kalendarz</string>
|
||||
<string name="calendars_disable_hint">Wyłącz kalendarz, aby ukryć go w aplikacji — wraz z jego wydarzeniami, filtrami i selektorami. Nic nie zostanie usunięte, a w każdej chwili możesz go tutaj ponownie włączyć.</string>
|
||||
<string name="calendars_show_in_app_a11y">Pokaż „%1$s” w aplikacji</string>
|
||||
<string name="calendars_synced_header">Synchronizowane kalendarze</string>
|
||||
<string name="calendars_synced_hint">Pochodzą z kont na Twoim urządzeniu. Możesz je tworzyć i edytować w powiązanych z nimi aplikacjach.</string>
|
||||
<string name="calendars_manage_in_app">Zarządzaj w aplikacji</string>
|
||||
|
||||
@@ -155,6 +155,9 @@
|
||||
<!-- Shown in place of the live rule read-out when an amount field holds a
|
||||
value outside 1–999 (i.e. 0), so the greyed-out OK button has a reason. -->
|
||||
<string name="event_edit_recurrence_incomplete">Enter a number from 1 to 999</string>
|
||||
<!-- %1$s is a list of the next few dates, e.g. "30 Jul, 6 Aug and 13 Aug". -->
|
||||
<string name="event_edit_recurrence_next">Next: %1$s</string>
|
||||
<string name="event_edit_recurrence_next_none">This rule never repeats</string>
|
||||
<string name="event_edit_recurrence_ends">Ends</string>
|
||||
<string name="event_edit_recurrence_end_never">Never</string>
|
||||
<string name="event_edit_recurrence_end_until">On a date</string>
|
||||
@@ -198,6 +201,12 @@
|
||||
<string name="event_availability_free">Free</string>
|
||||
<string name="event_access_private">Private</string>
|
||||
<string name="event_access_confidential">Confidential</string>
|
||||
<!-- Second lines in the visibility picker: what each level means to people
|
||||
the calendar is shared with. -->
|
||||
<string name="event_access_default_summary">Whatever this calendar normally does</string>
|
||||
<string name="event_access_public_summary">Everyone with access sees the full details</string>
|
||||
<string name="event_access_private_summary">Others see only that you\'re busy</string>
|
||||
<string name="event_access_confidential_summary">Marked confidential; what that means is up to the calendar account</string>
|
||||
<string name="event_attendee_organizer">Organizer</string>
|
||||
<string name="event_attendee_optional">Optional</string>
|
||||
<string name="event_attendee_resource">Resource</string>
|
||||
@@ -315,6 +324,9 @@
|
||||
<string name="settings_theme_system">System</string>
|
||||
<string name="settings_theme_light">Light</string>
|
||||
<string name="settings_theme_dark">Dark</string>
|
||||
<string name="settings_theme_hint">Whether the app is light or dark. The choice applies immediately.</string>
|
||||
<!-- %1$s is the theme the system currently resolves to, e.g. "Dark". -->
|
||||
<string name="settings_theme_system_summary">Currently %1$s</string>
|
||||
<string name="settings_default_view">Default view</string>
|
||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||
@@ -329,8 +341,14 @@
|
||||
<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>
|
||||
<!-- Specimen line shown in the headings-font picker, set in the chosen face. -->
|
||||
<string name="settings_font_specimen_heading">Thursday, 14 May</string>
|
||||
<!-- Specimen paragraph shown in the body-font picker, set in the chosen face. -->
|
||||
<string name="settings_font_specimen_body">Team review at 10:00, then lunch with Robin at the café on Marktplatz.</string>
|
||||
<string name="settings_week_start">Week starts on</string>
|
||||
<string name="settings_week_start_auto">Automatic</string>
|
||||
<!-- %1$s is the weekday the automatic setting currently resolves to, e.g. "Monday". -->
|
||||
<string name="settings_week_start_auto_summary">Currently %1$s</string>
|
||||
<string name="settings_week_numbers">Week numbers</string>
|
||||
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
|
||||
<string name="settings_today_toolbar">Today button in toolbar</string>
|
||||
@@ -341,6 +359,8 @@
|
||||
<string name="settings_time_format_auto">Automatic</string>
|
||||
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
||||
<!-- %1$s is a sample time written the way the system currently writes it. -->
|
||||
<string name="settings_time_format_auto_summary">Following the system: %1$s</string>
|
||||
<string name="settings_hour_lines">Hour lines</string>
|
||||
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
||||
<string name="settings_dim_completed">Dim completed events</string>
|
||||
@@ -349,11 +369,24 @@
|
||||
<string name="settings_past_events_show">Show</string>
|
||||
<string name="settings_past_events_dim">Dim</string>
|
||||
<string name="settings_past_events_hide">Hide</string>
|
||||
<!-- Stand-in event titles in the past-events preview. Short, everyday entries. -->
|
||||
<string name="settings_past_events_sample_morning">Team review</string>
|
||||
<string name="settings_past_events_sample_midday">Lunch with Robin</string>
|
||||
<string name="settings_past_events_sample_evening">Choir practice</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Agenda range</string>
|
||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
||||
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
||||
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
|
||||
|
||||
<string name="settings_widget_size">Agenda widget size</string>
|
||||
<string name="settings_widget_size_hint">How large the agenda home-screen widget draws its text. The month widget has no setting — its grid always fits itself to the space you give it.</string>
|
||||
<string name="settings_widget_size_small">Small</string>
|
||||
<string name="settings_widget_size_medium">Medium</string>
|
||||
<string name="settings_widget_size_large">Large</string>
|
||||
<string name="settings_widget_size_extra_large">Extra large</string>
|
||||
<!-- %1$d is the event text size as a percentage of the smallest step, e.g. 130. -->
|
||||
<string name="settings_widget_size_summary">Event text %1$d%%</string>
|
||||
<string name="settings_agenda_show_today">Always show today</string>
|
||||
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
|
||||
<string name="settings_agenda_range_bar">Range bar</string>
|
||||
@@ -404,6 +437,8 @@
|
||||
<string name="settings_default_reminder_allday">All-day events</string>
|
||||
<string name="settings_allday_reminder_time">All-day reminder time</string>
|
||||
<string name="settings_allday_reminder_time_hint">Reminders for all-day events fire at %1$s</string>
|
||||
<!-- Second line on an all-day lead-time row. %1$s is a clock time, e.g. "09:00". -->
|
||||
<string name="settings_allday_reminder_fires_at">Notifies at %1$s</string>
|
||||
<string name="reminder_none">None</string>
|
||||
<string name="reminder_use_default">Use default reminder</string>
|
||||
<string name="reminder_custom_amount">Amount</string>
|
||||
@@ -423,12 +458,39 @@
|
||||
<string name="settings_language_auto">System default</string>
|
||||
<string name="settings_translate">Help translate</string>
|
||||
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||
<!-- Hub group headers (#69) -->
|
||||
<string name="settings_group_look">Look & behaviour</string>
|
||||
<string name="settings_group_data">Data</string>
|
||||
<string name="settings_group_app">App</string>
|
||||
<!-- Group of reference links at the foot of the settings hub. -->
|
||||
<string name="settings_group_about">About</string>
|
||||
|
||||
<!-- Hub category subtitles -->
|
||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||
<string name="settings_views_subtitle">Month layout, quick-switch button, menu order</string>
|
||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||
<string name="settings_appearance_subtitle">Theme, colours, fonts</string>
|
||||
<string name="settings_views_subtitle">Default view, layout, order</string>
|
||||
<string name="settings_event_form_subtitle">Default fields and behaviour</string>
|
||||
<string name="settings_notifications_subtitle">Reminders and delivery</string>
|
||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||
<string name="settings_widgets_subtitle">Agenda widget, Quick Settings tile</string>
|
||||
<string name="settings_backup_subtitle">Export, import, automatic backup</string>
|
||||
|
||||
<!-- Widgets & tiles (#69) -->
|
||||
<string name="settings_section_widgets">Widgets & tiles</string>
|
||||
<string name="settings_widgets_hint">Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen.</string>
|
||||
|
||||
<!-- Backup & restore (#69) — the rows themselves reuse the calendars_* strings -->
|
||||
<string name="settings_section_backup">Backup & restore</string>
|
||||
|
||||
<!-- Views sub-headers (#69) -->
|
||||
<string name="settings_views_all_header">All views</string>
|
||||
<string name="settings_week_day_header">Week & day</string>
|
||||
|
||||
<!-- Picker descriptions (#69) -->
|
||||
<string name="settings_default_view_hint">The view Calendula opens on when you start it.</string>
|
||||
<string name="settings_week_start_hint">The day every week begins on, in all views and widgets.</string>
|
||||
<string name="settings_time_format_hint">How times are written throughout the app. Automatic follows your system setting.</string>
|
||||
<string name="settings_past_events_hint">What the agenda does with events that have already ended.</string>
|
||||
<string name="settings_dynamic_color_summary">Take the app\'s colours from your wallpaper.</string>
|
||||
|
||||
<!-- Contact special dates (issue #15) -->
|
||||
<string name="settings_section_special_dates">Contact special dates</string>
|
||||
@@ -474,11 +536,22 @@
|
||||
<string name="calendars_local_header">Your calendars</string>
|
||||
<string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string>
|
||||
<string name="calendars_add">Add calendar</string>
|
||||
<string name="calendars_disable_hint">Turn a calendar off to remove it from the app — its events, filters and pickers. Nothing is deleted, and you can turn it back on here anytime.</string>
|
||||
<string name="calendars_show_in_app_a11y">Show \"%1$s\" in the app</string>
|
||||
<string name="calendars_visibility_hint">Turn a calendar off to hide it on this device — its events disappear from the app and it stops reminding you. This is the same switch your other calendar apps use, so they hide it too. Nothing is deleted, no other device is affected, and you can turn it back on here anytime.</string>
|
||||
<string name="calendars_visibility_a11y">Show \"%1$s\"</string>
|
||||
<string name="calendars_visibility_notice_title">Some calendars are switched off</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula now shows the calendars that are switched on for this device, so what you see and what reminds you can no longer disagree. Some of yours are currently off — they were switched off here or in another calendar app. Turn any of them back on in Settings → Calendars.</string>
|
||||
<!-- Footer row under the event-form and .ics import calendar pickers. -->
|
||||
<string name="calendar_picker_missing_title">Missing a calendar?</string>
|
||||
<string name="calendar_picker_missing_summary">It may be switched off, read-only, or filled from your contacts — manage your calendars here.</string>
|
||||
<string name="calendars_state_read_only">Read-only</string>
|
||||
<string name="calendars_state_not_synced">Not synced to this device</string>
|
||||
<string name="calendars_state_managed">Filled from your contacts</string>
|
||||
<string name="calendars_managed_delete_locked">This calendar is filled from your contacts, so Calendula would create it again on the next sync. Turn special dates off under Settings → Special dates to delete it.</string>
|
||||
<string name="calendars_synced_header">Synced calendars</string>
|
||||
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
||||
<string name="calendars_manage_in_app">Manage in app</string>
|
||||
<!-- Account header when two accounts share a name: %1$s is the account, %2$s the app it comes from. -->
|
||||
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||
<string name="calendars_account_menu_a11y">More options for %1$s</string>
|
||||
<string name="calendars_enable_all">Enable all</string>
|
||||
<string name="calendars_disable_all">Disable all</string>
|
||||
|
||||
@@ -54,7 +54,8 @@ class AllDayReminderEncodingTest {
|
||||
for (time in listOf(0, nineAm, 20 * 60)) {
|
||||
for (semantic in listOf(0, 1_440, 2_880, 10_080)) {
|
||||
val raw = toProviderAllDayMinutes(semantic, date, berlin, time)
|
||||
assertThat(fromProviderAllDayMinutes(raw, date, berlin)).isEqualTo(semantic)
|
||||
assertThat(fromProviderAllDayMinutes(raw, date, berlin, time))
|
||||
.isEqualTo(semantic)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,9 +65,31 @@ class AllDayReminderEncodingTest {
|
||||
fun `pre-feature rows (raw multiple of 1440) still decode to whole days`() {
|
||||
// Reminders written before this feature stored raw N*1440 (fired at UTC
|
||||
// midnight). They must still read back as "N days before".
|
||||
assertThat(fromProviderAllDayMinutes(1_440, summer, berlin)).isEqualTo(1_440)
|
||||
assertThat(fromProviderAllDayMinutes(1_440, winter, berlin)).isEqualTo(1_440)
|
||||
assertThat(fromProviderAllDayMinutes(2_880, summer, berlin)).isEqualTo(2_880)
|
||||
assertThat(fromProviderAllDayMinutes(1_440, summer, berlin, nineAm)).isEqualTo(1_440)
|
||||
assertThat(fromProviderAllDayMinutes(1_440, winter, berlin, nineAm)).isEqualTo(1_440)
|
||||
assertThat(fromProviderAllDayMinutes(2_880, summer, berlin, nineAm)).isEqualTo(2_880)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an offset that encodes to a multiple still decodes to its own lead time`() {
|
||||
// New York at 20:00 is the collision: "1 day before" encodes to a plain 0,
|
||||
// which read at face value would say "at time of event". The screen has to
|
||||
// show what the reminder does, which is fire the evening before.
|
||||
val newYork = ZoneId.of("America/New_York")
|
||||
val eightPm = 20 * 60
|
||||
val raw = toProviderAllDayMinutes(1_440, summer, newYork, eightPm)
|
||||
|
||||
assertThat(raw).isEqualTo(0)
|
||||
assertThat(fromProviderAllDayMinutes(raw, summer, newYork, eightPm)).isEqualTo(1_440)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a foreign multiple west of UTC keeps its face-value lead time`() {
|
||||
// No encoded hour in this row, and its instant lands on the evening two
|
||||
// days out in New York — the local-date reading would say "2 days before".
|
||||
val newYork = ZoneId.of("America/New_York")
|
||||
|
||||
assertThat(fromProviderAllDayMinutes(1_440, summer, newYork, nineAm)).isEqualTo(1_440)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -74,8 +97,8 @@ class AllDayReminderEncodingTest {
|
||||
val atNine = toProviderAllDayMinutes(1_440, summer, berlin, nineAm)
|
||||
val atEight = toProviderAllDayMinutes(1_440, summer, berlin, 8 * 60)
|
||||
assertThat(atNine).isNotEqualTo(atEight)
|
||||
assertThat(fromProviderAllDayMinutes(atNine, summer, berlin)).isEqualTo(1_440)
|
||||
assertThat(fromProviderAllDayMinutes(atEight, summer, berlin)).isEqualTo(1_440)
|
||||
assertThat(fromProviderAllDayMinutes(atNine, summer, berlin, nineAm)).isEqualTo(1_440)
|
||||
assertThat(fromProviderAllDayMinutes(atEight, summer, berlin, nineAm)).isEqualTo(1_440)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -15,6 +15,7 @@ class CalendarMapperTest {
|
||||
visible: Int = 1,
|
||||
accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||
description: String? = null,
|
||||
syncEvents: Int? = 1,
|
||||
): MapColumnReader = MapColumnReader(
|
||||
CalendarProjection.IDX_ID to id,
|
||||
CalendarProjection.IDX_DISPLAY_NAME to displayName,
|
||||
@@ -24,6 +25,7 @@ class CalendarMapperTest {
|
||||
CalendarProjection.IDX_VISIBLE to visible,
|
||||
CalendarProjection.IDX_ACCESS_LEVEL to accessLevel,
|
||||
CalendarProjection.IDX_DESCRIPTION to description,
|
||||
CalendarProjection.IDX_SYNC_EVENTS to syncEvents,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -49,6 +51,18 @@ class CalendarMapperTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync_events 0 marks the calendar as not syncing its events`() {
|
||||
assertThat(reader(syncEvents = 0).toCalendarSource().syncsEvents).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a NULL sync_events column is treated as syncing`() {
|
||||
// The harmless default: it only ever holds the visibility migration back
|
||||
// from switching a calendar on.
|
||||
assertThat(reader(syncEvents = null).toCalendarSource().syncsEvents).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null displayName falls back to placeholder`() {
|
||||
val src = reader(displayName = null).toCalendarSource()
|
||||
|
||||
@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -41,8 +42,12 @@ class CalendarRepositoryImplTest {
|
||||
produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() },
|
||||
)
|
||||
|
||||
private fun makeCal(id: Long, name: String = "Cal $id"): CalendarSource =
|
||||
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true)
|
||||
private fun makeCal(
|
||||
id: Long,
|
||||
name: String = "Cal $id",
|
||||
visible: Boolean = true,
|
||||
): CalendarSource =
|
||||
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), visible)
|
||||
|
||||
private fun makeEvent(
|
||||
id: Long,
|
||||
@@ -171,37 +176,40 @@ class CalendarRepositoryImplTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances drops events whose calendar the user disabled`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = newPrefs(tempDir)
|
||||
prefs.setDisabledCalendarIds(setOf(2L))
|
||||
fun `instances drops events whose calendar is hidden at system level`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false))
|
||||
instancesResult = { _, _ ->
|
||||
listOf(
|
||||
makeEvent(10L, "Enabled", calendarId = 1L),
|
||||
makeEvent(11L, "Disabled", calendarId = 2L),
|
||||
makeEvent(10L, "Shown", calendarId = 1L),
|
||||
makeEvent(11L, "Switched off", calendarId = 2L),
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
repo.instances(range).test {
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("Enabled")
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("Shown")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances applies the union of hidden and disabled sets`(@TempDir tempDir: Path) = runTest {
|
||||
fun `instances applies the union of hidden and system-invisible calendars`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = newPrefs(tempDir)
|
||||
prefs.setHiddenCalendarIds(setOf(2L))
|
||||
prefs.setDisabledCalendarIds(setOf(3L))
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L), makeCal(3L, visible = false))
|
||||
instancesResult = { _, _ ->
|
||||
listOf(
|
||||
makeEvent(10L, "Shown", calendarId = 1L),
|
||||
makeEvent(11L, "Hidden", calendarId = 2L),
|
||||
makeEvent(12L, "Disabled", calendarId = 3L),
|
||||
makeEvent(12L, "Switched off", calendarId = 3L),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -215,9 +223,61 @@ class CalendarRepositoryImplTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances re-emits when the disabled set changes`(@TempDir tempDir: Path) = runTest {
|
||||
fun `instances re-emit after a calendar is switched off in the provider`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
||||
instancesResult = { _, _ ->
|
||||
listOf(
|
||||
makeEvent(10L, "A", calendarId = 1L),
|
||||
makeEvent(11L, "B", calendarId = 2L),
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
repo.instances(range).test {
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
|
||||
|
||||
// The write itself is what the provider notifies about; the observer
|
||||
// tick is what makes the views re-query.
|
||||
repo.setCalendarsVisible(listOf(2L), false)
|
||||
fake.tick()
|
||||
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("A")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setCalendarsVisible addresses each calendar on its own`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
// An _id IN (…) batch would skip the provider's own reminder-alarm
|
||||
// reschedule, so every calendar must be written by appended id.
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L), makeCal(3L))
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.setCalendarsVisible(listOf(1L, 3L), false)
|
||||
|
||||
assertThat(fake.visibilityWrites).containsExactly(1L to false, 3L to false).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without write permission the switch is kept app-side and still filters`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
// READ granted, WRITE denied: the provider flag can't be written, so the
|
||||
// choice is parked in the pending set — and honoured from there, or the
|
||||
// user's switched-off calendars would come back on upgrade (#75).
|
||||
val prefs = newPrefs(tempDir)
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
canWrite = false
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
||||
instancesResult = { _, _ ->
|
||||
listOf(
|
||||
makeEvent(10L, "A", calendarId = 1L),
|
||||
@@ -231,11 +291,177 @@ class CalendarRepositoryImplTest {
|
||||
repo.instances(range).test {
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
|
||||
|
||||
prefs.setDisabledCalendarIds(setOf(2L))
|
||||
repo.setCalendarsVisible(listOf(2L), false)
|
||||
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("A")
|
||||
assertThat(fake.visibilityWrites).isEmpty()
|
||||
assertThat(prefs.pendingDisabledCalendarIds.first()).containsExactly(2L)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendars reports a pending switch-off as off`(@TempDir tempDir: Path) = runTest {
|
||||
// Otherwise the Settings switch would snap straight back on for a
|
||||
// read-only install, and the pickers would keep offering the calendar.
|
||||
val prefs = newPrefs(tempDir)
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
canWrite = false
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
repo.calendars().test {
|
||||
assertThat(awaitItem().map { it.isVisibleInSystem }).containsExactly(true, true)
|
||||
|
||||
repo.setCalendarsVisible(listOf(2L), false)
|
||||
|
||||
assertThat(awaitItem().single { it.id == 2L }.isVisibleInSystem).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching a calendar back on without write permission retires its entry`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = newPrefs(tempDir)
|
||||
prefs.addPendingDisabledCalendarIds(setOf(2L))
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
canWrite = false
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.setCalendarsVisible(listOf(2L), true)
|
||||
|
||||
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider write clears anything still pending for that calendar`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = newPrefs(tempDir)
|
||||
prefs.addPendingDisabledCalendarIds(setOf(2L))
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.setCalendarsVisible(listOf(2L), false)
|
||||
|
||||
assertThat(fake.visibilityWrites).containsExactly(2L to false)
|
||||
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one tick costs one calendar query however many collectors there are`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
||||
instancesResult = { _, _ -> listOf(makeEvent(10L, "A", calendarId = 1L)) }
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
repo.calendars().test {
|
||||
awaitItem()
|
||||
repo.instances(range).test {
|
||||
awaitItem()
|
||||
// Both flows listed/filtered off the same snapshot.
|
||||
assertThat(fake.calendarQueries).isEqualTo(1)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
||||
// The next tick invalidates it — one fresh read, not one per flow.
|
||||
// (The list has to change: an identical one is collapsed.)
|
||||
fake.calendarsResult = listOf(makeCal(1L), makeCal(2L), makeCal(3L))
|
||||
fake.tick()
|
||||
awaitItem()
|
||||
assertThat(fake.calendarQueries).isEqualTo(2)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendars does not re-emit an unchanged list`(@TempDir tempDir: Path) = runTest {
|
||||
// The store is shared with SettingsPrefs, so an unrelated write would
|
||||
// otherwise re-run every view's combine for an identical list.
|
||||
val prefs = newPrefs(tempDir)
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(makeCal(1L)) }
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
repo.calendars().test {
|
||||
assertThat(awaitItem().map { it.id }).containsExactly(1L)
|
||||
|
||||
prefs.setLastUsedCalendarId(1L)
|
||||
fake.tick()
|
||||
|
||||
expectNoEvents()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a flushed switch-off never reads as on again before the provider ticks`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
// The reconciler's shape: write VISIBLE = 0 straight to the provider,
|
||||
// then release the id app-side. The provider's notification only arrives
|
||||
// afterwards (it is dispatched through the main looper), so the release
|
||||
// must not be read against the snapshot from before the write — that
|
||||
// would flash exactly the events being hidden back into every view.
|
||||
val prefs = newPrefs(tempDir)
|
||||
prefs.addPendingDisabledCalendarIds(setOf(2L))
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L))
|
||||
instancesResult = { _, _ ->
|
||||
listOf(makeEvent(10L, "A", calendarId = 1L), makeEvent(11L, "B", calendarId = 2L))
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
|
||||
// Warm the snapshot the way an open view would.
|
||||
repo.instances(range).test {
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("A")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
||||
fake.setCalendarVisible(2L, false) // no tick(): the observer hasn't fired yet
|
||||
prefs.removePendingDisabledCalendarIds(setOf(2L))
|
||||
|
||||
repo.instances(range).test {
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("A")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
repo.calendars().test {
|
||||
assertThat(awaitItem().single { it.id == 2L }.isVisibleInSystem).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `searchEvents drops results from calendars that are off or hidden`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = newPrefs(tempDir)
|
||||
prefs.setHiddenCalendarIds(setOf(3L))
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(makeCal(1L), makeCal(2L, visible = false), makeCal(3L))
|
||||
searchResult = {
|
||||
listOf(
|
||||
makeEvent(10L, "Shown", calendarId = 1L),
|
||||
makeEvent(11L, "Switched off", calendarId = 2L),
|
||||
makeEvent(12L, "Hidden", calendarId = 3L),
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
assertThat(repo.searchEvents("e").map { it.title }).containsExactly("Shown")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -79,7 +79,8 @@ class EventDetailMapperTest {
|
||||
private fun MapColumnReader.toDetail(
|
||||
attendees: List<de.jeanlucmakiola.calendula.domain.Attendee> = emptyList(),
|
||||
reminders: List<Reminder> = emptyList(),
|
||||
) = toEventDetailCore(attendees, reminders)
|
||||
allDayReminderTimeMinutes: Int = 9 * 60,
|
||||
) = toEventDetailCore(attendees, reminders, allDayReminderTimeMinutes)
|
||||
|
||||
@Test
|
||||
fun `happy path detail maps all fields and embeds matching EventInstance`() {
|
||||
|
||||
@@ -61,11 +61,19 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
|
||||
private val listeners = mutableListOf<() -> Unit>()
|
||||
|
||||
override fun calendars(): List<CalendarSource> = calendarsResult
|
||||
/** How often [calendars] was queried — the repository shares one read per tick. */
|
||||
var calendarQueries = 0
|
||||
private set
|
||||
|
||||
override fun calendars(): List<CalendarSource> {
|
||||
calendarQueries++
|
||||
return calendarsResult
|
||||
}
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||
instancesResult(beginMillis, endMillis)
|
||||
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
|
||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
||||
override fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? =
|
||||
eventDetailResult(eventId)
|
||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
eventColorPaletteResult(calendarId)
|
||||
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
||||
@@ -95,6 +103,27 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
updatedCalendars += UpdatedCalendar(id, displayName, color, description)
|
||||
}
|
||||
|
||||
/** (id, visible) pairs passed to [setCalendarVisible], in call order. */
|
||||
val visibilityWrites = mutableListOf<Pair<Long, Boolean>>()
|
||||
|
||||
override fun setCalendarVisible(id: Long, visible: Boolean) {
|
||||
writeError?.let { throw it }
|
||||
visibilityWrites += id to visible
|
||||
// Reflect the write so a follow-up [calendars] read sees it, the way the
|
||||
// provider would once its notification has re-triggered the query.
|
||||
calendarsResult = calendarsResult.map {
|
||||
if (it.id == id) it.copy(isVisibleInSystem = visible) else it
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the fake holds `WRITE_CALENDAR`; false models a read-only grant. */
|
||||
var canWrite: Boolean = true
|
||||
|
||||
override fun canWriteCalendars(): Boolean = canWrite
|
||||
|
||||
override fun isCalendarVisible(id: Long): Boolean? =
|
||||
calendarsResult.firstOrNull { it.id == id }?.isVisibleInSystem
|
||||
|
||||
override fun deleteCalendar(id: Long) {
|
||||
writeError?.let { throw it }
|
||||
deletedCalendarIds += id
|
||||
|
||||
@@ -52,32 +52,65 @@ class CalendarPrefsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabledCalendarIds defaults to empty when unset`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
|
||||
fun `the pending disabled set reads back what an older version stored`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
// Same key as the retired app-local "disabled calendars" model: an
|
||||
// upgrade inherits that set as switch-offs still owed to the provider.
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = CalendarPrefs(store)
|
||||
store.updateData { p ->
|
||||
p.toMutablePreferences().apply { this[CalendarPrefs.DISABLED_IDS_KEY] = "2,9" }
|
||||
}
|
||||
assertThat(prefs.pendingDisabledCalendarIds.first()).isEqualTo(setOf(2L, 9L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setDisabledCalendarIds round-trips through DataStore`(@TempDir tempDir: Path) = runTest {
|
||||
fun `the pending disabled set is empty when nothing was ever stored`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||
prefs.setDisabledCalendarIds(setOf(1L, 42L, 7L))
|
||||
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(1L, 42L, 7L))
|
||||
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setting empty disabled set clears storage`(@TempDir tempDir: Path) = runTest {
|
||||
fun `pending ids are added and dropped one at a time`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||
prefs.setDisabledCalendarIds(setOf(1L))
|
||||
prefs.setDisabledCalendarIds(emptySet())
|
||||
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
|
||||
prefs.addPendingDisabledCalendarIds(listOf(2L, 9L))
|
||||
prefs.addPendingDisabledCalendarIds(listOf(4L))
|
||||
|
||||
prefs.removePendingDisabledCalendarIds(setOf(9L))
|
||||
|
||||
assertThat(prefs.pendingDisabledCalendarIds.first()).isEqualTo(setOf(2L, 4L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hidden and disabled sets are stored independently`(@TempDir tempDir: Path) = runTest {
|
||||
fun `draining the pending set leaves the hidden set alone`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||
prefs.setHiddenCalendarIds(setOf(1L))
|
||||
prefs.setDisabledCalendarIds(setOf(2L))
|
||||
prefs.addPendingDisabledCalendarIds(setOf(2L))
|
||||
|
||||
prefs.removePendingDisabledCalendarIds(setOf(2L))
|
||||
|
||||
assertThat(prefs.pendingDisabledCalendarIds.first()).isEmpty()
|
||||
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L))
|
||||
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(2L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the visibility notice is unevaluated until it is written`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
// Null is what makes the evaluation one-shot: "no" is stored just as
|
||||
// firmly as "yes", so the notice can't resurface on a later launch.
|
||||
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.visibilityNoticePending.first()).isNull()
|
||||
|
||||
prefs.setVisibilityNoticePending(true)
|
||||
assertThat(prefs.visibilityNoticePending.first()).isTrue()
|
||||
|
||||
prefs.setVisibilityNoticePending(false)
|
||||
assertThat(prefs.visibilityNoticePending.first()).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
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,110 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class CalendarRowStateTest {
|
||||
|
||||
private fun cal(
|
||||
id: Long = 1L,
|
||||
name: String = "Cal $id",
|
||||
writable: Boolean = true,
|
||||
syncsEvents: Boolean = true,
|
||||
local: Boolean = false,
|
||||
managed: Boolean = false,
|
||||
) = CalendarSource(
|
||||
id = id,
|
||||
displayName = name,
|
||||
accountName = "account",
|
||||
accountType = if (local) "LOCAL" else "com.google",
|
||||
color = 0,
|
||||
isVisibleInSystem = true,
|
||||
canModifyContents = writable,
|
||||
isLocal = local,
|
||||
syncsEvents = syncsEvents,
|
||||
isManaged = managed,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a plain writable calendar carries no state labels`() {
|
||||
assertThat(cal().stateLabels()).isEmpty()
|
||||
assertThat(cal().hasVisibilitySwitch).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read-only calendar is labelled`() {
|
||||
assertThat(cal(writable = false).stateLabels())
|
||||
.containsExactly(CalendarStateLabel.READ_ONLY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-syncing account calendar is labelled and loses its switch`() {
|
||||
val calendar = cal(syncsEvents = false)
|
||||
assertThat(calendar.stateLabels()).containsExactly(CalendarStateLabel.NOT_SYNCED)
|
||||
assertThat(calendar.hasVisibilitySwitch).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both states can hold at once, read-only first`() {
|
||||
assertThat(cal(writable = false, syncsEvents = false).stateLabels())
|
||||
.containsExactly(CalendarStateLabel.READ_ONLY, CalendarStateLabel.NOT_SYNCED)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a managed special-dates mirror is labelled although it is writable`() {
|
||||
// Writable, visible, syncing — nothing else on the row would hint at why
|
||||
// it can't be picked as an event target.
|
||||
val calendar = cal(local = true, managed = true)
|
||||
assertThat(calendar.stateLabels()).containsExactly(CalendarStateLabel.MANAGED)
|
||||
assertThat(calendar.hasVisibilitySwitch).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a local calendar is never called not-synced`() {
|
||||
// Nothing syncs a device-local calendar, so sync_events says nothing
|
||||
// about it — and another app's local calendar can hold real events at 0.
|
||||
val calendar = cal(syncsEvents = false, local = true)
|
||||
assertThat(calendar.isNotSynced).isFalse()
|
||||
assertThat(calendar.stateLabels()).isEmpty()
|
||||
assertThat(calendar.hasVisibilitySwitch).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every named state keeps a calendar out of the pickers`() {
|
||||
// The labels and the picker exclusion are the same set, stated twice —
|
||||
// a labelled row the pickers still offered would make the footer's
|
||||
// "manage your calendars to see why" a lie (#76).
|
||||
assertThat(cal().isEventTarget).isTrue()
|
||||
listOf(
|
||||
cal(writable = false),
|
||||
cal(syncsEvents = false),
|
||||
cal(local = true, managed = true),
|
||||
).forEach { calendar ->
|
||||
assertThat(calendar.stateLabels()).isNotEmpty()
|
||||
assertThat(calendar.isEventTarget).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a switched-off calendar is no target although it carries no label`() {
|
||||
// The switch is right there on the row, so the state speaks for itself.
|
||||
val calendar = cal().copy(isVisibleInSystem = false)
|
||||
assertThat(calendar.stateLabels()).isEmpty()
|
||||
assertThat(calendar.isEventTarget).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manager order puts non-syncing calendars last and is otherwise stable`() {
|
||||
val ordered = listOf(
|
||||
cal(id = 1L, name = "Anna", syncsEvents = false),
|
||||
cal(id = 2L, name = "Bert"),
|
||||
cal(id = 3L, name = "Cleo", syncsEvents = false),
|
||||
cal(id = 4L, name = "Dana"),
|
||||
).orderedForManager()
|
||||
|
||||
assertThat(ordered.map { it.displayName })
|
||||
.containsExactly("Bert", "Dana", "Anna", "Cleo")
|
||||
.inOrder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Draining the app's pending "switched off" set into the system's
|
||||
* `Calendars.VISIBLE` (#75) — including the set an upgrade inherits from the
|
||||
* retired app-local visibility model.
|
||||
*/
|
||||
class CalendarVisibilityPlanTest {
|
||||
|
||||
private fun cal(
|
||||
id: Long,
|
||||
visible: Boolean = true,
|
||||
local: Boolean = false,
|
||||
syncsEvents: Boolean = true,
|
||||
): CalendarSource = CalendarSource(
|
||||
id = id,
|
||||
displayName = "Cal $id",
|
||||
accountName = "acc@local",
|
||||
accountType = if (local) "LOCAL" else "com.google",
|
||||
color = 0,
|
||||
isVisibleInSystem = visible,
|
||||
isLocal = local,
|
||||
syncsEvents = syncsEvents,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a pending calendar is switched off at system level`() {
|
||||
val plan = calendarVisibilityPlan(listOf(cal(1L, visible = true)), setOf(1L))
|
||||
assertThat(plan.hide).containsExactly(1L)
|
||||
assertThat(plan.settled).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calendar hidden at system level is never switched on`() {
|
||||
// The plan only hides: switching it on would un-hide the calendar in
|
||||
// every other calendar app and start firing its reminders.
|
||||
val plan = calendarVisibilityPlan(listOf(cal(1L, visible = false)), emptySet())
|
||||
assertThat(plan.isEmpty).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a not-synced calendar is switched off like any other`() {
|
||||
val plan = calendarVisibilityPlan(
|
||||
listOf(cal(1L, visible = true, syncsEvents = false)),
|
||||
setOf(1L),
|
||||
)
|
||||
assertThat(plan.hide).containsExactly(1L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pending calendar already switched off is settled without a write`() {
|
||||
val plan = calendarVisibilityPlan(listOf(cal(1L, visible = false)), setOf(1L))
|
||||
assertThat(plan.hide).isEmpty()
|
||||
assertThat(plan.settled).containsExactly(1L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pending id for a calendar that no longer exists is settled`() {
|
||||
val plan = calendarVisibilityPlan(listOf(cal(1L)), setOf(99L))
|
||||
assertThat(plan.hide).isEmpty()
|
||||
assertThat(plan.settled).containsExactly(99L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty pending set writes nothing`() {
|
||||
val plan = calendarVisibilityPlan(listOf(cal(1L), cal(2L, visible = false)), emptySet())
|
||||
assertThat(plan.isEmpty).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mixed device splits into writes and settled ids`() {
|
||||
val plan = calendarVisibilityPlan(
|
||||
listOf(
|
||||
cal(1L, visible = true), // not pending → untouched
|
||||
cal(2L, visible = false), // hidden elsewhere → untouched
|
||||
cal(3L, visible = true), // pending → hide
|
||||
cal(4L, visible = false), // pending, already off → settled
|
||||
),
|
||||
pendingDisabledIds = setOf(3L, 4L, 77L),
|
||||
)
|
||||
assertThat(plan.hide).containsExactly(3L)
|
||||
assertThat(plan.settled).containsExactly(4L, 77L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calendar hidden outside the app arms the notice`() {
|
||||
assertThat(
|
||||
hasSystemHiddenCalendars(listOf(cal(1L), cal(2L, visible = false)), emptySet()),
|
||||
).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calendar we are about to hide ourselves does not arm the notice`() {
|
||||
// It is off because the user switched it off here — nothing to explain.
|
||||
assertThat(
|
||||
hasSystemHiddenCalendars(listOf(cal(1L, visible = false)), setOf(1L)),
|
||||
).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-visible device does not arm the notice`() {
|
||||
assertThat(hasSystemHiddenCalendars(listOf(cal(1L), cal(2L)), emptySet())).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a device-local calendar is treated like any other`() {
|
||||
// Its sync_events flag says nothing about whether it holds events, so
|
||||
// neither the plan nor the notice may reason about it.
|
||||
val plan = calendarVisibilityPlan(
|
||||
listOf(cal(1L, visible = true, local = true, syncsEvents = false)),
|
||||
setOf(1L),
|
||||
)
|
||||
assertThat(plan.hide).containsExactly(1L)
|
||||
assertThat(
|
||||
hasSystemHiddenCalendars(
|
||||
listOf(cal(2L, visible = false, local = true, syncsEvents = false)),
|
||||
emptySet(),
|
||||
),
|
||||
).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The all-day day-boundary rule every surface shares. All-day events are stored
|
||||
* at UTC midnights with an exclusive end, so their dates must be resolved in UTC
|
||||
* whatever the device zone is — reading them in the device zone names the wrong
|
||||
* day on both sides of the meridian (#65 east, #82 west).
|
||||
*/
|
||||
class EventInstanceSpanTest {
|
||||
|
||||
private val berlin = TimeZone.of("Europe/Berlin") // UTC+2 in July
|
||||
private val newYork = TimeZone.of("America/New_York") // UTC-4 in July
|
||||
|
||||
/** 19 July 2026, all day: UTC midnight to the exclusive next UTC midnight. */
|
||||
private fun allDayJul19(): EventInstance = instance(
|
||||
start = utc(2026, 7, 19),
|
||||
end = utc(2026, 7, 20),
|
||||
isAllDay = true,
|
||||
)
|
||||
|
||||
private fun utc(y: Int, mo: Int, d: Int, h: Int = 0): Instant =
|
||||
LocalDateTime(y, mo, d, h, 0).toInstant(TimeZone.UTC)
|
||||
|
||||
private fun instance(start: Instant, end: Instant, isAllDay: Boolean) = EventInstance(
|
||||
instanceId = 1L,
|
||||
eventId = 1L,
|
||||
calendarId = 1L,
|
||||
title = "Event",
|
||||
start = start,
|
||||
end = end,
|
||||
isAllDay = isAllDay,
|
||||
color = 0xFF000000.toInt(),
|
||||
location = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `all-day event keeps its date west of UTC`() {
|
||||
// Regression for #82: 00:00 UTC on the 19th is 20:00 on the 18th in New
|
||||
// York, so resolving in the device zone would name the 18th.
|
||||
val event = allDayJul19()
|
||||
assertThat(event.spanFirstDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
|
||||
assertThat(event.spanLastDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
|
||||
assertThat(event.spansMultipleDays(newYork)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day event keeps its date east of UTC`() {
|
||||
// Regression for #65: the exclusive end dips past local midnight in
|
||||
// Berlin, which would leak the event onto the 20th.
|
||||
val event = allDayJul19()
|
||||
assertThat(event.spanFirstDay(berlin)).isEqualTo(LocalDate(2026, 7, 19))
|
||||
assertThat(event.spanLastDay(berlin)).isEqualTo(LocalDate(2026, 7, 19))
|
||||
assertThat(event.spansMultipleDays(berlin)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multi-day all-day event ends on its last covered day`() {
|
||||
val event = instance(utc(2026, 7, 19), utc(2026, 7, 22), isAllDay = true)
|
||||
assertThat(event.spanFirstDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
|
||||
assertThat(event.spanLastDay(newYork)).isEqualTo(LocalDate(2026, 7, 21))
|
||||
assertThat(event.spansMultipleDays(newYork)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timed event resolves in the device zone`() {
|
||||
// 23:30 UTC on the 19th is already the 20th in Berlin and still the 19th
|
||||
// in New York — a timed event follows the device zone, unlike all-day.
|
||||
val event = instance(utc(2026, 7, 19, 23), utc(2026, 7, 20, 1), isAllDay = false)
|
||||
assertThat(event.spanFirstDay(berlin)).isEqualTo(LocalDate(2026, 7, 20))
|
||||
assertThat(event.spanFirstDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero-length event occupies its start day`() {
|
||||
val event = instance(utc(2026, 7, 19, 12), utc(2026, 7, 19, 12), isAllDay = false)
|
||||
assertThat(event.spanLastDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dateZone pins all-day events to UTC and leaves timed events alone`() {
|
||||
assertThat(allDayJul19().dateZone(newYork)).isEqualTo(TimeZone.UTC)
|
||||
val timed = instance(utc(2026, 7, 19, 12), utc(2026, 7, 19, 13), isAllDay = false)
|
||||
assertThat(timed.dateZone(newYork)).isEqualTo(newYork)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class RecurrenceOccurrencesTest {
|
||||
|
||||
private fun date(year: Int, month: Int, day: Int) = LocalDate(year, month, day)
|
||||
|
||||
@Test
|
||||
fun `daily rule starts at the event's own date`() {
|
||||
val occurrences = SimpleRecurrence(RecurrenceFreq.Daily)
|
||||
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 7, 30),
|
||||
date(2026, 7, 31),
|
||||
date(2026, 8, 1),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interval multiplies the period`() {
|
||||
val occurrences = SimpleRecurrence(RecurrenceFreq.Daily, interval = 3)
|
||||
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 7, 30),
|
||||
date(2026, 8, 2),
|
||||
date(2026, 8, 5),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly without weekday picks repeats the start's weekday`() {
|
||||
// 30 Jul 2026 is a Thursday.
|
||||
val occurrences = SimpleRecurrence(RecurrenceFreq.Weekly)
|
||||
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 7, 30),
|
||||
date(2026, 8, 6),
|
||||
date(2026, 8, 13),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly with picks fires on every chosen day, in weekday order`() {
|
||||
val occurrences = SimpleRecurrence(
|
||||
RecurrenceFreq.Weekly,
|
||||
byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY),
|
||||
).upcomingOccurrences(date(2026, 7, 30), limit = 4)
|
||||
|
||||
// Thursday start: the Monday of that week is already past, so the first
|
||||
// hit is Friday 31 Jul, then Mon/Fri of the following weeks.
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 7, 31),
|
||||
date(2026, 8, 3),
|
||||
date(2026, 8, 7),
|
||||
date(2026, 8, 10),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly interval skips whole weeks, counted from Monday`() {
|
||||
val occurrences = SimpleRecurrence(
|
||||
RecurrenceFreq.Weekly,
|
||||
interval = 2,
|
||||
byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.THURSDAY),
|
||||
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||
|
||||
// Start week (27 Jul–2 Aug) contributes only its Thursday; the next block
|
||||
// is two weeks on, so 10 Aug and 13 Aug — not 3 Aug.
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 7, 30),
|
||||
date(2026, 8, 10),
|
||||
date(2026, 8, 13),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthly skips months without the start day rather than clamping`() {
|
||||
val occurrences = SimpleRecurrence(RecurrenceFreq.Monthly)
|
||||
.upcomingOccurrences(date(2026, 1, 31), limit = 4)
|
||||
|
||||
// February, April and June have no 31st — the rule passes them by.
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 1, 31),
|
||||
date(2026, 3, 31),
|
||||
date(2026, 5, 31),
|
||||
date(2026, 7, 31),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yearly on 29 February only lands in leap years`() {
|
||||
val occurrences = SimpleRecurrence(RecurrenceFreq.Yearly)
|
||||
.upcomingOccurrences(date(2024, 2, 29), limit = 3)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2024, 2, 29),
|
||||
date(2028, 2, 29),
|
||||
date(2032, 2, 29),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `count limits the series and skipped periods don't consume one`() {
|
||||
val occurrences = SimpleRecurrence(
|
||||
RecurrenceFreq.Monthly,
|
||||
end = RecurrenceEnd.Count(3),
|
||||
).upcomingOccurrences(date(2026, 1, 31), limit = 10)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 1, 31),
|
||||
date(2026, 3, 31),
|
||||
date(2026, 5, 31),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `until is inclusive of its own date`() {
|
||||
val occurrences = SimpleRecurrence(
|
||||
RecurrenceFreq.Daily,
|
||||
end = RecurrenceEnd.Until(date(2026, 8, 1)),
|
||||
).upcomingOccurrences(date(2026, 7, 30), limit = 10)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
date(2026, 7, 30),
|
||||
date(2026, 7, 31),
|
||||
date(2026, 8, 1),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an until before the start yields nothing instead of spinning`() {
|
||||
val occurrences = SimpleRecurrence(
|
||||
RecurrenceFreq.Daily,
|
||||
end = RecurrenceEnd.Until(date(2026, 7, 1)),
|
||||
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
|
||||
|
||||
assertThat(occurrences).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a run inside one year needs no year, one that leaves it does`() {
|
||||
val start = date(2026, 7, 30)
|
||||
val withinYear = SimpleRecurrence(RecurrenceFreq.Daily).upcomingOccurrences(start, limit = 3)
|
||||
assertThat(occurrencesSpanYears(withinYear, start)).isFalse()
|
||||
|
||||
// Yearly: same day and month every time, so the year is the only thing
|
||||
// telling the three dates apart.
|
||||
val yearly = SimpleRecurrence(RecurrenceFreq.Yearly).upcomingOccurrences(start, limit = 3)
|
||||
assertThat(occurrencesSpanYears(yearly, start)).isTrue()
|
||||
|
||||
// Monthly rolling past December.
|
||||
val newYearStart = date(2026, 11, 30)
|
||||
val overflowing = SimpleRecurrence(RecurrenceFreq.Monthly)
|
||||
.upcomingOccurrences(newYearStart, limit = 3)
|
||||
assertThat(overflowing).containsExactly(
|
||||
date(2026, 11, 30),
|
||||
date(2026, 12, 30),
|
||||
date(2027, 1, 30),
|
||||
).inOrder()
|
||||
assertThat(occurrencesSpanYears(overflowing, newYearStart)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `limit and count are both respected, whichever is smaller`() {
|
||||
val rule = SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Count(2))
|
||||
|
||||
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 5)).hasSize(2)
|
||||
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 1)).hasSize(1)
|
||||
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 0)).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* The decision layer of in-house reminder delivery (#75). The watermark rules are
|
||||
* the load-bearing part: they are what makes a dropped alarm recoverable and a
|
||||
* double scan harmless, now that no provider row records "already fired".
|
||||
*/
|
||||
class ReminderPlanTest {
|
||||
|
||||
private val now = 1_700_000_000_000L
|
||||
private val minute = 60_000L
|
||||
private val day = 24 * 60 * minute
|
||||
|
||||
private fun instance(
|
||||
eventId: Long,
|
||||
beginMillis: Long = now + 30 * minute,
|
||||
endMillis: Long = now + 90 * minute,
|
||||
calendarId: Long = 7L,
|
||||
isAllDay: Boolean = false,
|
||||
) = ReminderEventInstance(
|
||||
eventId = eventId,
|
||||
calendarId = calendarId,
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
title = "Event $eventId",
|
||||
location = null,
|
||||
isAllDay = isAllDay,
|
||||
)
|
||||
|
||||
/** [planReminders] with the fixtures' zone and all-day hour filled in. */
|
||||
private fun plan(
|
||||
instances: List<ReminderEventInstance>,
|
||||
minutesByEvent: Map<Long, List<Int>>,
|
||||
zone: ZoneId = berlin,
|
||||
allDayTimeMinutes: Int = nineAm,
|
||||
) = planReminders(instances, minutesByEvent, zone, allDayTimeMinutes)
|
||||
|
||||
@Test
|
||||
fun `a reminder fires its offset before the occurrence begins`() {
|
||||
val begin = now + 30 * minute
|
||||
val planned = plan(
|
||||
instances = listOf(instance(1L, beginMillis = begin)),
|
||||
minutesByEvent = mapOf(1L to listOf(10)),
|
||||
)
|
||||
|
||||
assertThat(planned.map { it.alarmMillis }).containsExactly(begin - 10 * minute)
|
||||
}
|
||||
|
||||
// --- all-day: the hour the user picked, on every occurrence -------------
|
||||
|
||||
private val berlin = ZoneId.of("Europe/Berlin")
|
||||
private val nineAm = 540
|
||||
|
||||
/** UTC midnight of [date] — how the provider stores an all-day occurrence. */
|
||||
private fun allDayBegin(date: String): Long =
|
||||
LocalDate.parse(date).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
|
||||
private fun firedAt(alarmMillis: Long, zone: ZoneId = berlin): ZonedDateTime =
|
||||
java.time.Instant.ofEpochMilli(alarmMillis).atZone(zone)
|
||||
|
||||
/**
|
||||
* The offset AllDayReminderEncoding would store for "[days] before, at
|
||||
* [timeOfDayMinutes]" when sampled against [eventDate] — i.e. exactly the row
|
||||
* the app writes today.
|
||||
*/
|
||||
private fun encodedAllDayMinutes(
|
||||
eventDate: String,
|
||||
days: Long,
|
||||
timeOfDayMinutes: Int = nineAm,
|
||||
zone: ZoneId = berlin,
|
||||
): Int {
|
||||
val date = LocalDate.parse(eventDate)
|
||||
val utcMidnight = date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val fire = date.minusDays(days)
|
||||
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
|
||||
.atZone(zone).toInstant().toEpochMilli()
|
||||
return ((utcMidnight - fire) / minute).toInt()
|
||||
}
|
||||
|
||||
private fun allDayAlarm(eventDate: String, rawMinutes: Int, zone: ZoneId = berlin): Long =
|
||||
plan(
|
||||
instances = listOf(
|
||||
instance(1L, beginMillis = allDayBegin(eventDate), isAllDay = true),
|
||||
),
|
||||
minutesByEvent = mapOf(1L to listOf(rawMinutes)),
|
||||
zone = zone,
|
||||
allDayTimeMinutes = nineAm,
|
||||
).single().alarmMillis
|
||||
|
||||
@Test
|
||||
fun `an all-day reminder fires at the hour the setting names`() {
|
||||
// Winter: Berlin is UTC+1. "1 day before" on the 15th means the 14th, 09:00.
|
||||
val alarm = allDayAlarm("2026-01-15", encodedAllDayMinutes("2026-01-15", days = 1))
|
||||
|
||||
assertThat(firedAt(alarm).toLocalDateTime())
|
||||
.isEqualTo(LocalDate.parse("2026-01-14").atTime(9, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a summer occurrence of a row written in winter is not an hour early`() {
|
||||
// The drift this replaces: the offset was sampled at UTC+1, the occurrence
|
||||
// falls at UTC+2, and firing at `begin - minutes` would land at 08:00.
|
||||
val winterRow = encodedAllDayMinutes("2026-01-15", days = 1)
|
||||
|
||||
val alarm = allDayAlarm("2026-07-15", winterRow)
|
||||
|
||||
assertThat(firedAt(alarm).toLocalDateTime())
|
||||
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a winter occurrence of a row written in summer is not an hour late`() {
|
||||
// The same drift in the other direction: sampled at UTC+2, fires at UTC+1.
|
||||
val summerRow = encodedAllDayMinutes("2026-07-15", days = 1)
|
||||
|
||||
val alarm = allDayAlarm("2026-01-15", summerRow)
|
||||
|
||||
assertThat(firedAt(alarm).toLocalDateTime())
|
||||
.isEqualTo(LocalDate.parse("2026-01-14").atTime(9, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every occurrence of a yearly all-day series fires at the same wall clock`() {
|
||||
// A birthday's offset is sampled once; the series must not walk off it.
|
||||
val row = encodedAllDayMinutes("2026-07-15", days = 1)
|
||||
val fired = listOf("2026-07-15", "2027-01-15", "2027-07-15", "2028-01-15")
|
||||
.map { firedAt(allDayAlarm(it, row)).toLocalTime() }
|
||||
|
||||
assertThat(fired.toSet()).containsExactly(LocalTime.of(9, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day reminder on the day itself fires that morning`() {
|
||||
// "At time of event" on an all-day event encodes to a negative offset.
|
||||
val sameDay = encodedAllDayMinutes("2026-07-15", days = 0)
|
||||
assertThat(sameDay).isLessThan(0)
|
||||
|
||||
val alarm = allDayAlarm("2026-07-15", sameDay)
|
||||
|
||||
assertThat(firedAt(alarm).toLocalDateTime())
|
||||
.isEqualTo(LocalDate.parse("2026-07-15").atTime(9, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plain 1440 row from another calendar app means one day before`() {
|
||||
// Foreign rows carry no encoded hour. Read at face value they would fire
|
||||
// at UTC midnight — 02:00 local in summer Berlin.
|
||||
val alarm = allDayAlarm("2026-07-15", rawMinutes = 1_440)
|
||||
|
||||
assertThat(firedAt(alarm).toLocalDateTime())
|
||||
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plain 1440 row west of UTC still means one day before`() {
|
||||
// UTC midnight of the 15th is the evening of the 14th in New York, so a
|
||||
// local-date reading of the offset would put this two days out.
|
||||
val newYork = ZoneId.of("America/New_York")
|
||||
|
||||
val alarm = allDayAlarm("2026-07-15", rawMinutes = 1_440, zone = newYork)
|
||||
|
||||
assertThat(firedAt(alarm, newYork).toLocalDateTime())
|
||||
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an encoded row west of UTC fires at the named hour`() {
|
||||
val newYork = ZoneId.of("America/New_York")
|
||||
val row = encodedAllDayMinutes("2026-07-15", days = 1, zone = newYork)
|
||||
|
||||
val alarm = allDayAlarm("2026-07-15", row, zone = newYork)
|
||||
|
||||
assertThat(firedAt(alarm, newYork).toLocalDateTime())
|
||||
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero-day row fires on the event's own date`() {
|
||||
assertThat(
|
||||
allDayLeadDays(
|
||||
rawMinutes = 0,
|
||||
startDate = LocalDate.parse("2026-07-15"),
|
||||
zone = berlin,
|
||||
allDayTimeMinutes = nineAm,
|
||||
),
|
||||
).isEqualTo(0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `our own row that encodes to a multiple is not read at face value`() {
|
||||
// New York with the all-day hour at 20:00: "1 day before" encodes to a
|
||||
// plain 0, because 20:00 EDT *is* UTC midnight. Read at face value it
|
||||
// fired at 20:00 on the day of the event instead of the evening before.
|
||||
val newYork = ZoneId.of("America/New_York")
|
||||
val eightPm = 20 * 60
|
||||
val row = encodedAllDayMinutes("2026-07-15", days = 1, timeOfDayMinutes = eightPm, zone = newYork)
|
||||
assertThat(row % 1_440).isEqualTo(0)
|
||||
|
||||
val alarm = plan(
|
||||
instances = listOf(
|
||||
instance(1L, beginMillis = allDayBegin("2026-07-15"), isAllDay = true),
|
||||
),
|
||||
minutesByEvent = mapOf(1L to listOf(row)),
|
||||
zone = newYork,
|
||||
allDayTimeMinutes = eightPm,
|
||||
).single().alarmMillis
|
||||
|
||||
assertThat(firedAt(alarm, newYork).toLocalDateTime())
|
||||
.isEqualTo(LocalDate.parse("2026-07-14").atTime(20, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `such a row keeps its lead time across the DST boundary that wrote it`() {
|
||||
// Same row, sampled in EDT, on a winter occurrence: its instant lands an
|
||||
// hour off the named hour, which still has to read as "one of ours".
|
||||
val newYork = ZoneId.of("America/New_York")
|
||||
val eightPm = 20 * 60
|
||||
val summerRow = encodedAllDayMinutes("2026-07-15", days = 1, timeOfDayMinutes = eightPm, zone = newYork)
|
||||
|
||||
val alarm = plan(
|
||||
instances = listOf(
|
||||
instance(1L, beginMillis = allDayBegin("2027-01-15"), isAllDay = true),
|
||||
),
|
||||
minutesByEvent = mapOf(1L to listOf(summerRow)),
|
||||
zone = newYork,
|
||||
allDayTimeMinutes = eightPm,
|
||||
).single().alarmMillis
|
||||
|
||||
assertThat(firedAt(alarm, newYork).toLocalDateTime())
|
||||
.isEqualTo(LocalDate.parse("2027-01-14").atTime(20, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed reminder is exact across a DST boundary`() {
|
||||
// Nothing to re-anchor: begin is an absolute instant either way.
|
||||
val begin = LocalDate.parse("2026-03-29").atTime(14, 0)
|
||||
.atZone(berlin).toInstant().toEpochMilli()
|
||||
val planned = plan(
|
||||
instances = listOf(instance(1L, beginMillis = begin, isAllDay = false)),
|
||||
minutesByEvent = mapOf(1L to listOf(30)),
|
||||
zone = berlin,
|
||||
allDayTimeMinutes = nineAm,
|
||||
)
|
||||
|
||||
assertThat(firedAt(planned.single().alarmMillis).toLocalDateTime())
|
||||
.isEqualTo(LocalDate.parse("2026-03-29").atTime(13, 30))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every occurrence of a series gets its own reminder`() {
|
||||
val planned = plan(
|
||||
instances = listOf(
|
||||
instance(1L, beginMillis = now + day),
|
||||
instance(1L, beginMillis = now + 2 * day),
|
||||
),
|
||||
minutesByEvent = mapOf(1L to listOf(15)),
|
||||
)
|
||||
|
||||
assertThat(planned.map { it.alarmMillis })
|
||||
.containsExactly(now + day - 15 * minute, now + 2 * day - 15 * minute)
|
||||
assertThat(planned.map { it.key }.toSet()).hasSize(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate reminder rows collapse to one`() {
|
||||
val planned = plan(
|
||||
instances = listOf(instance(1L)),
|
||||
minutesByEvent = mapOf(1L to listOf(10, 10)),
|
||||
)
|
||||
|
||||
assertThat(planned).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event with no reminders plans nothing`() {
|
||||
val planned = plan(
|
||||
instances = listOf(instance(1L)),
|
||||
minutesByEvent = emptyMap(),
|
||||
)
|
||||
|
||||
assertThat(planned).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same reminder keeps its key across scans`() {
|
||||
val plan = {
|
||||
plan(listOf(instance(1L)), mapOf(1L to listOf(10))).single().key
|
||||
}
|
||||
|
||||
assertThat(plan()).isEqualTo(plan())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `occurrences of one series get different keys`() {
|
||||
val planned = plan(
|
||||
instances = listOf(
|
||||
instance(1L, beginMillis = now + day),
|
||||
instance(1L, beginMillis = now + 2 * day),
|
||||
),
|
||||
minutesByEvent = mapOf(1L to listOf(15)),
|
||||
)
|
||||
|
||||
assertThat(planned[0].key).isNotEqualTo(planned[1].key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two reminders on one occurrence get different keys`() {
|
||||
val planned = plan(
|
||||
instances = listOf(instance(1L)),
|
||||
minutesByEvent = mapOf(1L to listOf(10, 30)),
|
||||
)
|
||||
|
||||
assertThat(planned[0].key).isNotEqualTo(planned[1].key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder whose moment has passed since the last scan is due`() {
|
||||
val planned = plan(
|
||||
instances = listOf(instance(1L, beginMillis = now + 5 * minute)),
|
||||
minutesByEvent = mapOf(1L to listOf(10)),
|
||||
)
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned, lastFiredMillis = now - 10 * minute, nowMillis = now,
|
||||
horizonMillis = now + day,
|
||||
)
|
||||
|
||||
assertThat(schedule.due).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder already covered by the watermark does not fire twice`() {
|
||||
// The scan runs again (a provider change, a reboot) after the alarm that
|
||||
// already posted this one. Nothing records "fired" but the watermark.
|
||||
val planned = plan(
|
||||
instances = listOf(instance(1L, beginMillis = now + 5 * minute)),
|
||||
minutesByEvent = mapOf(1L to listOf(10)),
|
||||
)
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned, lastFiredMillis = now - 4 * minute, nowMillis = now,
|
||||
horizonMillis = now + day,
|
||||
)
|
||||
|
||||
assertThat(schedule.due).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder exactly on the watermark does not fire again`() {
|
||||
val begin = now + 5 * minute
|
||||
val planned = plan(
|
||||
instances = listOf(instance(1L, beginMillis = begin)),
|
||||
minutesByEvent = mapOf(1L to listOf(10)),
|
||||
)
|
||||
val alarm = planned.single().alarmMillis
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned, lastFiredMillis = alarm, nowMillis = now, horizonMillis = now + day,
|
||||
)
|
||||
|
||||
assertThat(schedule.due).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missed alarm is caught up by a much later scan`() {
|
||||
// The device was off over the reminder; the scan on boot must still post it
|
||||
// while the event is ahead. This is what the provider path could never do.
|
||||
val planned = plan(
|
||||
instances = listOf(instance(1L, beginMillis = now + 5 * minute)),
|
||||
minutesByEvent = mapOf(1L to listOf(60)),
|
||||
)
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned, lastFiredMillis = now - 5 * day, nowMillis = now,
|
||||
horizonMillis = now + day,
|
||||
)
|
||||
|
||||
assertThat(schedule.due).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder for an occurrence that already ended is dropped`() {
|
||||
val planned = plan(
|
||||
instances = listOf(
|
||||
instance(1L, beginMillis = now - 3 * 60 * minute, endMillis = now - 2 * 60 * minute),
|
||||
),
|
||||
minutesByEvent = mapOf(1L to listOf(10)),
|
||||
)
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned, lastFiredMillis = now - 5 * day, nowMillis = now,
|
||||
horizonMillis = now + day,
|
||||
)
|
||||
|
||||
assertThat(schedule.due).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an occurrence with no end falls back to its begin for relevance`() {
|
||||
val planned = plan(
|
||||
instances = listOf(
|
||||
instance(1L, beginMillis = now - minute, endMillis = 0L),
|
||||
),
|
||||
minutesByEvent = mapOf(1L to listOf(10)),
|
||||
)
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned, lastFiredMillis = now - day, nowMillis = now, horizonMillis = now + day,
|
||||
)
|
||||
|
||||
assertThat(schedule.due).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `due reminders come out in occurrence order`() {
|
||||
val planned = plan(
|
||||
instances = listOf(
|
||||
instance(1L, beginMillis = now + 20 * minute),
|
||||
instance(2L, beginMillis = now + 5 * minute),
|
||||
),
|
||||
minutesByEvent = mapOf(1L to listOf(30), 2L to listOf(30)),
|
||||
)
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned, lastFiredMillis = now - day, nowMillis = now, horizonMillis = now + day,
|
||||
)
|
||||
|
||||
assertThat(schedule.due.map { it.instance.eventId }).containsExactly(2L, 1L).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the next wake-up is the earliest reminder still ahead`() {
|
||||
val planned = plan(
|
||||
instances = listOf(
|
||||
instance(1L, beginMillis = now + 20 * minute),
|
||||
instance(2L, beginMillis = now + 90 * minute),
|
||||
),
|
||||
minutesByEvent = mapOf(1L to listOf(5), 2L to listOf(5)),
|
||||
)
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned, lastFiredMillis = now, nowMillis = now, horizonMillis = now + day,
|
||||
)
|
||||
|
||||
assertThat(schedule.nextAlarmMillis).isEqualTo(now + 15 * minute)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with nothing pending the scan still re-runs at the horizon`() {
|
||||
val schedule = scheduleReminders(
|
||||
planned = emptyList(), lastFiredMillis = now, nowMillis = now,
|
||||
horizonMillis = now + day,
|
||||
)
|
||||
|
||||
assertThat(schedule.nextAlarmMillis).isEqualTo(now + day)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder beyond the horizon waits for the next scan`() {
|
||||
// Capping keeps the rolling window honest: the far-off reminder is picked
|
||||
// up by a later scan rather than pinned to an alarm we may never re-check.
|
||||
val planned = plan(
|
||||
instances = listOf(instance(1L, beginMillis = now + 30 * day)),
|
||||
minutesByEvent = mapOf(1L to listOf(5)),
|
||||
)
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned, lastFiredMillis = now, nowMillis = now, horizonMillis = now + day,
|
||||
)
|
||||
|
||||
assertThat(schedule.nextAlarmMillis).isEqualTo(now + day)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the query horizon stretches past the longest reminder offset`() {
|
||||
// A "2 weeks before" reminder has to be planned while its event is still
|
||||
// outside the plain lookahead, or it fires late.
|
||||
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = 20_160))
|
||||
.isEqualTo(7 * day + 14 * day)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the query horizon is capped against an outlier row`() {
|
||||
// The longest offset comes from any row in the provider — an imported
|
||||
// TRIGGER:-P100W would otherwise expand every series over two years, on
|
||||
// every scan.
|
||||
val hundredWeeks = 100 * 7 * 24 * 60
|
||||
|
||||
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = hundredWeeks))
|
||||
.isEqualTo(7 * day + MAX_REMINDER_LEAD_MILLIS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a first-ever scan starts from now, not from the epoch`() {
|
||||
// Otherwise an install — or the upgrade onto in-house delivery — treats
|
||||
// every reminder ever set as overdue and posts the lot.
|
||||
assertThat(reminderWatermark(lastScanMillis = null, nowMillis = now)).isEqualTo(now)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary watermark is used as recorded`() {
|
||||
assertThat(reminderWatermark(lastScanMillis = now - day, nowMillis = now))
|
||||
.isEqualTo(now - day)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a watermark left in the future by a clock change is clamped`() {
|
||||
// Left alone it would silence every reminder until real time caught up.
|
||||
assertThat(reminderWatermark(lastScanMillis = now + 5 * day, nowMillis = now))
|
||||
.isEqualTo(now)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a negative longest offset does not shrink the query horizon`() {
|
||||
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = -420))
|
||||
.isEqualTo(7 * day)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.spansMultipleDays
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
|
||||
@@ -45,9 +45,14 @@ class EventEditViewModelTest {
|
||||
private val beginMillis = 1_781_164_800_000L
|
||||
private val endMillis = beginMillis + 3_600_000L
|
||||
|
||||
private fun cal(id: Long): CalendarSource = CalendarSource(
|
||||
private fun cal(
|
||||
id: Long,
|
||||
visible: Boolean = true,
|
||||
syncsEvents: Boolean = true,
|
||||
): CalendarSource = CalendarSource(
|
||||
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
|
||||
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
|
||||
color = 0xFF112233.toInt(), isVisibleInSystem = visible, canModifyContents = true,
|
||||
syncsEvents = syncsEvents,
|
||||
)
|
||||
|
||||
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
|
||||
@@ -90,6 +95,66 @@ class EventEditViewModelTest {
|
||||
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
|
||||
private fun CoroutineScope.activate(vm: EventEditViewModel): Job = launch { vm.state.collect {} }
|
||||
|
||||
@Test
|
||||
fun `a calendar switched off in settings is not offered as a target`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L), cal(2L, visible = false))
|
||||
eventDetailResult = { detail(calendarId = 1L) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.calendars?.map { it.id }).containsExactly(1L)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calendar whose account is not synced to this device is not a target`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
// Writable and switched on, but the account keeps its events off the
|
||||
// device: nothing saved here ever reaches it, and the provider drops the
|
||||
// rows when the subscription comes back (#76).
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L), cal(2L, syncsEvents = false))
|
||||
eventDetailResult = { detail(calendarId = 1L) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.calendars?.map { it.id }).containsExactly(1L)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing an event in a switched-off calendar keeps it in the picker`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
// Otherwise the calendar row renders as the "no calendar" error and any
|
||||
// pick routes the save through a move the user never asked for.
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L), cal(2L, visible = false))
|
||||
eventDetailResult = { detail(calendarId = 2L) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.calendars?.map { it.id }).containsExactly(1L, 2L)
|
||||
assertThat(vm.state.value?.form?.calendarId).isEqualTo(2L)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing the calendar routes the save through a move, not an update`(
|
||||
@TempDir tempDir: Path,
|
||||
|
||||
@@ -28,7 +28,7 @@ class FilterGroupingTest {
|
||||
cal(3, "Shared", "team@dav"),
|
||||
)
|
||||
|
||||
val groups = groupByAccount(calendars, hidden = emptySet())
|
||||
val groups = groupCalendarsForFilter(calendars, hidden = emptySet())
|
||||
|
||||
assertThat(groups.map { it.account }).containsExactly("alice@dav", "team@dav").inOrder()
|
||||
assertThat(groups[0].calendars.map { it.displayName })
|
||||
@@ -43,7 +43,7 @@ class FilterGroupingTest {
|
||||
cal(2, "Work", "alice@dav"),
|
||||
)
|
||||
|
||||
val groups = groupByAccount(calendars, hidden = setOf(2L))
|
||||
val groups = groupCalendarsForFilter(calendars, hidden = setOf(2L))
|
||||
val rows = groups.single().calendars.associateBy { it.id }
|
||||
|
||||
assertThat(rows.getValue(1L).visible).isTrue()
|
||||
@@ -52,10 +52,52 @@ class FilterGroupingTest {
|
||||
|
||||
@Test
|
||||
fun `blank account name falls back to type`() {
|
||||
val groups = groupByAccount(
|
||||
val groups = groupCalendarsForFilter(
|
||||
listOf(cal(1, "Birthdays", account = "", type = "LOCAL")),
|
||||
hidden = emptySet(),
|
||||
)
|
||||
assertThat(groups.single().account).isEqualTo("LOCAL")
|
||||
}
|
||||
|
||||
/** #77: same address, two apps — two accounts, and they must read as two. */
|
||||
@Test
|
||||
fun `one name under two account types stays two groups`() {
|
||||
val calendars = listOf(
|
||||
cal(1, "Personal", "me@example.com", type = "com.google"),
|
||||
cal(2, "Shared", "me@example.com", type = "bitfire.at.davdroid"),
|
||||
)
|
||||
|
||||
val groups = groupCalendarsForFilter(calendars, hidden = emptySet())
|
||||
|
||||
assertThat(groups).hasSize(2)
|
||||
assertThat(groups.map { it.accountType })
|
||||
.containsExactly("com.google", "bitfire.at.davdroid").inOrder()
|
||||
assertThat(groups.map { it.calendars.single().id }).containsExactly(1L, 2L).inOrder()
|
||||
assertThat(groups.all { it.ambiguous }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one name under one type is not ambiguous`() {
|
||||
val groups = groupCalendarsForFilter(
|
||||
listOf(cal(1, "Personal", "me@example.com")),
|
||||
hidden = emptySet(),
|
||||
)
|
||||
|
||||
assertThat(groups.single().ambiguous).isFalse()
|
||||
}
|
||||
|
||||
/** Two different names from the same app are still two plain groups. */
|
||||
@Test
|
||||
fun `different names under one type are not ambiguous`() {
|
||||
val groups = groupCalendarsForFilter(
|
||||
listOf(
|
||||
cal(1, "Personal", "alice@example.com"),
|
||||
cal(2, "Shared", "bob@example.com"),
|
||||
),
|
||||
hidden = emptySet(),
|
||||
)
|
||||
|
||||
assertThat(groups).hasSize(2)
|
||||
assertThat(groups.none { it.ambiguous }).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,42 @@ class LaneEventsTest {
|
||||
.containsNoneIn(seated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overflow is exactly what seating left behind`() {
|
||||
val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) }
|
||||
val week = rowOfJuly6(events)
|
||||
val day = LocalDate(2026, 7, 6)
|
||||
|
||||
val seated = week.laneEvents(col = 0, day = day, laneCap = 3)
|
||||
val overflow = week.overflowEvents(col = 0, day = day, laneCap = 3)
|
||||
|
||||
assertThat(overflow).containsExactlyElementsIn(events.drop(3)).inOrder()
|
||||
assertThat(seated + overflow).containsExactlyElementsIn(events)
|
||||
assertThat(seated.size + overflow.size).isEqualTo(week.countByDay[day])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bar beyond the cap overflows on every day it covers`() {
|
||||
val bars = (0 until 4).map {
|
||||
allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L)
|
||||
}
|
||||
val week = rowOfJuly6(bars)
|
||||
val parked = week.spans.filter { it.lane >= 3 }.map { it.event }
|
||||
|
||||
(0..2).forEach { col ->
|
||||
val day = LocalDate(2026, 7, 6 + col)
|
||||
assertThat(week.overflowEvents(col, day, laneCap = 3))
|
||||
.containsExactlyElementsIn(parked)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a day that fits has no overflow`() {
|
||||
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 6), hour = 9, id = 1L)))
|
||||
|
||||
assertThat(week.overflowEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)).isEmpty()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BLUE = 0xFF3366CC.toInt()
|
||||
const val RED = 0xFFCC3333.toInt()
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class WidgetScaleTest {
|
||||
|
||||
@Test
|
||||
fun `the on-device calibration points map to their tiers`() {
|
||||
// The two sizes measured on-device: the compact widget stays COMPACT (the
|
||||
// baseline, unchanged), the full-width one steps up to LARGE — not XLARGE,
|
||||
// which read as too big on a phone (#51).
|
||||
assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full-width widget at ordinary height still scales up`() {
|
||||
// The regression the height cap used to cause: widening the widget without
|
||||
// also making it unusually tall is *the* resize #51 reports, and it must
|
||||
// reach the tier its width earned. Three cells tall is about 270dp.
|
||||
assertThat(scaleFor(DpSize(378.dp, 270.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(300.dp, 270.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(460.dp, 300.dp))).isEqualTo(WidgetScale.XLARGE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `width buckets into the four tiers`() {
|
||||
// Tall enough that the height cap never binds, isolating the width rule.
|
||||
val h = 500.dp
|
||||
assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(WidgetScale.XLARGE)
|
||||
assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(WidgetScale.XLARGE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extra height never raises the tier`() {
|
||||
// Height decides how many rows are visible, not how big they are: a tall,
|
||||
// narrow widget wants more events, not bigger text.
|
||||
assertThat(scaleFor(DpSize(222.dp, 200.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a genuinely squashed widget is stepped back down`() {
|
||||
// Same (wide) width, shrinking height. The cap exists to stop oversized type
|
||||
// surviving in a one- or two-row sliver — it must not fire at normal heights.
|
||||
// Heights are gross; the cap works on height minus 60dp of chrome, so the
|
||||
// LARGE floor is 190dp (130dp usable) and the REGULAR floor 130dp (70dp).
|
||||
val wide = 378.dp
|
||||
assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(wide, 260.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(wide, 190.dp))).isEqualTo(WidgetScale.LARGE)
|
||||
assertThat(scaleFor(DpSize(wide, 189.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(wide, 130.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||
assertThat(scaleFor(DpSize(wide, 129.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
// The provider's declared floor (minResizeWidth/Height = 110dp) is COMPACT.
|
||||
assertThat(scaleFor(DpSize(110.dp, 110.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the tier is monotonic in both axes`() {
|
||||
// Growing a widget must never make its type smaller. Sweeps the whole
|
||||
// plausible range rather than spot-checking, so a future threshold edit
|
||||
// can't accidentally invert a step.
|
||||
val widths = (110..900 step 7).map { it.dp }
|
||||
val heights = (110..900 step 7).map { it.dp }
|
||||
widths.forEach { w ->
|
||||
heights.zipWithNext { shorter, taller ->
|
||||
assertThat(scaleFor(DpSize(w, taller)))
|
||||
.isAtLeast(scaleFor(DpSize(w, shorter)))
|
||||
}
|
||||
}
|
||||
heights.forEach { h ->
|
||||
widths.zipWithNext { narrower, wider ->
|
||||
assertThat(scaleFor(DpSize(wider, h)))
|
||||
.isAtLeast(scaleFor(DpSize(narrower, h)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user