Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
883e8fd101 | ||
|
|
11d1147931 | ||
|
|
836817cd12 |
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Edits and deletions show up straight away on every phone.** On some phones, Samsung among them, an edited, moved or deleted event stayed where it was until the app was restarted, so a second edit could act on an event that had already changed. Calendula now refreshes its views itself after every change, and again when you come back to the app ([#364]).
|
||||||
|
- **The home-screen widgets do less work per update.** The month widget loads the months around the one it shows instead of more than two years of events, and the agenda widget only its chosen range. This should help widgets that stayed on "loading" on slower phones ([#363]).
|
||||||
|
|
||||||
## [2.22.3] — 2026-10-02
|
## [2.22.3] — 2026-10-02
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -1835,3 +1839,5 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#357]: https://codeberg.org/jlmakiola/calendula/issues/357
|
[#357]: https://codeberg.org/jlmakiola/calendula/issues/357
|
||||||
[#359]: https://codeberg.org/jlmakiola/calendula/issues/359
|
[#359]: https://codeberg.org/jlmakiola/calendula/issues/359
|
||||||
[#361]: https://codeberg.org/jlmakiola/calendula/issues/361
|
[#361]: https://codeberg.org/jlmakiola/calendula/issues/361
|
||||||
|
[#363]: https://codeberg.org/jlmakiola/calendula/issues/363
|
||||||
|
[#364]: https://codeberg.org/jlmakiola/calendula/issues/364
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ android {
|
|||||||
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
|
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
|
||||||
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
|
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
|
||||||
// See docs/RELEASING.md.
|
// See docs/RELEASING.md.
|
||||||
versionCode = 22203
|
versionCode = 2220401
|
||||||
versionName = "2.22.3"
|
versionName = "2.22.4-beta.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.core.net.toUri
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
@@ -56,6 +57,7 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** A prefilled create form from an external launch, with the source it came from. */
|
/** A prefilled create form from an external launch, with the source it came from. */
|
||||||
private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
||||||
@@ -63,6 +65,8 @@ private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
|||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
@Inject lateinit var calendarRepository: CalendarRepository
|
||||||
|
|
||||||
// Which of light/dark the system bars are drawn for. The styles installed
|
// Which of light/dark the system bars are drawn for. The styles installed
|
||||||
// in onCreate read this field live, so androidx's config-change replay
|
// in onCreate read this field live, so androidx's config-change replay
|
||||||
// picks up the in-app override instead of the night resource qualifier.
|
// picks up the in-app override instead of the night resource qualifier.
|
||||||
@@ -245,6 +249,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
// Changes made while we were away may never have notified us (#364).
|
||||||
|
calendarRepository.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||||
|
|||||||
@@ -610,8 +610,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// cancelled exception for the one instance (#47). A NULL status is a
|
// cancelled exception for the one instance (#47). A NULL status is a
|
||||||
// normal, un-cancelled event, so it must survive the filter — a bare
|
// normal, un-cancelled event, so it must survive the filter — a bare
|
||||||
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
||||||
"${CalendarContract.Instances.STATUS} IS NULL OR " +
|
// DELETED = 0: a provider that soft-deletes may keep the row's
|
||||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
|
// instances until a purge (#364).
|
||||||
|
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||||
|
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0",
|
||||||
null,
|
null,
|
||||||
CalendarContract.Instances.BEGIN + " ASC",
|
CalendarContract.Instances.BEGIN + " ASC",
|
||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import kotlin.time.Instant
|
|||||||
interface CalendarRepository {
|
interface CalendarRepository {
|
||||||
fun calendars(): Flow<List<CalendarSource>>
|
fun calendars(): Flow<List<CalendarSource>>
|
||||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||||
|
|
||||||
|
/** Re-query every open [calendars] / [instances] flow. */
|
||||||
|
fun refresh()
|
||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+34
-17
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -51,21 +52,37 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
private suspend fun allDayReminderTimeMinutes(): Int =
|
private suspend fun allDayReminderTimeMinutes(): Int =
|
||||||
settingsPrefs.allDayReminderTimeMinutes.first()
|
settingsPrefs.allDayReminderTimeMinutes.first()
|
||||||
|
|
||||||
|
// DROP_OLDEST: tryEmit never fails, so a tick that lands while a subscriber
|
||||||
|
// still holds the previous one is never lost (#364).
|
||||||
private val ticks = MutableSharedFlow<Unit>(
|
private val ticks = MutableSharedFlow<Unit>(
|
||||||
replay = 0,
|
replay = 0,
|
||||||
extraBufferCapacity = 1,
|
extraBufferCapacity = 1,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bumped on every provider notification, so one tick's calendar read can be
|
* Bumped on every [refresh], so one tick's calendar read can be
|
||||||
* shared by everything that needs it (see [calendarsSnapshot]).
|
* shared by everything that needs it (see [calendarsSnapshot]).
|
||||||
*/
|
*/
|
||||||
private val generation = AtomicLong(0L)
|
private val generation = AtomicLong(0L)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
dataSource.registerChangeListener {
|
dataSource.registerChangeListener(::refresh)
|
||||||
generation.incrementAndGet()
|
}
|
||||||
ticks.tryEmit(Unit)
|
|
||||||
|
// Writes re-query on their own too: some providers never notify the
|
||||||
|
// observer, which left every view stale until a restart (#364).
|
||||||
|
override fun refresh() {
|
||||||
|
generation.incrementAndGet()
|
||||||
|
ticks.tryEmit(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a provider write on [io], then re-query whether or not it threw. */
|
||||||
|
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +198,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
color: Int,
|
color: Int,
|
||||||
description: String?,
|
description: String?,
|
||||||
): Long = withContext(io) {
|
): Long = write {
|
||||||
dataSource.createLocalCalendar(displayName, color, description)
|
dataSource.createLocalCalendar(displayName, color, description)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,13 +207,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
color: Int,
|
color: Int,
|
||||||
description: String?,
|
description: String?,
|
||||||
) = withContext(io) { dataSource.updateCalendar(id, displayName, color, description) }
|
) = write { dataSource.updateCalendar(id, displayName, color, description) }
|
||||||
|
|
||||||
override suspend fun deleteCalendar(id: Long) =
|
override suspend fun deleteCalendar(id: Long) =
|
||||||
withContext(io) { dataSource.deleteCalendar(id) }
|
write { dataSource.deleteCalendar(id) }
|
||||||
|
|
||||||
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
||||||
withContext(io) {
|
write {
|
||||||
if (dataSource.canWriteCalendars()) {
|
if (dataSource.canWriteCalendars()) {
|
||||||
ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
||||||
// Nothing of ours is left waiting for the provider once the
|
// Nothing of ours is left waiting for the provider once the
|
||||||
@@ -217,7 +234,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun importEvents(
|
override suspend fun importEvents(
|
||||||
targetCalendarId: Long,
|
targetCalendarId: Long,
|
||||||
events: List<ParsedIcsEvent>,
|
events: List<ParsedIcsEvent>,
|
||||||
): IcsImportSummary = withContext(io) {
|
): IcsImportSummary = write {
|
||||||
val existing = dataSource.existingUids(targetCalendarId)
|
val existing = dataSource.existingUids(targetCalendarId)
|
||||||
// Both are per-calendar, not per-event: looking them up once keeps a
|
// Both are per-calendar, not per-event: looking them up once keeps a
|
||||||
// thousand-event restore to two extra queries. The palette is the
|
// thousand-event restore to two extra queries. The palette is the
|
||||||
@@ -268,7 +285,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
override suspend fun createEvent(form: EventForm): Long = write {
|
||||||
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,11 +293,11 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
eventId: Long,
|
eventId: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
) = withContext(io) {
|
) = write {
|
||||||
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
override suspend fun deleteEvent(eventId: Long) = write {
|
||||||
dataSource.deleteEvent(eventId)
|
dataSource.deleteEvent(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +306,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
targetCalendarId: Long,
|
targetCalendarId: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = write {
|
||||||
dataSource.moveEvent(
|
dataSource.moveEvent(
|
||||||
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
||||||
)
|
)
|
||||||
@@ -300,7 +317,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = write {
|
||||||
dataSource.updateOccurrence(
|
dataSource.updateOccurrence(
|
||||||
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
|
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
|
||||||
)
|
)
|
||||||
@@ -311,20 +328,20 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = write {
|
||||||
dataSource.updateEventFromOccurrence(
|
dataSource.updateEventFromOccurrence(
|
||||||
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
|
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
|
||||||
dataSource.deleteOccurrence(eventId, beginMillis)
|
dataSource.deleteOccurrence(eventId, beginMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteEventFromOccurrence(
|
override suspend fun deleteEventFromOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
) = withContext(io) {
|
) = write {
|
||||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
@@ -28,11 +29,12 @@ import kotlin.time.Clock
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far either side of today the month widget pre-loads. The displayed month
|
* Months either side of the displayed one the month widget loads, so a few arrow
|
||||||
* is chosen reactively in the composition, so one wide read covers ~13 months of
|
* taps page without a re-query. Kept small: every load expands recurrences across
|
||||||
* prev/next navigation without re-querying on every arrow tap.
|
* the whole window, and on slow devices a wide one held the widget on "loading"
|
||||||
|
* (#363).
|
||||||
*/
|
*/
|
||||||
private const val MONTH_WIDGET_RANGE_DAYS = 400
|
private const val MONTH_WIDGET_PAD_MONTHS = 3
|
||||||
|
|
||||||
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
|
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
@@ -76,6 +78,8 @@ sealed interface AgendaWidgetData {
|
|||||||
val savedShowToday: Boolean,
|
val savedShowToday: Boolean,
|
||||||
/** Saved widget size step (#103) — the fallback before Glance state is set. */
|
/** Saved widget size step (#103) — the fallback before Glance state is set. */
|
||||||
val savedWidgetSize: WidgetSize,
|
val savedWidgetSize: WidgetSize,
|
||||||
|
/** Last day [days] covers; a wider range picked later needs a reload. */
|
||||||
|
val loadedThrough: LocalDate,
|
||||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||||
val now: Instant,
|
val now: Instant,
|
||||||
) : AgendaWidgetData
|
) : AgendaWidgetData
|
||||||
@@ -94,14 +98,15 @@ sealed interface MonthWidgetSource {
|
|||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val weekStart: DayOfWeek,
|
val weekStart: DayOfWeek,
|
||||||
val instances: List<EventInstance>,
|
val instances: List<EventInstance>,
|
||||||
|
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
|
||||||
|
val months: IntRange,
|
||||||
) : MonthWidgetSource
|
) : MonthWidgetSource
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process-lived cache of the wide month window. Month navigation re-runs
|
* Process-lived cache of the month window. Month navigation can re-run
|
||||||
* `provideGlance` (via `updateAll`), and re-querying ~13 months of instances on
|
* `provideGlance`, and re-querying on every arrow tap is what made paging feel
|
||||||
* every arrow tap is what made paging feel sluggish — so we load once and reuse
|
* sluggish — so we load once and reuse the same snapshot for every nearby month. Invalidated by
|
||||||
* the same snapshot for every nearby month. Invalidated by
|
|
||||||
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
|
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
|
||||||
* receiver), and automatically when the day rolls over (the `today` guard).
|
* receiver), and automatically when the day rolls over (the `today` guard).
|
||||||
*/
|
*/
|
||||||
@@ -118,8 +123,11 @@ internal fun invalidateMonthWidgetCache() {
|
|||||||
* One-shot read of the upcoming agenda for the widget. Reuses the app's
|
* One-shot read of the upcoming agenda for the widget. Reuses the app's
|
||||||
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
|
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
|
||||||
* [first]-emitted snapshot already has hidden calendars filtered out.
|
* [first]-emitted snapshot already has hidden calendars filtered out.
|
||||||
|
*
|
||||||
|
* Loads [range] (the widget's own, or the saved pref when null) rather than the
|
||||||
|
* widest selectable window (#363).
|
||||||
*/
|
*/
|
||||||
internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
internal suspend fun Context.loadAgendaWidgetData(range: AgendaRange? = null): AgendaWidgetData {
|
||||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val anchor = today(zone)
|
val anchor = today(zone)
|
||||||
@@ -129,11 +137,11 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||||
val showToday = prefs.agendaShowToday.first()
|
val showToday = prefs.agendaShowToday.first()
|
||||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
// Load the widest selectable window once; the displayed range is sliced in
|
// The displayed range is still sliced in the composition from Glance state;
|
||||||
// the composition from Glance state, so changing the range is a plain
|
// a wider one picked later reloads there (see loadedThrough).
|
||||||
// recomposition and never depends on the widget session restarting.
|
val dayCount = (range ?: savedRange).dayCount(anchor, weekStart)
|
||||||
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
val window = agendaRange(anchor, dayCount - 1, zone)
|
||||||
val windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY)
|
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||||
val instances = ep.calendarRepository().instances(window).first()
|
val instances = ep.calendarRepository().instances(window).first()
|
||||||
val is24Hour = prefs.timeFormat.first()
|
val is24Hour = prefs.timeFormat.first()
|
||||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||||
@@ -148,22 +156,39 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
savedPastDisplay = savedPastDisplay,
|
savedPastDisplay = savedPastDisplay,
|
||||||
savedShowToday = showToday,
|
savedShowToday = showToday,
|
||||||
savedWidgetSize = prefs.widgetSize.first(),
|
savedWidgetSize = prefs.widgetSize.first(),
|
||||||
|
loadedThrough = windowEnd,
|
||||||
now = Clock.System.now(),
|
now = Clock.System.now(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal
|
||||||
internal suspend fun Context.loadMonthWidgetSource(): MonthWidgetSource {
|
|
||||||
|
private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1)
|
||||||
|
|
||||||
|
/** Read backing the month widget's grid for [centerIndex] and the months around it. */
|
||||||
|
internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): MonthWidgetSource {
|
||||||
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
|
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val anchor = today(zone)
|
val anchor = today(zone)
|
||||||
|
val center = centerIndex ?: monthIndexOf(anchor)
|
||||||
// Reuse the cached window unless the day changed (then it's stale for "today").
|
// Reuse the cached window unless the day changed (then it's stale for "today").
|
||||||
MonthWidgetCache.data?.let { if (it.today == anchor) return it }
|
MonthWidgetCache.data?.let { if (it.today == anchor && center in it.months) return it }
|
||||||
val ep = widgetEntryPoint()
|
val ep = widgetEntryPoint()
|
||||||
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
|
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
val from = anchor.minus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS)
|
||||||
val to = anchor.plus(MONTH_WIDGET_RANGE_DAYS, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
// Padded by the grid's spill into the neighbouring months.
|
||||||
|
val from = firstDayOfMonthIndex(months.first)
|
||||||
|
.minus(1, DateTimeUnit.WEEK)
|
||||||
|
.atStartOfDayIn(zone)
|
||||||
|
val to = firstDayOfMonthIndex(months.last + 1)
|
||||||
|
.plus(2, DateTimeUnit.WEEK)
|
||||||
|
.atTime(23, 59, 59)
|
||||||
|
.toInstant(zone)
|
||||||
val instances = ep.calendarRepository().instances(from..to).first()
|
val instances = ep.calendarRepository().instances(from..to).first()
|
||||||
return MonthWidgetSource.Ready(today = anchor, weekStart = weekStart, instances = instances)
|
return MonthWidgetSource.Ready(
|
||||||
.also { MonthWidgetCache.data = it }
|
today = anchor,
|
||||||
|
weekStart = weekStart,
|
||||||
|
instances = instances,
|
||||||
|
months = months,
|
||||||
|
).also { MonthWidgetCache.data = it }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
@@ -28,6 +34,7 @@ import androidx.glance.appwidget.lazy.items
|
|||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
import androidx.glance.currentState
|
import androidx.glance.currentState
|
||||||
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
import androidx.glance.layout.Alignment
|
import androidx.glance.layout.Alignment
|
||||||
import androidx.glance.layout.Box
|
import androidx.glance.layout.Box
|
||||||
@@ -125,7 +132,8 @@ class AgendaWidget : GlanceAppWidget() {
|
|||||||
override val sizeMode = SizeMode.Single
|
override val sizeMode = SizeMode.Single
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val data = context.loadAgendaWidgetData()
|
val stored = getAppWidgetState<Preferences>(context, id)[AGENDA_RANGE_KEY]
|
||||||
|
val data = context.loadAgendaWidgetData(stored?.let { parseAgendaRange(it, AgendaRange.Month) })
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val dark = (context.resources.configuration.uiMode and
|
||||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
provideContent {
|
provideContent {
|
||||||
@@ -190,71 +198,95 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||||
is AgendaWidgetData.Ready -> {
|
is AgendaWidgetData.Ready -> {
|
||||||
// Range read reactively from per-instance Glance state (falls back
|
// Range read reactively from per-instance Glance state (falls back
|
||||||
// to the saved pref for a freshly placed widget), then the wide
|
// to the saved pref for a freshly placed widget), then the loaded
|
||||||
// pre-loaded window is sliced to it — pure recomposition.
|
// window is sliced to it — pure recomposition.
|
||||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||||
val pastDisplay =
|
val context = androidx.glance.LocalContext.current
|
||||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
var loaded by remember(data) { mutableStateOf<AgendaWidgetData.Ready>(data) }
|
||||||
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
|
// A range wider than what was loaded reloads in a live session,
|
||||||
val rangeEnd = data.today.plus(
|
// where provideGlance doesn't run again (#363).
|
||||||
range.dayCount(data.today, data.weekStart) - 1,
|
LaunchedEffect(range) {
|
||||||
DateTimeUnit.DAY,
|
val end = loaded.today.plus(
|
||||||
)
|
range.dayCount(loaded.today, loaded.weekStart) - 1,
|
||||||
// Slice to the chosen range, then drop finished events (and any day
|
DateTimeUnit.DAY,
|
||||||
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
)
|
||||||
// Finally re-anchor today (as an empty placeholder day) when the
|
if (end > loaded.loadedThrough) {
|
||||||
// "always show today" pref is on and today has no events left (#35).
|
(context.loadAgendaWidgetData(range) as? AgendaWidgetData.Ready)
|
||||||
val visibleDays = anchorTodayIfMissing(
|
?.let { loaded = it }
|
||||||
days = data.days
|
}
|
||||||
.filter { it.date <= rangeEnd }
|
}
|
||||||
.let { days ->
|
AgendaRows(data = loaded, range = range, dark = dark, metrics = metrics)
|
||||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
}
|
||||||
days.mapNotNull { day ->
|
}
|
||||||
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
}
|
||||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
}
|
||||||
}
|
|
||||||
} else {
|
@Composable
|
||||||
days
|
private fun AgendaRows(
|
||||||
}
|
data: AgendaWidgetData.Ready,
|
||||||
},
|
range: AgendaRange,
|
||||||
today = data.today,
|
dark: Boolean,
|
||||||
enabled = showToday,
|
metrics: AgendaMetrics,
|
||||||
)
|
) {
|
||||||
if (visibleDays.isEmpty()) {
|
val pastDisplay =
|
||||||
WidgetMessage(R.string.agenda_empty_title, metrics)
|
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||||
|
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
|
||||||
|
val rangeEnd = data.today.plus(
|
||||||
|
range.dayCount(data.today, data.weekStart) - 1,
|
||||||
|
DateTimeUnit.DAY,
|
||||||
|
)
|
||||||
|
// Slice to the chosen range, then drop finished events (and any day
|
||||||
|
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
||||||
|
// Finally re-anchor today (as an empty placeholder day) when the
|
||||||
|
// "always show today" pref is on and today has no events left (#35).
|
||||||
|
val visibleDays = anchorTodayIfMissing(
|
||||||
|
days = data.days
|
||||||
|
.filter { it.date <= rangeEnd }
|
||||||
|
.let { days ->
|
||||||
|
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
|
days.mapNotNull { day ->
|
||||||
|
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
||||||
|
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val rows = buildList {
|
days
|
||||||
visibleDays.forEach { day ->
|
}
|
||||||
add(AgendaRow.Header(day.date, data.today))
|
},
|
||||||
if (day.events.isEmpty()) {
|
today = data.today,
|
||||||
add(AgendaRow.Placeholder(day.date))
|
enabled = showToday,
|
||||||
} else {
|
)
|
||||||
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
if (visibleDays.isEmpty()) {
|
||||||
}
|
WidgetMessage(R.string.agenda_empty_title, metrics)
|
||||||
}
|
} else {
|
||||||
}
|
val rows = buildList {
|
||||||
// Bound the payload, then drop a day header the cut left
|
visibleDays.forEach { day ->
|
||||||
// stranded with nothing under it.
|
add(AgendaRow.Header(day.date, data.today))
|
||||||
.take(MAX_AGENDA_ROWS)
|
if (day.events.isEmpty()) {
|
||||||
.dropLastWhile { it is AgendaRow.Header }
|
add(AgendaRow.Placeholder(day.date))
|
||||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
} else {
|
||||||
items(rows.size) { index ->
|
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
||||||
when (val row = rows[index]) {
|
}
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
}
|
||||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
}
|
||||||
is AgendaRow.Event -> EventRow(
|
// Bound the payload, then drop a day header the cut left
|
||||||
event = row.event,
|
// stranded with nothing under it.
|
||||||
day = row.date,
|
.take(MAX_AGENDA_ROWS)
|
||||||
dark = dark,
|
.dropLastWhile { it is AgendaRow.Header }
|
||||||
soften = data.soften,
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
is24Hour = data.is24Hour,
|
items(rows.size) { index ->
|
||||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
when (val row = rows[index]) {
|
||||||
row.event.hasEnded(data.now),
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
||||||
metrics = metrics,
|
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
||||||
)
|
is AgendaRow.Event -> EventRow(
|
||||||
}
|
event = row.event,
|
||||||
}
|
day = row.date,
|
||||||
}
|
dark = dark,
|
||||||
|
soften = data.soften,
|
||||||
|
is24Hour = data.is24Hour,
|
||||||
|
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||||
|
row.event.hasEnded(data.now),
|
||||||
|
metrics = metrics,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
@@ -26,6 +32,7 @@ import androidx.glance.appwidget.action.actionRunCallback
|
|||||||
import androidx.glance.appwidget.action.actionStartActivity
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
import androidx.glance.appwidget.cornerRadius
|
import androidx.glance.appwidget.cornerRadius
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
import androidx.glance.currentState
|
import androidx.glance.currentState
|
||||||
@@ -59,6 +66,7 @@ import de.jeanlucmakiola.calendula.ui.common.eventInk
|
|||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||||
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
import de.jeanlucmakiola.calendula.widget.today
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||||
@@ -81,10 +89,7 @@ private val LANE_HEIGHT = 14.dp
|
|||||||
private val DAY_NUMBER_HEIGHT = 18.dp
|
private val DAY_NUMBER_HEIGHT = 18.dp
|
||||||
private val GRID_HPADDING = 8.dp
|
private val GRID_HPADDING = 8.dp
|
||||||
|
|
||||||
private fun currentMonthIndex(zone: TimeZone): Int {
|
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
|
||||||
val t = today(zone)
|
|
||||||
return t.year * 12 + t.month.ordinal
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun yearMonthOf(index: Int): YearMonth =
|
private fun yearMonthOf(index: Int): YearMonth =
|
||||||
YearMonth(index / 12, Month(index % 12 + 1))
|
YearMonth(index / 12, Month(index % 12 + 1))
|
||||||
@@ -123,7 +128,8 @@ class MonthWidget : GlanceAppWidget() {
|
|||||||
override val sizeMode = SizeMode.Exact
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val source = context.loadMonthWidgetSource()
|
val shown = getAppWidgetState<Preferences>(context, id)[MONTH_INDEX_KEY]
|
||||||
|
val source = context.loadMonthWidgetSource(shown)
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val dark = (context.resources.configuration.uiMode and
|
||||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
// Read fresh (not through the cached source) so toggling the softener
|
// Read fresh (not through the cached source) so toggling the softener
|
||||||
@@ -198,18 +204,28 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
|
|||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
||||||
val ym = yearMonthOf(index)
|
val ym = yearMonthOf(index)
|
||||||
|
// Paging past the loaded months reloads around the new one in a
|
||||||
|
// live session, where provideGlance doesn't run again.
|
||||||
|
val context = LocalContext.current
|
||||||
|
var loaded by remember(source) { mutableStateOf<MonthWidgetSource.Ready>(source) }
|
||||||
|
LaunchedEffect(index) {
|
||||||
|
if (index !in loaded.months) {
|
||||||
|
(context.loadMonthWidgetSource(index) as? MonthWidgetSource.Ready)
|
||||||
|
?.let { loaded = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
// Column width from the live widget size, minus our H padding.
|
// Column width from the live widget size, minus our H padding.
|
||||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
val weeks = layoutMonthWeeks(ym, loaded.weekStart, loaded.instances, zone)
|
||||||
|
|
||||||
MonthHeader(label = monthLabel(ym, source.today.year))
|
MonthHeader(label = monthLabel(ym, loaded.today.year))
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
WeekdayHeader(weekStart = loaded.weekStart, colW = colW)
|
||||||
weeks.forEach { week ->
|
weeks.forEach { week ->
|
||||||
WeekRow(
|
WeekRow(
|
||||||
week = week,
|
week = week,
|
||||||
currentMonth = ym.month,
|
currentMonth = ym.month,
|
||||||
today = source.today,
|
today = loaded.today,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
soften = soften,
|
soften = soften,
|
||||||
colW = colW,
|
colW = colW,
|
||||||
|
|||||||
+39
@@ -95,6 +95,45 @@ class CalendarRepositoryImplTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instances re-emit after a write the provider never notified about`(@TempDir tempDir: Path) = runTest {
|
||||||
|
var current = listOf(makeEvent(10L))
|
||||||
|
val fake = FakeCalendarDataSource().apply { instancesResult = { _, _ -> current } }
|
||||||
|
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.eventId }).containsExactly(10L)
|
||||||
|
|
||||||
|
current = emptyList()
|
||||||
|
repo.deleteEvent(10L)
|
||||||
|
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instances re-emit after a write that failed`(@TempDir tempDir: Path) = runTest {
|
||||||
|
var current = listOf(makeEvent(10L))
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
instancesResult = { _, _ -> current }
|
||||||
|
writeError = WriteFailedException("boom")
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||||
|
repo.instances(range).test {
|
||||||
|
awaitItem()
|
||||||
|
|
||||||
|
current = emptyList()
|
||||||
|
assertThrows<WriteFailedException> { repo.deleteOccurrence(10L, 1_000L) }
|
||||||
|
|
||||||
|
assertThat(awaitItem()).isEmpty()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `instances forwards epoch-millis bounds to data source`(@TempDir tempDir: Path) = runTest {
|
fun `instances forwards epoch-millis bounds to data source`(@TempDir tempDir: Path) = runTest {
|
||||||
var observedBegin: Long? = null
|
var observedBegin: Long? = null
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ the package list (recurring writes, save conflicts, reminder delivery).
|
|||||||
straight back to it. Sync is DAVx5's / Google's / the system's job.
|
straight back to it. Sync is DAVx5's / Google's / the system's job.
|
||||||
2. **Observer-driven UI.** A `ContentObserver` on the provider triggers
|
2. **Observer-driven UI.** A `ContentObserver` on the provider triggers
|
||||||
re-queries; every screen recomposes from fresh provider state. After a
|
re-queries; every screen recomposes from fresh provider state. After a
|
||||||
write, nothing is patched by hand — the provider notifies, the views
|
write, nothing is patched by hand — the views refresh from the provider.
|
||||||
refresh. This also covers external changes (sync) for free.
|
This also covers external changes (sync) for free. Some providers don't
|
||||||
|
reliably notify (#364), so the repository also re-queries after each of
|
||||||
|
its own writes, and `MainActivity` does on every return to the foreground.
|
||||||
3. **JVM-first testing.** Everything between the UI and the
|
3. **JVM-first testing.** Everything between the UI and the
|
||||||
`ContentResolver` is shaped so it runs as a plain JUnit 5 test: pure
|
`ContentResolver` is shaped so it runs as a plain JUnit 5 test: pure
|
||||||
domain logic, cursor-free mappers, a `FakeCalendarDataSource` for
|
domain logic, cursor-free mappers, a `FakeCalendarDataSource` for
|
||||||
|
|||||||
Reference in New Issue
Block a user