Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d39ceb9ee0 | ||
|
|
081675906b | ||
|
|
661300728a | ||
|
|
18b9f19a61 | ||
|
|
6b108fb585 | ||
|
|
31ba82a295 | ||
|
|
99a584a82d | ||
|
|
2e71e4dc5f | ||
|
|
955dccff2b | ||
|
|
fa999f8cf0 | ||
|
|
515c7c3d26 | ||
|
|
9b5880b719 | ||
|
|
994fc1026f | ||
|
|
e9cf7d66ba | ||
|
|
e01d6efb84 | ||
|
|
b7c801c8db | ||
|
|
b15e90cc64 | ||
|
|
8d3be6aba3 | ||
|
|
464ae8952e | ||
|
|
2007541adf | ||
|
|
d6167617a9 | ||
|
|
38d7c1494d | ||
|
|
c0c0d56e86 | ||
|
|
745301d14b | ||
|
|
8d640b595e | ||
|
|
1925e1c2f4 | ||
|
|
0595124ecd | ||
|
|
8e3202d1ad | ||
|
|
5f8eb39206 | ||
|
|
e7bf700f36 | ||
|
|
c10342818a | ||
|
|
70a7bf1033 | ||
|
|
a92dd013bc | ||
|
|
6d6baf3f35 | ||
|
|
72f2b33754 | ||
|
|
89a456f88c | ||
|
|
d98d995326 | ||
|
|
20a76f1921 | ||
|
|
0c4a7992a7 | ||
|
|
5e6b88fa31 | ||
|
|
d8735cc738 | ||
|
|
185671adb5 | ||
|
|
ee268444b7 | ||
|
|
9387bdc1ee | ||
|
|
7cfdb43f37 | ||
|
|
06f658917f | ||
|
|
88d5614158 | ||
|
|
f896153fb7 | ||
|
|
1004356e75 | ||
|
|
77b0c6db09 | ||
|
|
dec2d1cb05 | ||
|
|
76314aebbd | ||
|
|
7d149d2ab1 | ||
|
|
ff4082d5df | ||
|
|
51f58e76b2 | ||
|
|
347ce74808 | ||
|
|
726dbbbaf3 | ||
|
|
fb434f3f15 | ||
|
|
aacd86cebe | ||
|
|
bd3b4605cb | ||
|
|
f267744c1c | ||
|
|
fffb033e39 | ||
|
|
f574f039f5 | ||
|
|
506ebdb092 |
No files matched your search
@@ -33,7 +33,7 @@ jobs:
|
||||
# directly drops the docker-in-docker requirement entirely.
|
||||
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
|
||||
container:
|
||||
image: ghcr.io/renovatebot/renovate:43.232.0
|
||||
image: ghcr.io/renovatebot/renovate:44.237.3
|
||||
steps:
|
||||
- name: Run Renovate
|
||||
run: renovate
|
||||
|
||||
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.24.2] — 2026-11-13
|
||||
|
||||
### Fixed
|
||||
- **Quick moves no longer get lost.** Dropping an event while an earlier move was still saving used to snap it back without a word. Drops now wait in line and are saved in order, and a moved event stays where you dropped it until the calendar shows it there. If an event changed before a drop could be saved, or the line is full, a message says so instead ([#494]).
|
||||
- **Undo after a run of moves** keeps the first move's Undo, and each failed move gets its own message rather than replacing the last one ([#494]).
|
||||
|
||||
### Changed
|
||||
- **The day view's title names the month**, like the week and 3-day views. Weekday and date already sit in the column header below it ([#501]).
|
||||
- Library updates (kotlinx-datetime, AppCompat).
|
||||
|
||||
## [2.24.1] — 2026-11-12
|
||||
|
||||
### Added
|
||||
@@ -1973,3 +1983,5 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#489]: https://codeberg.org/jlmakiola/calendula/issues/489
|
||||
[#491]: https://codeberg.org/jlmakiola/calendula/issues/491
|
||||
[#493]: https://codeberg.org/jlmakiola/calendula/issues/493
|
||||
[#494]: https://codeberg.org/jlmakiola/calendula/issues/494
|
||||
[#501]: https://codeberg.org/jlmakiola/calendula/issues/501
|
||||
@@ -7,4 +7,4 @@ source "https://rubygems.org"
|
||||
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
|
||||
# committed on purpose: this resolves an uploader's transitive deps, not the
|
||||
# app's, and none of it affects the reproducible release build.
|
||||
gem "fastlane", "2.237.0"
|
||||
gem "fastlane", "2.241.1"
|
||||
@@ -32,8 +32,8 @@ android {
|
||||
// (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.
|
||||
// See docs/RELEASING.md.
|
||||
versionCode = 2240199
|
||||
versionName = "2.24.1"
|
||||
versionCode = 2240299
|
||||
versionName = "2.24.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ class CalendarPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
|
||||
private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
|
||||
internal fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
|
||||
{ stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
|
||||
{ ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
|
||||
)
|
||||
@@ -166,6 +166,9 @@ class SettingsPrefs @Inject constructor(
|
||||
val defaultEventDurationMinutes: Flow<Int> = prefs.flow(SettingsKeys.defaultEventDurationMinutes)
|
||||
val perCalendarEventDuration: Flow<Map<Long, Int>> = prefs.flow(SettingsKeys.perCalendarEventDuration)
|
||||
val remindersEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.remindersEnabled)
|
||||
val mutedReminderCalendarIds: Flow<Set<Long>> = prefs.flow(SettingsKeys.mutedReminderCalendarIds)
|
||||
val notificationCategoryHintDismissed: Flow<Boolean> = prefs.flow(SettingsKeys.notificationCategoryHintDismissed)
|
||||
val reminderChannelVersions: Flow<Map<Long, Int>> = prefs.flow(SettingsKeys.reminderChannelVersions)
|
||||
val allowColorOnUnsupportedCalendars: Flow<Boolean> =
|
||||
prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars)
|
||||
val defaultReminderMinutes: Flow<List<Int>> = prefs.flow(SettingsKeys.defaultReminderMinutes)
|
||||
@@ -273,6 +276,25 @@ class SettingsPrefs @Inject constructor(
|
||||
|
||||
suspend fun setRemindersEnabled(enabled: Boolean) = prefs.set(SettingsKeys.remindersEnabled, enabled)
|
||||
|
||||
/**
|
||||
* Reserve a new reminder channel version for each calendar in [floors], above
|
||||
* both its stored high-water mark and its floor, and return them. Versions
|
||||
* only grow: the system would restore a deleted channel's old settings if
|
||||
* its id came back (#518).
|
||||
*/
|
||||
suspend fun reserveReminderChannelVersions(floors: Map<Long, Int>): Map<Long, Int> {
|
||||
val stored = prefs.update(SettingsKeys.reminderChannelVersions) { current ->
|
||||
current + floors.mapValues { (id, floor) -> maxOf((current[id] ?: 0) + 1, floor) }
|
||||
}
|
||||
return floors.keys.associateWith { stored.getValue(it) }
|
||||
}
|
||||
|
||||
suspend fun dismissNotificationCategoryHint() = prefs.set(SettingsKeys.notificationCategoryHintDismissed, true)
|
||||
|
||||
suspend fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
|
||||
prefs.update(SettingsKeys.mutedReminderCalendarIds) { if (enabled) it - calendarId else it + calendarId }
|
||||
}
|
||||
|
||||
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) =
|
||||
prefs.set(SettingsKeys.allowColorOnUnsupportedCalendars, enabled)
|
||||
|
||||
@@ -594,6 +616,27 @@ object SettingsKeys {
|
||||
*/
|
||||
val remindersEnabled: Pref<Boolean> = booleanPref("reminders_enabled", true)
|
||||
|
||||
/**
|
||||
* Calendars whose reminders Calendula doesn't post (#272), independent of
|
||||
* `Calendars.VISIBLE`: the events still show, only the notifications stop.
|
||||
*/
|
||||
val mutedReminderCalendarIds: Pref<Set<Long>> = idSetPref("muted_reminder_calendar_ids")
|
||||
|
||||
/**
|
||||
* Whether the one-time hint that some skins (Samsung) start new notification
|
||||
* categories switched off has been dismissed (#145).
|
||||
*/
|
||||
val notificationCategoryHintDismissed: Pref<Boolean> = booleanPref("notification_category_hint_dismissed", false)
|
||||
|
||||
/**
|
||||
* The highest reminder channel version handed out per calendar (#518): a
|
||||
* sound picked in the app recreates the channel under a new one. The
|
||||
* channels themselves say which version is current; this only keeps ids
|
||||
* from being reused. Serialised as `id=version;id=version`.
|
||||
*/
|
||||
val reminderChannelVersions: Pref<Map<Long, Int>> =
|
||||
nullableStringPref("reminder_channel_versions").map(::parseChannelVersions) { it.toStoredVersions() }
|
||||
|
||||
/**
|
||||
* Whether to offer a custom event colour on calendars that publish no colour
|
||||
* palette; synced ones without a palette (some CalDAV) may drop or overwrite
|
||||
@@ -977,6 +1020,18 @@ private fun parseDurationOverrides(stored: String?): Map<Long, Int> =
|
||||
calendarId to minutes
|
||||
}.toMap()
|
||||
|
||||
private fun parseChannelVersions(stored: String?): Map<Long, Int> =
|
||||
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
|
||||
val parts = entry.split(KEY_VALUE_SEP)
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
|
||||
val version = parts[1].trim().toIntOrNull()?.takeIf { it > 0 } ?: return@mapNotNull null
|
||||
calendarId to version
|
||||
}.toMap()
|
||||
|
||||
private fun Map<Long, Int>.toStoredVersions(): String? =
|
||||
if (isEmpty()) null else entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, v) -> "$id$KEY_VALUE_SEP$v" }
|
||||
|
||||
private fun Map<Long, Int>.toStoredDurations(): String =
|
||||
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) ->
|
||||
"$id$KEY_VALUE_SEP$minutes"
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
|
||||
/** The single channel every reminder posted on before they moved per calendar (#145). */
|
||||
const val LEGACY_REMINDER_CHANNEL_ID = "reminders"
|
||||
|
||||
private const val CHANNEL_PREFIX = "reminders.calendar."
|
||||
private const val GROUP_PREFIX = "reminders.account."
|
||||
private const val LOCAL_GROUP_ID = GROUP_PREFIX + "local"
|
||||
|
||||
private const val VERSION_SEPARATOR = ".v"
|
||||
|
||||
/**
|
||||
* The notification channel [calendarId]'s reminders post on. A sound picked in
|
||||
* the app moves it to a new [version] (#518), since a channel's sound is fixed
|
||||
* once created; version 0 is the unversioned id #145 introduced.
|
||||
*/
|
||||
fun reminderChannelId(calendarId: Long, version: Int = 0): String =
|
||||
if (version == 0) "$CHANNEL_PREFIX$calendarId" else "$CHANNEL_PREFIX$calendarId$VERSION_SEPARATOR$version"
|
||||
|
||||
/** The calendar and version a reminder channel id names, or null for any other id. */
|
||||
fun parseReminderChannelId(channelId: String): Pair<Long, Int>? {
|
||||
if (!isReminderChannelId(channelId)) return null
|
||||
val rest = channelId.removePrefix(CHANNEL_PREFIX)
|
||||
val calendar = rest.substringBefore(VERSION_SEPARATOR).toLongOrNull() ?: return null
|
||||
if (VERSION_SEPARATOR !in rest) return calendar to 0
|
||||
val version = rest.substringAfter(VERSION_SEPARATOR).toIntOrNull()?.takeIf { it > 0 } ?: return null
|
||||
return calendar to version
|
||||
}
|
||||
|
||||
/**
|
||||
* Each calendar's current channel version: the highest one that exists, so a
|
||||
* new version only takes over once it is written. A calendar with none falls
|
||||
* back to its [highWater] mark — re-creating that id restores its own settings.
|
||||
*/
|
||||
fun currentReminderChannelVersions(existingChannelIds: Collection<String>, highWater: Map<Long, Int>): Map<Long, Int> {
|
||||
val existing = existingChannelIds.mapNotNull(::parseReminderChannelId)
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, versions) -> versions.max() }
|
||||
return highWater + existing
|
||||
}
|
||||
|
||||
/** Whether [channelId] is one of the per-calendar reminder channels. */
|
||||
fun isReminderChannelId(channelId: String): Boolean = channelId.startsWith(CHANNEL_PREFIX)
|
||||
|
||||
/** Whether [groupId] is one of the per-account reminder channel groups. */
|
||||
fun isReminderGroupId(groupId: String): Boolean = groupId.startsWith(GROUP_PREFIX)
|
||||
|
||||
/**
|
||||
* One account's channel group, labelled as the calendar manager labels it. The
|
||||
* app's own device calendars share a single group ([isLocal]); [ambiguous]
|
||||
* marks a [label] another group shows too, so it should name the source app.
|
||||
*/
|
||||
data class ReminderChannelGroupSpec(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val accountType: String,
|
||||
val isLocal: Boolean,
|
||||
val ambiguous: Boolean,
|
||||
)
|
||||
|
||||
data class ReminderChannelSpec(
|
||||
val id: String,
|
||||
val calendarId: Long,
|
||||
val name: String,
|
||||
/** Null only for a channel created before its calendar could be looked up. */
|
||||
val groupId: String?,
|
||||
/**
|
||||
* Whether a sync creates it up front. A calendar that syncs no events never
|
||||
* fires, so its channel only appears once something posts on it.
|
||||
*/
|
||||
val upFront: Boolean = true,
|
||||
)
|
||||
|
||||
/** What a channel already looks like in the system, as far as the plan compares it. */
|
||||
data class ExistingReminderChannel(val id: String, val name: String, val description: String?, val groupId: String?)
|
||||
|
||||
data class ReminderChannelPlan(
|
||||
val groups: List<ReminderChannelGroupSpec>,
|
||||
val channels: List<ReminderChannelSpec>,
|
||||
/**
|
||||
* The [channels] a sync writes: missing up-front ones, and existing ones
|
||||
* whose name or (localized) description changed, or that still lack a group.
|
||||
*/
|
||||
val channelsToWrite: List<ReminderChannelSpec>,
|
||||
/** The [groups] holding a channel that exists or is about to be written. */
|
||||
val groupsInUse: List<ReminderChannelGroupSpec>,
|
||||
val staleChannelIds: List<String>,
|
||||
val staleGroupIds: List<String>,
|
||||
/** Stale channels kept for now, because a notification on them is still showing. */
|
||||
val keptChannelIds: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* What the reminder channels should look like for [calendars]: one channel per
|
||||
* calendar inside a group per account, which of them need writing, and the
|
||||
* reminder channels and groups in [existing] / [existingGroupIds] no calendar
|
||||
* claims any more. Ids outside the reminder namespace (the backup channel, the
|
||||
* legacy channel) are never touched.
|
||||
*/
|
||||
fun planReminderChannels(
|
||||
calendars: List<CalendarSource>,
|
||||
existing: Collection<ExistingReminderChannel>,
|
||||
existingGroupIds: Collection<String>,
|
||||
unnamed: String,
|
||||
description: String,
|
||||
versions: Map<Long, Int> = emptyMap(),
|
||||
showingChannelIds: Set<String> = emptySet(),
|
||||
): ReminderChannelPlan {
|
||||
val (local, synced) = calendars.partition { it.isLocal }
|
||||
val groups = buildList {
|
||||
if (local.isNotEmpty()) {
|
||||
add(ReminderChannelGroupSpec(LOCAL_GROUP_ID, "", local.first().accountType, isLocal = true, ambiguous = false))
|
||||
}
|
||||
synced.groupByAccount().forEach { group ->
|
||||
add(
|
||||
ReminderChannelGroupSpec(
|
||||
id = accountGroupId(group.key.type, group.key.name),
|
||||
label = group.label,
|
||||
accountType = group.accountType,
|
||||
isLocal = false,
|
||||
ambiguous = group.ambiguous,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val existingById = existing.associateBy { it.id }
|
||||
val channels = calendars.map { calendar ->
|
||||
ReminderChannelSpec(
|
||||
id = reminderChannelId(calendar.id, versions[calendar.id] ?: 0),
|
||||
calendarId = calendar.id,
|
||||
name = calendar.displayName.ifBlank { unnamed },
|
||||
groupId = if (calendar.isLocal) LOCAL_GROUP_ID else accountGroupId(calendar.accountType, calendar.accountName),
|
||||
upFront = calendar.isLocal || calendar.syncsEvents,
|
||||
)
|
||||
}
|
||||
val wantedChannels = channels.mapTo(HashSet()) { it.id }
|
||||
val wantedGroups = groups.mapTo(HashSet()) { it.id }
|
||||
val toWrite = channels.filter { spec ->
|
||||
val current = existingById[spec.id]
|
||||
if (current == null) {
|
||||
spec.upFront
|
||||
} else {
|
||||
current.name != spec.name || current.description != description ||
|
||||
(current.groupId == null && spec.groupId != null)
|
||||
}
|
||||
}
|
||||
val usedGroupIds = channels
|
||||
.filter { it.id in existingById || it in toWrite }
|
||||
.mapNotNullTo(HashSet()) { it.groupId }
|
||||
val (kept, stale) = existingById.keys
|
||||
.filter { isReminderChannelId(it) && it !in wantedChannels }
|
||||
.partition { it in showingChannelIds }
|
||||
val keptGroups = kept.mapNotNullTo(HashSet()) { existingById.getValue(it).groupId }
|
||||
return ReminderChannelPlan(
|
||||
groups = groups,
|
||||
channels = channels,
|
||||
channelsToWrite = toWrite,
|
||||
groupsInUse = groups.filter { it.id in usedGroupIds },
|
||||
staleChannelIds = stale,
|
||||
staleGroupIds = existingGroupIds.filter { isReminderGroupId(it) && it !in wantedGroups && it !in keptGroups },
|
||||
keptChannelIds = kept,
|
||||
)
|
||||
}
|
||||
|
||||
private fun accountGroupId(type: String, name: String): String = "$GROUP_PREFIX$type/$name"
|
||||
|
||||
/** A channel as far as its blocked state goes: switched off itself, and the group it sits in. */
|
||||
data class ReminderChannelState(val blocked: Boolean, val groupId: String?)
|
||||
|
||||
/** Reminder notifications the system blocks, while Calendula would post them. */
|
||||
data class ReminderChannelStatus(
|
||||
val appBlocked: Boolean = false,
|
||||
val blockedCalendarIds: Set<Long> = emptySet(),
|
||||
/** The blocked ones whose whole account group is off, which only the app's own screen can undo. */
|
||||
val groupBlockedCalendarIds: Set<Long> = emptySet(),
|
||||
)
|
||||
|
||||
/**
|
||||
* What the system blocks of [calendarIds]' reminders: all of them while the
|
||||
* app's notifications are off, otherwise those whose channel, or the group it
|
||||
* sits in, is switched off. A calendar without a channel yet isn't blocked.
|
||||
*/
|
||||
fun reminderChannelStatus(
|
||||
calendarIds: Collection<Long>,
|
||||
appEnabled: Boolean,
|
||||
channels: Map<String, ReminderChannelState>,
|
||||
blockedGroupIds: Set<String>,
|
||||
versions: Map<Long, Int> = emptyMap(),
|
||||
): ReminderChannelStatus {
|
||||
fun channel(calendarId: Long) = channels[reminderChannelId(calendarId, versions[calendarId] ?: 0)]
|
||||
val groupBlocked = calendarIds.filterTo(HashSet()) { channel(it)?.groupId in blockedGroupIds }
|
||||
val blocked = if (!appEnabled) {
|
||||
calendarIds.toSet()
|
||||
} else {
|
||||
calendarIds.filterTo(HashSet()) { channel(it)?.blocked == true } + groupBlocked
|
||||
}
|
||||
return ReminderChannelStatus(appBlocked = !appEnabled, blockedCalendarIds = blocked, groupBlockedCalendarIds = groupBlocked)
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
import android.app.NotificationManager.IMPORTANCE_DEFAULT
|
||||
import android.app.NotificationManager.IMPORTANCE_LOW
|
||||
import android.app.NotificationManager.IMPORTANCE_MIN
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppLabel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The reminder notification channels: one per calendar, grouped by account
|
||||
* (#145), created up front so each calendar can be muted or given its own sound
|
||||
* in the system settings before it ever fires.
|
||||
*
|
||||
* A channel the user has touched keeps their settings; [sync] only creates the
|
||||
* missing ones, renames changed ones, and deletes those whose calendar is gone.
|
||||
* A sound picked in the app ([setSound], #518) recreates the channel under the
|
||||
* calendar's next version; the old one goes once nothing shows on it.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderChannels @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
) {
|
||||
private val manager: NotificationManager
|
||||
get() = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
/** What the last [sync] saw, so an unchanged provider costs no channel writes. */
|
||||
@Volatile
|
||||
private var lastSync: Any? = null
|
||||
|
||||
/**
|
||||
* Bring the channels in line with the provider's calendars. An empty
|
||||
* snapshot changes nothing: that is a provider hiccup far more often than
|
||||
* every calendar gone at once.
|
||||
*/
|
||||
@Synchronized
|
||||
fun sync() {
|
||||
val calendars = calendarDataSource.calendars()
|
||||
if (calendars.isEmpty()) return
|
||||
val manager = manager
|
||||
val existing = manager.notificationChannels
|
||||
val existingIds = existing.mapTo(HashSet()) { it.id }
|
||||
val versions = currentVersions(existingIds).also { knownVersions = it }
|
||||
if (syncKey(calendars, existingIds, versions) == lastSync && LEGACY_REMINDER_CHANNEL_ID !in existingIds) return
|
||||
val groups = manager.notificationChannelGroups
|
||||
val groupIds = groups.map { it.id }
|
||||
// Active notifications are only read when there is something stale to protect.
|
||||
val plan = plan(calendars, existing, groupIds, versions).let { draft ->
|
||||
if (draft.staleChannelIds.isEmpty()) draft else plan(calendars, existing, groupIds, versions, showingChannelIds(draft.staleChannelIds))
|
||||
}
|
||||
plan.staleChannelIds.forEach(manager::deleteNotificationChannel)
|
||||
plan.staleGroupIds.forEach(manager::deleteNotificationChannelGroup)
|
||||
val groupNames = groups.associate { it.id to it.name?.toString() }
|
||||
write(
|
||||
groups = plan.groupsInUse.filter { groupNames[it.id] != groupLabel(it) },
|
||||
channels = plan.channelsToWrite,
|
||||
existing = existing,
|
||||
)
|
||||
val ids = existingIds - plan.staleChannelIds.toSet() + plan.channelsToWrite.map { it.id }
|
||||
val legacyGone = retireLegacyChannel()
|
||||
// A kept channel still has to go once its notification does, so don't cache.
|
||||
lastSync = if (plan.keptChannelIds.isEmpty()) {
|
||||
syncKey(calendars, if (legacyGone) ids - LEGACY_REMINDER_CHANNEL_ID else ids, versions)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncKey(calendars: List<CalendarSource>, channelIds: Set<String>, versions: Map<Long, Int>): Any = listOf(
|
||||
calendars.map { listOf(it.id, it.displayName, it.accountName, it.accountType, it.isLocal, it.syncsEvents) },
|
||||
Locale.getDefault(),
|
||||
channelIds,
|
||||
versions,
|
||||
)
|
||||
|
||||
/**
|
||||
* The current versions last resolved under the lock ([sync], [channelFor]).
|
||||
* Only [recreate], under the same lock, moves a calendar higher and updates
|
||||
* it, so a hit whose channel still exists is current.
|
||||
*/
|
||||
@Volatile
|
||||
private var knownVersions: Map<Long, Int> = emptyMap()
|
||||
|
||||
private fun currentVersions(existingChannelIds: Collection<String>): Map<Long, Int> =
|
||||
currentReminderChannelVersions(existingChannelIds, runBlocking { settingsPrefs.reminderChannelVersions.first() })
|
||||
|
||||
/** Channels with a notification on screen; on failure all of [candidates] count, so none is deleted. */
|
||||
private fun showingChannelIds(candidates: Collection<String>): Set<String> = runCatching {
|
||||
manager.activeNotifications.mapNotNullTo(HashSet()) { it.notification.channelId }
|
||||
}.getOrElse { e ->
|
||||
Log.w(TAG, "Could not read active notifications", e)
|
||||
candidates.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel to post [calendarId]'s reminder on, created first if a sync
|
||||
* has not got to it yet. Null when the calendar no longer exists. Should the
|
||||
* calendar lookup fail or come back empty, the channel is still created,
|
||||
* plainly named and ungrouped, for the next sync to name and file — a
|
||||
* reminder is never lost to its channel.
|
||||
*/
|
||||
@Synchronized
|
||||
fun channelFor(calendarId: Long): String? {
|
||||
val manager = manager
|
||||
knownVersions[calendarId]?.let { version ->
|
||||
val known = reminderChannelId(calendarId, version)
|
||||
if (manager.getNotificationChannel(known) != null) return known
|
||||
}
|
||||
val existing = manager.notificationChannels
|
||||
val versions = currentVersions(existing.map { it.id }).also { knownVersions = it }
|
||||
val id = reminderChannelId(calendarId, versions[calendarId] ?: 0)
|
||||
if (existing.any { it.id == id }) return id
|
||||
val calendars = try {
|
||||
calendarDataSource.calendars()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not look up calendar $calendarId for its channel", e)
|
||||
emptyList()
|
||||
}
|
||||
val fallback = ReminderChannelSpec(id, calendarId, context.getString(R.string.reminder_channel_name), null)
|
||||
if (calendars.isEmpty()) {
|
||||
write(emptyList(), listOf(fallback), existing)
|
||||
return id
|
||||
}
|
||||
val plan = plan(calendars, existing, emptyList(), versions)
|
||||
val spec = plan.channels.firstOrNull { it.calendarId == calendarId } ?: return null
|
||||
try {
|
||||
val knownGroups = manager.notificationChannelGroups.mapTo(HashSet()) { it.id }
|
||||
write(
|
||||
groups = plan.groups.filter { it.id == spec.groupId && it.id !in knownGroups },
|
||||
channels = listOf(spec),
|
||||
existing = existing,
|
||||
)
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "Could not file the channel of calendar $calendarId", e)
|
||||
write(emptyList(), listOf(fallback), existing)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Run [block] with [calendarId]'s channel held: no sound pick or sync can
|
||||
* move or retire it until [block] returns. Null, without running [block],
|
||||
* when the calendar is gone or its channel can't be made; [block]'s own
|
||||
* failures propagate.
|
||||
*/
|
||||
@Synchronized
|
||||
fun <T> withChannel(calendarId: Long, block: (String) -> T): T? {
|
||||
val channelId = try {
|
||||
channelFor(calendarId)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "No channel for calendar $calendarId", e)
|
||||
null
|
||||
}
|
||||
return channelId?.let(block)
|
||||
}
|
||||
|
||||
/** What the system currently lets through for [calendarIds]' reminders (#145). */
|
||||
fun status(calendarIds: Collection<Long>): ReminderChannelStatus {
|
||||
val manager = manager
|
||||
val appEnabled = manager.areNotificationsEnabled()
|
||||
val existing = manager.notificationChannels
|
||||
val channels = existing.associate {
|
||||
it.id to ReminderChannelState(it.importance == NotificationManager.IMPORTANCE_NONE, it.group)
|
||||
}
|
||||
val blockedGroups = manager.notificationChannelGroups.filter { it.isBlocked }.mapTo(HashSet()) { it.id }
|
||||
return reminderChannelStatus(calendarIds, appEnabled, channels, blockedGroups, currentVersions(channels.keys))
|
||||
}
|
||||
|
||||
/** A "Silent" (low) channel keeps its sound but never plays it. */
|
||||
private fun NotificationChannel.audibleSound(): Uri? =
|
||||
sound.takeUnless { importance in IMPORTANCE_MIN..IMPORTANCE_LOW }
|
||||
|
||||
/**
|
||||
* The sound each of [calendarIds]' channels plays; null is silent. A
|
||||
* calendar whose channel doesn't exist yet is left out.
|
||||
*/
|
||||
fun sounds(calendarIds: Collection<Long>): Map<Long, Uri?> {
|
||||
val byId = manager.notificationChannels.associateBy { it.id }
|
||||
val versions = currentVersions(byId.keys)
|
||||
return calendarIds.mapNotNull { id ->
|
||||
byId[reminderChannelId(id, versions[id] ?: 0)]?.let { id to it.audibleSound() }
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Give [calendarIds]' reminders [sound] (null = silent). A channel's sound is
|
||||
* fixed once created, so each moves to a new channel version carrying over
|
||||
* everything else, and the old one retires on the next sync (#518). Calendars without a
|
||||
* channel are skipped unless [createMissing]. Returns whether every move
|
||||
* went through.
|
||||
*/
|
||||
suspend fun setSound(calendarIds: Collection<Long>, sound: Uri?, createMissing: Boolean): Boolean {
|
||||
if (createMissing) calendarIds.forEach(::channelFor)
|
||||
val existingIds = manager.notificationChannels.mapTo(HashSet()) { it.id }
|
||||
val current = currentVersions(existingIds)
|
||||
val floors = calendarIds
|
||||
.filter { reminderChannelId(it, current[it] ?: 0) in existingIds }
|
||||
.associateWith { (current[it] ?: 0) + 1 }
|
||||
if (floors.isEmpty()) return false
|
||||
return recreate(settingsPrefs.reserveReminderChannelVersions(floors), sound)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create each calendar's [versions] from its current channel. The old one is
|
||||
* left to the next [sync], which retires it once nothing shows on it.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun recreate(versions: Map<Long, Int>, sound: Uri?): Boolean {
|
||||
val manager = manager
|
||||
val byId = manager.notificationChannels.associateBy { it.id }
|
||||
val current = currentReminderChannelVersions(byId.keys, emptyMap())
|
||||
var allApplied = true
|
||||
versions.forEach { (calendarId, version) ->
|
||||
val from = current[calendarId]?.takeIf { it < version }
|
||||
val old = from?.let { byId[reminderChannelId(calendarId, it)] }
|
||||
if (old == null) {
|
||||
allApplied = false
|
||||
return@forEach
|
||||
}
|
||||
try {
|
||||
// Picking a sound asks for one, which a "Silent" (low) channel would never play.
|
||||
val importance = if (sound != null && old.importance in IMPORTANCE_MIN..IMPORTANCE_LOW) {
|
||||
IMPORTANCE_DEFAULT
|
||||
} else {
|
||||
old.importance
|
||||
}
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(reminderChannelId(calendarId, version), old.name, importance).apply {
|
||||
description = old.description
|
||||
old.group?.let { group = it }
|
||||
inheritFrom(old)
|
||||
setSound(sound, old.audioAttributes ?: DEFAULT_AUDIO_ATTRIBUTES)
|
||||
},
|
||||
)
|
||||
knownVersions = knownVersions + (calendarId to version)
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "Could not move calendar $calendarId to a new sound", e)
|
||||
allApplied = false
|
||||
}
|
||||
}
|
||||
lastSync = null
|
||||
return allApplied
|
||||
}
|
||||
|
||||
private fun plan(
|
||||
calendars: List<CalendarSource>,
|
||||
existing: List<NotificationChannel>,
|
||||
existingGroupIds: List<String>,
|
||||
versions: Map<Long, Int>,
|
||||
showingChannelIds: Set<String> = emptySet(),
|
||||
): ReminderChannelPlan = planReminderChannels(
|
||||
calendars = calendars,
|
||||
existing = existing.map { ExistingReminderChannel(it.id, it.name.toString(), it.description, it.group) },
|
||||
existingGroupIds = existingGroupIds,
|
||||
unnamed = context.getString(R.string.calendar_unnamed),
|
||||
description = context.getString(R.string.reminder_channel_description),
|
||||
versions = versions,
|
||||
showingChannelIds = showingChannelIds,
|
||||
)
|
||||
|
||||
private fun write(
|
||||
groups: List<ReminderChannelGroupSpec>,
|
||||
channels: List<ReminderChannelSpec>,
|
||||
existing: List<NotificationChannel>,
|
||||
) {
|
||||
val manager = manager
|
||||
if (groups.isNotEmpty()) {
|
||||
manager.createNotificationChannelGroups(groups.map { NotificationChannelGroup(it.id, groupLabel(it)) })
|
||||
}
|
||||
if (channels.isEmpty()) return
|
||||
val byId = existing.associateBy { it.id }
|
||||
val description = context.getString(R.string.reminder_channel_description)
|
||||
val template = byId[LEGACY_REMINDER_CHANNEL_ID]
|
||||
// Importance only applies on creation; an existing channel keeps its own.
|
||||
manager.createNotificationChannels(
|
||||
channels.map { spec ->
|
||||
val current = byId[spec.id]
|
||||
NotificationChannel(
|
||||
spec.id,
|
||||
spec.name,
|
||||
current?.importance ?: template?.importance ?: NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
this.description = description
|
||||
spec.groupId?.let { group = it }
|
||||
if (current == null && template != null) inheritFrom(template)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry a channel's sound, vibration, lights and badge over to a new one: the
|
||||
* legacy channel to a per-calendar one on upgrade, or a calendar's channel to
|
||||
* its next version (#518). Lock-screen visibility and a DND override can't
|
||||
* follow: the system resets both on channels an app creates.
|
||||
*/
|
||||
private fun NotificationChannel.inheritFrom(template: NotificationChannel) {
|
||||
setSound(template.sound, template.audioAttributes)
|
||||
// The pattern setter also flips vibration on or off, so it goes first.
|
||||
vibrationPattern = template.vibrationPattern
|
||||
enableVibration(template.shouldVibrate())
|
||||
enableLights(template.shouldShowLights())
|
||||
lightColor = template.lightColor
|
||||
setShowBadge(template.canShowBadge())
|
||||
// Only honoured with Do Not Disturb access, so usually left to the user.
|
||||
setBypassDnd(template.canBypassDnd())
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the pre-#145 channel once the per-calendar ones carry its settings,
|
||||
* but not while a reminder posted on it is still on screen — deleting the
|
||||
* channel would take that notification with it. Returns whether it is gone.
|
||||
*/
|
||||
private fun retireLegacyChannel(): Boolean {
|
||||
val manager = manager
|
||||
if (manager.getNotificationChannel(LEGACY_REMINDER_CHANNEL_ID) == null) return true
|
||||
val showing = LEGACY_REMINDER_CHANNEL_ID in showingChannelIds(listOf(LEGACY_REMINDER_CHANNEL_ID))
|
||||
if (!showing) manager.deleteNotificationChannel(LEGACY_REMINDER_CHANNEL_ID)
|
||||
return !showing
|
||||
}
|
||||
|
||||
private fun groupLabel(group: ReminderChannelGroupSpec): String {
|
||||
if (group.isLocal) return context.getString(R.string.calendars_local_header)
|
||||
val name = group.label.ifBlank { context.getString(R.string.calendar_unnamed) }
|
||||
if (!group.ambiguous) return name
|
||||
return context.getString(R.string.calendars_account_from_source, name, sourceAppLabel(context, group.accountType))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderChannels"
|
||||
|
||||
val DEFAULT_AUDIO_ATTRIBUTES: AudioAttributes = Notification.AUDIO_ATTRIBUTES_DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
@@ -29,8 +27,8 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Posts one notification per occurrence on a dedicated channel. Tapping opens
|
||||
* the event's detail screen.
|
||||
* Posts one notification per occurrence on its calendar's channel
|
||||
* ([ReminderChannels], #145). Tapping opens the event's detail screen.
|
||||
*
|
||||
* The tag is the [occurrenceKey], so each reminder for an occurrence replaces
|
||||
* the one before it in place (#463). A different reminder alerts afresh; the
|
||||
@@ -43,6 +41,7 @@ class ReminderNotifier @Inject constructor(
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val channels: ReminderChannels,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -53,19 +52,19 @@ class ReminderNotifier @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* The single choke point for "this calendar is switched off", covering the
|
||||
* two paths that reach [post] around the scan's own filter: a snooze armed
|
||||
* before the switch-off, and a read-only install whose switch lives in
|
||||
* [CalendarPrefs].
|
||||
* The single choke point for "this calendar posts no reminders": switched
|
||||
* off (also covering a snooze armed before the switch-off, and a read-only
|
||||
* install whose switch lives in [CalendarPrefs]), or its notifications
|
||||
* turned off in Calendula's own settings (#272).
|
||||
*/
|
||||
private suspend fun isSilenced(calendarId: Long): Boolean =
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
calendarId in settingsPrefs.mutedReminderCalendarIds.first() ||
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
calendarDataSource.isCalendarVisible(calendarId) == false
|
||||
|
||||
/** Post [alert], unless its calendar is switched off. */
|
||||
/** Post [alert], unless its calendar is silenced or gone. */
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
if (isSilenced(alert.calendarId)) return
|
||||
ensureChannel()
|
||||
val title = displayTitle(alert)
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
@@ -88,7 +87,15 @@ class ReminderNotifier @Inject constructor(
|
||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||
)
|
||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
// Under the channel lock, so a sound picked meanwhile (#518) can't move it mid-post.
|
||||
val posted = channels.withChannel(alert.calendarId) { channelId -> notify(alert, channelId, title, text) }
|
||||
if (posted == null) {
|
||||
Log.i(TAG, "Dropped the reminder of event ${alert.eventId}: no channel for calendar ${alert.calendarId}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(alert: ReminderAlert, channelId: String, title: String, text: String) {
|
||||
val notification = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
@@ -153,7 +160,7 @@ class ReminderNotifier @Inject constructor(
|
||||
private fun cancelLegacy(alert: ReminderAlert) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.activeNotifications
|
||||
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == CHANNEL_ID }
|
||||
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == LEGACY_REMINDER_CHANNEL_ID }
|
||||
.filter { !it.notification.extras.containsKey(EXTRA_REMINDER_KEY) }
|
||||
.filter { it.notification.`when` == alert.beginMillis }
|
||||
.filter { it.notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString() == displayTitle(alert) }
|
||||
@@ -181,21 +188,8 @@ class ReminderNotifier @Inject constructor(
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
/** Channel creation is idempotent; re-running refreshes the localized name. */
|
||||
private fun ensureChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.reminder_channel_name),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = context.getString(R.string.reminder_channel_description)
|
||||
}
|
||||
NotificationManagerCompat.from(context).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderNotifier"
|
||||
const val CHANNEL_ID = "reminders"
|
||||
|
||||
// One id, distinct tags: the tag (occurrence) already keys the
|
||||
// notification, so a fixed id keeps cancellation/replacement simple.
|
||||
|
||||
@@ -43,6 +43,7 @@ class ReminderScanner @Inject constructor(
|
||||
private val source: ReminderInstanceSource,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val notifier: ReminderNotifier,
|
||||
private val channels: ReminderChannels,
|
||||
private val snoozes: ReminderSnoozeScheduler,
|
||||
private val alarms: ReminderAlarmScheduler,
|
||||
private val state: ReminderStatePrefs,
|
||||
@@ -85,10 +86,14 @@ class ReminderScanner @Inject constructor(
|
||||
return
|
||||
}
|
||||
|
||||
syncChannels()
|
||||
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
|
||||
// Reach into the past too: an all-day "at time of event" encodes to a
|
||||
// negative offset, and a catch-up pass needs the occurrences it missed.
|
||||
// Calendars muted in-app (#272) plan nothing, so they arm no wake-ups.
|
||||
val muted = settingsPrefs.mutedReminderCalendarIds.first()
|
||||
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
||||
.filter { it.calendarId !in muted }
|
||||
val planned = planReminders(
|
||||
instances = occurrences,
|
||||
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId })
|
||||
@@ -117,6 +122,19 @@ class ReminderScanner @Inject constructor(
|
||||
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider change re-scans, so a calendar added, renamed or removed keeps
|
||||
* its channel in step here. Skipped while reminders are off, when the
|
||||
* channels have nothing to do. A failure never costs the reminders.
|
||||
*/
|
||||
private fun syncChannels() {
|
||||
try {
|
||||
channels.sync()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reminder channel sync failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||
|
||||
/**
|
||||
|
||||
@@ -485,9 +485,8 @@ fun CalendarHost(
|
||||
movableCalendarIds = movableCalendarIds,
|
||||
dragEnabled = dragToReschedule,
|
||||
move = { reschedule.move(it) },
|
||||
inFlight = reschedule.inFlight,
|
||||
drops = reschedule.drops,
|
||||
undoStarted = reschedule.undoStarted,
|
||||
abandoned = reschedule.abandoned,
|
||||
edit = onEditEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -72,11 +73,13 @@ fun accountGroupTitle(group: CalendarAccountGroup): String {
|
||||
@Composable
|
||||
fun sourceAppName(accountType: String): String {
|
||||
val context = LocalContext.current
|
||||
return remember(accountType) {
|
||||
val pm = context.packageManager
|
||||
val packages = sourceAppPackages(context, accountType)
|
||||
packages.firstNotNullOfOrNull { pkg ->
|
||||
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||
} ?: accountType
|
||||
}
|
||||
return remember(accountType) { sourceAppLabel(context, accountType) }
|
||||
}
|
||||
|
||||
/** [sourceAppName] outside composition, e.g. for notification channel groups. */
|
||||
fun sourceAppLabel(context: Context, accountType: String): String {
|
||||
val pm = context.packageManager
|
||||
return sourceAppPackages(context, accountType).firstNotNullOfOrNull { pkg ->
|
||||
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||
} ?: accountType
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import java.util.Locale
|
||||
* out in [locale]'s own order, with the year shown only when [date] falls outside
|
||||
* [currentYear].
|
||||
*
|
||||
* Pass [skeleton] *without* a year field — "LLLL" for a month, "EEEdMMM" for a
|
||||
* day. A skeleton is a field list, so wanting the year is just asking for one
|
||||
* more field; the locale still decides where it lands ("July 2026" vs "2026年7月").
|
||||
* Pass [skeleton] *without* a year field, e.g. "LLLL" for a month. A skeleton
|
||||
* is a field list, so wanting the year is just asking for one more field; the
|
||||
* locale still decides where it lands ("July 2026" vs "2026年7月").
|
||||
*
|
||||
* Dropping the year in the current year is the one bit of policy here: the title
|
||||
* sits directly above a grid that already says which year it is, and the year's
|
||||
|
||||
@@ -90,6 +90,9 @@ fun EventMoveHost(
|
||||
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
|
||||
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
||||
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
||||
MoveOutcome.Stale -> stringResource(R.string.event_move_stale)
|
||||
MoveOutcome.Skipped -> stringResource(R.string.event_move_skipped)
|
||||
MoveOutcome.QueueFull -> stringResource(R.string.event_move_queue_full)
|
||||
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
|
||||
MoveOutcome.BlockedCancelledSeries -> stringResource(R.string.event_move_blocked_cancelled_series)
|
||||
MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -16,6 +19,7 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
|
||||
@@ -37,24 +41,18 @@ class EventMoveScope(
|
||||
val dragEnabled: Boolean,
|
||||
/**
|
||||
* Hand a drop to the writer. Returns nothing on purpose: a drop is answered
|
||||
* before its write resolves, so [abandoned] — not a return value — is what
|
||||
* tells a held copy that nothing landed.
|
||||
* before its write resolves, so [drops] — not a return value — is what tells
|
||||
* a held copy whether anything landed.
|
||||
*/
|
||||
val move: (MoveRequest) -> Unit,
|
||||
/**
|
||||
* True while a dropped event is being written, including the time its scope
|
||||
* dialog is up. A flow rather than a value so this scope stays the same
|
||||
* object across a move — every visible block reads it as a composition local.
|
||||
* Each drop's progress by its token — see `RescheduleViewModel.drops`. A flow
|
||||
* rather than a value so this scope stays the same object across a move —
|
||||
* every visible block reads it as a composition local.
|
||||
*/
|
||||
val inFlight: StateFlow<Boolean>,
|
||||
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
|
||||
val undoStarted: StateFlow<Int>,
|
||||
/**
|
||||
* Ticks when a drop ends with nothing landing — see
|
||||
* `RescheduleViewModel.abandoned`. [move] answers before the write is
|
||||
* resolved, so this is what tells a held copy to stop waiting.
|
||||
*/
|
||||
val abandoned: StateFlow<Int>,
|
||||
val drops: StateFlow<DropStates>,
|
||||
/** The undo write that just began — see `RescheduleViewModel.undoStarted`. */
|
||||
val undoStarted: StateFlow<UndoStart?>,
|
||||
/** Open an event in the edit form — the pointer-free route to the same change. */
|
||||
val edit: (EventInstance) -> Unit,
|
||||
) {
|
||||
@@ -63,30 +61,42 @@ class EventMoveScope(
|
||||
|
||||
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
|
||||
|
||||
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
|
||||
private val moveTokens = AtomicLong()
|
||||
|
||||
private val NEVER_UNDONE = MutableStateFlow(0)
|
||||
/** A fresh identity for one drop or undo, unique for the process. */
|
||||
fun nextMoveToken(): Long = moveTokens.incrementAndGet()
|
||||
|
||||
private val NEVER_ABANDONED = MutableStateFlow(0)
|
||||
/** How many abandoned drops [DropStates] remembers — far more than can be on screen at once. */
|
||||
private const val ABANDONED_KEPT = 32
|
||||
|
||||
/**
|
||||
* Whether a dropped event is still being written — false wherever moving is off.
|
||||
* The drag overlays hold a landed block on its target for this window.
|
||||
*/
|
||||
@Composable
|
||||
fun moveInFlight(): Boolean {
|
||||
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT
|
||||
return flow.collectAsStateWithLifecycle().value
|
||||
/** Per-drop progress, by token — see `RescheduleViewModel.drops`. */
|
||||
@Immutable
|
||||
data class DropStates(
|
||||
val pending: Set<Long> = emptySet(),
|
||||
val abandoned: Set<Long> = emptySet(),
|
||||
) {
|
||||
fun started(token: Long): DropStates = copy(pending = pending + token)
|
||||
|
||||
fun ended(token: Long, landed: Boolean): DropStates = copy(
|
||||
pending = pending - token,
|
||||
// Insertion-ordered, so the oldest go first.
|
||||
abandoned = if (landed) abandoned else (abandoned + token).let { kept ->
|
||||
if (kept.size > ABANDONED_KEPT) kept.drop(kept.size - ABANDONED_KEPT).toSet() else kept
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val NO_DROPS = MutableStateFlow(DropStates())
|
||||
|
||||
private val NEVER_UNDONE = MutableStateFlow<UndoStart?>(null)
|
||||
|
||||
/**
|
||||
* How many drops have ended with nothing landing. Read as a plain count so a
|
||||
* drag overlay can capture it at the moment of the drop and tell, later, whether
|
||||
* *its own* drop was one of them.
|
||||
* Every drop's progress — empty wherever moving is off. A drag overlay keeps
|
||||
* the token of its own drop and holds its copy while that token is pending.
|
||||
*/
|
||||
@Composable
|
||||
fun abandonedMoves(): Int {
|
||||
val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED
|
||||
fun dropStates(): DropStates {
|
||||
val flow = LocalEventMove.current?.drops ?: NO_DROPS
|
||||
return flow.collectAsStateWithLifecycle().value
|
||||
}
|
||||
|
||||
@@ -95,17 +105,53 @@ fun abandonedMoves(): Int {
|
||||
* this composable came on screen.
|
||||
*/
|
||||
@Composable
|
||||
fun OnUndoStarted(onUndo: () -> Unit) {
|
||||
fun OnUndoStarted(onUndo: (UndoStart) -> Unit) {
|
||||
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
|
||||
val tick by flow.collectAsStateWithLifecycle()
|
||||
var seen by remember { mutableIntStateOf(tick) }
|
||||
LaunchedEffect(tick) {
|
||||
if (tick == seen) return@LaunchedEffect
|
||||
seen = tick
|
||||
onUndo()
|
||||
val start by flow.collectAsStateWithLifecycle()
|
||||
var seen by remember { mutableStateOf(start) }
|
||||
LaunchedEffect(start) {
|
||||
val started = start
|
||||
if (started == null || started == seen) return@LaunchedEffect
|
||||
seen = started
|
||||
onUndo(started)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The life of a drop parked by a newer pickup (#494): held while its [token]
|
||||
* is pending, then a beat after the grid [seated] it — or [timeoutMillis] if it
|
||||
* never does — handed over and faded, then released. Returns the copy's alpha.
|
||||
* An abandoned drop skips the wait; nothing is coming to seat it.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberHeldSettle(
|
||||
token: Long,
|
||||
seated: Boolean,
|
||||
timeoutMillis: Long,
|
||||
onHandOver: () -> Unit,
|
||||
onRelease: () -> Unit,
|
||||
): Float {
|
||||
val drops = dropStates()
|
||||
val pending = token in drops.pending
|
||||
val abandoned = token in drops.abandoned
|
||||
val handOver by rememberUpdatedState(onHandOver)
|
||||
val release by rememberUpdatedState(onRelease)
|
||||
var fading by remember(token) { mutableStateOf(false) }
|
||||
LaunchedEffect(token, pending, abandoned, seated) {
|
||||
if (pending) return@LaunchedEffect
|
||||
if (!abandoned) delay(if (seated) SETTLE_GRACE_MILLIS else timeoutMillis)
|
||||
fading = true
|
||||
handOver()
|
||||
delay(SETTLE_FADE_MILLIS.toLong())
|
||||
release()
|
||||
}
|
||||
return animateFloatAsState(
|
||||
targetValue = if (fading) 0f else 1f,
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "held-settle",
|
||||
).value
|
||||
}
|
||||
|
||||
/** Opacity the source block keeps while its floating copy travels. */
|
||||
const val GHOST_ALPHA: Float = 0.3f
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -40,12 +41,21 @@ import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.TimeMark
|
||||
import kotlin.time.TimeSource
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MILLIS_PER_MINUTE = 60_000
|
||||
private const val MINUTES_PER_DAY = 24 * 60
|
||||
|
||||
/** How many drops may wait behind the one in flight before more are refused. */
|
||||
private const val MAX_QUEUED = 8
|
||||
|
||||
/** How long after a write its event's drawn times may still be the old ones. */
|
||||
private val RECENT_WRITE = 3.seconds
|
||||
|
||||
/** Where a dragged event should land. */
|
||||
sealed interface MoveTarget {
|
||||
/** A new start instant — a timeline drag, which moves time and day at once. */
|
||||
@@ -74,6 +84,10 @@ data class MoveRequest(
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
val target: MoveTarget,
|
||||
/** Whether the dragged occurrence is all-day, which decides the zone its day is read in. */
|
||||
val isAllDay: Boolean,
|
||||
/** The drop's own identity, from [nextMoveToken], which the view holding its copy waits on. */
|
||||
val token: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -92,8 +106,13 @@ data class MoveUndo(
|
||||
val moved: EventForm,
|
||||
/** The form as it stood before the move. */
|
||||
val restored: EventForm,
|
||||
/** The token of the drop this undoes, so the view that drew it carries the right chip back. */
|
||||
val dropToken: Long,
|
||||
)
|
||||
|
||||
/** An undo write that just began: its own [token], and the [dropToken] of the drop it undoes. */
|
||||
data class UndoStart(val token: Long, val dropToken: Long)
|
||||
|
||||
sealed interface MoveOutcome {
|
||||
/**
|
||||
* Written. [startMillis] is where the dragged occurrence now begins, for the
|
||||
@@ -115,6 +134,19 @@ sealed interface MoveOutcome {
|
||||
/** The event vanished (sync, another device) between the read and the write. */
|
||||
data object Gone : MoveOutcome
|
||||
|
||||
/** Drops that waited behind a scope dialog, cancelled rather than run after it (#494). */
|
||||
data object Skipped : MoveOutcome
|
||||
|
||||
/** More drops waiting than the queue holds; this one was not taken. */
|
||||
data object QueueFull : MoveOutcome
|
||||
|
||||
/**
|
||||
* The drop's recurring occurrence may no longer be where it was picked up —
|
||||
* its series was just written — and can't be followed there, so it is not
|
||||
* written (#494).
|
||||
*/
|
||||
data object Stale : MoveOutcome
|
||||
|
||||
/** The drop would push the series past its own `UNTIL`, generating nothing. */
|
||||
data object BlockedSeriesEnd : MoveOutcome
|
||||
|
||||
@@ -152,36 +184,32 @@ class RescheduleViewModel @Inject constructor(
|
||||
|
||||
private val _inFlight = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* True from the moment a drop is accepted until its write settles — the
|
||||
* window the dropped block holds its landing position for.
|
||||
*/
|
||||
/** True while any drop or undo runs or waits — what the confirmation chip holds for. */
|
||||
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
||||
|
||||
private val _abandoned = MutableStateFlow(0)
|
||||
private val _drops = MutableStateFlow(DropStates())
|
||||
|
||||
/**
|
||||
* Ticks when a drop ends with nothing on the target day: refused outright,
|
||||
* refused before the write, written and failed, or its scope dialog
|
||||
* dismissed. [move] answers before any of that is known, so without this
|
||||
* signal the view that drew the drop held its copy out for the full settle
|
||||
* timeout waiting for a grid that was never going to draw it (#253).
|
||||
* Each drop's own state, by its [MoveRequest.token]: pending from the moment
|
||||
* it is accepted until its write settles, and abandoned once it ends with
|
||||
* nothing on the target day — refused, failed, or its scope dialog dismissed.
|
||||
* Per drop, so a queued drop's held copy waits on its own write and is never
|
||||
* let go by another drop's failure (#253, #494).
|
||||
*/
|
||||
val abandoned: StateFlow<Int> = _abandoned.asStateFlow()
|
||||
val drops: StateFlow<DropStates> = _drops.asStateFlow()
|
||||
|
||||
private val _undoStarted = MutableStateFlow(0)
|
||||
private val _undoStarted = MutableStateFlow<UndoStart?>(null)
|
||||
|
||||
/**
|
||||
* Ticks the moment an undo write begins, before the provider has anything to
|
||||
* An undo write the moment it begins, before the provider has anything to
|
||||
* re-read, so the view that drew the drop can carry its chip back rather than
|
||||
* let it reappear on the old day.
|
||||
* let it reappear on the old day. Its fate then reads off [drops].
|
||||
*/
|
||||
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
|
||||
val undoStarted: StateFlow<UndoStart?> = _undoStarted.asStateFlow()
|
||||
|
||||
/**
|
||||
* Set from the moment a drop is accepted until its write settles. Two drops
|
||||
* of the same recurring event inside that window would compute their shift
|
||||
* from the same pre-move occurrence, and the shifts would compound.
|
||||
* Set while a drop is being prepared, waits on its scope dialog, or is
|
||||
* written. Drops arriving in that window wait in [queue] and run in order.
|
||||
*/
|
||||
private var busy = false
|
||||
set(value) {
|
||||
@@ -189,6 +217,22 @@ class RescheduleViewModel @Inject constructor(
|
||||
_inFlight.value = value
|
||||
}
|
||||
|
||||
/** Whether a move has been announced since the pipeline was last idle — see [report]. */
|
||||
private var movedThisRun = false
|
||||
|
||||
/** Outcomes waiting for the chip, shown one after another as each is consumed. */
|
||||
private val notices = ArrayDeque<MoveOutcome>()
|
||||
|
||||
/**
|
||||
* When each event's series (by id) or single occurrence (by id and begin)
|
||||
* was last written. The grid draws the old times until it re-reads, so a
|
||||
* drop on a recurring event written moments ago may carry stale times (#494).
|
||||
*/
|
||||
private val written = mutableMapOf<Pair<Long, Long?>, TimeMark>()
|
||||
|
||||
/** Drops that arrived while another was in flight, oldest first (#494). */
|
||||
private val queue = ArrayDeque<MoveRequest>()
|
||||
|
||||
/**
|
||||
* The calendars whose events may be dragged. Nothing below the UI guards
|
||||
* this, so a block outside this set registers no drag gesture at all.
|
||||
@@ -219,38 +263,50 @@ class RescheduleViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a drop, unless one is already being written. False means nothing will
|
||||
* be written; [abandoned] ticks for that refusal too, so a held copy is let
|
||||
* go by the one signal rather than by every caller remembering to.
|
||||
* Take a drop: run it now, or queue it behind the ones in flight. False means
|
||||
* nothing will be written — the queue is full — and the drop is marked
|
||||
* abandoned in [drops], so its held copy is let go by the one signal rather
|
||||
* than by every caller remembering to.
|
||||
*
|
||||
* The Boolean is diagnostic — the tests read it, [EventMoveScope] does not
|
||||
* hand it on. A caller that gated on it would be back to releasing its copy
|
||||
* from two places, and it answers before the write is resolved anyway.
|
||||
* hand it on. It answers before the write is resolved anyway.
|
||||
*/
|
||||
fun move(request: MoveRequest): Boolean {
|
||||
if (busy || _scopePrompt.value != null) {
|
||||
_abandoned.value += 1
|
||||
if (queue.size >= MAX_QUEUED) {
|
||||
_drops.update { it.ended(request.token, landed = false) }
|
||||
report(MoveOutcome.QueueFull)
|
||||
return false
|
||||
}
|
||||
if (_scopePrompt.value != null) {
|
||||
_drops.update { it.ended(request.token, landed = false) }
|
||||
report(MoveOutcome.Skipped)
|
||||
return false
|
||||
}
|
||||
_drops.update { it.started(request.token) }
|
||||
if (busy) queue.addLast(request) else run(request)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun run(request: MoveRequest) {
|
||||
busy = true
|
||||
viewModelScope.launch {
|
||||
val prepared = prepare(request)
|
||||
if (prepared == null) {
|
||||
_abandoned.value += 1
|
||||
busy = false
|
||||
settle(request.token, landed = false)
|
||||
return@launch
|
||||
}
|
||||
// A cancelled series only moves as a whole (#388).
|
||||
val seriesOnly = !prepared.original.canSaveApartFromSeries()
|
||||
if (!prepared.isRecurring || (seriesOnly && prepared.canRealign)) {
|
||||
write(prepared, RecurringWriteScope.AllEvents)
|
||||
busy = false
|
||||
settle(request.token, write(prepared, RecurringWriteScope.AllEvents))
|
||||
} else if (seriesOnly) {
|
||||
_outcome.value = MoveOutcome.BlockedCancelledSeries
|
||||
_abandoned.value += 1
|
||||
busy = false
|
||||
report(MoveOutcome.BlockedCancelledSeries)
|
||||
settle(request.token, landed = false)
|
||||
} else {
|
||||
// Still busy: the scope dialog is now the thing in flight.
|
||||
// Still busy: the scope dialog is now the thing in flight. Drops
|
||||
// waiting behind it are cancelled, not held behind a question
|
||||
// about another event.
|
||||
skipQueued()
|
||||
pending = prepared
|
||||
_scopePrompt.value = MoveScopePrompt(
|
||||
occurrenceOnly = !prepared.canRealign,
|
||||
@@ -258,7 +314,6 @@ class RescheduleViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Answer the scope dialog. */
|
||||
@@ -269,35 +324,92 @@ class RescheduleViewModel @Inject constructor(
|
||||
pending = null
|
||||
_scopePrompt.value = null
|
||||
viewModelScope.launch {
|
||||
write(prepared, scope)
|
||||
busy = false
|
||||
settle(prepared.request.token, write(prepared, scope))
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the scope dialog without writing. */
|
||||
fun cancelScope() {
|
||||
val prepared = pending ?: return
|
||||
pending = null
|
||||
busy = false
|
||||
_scopePrompt.value = null
|
||||
_abandoned.value += 1
|
||||
settle(prepared.request.token, landed = false)
|
||||
}
|
||||
|
||||
/** End the drop [token], then run the next queued one, if any. */
|
||||
private fun settle(token: Long, landed: Boolean) {
|
||||
_drops.update { it.ended(token, landed) }
|
||||
// Straight on to the next, so busy never reads false between two drops.
|
||||
val next = queue.removeFirstOrNull()
|
||||
if (next != null) {
|
||||
run(next)
|
||||
} else {
|
||||
busy = false
|
||||
movedThisRun = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun skipQueued() {
|
||||
if (queue.isEmpty()) return
|
||||
val skipped = queue.map { it.token }
|
||||
queue.clear()
|
||||
_drops.update { states -> skipped.fold(states) { acc, token -> acc.ended(token, landed = false) } }
|
||||
report(MoveOutcome.Skipped)
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue [outcome] for the chip. While drops run back to back only the first
|
||||
* move is announced, so its Undo stays on offer; every failure gets its own
|
||||
* turn after it, since the grid shows what moved but only the chip says what
|
||||
* didn't. A later write to the announced move's event takes that Undo away,
|
||||
* as putting the event back would undo the later move too.
|
||||
*/
|
||||
private fun report(outcome: MoveOutcome, eventId: Long? = null) {
|
||||
if (outcome is MoveOutcome.Moved) {
|
||||
if (movedThisRun) {
|
||||
dropUndoFor(eventId)
|
||||
return
|
||||
}
|
||||
movedThisRun = true
|
||||
} else if (outcome == (notices.lastOrNull() ?: _outcome.value)) {
|
||||
return
|
||||
}
|
||||
if (_outcome.value == null) _outcome.value = outcome else notices.addLast(outcome)
|
||||
}
|
||||
|
||||
private fun dropUndoFor(eventId: Long?) {
|
||||
fun MoveOutcome.withoutUndo(): MoveOutcome =
|
||||
if (this is MoveOutcome.Moved && undo?.eventId == eventId) copy(undo = null) else this
|
||||
_outcome.value = _outcome.value?.withoutUndo()
|
||||
val rest = notices.map { it.withoutUndo() }
|
||||
notices.clear()
|
||||
notices.addAll(rest)
|
||||
}
|
||||
|
||||
private fun markWritten(eventId: Long, beginMillis: Long?) {
|
||||
written[eventId to beginMillis] = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
|
||||
private fun writtenRecently(eventId: Long, beginMillis: Long): Boolean {
|
||||
written.entries.removeAll { it.value.elapsedNow() > RECENT_WRITE }
|
||||
return (eventId to null) in written || (eventId to beginMillis) in written
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a completed move back where it came from. The outcome stands until the
|
||||
* inverse write reports back, so the one chip changes what it says rather
|
||||
* than closing and reopening. False when another write is already running —
|
||||
* the chip hides its action for that window, so this is the backstop.
|
||||
*
|
||||
* [abandoned] ticks once an inverse that *started* fails to land: [undoStarted]
|
||||
* has sent the chip back on its journey by then and nothing else will seat it.
|
||||
* A refusal ticks nothing — it launched no journey of its own, and the counter
|
||||
* is global, so a tick here would release some other drop's held copy before
|
||||
* the grid has drawn it.
|
||||
* the chip hides its action for that window, so this is the backstop. An
|
||||
* undo is a journey of its own: [undoStarted] names its token, and [drops]
|
||||
* reports whether it landed.
|
||||
*/
|
||||
fun undo(undo: MoveUndo): Boolean {
|
||||
if (busy) return false
|
||||
val token = nextMoveToken()
|
||||
_drops.update { it.started(token) }
|
||||
busy = true
|
||||
_undoStarted.value += 1
|
||||
_undoStarted.value = UndoStart(token, undo.dropToken)
|
||||
markWritten(undo.eventId, null)
|
||||
viewModelScope.launch {
|
||||
_outcome.value = try {
|
||||
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
|
||||
@@ -311,33 +423,40 @@ class RescheduleViewModel @Inject constructor(
|
||||
} catch (e: Exception) {
|
||||
MoveOutcome.Failed
|
||||
}
|
||||
// Read off the outcome, the way [write] does — no second copy of the
|
||||
// same fact to drift from it.
|
||||
if (_outcome.value !is MoveOutcome.Undone) _abandoned.value += 1
|
||||
busy = false
|
||||
settle(token, landed = _outcome.value is MoveOutcome.Undone)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Clear the outcome once the screen has shown it. */
|
||||
/** Clear the outcome once the screen has shown it, bringing on the next one waiting. */
|
||||
fun consumeOutcome() {
|
||||
_outcome.value = null
|
||||
_outcome.value = notices.removeFirstOrNull()
|
||||
}
|
||||
|
||||
private suspend fun prepare(request: MoveRequest): PreparedMove? {
|
||||
private suspend fun prepare(dropped: MoveRequest): PreparedMove? {
|
||||
val zone = zoneSource.current()
|
||||
val detail = try {
|
||||
repository.eventDetail(request.eventId)
|
||||
repository.eventDetail(dropped.eventId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: NoSuchEventException) {
|
||||
_outcome.value = MoveOutcome.Gone
|
||||
report(MoveOutcome.Gone)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
_outcome.value = MoveOutcome.Failed
|
||||
report(MoveOutcome.Failed)
|
||||
return null
|
||||
}
|
||||
// The drop carries the times the grid drew, which an earlier write may
|
||||
// have moved on since (#494).
|
||||
val request = if (detail.rrule == null || detail.isException) {
|
||||
followed(dropped, detail, zone)
|
||||
} else if (writtenRecently(dropped.eventId, dropped.beginMillis)) {
|
||||
report(MoveOutcome.Stale)
|
||||
return null
|
||||
} else {
|
||||
dropped
|
||||
}
|
||||
|
||||
val zone = zoneSource.current()
|
||||
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
|
||||
val shifted = when (val target = request.target) {
|
||||
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
||||
@@ -350,7 +469,7 @@ class RescheduleViewModel @Inject constructor(
|
||||
// The UNTIL check is deferred to the write, which knows how far the move
|
||||
// reaches and so which date has to clear it.
|
||||
if ((shifted.problems(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
||||
_outcome.value = MoveOutcome.Failed
|
||||
report(MoveOutcome.Failed)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -395,14 +514,14 @@ class RescheduleViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
|
||||
/** Write [prepared] in [scope]; true when it landed. */
|
||||
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
|
||||
val request = prepared.request
|
||||
if (endsBeforeItStarts(prepared, scope)) {
|
||||
_outcome.value = MoveOutcome.BlockedSeriesEnd
|
||||
_abandoned.value += 1
|
||||
return
|
||||
report(MoveOutcome.BlockedSeriesEnd)
|
||||
return false
|
||||
}
|
||||
_outcome.value = try {
|
||||
val outcome = try {
|
||||
when {
|
||||
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
|
||||
repository.updateEvent(request.eventId, prepared.original, prepared.updated)
|
||||
@@ -437,9 +556,37 @@ class RescheduleViewModel @Inject constructor(
|
||||
} catch (e: Exception) {
|
||||
MoveOutcome.Failed
|
||||
}
|
||||
// Nothing landed, so the copy the view is holding has nothing to fade
|
||||
// into — let it go now rather than at the end of the settle timeout.
|
||||
if (_outcome.value !is MoveOutcome.Moved) _abandoned.value += 1
|
||||
if (outcome is MoveOutcome.Moved) {
|
||||
markWritten(request.eventId, request.beginMillis.takeIf { scope == RecurringWriteScope.ThisEvent && prepared.isRecurring })
|
||||
}
|
||||
report(outcome, request.eventId)
|
||||
return outcome is MoveOutcome.Moved
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-off [dropped] rebased onto where its event now is, so the last drop
|
||||
* wins: absolute targets stand as they are, and a whole-day shift is re-aimed
|
||||
* at the day it was dropped on. Unchanged when the event hasn't moved.
|
||||
*/
|
||||
private fun followed(dropped: MoveRequest, detail: EventDetail, zone: TimeZone): MoveRequest {
|
||||
val now = detail.instance
|
||||
val target = when (val target = dropped.target) {
|
||||
is MoveTarget.ByDays -> {
|
||||
// Each day read the way its own kind anchors it: all-day at UTC.
|
||||
val aimed = Instant.fromEpochMilliseconds(dropped.beginMillis)
|
||||
.toLocalDateTime(if (dropped.isAllDay) TimeZone.UTC else zone).date
|
||||
.plus(target.days, DateTimeUnit.DAY)
|
||||
val from = now.start.toLocalDateTime(if (now.isAllDay) TimeZone.UTC else zone).date
|
||||
MoveTarget.ByDays((aimed.toEpochDays() - from.toEpochDays()).toInt())
|
||||
}
|
||||
else -> target
|
||||
}
|
||||
return dropped.copy(
|
||||
beginMillis = now.start.toEpochMilliseconds(),
|
||||
endMillis = now.end.toEpochMilliseconds(),
|
||||
target = target,
|
||||
isAllDay = now.isAllDay,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -506,6 +653,7 @@ class RescheduleViewModel @Inject constructor(
|
||||
eventId = prepared.request.eventId,
|
||||
moved = prepared.updated,
|
||||
restored = prepared.original,
|
||||
dropToken = prepared.request.token,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -60,7 +61,6 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
@@ -139,9 +139,17 @@ data class StripDrag(val event: EventInstance, val dayShift: Int = 0, val overGr
|
||||
sealed interface StripDrop {
|
||||
val event: EventInstance
|
||||
|
||||
data class Days(override val event: EventInstance, val days: Int) : StripDrop
|
||||
/** The drop's identity, which its held copy waits on. */
|
||||
val token: Long
|
||||
|
||||
data class Timed(override val event: EventInstance, val start: Instant, val end: Instant) : StripDrop
|
||||
data class Days(override val event: EventInstance, val days: Int, override val token: Long) : StripDrop
|
||||
|
||||
data class Timed(
|
||||
override val event: EventInstance,
|
||||
val start: Instant,
|
||||
val end: Instant,
|
||||
override val token: Long,
|
||||
) : StripDrop
|
||||
}
|
||||
|
||||
/** The event a long press left selected (#141). */
|
||||
@@ -297,7 +305,9 @@ data class TimelineDrop(
|
||||
val date: LocalDate,
|
||||
val startMin: Int,
|
||||
/** Minutes from the event's start to the dragged block's top edge. */
|
||||
val clipOffsetMin: Int = 0,
|
||||
val clipOffsetMin: Int,
|
||||
/** The drop's identity, which its held copy waits on. */
|
||||
val token: Long,
|
||||
)
|
||||
|
||||
/** The id a drafted block's stand-in event carries, which no provider row has (#153). */
|
||||
@@ -340,8 +350,37 @@ internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone):
|
||||
/** Which end of an event a resize handle moves (#141). */
|
||||
enum class ResizeEdge { Top, Bottom }
|
||||
|
||||
/** A drop parked by a newer pickup while its write still runs — see [TimelineDragController.held]. */
|
||||
data class HeldDrop(
|
||||
val copy: TimelineDrag,
|
||||
val token: Long,
|
||||
val instanceId: Long,
|
||||
/** The event row and start the drop came from, as [TimelineDragController.ghosts] matches them. */
|
||||
val origin: Pair<Long, Instant>,
|
||||
/** A resize's new end, which has to have landed too before the drop counts as seated. */
|
||||
val end: Instant?,
|
||||
/** Whether the grid now draws the drop at its landing slot. */
|
||||
val seated: Boolean = false,
|
||||
val handedOver: Boolean = false,
|
||||
) {
|
||||
fun ghosts(block: TimedBlock): Boolean = block.event.instanceId == instanceId ||
|
||||
(block.event.eventId == origin.first && block.event.start == origin.second)
|
||||
}
|
||||
|
||||
/** A strip drop parked by a newer one while its write still runs — see [TimelineDragController.heldStrips]. */
|
||||
data class HeldStrip(val drag: StripDrag, val token: Long, val seated: Boolean = false)
|
||||
|
||||
/** An event's new start and end, as a resize leaves them. */
|
||||
data class ResizedSpan(val start: Instant, val end: Instant)
|
||||
|
||||
/** Where a finished resize asks its event to begin and end. */
|
||||
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
|
||||
data class TimelineResizeDrop(
|
||||
val event: EventInstance,
|
||||
val start: Instant,
|
||||
val end: Instant,
|
||||
/** The drop's identity, which its held copy waits on. */
|
||||
val token: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s
|
||||
@@ -352,17 +391,16 @@ data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val
|
||||
* it was drawn (see [repicked]). An end takes a pass the grid draws at the drop
|
||||
* point, so the block settles under the finger.
|
||||
*/
|
||||
internal fun resizedDrop(
|
||||
internal fun resizedSpan(
|
||||
event: EventInstance,
|
||||
edge: ResizeEdge?,
|
||||
date: LocalDate,
|
||||
startMin: Int,
|
||||
spanMin: Int,
|
||||
zone: TimeZone,
|
||||
): TimelineResizeDrop {
|
||||
): ResizedSpan {
|
||||
val drawn = wallClockSpan(event, zone)
|
||||
return TimelineResizeDrop(
|
||||
event = event,
|
||||
return ResizedSpan(
|
||||
start = if (edge == ResizeEdge.Top) {
|
||||
event.start.toFormTime(zone).repicked(
|
||||
wallClockTime(date, startMin),
|
||||
@@ -513,6 +551,14 @@ class TimelineDragController {
|
||||
*/
|
||||
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
|
||||
|
||||
/**
|
||||
* Earlier drops still being written when another block was picked up. They
|
||||
* keep their copy and their ghost until their own token settles, rather than
|
||||
* vanishing and flashing back at the old slot (#494).
|
||||
*/
|
||||
var held: List<HeldDrop> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
/** How long a block drafted on an empty slot starts out, in minutes (#153). */
|
||||
var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION
|
||||
|
||||
@@ -611,14 +657,8 @@ class TimelineDragController {
|
||||
var settlingSelected: Boolean = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* The abandoned-move counter, read at the drop so the mark can't be a
|
||||
* composition behind the tick it is compared against.
|
||||
*/
|
||||
var abandonedTicks: StateFlow<Int>? = null
|
||||
|
||||
/** What [abandonedTicks] stood at when the settling drop was let go. */
|
||||
var settledAbandonedMark: Int = 0
|
||||
/** The token of the drop [settling] waits on. */
|
||||
var settledToken: Long = 0L
|
||||
private set
|
||||
|
||||
private var source: TimedBlock? = null
|
||||
@@ -661,8 +701,12 @@ class TimelineDragController {
|
||||
var stripSeated: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** What [abandonedTicks] stood at when [stripSettling] was let go. */
|
||||
var stripAbandonedMark: Int = 0
|
||||
/** Strip drops parked by a newer strip drop while their writes still run (#494). */
|
||||
var heldStrips: List<HeldStrip> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
/** The token of the drop [stripSettling] waits on. */
|
||||
var stripToken: Long = 0L
|
||||
private set
|
||||
|
||||
/** The shown day the finger picked the strip bar up on. */
|
||||
@@ -687,7 +731,7 @@ class TimelineDragController {
|
||||
dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO)
|
||||
dragKept = zone.repeatedHourOffsetAt(block.event.start)
|
||||
this.titleLines = titleLines
|
||||
settling = null
|
||||
parkSettling()
|
||||
isDragging = true
|
||||
liftedInstanceId = block.event.instanceId
|
||||
grab = pointerInRoot - blockInRoot
|
||||
@@ -726,7 +770,7 @@ class TimelineDragController {
|
||||
dragDuration = draftMinutes.minutes
|
||||
dragKept = null
|
||||
titleLines = 1
|
||||
settling = null
|
||||
parkSettling()
|
||||
isDragging = true
|
||||
liftedInstanceId = event.instanceId
|
||||
grab = Offset(0f, geometry.hourPx * STRIP_GRAB_MINUTES / 60f)
|
||||
@@ -749,16 +793,18 @@ class TimelineDragController {
|
||||
val zone = dragZone
|
||||
cancel()
|
||||
if (held.overGrid && landed != null && zone != null && !landed.edgeOnly) {
|
||||
settle(landed)
|
||||
val start = TimelineDrop(landed.event, landed.date, landed.startMin).startInstant(zone)
|
||||
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes)
|
||||
val token = settle(landed)
|
||||
val start = dropStartInstant(landed.event, landed.date, landed.startMin, clipOffsetMin = 0, zone)
|
||||
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes, token)
|
||||
}
|
||||
if (held.overGrid || held.dayShift == 0) return null
|
||||
// One still settling keeps its place on the strip until its own write lands (#494).
|
||||
stripSettling?.let { heldStrips = heldStrips + HeldStrip(it, stripToken, stripSeated) }
|
||||
stripSettling = held
|
||||
stripSeated = false
|
||||
stripAbandonedMark = abandonedTicks?.value ?: 0
|
||||
stripToken = nextMoveToken()
|
||||
drops++
|
||||
return StripDrop.Days(held.event, held.dayShift)
|
||||
return StripDrop.Days(held.event, held.dayShift, stripToken)
|
||||
}
|
||||
|
||||
/** Let the held strip drop go, once the strip shows the event at its new days or the move failed. */
|
||||
@@ -772,6 +818,21 @@ class TimelineDragController {
|
||||
if (stripSettling != null) stripSeated = true
|
||||
}
|
||||
|
||||
/** Note that the strip draws the parked strip drop [token] at its new days. */
|
||||
fun seatHeldStrip(token: Long) {
|
||||
if (heldStrips.any { it.token == token && !it.seated }) {
|
||||
heldStrips = heldStrips.map { if (it.token == token) it.copy(seated = true) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Let the parked strip drop [token] go. */
|
||||
fun releaseHeldStrip(token: Long) {
|
||||
heldStrips = heldStrips.filterNot { it.token == token }
|
||||
}
|
||||
|
||||
/** Every strip drop still shown at its new days: the parked ones, then the latest. */
|
||||
fun stripPreviews(): List<StripDrag> = heldStrips.map { it.drag } + listOfNotNull(stripDrag ?: stripSettling)
|
||||
|
||||
fun move(pointerInRoot: Offset) {
|
||||
pointer = pointerInRoot
|
||||
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
|
||||
@@ -806,12 +867,36 @@ class TimelineDragController {
|
||||
* an event that crosses midnight (#253).
|
||||
*/
|
||||
fun ghosts(block: TimedBlock): Boolean {
|
||||
if (held.any { !it.handedOver && it.ghosts(block) }) return true
|
||||
if (liftedInstanceId == null) return false
|
||||
if (block.event.instanceId == liftedInstanceId) return true
|
||||
val (eventId, start) = settledOrigin ?: return false
|
||||
return block.event.eventId == eventId && block.event.start == start
|
||||
}
|
||||
|
||||
/** Move a still-settling drop to [held] before a new pickup takes the slot. */
|
||||
private fun parkSettling() {
|
||||
val landed = settling
|
||||
val origin = settledOrigin
|
||||
if (landed != null && origin != null && !landed.event.isDraft) {
|
||||
held = held + HeldDrop(landed, settledToken, landed.event.instanceId, origin, settledEnd, settledOnGrid)
|
||||
}
|
||||
settling = null
|
||||
settledOnGrid = false
|
||||
settledOrigin = null
|
||||
settledEnd = null
|
||||
}
|
||||
|
||||
/** Stop ghosting the held drop [token]'s source while its copy fades. */
|
||||
fun handOverHeld(token: Long) {
|
||||
held = held.map { if (it.token == token) it.copy(handedOver = true) else it }
|
||||
}
|
||||
|
||||
/** Drop the held copy [token] once it has faded. */
|
||||
fun releaseHeld(token: Long) {
|
||||
held = held.filterNot { it.token == token }
|
||||
}
|
||||
|
||||
/**
|
||||
* What a day column now holds, so a settled drop can tell when the grid has
|
||||
* caught up with it. Matched on the landing slot plus either the event row
|
||||
@@ -819,16 +904,23 @@ class TimelineDragController {
|
||||
* blank title matches nothing: every untitled event has one (#253).
|
||||
*/
|
||||
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
|
||||
if (held.any { !it.seated && drawsDrop(it.copy, it.end, date, blocks) }) {
|
||||
held = held.map { if (!it.seated && drawsDrop(it.copy, it.end, date, blocks)) it.copy(seated = true) else it }
|
||||
}
|
||||
val landed = settling ?: return
|
||||
if (settledOnGrid) return
|
||||
settledOnGrid = drawsDrop(landed, settledEnd, date, blocks)
|
||||
}
|
||||
|
||||
/** Whether [blocks], [date]'s column, draws the drop [landed] where it landed. */
|
||||
private fun drawsDrop(landed: TimelineDrag, end: Instant?, date: LocalDate, blocks: List<TimedBlock>): Boolean {
|
||||
// Where this day would draw it, which for a drop clipped at midnight is
|
||||
// the day's own start rather than the edge the finger held (#253) — and
|
||||
// which for a tail dragged clear of its day is the day before it.
|
||||
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
|
||||
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
|
||||
.firstOrNull() ?: return
|
||||
val end = settledEnd
|
||||
settledOnGrid = blocks.any { block ->
|
||||
.firstOrNull() ?: return false
|
||||
return blocks.any { block ->
|
||||
block.startMin == slice.startMin &&
|
||||
// A resize may keep its start, so its end has to have landed too.
|
||||
(end == null || block.event.end == end) &&
|
||||
@@ -855,12 +947,15 @@ class TimelineDragController {
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
// A draft dropped on a day off screen would vanish, so it stays where it was (#153).
|
||||
if (landed.edgeOnly && landed.event.isDraft) return null
|
||||
settle(landed)
|
||||
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
|
||||
val token = settle(landed)
|
||||
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset, token)
|
||||
}
|
||||
|
||||
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
|
||||
private fun settle(landed: TimelineDrag, end: Instant? = null) {
|
||||
/**
|
||||
* Keep [landed]'s copy on the target as [settling] until [release]; [end] is a
|
||||
* resize's. Returns the drop's fresh token.
|
||||
*/
|
||||
private fun settle(landed: TimelineDrag, end: Instant? = null): Long {
|
||||
// Landed on midnight to midnight, the event moves to the all-day strip (#296).
|
||||
val leavesGrid = !landed.event.isDraft && landed.eventSpanMin > 0 &&
|
||||
landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 &&
|
||||
@@ -873,9 +968,10 @@ class TimelineDragController {
|
||||
settling = if (landed.edgeOnly || leavesGrid) landed.copy(pieces = emptyList()) else landed
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = landed.event.eventId to landed.event.start
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
settledToken = nextMoveToken()
|
||||
settledEnd = end
|
||||
drops++
|
||||
return settledToken
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -895,7 +991,7 @@ class TimelineDragController {
|
||||
resize = session
|
||||
clipOffsetMin = 0
|
||||
this.titleLines = titleLines
|
||||
settling = null
|
||||
parkSettling()
|
||||
isDragging = true
|
||||
pointerMoved = true
|
||||
liftedInstanceId = block.event.instanceId
|
||||
@@ -926,10 +1022,10 @@ class TimelineDragController {
|
||||
val event = source?.event ?: return null
|
||||
val edge = resize?.edge
|
||||
if (edge != null) {
|
||||
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone)
|
||||
val drop = resizedSpan(event, edge, date, startMin, spanMin, zone)
|
||||
return event.copy(start = drop.start, end = drop.end)
|
||||
}
|
||||
val start = TimelineDrop(event, date, startMin, clipOffsetMin).startInstant(zone)
|
||||
val start = dropStartInstant(event, date, startMin, clipOffsetMin, zone)
|
||||
return event.copy(start = start, end = start + dragDuration)
|
||||
}
|
||||
|
||||
@@ -943,9 +1039,8 @@ class TimelineDragController {
|
||||
val edge = resize?.edge
|
||||
cancel()
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
|
||||
settle(landed, end = drop.end)
|
||||
return drop
|
||||
val span = resizedSpan(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
|
||||
return TimelineResizeDrop(landed.event, span.start, span.end, settle(landed, end = span.end))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1199,7 +1294,6 @@ fun rememberTimelineDragController(): TimelineDragController {
|
||||
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
||||
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
||||
controller.slopPx = LocalViewConfiguration.current.touchSlop
|
||||
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
||||
return controller
|
||||
}
|
||||
|
||||
@@ -1229,7 +1323,16 @@ fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
|
||||
* visibly settles there.
|
||||
*/
|
||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||
wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
|
||||
dropStartInstant(event, date, startMin, clipOffsetMin, zone)
|
||||
|
||||
/** Where [event] starts when its block's top edge lands [startMin] past [date]'s midnight — see [TimelineDrop]. */
|
||||
internal fun dropStartInstant(
|
||||
event: EventInstance,
|
||||
date: LocalDate,
|
||||
startMin: Int,
|
||||
clipOffsetMin: Int,
|
||||
zone: TimeZone,
|
||||
): Instant = wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
|
||||
|
||||
/**
|
||||
* The wall-clock minutes an event lasting [duration] covers when it starts
|
||||
@@ -1301,6 +1404,7 @@ private const val SETTLE_TIMEOUT_MILLIS = 900L
|
||||
*/
|
||||
const val SETTLE_FADE_MILLIS: Int = 250
|
||||
|
||||
|
||||
/**
|
||||
* The floating block, drawn over the whole calendar so it is free of the day
|
||||
* column's clip and of the scroll viewport's rounded corners.
|
||||
@@ -1308,12 +1412,10 @@ const val SETTLE_FADE_MILLIS: Int = 250
|
||||
@Composable
|
||||
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val density = LocalDensity.current
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val moveInFlight = moveInFlight()
|
||||
val drops = dropStates()
|
||||
val moveInFlight = controller.settledToken in drops.pending
|
||||
val stripInFlight = controller.stripToken in drops.pending
|
||||
|
||||
// Both live here rather than beside the controller: they read [drag], which
|
||||
// changes every frame, and this composable is the one that is meant to.
|
||||
@@ -1327,12 +1429,11 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||
// drop itself.
|
||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||
val abandoned = abandonedMoves()
|
||||
val abandoned = controller.settledToken in drops.abandoned
|
||||
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) {
|
||||
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
||||
// Past the mark this drop was let go on, it was abandoned: nothing will
|
||||
// land and there is nothing to wait for.
|
||||
if (abandoned == controller.settledAbandonedMark) {
|
||||
// An abandoned drop: nothing will land and there is nothing to wait for.
|
||||
if (!abandoned) {
|
||||
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||
}
|
||||
handingOver = true
|
||||
@@ -1342,9 +1443,10 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
}
|
||||
|
||||
// The same hold for a bar moved along the all-day strip.
|
||||
LaunchedEffect(controller.stripSettling, moveInFlight, controller.stripSeated, abandoned) {
|
||||
if (controller.stripSettling == null || moveInFlight) return@LaunchedEffect
|
||||
if (abandoned == controller.stripAbandonedMark) {
|
||||
val stripAbandoned = controller.stripToken in drops.abandoned
|
||||
LaunchedEffect(controller.stripSettling, stripInFlight, controller.stripSeated, stripAbandoned) {
|
||||
if (controller.stripSettling == null || stripInFlight) return@LaunchedEffect
|
||||
if (!stripAbandoned) {
|
||||
delay(if (controller.stripSeated) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||
}
|
||||
controller.releaseStrip()
|
||||
@@ -1358,6 +1460,12 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||
) {
|
||||
controller.held.forEach { parked ->
|
||||
key(parked.token) { HeldTimelineCopy(controller, parked, origin) }
|
||||
}
|
||||
controller.heldStrips.forEach { parked ->
|
||||
key(parked.token) { HeldStripSettle(controller, parked) }
|
||||
}
|
||||
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||
val landed = controller.drag == null
|
||||
@@ -1371,6 +1479,58 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "drag-handover",
|
||||
)
|
||||
TimelineCopy(drag, controller.geometry, origin, lift, copyAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [TimelineDragController.ghosts] for [block], derived so the block recomposes
|
||||
* only when its own answer changes rather than on every lift, park and release.
|
||||
*/
|
||||
@Composable
|
||||
fun TimelineDragController.ghostsDerived(block: TimedBlock): Boolean {
|
||||
val ghosted by remember(this, block) { derivedStateOf { ghosts(block) } }
|
||||
return ghosted
|
||||
}
|
||||
|
||||
/** A drop parked by a newer pickup: held at its landing slot until its own write settles. */
|
||||
@Composable
|
||||
private fun HeldTimelineCopy(controller: TimelineDragController, parked: HeldDrop, origin: Offset) {
|
||||
val alpha = rememberHeldSettle(
|
||||
token = parked.token,
|
||||
seated = parked.seated,
|
||||
timeoutMillis = SETTLE_TIMEOUT_MILLIS,
|
||||
onHandOver = { controller.handOverHeld(parked.token) },
|
||||
onRelease = { controller.releaseHeld(parked.token) },
|
||||
)
|
||||
TimelineCopy(parked.copy, controller.geometry, origin, lift = 0f, copyAlpha = alpha)
|
||||
}
|
||||
|
||||
/** A strip drop parked by a newer one: shown at its new days until its own write settles. */
|
||||
@Composable
|
||||
private fun HeldStripSettle(controller: TimelineDragController, parked: HeldStrip) {
|
||||
rememberHeldSettle(
|
||||
token = parked.token,
|
||||
seated = parked.seated,
|
||||
timeoutMillis = SETTLE_TIMEOUT_MILLIS,
|
||||
onHandOver = {},
|
||||
onRelease = { controller.releaseHeldStrip(parked.token) },
|
||||
)
|
||||
}
|
||||
|
||||
/** [drag]'s pieces drawn over the calendar, clipped to the timeline's viewport. */
|
||||
@Composable
|
||||
private fun TimelineCopy(
|
||||
drag: TimelineDrag,
|
||||
geometry: TimelineGeometry,
|
||||
origin: Offset,
|
||||
lift: Float,
|
||||
copyAlpha: Float,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val paint = eventPaint(drag.event, dark)
|
||||
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
// Read off the event rather than the held block, so grabbing either half
|
||||
@@ -1386,7 +1546,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
// so no column clip or rounded corner cuts it, but it still belongs to
|
||||
// the timeline: a piece a whole day tall reaches far past both ends of
|
||||
// the viewport, and unclipped it paints over the headers above (#267).
|
||||
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
|
||||
val port = geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
|
||||
// How much of each piece the viewport actually shows. A multi-day event
|
||||
// has a piece per day, and the widest one is a full 24 hours — taller
|
||||
// than the screen, its top at a midnight scrolled out of sight. Picking
|
||||
@@ -1438,7 +1598,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One piece of a dragged block, floating over the calendar. */
|
||||
|
||||
@@ -14,8 +14,7 @@ import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import de.jeanlucmakiola.calendula.ui.common.nextMoveToken
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlin.math.abs
|
||||
|
||||
@@ -107,11 +106,22 @@ data class MonthChipDrag(
|
||||
val time: String? = null,
|
||||
)
|
||||
|
||||
/** A drop parked by a newer pickup while its write still runs — see [MonthDragController.held]. */
|
||||
data class HeldChip(
|
||||
val copy: MonthChipDrag,
|
||||
val token: Long,
|
||||
val handedOver: Boolean = false,
|
||||
/** Where the grid now draws the drop, in root coordinates, once a row seats it. */
|
||||
val seat: Offset? = null,
|
||||
)
|
||||
|
||||
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
||||
data class MonthChipDrop(
|
||||
val event: EventInstance,
|
||||
val grabDate: LocalDate,
|
||||
val targetDate: LocalDate,
|
||||
/** The drop's identity, which its held copy waits on — see [MonthDragController.settledToken]. */
|
||||
val token: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -167,19 +177,22 @@ class MonthDragController {
|
||||
private set
|
||||
|
||||
/**
|
||||
* The last drop this controller made, kept past [release] for [beginUndo] —
|
||||
* the confirmation chip carries Undo long after the copy is gone.
|
||||
* This controller's recent drops by token, kept past [release] for
|
||||
* [beginUndo] — the confirmation chip carries Undo long after the copy is
|
||||
* gone, and it may be any of them the chip offers to undo.
|
||||
*/
|
||||
private var undoable: MonthChipDrag? = null
|
||||
private val undoable = LinkedHashMap<Long, MonthChipDrag>()
|
||||
|
||||
/**
|
||||
* The abandoned-move counter, read at the drop so the mark can't be a
|
||||
* composition behind the tick it is compared against.
|
||||
* Earlier drops still being written when another chip was picked up. They
|
||||
* keep their copy and their ghost until their own token settles, rather than
|
||||
* vanishing and flashing back on the old day (#494).
|
||||
*/
|
||||
var abandonedTicks: StateFlow<Int>? = null
|
||||
var held: List<HeldChip> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
/** What [abandonedTicks] stood at when the settling chip was let go. */
|
||||
var settledAbandonedMark: Int = 0
|
||||
/** The token of the drop (or undo) [settling] is waiting on. */
|
||||
var settledToken: Long = 0L
|
||||
private set
|
||||
|
||||
private var event: EventInstance? = null
|
||||
@@ -208,7 +221,7 @@ class MonthDragController {
|
||||
this.event = event
|
||||
this.grabDate = grabDate
|
||||
this.time = time
|
||||
settling = null
|
||||
parkSettling()
|
||||
isDragging = true
|
||||
liftedInstanceId = event.instanceId
|
||||
grab = pointerInRoot - chipInRoot
|
||||
@@ -234,6 +247,39 @@ class MonthDragController {
|
||||
settledGhost = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [event]'s chip belongs to a held drop and must stay a ghost: its
|
||||
* source, or once a row seats it, the chip the grid draws on its target day.
|
||||
*/
|
||||
fun isHeldChip(event: EventInstance, days: List<LocalDate>?): Boolean = held.any { parked ->
|
||||
!parked.handedOver && (
|
||||
parked.copy.event.instanceId == event.instanceId ||
|
||||
(
|
||||
parked.seat != null && days != null && parked.copy.targetDate in days &&
|
||||
isSameEvent(event, parked.copy.event)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Move a still-settling drop to [held] before a new pickup takes the slot. */
|
||||
private fun parkSettling() {
|
||||
val landed = settling
|
||||
if (landed != null) held = held + HeldChip(landed, settledToken)
|
||||
settling = null
|
||||
settledInRoot = null
|
||||
settledGhost = false
|
||||
}
|
||||
|
||||
/** Stop ghosting the held drop [token]'s source while its copy fades. */
|
||||
fun handOverHeld(token: Long) {
|
||||
held = held.map { if (it.token == token) it.copy(handedOver = true) else it }
|
||||
}
|
||||
|
||||
/** Drop the held copy [token] once it has faded. */
|
||||
fun releaseHeld(token: Long) {
|
||||
held = held.filterNot { it.token == token }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [days] holds the chip the grid has drawn for the settled drop —
|
||||
* the seat the copy is on its way to, which must ghost until it gets there.
|
||||
@@ -250,6 +296,13 @@ class MonthDragController {
|
||||
* nearest where the chip was let go is the one the finger was over.
|
||||
*/
|
||||
fun noteSettled(token: Any) {
|
||||
val row = rows[token]
|
||||
if (row != null && held.any { it.seat == null }) {
|
||||
held = held.map { parked ->
|
||||
val target = parked.copy.targetDate
|
||||
if (parked.seat != null || target == null) parked else parked.copy(seat = row.seatOf(parked.copy.event, target))
|
||||
}
|
||||
}
|
||||
val landed = settling ?: return
|
||||
val target = landed.targetDate ?: return
|
||||
val at = rows[token]?.seatOf(landed.event, target) ?: return
|
||||
@@ -265,16 +318,16 @@ class MonthDragController {
|
||||
* there is no drop of this controller's left to undo, or the grid doesn't
|
||||
* seat the moved event where the chip would have to start from.
|
||||
*/
|
||||
fun beginUndo(): Boolean {
|
||||
val last = undoable ?: return false
|
||||
undoable = null
|
||||
fun beginUndo(token: Long, dropToken: Long): Boolean {
|
||||
val last = undoable.remove(dropToken) ?: return false
|
||||
parkSettling()
|
||||
val from = last.targetDate ?: return false
|
||||
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
|
||||
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
|
||||
liftedInstanceId = last.event.instanceId
|
||||
settledGhost = true
|
||||
settledInRoot = null
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
settledToken = token
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -294,10 +347,11 @@ class MonthDragController {
|
||||
)
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledGhost = true
|
||||
undoable = settling
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
settledToken = nextMoveToken()
|
||||
undoable[settledToken] = requireNotNull(settling)
|
||||
if (undoable.size > UNDOABLE_KEPT) undoable.remove(undoable.keys.first())
|
||||
drops++
|
||||
return MonthChipDrop(landed.event, landed.grabDate, target)
|
||||
return MonthChipDrop(landed.event, landed.grabDate, target, settledToken)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -355,9 +409,10 @@ class MonthDragController {
|
||||
|
||||
@Composable
|
||||
fun rememberMonthDragController(): MonthDragController {
|
||||
val controller = remember { MonthDragController() }
|
||||
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
||||
return controller
|
||||
return remember { MonthDragController() }
|
||||
}
|
||||
|
||||
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
|
||||
|
||||
/** How many recent drops a month controller keeps for an undo to carry back. */
|
||||
private const val UNDOABLE_KEPT = 8
|
||||
@@ -125,11 +125,11 @@ import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
|
||||
import de.jeanlucmakiola.calendula.ui.common.abandonedMoves
|
||||
import de.jeanlucmakiola.calendula.ui.common.dropStates
|
||||
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberHeldSettle
|
||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
|
||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
@@ -515,7 +515,7 @@ fun MonthScreen(
|
||||
val chipDrag = rememberMonthDragController()
|
||||
// Undo moves the event back, so the chip travels back too. The
|
||||
// signal comes from the write, not from the chip that offers it.
|
||||
OnUndoStarted { chipDrag.beginUndo() }
|
||||
OnUndoStarted { start -> chipDrag.beginUndo(start.token, start.dropToken) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
@@ -591,11 +591,11 @@ private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
|
||||
@Composable
|
||||
private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Boolean) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val density = LocalDensity.current
|
||||
val timeFit = rememberMonthChipTimeFit(showStartTimes)
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val moveInFlight = moveInFlight()
|
||||
val drops = dropStates()
|
||||
val moveInFlight = controller.settledToken in drops.pending
|
||||
val abandoned = controller.settledToken in drops.abandoned
|
||||
// Here rather than beside the controller: this reads [drag], which changes
|
||||
// every frame, and this composable is the one that is meant to.
|
||||
DragSnapHaptics(controller.drag?.targetDate, controller.drops)
|
||||
@@ -608,14 +608,12 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
|
||||
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||
val seat = controller.settledInRoot
|
||||
val abandoned = abandonedMoves()
|
||||
LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) {
|
||||
val landed = controller.settling
|
||||
if (landed == null || moveInFlight) return@LaunchedEffect
|
||||
if (seat == null) {
|
||||
// Past the mark this drop was let go on, it was abandoned and no
|
||||
// seat is ever coming.
|
||||
if (abandoned == controller.settledAbandonedMark) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||
// An abandoned drop has no seat coming.
|
||||
if (!abandoned) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||
} else {
|
||||
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
||||
gliding = true
|
||||
@@ -635,6 +633,9 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||
) {
|
||||
controller.held.forEach { parked ->
|
||||
key(parked.token) { HeldMonthChip(controller, parked, origin, timeFit) }
|
||||
}
|
||||
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||
val lift by animateFloatAsState(
|
||||
@@ -646,42 +647,93 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "chip-handover",
|
||||
)
|
||||
MonthBar(
|
||||
event = drag.event,
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
// The time the source chip carried, drawn only if this one-column
|
||||
// cut-out of it has the room — which a slice of a multi-day bar has
|
||||
// not, however wide the bar was.
|
||||
time = drag.time,
|
||||
MonthChipCopy(
|
||||
drag = drag,
|
||||
// Read at layout, so the glide doesn't recompose every frame.
|
||||
at = { if (gliding) glide.value else drag.topLeftInRoot },
|
||||
origin = origin,
|
||||
lift = lift,
|
||||
alpha = copyAlpha,
|
||||
timeFit = timeFit,
|
||||
chipWidth = with(density) { drag.sizePx.width.toDp() },
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
.absoluteOffset {
|
||||
val at = if (gliding) glide.value else drag.topLeftInRoot
|
||||
IntOffset(
|
||||
(at.x - origin.x).roundToInt(),
|
||||
(at.y - origin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.width(with(density) { drag.sizePx.width.toDp() })
|
||||
.height(with(density) { drag.sizePx.height.toDp() })
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.04f * lift
|
||||
scaleY = 1f + 0.04f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
alpha = copyAlpha
|
||||
shape = EventChipShape
|
||||
clip = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A drop parked by a newer pickup: held on its landing day until its own write settles. */
|
||||
@Composable
|
||||
private fun HeldMonthChip(
|
||||
controller: MonthDragController,
|
||||
parked: HeldChip,
|
||||
origin: Offset,
|
||||
timeFit: MonthChipTimeFit?,
|
||||
) {
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val glide = remember { Animatable(parked.copy.topLeftInRoot, Offset.VectorConverter) }
|
||||
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||
val seat = parked.seat
|
||||
val landed = parked.token !in dropStates().pending
|
||||
// Carried onto its seat once the write is in, as the settling chip is.
|
||||
LaunchedEffect(seat, landed) {
|
||||
if (seat == null || !landed) return@LaunchedEffect
|
||||
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
|
||||
}
|
||||
val alpha = rememberHeldSettle(
|
||||
token = parked.token,
|
||||
seated = seat != null,
|
||||
timeoutMillis = MONTH_SETTLE_TIMEOUT_MILLIS,
|
||||
onHandOver = { controller.handOverHeld(parked.token) },
|
||||
onRelease = { controller.releaseHeld(parked.token) },
|
||||
)
|
||||
MonthChipCopy(parked.copy, { glide.value }, origin, lift = 0f, alpha = alpha, timeFit = timeFit)
|
||||
}
|
||||
|
||||
/** [drag]'s chip floating over the grid with its top left at [at], in root coordinates. */
|
||||
@Composable
|
||||
private fun MonthChipCopy(
|
||||
drag: MonthChipDrag,
|
||||
at: () -> Offset,
|
||||
origin: Offset,
|
||||
lift: Float,
|
||||
alpha: Float,
|
||||
timeFit: MonthChipTimeFit?,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
MonthBar(
|
||||
event = drag.event,
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
// The time the source chip carried, drawn only if this one-column
|
||||
// cut-out of it has the room — which a slice of a multi-day bar has
|
||||
// not, however wide the bar was.
|
||||
time = drag.time,
|
||||
timeFit = timeFit,
|
||||
chipWidth = with(density) { drag.sizePx.width.toDp() },
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
.absoluteOffset {
|
||||
val topLeft = at()
|
||||
IntOffset(
|
||||
(topLeft.x - origin.x).roundToInt(),
|
||||
(topLeft.y - origin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.width(with(density) { drag.sizePx.width.toDp() })
|
||||
.height(with(density) { drag.sizePx.height.toDp() })
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.04f * lift
|
||||
scaleY = 1f + 0.04f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
this.alpha = alpha
|
||||
shape = EventChipShape
|
||||
clip = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Content for both scrolling styles. No swipe detector and no [AnimatedContent]:
|
||||
* vertical scrolling owns the gesture, and the list never swaps wholesale — it
|
||||
@@ -2258,7 +2310,7 @@ private fun MonthWeekRow(
|
||||
// Once the grid holds the settled chip, tell the controller where this row
|
||||
// has seated it, so the copy in flight can glide onto it. Keyed on the week,
|
||||
// which is what changes when the re-read lands.
|
||||
LaunchedEffect(week, dragController?.settling) {
|
||||
LaunchedEffect(week, dragController?.settling, dragController?.held) {
|
||||
dragController?.noteSettled(rowToken)
|
||||
}
|
||||
|
||||
@@ -2691,6 +2743,8 @@ private fun monthChipDragModifier(
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.ByDays(delta),
|
||||
isAllDay = drop.event.isAllDay,
|
||||
token = drop.token,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -2836,8 +2890,17 @@ private fun MonthBar(
|
||||
val moveAction = eventMoveAction(event)
|
||||
// The source stays put as a ghost while its floating copy travels.
|
||||
val monthDrag = LocalMonthDrag.current
|
||||
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
||||
monthDrag?.isSettledChip(event, days) == true
|
||||
// Derived, so a chip recomposes only when its own answer changes rather than
|
||||
// on every lift, park and release anywhere in the grid.
|
||||
val lifted by remember(monthDrag, event, days) {
|
||||
derivedStateOf {
|
||||
monthDrag != null && (
|
||||
monthDrag.liftedInstanceId == event.instanceId ||
|
||||
monthDrag.isSettledChip(event, days) ||
|
||||
monthDrag.isHeldChip(event, days)
|
||||
)
|
||||
}
|
||||
}
|
||||
val ghost = ghostAlpha(lifted)
|
||||
val shape = monthBarShape(continuesLeft, continuesRight)
|
||||
Box(
|
||||
|
||||
+275
-40
@@ -6,22 +6,35 @@ import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.provider.Settings.System.DEFAULT_NOTIFICATION_URI
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -29,8 +42,11 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
@@ -39,10 +55,14 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.android.notificationSoundPickerIntent
|
||||
import de.jeanlucmakiola.floret.android.openNotificationSettings
|
||||
import de.jeanlucmakiola.floret.android.pickedSound
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.ExpandIcon
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
|
||||
import de.jeanlucmakiola.floret.components.IconInfoCard
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
@@ -50,6 +70,7 @@ import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalTime
|
||||
|
||||
/**
|
||||
@@ -86,6 +107,63 @@ internal fun NotificationsScreen(
|
||||
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||
var calendarSectionExpanded by remember { mutableStateOf(false) }
|
||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val channelStatus by viewModel.channelStatus.collectAsStateWithLifecycle()
|
||||
val calendarIds = state.reminderCalendars.map { it.id }
|
||||
LifecycleResumeEffect(calendarIds) {
|
||||
viewModel.refreshChannelStatus(calendarIds)
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
val openCalendarSettings: (Long) -> Unit = { calendarId ->
|
||||
scope.launch { context.openNotificationSettings(viewModel.soundSettingsChannel(calendarId)) }
|
||||
}
|
||||
val openAppSettings: () -> Unit = {
|
||||
scope.launch {
|
||||
viewModel.syncReminderChannels()
|
||||
context.openNotificationSettings()
|
||||
}
|
||||
}
|
||||
val loadedSounds by viewModel.channelSounds.collectAsStateWithLifecycle()
|
||||
val sounds = loadedSounds.orEmpty()
|
||||
val soundFailedMessage = stringResource(R.string.settings_notification_sound_failed)
|
||||
val soundUnreadableMessage = stringResource(R.string.settings_notification_sound_unreadable)
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.soundFailed.collect { failure ->
|
||||
val message = if (failure == SoundFailure.Unreadable) soundUnreadableMessage else soundFailedMessage
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
// ALL_CALENDARS, a calendar id, or null when no pick is pending.
|
||||
var soundTarget by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
val soundPicker = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val target = soundTarget
|
||||
soundTarget = null
|
||||
val picked = pickedSound(result.resultCode, result.data) ?: return@rememberLauncherForActivityResult
|
||||
when (target) {
|
||||
null -> Unit
|
||||
ALL_CALENDARS -> viewModel.setReminderSoundForAll(picked.uri)
|
||||
else -> viewModel.setReminderSound(target, picked.uri)
|
||||
}
|
||||
}
|
||||
val pickSoundTitle = stringResource(R.string.settings_notification_sound)
|
||||
val pickSound: (Long) -> Unit = pick@{ target ->
|
||||
if (soundTarget != null) return@pick
|
||||
val current = if (target == ALL_CALENDARS) {
|
||||
(sharedSound(sounds, calendarIds) as? SharedSound.Same)?.sound
|
||||
} else {
|
||||
sounds[target]
|
||||
}
|
||||
soundTarget = target
|
||||
try {
|
||||
// A missing or mixed sound preselects Default; a silent one, Silent.
|
||||
val preselect = if (current == null) DEFAULT_NOTIFICATION_URI else current.uri
|
||||
soundPicker.launch(notificationSoundPickerIntent(preselect, pickSoundTitle))
|
||||
} catch (e: RuntimeException) {
|
||||
// No picker, or one that refuses to start: the system screen still has the sound.
|
||||
soundTarget = null
|
||||
if (target == ALL_CALENDARS) openAppSettings() else openCalendarSettings(target)
|
||||
}
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_notifications),
|
||||
@@ -93,6 +171,16 @@ internal fun NotificationsScreen(
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
if (IS_SAMSUNG && !state.notificationCategoryHintDismissed) {
|
||||
NotificationCategoryHint(
|
||||
onOpen = {
|
||||
viewModel.dismissNotificationCategoryHint()
|
||||
openAppSettings()
|
||||
},
|
||||
onDismiss = viewModel::dismissNotificationCategoryHint,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_reminders),
|
||||
checked = state.remindersEnabled,
|
||||
@@ -100,6 +188,25 @@ internal fun NotificationsScreen(
|
||||
summary = stringResource(R.string.settings_reminders_hint),
|
||||
position = Position.Top,
|
||||
)
|
||||
if (calendarIds.isNotEmpty()) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
summary = loadedSounds?.let { globalSoundLabel(it, calendarIds) },
|
||||
position = Position.Middle,
|
||||
onClick = { pickSound(ALL_CALENDARS) },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_more),
|
||||
summary = if (channelStatus.appBlocked) {
|
||||
stringResource(R.string.settings_notifications_blocked_hint)
|
||||
} else {
|
||||
stringResource(R.string.settings_notification_sound_hint)
|
||||
},
|
||||
position = Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = openAppSettings,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = reminderChoiceLabel(state.defaultReminderMinutes),
|
||||
@@ -149,10 +256,8 @@ internal fun NotificationsScreen(
|
||||
onClick = { showSnooze = true },
|
||||
)
|
||||
|
||||
// Per-calendar overrides, folded behind one header. Each writable
|
||||
// calendar may keep, drop or replace the global default, separately for
|
||||
// timed and all-day events.
|
||||
if (state.writableCalendars.isNotEmpty()) {
|
||||
// Per-calendar notifications and reminder overrides (#145, #272).
|
||||
if (state.reminderCalendars.isNotEmpty()) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_calendar_reminders_title),
|
||||
@@ -167,30 +272,18 @@ internal fun NotificationsScreen(
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column {
|
||||
state.writableCalendars.forEach { calendar ->
|
||||
state.reminderCalendars.forEach { calendar ->
|
||||
Spacer(Modifier.height(16.dp))
|
||||
// Special-dates calendars own their reminders in their
|
||||
// own section — link there instead.
|
||||
if (calendar.id in state.managedCalendarIds) {
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||
position = Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = onOpenSpecialDates,
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
val expanded = calendar.id in expandedCalendars
|
||||
val notifying = calendar.id !in state.mutedReminderCalendarIds
|
||||
val blocked = notifying && calendar.id in channelStatus.blockedCalendarIds
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = when {
|
||||
!notifying -> stringResource(R.string.settings_calendar_notifications_off)
|
||||
blocked -> stringResource(R.string.settings_notifications_blocked)
|
||||
else -> null
|
||||
},
|
||||
position = if (expanded) Position.Top else Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = { ExpandIcon(expanded) },
|
||||
@@ -207,22 +300,18 @@ internal fun NotificationsScreen(
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column {
|
||||
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||
)
|
||||
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
||||
)
|
||||
}
|
||||
CalendarNotificationRows(
|
||||
calendar = calendar,
|
||||
state = state,
|
||||
notifying = notifying,
|
||||
blocked = blocked,
|
||||
soundLabel = loadedSounds?.let { soundLabel(it[calendar.id]) },
|
||||
onSetNotifying = { viewModel.setCalendarRemindersEnabled(calendar.id, it) },
|
||||
onPickSound = { pickSound(calendar.id) },
|
||||
onOpenSettings = { openCalendarSettings(calendar.id) },
|
||||
onOpenSpecialDates = onOpenSpecialDates,
|
||||
onEditOverride = { overrideDialog = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,6 +398,152 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One calendar's expanded rows: its notification switch and channel deep-link
|
||||
* for every calendar, then the default-reminder overrides where it takes them —
|
||||
* or, for a special-dates calendar, a link to the section that owns them.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarNotificationRows(
|
||||
calendar: CalendarSource,
|
||||
state: SettingsUiState,
|
||||
notifying: Boolean,
|
||||
blocked: Boolean,
|
||||
soundLabel: String?,
|
||||
onSetNotifying: (Boolean) -> Unit,
|
||||
onPickSound: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSpecialDates: () -> Unit,
|
||||
onEditOverride: (OverrideTarget) -> Unit,
|
||||
) {
|
||||
val managed = calendar.id in state.managedCalendarIds
|
||||
val takesOverrides = calendar.canModifyContents && !managed
|
||||
Column {
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_calendar_notifications),
|
||||
checked = notifying,
|
||||
onCheckedChange = onSetNotifying,
|
||||
summary = stringResource(R.string.settings_calendar_notifications_hint),
|
||||
position = Position.Middle,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
summary = soundLabel,
|
||||
position = Position.Middle,
|
||||
onClick = onPickSound,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_more),
|
||||
summary = if (blocked) {
|
||||
stringResource(R.string.settings_notifications_blocked_hint)
|
||||
} else {
|
||||
stringResource(R.string.settings_calendar_notification_sound_hint)
|
||||
},
|
||||
position = if (managed || takesOverrides) Position.Middle else Position.Bottom,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onOpenSettings,
|
||||
)
|
||||
if (managed) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = onOpenSpecialDates,
|
||||
)
|
||||
} else if (takesOverrides) {
|
||||
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = false)) },
|
||||
)
|
||||
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = true)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** [soundTarget] meaning every calendar at once. */
|
||||
private const val ALL_CALENDARS = Long.MIN_VALUE
|
||||
|
||||
/** What the calendars that have a channel share, sound-wise. */
|
||||
private sealed interface SharedSound {
|
||||
data object None : SharedSound
|
||||
data object Mixed : SharedSound
|
||||
data class Same(val sound: ReminderSound) : SharedSound
|
||||
}
|
||||
|
||||
private fun sharedSound(sounds: Map<Long, ReminderSound>, calendarIds: List<Long>): SharedSound {
|
||||
val distinct = calendarIds.mapNotNull { sounds[it] }.distinctBy { it.uri }
|
||||
return when (distinct.size) {
|
||||
0 -> SharedSound.None
|
||||
1 -> SharedSound.Same(distinct.single())
|
||||
else -> SharedSound.Mixed
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun soundLabel(sound: ReminderSound?): String = when {
|
||||
sound == null -> stringResource(R.string.settings_notification_sound_default)
|
||||
sound.uri == null -> stringResource(R.string.settings_notification_sound_silent)
|
||||
sound.uri == DEFAULT_NOTIFICATION_URI -> sound.title
|
||||
?.let { stringResource(R.string.settings_notification_sound_default_named, it) }
|
||||
?: stringResource(R.string.settings_notification_sound_default)
|
||||
else -> sound.title ?: stringResource(R.string.settings_notification_sound_custom)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun globalSoundLabel(sounds: Map<Long, ReminderSound>, calendarIds: List<Long>): String =
|
||||
when (val shared = sharedSound(sounds, calendarIds)) {
|
||||
SharedSound.None -> soundLabel(null)
|
||||
SharedSound.Mixed -> stringResource(R.string.settings_notification_sound_mixed)
|
||||
is SharedSound.Same -> soundLabel(shared.sound)
|
||||
}
|
||||
|
||||
/** Samsung's One UI can start a new notification category switched off (#145). */
|
||||
private val IS_SAMSUNG = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
|
||||
/** The one-time Samsung note, with a way into the app's notification settings. */
|
||||
@Composable
|
||||
private fun NotificationCategoryHint(onOpen: () -> Unit, onDismiss: () -> Unit) {
|
||||
IconInfoCard(
|
||||
icon = Icons.Outlined.Info,
|
||||
iconContentDescription = null,
|
||||
iconAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_notification_category_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.reminder_action_dismiss)) }
|
||||
TextButton(onClick = onOpen) { Text(stringResource(R.string.settings_notification_category_hint_open)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExternalLinkIcon() {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.net.Uri
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -96,8 +97,14 @@ data class SettingsUiState(
|
||||
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||
/** Switched-on calendars you can edit: the per-calendar event-duration rows. */
|
||||
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||
/** Switched-on calendars, each with its own notification switch and channel (#145, #272). */
|
||||
val reminderCalendars: List<CalendarSource> = emptyList(),
|
||||
/** Calendars whose reminders Calendula doesn't post (#272). */
|
||||
val mutedReminderCalendarIds: Set<Long> = emptySet(),
|
||||
/** Whether the Samsung notification-category hint has been dismissed (#145); hidden until prefs load. */
|
||||
val notificationCategoryHintDismissed: Boolean = true,
|
||||
/**
|
||||
* Whether the event-colour picker is offered on calendars that publish no
|
||||
* colour palette (the colour may then not survive their next sync).
|
||||
@@ -130,3 +137,9 @@ data class SpecialDatesUiState(
|
||||
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||
val lastRun: Long = 0L,
|
||||
)
|
||||
|
||||
/** A reminder channel's sound: null [uri] is silent; [title] is null when it can't be resolved. */
|
||||
data class ReminderSound(val uri: Uri?, val title: String?)
|
||||
|
||||
/** Why a picked reminder sound wasn't applied. */
|
||||
enum class SoundFailure { NotApplied, Unreadable }
|
||||
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.calendula.ui.settings
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -16,10 +18,12 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
|
||||
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
|
||||
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
||||
import de.jeanlucmakiola.floret.android.ringtoneTitle
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -33,6 +37,8 @@ import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannelStatus
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannels
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
@@ -45,7 +51,12 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -54,6 +65,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -62,11 +74,13 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
calendarPrefs: CalendarPrefs,
|
||||
repository: CalendarRepository,
|
||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||
private val launcherNameManager: LauncherNameManager,
|
||||
private val reminderScanner: ReminderScanner,
|
||||
private val reminderChannels: ReminderChannels,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
@@ -74,16 +88,16 @@ class SettingsViewModel @Inject constructor(
|
||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
||||
/**
|
||||
* Writable calendars that are switched on — the only ones that take a
|
||||
* per-calendar reminder override, since a switched-off one plans no
|
||||
* reminders at all (#75).
|
||||
* Calendars that are switched on — the only ones that plan reminders at
|
||||
* all (#75), so the only ones with notification settings.
|
||||
*/
|
||||
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||
.map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
|
||||
.catch { emit(emptyList()) }
|
||||
private val visibleCalendars: Flow<List<CalendarSource>> =
|
||||
combine(repository.calendars(), calendarPrefs.pendingDisabledCalendarIds) { calendars, pending ->
|
||||
calendars.filter { it.isVisibleInSystem && it.id !in pending }
|
||||
}.catch { emit(emptyList()) }
|
||||
|
||||
val state: StateFlow<SettingsUiState> =
|
||||
combine(prefs.data, writableCalendars) { stored, calendars -> stored.toUiState(calendars) }
|
||||
combine(prefs.data, visibleCalendars) { stored, calendars -> stored.toUiState(calendars) }
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||
@@ -125,7 +139,10 @@ class SettingsViewModel @Inject constructor(
|
||||
snoozeMinutes = this[SettingsKeys.snoozeMinutes],
|
||||
perCalendarReminderOverride = this[SettingsKeys.perCalendarReminderOverride],
|
||||
perCalendarAllDayReminderOverride = this[SettingsKeys.perCalendarAllDayReminderOverride],
|
||||
writableCalendars = calendars,
|
||||
writableCalendars = calendars.filter { it.canModifyContents },
|
||||
reminderCalendars = calendars,
|
||||
mutedReminderCalendarIds = this[SettingsKeys.mutedReminderCalendarIds],
|
||||
notificationCategoryHintDismissed = this[SettingsKeys.notificationCategoryHintDismissed],
|
||||
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
|
||||
managedCalendarIds = specialDatesCalendars.values.toSet(),
|
||||
)
|
||||
@@ -357,6 +374,130 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-plans, since a muted calendar armed no wake-up for its next reminder.
|
||||
* Unmuting scans once more before the write, so the watermark starts at the
|
||||
* unmute and nothing that came due while muted is replayed.
|
||||
*/
|
||||
fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
if (enabled) reminderScanner.scan()
|
||||
prefs.setCalendarRemindersEnabled(calendarId, enabled)
|
||||
reminderScanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
private val _channelStatus = MutableStateFlow(ReminderChannelStatus())
|
||||
|
||||
/** What the system blocks of the reminders; re-read with [refreshChannelStatus] (#145). */
|
||||
val channelStatus: StateFlow<ReminderChannelStatus> = _channelStatus.asStateFlow()
|
||||
|
||||
private var channelStatusJob: Job? = null
|
||||
|
||||
private val _channelSounds = MutableStateFlow<Map<Long, ReminderSound>?>(null)
|
||||
|
||||
/** Each calendar's reminder sound (#518), null until first read; a calendar without a channel yet is absent. */
|
||||
val channelSounds: StateFlow<Map<Long, ReminderSound>?> = _channelSounds.asStateFlow()
|
||||
|
||||
private val _soundFailed = Channel<SoundFailure>(Channel.CONFLATED)
|
||||
|
||||
/** Emits when a picked sound couldn't be applied; kept until the screen collects it. */
|
||||
val soundFailed: Flow<SoundFailure> = _soundFailed.receiveAsFlow()
|
||||
|
||||
private val soundTitles = mutableMapOf<Uri, String?>()
|
||||
|
||||
fun refreshChannelStatus(calendarIds: Collection<Long>) {
|
||||
channelStatusJob?.cancel()
|
||||
channelStatusJob = viewModelScope.launch {
|
||||
onChannels { status(calendarIds) }?.let { _channelStatus.value = it }
|
||||
val sounds = onChannels { sounds(calendarIds) } ?: return@launch
|
||||
_channelSounds.value = withContext(io) {
|
||||
sounds.mapValues { (_, uri) ->
|
||||
ReminderSound(uri, uri?.let(::soundTitle))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A sound's title, cached unless it is a "default" alias whose target can change. */
|
||||
private fun soundTitle(uri: Uri): String? {
|
||||
if (uri.authority == Settings.AUTHORITY) return appContext.ringtoneTitle(uri)
|
||||
return synchronized(soundTitles) { soundTitles.getOrPut(uri) { appContext.ringtoneTitle(uri) } }
|
||||
}
|
||||
|
||||
/** Give [calendarId]'s reminders [sound] (null = silent), on a fresh channel (#518). */
|
||||
fun setReminderSound(calendarId: Long, sound: Uri?) = applySound(sound) {
|
||||
setSound(listOf(calendarId), sound, createMissing = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every switched-on calendar's reminders [sound]. Reads the calendars
|
||||
* itself, so a pick returning after process death still finds them; those
|
||||
* that only get a channel when something posts are left alone.
|
||||
*/
|
||||
fun setReminderSoundForAll(sound: Uri?) = applySound(sound) {
|
||||
try {
|
||||
sync()
|
||||
} catch (e: Exception) {
|
||||
Log.w("SettingsViewModel", "Channel sync before a sound pick failed", e)
|
||||
}
|
||||
val ids = visibleCalendars.first().map { it.id }
|
||||
ids.isNotEmpty() && setSound(ids, sound, createMissing = false)
|
||||
}
|
||||
|
||||
private fun canRead(uri: Uri): Boolean = uri.authority == Settings.AUTHORITY ||
|
||||
runCatching { appContext.contentResolver.openAssetFileDescriptor(uri, "r")?.close() != null }.getOrDefault(false)
|
||||
|
||||
/** Runs to completion even if the screen closes meanwhile, so a pick is never half-applied. */
|
||||
private fun applySound(sound: Uri?, block: suspend ReminderChannels.() -> Boolean) {
|
||||
viewModelScope.launch {
|
||||
val failure = withContext(io + NonCancellable) {
|
||||
try {
|
||||
// The system plays the sound but checks that Calendula could read it.
|
||||
when {
|
||||
sound != null && !canRead(sound) -> SoundFailure.Unreadable
|
||||
reminderChannels.block() -> null
|
||||
else -> SoundFailure.NotApplied
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("SettingsViewModel", "Could not set the reminder sound", e)
|
||||
SoundFailure.NotApplied
|
||||
}
|
||||
}
|
||||
failure?.let(_soundFailed::trySend)
|
||||
refreshChannelStatus(visibleCalendars.first().map { it.id })
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissNotificationCategoryHint() = save { dismissNotificationCategoryHint() }
|
||||
|
||||
/**
|
||||
* The channel whose system screen to open for [calendarId] (#340), created
|
||||
* if needed; null when only the app's own screen can help — the app or the
|
||||
* calendar's account group is blocked there, or the channel couldn't be made.
|
||||
*/
|
||||
suspend fun soundSettingsChannel(calendarId: Long): String? = onChannels {
|
||||
val channelId = channelFor(calendarId) ?: return@onChannels null
|
||||
val status = status(listOf(calendarId))
|
||||
channelId.takeUnless { status.appBlocked || calendarId in status.groupBlockedCalendarIds }
|
||||
}
|
||||
|
||||
/** Create the per-calendar channels before showing the app's channel list, even with reminders off. */
|
||||
suspend fun syncReminderChannels() {
|
||||
onChannels { sync() }
|
||||
}
|
||||
|
||||
private suspend fun <T> onChannels(block: ReminderChannels.() -> T): T? = withContext(io) {
|
||||
try {
|
||||
reminderChannels.block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("SettingsViewModel", "Reminder channel update failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutofocusEventTitle(enabled: Boolean) = save { setAutofocusEventTitle(enabled) }
|
||||
|
||||
fun setDefaultEventDuration(minutes: Int) = save { setDefaultEventDurationMinutes(minutes) }
|
||||
|
||||
@@ -157,6 +157,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostsDerived
|
||||
import de.jeanlucmakiola.calendula.ui.common.StripDrag
|
||||
import de.jeanlucmakiola.calendula.ui.common.StripDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||
@@ -310,14 +311,6 @@ internal enum class TimelineKind(
|
||||
// A run of days often straddles two weeks, so no single number fits it.
|
||||
MultiDay(showWeekNumber = false, todayText = R.string.week_today_action, headerBias = 0f),
|
||||
Day(showWeekNumber = false, todayText = R.string.day_today_action, headerBias = -1f),
|
||||
;
|
||||
|
||||
fun title(start: LocalDate, end: LocalDate, locale: Locale, currentYear: Int, abbreviated: Boolean): String =
|
||||
if (this == Day) {
|
||||
formatDayTitle(start, locale, currentYear, abbreviated)
|
||||
} else {
|
||||
formatWeekTitle(start, end, locale, currentYear, abbreviated)
|
||||
}
|
||||
}
|
||||
|
||||
internal val CalendarView.timelineKind: TimelineKind
|
||||
@@ -492,9 +485,10 @@ private fun PagedTimelineScreen(
|
||||
val jumpToToday = { jumpToDate(today) }
|
||||
|
||||
val locale = currentLocale()
|
||||
val (title, shortTitle) = remember(titleKind, weekStart, weekEnd, locale, today.year) {
|
||||
titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = false) to
|
||||
titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = true)
|
||||
// The day view names the month too: its header already shows weekday and date (#501).
|
||||
val (title, shortTitle) = remember(weekStart, weekEnd, locale, today.year) {
|
||||
formatTimelineTitle(weekStart, weekEnd, locale, today.year, abbreviated = false) to
|
||||
formatTimelineTitle(weekStart, weekEnd, locale, today.year, abbreviated = true)
|
||||
}
|
||||
PublishChrome(
|
||||
ScreenChrome(
|
||||
@@ -591,7 +585,9 @@ private fun WeekContent(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
isAllDay = drop.event.isAllDay,
|
||||
target = MoveTarget.Start(start),
|
||||
token = drop.token,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -610,7 +606,9 @@ private fun WeekContent(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
isAllDay = drop.event.isAllDay,
|
||||
target = MoveTarget.Span(drop.start, drop.end),
|
||||
token = drop.token,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -622,10 +620,12 @@ private fun WeekContent(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
isAllDay = drop.event.isAllDay,
|
||||
target = when (drop) {
|
||||
is StripDrop.Days -> MoveTarget.ByDays(drop.days)
|
||||
is StripDrop.Timed -> MoveTarget.Span(drop.start, drop.end)
|
||||
},
|
||||
token = drop.token,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -767,8 +767,7 @@ private fun WeekPager(
|
||||
// Until its days arrive a page shows their frame without events.
|
||||
val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) }
|
||||
// A bar held over other days in the strip makes room there before it is dropped.
|
||||
val held = (dragController.stripDrag ?: dragController.stripSettling)
|
||||
?.takeIf { page == pagerState.currentPage }
|
||||
val held = dragController.stripPreviews().takeIf { page == pagerState.currentPage }.orEmpty()
|
||||
val zone = LocalTimelineZone.current
|
||||
val shown = remember(loaded, held, zone) { loaded?.withStripPreview(held, zone) }
|
||||
val stripHeight = shown?.allDayStripHeight()
|
||||
@@ -793,17 +792,24 @@ private fun WeekPager(
|
||||
}
|
||||
|
||||
/**
|
||||
* [this] with the strip bar [held] sideways laid out at the days it is over, so
|
||||
* the bars there move aside live. The bar keeps its own event, and with it its
|
||||
* key and the gesture on it; one moved off the page keeps a lane it is not seen in.
|
||||
* [this] with the strip bars [held] sideways laid out at the days they are over,
|
||||
* so the bars there move aside live. Each bar keeps its own event, and with it
|
||||
* its key and the gesture on it; one moved off the page keeps a lane it is not
|
||||
* seen in.
|
||||
*/
|
||||
internal fun WeekUiState.Success.withStripPreview(held: StripDrag?, zone: TimeZone): WeekUiState.Success {
|
||||
if (held == null || held.overGrid || held.dayShift == 0) return this
|
||||
val id = held.event.instanceId
|
||||
val original = allDaySpans.firstOrNull { it.event.instanceId == id } ?: return this
|
||||
val events = allDaySpans.map { if (it.event.instanceId == id) it.event.shiftedDays(held.dayShift, zone) else it.event }
|
||||
val spans = layoutAllDay(events, days, zone).map { if (it.event.instanceId == id) it.copy(event = original.event) else it }
|
||||
return copy(allDaySpans = if (spans.any { it.event.instanceId == id }) spans else spans + original.copy(lane = 0))
|
||||
internal fun WeekUiState.Success.withStripPreview(held: List<StripDrag>, zone: TimeZone): WeekUiState.Success {
|
||||
val shifts = held.filter { !it.overGrid && it.dayShift != 0 }.associate { it.event.instanceId to it.dayShift }
|
||||
if (shifts.isEmpty()) return this
|
||||
val originals = allDaySpans.filter { it.event.instanceId in shifts }.associateBy { it.event.instanceId }
|
||||
if (originals.isEmpty()) return this
|
||||
val events = allDaySpans.map { span ->
|
||||
shifts[span.event.instanceId]?.let { span.event.shiftedDays(it, zone) } ?: span.event
|
||||
}
|
||||
val spans = layoutAllDay(events, days, zone).map { span ->
|
||||
originals[span.event.instanceId]?.let { span.copy(event = it.event) } ?: span
|
||||
}
|
||||
val laidOut = spans.mapTo(HashSet()) { it.event.instanceId }
|
||||
return copy(allDaySpans = spans + originals.values.filter { it.event.instanceId !in laidOut }.map { it.copy(lane = 0) })
|
||||
}
|
||||
|
||||
private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance {
|
||||
@@ -811,6 +817,17 @@ private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance
|
||||
return copy(start = start.plus(days, DateTimeUnit.DAY, dates), end = end.plus(days, DateTimeUnit.DAY, dates))
|
||||
}
|
||||
|
||||
/** Whether these bars draw the strip drop [drop] at the days it was dropped on. */
|
||||
private fun List<PlacedBar>.drawStripDrop(drop: StripDrag, zone: TimeZone): Boolean {
|
||||
val target = drop.event.spanFirstDay(zone).plus(drop.dayShift, DateTimeUnit.DAY)
|
||||
return any { bar ->
|
||||
bar.event.instanceId != drop.event.instanceId &&
|
||||
(bar.event.eventId == drop.event.eventId ||
|
||||
(drop.event.title.isNotBlank() && bar.event.title == drop.event.title)) &&
|
||||
bar.event.spanFirstDay(zone) == target
|
||||
}
|
||||
}
|
||||
|
||||
/** A page with no events yet, for one whose days are still loading. */
|
||||
private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success {
|
||||
val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
|
||||
@@ -1193,17 +1210,11 @@ private fun AllDayStrip(
|
||||
val sideways = held?.takeIf { !it.overGrid }
|
||||
val lifted = dragController.liftedInstanceId?.takeIf { active && dragController.stripDrag?.overGrid != false }
|
||||
val settling = dragController.stripSettling?.takeIf { active }
|
||||
val heldStrips = dragController.heldStrips.takeIf { active }.orEmpty()
|
||||
// Seated once the re-read draws the event at the days it was dropped on.
|
||||
LaunchedEffect(settling, bars) {
|
||||
if (settling == null) return@LaunchedEffect
|
||||
val target = settling.event.spanFirstDay(zone).plus(settling.dayShift, DateTimeUnit.DAY)
|
||||
val landed = bars.any { bar ->
|
||||
bar.event.instanceId != settling.event.instanceId &&
|
||||
(bar.event.eventId == settling.event.eventId ||
|
||||
(settling.event.title.isNotBlank() && bar.event.title == settling.event.title)) &&
|
||||
bar.event.spanFirstDay(zone) == target
|
||||
}
|
||||
if (landed) dragController.seatStrip()
|
||||
LaunchedEffect(settling, heldStrips, bars) {
|
||||
if (settling != null && bars.drawStripDrop(settling, zone)) dragController.seatStrip()
|
||||
heldStrips.forEach { if (!it.seated && bars.drawStripDrop(it.drag, zone)) dragController.seatHeldStrip(it.token) }
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -1588,7 +1599,7 @@ private fun DayColumnCard(
|
||||
widthFraction = 1f / block.laneCount,
|
||||
y = top,
|
||||
height = height,
|
||||
landing = dragController.ghosts(block),
|
||||
landing = dragController.ghostsDerived(block),
|
||||
)
|
||||
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
|
||||
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
|
||||
@@ -1756,7 +1767,7 @@ private fun EventBlock(
|
||||
onCancel = dragController::cancel,
|
||||
onRelease = { dragController.select(block.event, fromLift = draggable) },
|
||||
)
|
||||
val lifted = draggable && dragController.ghosts(block)
|
||||
val lifted = draggable && dragController.ghostsDerived(block)
|
||||
// Released in place, the block takes over the copy's lift without a dip.
|
||||
val takesOverLift = selected && dragController.selection?.fromLift == true
|
||||
val ghostFade = ghostAlpha(lifted)
|
||||
@@ -2016,7 +2027,7 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
/**
|
||||
* The week's title: just the month [weekStart] falls in.
|
||||
* Every timeline's title, the day view's included: just the month [weekStart] falls in.
|
||||
*
|
||||
* The day numbers are already in the column headers directly below, so spelling
|
||||
* out "13.–19. Jul" restates them in the widest string in the bar. Naming the
|
||||
@@ -2031,7 +2042,7 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
|
||||
* "December" with no year would be actively misleading while the reader is
|
||||
* looking straight at January dates.
|
||||
*/
|
||||
private fun formatWeekTitle(
|
||||
private fun formatTimelineTitle(
|
||||
weekStart: LocalDate,
|
||||
weekEnd: LocalDate,
|
||||
locale: Locale,
|
||||
@@ -2046,17 +2057,3 @@ private fun formatWeekTitle(
|
||||
forceYear = weekEnd.year != weekStart.year,
|
||||
)
|
||||
}
|
||||
|
||||
/** The day's title: its weekday and date, abbreviated to the date alone when short of room. */
|
||||
private fun formatDayTitle(
|
||||
date: LocalDate,
|
||||
locale: Locale,
|
||||
currentYear: Int,
|
||||
abbreviated: Boolean = false,
|
||||
): String =
|
||||
formatCalendarTitle(
|
||||
date = date.toJavaLocalDate(),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
|
||||
)
|
||||
@@ -110,6 +110,9 @@
|
||||
<string name="event_move_failed">Couldn\'t move the event</string>
|
||||
<string name="event_move_write_denied">Calendula needs write access to move events</string>
|
||||
<string name="event_move_gone">That event no longer exists</string>
|
||||
<string name="event_move_stale">That event moved before your drop could be saved</string>
|
||||
<string name="event_move_queue_full">Too many moves at once, so that one wasn\'t saved</string>
|
||||
<string name="event_move_skipped">Moves made while the question was open weren\'t saved</string>
|
||||
<string name="event_move_blocked_series_end">Can\'t move an event past the end of its series</string>
|
||||
<string name="event_move_blocked_cancelled_series">A cancelled series can only move as a whole, and this would change which days it falls on</string>
|
||||
|
||||
@@ -728,7 +731,26 @@
|
||||
<string name="reminder_custom_amount">Amount</string>
|
||||
<string name="reminder_custom_set">Set</string>
|
||||
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
|
||||
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
|
||||
<string name="settings_calendar_reminders_hint">Turn notifications off or pick a sound per calendar, and override the default reminder for calendars you can edit.</string>
|
||||
<string name="settings_calendar_notifications">Notifications</string>
|
||||
<string name="settings_calendar_notifications_hint">Notify at this calendar\'s reminder times</string>
|
||||
<string name="settings_calendar_notifications_off">Notifications off</string>
|
||||
<string name="settings_notification_sound">Notification sound</string>
|
||||
<string name="settings_notification_sound_silent">Silent</string>
|
||||
<string name="settings_notification_sound_default">Default</string>
|
||||
<!-- %1$s is the name of the device's default notification sound. -->
|
||||
<string name="settings_notification_sound_default_named">Default (%1$s)</string>
|
||||
<string name="settings_notification_sound_custom">Custom sound</string>
|
||||
<string name="settings_notification_sound_failed">Couldn\'t change the notification sound</string>
|
||||
<string name="settings_notification_sound_unreadable">Calendula can\'t play this sound. Pick another one.</string>
|
||||
<string name="settings_notification_sound_mixed">Differs per calendar</string>
|
||||
<string name="settings_notification_more">More notification settings</string>
|
||||
<string name="settings_notification_sound_hint">Vibration, pop-ups and more for each calendar, in system settings</string>
|
||||
<string name="settings_notifications_blocked">Blocked in system settings</string>
|
||||
<string name="settings_notifications_blocked_hint">Blocked in system settings — tap to allow</string>
|
||||
<string name="settings_notification_category_hint">On Samsung phones, new notification categories can start switched off. If a calendar\'s reminders don\'t arrive, check that its category is on.</string>
|
||||
<string name="settings_notification_category_hint_open">Open settings</string>
|
||||
<string name="settings_calendar_notification_sound_hint">Vibration, pop-ups and more for this calendar, in system settings</string>
|
||||
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
|
||||
|
||||
@@ -601,6 +601,30 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendar notifications default on and toggle per calendar`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
assertThat(prefs.mutedReminderCalendarIds.first()).isEmpty()
|
||||
prefs.setCalendarRemindersEnabled(7L, false)
|
||||
prefs.setCalendarRemindersEnabled(9L, false)
|
||||
assertThat(prefs.mutedReminderCalendarIds.first()).containsExactly(7L, 9L)
|
||||
prefs.setCalendarRemindersEnabled(7L, true)
|
||||
assertThat(prefs.mutedReminderCalendarIds.first()).containsExactly(9L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reminder channel versions only grow`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
assertThat(prefs.reminderChannelVersions.first()).isEmpty()
|
||||
assertThat(prefs.reserveReminderChannelVersions(mapOf(7L to 1))).containsExactly(7L, 1)
|
||||
assertThat(prefs.reserveReminderChannelVersions(mapOf(7L to 1, 9L to 5))).containsExactly(7L, 2, 9L, 5)
|
||||
assertThat(prefs.reminderChannelVersions.first()).containsExactly(7L, 2, 9L, 5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage per-calendar duration entries are dropped, the rest survive`(
|
||||
@TempDir tempDir: Path,
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ReminderChannelPlanTest {
|
||||
|
||||
private fun calendar(id: Long, account: String, type: String, local: Boolean = false) = CalendarSource(
|
||||
id = id,
|
||||
displayName = "Cal $id",
|
||||
accountName = account,
|
||||
accountType = type,
|
||||
color = 0,
|
||||
isVisibleInSystem = true,
|
||||
isLocal = local,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `one channel per calendar, grouped by account`() {
|
||||
val plan = planReminderChannels(
|
||||
calendars = listOf(
|
||||
calendar(1, "me@example.org", "com.google"),
|
||||
calendar(2, "me@example.org", "com.google"),
|
||||
calendar(3, "dav", "bitfire.at.davdroid"),
|
||||
),
|
||||
existing = emptyList(),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(plan.groups).hasSize(2)
|
||||
assertThat(plan.channels.map { it.id })
|
||||
.containsExactly(reminderChannelId(1), reminderChannelId(2), reminderChannelId(3))
|
||||
assertThat(plan.channels[0].groupId).isEqualTo(plan.channels[1].groupId)
|
||||
assertThat(plan.channels[2].groupId).isNotEqualTo(plan.channels[0].groupId)
|
||||
assertThat(plan.staleChannelIds).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device calendars share one group`() {
|
||||
val plan = planReminderChannels(
|
||||
calendars = listOf(
|
||||
calendar(1, "Calendula", "LOCAL", local = true),
|
||||
calendar(2, "Other", "LOCAL", local = true),
|
||||
),
|
||||
existing = emptyList(),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(plan.groups).hasSize(1)
|
||||
assertThat(plan.groups.single().isLocal).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same account name under two types is ambiguous`() {
|
||||
val plan = planReminderChannels(
|
||||
calendars = listOf(
|
||||
calendar(1, "me@example.org", "com.google"),
|
||||
calendar(2, "me@example.org", "bitfire.at.davdroid"),
|
||||
calendar(3, "work", "bitfire.at.davdroid"),
|
||||
),
|
||||
existing = emptyList(),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(plan.groups.filter { it.ambiguous }.map { it.accountType })
|
||||
.containsExactly("com.google", "bitfire.at.davdroid")
|
||||
assertThat(plan.groups.single { it.label == "work" }.ambiguous).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale reminder channels and groups go, foreign ones stay`() {
|
||||
val current = calendar(1, "me@example.org", "com.google")
|
||||
fun plan(calendars: List<CalendarSource>, existing: List<String> = emptyList(), groups: List<String> = emptyList()) =
|
||||
planReminderChannels(calendars, existing.map { ExistingReminderChannel(it, "Cal ${it.last()}", DESC, null) }, groups, "Unnamed", DESC)
|
||||
val keptGroup = plan(listOf(current)).groups.single().id
|
||||
val goneGroup = plan(listOf(calendar(9, "old", "com.google"))).groups.single().id
|
||||
val result = plan(
|
||||
calendars = listOf(current),
|
||||
existing = listOf(reminderChannelId(1), reminderChannelId(9), LEGACY_REMINDER_CHANNEL_ID, "backup"),
|
||||
groups = listOf(keptGroup, goneGroup, "other"),
|
||||
)
|
||||
assertThat(result.staleChannelIds).containsExactly(reminderChannelId(9))
|
||||
assertThat(result.staleGroupIds).containsExactly(goneGroup)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only missing or renamed channels are written`() {
|
||||
val plan = planReminderChannels(
|
||||
calendars = listOf(
|
||||
calendar(1, "a", "com.google"),
|
||||
calendar(2, "a", "com.google"),
|
||||
calendar(3, "a", "com.google").copy(displayName = ""),
|
||||
calendar(4, "a", "com.google"),
|
||||
),
|
||||
existing = listOf(
|
||||
ExistingReminderChannel(reminderChannelId(1), "Cal 1", DESC, "g"),
|
||||
ExistingReminderChannel(reminderChannelId(4), "Cal 4", "Old description", "g"),
|
||||
ExistingReminderChannel(reminderChannelId(2), "Old name", DESC, "g"),
|
||||
),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(plan.channelsToWrite.map { it.calendarId }).containsExactly(2L, 3L, 4L)
|
||||
assertThat(plan.channelsToWrite.single { it.calendarId == 3L }.name).isEqualTo("Unnamed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-syncing calendars keep a channel but get none up front`() {
|
||||
val quiet = calendar(2, "a", "com.google").copy(syncsEvents = false)
|
||||
val fresh = planReminderChannels(listOf(quiet), emptyList(), emptyList(), "Unnamed", DESC)
|
||||
assertThat(fresh.channelsToWrite).isEmpty()
|
||||
assertThat(fresh.groupsInUse).isEmpty()
|
||||
val posted = planReminderChannels(
|
||||
calendars = listOf(quiet),
|
||||
existing = listOf(ExistingReminderChannel(reminderChannelId(2), "Cal 2", DESC, null)),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(posted.staleChannelIds).isEmpty()
|
||||
assertThat(posted.channelsToWrite.map { it.calendarId }).containsExactly(2L)
|
||||
assertThat(posted.groupsInUse).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocked calendars follow app, channel and group`() {
|
||||
val channels = mapOf(
|
||||
reminderChannelId(1) to ReminderChannelState(blocked = true, groupId = "g1"),
|
||||
reminderChannelId(2) to ReminderChannelState(blocked = false, groupId = "g2"),
|
||||
reminderChannelId(3) to ReminderChannelState(blocked = false, groupId = "g1"),
|
||||
)
|
||||
val ids = listOf(1L, 2L, 3L, 4L)
|
||||
val groupOff = reminderChannelStatus(ids, appEnabled = true, channels, blockedGroupIds = setOf("g2"))
|
||||
assertThat(groupOff.blockedCalendarIds).containsExactly(1L, 2L)
|
||||
assertThat(groupOff.groupBlockedCalendarIds).containsExactly(2L)
|
||||
assertThat(reminderChannelStatus(ids, appEnabled = true, channels, blockedGroupIds = emptySet()).blockedCalendarIds)
|
||||
.containsExactly(1L)
|
||||
val appOff = reminderChannelStatus(ids, appEnabled = false, channels, blockedGroupIds = emptySet())
|
||||
assertThat(appOff.appBlocked).isTrue()
|
||||
assertThat(appOff.blockedCalendarIds).containsExactly(1L, 2L, 3L, 4L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `channel ids round-trip with versions`() {
|
||||
assertThat(parseReminderChannelId(reminderChannelId(7))).isEqualTo(7L to 0)
|
||||
assertThat(parseReminderChannelId(reminderChannelId(7, 3))).isEqualTo(7L to 3)
|
||||
assertThat(parseReminderChannelId("reminders.calendar.7.v0")).isNull()
|
||||
assertThat(parseReminderChannelId("reminders.calendar.x")).isNull()
|
||||
assertThat(parseReminderChannelId(LEGACY_REMINDER_CHANNEL_ID)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a version bump retires the old channel unless it is showing`() {
|
||||
val existing = listOf(
|
||||
ExistingReminderChannel(reminderChannelId(1), "Cal 1", DESC, "g"),
|
||||
ExistingReminderChannel(reminderChannelId(2), "Cal 2", DESC, "g"),
|
||||
)
|
||||
val plan = planReminderChannels(
|
||||
calendars = listOf(calendar(1, "a", "com.google"), calendar(2, "a", "com.google")),
|
||||
existing = existing,
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
versions = mapOf(1L to 2, 2L to 1),
|
||||
showingChannelIds = setOf(reminderChannelId(2)),
|
||||
)
|
||||
assertThat(plan.channels.map { it.id }).containsExactly(reminderChannelId(1, 2), reminderChannelId(2, 1))
|
||||
assertThat(plan.staleChannelIds).containsExactly(reminderChannelId(1))
|
||||
assertThat(plan.keptChannelIds).containsExactly(reminderChannelId(2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the current version is the highest that exists, else the high-water mark`() {
|
||||
val versions = currentReminderChannelVersions(
|
||||
existingChannelIds = listOf(reminderChannelId(1), reminderChannelId(1, 2), reminderChannelId(3), "backup"),
|
||||
highWater = mapOf(1L to 5, 2L to 4),
|
||||
)
|
||||
// Calendar 1 reserved v5 but never wrote it, so v2 stays current.
|
||||
assertThat(versions).containsExactly(1L, 2, 2L, 4, 3L, 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status reads the current channel version`() {
|
||||
val channels = mapOf(
|
||||
reminderChannelId(1) to ReminderChannelState(blocked = true, groupId = null),
|
||||
reminderChannelId(1, 2) to ReminderChannelState(blocked = false, groupId = null),
|
||||
)
|
||||
val status = reminderChannelStatus(listOf(1L), true, channels, emptySet(), versions = mapOf(1L to 2))
|
||||
assertThat(status.blockedCalendarIds).isEmpty()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DESC = "Reminders"
|
||||
}
|
||||
}
|
||||
+278
-23
@@ -13,10 +13,12 @@ import de.jeanlucmakiola.calendula.testEvent
|
||||
import de.jeanlucmakiola.calendula.testPrefs
|
||||
import de.jeanlucmakiola.calendula.testRepository
|
||||
import de.jeanlucmakiola.calendula.testSettings
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -79,15 +81,43 @@ class RescheduleViewModelTest {
|
||||
status = status,
|
||||
)
|
||||
|
||||
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel {
|
||||
/**
|
||||
* [present] are the events whose occurrence the provider still reports where
|
||||
* a drop picked it up; [io] a [StandardTestDispatcher] keeps a drop in flight.
|
||||
*/
|
||||
private fun viewModel(
|
||||
tempDir: Path,
|
||||
fake: FakeCalendarDataSource,
|
||||
present: List<Long> = listOf(42L, 7L),
|
||||
io: CoroutineDispatcher = dispatcher,
|
||||
): RescheduleViewModel {
|
||||
fake.occurrencesAt(present)
|
||||
val repo = testRepository(
|
||||
fake, tempDir, dispatcher,
|
||||
fake, tempDir, io,
|
||||
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
|
||||
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
|
||||
)
|
||||
return RescheduleViewModel(repo, dispatcher, FakeZoneSource())
|
||||
return RescheduleViewModel(repo, io, FakeZoneSource())
|
||||
}
|
||||
|
||||
private fun slowViewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel =
|
||||
viewModel(tempDir, fake, io = StandardTestDispatcher(dispatcher.scheduler))
|
||||
|
||||
/** Every query answers with an occurrence of each of [ids] exactly at the queried span. */
|
||||
private fun FakeCalendarDataSource.occurrencesAt(ids: List<Long>) {
|
||||
instancesResult = { begin, end ->
|
||||
ids.map { id ->
|
||||
testEvent(
|
||||
id = id,
|
||||
start = Instant.fromEpochMilliseconds(begin),
|
||||
end = Instant.fromEpochMilliseconds(end),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val RescheduleViewModel.abandonedCount: Int get() = drops.value.abandoned.size
|
||||
|
||||
private fun CoroutineScope.activate(vm: RescheduleViewModel): Job =
|
||||
launch { vm.movableCalendarIds.collect {} }
|
||||
|
||||
@@ -97,6 +127,8 @@ class RescheduleViewModelTest {
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.ByDays(2),
|
||||
isAllDay = false,
|
||||
token = nextMoveToken(),
|
||||
)
|
||||
|
||||
/** A drop an hour later, expressed the way a timeline drag expresses one. */
|
||||
@@ -105,6 +137,8 @@ class RescheduleViewModelTest {
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
|
||||
isAllDay = false,
|
||||
token = nextMoveToken(),
|
||||
)
|
||||
|
||||
/** The end dragged an hour later, the start left where it is. */
|
||||
@@ -116,6 +150,8 @@ class RescheduleViewModelTest {
|
||||
Instant.fromEpochMilliseconds(beginMillis),
|
||||
Instant.fromEpochMilliseconds(endMillis + 3_600_000L),
|
||||
),
|
||||
isAllDay = false,
|
||||
token = nextMoveToken(),
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -259,21 +295,228 @@ class RescheduleViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second drop is refused while the first is still in flight`(
|
||||
fun `a drop of another event waits for the one in flight, then lands`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||
eventDetailResult = { detail() }
|
||||
}
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
|
||||
val first = oneHourLater()
|
||||
val second = toWednesday().copy(eventId = 7L, token = nextMoveToken())
|
||||
assertThat(vm.move(first)).isTrue()
|
||||
assertThat(vm.move(second)).isTrue()
|
||||
assertThat(vm.drops.value.pending).containsExactly(first.token, second.token)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.updatedEvents.map { it.first }).containsExactly(42L, 7L).inOrder()
|
||||
assertThat(vm.drops.value).isEqualTo(DropStates())
|
||||
assertThat(vm.inFlight.value).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed drop lets go of its own copy only, not the one queued behind it`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail() }
|
||||
}
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
|
||||
// Zero distance: refused in prepare.
|
||||
val nowhere = oneHourLater().copy(
|
||||
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
|
||||
token = nextMoveToken(),
|
||||
)
|
||||
val queued = toWednesday().copy(eventId = 7L, token = nextMoveToken())
|
||||
vm.move(nowhere)
|
||||
vm.move(queued)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.drops.value.abandoned).containsExactly(nowhere.token)
|
||||
assertThat(fake.updatedEvents.map { it.first }).containsExactly(7L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same event dropped twice ends up where it was dropped last`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val hour = 3_600_000L
|
||||
val fake = FakeCalendarDataSource()
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
// Once the first drop is written, the event sits an hour later.
|
||||
fake.eventDetailResult = {
|
||||
if (fake.updatedEvents.isEmpty()) detail() else detail(anchorMillis = beginMillis + hour)
|
||||
}
|
||||
fake.instancesResult = { _, _ ->
|
||||
val shift = if (fake.updatedEvents.isEmpty()) 0L else hour
|
||||
listOf(
|
||||
testEvent(
|
||||
id = 42L,
|
||||
start = Instant.fromEpochMilliseconds(beginMillis + shift),
|
||||
end = Instant.fromEpochMilliseconds(endMillis + shift),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
vm.move(oneHourLater())
|
||||
// Picked up from where the grid still drew it, and dropped two days on.
|
||||
vm.move(toWednesday())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.updatedEvents).hasSize(2)
|
||||
val last = fake.updatedEvents.last().third
|
||||
assertThat(last.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(13, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drop on a series written moments ago is not written`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
// A whole-series write shifts every occurrence; the grid still draws the
|
||||
// old ones, and another occurrence may now sit at the dragged time.
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail(rrule = "FREQ=DAILY") } }
|
||||
val vm = viewModel(tempDir, fake)
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
vm.moveWithScope(RecurringWriteScope.AllEvents)
|
||||
advanceUntilIdle()
|
||||
vm.consumeOutcome()
|
||||
|
||||
assertThat(vm.move(toWednesday())).isTrue()
|
||||
val again = toWednesday()
|
||||
vm.move(again)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Parked on the scope dialog, so the first drop still owns the pipeline.
|
||||
assertThat(vm.move(oneHourLater())).isFalse()
|
||||
assertThat(vm.scopePrompt.value).isNull()
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Stale)
|
||||
assertThat(vm.drops.value.abandoned).containsExactly(again.token)
|
||||
assertThat(fake.updatedEvents).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single occurrence written leaves the rest of its series movable`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail(rrule = "FREQ=WEEKLY") } }
|
||||
val vm = viewModel(tempDir, fake)
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
vm.moveWithScope(RecurringWriteScope.ThisEvent)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Another occurrence, a week on.
|
||||
val week = 7 * 86_400_000L
|
||||
vm.move(oneHourLater().copy(beginMillis = beginMillis + week, endMillis = endMillis + week, token = nextMoveToken()))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chip messages wait their turn instead of replacing each other`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { id ->
|
||||
if (id == 42L) detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE", status = EventStatus.Cancelled) else detail()
|
||||
}
|
||||
}
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
|
||||
vm.move(toWednesday())
|
||||
vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken()))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries)
|
||||
vm.consumeOutcome()
|
||||
assertThat((vm.outcome.value as MoveOutcome.Moved).undo?.eventId).isEqualTo(7L)
|
||||
vm.consumeOutcome()
|
||||
assertThat(vm.outcome.value).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full queue says so`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
|
||||
repeat(9) { i -> vm.move(toWednesday().copy(eventId = 100L + i, token = nextMoveToken())) }
|
||||
val refused = toWednesday().copy(eventId = 200L, token = nextMoveToken())
|
||||
assertThat(vm.move(refused)).isFalse()
|
||||
|
||||
assertThat(vm.drops.value.abandoned).containsExactly(refused.token)
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.QueueFull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deleted event reports itself as gone, not moved`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { null } }
|
||||
val vm = viewModel(tempDir, fake, present = emptyList())
|
||||
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Gone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drops waiting when a scope dialog opens are cancelled, not held behind it`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { id -> if (id == 42L) detail(rrule = "FREQ=WEEKLY") else detail() }
|
||||
}
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
val queued = toWednesday().copy(eventId = 7L, token = nextMoveToken())
|
||||
vm.move(queued)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isNotNull()
|
||||
assertThat(vm.drops.value.abandoned).containsExactly(queued.token)
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Skipped)
|
||||
|
||||
// One made while the dialog is up is refused the same way.
|
||||
val during = toWednesday().copy(eventId = 7L, token = nextMoveToken())
|
||||
assertThat(vm.move(during)).isFalse()
|
||||
|
||||
vm.moveWithScope(RecurringWriteScope.AllEvents)
|
||||
advanceUntilIdle()
|
||||
assertThat(fake.updatedEvents.map { it.first }).containsExactly(42L)
|
||||
// The skip has its turn, then the move it waited on, with its Undo.
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Skipped)
|
||||
vm.consumeOutcome()
|
||||
assertThat((vm.outcome.value as MoveOutcome.Moved).undo?.eventId).isEqualTo(42L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first move of a run keeps the chip and its undo`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken()))
|
||||
advanceUntilIdle()
|
||||
|
||||
val shown = vm.outcome.value as MoveOutcome.Moved
|
||||
assertThat(fake.updatedEvents).hasSize(2)
|
||||
assertThat(shown.undo?.eventId).isEqualTo(42L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a later move of the same event takes the first move's undo away`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
vm.move(toWednesday())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.updatedEvents).hasSize(2)
|
||||
assertThat((vm.outcome.value as MoveOutcome.Moved).undo).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -284,19 +527,19 @@ class RescheduleViewModelTest {
|
||||
advanceUntilIdle()
|
||||
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
|
||||
|
||||
// A second drop parks on its scope dialog, which holds the pipeline.
|
||||
// A second drop, of another event, parks on its scope dialog, which holds the pipeline.
|
||||
fake.eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||
vm.move(toWednesday())
|
||||
vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken()))
|
||||
advanceUntilIdle()
|
||||
|
||||
val abandoned = vm.abandoned.value
|
||||
val abandoned = vm.abandonedCount
|
||||
assertThat(vm.undo(undo)).isFalse()
|
||||
advanceUntilIdle()
|
||||
assertThat(fake.updatedEvents).hasSize(1)
|
||||
// Nothing ticks: the refusal started no journey of its own, and the
|
||||
// counter is global — a tick here would release the copy the drop that
|
||||
// *is* in flight is holding, before the grid has drawn it.
|
||||
assertThat(vm.abandoned.value).isEqualTo(abandoned)
|
||||
assertThat(vm.abandonedCount).isEqualTo(abandoned)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -311,13 +554,13 @@ class RescheduleViewModelTest {
|
||||
advanceUntilIdle()
|
||||
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
|
||||
|
||||
val abandoned = vm.abandoned.value
|
||||
val abandoned = vm.abandonedCount
|
||||
fake.writeError = SecurityException("permission revoked")
|
||||
assertThat(vm.undo(undo)).isTrue()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
|
||||
assertThat(vm.abandoned.value).isEqualTo(abandoned + 1)
|
||||
assertThat(vm.abandonedCount).isEqualTo(abandoned + 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -330,12 +573,12 @@ class RescheduleViewModelTest {
|
||||
advanceUntilIdle()
|
||||
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
|
||||
|
||||
val abandoned = vm.abandoned.value
|
||||
val abandoned = vm.abandonedCount
|
||||
vm.undo(undo)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone)
|
||||
assertThat(vm.abandoned.value).isEqualTo(abandoned)
|
||||
assertThat(vm.abandonedCount).isEqualTo(abandoned)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -415,6 +658,8 @@ class RescheduleViewModelTest {
|
||||
target = MoveTarget.Start(
|
||||
Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L),
|
||||
),
|
||||
isAllDay = false,
|
||||
token = nextMoveToken(),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
@@ -435,6 +680,8 @@ class RescheduleViewModelTest {
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
|
||||
isAllDay = false,
|
||||
token = nextMoveToken(),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
@@ -556,17 +803,19 @@ class RescheduleViewModelTest {
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
|
||||
isAllDay = false,
|
||||
token = nextMoveToken(),
|
||||
),
|
||||
)).isTrue()
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.abandoned.value).isEqualTo(1)
|
||||
assertThat(vm.abandonedCount).isEqualTo(1)
|
||||
|
||||
// Refused by the provider, after the write was attempted.
|
||||
fake.writeError = SecurityException("revoked")
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
|
||||
assertThat(vm.abandoned.value).isEqualTo(2)
|
||||
assertThat(vm.abandonedCount).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -580,11 +829,11 @@ class RescheduleViewModelTest {
|
||||
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.abandoned.value).isEqualTo(0)
|
||||
assertThat(vm.abandonedCount).isEqualTo(0)
|
||||
|
||||
vm.cancelScope()
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.abandoned.value).isEqualTo(1)
|
||||
assertThat(vm.abandonedCount).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -596,7 +845,7 @@ class RescheduleViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
|
||||
assertThat(vm.abandoned.value).isEqualTo(0)
|
||||
assertThat(vm.abandonedCount).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -668,6 +917,8 @@ class RescheduleViewModelTest {
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.Span(sundayEvening, Instant.fromEpochMilliseconds(endMillis)),
|
||||
isAllDay = false,
|
||||
token = nextMoveToken(),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
@@ -709,7 +960,7 @@ class RescheduleViewModelTest {
|
||||
|
||||
assertThat(vm.scopePrompt.value).isNull()
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries)
|
||||
assertThat(vm.abandoned.value).isEqualTo(1)
|
||||
assertThat(vm.abandonedCount).isEqualTo(1)
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
assertThat(fake.updatedOccurrences).isEmpty()
|
||||
}
|
||||
@@ -739,6 +990,8 @@ class RescheduleViewModelTest {
|
||||
beginMillis = dayStart,
|
||||
endMillis = dayStart + 86_400_000L,
|
||||
target = MoveTarget.Span(start, Instant.fromEpochMilliseconds(endMillis)),
|
||||
isAllDay = true,
|
||||
token = nextMoveToken(),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
@@ -775,6 +1028,8 @@ class RescheduleViewModelTest {
|
||||
beginMillis = dayStart,
|
||||
endMillis = dayStart + 86_400_000L,
|
||||
target = MoveTarget.Span(eight, eight + 1.hours),
|
||||
isAllDay = true,
|
||||
token = nextMoveToken(),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -53,7 +53,7 @@ class TimelineDropTest {
|
||||
@Test
|
||||
fun `dropping the head asks for exactly where it landed`() {
|
||||
val target = LocalDate(2026, 9, 2)
|
||||
val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0)
|
||||
val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0, token = 0L)
|
||||
|
||||
assertThat(drop.startInstant(zone)).isEqualTo(target.atTime(20, 0).toInstant(zone))
|
||||
}
|
||||
@@ -63,7 +63,7 @@ class TimelineDropTest {
|
||||
val tail = blockOn(sun)
|
||||
val offset = tail.clipOffsetMinutes(sun, zone)
|
||||
// Dragged three days back, same height in the column: Sun 6 -> Thu 3.
|
||||
val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset)
|
||||
val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset, token = 0L)
|
||||
|
||||
// The event began Sat 5 Sep 20:00, so it now begins Wed 2 Sep 20:00 —
|
||||
// the whole event moved three days, and no time of day was invented.
|
||||
@@ -75,7 +75,7 @@ class TimelineDropTest {
|
||||
fun `dragging the tail down the column re-times the event across midnight`() {
|
||||
val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
|
||||
// Tail top pulled from 00:00 to 03:00 on its own day.
|
||||
val drop = TimelineDrop(overnight, sun, 3 * 60, offset)
|
||||
val drop = TimelineDrop(overnight, sun, 3 * 60, offset, token = 0L)
|
||||
|
||||
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(23, 0).toInstant(zone))
|
||||
}
|
||||
@@ -84,7 +84,7 @@ class TimelineDropTest {
|
||||
fun `pulling the tail above its own midnight moves the event earlier`() {
|
||||
val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
|
||||
// The tail's top edge dragged an hour above the midnight it rests on.
|
||||
val drop = TimelineDrop(overnight, sun, -60, offset)
|
||||
val drop = TimelineDrop(overnight, sun, -60, offset, token = 0L)
|
||||
|
||||
// Event was Sat 20:00 to Sun 08:00; it now begins an hour earlier.
|
||||
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(19, 0).toInstant(zone))
|
||||
@@ -92,7 +92,7 @@ class TimelineDropTest {
|
||||
|
||||
@Test
|
||||
fun `a top edge dragged past the next midnight resolves onto the day after`() {
|
||||
val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0)
|
||||
val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0, token = 0L)
|
||||
|
||||
assertThat(drop.startInstant(zone)).isEqualTo(sun.atTime(1, 0).toInstant(zone))
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class TimelineDropTest {
|
||||
val tail = layoutDay(listOf(overnightIntoDst), sun, berlin).single()
|
||||
val offset = tail.clipOffsetMinutes(sun, berlin)
|
||||
// Dropped back onto its own slot a week earlier, clear of the boundary.
|
||||
val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset)
|
||||
val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset, token = 0L)
|
||||
|
||||
assertThat(drop.startInstant(berlin))
|
||||
.isEqualTo(LocalDate(2026, 3, 21).atTime(22, 0).toInstant(berlin))
|
||||
@@ -155,7 +155,7 @@ class TimelineDropTest {
|
||||
)
|
||||
val block = layoutDay(listOf(event), fallBack, brussels).single()
|
||||
assertThat(block.clipOffsetMinutes(fallBack, brussels)).isEqualTo(0)
|
||||
val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0)
|
||||
val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0, token = 0L)
|
||||
assertThat(drop.startInstant(brussels)).isEqualTo(event.start)
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ class TimelineDropTest {
|
||||
val tail = layoutDay(listOf(event), fallBack, brussels).single()
|
||||
val offset = tail.clipOffsetMinutes(fallBack, brussels)
|
||||
assertThat(offset).isEqualTo(2 * 60)
|
||||
val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset)
|
||||
val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset, token = 0L)
|
||||
assertThat(drop.startInstant(brussels)).isEqualTo(event.start)
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ class TimelineDropTest {
|
||||
end = Instant.parse("2026-10-25T02:30:00Z"),
|
||||
)
|
||||
val block = layoutDay(listOf(event), fallBack, brussels).single()
|
||||
val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0)
|
||||
val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0, token = 0L)
|
||||
assertThat(drop.startInstant(brussels)).isEqualTo(Instant.parse("2026-10-25T01:45:00Z"))
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ class TimelineDropTest {
|
||||
start = Instant.parse("2026-10-25T01:30:00Z"),
|
||||
end = Instant.parse("2026-10-25T03:00:00Z"),
|
||||
)
|
||||
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels)
|
||||
val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels)
|
||||
assertThat(drop.start).isEqualTo(event.start)
|
||||
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T01:45:00Z"))
|
||||
}
|
||||
@@ -246,7 +246,7 @@ class TimelineDropTest {
|
||||
start = Instant.parse("2026-10-25T00:30:00Z"),
|
||||
end = Instant.parse("2026-10-25T01:15:00Z"),
|
||||
)
|
||||
val drop = resizedDrop(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels)
|
||||
val drop = resizedSpan(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels)
|
||||
assertThat(drop.start).isEqualTo(Instant.parse("2026-10-25T00:00:00Z"))
|
||||
assertThat(drop.end).isEqualTo(event.end)
|
||||
}
|
||||
@@ -258,7 +258,7 @@ class TimelineDropTest {
|
||||
start = Instant.parse("2026-10-25T00:20:00Z"),
|
||||
end = Instant.parse("2026-10-25T01:10:00Z"),
|
||||
)
|
||||
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels)
|
||||
val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels)
|
||||
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:50:00Z"))
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ class TimelineDropTest {
|
||||
start = Instant.parse("2026-10-24T23:00:00Z"),
|
||||
end = Instant.parse("2026-10-25T03:00:00Z"),
|
||||
)
|
||||
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels)
|
||||
val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels)
|
||||
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:15:00Z"))
|
||||
}
|
||||
}
|
||||
@@ -356,12 +356,12 @@ class WeekLayoutTest {
|
||||
)
|
||||
assertThat(state.allDaySpans.map { it.lane }).containsExactly(0, 0)
|
||||
|
||||
val preview = state.withStripPreview(StripDrag(held, dayShift = 2), zone).allDaySpans
|
||||
val preview = state.withStripPreview(listOf(StripDrag(held, dayShift = 2)), zone).allDaySpans
|
||||
val moved = preview.single { it.event.instanceId == held.instanceId }
|
||||
|
||||
assertThat(moved.event).isEqualTo(held)
|
||||
assertThat(moved.startCol).isEqualTo(2)
|
||||
assertThat(preview.map { it.lane }).containsExactly(0, 1)
|
||||
assertThat(state.withStripPreview(StripDrag(held, dayShift = 2, overGrid = true), zone)).isSameInstanceAs(state)
|
||||
assertThat(state.withStripPreview(listOf(StripDrag(held, dayShift = 2, overGrid = true)), zone)).isSameInstanceAs(state)
|
||||
}
|
||||
}
|
||||
@@ -391,6 +391,36 @@ gate also covers a snooze re-shown from our own alarm after its calendar was
|
||||
switched off. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
|
||||
separate in-app declutter that never touches reminders.
|
||||
|
||||
**A channel per calendar (#145).** `ReminderChannels` keeps one notification
|
||||
channel per calendar, grouped by account (one that syncs no events only gets
|
||||
its channel once something posts on it), so a calendar can be
|
||||
muted or given its own sound in the system settings before it ever fires. Every
|
||||
scan with reminders on syncs them (skipped when nothing changed): missing
|
||||
channels are created, renamed ones updated, and channels of deleted calendars
|
||||
removed — but never on an empty snapshot, which is a provider hiccup more often
|
||||
than every calendar gone. Switched-off calendars keep theirs, so toggling one
|
||||
never cancels a showing reminder or loses its settings. `post` creates a missing
|
||||
channel itself, so a reminder never waits on a sync. A new channel copies the sound, vibration and
|
||||
importance of the pre-#145 single `reminders` channel, which is deleted once
|
||||
nothing showing still sits on it. Settings → Notifications deep-links into the
|
||||
system's channel screens for everything but the sound (#340). A sound picked in
|
||||
the app (#518) can't be set on an existing channel, so
|
||||
`ReminderChannels.setSound` recreates it under a new version
|
||||
(`reminders.calendar.<id>.v<n>`), copying everything else from the old one. The
|
||||
current version is simply the highest channel that exists, so the new one only
|
||||
takes over once written; `SettingsPrefs.reminderChannelVersions` is just a
|
||||
high-water mark that keeps ids from being reused (a re-used id would bring back
|
||||
the deleted channel's settings). `setSound` leaves the old version to the sync,
|
||||
which treats older versions as stale and keeps one while a notification still
|
||||
shows on it; `ReminderNotifier.post` resolves and posts under the channel lock
|
||||
(`withChannel`), so a pick never moves a channel mid-post.
|
||||
Separate from the channels, `SettingsPrefs.mutedReminderCalendarIds` is the in-app per-calendar
|
||||
off switch (#272): the scan plans nothing for those calendars (so they arm no
|
||||
wake-ups; toggling it re-scans), and `ReminderNotifier.post` checks it again
|
||||
for snoozes. The settings screen re-reads `ReminderChannels.status` on every
|
||||
resume and marks a calendar "Blocked in system settings" when the app, its
|
||||
channel or its account group is switched off there.
|
||||
|
||||
Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast.
|
||||
Keeping both would double-post wherever the provider works, and Etar's way out —
|
||||
a latch that disables its own scheduling once a real broadcast arrives — cannot
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
الإصلاحات
|
||||
• لم تعد الأحداث المنقولة بسرعة متتالية تضيع: تُحفظ عمليات النقل بالترتيب.
|
||||
• تظهر رسالة عند تعذّر حفظ عملية نقل.
|
||||
|
||||
التغييرات
|
||||
• يعرض عنوان عرض اليوم الشهر.
|
||||
@@ -0,0 +1,6 @@
|
||||
Выпраўлена
|
||||
• Падзеі, перанесеныя хутка адна за адной, больш не губляюцца: пераносы захоўваюцца па чарзе.
|
||||
• Паведамленне паказвае, калі перанос не ўдалося захаваць.
|
||||
|
||||
Зменена
|
||||
• Загаловак дзённага прагляду паказвае месяц.
|
||||
@@ -0,0 +1,6 @@
|
||||
Opraveno
|
||||
• Události rychle přesunuté za sebou se už neztrácí: přesuny se ukládají postupně.
|
||||
• Zpráva upozorní, když přesun nešel uložit.
|
||||
|
||||
Změněno
|
||||
• Název denního zobrazení ukazuje měsíc.
|
||||
@@ -0,0 +1,6 @@
|
||||
Behoben
|
||||
• Schnell hintereinander verschobene Termine gehen nicht mehr verloren: Verschiebungen werden der Reihe nach gespeichert.
|
||||
• Eine Meldung sagt, wenn eine Verschiebung nicht gespeichert werden konnte.
|
||||
|
||||
Geändert
|
||||
• Der Titel der Tagesansicht zeigt den Monat.
|
||||
@@ -0,0 +1,6 @@
|
||||
Fixed
|
||||
• Events moved in quick succession are no longer lost: moves are saved in order.
|
||||
• A message says when a move could not be saved.
|
||||
|
||||
Changed
|
||||
• The day view title shows the month.
|
||||
@@ -0,0 +1,6 @@
|
||||
Correcciones
|
||||
• Los eventos movidos rápidamente uno tras otro ya no se pierden: los cambios se guardan en orden.
|
||||
• Un mensaje avisa cuando no se pudo guardar un cambio.
|
||||
|
||||
Cambios
|
||||
• El título de la vista diaria muestra el mes.
|
||||
@@ -0,0 +1,6 @@
|
||||
Corrections
|
||||
• Les événements déplacés rapidement à la suite ne sont plus perdus : les déplacements sont enregistrés dans l’ordre.
|
||||
• Un message indique quand un déplacement n’a pas pu être enregistré.
|
||||
|
||||
Modifications
|
||||
• Le titre de la vue jour affiche le mois.
|
||||
@@ -0,0 +1,6 @@
|
||||
Javítva
|
||||
• A gyors egymásutánban áthelyezett események többé nem vesznek el: az áthelyezések sorban mentődnek.
|
||||
• Üzenet jelzi, ha egy áthelyezést nem sikerült menteni.
|
||||
|
||||
Változások
|
||||
• A napi nézet címe a hónapot mutatja.
|
||||
@@ -0,0 +1,6 @@
|
||||
Correzioni
|
||||
• Gli eventi spostati rapidamente uno dopo l’altro non vanno più persi: gli spostamenti vengono salvati in ordine.
|
||||
• Un messaggio avvisa quando uno spostamento non è stato salvato.
|
||||
|
||||
Modifiche
|
||||
• Il titolo della vista giornaliera mostra il mese.
|
||||
@@ -0,0 +1,6 @@
|
||||
修正
|
||||
• 続けてすばやく移動した予定が失われなくなりました。移動は順番に保存されます。
|
||||
• 移動を保存できなかったときはメッセージが表示されます。
|
||||
|
||||
変更
|
||||
• 日表示のタイトルに月が表示されるようになりました。
|
||||
@@ -0,0 +1,6 @@
|
||||
Opgelost
|
||||
• Snel na elkaar verplaatste afspraken gaan niet meer verloren: verplaatsingen worden op volgorde opgeslagen.
|
||||
• Een melding laat weten wanneer een verplaatsing niet kon worden opgeslagen.
|
||||
|
||||
Gewijzigd
|
||||
• De titel van de dagweergave toont de maand.
|
||||
@@ -0,0 +1,6 @@
|
||||
Poprawki
|
||||
• Wydarzenia szybko przenoszone jedno po drugim już nie giną: przeniesienia są zapisywane po kolei.
|
||||
• Komunikat informuje, gdy przeniesienia nie udało się zapisać.
|
||||
|
||||
Zmiany
|
||||
• Tytuł widoku dnia pokazuje miesiąc.
|
||||
@@ -0,0 +1,6 @@
|
||||
Correções
|
||||
• Eventos movidos rapidamente um após o outro não se perdem mais: as mudanças são salvas em ordem.
|
||||
• Uma mensagem avisa quando uma mudança não pôde ser salva.
|
||||
|
||||
Alterações
|
||||
• O título da visualização diária mostra o mês.
|
||||
@@ -0,0 +1,6 @@
|
||||
Correções
|
||||
• Os eventos movidos rapidamente uns após os outros já não se perdem: as alterações são guardadas por ordem.
|
||||
• Uma mensagem avisa quando uma alteração não pôde ser guardada.
|
||||
|
||||
Alterações
|
||||
• O título da vista diária mostra o mês.
|
||||
@@ -0,0 +1,6 @@
|
||||
Исправлено
|
||||
• События, быстро перенесённые одно за другим, больше не теряются: переносы сохраняются по очереди.
|
||||
• Сообщение показывает, если перенос не удалось сохранить.
|
||||
|
||||
Изменено
|
||||
• Заголовок дневного вида показывает месяц.
|
||||
@@ -0,0 +1,6 @@
|
||||
Opravené
|
||||
• Udalosti rýchlo presunuté za sebou sa už nestrácajú: presuny sa ukladajú postupne.
|
||||
• Správa upozorní, keď sa presun nepodarilo uložiť.
|
||||
|
||||
Zmenené
|
||||
• Názov denného zobrazenia ukazuje mesiac.
|
||||
@@ -0,0 +1,6 @@
|
||||
修复
|
||||
• 快速连续移动的日程不再丢失:移动会按顺序保存。
|
||||
• 移动无法保存时会显示提示。
|
||||
|
||||
变更
|
||||
• 日视图标题显示月份。
|
||||
@@ -0,0 +1,6 @@
|
||||
修正
|
||||
• 快速連續移動的活動不再遺失:移動會依序儲存。
|
||||
• 移動無法儲存時會顯示訊息。
|
||||
|
||||
變更
|
||||
• 日檢視標題會顯示月份。
|
||||
+1
-1
Submodule floret-kit updated: a039ab5bd4...40ef6992ae.
@@ -4,7 +4,7 @@ kotlin = "2.4.20"
|
||||
ksp = "2.3.11"
|
||||
hilt = "2.60.1"
|
||||
coreKtx = "1.19.0"
|
||||
appcompat = "1.7.1"
|
||||
appcompat = "1.8.0"
|
||||
lifecycleRuntime = "2.11.0"
|
||||
activityCompose = "1.13.0"
|
||||
composeBom = "2026.09.00"
|
||||
@@ -18,7 +18,7 @@ junitPlatform = "6.1.3"
|
||||
truth = "1.4.5"
|
||||
androidxJunit = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
kotlinxDatetime = "0.7.0"
|
||||
kotlinxDatetime = "0.8.0-0.6.x-compat"
|
||||
kotlinxCoroutines = "1.11.0"
|
||||
turbine = "1.2.1"
|
||||
hiltNavigationCompose = "1.4.0"
|
||||
|
||||
Reference in new issue
Block a user