Compare commits
12 Commits
fix/214-wi
...
2f46eba4fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f46eba4fa | |||
|
|
1d07b64a28 | ||
| d037492cf6 | |||
| 8e2109d073 | |||
| bf6415c023 | |||
| 7aef01d95e | |||
| ce4d6bc4d1 | |||
| 4ad805e747 | |||
| bb6e3ad336 | |||
| edbeadfa30 | |||
| ef48717e2c | |||
| 41593a0e9d |
65
CHANGELOG.md
65
CHANGELOG.md
@@ -7,6 +7,33 @@ 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]).
|
||||
- Both home-screen widgets have a size you set yourself. **Settings → Widgets →
|
||||
Widget size** offers Small, Medium, Large and Extra large: it sets how big the
|
||||
agenda widget's text is, and how big the month widget's day columns and text
|
||||
are. Small is what the widgets look like today, so nothing changes until you
|
||||
turn it up ([#51], [#103]).
|
||||
|
||||
### 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 +41,39 @@ 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
|
||||
- The month widget shows all seven days again. Unless it happened to be resized
|
||||
to just the right shape, it drew only about four columns and cut the last one
|
||||
off part-way through — the rest of the week was simply missing. The widget was
|
||||
sizing its columns from the width the launcher reported, which is not the width
|
||||
it is actually drawn into, so the columns came out too wide to fit. Nothing in
|
||||
either widget is measured any more: they draw at the size you pick under
|
||||
Settings → Widgets. The month widget can no longer be resized narrower than its
|
||||
grid needs, either ([#103], [#51]).
|
||||
- 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 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 +1179,8 @@ 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
|
||||
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
||||
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -39,6 +40,24 @@ class CalendulaApp : Application() {
|
||||
)
|
||||
reconcileAutoBackup()
|
||||
reconcileSpecialDates()
|
||||
reconcileCalendarVisibility()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -110,6 +110,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 +399,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 {
|
||||
|
||||
@@ -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,140 @@ 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)
|
||||
}
|
||||
|
||||
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 +212,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"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(","))
|
||||
}
|
||||
|
||||
@@ -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 both home-screen widgets draw themselves at (#103, #51).
|
||||
* Defaults to [WidgetSize.SMALL], which reproduces their 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: the
|
||||
* width a launcher reports is not the width the widget is drawn into, and the
|
||||
* month grid sized its seven columns from it and lost the last three.
|
||||
*/
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
|
||||
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
||||
|
||||
/**
|
||||
* The alerts [EventReminderReceiver] may mark handled (`STATE_FIRED`): the ones
|
||||
* it posted, plus the ones it silenced whose event is already over.
|
||||
*
|
||||
* A silenced alert for an event still ahead is deliberately left
|
||||
* `STATE_SCHEDULED`. Silencing is not handling — the calendar is switched off in
|
||||
* Calendula while the provider still holds `VISIBLE = 1` (a read-only install,
|
||||
* or an upgrade whose flush hasn't landed), so switching it back on before the
|
||||
* event must still be able to surface the reminder. [ReminderAlertStore.dueAlerts]
|
||||
* only ever returns scheduled rows, so marking them here would lose them for
|
||||
* good; leaving them makes the provider's own table the stash
|
||||
* ([ReminderRecovery]).
|
||||
*/
|
||||
internal fun handledAlertIds(
|
||||
due: List<ReminderAlert>,
|
||||
postedIds: Set<Long>,
|
||||
nowMillis: Long,
|
||||
): List<Long> = due
|
||||
.filter { it.alertId in postedIds || !it.isRelevantAt(nowMillis) }
|
||||
.map { it.alertId }
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -17,29 +16,6 @@ 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
|
||||
@@ -49,6 +25,14 @@ internal fun postableAlerts(
|
||||
* 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.
|
||||
*
|
||||
* There is no per-calendar filtering here: a calendar switched off in
|
||||
* Settings → Calendars has `Calendars.VISIBLE = 0`, and the provider creates no
|
||||
* alert rows for it in the first place (#75). The one case that flag can't
|
||||
* cover — a read-only install, which keeps its switches app-side — is gated in
|
||||
* [ReminderNotifier.post], where the snoozed re-show passes too. What that gate
|
||||
* silences is *not* marked fired while the event is still ahead, so switching
|
||||
* the calendar back on can still surface it (see [handledAlertIds]).
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class EventReminderReceiver : BroadcastReceiver() {
|
||||
@@ -56,8 +40,6 @@ 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
|
||||
@@ -72,17 +54,10 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
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)
|
||||
val postedIds = due
|
||||
.filter { notifier.post(it) }
|
||||
.mapTo(mutableSetOf()) { it.alertId }
|
||||
alertStore.markFired(handledAlertIds(due, postedIds, now), now)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
|
||||
@@ -23,7 +23,9 @@ import javax.inject.Inject
|
||||
* - **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 ours, so no
|
||||
* provider alert row stands between it and the notification).
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderActionReceiver : BroadcastReceiver() {
|
||||
|
||||
@@ -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
|
||||
@@ -39,6 +40,7 @@ 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 +51,26 @@ 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 provider
|
||||
* side needs no help — with `VISIBLE = 0` it creates no alert rows at all —
|
||||
* but two paths reach [post] without one: a snooze we re-show from our own
|
||||
* exact alarm, scheduled 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: a silenced alert must stay unhandled so that
|
||||
* switching the calendar back on can still surface it (see [handledAlertIds]).
|
||||
*/
|
||||
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()
|
||||
@@ -106,6 +122,8 @@ class ReminderNotifier @Inject constructor(
|
||||
// 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). */
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Re-posts the reminders a switched-off calendar silenced, when it is switched
|
||||
* back on while they still matter.
|
||||
*
|
||||
* Only the app-side switch needs this — a read-only install, or an upgrade the
|
||||
* reconciler hasn't flushed yet. With `Calendars.VISIBLE = 0` the provider
|
||||
* deletes the calendar's alert rows itself and re-creates them on the way back;
|
||||
* app-side the rows stay, still `STATE_SCHEDULED`, because
|
||||
* [EventReminderReceiver] deliberately leaves the ones it silenced unhandled
|
||||
* (see [handledAlertIds]). So the provider's own table is the stash, and nothing
|
||||
* is mirrored locally.
|
||||
*
|
||||
* Best effort at switch-on time: it mirrors the receiver's gates (reminders on,
|
||||
* notifications postable) and there is no later re-scan, so an alert left
|
||||
* unposted because those are closed is simply released.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderRecovery @Inject constructor(
|
||||
private val alertStore: ReminderAlertStore,
|
||||
private val notifier: ReminderNotifier,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
suspend fun rePostFor(calendarIds: Collection<Long>) = withContext(io) {
|
||||
if (calendarIds.isEmpty()) return@withContext
|
||||
if (!settingsPrefs.remindersEnabled.first() || !notifier.canPost()) return@withContext
|
||||
val now = System.currentTimeMillis()
|
||||
val ids = calendarIds.toSet()
|
||||
val recovered = alertStore.dueAlerts(now)
|
||||
.filter { it.calendarId in ids && it.isRelevantAt(now) }
|
||||
if (recovered.isEmpty()) return@withContext
|
||||
val postedIds = recovered.filter { notifier.post(it) }.map { it.alertId }
|
||||
alertStore.markFired(postedIds, now)
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
|
||||
@@ -415,6 +415,7 @@ fun CalendarHost(
|
||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -434,6 +435,7 @@ fun CalendarHost(
|
||||
editKey = null
|
||||
detailKey = null
|
||||
},
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -450,18 +452,6 @@ fun CalendarHost(
|
||||
)
|
||||
}
|
||||
|
||||
// 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 },
|
||||
)
|
||||
}
|
||||
|
||||
// Import flow for an opened/received .ics file. A single event routes
|
||||
// into the create form (prefilled, for review); many open the picker.
|
||||
importUri?.let { uri ->
|
||||
@@ -469,6 +459,7 @@ fun CalendarHost(
|
||||
uri = uri,
|
||||
forceMany = importForceMany,
|
||||
onClose = { importUri = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onOpenSingle = { form ->
|
||||
importUri = null
|
||||
importFormSource = ImportSource.File
|
||||
@@ -483,6 +474,27 @@ 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 },
|
||||
// The manager opens the import too (restore from backup), and
|
||||
// that way round it 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 the
|
||||
// manager once the import is done.
|
||||
onImport = { importUri = it; importForceMany = true; showCalendars = 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
|
||||
@@ -78,6 +81,17 @@ 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 (onboardingDone == true && noticePending) {
|
||||
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
||||
}
|
||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||
when (done) {
|
||||
true -> CalendarHost(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ 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.Info
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
@@ -91,6 +92,12 @@ 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.isEventTarget
|
||||
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.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
@@ -139,7 +146,7 @@ fun CalendarsScreen(
|
||||
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()
|
||||
@@ -158,6 +165,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,7 +185,6 @@ fun CalendarsScreen(
|
||||
CalendarsList(
|
||||
local = calendars.filter { it.isLocal },
|
||||
synced = calendars.filterNot { it.isLocal },
|
||||
disabledIds = disabledIds,
|
||||
error = error,
|
||||
onConsumeError = viewModel::consumeError,
|
||||
backupResult = backupResult,
|
||||
@@ -191,8 +198,8 @@ fun CalendarsScreen(
|
||||
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,7 +208,6 @@ fun CalendarsScreen(
|
||||
private fun CalendarsList(
|
||||
local: List<CalendarSource>,
|
||||
synced: List<CalendarSource>,
|
||||
disabledIds: Set<Long>,
|
||||
error: Boolean,
|
||||
onConsumeError: () -> Unit,
|
||||
backupResult: BackupResult?,
|
||||
@@ -215,8 +221,8 @@ private fun CalendarsList(
|
||||
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() }
|
||||
@@ -281,12 +287,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 +304,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 +323,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) },
|
||||
@@ -330,9 +336,9 @@ private fun CalendarsList(
|
||||
// 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 }
|
||||
// Restore/import can target any calendar the import picker would offer
|
||||
// (local or synced), so its availability is broader than export's.
|
||||
val canImport = (local + synced).any { it.isEventTarget }
|
||||
if (exportable.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||
@@ -415,7 +421,11 @@ private fun CalendarsList(
|
||||
.forEach { (account, cals) ->
|
||||
val expanded = account !in collapsedAccounts
|
||||
val accountType = cals.first().accountType
|
||||
val accountDisabled = cals.all { it.id in disabledIds }
|
||||
// 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,
|
||||
@@ -435,24 +445,36 @@ private fun CalendarsList(
|
||||
collapsedAccounts - account
|
||||
}
|
||||
},
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -560,6 +582,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 +616,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 +661,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 +746,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 +780,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,
|
||||
|
||||
@@ -12,10 +12,8 @@ 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.data.reminders.ReminderRecovery
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -45,10 +43,8 @@ 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,
|
||||
private val reminderRecovery: ReminderRecovery,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -62,20 +58,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 +74,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 +149,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.
|
||||
*
|
||||
* Switching one back on also re-posts the reminders it silenced while it was
|
||||
* off and that are still relevant — those the app kept app-side because it
|
||||
* may not write the flag ([ReminderRecovery]).
|
||||
*/
|
||||
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)
|
||||
if (visible) reminderRecovery.rePostFor(listOf(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (the
|
||||
* provider only re-arms reminder alarms for a single-id update), 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)
|
||||
if (visible) reminderRecovery.rePostFor(ids)
|
||||
}
|
||||
|
||||
// --- Automatic backup (issue #8) ------------------------------------
|
||||
|
||||
@@ -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,12 +44,19 @@ 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) {
|
||||
@@ -75,6 +84,23 @@ fun ColumnScope.CalendarPickerGroups(
|
||||
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. */
|
||||
|
||||
@@ -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
|
||||
@@ -193,6 +195,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 +312,7 @@ fun EventEditScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
onManageCalendars = onManageCalendars,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -500,6 +504,7 @@ private fun EventEditContent(
|
||||
state: EventEditUiState,
|
||||
viewModel: EventEditViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
) {
|
||||
val form = state.form
|
||||
val locale = currentLocale()
|
||||
@@ -509,6 +514,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 +1123,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 +1142,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 },
|
||||
)
|
||||
}
|
||||
@@ -2277,6 +2308,7 @@ private fun CalendarPicker(
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
) {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_detail_calendar),
|
||||
@@ -2286,6 +2318,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() -
|
||||
|
||||
@@ -30,12 +30,11 @@ 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 {
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -117,6 +117,7 @@ import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
@@ -150,6 +151,7 @@ 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.calendula.widget.WidgetSize
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalTime
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
@@ -499,6 +501,7 @@ private fun AppearanceScreen(
|
||||
var showDefaultView by remember { mutableStateOf(false) }
|
||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||
var showWidgetSize by remember { mutableStateOf(false) }
|
||||
var showPastEvents by remember { mutableStateOf(false) }
|
||||
var showBrandFont by remember { mutableStateOf(false) }
|
||||
var showPlainFont by remember { mutableStateOf(false) }
|
||||
@@ -699,6 +702,18 @@ private fun AppearanceScreen(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Widgets — the size step applies to both home-screen widgets, so it sits
|
||||
// in its own group rather than under Agenda.
|
||||
SectionHeader(stringResource(R.string.settings_widgets_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_widget_size),
|
||||
summary = widgetSizeLabel(state.widgetSize),
|
||||
position = Position.Alone,
|
||||
onClick = { showWidgetSize = 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 calendar
|
||||
// formatting. A sub-page chooser (not a switch), matching the app's other
|
||||
@@ -810,6 +825,18 @@ private fun AppearanceScreen(
|
||||
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) },
|
||||
onSelect = viewModel::setWidgetSize,
|
||||
onDismiss = { showWidgetSize = false },
|
||||
)
|
||||
}
|
||||
if (showTimeFormat) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_time_format),
|
||||
@@ -2070,6 +2097,16 @@ private fun pastEventDisplayLabel(mode: PastEventDisplay): String = stringResour
|
||||
},
|
||||
)
|
||||
|
||||
@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
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun languageLabel(tag: String?): String =
|
||||
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
||||
|
||||
@@ -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 both home-screen widgets draw themselves at (#103, #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). */
|
||||
|
||||
@@ -38,10 +38,13 @@ 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.MONTH_SIZE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -74,9 +77,14 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
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 +163,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 +188,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 +303,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 +482,29 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the size step both widgets draw at (#103, #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.
|
||||
*/
|
||||
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 }
|
||||
}
|
||||
manager.getGlanceIds(MonthWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[MONTH_SIZE_KEY] = size.name }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
MonthWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAgendaShowToday(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setAgendaShowToday(enabled)
|
||||
|
||||
@@ -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,18 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
/**
|
||||
* The size step a widget draws itself at — a **user setting**, not something
|
||||
* derived from the widget's measured size (#103, #51).
|
||||
*
|
||||
* Both Glance widgets used to bucket their live size (`SizeMode.Exact` +
|
||||
* `LocalSize.current`) into a tier and scale from that. The size a launcher
|
||||
* reports is not the width the widget is actually drawn into, which broke the
|
||||
* month grid outright: its seven columns were sized from that number, came out
|
||||
* too wide, and only the first four fitted. Nothing here reads a measured size
|
||||
* any more — the user picks a step, every metric follows from it, and both
|
||||
* widgets declare `SizeMode.Single`.
|
||||
*
|
||||
* [SMALL] is the default and reproduces the widgets' 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()
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
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.WidgetSize
|
||||
|
||||
/** Event rows (lanes) shown per week before the rest collapse into "+N". */
|
||||
internal const val MAX_LANES = 3
|
||||
|
||||
/**
|
||||
* Every size the month widget draws from, resolved from the user's chosen
|
||||
* [WidgetSize].
|
||||
*
|
||||
* [columnWidth] is the load-bearing one. The grid lays its seven day columns out
|
||||
* at this fixed width and centres the result in whatever space the host gives it,
|
||||
* rather than dividing a launcher-reported width by seven (#103) — a reported
|
||||
* width that overshoots the real one used to push the last columns off the edge.
|
||||
* A fixed column also keeps a multi-day event a single connected bar
|
||||
* `columnWidth * n` wide, which is why the columns can't simply be weighted.
|
||||
*/
|
||||
internal data class MonthMetrics(
|
||||
val columnWidth: Dp, // one day column
|
||||
val gridPadding: Dp, // horizontal padding either side of the grid
|
||||
val laneHeight: Dp, // one event-bar row
|
||||
val dayNumberHeight: Dp, // day-number row, and the today circle's diameter
|
||||
val headerTitle: TextUnit,
|
||||
val weekday: TextUnit, // narrow weekday initials
|
||||
val dayNumber: TextUnit,
|
||||
val eventTitle: TextUnit, // title inside an event bar
|
||||
val overflow: TextUnit, // the "+N" line
|
||||
val iconImage: Dp, // header arrow/today glyph
|
||||
val iconBox: Dp, // header action touch target
|
||||
) {
|
||||
/** Width the seven columns plus their padding occupy — what a placement must fit. */
|
||||
val gridWidth: Dp get() = columnWidth * 7 + gridPadding * 2
|
||||
}
|
||||
|
||||
/*
|
||||
* SMALL reproduces the widget's original constants so an existing widget is
|
||||
* unchanged until its owner turns the size up — except [columnWidth], which had
|
||||
* no fixed value before (it was derived from the reported width, at roughly 33dp
|
||||
* on a default 4-cell placement). 30dp sits just under that so the smallest step
|
||||
* still fits the narrowest placement the provider allows; see
|
||||
* `appwidget_info_month.xml`'s minResizeWidth, which is pinned against
|
||||
* SMALL.gridWidth by a test.
|
||||
*/
|
||||
|
||||
private val SMALL_METRICS = MonthMetrics(
|
||||
columnWidth = 30.dp,
|
||||
gridPadding = 4.dp,
|
||||
laneHeight = 14.dp,
|
||||
dayNumberHeight = 18.dp,
|
||||
headerTitle = 15.sp,
|
||||
weekday = 11.sp,
|
||||
dayNumber = 11.sp,
|
||||
eventTitle = 9.sp,
|
||||
overflow = 9.sp,
|
||||
iconImage = 20.dp,
|
||||
iconBox = 40.dp,
|
||||
)
|
||||
|
||||
private val MEDIUM_METRICS = MonthMetrics(
|
||||
columnWidth = 38.dp,
|
||||
gridPadding = 6.dp,
|
||||
laneHeight = 16.dp,
|
||||
dayNumberHeight = 22.dp,
|
||||
headerTitle = 17.sp,
|
||||
weekday = 12.sp,
|
||||
dayNumber = 13.sp,
|
||||
eventTitle = 10.sp,
|
||||
overflow = 10.sp,
|
||||
iconImage = 22.dp,
|
||||
iconBox = 44.dp,
|
||||
)
|
||||
|
||||
private val LARGE_METRICS = MonthMetrics(
|
||||
columnWidth = 46.dp,
|
||||
gridPadding = 8.dp,
|
||||
laneHeight = 19.dp,
|
||||
dayNumberHeight = 26.dp,
|
||||
headerTitle = 19.sp,
|
||||
weekday = 13.sp,
|
||||
dayNumber = 15.sp,
|
||||
eventTitle = 11.sp,
|
||||
overflow = 11.sp,
|
||||
iconImage = 24.dp,
|
||||
iconBox = 48.dp,
|
||||
)
|
||||
|
||||
private val EXTRA_LARGE_METRICS = MonthMetrics(
|
||||
columnWidth = 54.dp,
|
||||
gridPadding = 8.dp,
|
||||
laneHeight = 22.dp,
|
||||
dayNumberHeight = 30.dp,
|
||||
headerTitle = 22.sp,
|
||||
weekday = 14.sp,
|
||||
dayNumber = 17.sp,
|
||||
eventTitle = 12.sp,
|
||||
overflow = 12.sp,
|
||||
iconImage = 26.dp,
|
||||
iconBox = 52.dp,
|
||||
)
|
||||
|
||||
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
|
||||
private val MONTH_METRICS = listOf(
|
||||
SMALL_METRICS,
|
||||
MEDIUM_METRICS,
|
||||
LARGE_METRICS,
|
||||
EXTRA_LARGE_METRICS,
|
||||
)
|
||||
|
||||
internal fun monthMetricsFor(size: WidgetSize): MonthMetrics = MONTH_METRICS[size.ordinal]
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
@@ -13,7 +14,6 @@ import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.action.clickable
|
||||
@@ -56,6 +56,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
@@ -73,11 +74,13 @@ import java.util.Locale
|
||||
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
|
||||
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
|
||||
|
||||
/** Event rows (lanes) shown per week before the rest collapse into "+N". */
|
||||
private const val MAX_LANES = 3
|
||||
private val LANE_HEIGHT = 14.dp
|
||||
private val DAY_NUMBER_HEIGHT = 18.dp
|
||||
private val GRID_HPADDING = 8.dp
|
||||
/**
|
||||
* Per-instance Glance state key holding the chosen [WidgetSize]. Read reactively
|
||||
* in the composition so changing the setting reflects on a live widget by plain
|
||||
* recomposition — `updateAll` does not reliably re-run the `provideGlance`
|
||||
* preamble for a live session (same reason as [MONTH_INDEX_KEY]).
|
||||
*/
|
||||
internal val MONTH_SIZE_KEY = stringPreferencesKey("widget_size")
|
||||
|
||||
private fun currentMonthIndex(zone: TimeZone): Int {
|
||||
val t = today(zone)
|
||||
@@ -92,27 +95,45 @@ private fun yearMonthOf(index: Int): YearMonth =
|
||||
* event bars and titled single-day pills (the in-app lane layout via
|
||||
* [layoutMonthWeeks]), and prev/next/today navigation.
|
||||
*
|
||||
* Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a
|
||||
* multi-day span renders as a single Box spanning its columns — connected, no
|
||||
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
||||
* state and is read reactively in the composition ([currentState]) so the arrows
|
||||
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
||||
* Columns are a fixed [MonthMetrics.columnWidth] wide — the user's chosen
|
||||
* [WidgetSize], never a measured size — and the grid is centred in whatever space
|
||||
* the host gives it. Sizing the columns off the launcher-reported width is what
|
||||
* broke the grid (#103): the reported width overshot the width actually drawn
|
||||
* into, so `width / 7` came out too wide and only the first four columns fitted.
|
||||
* A fixed column also keeps a multi-day event one connected Box spanning its
|
||||
* columns — no inter-cell seam, rounded end caps — which weighted columns, the
|
||||
* other way to be measurement-free, could not express.
|
||||
*
|
||||
* The displayed month lives in Glance state and is read reactively in the
|
||||
* composition ([currentState]) so the arrows move it via plain recomposition, not
|
||||
* a (here-unreliable) widget session reload.
|
||||
*/
|
||||
class MonthWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
// Single, not Exact: nothing in the layout depends on the widget's measured
|
||||
// size any more (#103), so there is no reason to pay for one RemoteViews per
|
||||
// host size bucket.
|
||||
override val sizeMode = SizeMode.Single
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val source = context.loadMonthWidgetSource()
|
||||
val dark = (context.resources.configuration.uiMode and
|
||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
// Read fresh (not through the cached source) so toggling the softener
|
||||
// redraws with the new choice; it's one cheap DataStore read.
|
||||
val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first()
|
||||
// Read fresh (not through the cached source) so toggling the softener or
|
||||
// the size redraws with the new choice; two cheap DataStore reads.
|
||||
val prefs = context.widgetEntryPoint().settingsPrefs()
|
||||
val soften = prefs.softenCalendarColors.first()
|
||||
val savedSize = prefs.widgetSize.first()
|
||||
provideContent {
|
||||
CalendulaGlanceTheme {
|
||||
MonthWidgetBody(source = source, dark = dark, soften = soften)
|
||||
MonthWidgetBody(
|
||||
source = source,
|
||||
dark = dark,
|
||||
soften = soften,
|
||||
savedSize = savedSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,29 +164,35 @@ class ResetMonthAction : ActionCallback {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) {
|
||||
private fun MonthWidgetBody(
|
||||
source: MonthWidgetSource,
|
||||
dark: Boolean,
|
||||
soften: Boolean,
|
||||
savedSize: WidgetSize,
|
||||
) {
|
||||
// Size read reactively from per-instance Glance state (falling back to the
|
||||
// saved pref for a freshly placed widget), never from the measured size.
|
||||
val metrics = monthMetricsFor(currentState(MONTH_SIZE_KEY).toWidgetSize(savedSize))
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(GlanceTheme.colors.surface)
|
||||
.padding(horizontal = GRID_HPADDING, vertical = 6.dp),
|
||||
.padding(horizontal = metrics.gridPadding, vertical = 6.dp),
|
||||
) {
|
||||
when (source) {
|
||||
MonthWidgetSource.NeedsPermission -> {
|
||||
MonthHeader(label = "Calendula")
|
||||
MonthHeader(label = "Calendula", metrics = metrics)
|
||||
PermissionMessage()
|
||||
}
|
||||
is MonthWidgetSource.Ready -> {
|
||||
val zone = systemZone()
|
||||
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
||||
val ym = yearMonthOf(index)
|
||||
// Column width from the live widget size, minus our H padding.
|
||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||
|
||||
MonthHeader(label = monthLabel(ym, source.today.year))
|
||||
MonthHeader(label = monthLabel(ym, source.today.year), metrics = metrics)
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||
WeekdayHeader(weekStart = source.weekStart, metrics = metrics)
|
||||
weeks.forEach { week ->
|
||||
WeekRow(
|
||||
week = week,
|
||||
@@ -173,7 +200,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
|
||||
today = source.today,
|
||||
dark = dark,
|
||||
soften = soften,
|
||||
colW = colW,
|
||||
metrics = metrics,
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
)
|
||||
}
|
||||
@@ -182,8 +209,26 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A grid row: the seven fixed-width columns, centred in the space the host gave
|
||||
* us. Every row of the grid goes through this so they can never drift out of
|
||||
* alignment, and centring means leftover width shows as an even margin either
|
||||
* side rather than a ragged edge (turn the size up to fill it).
|
||||
*/
|
||||
@Composable
|
||||
private fun MonthHeader(label: String) {
|
||||
private fun GridRow(content: @Composable androidx.glance.layout.RowScope.() -> Unit) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
private fun String?.toWidgetSize(default: WidgetSize): WidgetSize =
|
||||
this?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } } ?: default
|
||||
|
||||
@Composable
|
||||
private fun MonthHeader(label: String, metrics: MonthMetrics) {
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
@@ -192,6 +237,7 @@ private fun MonthHeader(label: String) {
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_left,
|
||||
contentDescription = context.getString(R.string.widget_prev_month),
|
||||
metrics = metrics,
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionRunCallback<ShiftMonthAction>(
|
||||
actionParametersOf(ShiftMonthAction.deltaKey to -1),
|
||||
@@ -204,7 +250,7 @@ private fun MonthHeader(label: String) {
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontSize = 15.sp,
|
||||
fontSize = metrics.headerTitle,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
@@ -220,11 +266,13 @@ private fun MonthHeader(label: String) {
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_today,
|
||||
contentDescription = context.getString(R.string.widget_today),
|
||||
metrics = metrics,
|
||||
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
|
||||
)
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_right,
|
||||
contentDescription = context.getString(R.string.widget_next_month),
|
||||
metrics = metrics,
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionRunCallback<ShiftMonthAction>(
|
||||
actionParametersOf(ShiftMonthAction.deltaKey to 1),
|
||||
@@ -235,32 +283,37 @@ private fun MonthHeader(label: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
||||
private fun HeaderIcon(
|
||||
resId: Int,
|
||||
contentDescription: String,
|
||||
metrics: MonthMetrics,
|
||||
onClick: GlanceModifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(40.dp).then(onClick),
|
||||
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(resId),
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
modifier = GlanceModifier.size(metrics.iconImage),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
private fun WeekdayHeader(weekStart: DayOfWeek, metrics: MonthMetrics) {
|
||||
GridRow {
|
||||
weekdayNarrowNames(weekStart).forEach { name ->
|
||||
Text(
|
||||
text = name,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
fontSize = metrics.weekday,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier.width(colW),
|
||||
modifier = GlanceModifier.width(metrics.columnWidth),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -283,29 +336,30 @@ private fun WeekRow(
|
||||
today: LocalDate,
|
||||
dark: Boolean,
|
||||
soften: Boolean,
|
||||
colW: Dp,
|
||||
metrics: MonthMetrics,
|
||||
modifier: GlanceModifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Day numbers.
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
GridRow {
|
||||
week.days.forEach { date ->
|
||||
DayNumber(
|
||||
date = date,
|
||||
isToday = date == today,
|
||||
inMonth = date.month == currentMonth,
|
||||
colW = colW,
|
||||
metrics = metrics,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
// One lane row per event row. A multi-day span is a single Box spanning
|
||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||
// its columns (columnWidth * n) so it's connected with no seam and
|
||||
// rounded ends.
|
||||
repeat(MAX_LANES) { lane ->
|
||||
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
||||
LaneRow(week = week, lane = lane, dark = dark, soften = soften, metrics = metrics)
|
||||
Spacer(GlanceModifier.height(1.dp))
|
||||
}
|
||||
OverflowRow(week = week, colW = colW)
|
||||
OverflowRow(week = week, metrics = metrics)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,19 +373,20 @@ private fun openDayAction(context: Context, date: LocalDate) =
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
||||
|
||||
@Composable
|
||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, metrics: MonthMetrics) {
|
||||
val context = LocalContext.current
|
||||
val diameter = metrics.dayNumberHeight
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.height(DAY_NUMBER_HEIGHT)
|
||||
.width(metrics.columnWidth)
|
||||
.height(diameter)
|
||||
.clickable(openDayAction(context, date)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(DAY_NUMBER_HEIGHT)
|
||||
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
|
||||
.size(diameter)
|
||||
.then(if (isToday) GlanceModifier.cornerRadius(diameter / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
@@ -342,7 +397,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
||||
inMonth -> GlanceTheme.colors.onSurface
|
||||
else -> GlanceTheme.colors.onSurfaceVariant
|
||||
},
|
||||
fontSize = 11.sp,
|
||||
fontSize = metrics.dayNumber,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
),
|
||||
)
|
||||
@@ -351,27 +406,39 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
|
||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, metrics: MonthMetrics) {
|
||||
val context = LocalContext.current
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
GridRow {
|
||||
var col = 0
|
||||
while (col < 7) {
|
||||
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
if (span != null) {
|
||||
val cols = span.endCol - col + 1
|
||||
SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
|
||||
SpanBar(
|
||||
event = span.event,
|
||||
dark = dark,
|
||||
soften = soften,
|
||||
width = metrics.columnWidth * cols,
|
||||
metrics = metrics,
|
||||
)
|
||||
col = span.endCol + 1
|
||||
} else {
|
||||
val timed = timedEventAt(week, lane, col, week.days[col])
|
||||
if (timed != null) {
|
||||
SpanBar(event = timed, dark = dark, soften = soften, width = colW)
|
||||
SpanBar(
|
||||
event = timed,
|
||||
dark = dark,
|
||||
soften = soften,
|
||||
width = metrics.columnWidth,
|
||||
metrics = metrics,
|
||||
)
|
||||
} else {
|
||||
// Empty lane cell: a tap opens that day, so blank space in a
|
||||
// day column is a day-open target just like the number is.
|
||||
Box(
|
||||
GlanceModifier
|
||||
.width(colW)
|
||||
.height(LANE_HEIGHT)
|
||||
.width(metrics.columnWidth)
|
||||
.height(metrics.laneHeight)
|
||||
.clickable(openDayAction(context, week.days[col])),
|
||||
) {}
|
||||
}
|
||||
@@ -383,13 +450,19 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean,
|
||||
|
||||
/** A single connected, rounded event bar [width] wide with its clipped title. */
|
||||
@Composable
|
||||
private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) {
|
||||
private fun SpanBar(
|
||||
event: EventInstance,
|
||||
dark: Boolean,
|
||||
soften: Boolean,
|
||||
width: Dp,
|
||||
metrics: MonthMetrics,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val fill = eventFill(event.color, dark, soften)
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(width)
|
||||
.height(LANE_HEIGHT)
|
||||
.height(metrics.laneHeight)
|
||||
.padding(horizontal = 1.dp)
|
||||
// Tap an event bar to open its detail, rooted in the month view.
|
||||
.clickable(
|
||||
@@ -414,7 +487,7 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width:
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
|
||||
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = metrics.eventTitle),
|
||||
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||
)
|
||||
}
|
||||
@@ -422,9 +495,9 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width:
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||
private fun OverflowRow(week: MonthWeek, metrics: MonthMetrics) {
|
||||
val context = LocalContext.current
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
GridRow {
|
||||
week.days.forEachIndexed { col, date ->
|
||||
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||
@@ -434,8 +507,8 @@ private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||
// it shows "+N" or is blank) opens that day, same as the app.
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.height(LANE_HEIGHT)
|
||||
.width(metrics.columnWidth)
|
||||
.height(metrics.laneHeight)
|
||||
.clickable(openDayAction(context, date)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
@@ -443,7 +516,7 @@ private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||
Text(
|
||||
text = "+$hidden",
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.overflow),
|
||||
modifier = GlanceModifier.padding(start = 3.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -354,6 +354,14 @@
|
||||
<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_widgets_header">Widgets</string>
|
||||
<string name="settings_widget_size">Widget size</string>
|
||||
<string name="settings_widget_size_hint">How large both home-screen widgets draw their text and, for the month widget, its day columns. Pick a bigger size to fill a bigger widget.</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>
|
||||
<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>
|
||||
@@ -474,8 +482,17 @@
|
||||
<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>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- minResizeWidth is the floor the smallest widget size step still fits its
|
||||
seven day columns in (#103); MonthScaleTest pins it against SMALL.gridWidth. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="180dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="4"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeWidth="250dp"
|
||||
android:minResizeHeight="150dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,7 +61,14 @@ 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)
|
||||
@@ -95,6 +102,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* What the reminder receiver may mark handled. The silenced-but-still-ahead case
|
||||
* is the one that matters: those rows are the only copy of the reminder (#75).
|
||||
*/
|
||||
class AlertHandlingTest {
|
||||
|
||||
private val now = 1_700_000_000_000L
|
||||
|
||||
private fun alert(
|
||||
id: Long,
|
||||
calendarId: Long = 1L,
|
||||
beginMillis: Long = now + 60_000L,
|
||||
endMillis: Long = now + 3_600_000L,
|
||||
) = ReminderAlert(
|
||||
alertId = id,
|
||||
eventId = id * 10,
|
||||
calendarId = calendarId,
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
title = "E $id",
|
||||
location = null,
|
||||
isAllDay = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `posted alerts are handled`() {
|
||||
val due = listOf(alert(1L), alert(2L))
|
||||
|
||||
assertThat(handledAlertIds(due, postedIds = setOf(1L, 2L), nowMillis = now))
|
||||
.containsExactly(1L, 2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a silenced alert whose event is still ahead stays unhandled`() {
|
||||
// Switching its calendar back on before the event has to bring it back,
|
||||
// and dueAlerts only ever returns STATE_SCHEDULED rows.
|
||||
val due = listOf(alert(1L), alert(2L))
|
||||
|
||||
assertThat(handledAlertIds(due, postedIds = setOf(1L), nowMillis = now))
|
||||
.containsExactly(1L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a silenced alert whose event is over is handled`() {
|
||||
// Nothing left to re-surface, so it must not linger as scheduled.
|
||||
val over = alert(2L, beginMillis = now - 7_200_000L, endMillis = now - 3_600_000L)
|
||||
val due = listOf(alert(1L), over)
|
||||
|
||||
assertThat(handledAlertIds(due, postedIds = setOf(1L), nowMillis = now))
|
||||
.containsExactly(1L, 2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown end time falls back to the begin time`() {
|
||||
val started = alert(1L, beginMillis = now - 1L, endMillis = 0L)
|
||||
val notYet = alert(2L, beginMillis = now + 1L, endMillis = 0L)
|
||||
|
||||
assertThat(handledAlertIds(listOf(started, notYet), postedIds = emptySet(), nowMillis = now))
|
||||
.containsExactly(1L)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetScale
|
||||
import de.jeanlucmakiola.calendula.widget.scaleFor
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AgendaScaleTest {
|
||||
@@ -13,23 +11,10 @@ class AgendaScaleTest {
|
||||
// --- the "default size is unchanged" regression guard ---------------------
|
||||
|
||||
@Test
|
||||
fun `every default placement width stays COMPACT`() {
|
||||
// Ties the guarantee to the provider XML's declared 3-cell default rather
|
||||
// than to one measured launcher: the whole band a default placement can
|
||||
// land in must bucket to COMPACT, or a freshly placed widget silently
|
||||
// changes appearance (#51). See AGENDA_DEFAULT_WIDTH_BAND.
|
||||
val band = AGENDA_DEFAULT_WIDTH_BAND
|
||||
var w = band.start
|
||||
while (w <= band.endInclusive) {
|
||||
assertThat(scaleFor(DpSize(w, 270.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||
w += 1.dp
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `COMPACT metrics equal the widget's original constants`() {
|
||||
// If this fails, a default-sized agenda widget no longer looks as it did.
|
||||
val m = metricsFor(WidgetScale.COMPACT)
|
||||
fun `SMALL metrics equal the widget's original constants`() {
|
||||
// SMALL is the default, so if this fails an agenda widget whose owner never
|
||||
// touched the size setting no longer looks as it did (#51).
|
||||
val m = metricsFor(WidgetSize.SMALL)
|
||||
assertThat(m.title).isEqualTo(16.sp)
|
||||
assertThat(m.dayHeader).isEqualTo(13.sp)
|
||||
assertThat(m.eventTitle).isEqualTo(14.sp)
|
||||
@@ -54,10 +39,10 @@ class AgendaScaleTest {
|
||||
// --- the ramp ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `sizes are non-decreasing across the tiers`() {
|
||||
val tiers = WidgetScale.entries.map(::metricsFor)
|
||||
fun `sizes are non-decreasing across the steps`() {
|
||||
val steps = WidgetSize.entries.map(::metricsFor)
|
||||
|
||||
tiers.zipWithNext { small, big ->
|
||||
steps.zipWithNext { small, big ->
|
||||
assertThat(big.title.value).isAtLeast(small.title.value)
|
||||
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
|
||||
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
|
||||
@@ -76,16 +61,16 @@ class AgendaScaleTest {
|
||||
fun `the event title keeps its lead over the time line`() {
|
||||
// The secondary line steps more slowly on purpose; if it ever caught up the
|
||||
// row would lose its hierarchy.
|
||||
WidgetScale.entries.map(::metricsFor).forEach { m ->
|
||||
WidgetSize.entries.map(::metricsFor).forEach { m ->
|
||||
assertThat(m.eventTitle.value).isGreaterThan(m.eventTime.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no tier grows type more than half again over the baseline`() {
|
||||
fun `no step grows type more than half again over the baseline`() {
|
||||
// Guards against a future edit turning "more readable" into "absurd".
|
||||
val base = metricsFor(WidgetScale.COMPACT)
|
||||
val top = metricsFor(WidgetScale.XLARGE)
|
||||
val base = metricsFor(WidgetSize.SMALL)
|
||||
val top = metricsFor(WidgetSize.EXTRA_LARGE)
|
||||
assertThat(top.title.value / base.title.value).isLessThan(1.5f)
|
||||
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
|
||||
}
|
||||
@@ -96,7 +81,7 @@ class AgendaScaleTest {
|
||||
fun `the stripe tracks the system font scale`() {
|
||||
// The stripe is Dp, the text beside it is sp: without this the two diverge
|
||||
// at large accessibility font settings and the stripe under-runs the row.
|
||||
val m = metricsFor(WidgetScale.COMPACT)
|
||||
val m = metricsFor(WidgetSize.SMALL)
|
||||
assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp)
|
||||
assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f)
|
||||
assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
|
||||
@@ -104,7 +89,7 @@ class AgendaScaleTest {
|
||||
|
||||
@Test
|
||||
fun `scaling for the default font scale changes nothing`() {
|
||||
val m = metricsFor(WidgetScale.LARGE)
|
||||
val m = metricsFor(WidgetSize.LARGE)
|
||||
assertThat(m.scaledForFont(1f)).isSameInstanceAs(m)
|
||||
}
|
||||
|
||||
@@ -112,7 +97,7 @@ class AgendaScaleTest {
|
||||
fun `font scaling leaves the sp sizes alone`() {
|
||||
// Glance already applies the font scale to sp; scaling them here too would
|
||||
// double-count it.
|
||||
val m = metricsFor(WidgetScale.REGULAR)
|
||||
val m = metricsFor(WidgetSize.MEDIUM)
|
||||
val scaled = m.scaledForFont(1.3f)
|
||||
assertThat(scaled.title).isEqualTo(m.title)
|
||||
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MonthScaleTest {
|
||||
|
||||
/**
|
||||
* `appwidget_info_month.xml`'s `minResizeWidth` — the narrowest the host will
|
||||
* let a month widget be resized to. Mirrored here because the smallest size
|
||||
* step has to fit inside it; the two move together or the grid clips again.
|
||||
*/
|
||||
private val MIN_RESIZE_WIDTH = 250.dp
|
||||
|
||||
// --- the #103 guarantee: seven columns always fit -------------------------
|
||||
|
||||
@Test
|
||||
fun `the smallest step fits the narrowest placement the provider allows`() {
|
||||
// This is the whole fix. The grid is laid out at a fixed width, so the
|
||||
// default step must fit the narrowest widget a user can drag it down to —
|
||||
// otherwise columns fall off the edge exactly as they did when the width
|
||||
// came from the launcher (#103). The slack covers host chrome, which is
|
||||
// not part of the declared size.
|
||||
val small = monthMetricsFor(WidgetSize.SMALL).gridWidth
|
||||
assertThat(small.value).isLessThan(MIN_RESIZE_WIDTH.value)
|
||||
assertThat((MIN_RESIZE_WIDTH - small).value).isAtLeast(24f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every step lays out exactly seven columns plus its padding`() {
|
||||
WidgetSize.entries.map(::monthMetricsFor).forEach { m ->
|
||||
assertThat(m.gridWidth).isEqualTo(m.columnWidth * 7 + m.gridPadding * 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the largest step still fits a full-width phone widget`() {
|
||||
// EXTRA_LARGE is opt-in, but it should be reachable on a normal handset
|
||||
// rather than being a tablet-only trap.
|
||||
assertThat(monthMetricsFor(WidgetSize.EXTRA_LARGE).gridWidth.value).isAtMost(400f)
|
||||
}
|
||||
|
||||
// --- the ramp ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `sizes are non-decreasing across the steps`() {
|
||||
monthMetricsFor(WidgetSize.SMALL)
|
||||
WidgetSize.entries.map(::monthMetricsFor).zipWithNext { small, big ->
|
||||
assertThat(big.columnWidth.value).isAtLeast(small.columnWidth.value)
|
||||
assertThat(big.laneHeight.value).isAtLeast(small.laneHeight.value)
|
||||
assertThat(big.dayNumberHeight.value).isAtLeast(small.dayNumberHeight.value)
|
||||
assertThat(big.headerTitle.value).isAtLeast(small.headerTitle.value)
|
||||
assertThat(big.weekday.value).isAtLeast(small.weekday.value)
|
||||
assertThat(big.dayNumber.value).isAtLeast(small.dayNumber.value)
|
||||
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
|
||||
assertThat(big.overflow.value).isAtLeast(small.overflow.value)
|
||||
assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
|
||||
assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the today circle always fits inside its column`() {
|
||||
// The day number sits in a circle dayNumberHeight across, centred in a
|
||||
// column. If it ever outgrew the column it would collide with its
|
||||
// neighbours.
|
||||
WidgetSize.entries.map(::monthMetricsFor).forEach { m ->
|
||||
assertThat(m.dayNumberHeight.value).isLessThan(m.columnWidth.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SMALL keeps the widget's original row metrics`() {
|
||||
// SMALL is the default, so a month widget whose owner never touched the
|
||||
// setting must keep the metrics it shipped with.
|
||||
val m = monthMetricsFor(WidgetSize.SMALL)
|
||||
assertThat(m.laneHeight).isEqualTo(14.dp)
|
||||
assertThat(m.dayNumberHeight).isEqualTo(18.dp)
|
||||
assertThat(m.headerTitle.value).isEqualTo(15f)
|
||||
assertThat(m.weekday.value).isEqualTo(11f)
|
||||
assertThat(m.dayNumber.value).isEqualTo(11f)
|
||||
assertThat(m.eventTitle.value).isEqualTo(9f)
|
||||
assertThat(m.overflow.value).isEqualTo(9f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three lanes plus the day number stay inside a compact week row`() {
|
||||
// A week row gets roughly a sixth of the grid's height. If the metrics
|
||||
// outgrew that, lanes would be clipped rather than merely tight.
|
||||
val m = monthMetricsFor(WidgetSize.SMALL)
|
||||
val weekRow = m.dayNumberHeight + 2.dp + (m.laneHeight + 1.dp) * MAX_LANES + m.laneHeight
|
||||
assertThat(weekRow.value).isLessThan(90f)
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,27 @@ sequenceDiagram
|
||||
Posting happens before marking: a crash in between re-posts silently (same
|
||||
tag + `setOnlyAlertOnce`) rather than losing a reminder. Swiped
|
||||
notifications never return because `FIRED` rows are never re-queried.
|
||||
|
||||
**One visibility model.** The provider only schedules alarms for calendars with
|
||||
`Calendars.VISIBLE = 1`, so that flag *is* the app's on/off switch: Settings →
|
||||
Calendars writes it (one calendar per update — `CalendarProvider2` skips its own
|
||||
`checkNextAlarm()` reschedule for any selection that isn't `_id=`), and every
|
||||
display predicate reads `CalendarSource.isVisibleInSystem`. The reconciliation
|
||||
runs one way only: a calendar the user switched off in Calendula is switched off
|
||||
in the provider, never the reverse — un-hiding one would reach into every other
|
||||
calendar app on the device — and a one-time notice explains the calendars that
|
||||
were already off. `CalendarPrefs.pendingDisabledCalendarIds` holds the switch-offs
|
||||
the app has not been allowed to write yet (read-only permission grant, or a
|
||||
pre-permission launch); `CalendarVisibilityReconciler` drains it entry by entry,
|
||||
and until it does, the repository and `ReminderNotifier.post` honour it. That
|
||||
gate also covers a snooze re-shown from our own alarm after its calendar was
|
||||
switched off. Silencing is not handling: an alert the gate drops keeps its
|
||||
`SCHEDULED` state while its event is still ahead (`handledAlertIds`), so
|
||||
switching the calendar back on re-posts it (`ReminderRecovery`) instead of
|
||||
losing it — the provider's own table is the stash. The drawer's filter sheet
|
||||
(`CalendarPrefs.hiddenCalendarIds`) is a separate in-app declutter that never
|
||||
touches reminders.
|
||||
|
||||
Deliberately absent until real devices prove it necessary: own alarm
|
||||
scheduling, `BOOT_COMPLETED`, snooze/dismiss actions, battery-exemption
|
||||
prompts.
|
||||
|
||||
Reference in New Issue
Block a user