Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f181ae7ac9 | ||
|
|
04f3b7ec8f | ||
|
|
31ba82a295 | ||
|
|
99a584a82d | ||
|
|
2e71e4dc5f | ||
|
|
955dccff2b | ||
|
|
7361e8d98e | ||
|
|
9178828b41 | ||
|
|
0b6cdf992e | ||
|
|
014d31f9b9 | ||
|
|
4287862f47 | ||
|
|
6cf6a6fdbf | ||
|
|
d2b6637f72 | ||
|
|
0b223f53fc | ||
|
|
c52c6c54cc | ||
|
|
fa999f8cf0 | ||
|
|
515c7c3d26 | ||
|
|
9b5880b719 | ||
|
|
994fc1026f | ||
|
|
e9cf7d66ba | ||
|
|
e01d6efb84 | ||
|
|
b7c801c8db | ||
|
|
b15e90cc64 | ||
|
|
8d3be6aba3 | ||
|
|
464ae8952e | ||
|
|
2007541adf | ||
|
|
d6167617a9 | ||
|
|
38d7c1494d | ||
|
|
c0c0d56e86 | ||
|
|
745301d14b | ||
|
|
8d640b595e | ||
|
|
1925e1c2f4 | ||
|
|
0595124ecd | ||
|
|
8e3202d1ad | ||
|
|
5f8eb39206 | ||
|
|
e7bf700f36 | ||
|
|
c10342818a | ||
|
|
0f29dc4db6 | ||
|
|
80fae240da | ||
|
|
ab2e11a2e4 | ||
|
|
fb4fd17603 | ||
|
|
86f7637213 | ||
|
|
c5084b86af | ||
|
|
919b95c378 | ||
|
|
ec7db10601 | ||
|
|
73403ee799 | ||
|
|
a294542eda | ||
|
|
786bde2965 | ||
|
|
e2ac8bbdc7 | ||
|
|
673cfc2f99 | ||
|
|
c533cc0439 | ||
|
|
7a49dffe0d | ||
|
|
e6ab85d382 | ||
|
|
011356e103 | ||
|
|
c8595ccedd | ||
|
|
9e1fd2956d | ||
|
|
6a1697dc5a | ||
|
|
2641145b60 | ||
|
|
70a7bf1033 | ||
|
|
a92dd013bc |
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,32 @@ 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
|
||||
- **A classic style for the agenda widget.** Its settings now have a Style option: Cards keeps the new look, Classic brings back the 2.23 layout with a line per day and a colour stripe beside each event, under the current header. Dates in another year show the year. Classic has an Invert text colour switch, so its text stays readable on light and dark wallpapers ([#489]).
|
||||
- **Widget opacity.** Every widget with a Solid or Tinted background has an Opacity slider from 20 to 100%. Below 50%, the card-style widgets back their content like a Transparent widget so it stays readable ([#489]).
|
||||
|
||||
### Fixed
|
||||
- **Week numbers match your region.** With weeks starting on Sunday, the month and week views showed every week number one too low. Rows are now numbered by your region's rules, so ISO regions such as Germany are unchanged ([#493]).
|
||||
- **The month widget shows events at every height.** At some sizes a week row had room for a little more than the date but not a full line of events, and drew none. It now fits a slimmer line there, and a row with a single line shows "+N" on days with more events than fit ([#491]).
|
||||
- **Dimmed past events in dark widgets are readable again.** They used to turn pastel with white text on top ([#475]).
|
||||
- **A widget set to Light or Dark matches the system colours.** With Material You, the + button and today's date could look different from a widget that follows the app ([#475]).
|
||||
|
||||
### Changed
|
||||
- **Fewer permissions.** Calendula no longer asks for network state, wake lock or foreground service access, which a library added without the app needing them ([#448]).
|
||||
- Translation updates from [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
|
||||
## [2.24.0] — 2026-11-06
|
||||
|
||||
### Added
|
||||
@@ -1952,3 +1978,10 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#473]: https://codeberg.org/jlmakiola/calendula/issues/473
|
||||
[#476]: https://codeberg.org/jlmakiola/calendula/issues/476
|
||||
[#483]: https://codeberg.org/jlmakiola/calendula/issues/483
|
||||
[#448]: https://codeberg.org/jlmakiola/calendula/issues/448
|
||||
[#475]: https://codeberg.org/jlmakiola/calendula/issues/475
|
||||
[#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 = 2240099
|
||||
versionName = "2.24.0"
|
||||
versionCode = 2240299
|
||||
versionName = "2.24.2"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -182,6 +182,7 @@ dependencies {
|
||||
implementation("de.jeanlucmakiola.floret:identity")
|
||||
implementation("de.jeanlucmakiola.floret:components")
|
||||
implementation("de.jeanlucmakiola.floret:glance")
|
||||
implementation("de.jeanlucmakiola.floret:ring")
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
|
||||
@@ -41,10 +41,15 @@
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- WorkManager declares these for network constraints and foreground workers, neither of which the app uses (#448). -->
|
||||
<!-- WorkManager declares this for network constraints, which the app doesn't use (#448). -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" tools:node="remove" />
|
||||
|
||||
<!-- Full-screen reminder alerts and their ring (#171), opt-in per calendar. -->
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||
returns null and the calendar manager's per-account "manage" button can't
|
||||
@@ -322,6 +327,20 @@
|
||||
android:name=".data.reminders.ReminderActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- The full-screen reminder alert (#171), in a task of its own. -->
|
||||
<activity
|
||||
android:name=".ui.reminders.ReminderAlertActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTask"
|
||||
android:taskAffinity="" />
|
||||
|
||||
<!-- Rings a "Ring until dismissed" reminder (#171). -->
|
||||
<service
|
||||
android:name=".data.reminders.ReminderRingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="systemExempted" />
|
||||
|
||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||
<activity
|
||||
android:name=".widget.config.WidgetConfigActivity"
|
||||
|
||||
@@ -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(",") },
|
||||
)
|
||||
@@ -5,6 +5,9 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.parseAlertStyles
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.toStoredAlertStyles
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
@@ -166,6 +169,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 reminderAlertStyles: Flow<Map<Long, ReminderAlertStyle>> = prefs.flow(SettingsKeys.reminderAlertStyles)
|
||||
val notificationCategoryHintDismissed: Flow<Boolean> = prefs.flow(SettingsKeys.notificationCategoryHintDismissed)
|
||||
val allowColorOnUnsupportedCalendars: Flow<Boolean> =
|
||||
prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars)
|
||||
val defaultReminderMinutes: Flow<List<Int>> = prefs.flow(SettingsKeys.defaultReminderMinutes)
|
||||
@@ -273,6 +279,16 @@ class SettingsPrefs @Inject constructor(
|
||||
|
||||
suspend fun setRemindersEnabled(enabled: Boolean) = prefs.set(SettingsKeys.remindersEnabled, enabled)
|
||||
|
||||
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 setCalendarAlertStyle(calendarId: Long, style: ReminderAlertStyle) {
|
||||
prefs.update(SettingsKeys.reminderAlertStyles) { it + (calendarId to style) }
|
||||
}
|
||||
|
||||
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) =
|
||||
prefs.set(SettingsKeys.allowColorOnUnsupportedCalendars, enabled)
|
||||
|
||||
@@ -594,6 +610,22 @@ 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")
|
||||
|
||||
/** Per-calendar alert styles (#171); a calendar absent from the map uses plain notifications. */
|
||||
val reminderAlertStyles: Pref<Map<Long, ReminderAlertStyle>> =
|
||||
nullableStringPref("reminder_alert_styles").map(::parseAlertStyles) { it.toStoredAlertStyles() }
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* What the full-screen alerts (#171) share within the process: the reminder
|
||||
* [ReminderRingService] is ringing for, and the reminders just dismissed or
|
||||
* snoozed, so an alert screen showing one closes whichever way it was handled.
|
||||
*/
|
||||
@Singleton
|
||||
class FullScreenAlerts @Inject constructor() {
|
||||
|
||||
/** Written only by [ReminderRingService], on the main thread, for as long as it rings. */
|
||||
@Volatile
|
||||
var ringing: ReminderAlert? = null
|
||||
internal set
|
||||
|
||||
/** Rings handed to [ReminderRingService] that have not reached it yet, with how many starts each. */
|
||||
private val pending = ConcurrentHashMap<Long, Pair<ReminderAlert, Int>>()
|
||||
|
||||
/** Set when the service is stopped from outside but its ring should go back to the shade. */
|
||||
@Volatile
|
||||
internal var handBackOnDestroy = false
|
||||
|
||||
internal fun addPending(alert: ReminderAlert) {
|
||||
pending.compute(alert.key) { _, entry -> alert to ((entry?.second ?: 0) + 1) }
|
||||
}
|
||||
|
||||
internal fun removePending(alert: ReminderAlert) {
|
||||
pending.computeIfPresent(alert.key) { _, entry -> if (entry.second <= 1) null else entry.first to entry.second - 1 }
|
||||
}
|
||||
|
||||
internal fun isPending(alert: ReminderAlert): Boolean = pending.containsKey(alert.key)
|
||||
|
||||
internal val anyPending: Boolean get() = pending.isNotEmpty()
|
||||
|
||||
/**
|
||||
* Whether a ring going or on its way [match]es, so a stop is worth sending.
|
||||
* Pending first: the service sets [ringing] before it lets a start go.
|
||||
*/
|
||||
fun mayRing(match: RingMatch): Boolean =
|
||||
pending.values.any { match.matches(it.first) } || ringing?.let(match::matches) == true
|
||||
|
||||
private val handledKeys = MutableSharedFlow<Long>(
|
||||
extraBufferCapacity = 8,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/** The [ReminderAlert.key]s of reminders as they are dismissed, snoozed or handed back. */
|
||||
val handled: SharedFlow<Long> = handledKeys.asSharedFlow()
|
||||
|
||||
fun markHandled(alert: ReminderAlert) {
|
||||
handledKeys.tryEmit(alert.key)
|
||||
}
|
||||
}
|
||||
|
||||
/** What becomes of a ring once its settings are re-read. */
|
||||
enum class RingVerdict { Ring, HandBack, Drop }
|
||||
|
||||
/** Which ring a stop is for. */
|
||||
sealed interface RingMatch {
|
||||
fun matches(alert: ReminderAlert): Boolean
|
||||
|
||||
data class Key(val key: Long) : RingMatch {
|
||||
override fun matches(alert: ReminderAlert) = alert.key == key
|
||||
}
|
||||
|
||||
data class Occurrence(val occurrenceKey: Long) : RingMatch {
|
||||
override fun matches(alert: ReminderAlert) = alert.occurrenceKey == occurrenceKey
|
||||
}
|
||||
|
||||
data class Calendar(val calendarId: Long) : RingMatch {
|
||||
override fun matches(alert: ReminderAlert) = alert.calendarId == calendarId
|
||||
}
|
||||
|
||||
data object Any : RingMatch {
|
||||
override fun matches(alert: ReminderAlert) = true
|
||||
}
|
||||
}
|
||||
+20
-11
@@ -13,8 +13,8 @@ import javax.inject.Inject
|
||||
/**
|
||||
* Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus
|
||||
* the internal re-show when a snooze elapses. All three are app-internal
|
||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||
* alarm), so the receiver is not exported.
|
||||
* intents (notification action buttons, the full-screen alert's buttons and
|
||||
* our own [ReminderSnoozeScheduler] alarm), so the receiver is not exported.
|
||||
*
|
||||
* - **Dismiss** just cancels the notification — the scan's watermark has moved
|
||||
* past this reminder, so nothing re-posts it.
|
||||
@@ -74,6 +74,9 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
*/
|
||||
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
|
||||
|
||||
/** Not handled here either: the slot of the full-screen alert's own intent (#171). */
|
||||
const val ACTION_ALERT_SCREEN = "de.jeanlucmakiola.calendula.reminders.ALERT_SCREEN"
|
||||
|
||||
private const val EXTRA_ALERT_KEY = "alert_key"
|
||||
private const val EXTRA_EVENT_ID = "event_id"
|
||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||
@@ -94,16 +97,21 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
data = "calendula://reminder/${identity(alert, action)}".toUri()
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
putExtra(EXTRA_END, alert.endMillis)
|
||||
putExtra(EXTRA_TITLE, alert.title)
|
||||
putExtra(EXTRA_LOCATION, alert.location)
|
||||
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
|
||||
putAlert(alert)
|
||||
}
|
||||
|
||||
/** Carries [alert] as extras, for [alertFrom] to read back. */
|
||||
fun Intent.putAlert(alert: ReminderAlert): Intent = apply {
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
putExtra(EXTRA_END, alert.endMillis)
|
||||
putExtra(EXTRA_TITLE, alert.title)
|
||||
putExtra(EXTRA_LOCATION, alert.location)
|
||||
putExtra(EXTRA_ALL_DAY, alert.isAllDay)
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable request code per (alert, action), so one notification's
|
||||
* PendingIntents stay distinct. The shift keeps the action slot intact;
|
||||
@@ -115,6 +123,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
ACTION_DISMISS -> 2
|
||||
ACTION_SHOW -> 3
|
||||
ACTION_OPEN -> 4
|
||||
ACTION_ALERT_SCREEN -> 5
|
||||
else -> 0
|
||||
}
|
||||
return (identity(alert, action).toInt() shl 3) + actionOffset
|
||||
@@ -123,7 +132,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
private fun identity(alert: ReminderAlert, action: String): Long =
|
||||
if (action == ACTION_SHOW) alert.occurrenceKey else alert.key
|
||||
|
||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||
return ReminderAlert(
|
||||
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
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"
|
||||
|
||||
/** The silent channel ringing full-screen alerts post on (#171); outside the per-calendar namespace. */
|
||||
const val RING_CHANNEL_ID = "reminders_ring"
|
||||
|
||||
private const val CHANNEL_PREFIX = "reminders.calendar."
|
||||
private const val GROUP_PREFIX = "reminders.account."
|
||||
private const val LOCAL_GROUP_ID = GROUP_PREFIX + "local"
|
||||
|
||||
/** The notification channel [calendarId]'s reminders post on. */
|
||||
fun reminderChannelId(calendarId: Long): String = CHANNEL_PREFIX + calendarId
|
||||
|
||||
/** 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>,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): 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 channels = calendars.map { calendar ->
|
||||
ReminderChannelSpec(
|
||||
id = reminderChannelId(calendar.id),
|
||||
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 existingById = existing.associateBy { it.id }
|
||||
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 }
|
||||
return ReminderChannelPlan(
|
||||
groups = groups,
|
||||
channels = channels,
|
||||
channelsToWrite = toWrite,
|
||||
groupsInUse = groups.filter { it.id in usedGroupIds },
|
||||
staleChannelIds = existingById.keys.filter { isReminderChannelId(it) && it !in wantedChannels },
|
||||
staleGroupIds = existingGroupIds.filter { isReminderGroupId(it) && it !in wantedGroups },
|
||||
)
|
||||
}
|
||||
|
||||
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(),
|
||||
/** The shared ring channel (#171) is switched off, so ringing calendars only sound once. */
|
||||
val ringBlocked: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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>,
|
||||
): ReminderChannelStatus {
|
||||
val groupBlocked = calendarIds.filterTo(HashSet()) { channels[reminderChannelId(it)]?.groupId in blockedGroupIds }
|
||||
val blocked = if (!appEnabled) {
|
||||
calendarIds.toSet()
|
||||
} else {
|
||||
calendarIds.filterTo(HashSet()) { channels[reminderChannelId(it)]?.blocked == true } + groupBlocked
|
||||
}
|
||||
return ReminderChannelStatus(
|
||||
appBlocked = !appEnabled,
|
||||
blockedCalendarIds = blocked,
|
||||
groupBlockedCalendarIds = groupBlocked,
|
||||
ringBlocked = appEnabled && channels[RING_CHANNEL_ID]?.blocked == true,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
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.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppLabel
|
||||
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.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderChannels @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
) {
|
||||
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 }
|
||||
if (syncKey(calendars, existingIds) == lastSync && LEGACY_REMINDER_CHANNEL_ID !in existingIds) return
|
||||
val groups = manager.notificationChannelGroups
|
||||
val plan = plan(calendars, existing, groups.map { it.id })
|
||||
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,
|
||||
)
|
||||
if (RING_CHANNEL_ID in existingIds) ensureRingChannel()
|
||||
val ids = existingIds - plan.staleChannelIds.toSet() + plan.channelsToWrite.map { it.id }
|
||||
lastSync = syncKey(calendars, if (retireLegacyChannel()) ids - LEGACY_REMINDER_CHANNEL_ID else ids)
|
||||
}
|
||||
|
||||
private fun syncKey(calendars: List<CalendarSource>, channelIds: Set<String>): Any = listOf(
|
||||
calendars.map { listOf(it.id, it.displayName, it.accountName, it.accountType, it.isLocal, it.syncsEvents) },
|
||||
Locale.getDefault(),
|
||||
channelIds,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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. A ringing [style] posts on the
|
||||
* shared ring channel instead, once the calendar is known to exist.
|
||||
*/
|
||||
@Synchronized
|
||||
fun channelFor(calendarId: Long, style: ReminderAlertStyle = ReminderAlertStyle.Notification): String? {
|
||||
val calendarChannel = calendarChannelFor(calendarId) ?: return null
|
||||
return if (style == ReminderAlertStyle.FullScreenRing) ringChannel() else calendarChannel
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel a ringing full-screen alert (#171) posts on, shared by every
|
||||
* calendar. Silent: the ring service owns the sound and vibration, so the
|
||||
* channel adds neither. High importance keeps the heads-up a denied
|
||||
* full-screen intent degrades to.
|
||||
*/
|
||||
private fun ringChannel(): String {
|
||||
ensureRingChannel()
|
||||
return RING_CHANNEL_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the ring channel, or renames it after a language change. Called
|
||||
* as soon as a calendar is set to ring, so the channel can be checked in
|
||||
* system settings (and switched on, where a skin starts it off) before the
|
||||
* first ring.
|
||||
*/
|
||||
@Synchronized
|
||||
fun ensureRingChannel() {
|
||||
val manager = manager
|
||||
val name = context.getString(R.string.reminder_ring_channel_name)
|
||||
val description = context.getString(R.string.reminder_ring_channel_description)
|
||||
val current = manager.getNotificationChannel(RING_CHANNEL_ID)
|
||||
if (current != null && current.name?.toString() == name && current.description == description) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
RING_CHANNEL_ID,
|
||||
name,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
this.description = description
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a ring may sound for [calendarId]: neither its own channel, its
|
||||
* account's group nor the ring channel is blocked, since the service plays
|
||||
* the sound itself.
|
||||
*/
|
||||
fun canRing(calendarId: Long): Boolean {
|
||||
val status = status(listOf(calendarId))
|
||||
return !status.appBlocked && calendarId !in status.blockedCalendarIds && !status.ringBlocked
|
||||
}
|
||||
|
||||
private fun calendarChannelFor(calendarId: Long): String? {
|
||||
val id = reminderChannelId(calendarId)
|
||||
val manager = manager
|
||||
if (manager.getNotificationChannel(id) != null) return id
|
||||
val existing = manager.notificationChannels
|
||||
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())
|
||||
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
|
||||
}
|
||||
|
||||
/** What the system currently lets through for [calendarIds]' reminders (#145). */
|
||||
fun status(calendarIds: Collection<Long>): ReminderChannelStatus {
|
||||
val manager = manager
|
||||
val appEnabled = manager.areNotificationsEnabled()
|
||||
val channels = manager.notificationChannels.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)
|
||||
}
|
||||
|
||||
private fun plan(
|
||||
calendars: List<CalendarSource>,
|
||||
existing: List<NotificationChannel>,
|
||||
existingGroupIds: List<String>,
|
||||
): 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),
|
||||
)
|
||||
|
||||
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 the old single channel's sound, vibration, lights and badge over to
|
||||
* a new per-calendar one (the system resets lock-screen visibility itself), so an upgrade keeps how reminders sounded.
|
||||
*/
|
||||
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 = runCatching {
|
||||
manager.activeNotifications.any { it.notification.channelId == LEGACY_REMINDER_CHANNEL_ID }
|
||||
}.getOrElse { e ->
|
||||
Log.w(TAG, "Could not read active notifications", e)
|
||||
true
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
@@ -18,7 +17,11 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.alertStyleFor
|
||||
import de.jeanlucmakiola.calendula.ui.reminders.ReminderAlertActivity
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.ring.canUseFullScreenIntent
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import java.time.DayOfWeek
|
||||
@@ -29,8 +32,9 @@ 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), or as a full-screen alert where the calendar
|
||||
* asks for one (#171). 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 +47,8 @@ class ReminderNotifier @Inject constructor(
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val channels: ReminderChannels,
|
||||
private val alerts: FullScreenAlerts,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -53,20 +59,96 @@ 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. */
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
/**
|
||||
* Post [alert] in its calendar's alert style (#171), unless the calendar is
|
||||
* silenced or gone. [ring] false posts a ringing style as a full-screen
|
||||
* alert that sounds once instead; [quiet] posts a plain notification that
|
||||
* makes no sound. Both are how a ring hands a reminder back to the shade.
|
||||
*/
|
||||
suspend fun post(alert: ReminderAlert, quiet: Boolean = false, ring: Boolean = true) {
|
||||
if (isSilenced(alert.calendarId)) return
|
||||
ensureChannel()
|
||||
var style = when {
|
||||
quiet -> ReminderAlertStyle.Notification
|
||||
else -> effectiveStyle(alert).let {
|
||||
if (it == ReminderAlertStyle.FullScreenRing && !ring) ReminderAlertStyle.FullScreen else it
|
||||
}
|
||||
}
|
||||
// Rung already and handed back to the shade: a catch-up post leaves it there.
|
||||
if (style == ReminderAlertStyle.FullScreenRing && (isShowing(alert) || alerts.isPending(alert))) return
|
||||
val channelId = channelFor(alert, style)
|
||||
// The ring channel could not be made: still full-screen, sounding once.
|
||||
?: if (style == ReminderAlertStyle.FullScreenRing) {
|
||||
style = ReminderAlertStyle.FullScreen
|
||||
channelFor(alert, style) ?: return
|
||||
} else {
|
||||
return
|
||||
}
|
||||
val title = displayTitle(alert)
|
||||
val text = contentText(alert)
|
||||
if (style == ReminderAlertStyle.FullScreenRing) {
|
||||
if (ReminderRingService.start(context, alerts, alert, build(alert, channelId, title, text, style, quiet = false))) {
|
||||
// An earlier reminder for this occurrence gives way to the ring.
|
||||
NotificationManagerCompat.from(context).cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID)
|
||||
cancelLegacy(alert)
|
||||
return
|
||||
}
|
||||
// Android refused the ring: still full-screen, sounding once on the calendar's channel.
|
||||
stopRing(RingMatch.Occurrence(alert.occurrenceKey))
|
||||
val fallback = channelFor(alert, ReminderAlertStyle.FullScreen) ?: return
|
||||
notify(alert, build(alert, fallback, title, text, ReminderAlertStyle.FullScreen, quiet = false))
|
||||
return
|
||||
}
|
||||
// A fresh reminder replaces a ring for its occurrence from before the style changed.
|
||||
if (ring) stopRing(RingMatch.Occurrence(alert.occurrenceKey))
|
||||
notify(alert, build(alert, channelId, title, text, style, quiet))
|
||||
}
|
||||
|
||||
/** Whether a ring for [alert] should go on, now that settings may have changed since it started. */
|
||||
suspend fun ringVerdict(alert: ReminderAlert): RingVerdict = when {
|
||||
!settingsPrefs.remindersEnabled.first() || !canPost() || isSilenced(alert.calendarId) ||
|
||||
calendarDataSource.isCalendarVisible(alert.calendarId) == null -> RingVerdict.Drop
|
||||
effectiveStyle(alert) != ReminderAlertStyle.FullScreenRing -> RingVerdict.HandBack
|
||||
else -> RingVerdict.Ring
|
||||
}
|
||||
|
||||
/** Whether a live ring for [alert] may go on: its notification can still be seen, and its calendar still notifies. */
|
||||
suspend fun canStillRing(alert: ReminderAlert): Boolean =
|
||||
canPost() && channels.canRing(alert.calendarId) && !isSilenced(alert.calendarId)
|
||||
|
||||
/** The calendar's style, short of ringing where the user blocked its calendar, group or the ring channel. */
|
||||
private suspend fun effectiveStyle(alert: ReminderAlert): ReminderAlertStyle {
|
||||
val chosen = settingsPrefs.reminderAlertStyles.first().alertStyleFor(alert.calendarId)
|
||||
return if (chosen == ReminderAlertStyle.FullScreenRing && !channels.canRing(alert.calendarId)) {
|
||||
ReminderAlertStyle.FullScreen
|
||||
} else {
|
||||
chosen
|
||||
}
|
||||
}
|
||||
|
||||
private fun channelFor(alert: ReminderAlert, style: ReminderAlertStyle): String? = try {
|
||||
channels.channelFor(alert.calendarId, style).also {
|
||||
if (it == null) Log.i(TAG, "Dropped the reminder of event ${alert.eventId}: calendar ${alert.calendarId} is gone")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "No channel for the reminder of event ${alert.eventId}", e)
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun contentText(alert: ReminderAlert): String =
|
||||
listOfNotNull(timeText(alert), alert.location).joinToString(" · ")
|
||||
|
||||
/** When [alert]'s occurrence happens, as its notification words it. */
|
||||
suspend fun timeText(alert: ReminderAlert): String {
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val zone = ZoneId.systemDefault()
|
||||
@@ -74,7 +156,7 @@ class ReminderNotifier @Inject constructor(
|
||||
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
|
||||
// its shared ISO number (1..7) for the date math in reminderTimeText.
|
||||
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
|
||||
val time = reminderTimeText(
|
||||
return reminderTimeText(
|
||||
beginMillis = alert.beginMillis,
|
||||
endMillis = alert.endMillis,
|
||||
isAllDay = alert.isAllDay,
|
||||
@@ -87,19 +169,35 @@ class ReminderNotifier @Inject constructor(
|
||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||
)
|
||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reminder's notification in [style]. A ringing one belongs to
|
||||
* [ReminderRingService]: ongoing and opening the alert screen. A full-screen
|
||||
* one adds the alert screen as a full-screen intent where Android allows
|
||||
* it, degrading to a heads-up where it does not, and is dismissed by a
|
||||
* swipe, so its screen closes too.
|
||||
*/
|
||||
private fun build(
|
||||
alert: ReminderAlert,
|
||||
channelId: String,
|
||||
title: String,
|
||||
text: String,
|
||||
style: ReminderAlertStyle,
|
||||
quiet: Boolean,
|
||||
): Notification {
|
||||
val ringing = style == ReminderAlertStyle.FullScreenRing
|
||||
val fullScreen = style.isFullScreen
|
||||
val builder = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setWhen(alert.beginMillis)
|
||||
.setShowWhen(false)
|
||||
.setCategory(NotificationCompat.CATEGORY_EVENT)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(shownKey(alert) == alert.key)
|
||||
.setCategory(if (ringing) NotificationCompat.CATEGORY_ALARM else NotificationCompat.CATEGORY_EVENT)
|
||||
.setPriority(if (ringing) NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH)
|
||||
.addExtras(Bundle().apply { putLong(EXTRA_REMINDER_KEY, alert.key) })
|
||||
.setContentIntent(detailIntent(alert))
|
||||
.setContentIntent(if (ringing) alertScreenIntent(alert) else detailIntent(alert))
|
||||
.addAction(
|
||||
R.drawable.ic_notification_snooze,
|
||||
context.getString(R.string.reminder_action_snooze),
|
||||
@@ -110,7 +208,18 @@ class ReminderNotifier @Inject constructor(
|
||||
context.getString(R.string.reminder_action_dismiss),
|
||||
actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
|
||||
)
|
||||
.build()
|
||||
if (fullScreen) builder.setDeleteIntent(actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS))
|
||||
if (ringing) {
|
||||
builder.setOngoing(true).setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
} else {
|
||||
builder.setAutoCancel(true).setOnlyAlertOnce(shownKey(alert) == alert.key)
|
||||
}
|
||||
if (quiet) builder.setSilent(true)
|
||||
if (fullScreen && context.canUseFullScreenIntent()) builder.setFullScreenIntent(alertScreenIntent(alert), true)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun notify(alert: ReminderAlert, notification: Notification) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
cancelLegacy(alert)
|
||||
try {
|
||||
@@ -131,10 +240,25 @@ class ReminderNotifier @Inject constructor(
|
||||
if (shownKey(alert) == alert.key) manager.cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID)
|
||||
// Posted before reminders shared a notification, under the reminder's own key.
|
||||
manager.cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||
stopRing(RingMatch.Key(alert.key))
|
||||
alerts.markHandled(alert)
|
||||
}
|
||||
|
||||
/** Stop the ring if it is for a reminder [match]es; [handBack] keeps it in the shade, quietly. */
|
||||
fun stopRing(match: RingMatch, handBack: Boolean = false) {
|
||||
if (alerts.mayRing(match)) ReminderRingService.stop(context, alerts, match, handBack)
|
||||
}
|
||||
|
||||
/** Whether a different reminder for [alert]'s occurrence is on screen now. */
|
||||
fun isSuperseded(alert: ReminderAlert): Boolean = shownKey(alert).let { it != null && it != alert.key }
|
||||
fun isSuperseded(alert: ReminderAlert): Boolean {
|
||||
val ringing = alerts.ringing?.takeIf { it.occurrenceKey == alert.occurrenceKey }
|
||||
if (ringing != null) return ringing.key != alert.key
|
||||
return shownKey(alert).let { it != null && it != alert.key }
|
||||
}
|
||||
|
||||
/** Whether [alert] itself is still on screen, as a notification or ringing. */
|
||||
fun isShowing(alert: ReminderAlert): Boolean =
|
||||
alerts.ringing?.key == alert.key || shownKey(alert) == alert.key
|
||||
|
||||
/** The key of the reminder showing for [alert]'s occurrence, if any. */
|
||||
private fun shownKey(alert: ReminderAlert): Long? {
|
||||
@@ -153,7 +277,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) }
|
||||
@@ -171,6 +295,13 @@ class ReminderNotifier @Inject constructor(
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun alertScreenIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_ALERT_SCREEN),
|
||||
ReminderAlertActivity.intent(context, alert),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
// Shares the per-(alert, action) request-code scheme with the buttons.
|
||||
@@ -181,21 +312,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.
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.RingtoneManager
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver.Companion.putAlert
|
||||
import de.jeanlucmakiola.floret.ring.RingVibrator
|
||||
import de.jeanlucmakiola.floret.ring.RingWakeLock
|
||||
import de.jeanlucmakiola.floret.ring.TonePlayer
|
||||
import de.jeanlucmakiola.floret.ring.isInCall
|
||||
import de.jeanlucmakiola.floret.ring.startRingForeground
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Rings a reminder whose calendar asks for "Ring until dismissed" (#171): the
|
||||
* alarm sound looped with vibration, for as long as the reminder is neither
|
||||
* dismissed nor snoozed, up to [RING_TIMEOUT_MILLIS]. Then it goes quiet and
|
||||
* hands the reminder back to the shade as an ordinary notification.
|
||||
*
|
||||
* One reminder rings at a time. A new one takes over, and the one it replaces
|
||||
* goes back to the shade, silently, unless it was for the same occurrence.
|
||||
* Starts and stops both arrive as commands, so they are handled on the main
|
||||
* thread in the order they were sent.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderRingService : Service() {
|
||||
|
||||
@Inject lateinit var alerts: FullScreenAlerts
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
|
||||
@Inject @ApplicationScope
|
||||
lateinit var appScope: CoroutineScope
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private val tones by lazy { TonePlayer(this) }
|
||||
private val vibrator by lazy { RingVibrator(this) }
|
||||
private val wakeLock by lazy { RingWakeLock(this, WAKE_LOCK_TAG, RING_TIMEOUT_MILLIS) }
|
||||
|
||||
private var ringing: Job? = null
|
||||
private var timeout: Job? = null
|
||||
private var notificationId = RING_NOTIFICATION_ID
|
||||
private var lastStartId: Int? = null
|
||||
|
||||
/** The start the system would redeliver, so a takeover can mark it done. */
|
||||
private var ringStartId: Int? = null
|
||||
|
||||
/** Whether the ring's players were ever built, so a stop-only service builds none. */
|
||||
private var rang = false
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
lastStartId = startId
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stop(intent)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
val alert = intent?.let(ReminderActionReceiver::alertFrom)
|
||||
if (intent == null || alert == null) {
|
||||
stopIfIdle()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
try {
|
||||
return startRing(intent, alert, startId)
|
||||
} finally {
|
||||
// Only now, once alerts.ringing says whether it rings, so a stop sent meanwhile is never skipped.
|
||||
if (flags and START_FLAG_REDELIVERY == 0) alerts.removePending(alert)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRing(intent: Intent, alert: ReminderAlert, startId: Int): Int {
|
||||
val notification = IntentCompat.getParcelableExtra(intent, EXTRA_NOTIFICATION, Notification::class.java)
|
||||
if (notification == null) {
|
||||
stopIfIdle()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
val previous = alerts.ringing
|
||||
// A different reminder posts under the other id, so it is a new
|
||||
// notification whose full-screen intent opens; an update would not.
|
||||
val replacing = previous != null && previous.key != alert.key
|
||||
val shown = notificationId
|
||||
if (replacing) notificationId = if (shown == RING_NOTIFICATION_ID) OTHER_RING_NOTIFICATION_ID else RING_NOTIFICATION_ID
|
||||
val foreground = runCatching { startRingForeground(notificationId, notification) }
|
||||
if (foreground.isFailure) {
|
||||
// A restart after a process death may be refused a foreground start:
|
||||
// the reminder still shows full-screen, sounding once.
|
||||
Log.w(TAG, "Could not ring for event ${alert.eventId}", foreground.exceptionOrNull())
|
||||
notificationId = shown
|
||||
handBack(alert, quiet = false)
|
||||
stopIfIdle()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
if (replacing) NotificationManagerCompat.from(this).cancel(shown)
|
||||
// A duplicate start for this very ring leaves the original in charge of redelivery.
|
||||
if (previous?.key == alert.key) return START_NOT_STICKY
|
||||
// Only the newest start is redelivered after a process death; the older
|
||||
// ones are done, so a handled reminder never rings again.
|
||||
ringStartId?.let { stopSelfResult(it) }
|
||||
ringStartId = startId
|
||||
alerts.ringing = alert
|
||||
if (previous != null) {
|
||||
// Its screen closes either way; another occurrence goes back to the shade.
|
||||
if (previous.occurrenceKey != alert.occurrenceKey) handBack(previous) else alerts.markHandled(previous)
|
||||
}
|
||||
val since = intent.getLongExtra(EXTRA_SINCE, System.currentTimeMillis())
|
||||
ring(alert, remaining = since + RING_TIMEOUT_MILLIS - System.currentTimeMillis())
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (rang) releaseRing()
|
||||
// Stopped from outside: its screen must not stay up for a ring that is gone.
|
||||
alerts.ringing?.let { if (alerts.handBackOnDestroy) handBack(it) else alerts.markHandled(it) }
|
||||
alerts.handBackOnDestroy = false
|
||||
alerts.ringing = null
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/** Rings for what is left of the ring's time, so a ring restarted after a process death does not begin again. */
|
||||
private fun ring(alert: ReminderAlert, remaining: Long) {
|
||||
releaseRing()
|
||||
rang = true
|
||||
wakeLock.acquire()
|
||||
timeout = scope.launch {
|
||||
delay(remaining.coerceAtLeast(0L))
|
||||
giveUp()
|
||||
}
|
||||
ringing = scope.launch {
|
||||
// Settings changed while this ring was on its way apply before it makes a sound.
|
||||
val verdict = try {
|
||||
withContext(Dispatchers.IO) { notifier.ringVerdict(alert) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not re-check the ring for event ${alert.eventId}", e)
|
||||
RingVerdict.Ring
|
||||
}
|
||||
if (verdict != RingVerdict.Ring) {
|
||||
// Never heard yet, so it alerts once in its new style.
|
||||
if (verdict == RingVerdict.HandBack) handBack(alert, quiet = false)
|
||||
end()
|
||||
return@launch
|
||||
}
|
||||
sound(alert)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tone and vibration, paused for a phone call and back after it, until the
|
||||
* user hides the ring's notification, which would leave no way to stop it.
|
||||
*/
|
||||
private suspend fun sound(alert: ReminderAlert) {
|
||||
var sounding = false
|
||||
var tick = 0
|
||||
while (true) {
|
||||
if (tick++ % VISIBILITY_CHECK_TICKS == 0 && !stillRings(alert)) {
|
||||
handBack(alert)
|
||||
end()
|
||||
return
|
||||
}
|
||||
val inCall = isInCall()
|
||||
if (inCall && sounding) {
|
||||
tones.stop()
|
||||
vibrator.stop()
|
||||
sounding = false
|
||||
} else if (!inCall && !sounding) {
|
||||
vibrator.start()
|
||||
tones.play(scope, ringSources())
|
||||
sounding = true
|
||||
}
|
||||
delay(CALL_CHECK_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun stillRings(alert: ReminderAlert): Boolean = try {
|
||||
withContext(Dispatchers.IO) { notifier.canStillRing(alert) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not re-check the ring for event ${alert.eventId}", e)
|
||||
true
|
||||
}
|
||||
|
||||
private fun stop(intent: Intent) {
|
||||
val current = alerts.ringing
|
||||
if (current != null && matchFrom(intent).matches(current)) {
|
||||
if (intent.getBooleanExtra(EXTRA_HAND_BACK, false)) handBack(current)
|
||||
end()
|
||||
} else {
|
||||
stopIfIdle()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rung long enough: back to the shade quietly. */
|
||||
private fun giveUp() {
|
||||
timeout = null
|
||||
alerts.ringing?.let(::handBack)
|
||||
end()
|
||||
}
|
||||
|
||||
/** Ends the ring, closes its screen, and stops the service unless a newer command is on its way. */
|
||||
private fun end() {
|
||||
alerts.ringing?.let(alerts::markHandled)
|
||||
releaseRing()
|
||||
alerts.ringing = null
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopIfIdle()
|
||||
}
|
||||
|
||||
/** Declines while a newer command is still on its way. */
|
||||
private fun stopIfIdle() {
|
||||
if (alerts.ringing == null) lastStartId?.let { stopSelfResult(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [alert] back in the shade, in the app's scope so a service stopped
|
||||
* meanwhile cannot cancel it, and its alert screen closed. A newer reminder
|
||||
* for the occurrence that took its place meanwhile is left alone.
|
||||
*/
|
||||
private fun handBack(alert: ReminderAlert, quiet: Boolean = true) {
|
||||
alerts.markHandled(alert)
|
||||
appScope.launch {
|
||||
try {
|
||||
if (!notifier.isSuperseded(alert)) notifier.post(alert, quiet = quiet, ring = false)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not hand the reminder of event ${alert.eventId} back", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseRing() {
|
||||
ringing?.cancel()
|
||||
ringing = null
|
||||
timeout?.cancel()
|
||||
timeout = null
|
||||
tones.stop()
|
||||
vibrator.stop()
|
||||
wakeLock.release()
|
||||
}
|
||||
|
||||
/** The device's alarm sound; "None", or none opening, leaves the vibration. */
|
||||
private fun ringSources() = listOfNotNull(
|
||||
RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_ALARM),
|
||||
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM),
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ReminderRingService"
|
||||
private const val ACTION_RING = "de.jeanlucmakiola.calendula.reminders.RING"
|
||||
private const val ACTION_STOP = "de.jeanlucmakiola.calendula.reminders.RING_STOP"
|
||||
private const val EXTRA_NOTIFICATION = "notification"
|
||||
private const val EXTRA_SINCE = "since"
|
||||
private const val CALL_CHECK_MILLIS = 1_000L
|
||||
|
||||
/** How many call checks between re-reading whether the ring's notification can still be seen. */
|
||||
private const val VISIBILITY_CHECK_TICKS = 5
|
||||
private const val EXTRA_MATCH_KIND = "match_kind"
|
||||
private const val EXTRA_MATCH_VALUE = "match_value"
|
||||
private const val EXTRA_HAND_BACK = "hand_back"
|
||||
private const val WAKE_LOCK_TAG = "calendula:reminder-ring"
|
||||
|
||||
/** Clear of the reminders' tagged id 1 and the backup notice's 2. */
|
||||
private const val RING_NOTIFICATION_ID = 171
|
||||
private const val OTHER_RING_NOTIFICATION_ID = 172
|
||||
|
||||
/** How long a reminder rings unanswered before it goes quiet. */
|
||||
const val RING_TIMEOUT_MILLIS = 10L * 60 * 1000
|
||||
|
||||
/**
|
||||
* Rings [alert] under [notification]. False when Android refused to
|
||||
* start the service from where the reminder fired.
|
||||
*/
|
||||
fun start(context: Context, alerts: FullScreenAlerts, alert: ReminderAlert, notification: Notification): Boolean {
|
||||
val intent = Intent(context, ReminderRingService::class.java)
|
||||
.setAction(ACTION_RING)
|
||||
.putAlert(alert)
|
||||
.putExtra(EXTRA_NOTIFICATION, notification)
|
||||
.putExtra(EXTRA_SINCE, System.currentTimeMillis())
|
||||
alerts.addPending(alert)
|
||||
return try {
|
||||
RingWakeLock.handOff(context, WAKE_LOCK_TAG)
|
||||
context.startForegroundService(intent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not start the ring for event ${alert.eventId}", e)
|
||||
alerts.removePending(alert)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the ring if it is for a reminder [match]es, in order with the
|
||||
* starts sent before it. [handBack] puts it back in the shade quietly.
|
||||
* A command rather than `stopService`, which would also drop a start
|
||||
* still on its way; sent while the ring's foreground service keeps the
|
||||
* app allowed to.
|
||||
*/
|
||||
fun stop(context: Context, alerts: FullScreenAlerts, match: RingMatch, handBack: Boolean = false) {
|
||||
val (kind, value) = when (match) {
|
||||
is RingMatch.Key -> 0 to match.key
|
||||
is RingMatch.Occurrence -> 1 to match.occurrenceKey
|
||||
is RingMatch.Calendar -> 2 to match.calendarId
|
||||
RingMatch.Any -> 3 to 0L
|
||||
}
|
||||
val intent = Intent(context, ReminderRingService::class.java)
|
||||
.setAction(ACTION_STOP)
|
||||
.putExtra(EXTRA_MATCH_KIND, kind)
|
||||
.putExtra(EXTRA_MATCH_VALUE, value)
|
||||
.putExtra(EXTRA_HAND_BACK, handBack)
|
||||
try {
|
||||
context.startService(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not stop the ring", e)
|
||||
// Refused from the background: nothing must ring on regardless,
|
||||
// unless a start is still on its way, which this would crash.
|
||||
if (!alerts.anyPending) {
|
||||
alerts.handBackOnDestroy = handBack
|
||||
context.stopService(Intent(context, ReminderRingService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchFrom(intent: Intent): RingMatch {
|
||||
val value = intent.getLongExtra(EXTRA_MATCH_VALUE, 0L)
|
||||
return when (intent.getIntExtra(EXTRA_MATCH_KIND, 3)) {
|
||||
0 -> RingMatch.Key(value)
|
||||
1 -> RingMatch.Occurrence(value)
|
||||
2 -> RingMatch.Calendar(value)
|
||||
else -> RingMatch.Any
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
/** How a calendar's reminders get the user's attention (#171). */
|
||||
enum class ReminderAlertStyle(val storageValue: String) {
|
||||
/** An ordinary notification on the calendar's channel. */
|
||||
Notification("notification"),
|
||||
|
||||
/** A full-screen alert that sounds the calendar's notification sound once and stays until handled. */
|
||||
FullScreen("full_screen"),
|
||||
|
||||
/** A full-screen alert that rings and vibrates like an alarm until handled. */
|
||||
FullScreenRing("full_screen_ring"),
|
||||
;
|
||||
|
||||
val isFullScreen: Boolean get() = this != Notification
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String): ReminderAlertStyle? = entries.firstOrNull { it.storageValue == value }
|
||||
}
|
||||
}
|
||||
|
||||
/** [calendarId]'s alert style; a calendar without one uses plain notifications. */
|
||||
fun Map<Long, ReminderAlertStyle>.alertStyleFor(calendarId: Long): ReminderAlertStyle =
|
||||
this[calendarId] ?: ReminderAlertStyle.Notification
|
||||
|
||||
/**
|
||||
* Parse `id=style` entries joined by `;`. Malformed entries and unknown styles
|
||||
* are dropped, so a garbled value degrades to plain notifications.
|
||||
*/
|
||||
fun parseAlertStyles(stored: String?): Map<Long, ReminderAlertStyle> =
|
||||
stored?.split(";").orEmpty().mapNotNull { entry ->
|
||||
val parts = entry.split("=")
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
|
||||
val style = ReminderAlertStyle.fromStorage(parts[1].trim()) ?: return@mapNotNull null
|
||||
calendarId to style
|
||||
}.toMap()
|
||||
|
||||
/** The stored form of [parseAlertStyles]; plain-notification entries are left out. */
|
||||
fun Map<Long, ReminderAlertStyle>.toStoredAlertStyles(): String =
|
||||
entries.filter { it.value != ReminderAlertStyle.Notification }
|
||||
.sortedBy { it.key }
|
||||
.joinToString(";") { (id, style) -> "$id=${style.storageValue}" }
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package de.jeanlucmakiola.calendula.ui.reminders
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.FullScreenAlerts
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderActionReceiver.Companion.putAlert
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlert
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderRingService
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.isDark
|
||||
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||
import de.jeanlucmakiola.floret.ring.showOverLockScreen
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The full-screen reminder alert (#171), opened by the reminder's full-screen
|
||||
* intent over the lock screen, or, for a ringing one, from its heads-up.
|
||||
* It stays until the reminder is dismissed or snoozed — here, from the
|
||||
* notification, or by the ring giving up — and back does not close it.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderAlertActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var alerts: FullScreenAlerts
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarDataSource: CalendarDataSource
|
||||
|
||||
@Inject @IoDispatcher
|
||||
lateinit var io: CoroutineDispatcher
|
||||
|
||||
private var state by mutableStateOf<ReminderAlertUiState>(ReminderAlertUiState.Loading)
|
||||
private var alert: ReminderAlert? = null
|
||||
private var loading: Job? = null
|
||||
private var screenOn: Job? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
showOverLockScreen()
|
||||
enableEdgeToEdge()
|
||||
show(intent)
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
alerts.handled.collect { key -> if (key == alert?.key) finish() }
|
||||
}
|
||||
}
|
||||
val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM
|
||||
val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true
|
||||
setContent {
|
||||
val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme)
|
||||
val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor)
|
||||
val soften by settingsPrefs.softenCalendarColors.collectAsStateWithLifecycle(true)
|
||||
CalendulaTheme(
|
||||
darkTheme = themeMode.isDark(isSystemInDarkTheme()),
|
||||
dynamicColor = dynamicColor,
|
||||
pureBlack = themeMode.isPureBlack,
|
||||
) {
|
||||
CompositionLocalProvider(LocalSoftenColors provides soften) {
|
||||
BackHandler(enabled = true) { /* Handled by Dismiss or Snooze, not by leaving. */ }
|
||||
when (val current = state) {
|
||||
ReminderAlertUiState.Loading ->
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||
is ReminderAlertUiState.Showing -> ReminderAlertScreen(
|
||||
state = current,
|
||||
onSnooze = { act(ReminderActionReceiver.ACTION_SNOOZE) },
|
||||
onDismiss = { act(ReminderActionReceiver.ACTION_DISMISS) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
show(intent)
|
||||
}
|
||||
|
||||
/** Tapped from a stale notification, or handled while this window was away. */
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val current = alert
|
||||
if (current != null && !notifier.isShowing(current)) finish()
|
||||
}
|
||||
|
||||
private fun show(intent: Intent) {
|
||||
val next = ReminderActionReceiver.alertFrom(intent)
|
||||
if (next == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
alert = next
|
||||
// A ring still going, this one's or another's, keeps the screen up as long as it rings.
|
||||
val ringing = alerts.ringing != null
|
||||
keepScreenOn(if (ringing) ReminderRingService.RING_TIMEOUT_MILLIS else ONCE_SCREEN_ON_MILLIS)
|
||||
loading?.cancel()
|
||||
state = ReminderAlertUiState.Loading
|
||||
loading = lifecycleScope.launch {
|
||||
val calendar = try {
|
||||
withContext(io) { calendarDataSource.calendars().firstOrNull { it.id == next.calendarId } }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// The alert still shows, only without its calendar.
|
||||
Log.w(TAG, "Could not read the calendar of event ${next.eventId}", e)
|
||||
null
|
||||
}
|
||||
state = ReminderAlertUiState.Showing(
|
||||
title = next.title,
|
||||
time = notifier.timeText(next),
|
||||
location = next.location?.takeIf { it.isNotBlank() },
|
||||
calendarName = calendar?.displayName,
|
||||
calendarColor = calendar?.color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** An alert nobody answers lets the display sleep again; it stays on the lock screen. */
|
||||
private fun keepScreenOn(millis: Long) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
screenOn?.cancel()
|
||||
screenOn = lifecycleScope.launch {
|
||||
delay(millis)
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
private fun act(action: String) {
|
||||
val current = alert ?: return
|
||||
sendBroadcast(ReminderActionReceiver.intent(this, action, current))
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ReminderAlertActivity"
|
||||
private const val ONCE_SCREEN_ON_MILLIS = 60_000L
|
||||
|
||||
fun intent(context: Context, alert: ReminderAlert): Intent =
|
||||
Intent(context, ReminderAlertActivity::class.java)
|
||||
.setData("calendula://reminder-alert/${alert.key}".toUri())
|
||||
.putAlert(alert)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package de.jeanlucmakiola.calendula.ui.reminders
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Event
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material.icons.outlined.Snooze
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.ring.RingBadge
|
||||
|
||||
/**
|
||||
* The alert itself, after the incoming-call screen: the calendar's pulsing
|
||||
* badge, what and when in the middle, and the two ways out at the bottom,
|
||||
* large enough to hit half awake.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ReminderAlertScreen(
|
||||
state: ReminderAlertUiState.Showing,
|
||||
onSnooze: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding()
|
||||
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Centred, but scrolls rather than pushing the buttons off a short landscape screen.
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.heightIn(min = maxHeight),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
state.calendarName?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.size(24.dp))
|
||||
}
|
||||
CalendarBadge(state.calendarColor)
|
||||
Spacer(Modifier.size(32.dp))
|
||||
Text(
|
||||
text = state.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
text = state.time,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
state.location?.let { location ->
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.LocationOn,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text(
|
||||
text = location,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(24.dp))
|
||||
Row(
|
||||
modifier = Modifier.widthIn(max = 480.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
AlertButton(
|
||||
label = stringResource(R.string.reminder_action_snooze),
|
||||
icon = Icons.Outlined.Snooze,
|
||||
tonal = true,
|
||||
onClick = onSnooze,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AlertButton(
|
||||
label = stringResource(R.string.reminder_action_dismiss),
|
||||
icon = Icons.Rounded.Close,
|
||||
tonal = false,
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The calendar's colour as the badge fill, or the theme's primary container without one. */
|
||||
@Composable
|
||||
private fun CalendarBadge(color: Int?) {
|
||||
if (color == null) {
|
||||
RingBadge(icon = Icons.Outlined.Event, size = 120.dp)
|
||||
return
|
||||
}
|
||||
val fill = eventFill(color, LocalFloretDarkTheme.current, LocalSoftenColors.current)
|
||||
RingBadge(
|
||||
icon = Icons.Outlined.Event,
|
||||
containerColor = fill,
|
||||
contentColor = eventInk(fill, alpha = 1f),
|
||||
size = 120.dp,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun AlertButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
tonal: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val height = ButtonDefaults.LargeContainerHeight
|
||||
val content: @Composable () -> Unit = {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.iconSizeFor(height)))
|
||||
Spacer(Modifier.size(ButtonDefaults.iconSpacingFor(height)))
|
||||
Text(label, style = ButtonDefaults.textStyleFor(height), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
val buttonModifier = modifier.heightIn(min = height)
|
||||
val padding = ButtonDefaults.contentPaddingFor(height)
|
||||
if (tonal) {
|
||||
FilledTonalButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
shapes = ButtonDefaults.shapesFor(height),
|
||||
contentPadding = padding,
|
||||
) { content() }
|
||||
} else {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
shapes = ButtonDefaults.shapesFor(height),
|
||||
contentPadding = padding,
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.reminders
|
||||
|
||||
sealed interface ReminderAlertUiState {
|
||||
/** The calendar is still being read; the surface is already up. */
|
||||
data object Loading : ReminderAlertUiState
|
||||
|
||||
data class Showing(
|
||||
/** Raw title; blank shows as "(no title)". */
|
||||
val title: String,
|
||||
val time: String,
|
||||
val location: String?,
|
||||
/** Null when the calendar could not be read. */
|
||||
val calendarName: String?,
|
||||
val calendarColor: Int?,
|
||||
) : ReminderAlertUiState
|
||||
}
|
||||
+327
-56
@@ -9,28 +9,39 @@ import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.reminders.RING_CHANNEL_ID
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.alertStyleFor
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
@@ -39,17 +50,23 @@ 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.openNotificationSettings
|
||||
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.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.ring.canUseFullScreenIntent
|
||||
import de.jeanlucmakiola.floret.ring.openFullScreenIntentSettings
|
||||
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalTime
|
||||
|
||||
/**
|
||||
@@ -84,8 +101,21 @@ internal fun NotificationsScreen(
|
||||
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||
var showSnooze by remember { mutableStateOf(false) }
|
||||
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||
var alertStylePicker by remember { mutableStateOf<Long?>(null) }
|
||||
var alertSoundPicker by remember { mutableStateOf<Long?>(null) }
|
||||
val canFullScreen = rememberCanUseFullScreenIntent()
|
||||
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 openCalendarSound: (Long) -> Unit = { calendarId ->
|
||||
scope.launch { context.openNotificationSettings(viewModel.soundSettingsChannel(calendarId)) }
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_notifications),
|
||||
@@ -93,6 +123,19 @@ internal fun NotificationsScreen(
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
if (IS_SAMSUNG && !state.notificationCategoryHintDismissed) {
|
||||
NotificationCategoryHint(
|
||||
onOpen = {
|
||||
viewModel.dismissNotificationCategoryHint()
|
||||
scope.launch {
|
||||
viewModel.syncReminderChannels()
|
||||
context.openNotificationSettings()
|
||||
}
|
||||
},
|
||||
onDismiss = viewModel::dismissNotificationCategoryHint,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_reminders),
|
||||
checked = state.remindersEnabled,
|
||||
@@ -100,6 +143,22 @@ internal fun NotificationsScreen(
|
||||
summary = stringResource(R.string.settings_reminders_hint),
|
||||
position = Position.Top,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
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 = {
|
||||
scope.launch {
|
||||
viewModel.syncReminderChannels()
|
||||
context.openNotificationSettings()
|
||||
}
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = reminderChoiceLabel(state.defaultReminderMinutes),
|
||||
@@ -149,10 +208,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 +224,26 @@ 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
|
||||
val heldBack = fullScreenHeldBack(
|
||||
style = state.reminderAlertStyles.alertStyleFor(calendar.id),
|
||||
notifying = notifying,
|
||||
blocked = blocked,
|
||||
ringBlocked = channelStatus.ringBlocked,
|
||||
canFullScreen = canFullScreen,
|
||||
)
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = when {
|
||||
!notifying -> stringResource(R.string.settings_calendar_notifications_off)
|
||||
blocked -> stringResource(R.string.settings_notifications_blocked)
|
||||
heldBack.any -> stringResource(R.string.settings_full_screen_held_back)
|
||||
else -> null
|
||||
},
|
||||
position = if (expanded) Position.Top else Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = { ExpandIcon(expanded) },
|
||||
@@ -207,22 +260,21 @@ 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,
|
||||
heldBack = heldBack,
|
||||
onOpenRingSettings = { context.openNotificationSettings(RING_CHANNEL_ID) },
|
||||
onSetNotifying = { viewModel.setCalendarRemindersEnabled(calendar.id, it) },
|
||||
onOpenSound = { openCalendarSound(calendar.id) },
|
||||
onEditAlertStyle = { alertStylePicker = calendar.id },
|
||||
onEditAlertSound = { alertSoundPicker = calendar.id },
|
||||
onAllowFullScreen = { context.openFullScreenIntentSettings() },
|
||||
onOpenSpecialDates = onOpenSpecialDates,
|
||||
onEditOverride = { overrideDialog = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,6 +282,51 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
alertStylePicker?.let { calendarId ->
|
||||
val current = state.reminderAlertStyles.alertStyleFor(calendarId)
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_alert_style),
|
||||
options = listOf(ReminderAlertStyle.Notification, ReminderAlertStyle.FullScreen),
|
||||
selected = if (current.isFullScreen) ReminderAlertStyle.FullScreen else ReminderAlertStyle.Notification,
|
||||
label = { alertStyleLabel(it) },
|
||||
summary = {
|
||||
stringResource(
|
||||
if (it.isFullScreen) {
|
||||
R.string.settings_alert_style_full_screen_summary
|
||||
} else {
|
||||
R.string.settings_alert_style_notification_summary
|
||||
},
|
||||
)
|
||||
},
|
||||
onSelect = { picked ->
|
||||
if (picked.isFullScreen != current.isFullScreen) {
|
||||
viewModel.setCalendarAlertStyle(calendarId, picked)
|
||||
// Guide the user straight to the switch Android 14+ may hold back.
|
||||
if (picked.isFullScreen && !canFullScreen) context.openFullScreenIntentSettings()
|
||||
}
|
||||
},
|
||||
onDismiss = { alertStylePicker = null },
|
||||
)
|
||||
}
|
||||
alertSoundPicker?.let { calendarId ->
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_alert_sound),
|
||||
options = listOf(ReminderAlertStyle.FullScreen, ReminderAlertStyle.FullScreenRing),
|
||||
selected = state.reminderAlertStyles.alertStyleFor(calendarId),
|
||||
label = { alertSoundLabel(it) },
|
||||
summary = {
|
||||
stringResource(
|
||||
if (it == ReminderAlertStyle.FullScreenRing) {
|
||||
R.string.settings_alert_sound_ring_summary
|
||||
} else {
|
||||
R.string.settings_alert_sound_once_summary
|
||||
},
|
||||
)
|
||||
},
|
||||
onSelect = { viewModel.setCalendarAlertStyle(calendarId, it) },
|
||||
onDismiss = { alertSoundPicker = null },
|
||||
)
|
||||
}
|
||||
if (showSnooze) {
|
||||
DurationPicker(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
@@ -309,6 +406,149 @@ 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,
|
||||
heldBack: FullScreenHeldBack,
|
||||
onOpenRingSettings: () -> Unit,
|
||||
onSetNotifying: (Boolean) -> Unit,
|
||||
onOpenSound: () -> Unit,
|
||||
onEditAlertStyle: () -> Unit,
|
||||
onEditAlertSound: () -> Unit,
|
||||
onAllowFullScreen: () -> Unit,
|
||||
onOpenSpecialDates: () -> Unit,
|
||||
onEditOverride: (OverrideTarget) -> Unit,
|
||||
) {
|
||||
val managed = calendar.id in state.managedCalendarIds
|
||||
val takesOverrides = calendar.canModifyContents && !managed
|
||||
val alertStyle = state.reminderAlertStyles.alertStyleFor(calendar.id)
|
||||
val needsPermission = heldBack.permission
|
||||
val ringHeldBack = heldBack.ring
|
||||
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 = when {
|
||||
blocked -> stringResource(R.string.settings_notifications_blocked_hint)
|
||||
alertStyle == ReminderAlertStyle.FullScreenRing ->
|
||||
stringResource(R.string.settings_calendar_notification_sound_ring_hint)
|
||||
else -> stringResource(R.string.settings_calendar_notification_sound_hint)
|
||||
},
|
||||
position = Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onOpenSound,
|
||||
)
|
||||
val lastAlertRow = !(managed || takesOverrides)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_alert_style),
|
||||
summary = alertStyleLabel(alertStyle),
|
||||
position = if (lastAlertRow && !alertStyle.isFullScreen) Position.Bottom else Position.Middle,
|
||||
onClick = onEditAlertStyle,
|
||||
)
|
||||
if (alertStyle.isFullScreen) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_alert_sound),
|
||||
summary = alertSoundLabel(alertStyle),
|
||||
position = if (lastAlertRow && !needsPermission && !ringHeldBack) Position.Bottom else Position.Middle,
|
||||
onClick = onEditAlertSound,
|
||||
)
|
||||
}
|
||||
if (ringHeldBack) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.reminder_ring_channel_name),
|
||||
summary = stringResource(R.string.settings_ring_blocked_hint),
|
||||
position = if (lastAlertRow && !needsPermission) Position.Bottom else Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onOpenRingSettings,
|
||||
)
|
||||
}
|
||||
if (needsPermission) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_full_screen_permission),
|
||||
summary = stringResource(R.string.settings_full_screen_permission_hint),
|
||||
position = if (lastAlertRow) Position.Bottom else Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onAllowFullScreen,
|
||||
)
|
||||
}
|
||||
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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
|
||||
@@ -328,6 +568,39 @@ private fun calendarOverrideSummary(
|
||||
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||
}
|
||||
|
||||
/** What keeps a full-screen calendar from alerting as chosen: the missing permission, or the blocked ring channel. */
|
||||
private data class FullScreenHeldBack(val permission: Boolean, val ring: Boolean) {
|
||||
val any: Boolean get() = permission || ring
|
||||
}
|
||||
|
||||
private fun fullScreenHeldBack(
|
||||
style: ReminderAlertStyle,
|
||||
notifying: Boolean,
|
||||
blocked: Boolean,
|
||||
ringBlocked: Boolean,
|
||||
canFullScreen: Boolean,
|
||||
): FullScreenHeldBack {
|
||||
val live = notifying && !blocked
|
||||
return FullScreenHeldBack(
|
||||
permission = live && style.isFullScreen && !canFullScreen,
|
||||
ring = live && style == ReminderAlertStyle.FullScreenRing && ringBlocked,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun alertStyleLabel(style: ReminderAlertStyle): String = stringResource(
|
||||
if (style.isFullScreen) R.string.settings_alert_style_full_screen else R.string.settings_alert_style_notification,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun alertSoundLabel(style: ReminderAlertStyle): String = stringResource(
|
||||
if (style == ReminderAlertStyle.FullScreenRing) R.string.settings_alert_sound_ring else R.string.settings_alert_sound_once,
|
||||
)
|
||||
|
||||
/** Whether Android lets full-screen alerts open, re-read on every `ON_RESUME` like [rememberBatteryOptimizationExempt]. */
|
||||
@Composable
|
||||
private fun rememberCanUseFullScreenIntent(): Boolean = rememberOnResume { it.canUseFullScreenIntent() }
|
||||
|
||||
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||
|
||||
@@ -336,20 +609,18 @@ private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||
* `ON_RESUME` so a change made in system settings shows up at once.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberBatteryOptimizationExempt(): Boolean {
|
||||
private fun rememberBatteryOptimizationExempt(): Boolean = rememberOnResume(::isIgnoringBatteryOptimizations)
|
||||
|
||||
/** [read], re-read on every `ON_RESUME` so a change made in system settings shows up at once. */
|
||||
@Composable
|
||||
private fun rememberOnResume(read: (Context) -> Boolean): Boolean {
|
||||
val context = LocalContext.current
|
||||
var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
exempt = isIgnoringBatteryOptimizations(context)
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
var value by remember { mutableStateOf(read(context)) }
|
||||
LifecycleResumeEffect(context) {
|
||||
value = read(context)
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
return exempt
|
||||
return value
|
||||
}
|
||||
|
||||
private fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -96,8 +97,16 @@ 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(),
|
||||
/** Per-calendar alert styles (#171); absent means a plain notification. */
|
||||
val reminderAlertStyles: Map<Long, ReminderAlertStyle> = emptyMap(),
|
||||
/** 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).
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.settings
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -20,6 +21,7 @@ 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,8 +35,13 @@ 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.ReminderNotifier
|
||||
import de.jeanlucmakiola.calendula.data.reminders.RingMatch
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
@@ -45,7 +52,9 @@ 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.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -62,11 +71,14 @@ 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,
|
||||
private val reminderNotifier: ReminderNotifier,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
@@ -74,16 +86,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 +137,11 @@ 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],
|
||||
reminderAlertStyles = this[SettingsKeys.reminderAlertStyles],
|
||||
notificationCategoryHintDismissed = this[SettingsKeys.notificationCategoryHintDismissed],
|
||||
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
|
||||
managedCalendarIds = specialDatesCalendars.values.toSet(),
|
||||
)
|
||||
@@ -353,10 +369,87 @@ class SettingsViewModel @Inject constructor(
|
||||
fun setRemindersEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(enabled)
|
||||
if (!enabled) reminderNotifier.stopRing(RingMatch.Any)
|
||||
reminderScanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
if (!enabled) reminderNotifier.stopRing(RingMatch.Calendar(calendarId))
|
||||
reminderScanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCalendarAlertStyle(calendarId: Long, style: ReminderAlertStyle) {
|
||||
viewModelScope.launch {
|
||||
prefs.setCalendarAlertStyle(calendarId, style)
|
||||
// Up front, so it can be checked in system settings before the first ring.
|
||||
if (style == ReminderAlertStyle.FullScreenRing) {
|
||||
onChannels { ensureRingChannel() }
|
||||
refreshChannelStatus(state.value.reminderCalendars.map { it.id })
|
||||
}
|
||||
// A ring for it goes quiet but stays in the shade.
|
||||
if (style != ReminderAlertStyle.FullScreenRing) {
|
||||
reminderNotifier.stopRing(RingMatch.Calendar(calendarId), handBack = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
fun refreshChannelStatus(calendarIds: Collection<Long>) {
|
||||
channelStatusJob?.cancel()
|
||||
channelStatusJob = viewModelScope.launch {
|
||||
onChannels { status(calendarIds) }?.let { _channelStatus.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
val ringing = state.value.reminderAlertStyles.containsValue(ReminderAlertStyle.FullScreenRing)
|
||||
onChannels {
|
||||
sync()
|
||||
if (ringing) ensureRingChannel()
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
)
|
||||
@@ -292,7 +292,7 @@
|
||||
<string name="settings_dim_completed_summary">تعتيم الأحداث التي انتهت بالفعل في عرض الشهر والأسبوع</string>
|
||||
<string name="settings_today_toolbar">زر اليوم في شريط الأدوات</string>
|
||||
<string name="settings_today_toolbar_summary">إظهار زر الانتقال إلى اليوم في شريط الأدوات بدلاً من زر عائم</string>
|
||||
<string name="settings_app_name_summary">اعرض Calendula كـ ”Calendar“ في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
|
||||
<string name="settings_app_name_summary">اعرض Calendula كـ ”التقويم“ في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
|
||||
<string name="settings_past_events_dim">تعتيم</string>
|
||||
<string name="settings_past_events">الأحداث السابقة</string>
|
||||
<string name="settings_agenda_range">مدى الجدول</string>
|
||||
|
||||
@@ -697,7 +697,7 @@
|
||||
<string name="onboarding_calendars_benefit_sync_title">Або наладзьце сынхранізацыю з уліковым запісам</string>
|
||||
<string name="onboarding_calendars_created_more_title">Прастора для іншых</string>
|
||||
<string name="onboarding_calendars_created_title">«%1$s» гатовы</string>
|
||||
<string name="onboarding_calendars_benefit_sync_body">Calendula паказвае ўліковыя запісы, якія ўжо сынхранізуюцца. Для выкананьня сынхранізацыі сэрвэры CalDAV патрабуець DAVx\\u2075.</string>
|
||||
<string name="onboarding_calendars_benefit_sync_body">Calendula паказвае ўліковыя запісы, якія ўжо сынхранізуюцца. Для выкананьня сынхранізацыі сэрвэры CalDAV патрабуець DAVx.</string>
|
||||
<string name="onboarding_calendars_created_body">Ён застаецца на гэтай прыладзе. У ім будуць запісвацца новыя падзеі, пакуль ня будзе выбраны іншы каляндар.</string>
|
||||
<string name="onboarding_calendars_created_more_body">Праз мэню Налады → Календары можна задаць колькасьць, назвы і колеры наяўных календароў.</string>
|
||||
<string name="onboarding_calendars_created_continue">Далей</string>
|
||||
|
||||
@@ -374,7 +374,7 @@
|
||||
<string name="settings_special_dates_type_anniversary">Anniversaires</string>
|
||||
<string name="settings_special_dates_type_custom">Autres dates</string>
|
||||
<string name="settings_special_dates_template">Format du titre</string>
|
||||
<string name="settings_special_dates_template_hint">Utilisez {name} pour le nom et {year} pour l’année (celle de naissance ou celle du début d’un anniversaire ; masqué si inconnu).</string>
|
||||
<string name="settings_special_dates_template_hint">Utilisez {name} pour le nom et {year} pour l’année (celle de naissance ou celle du début d’un anniversaire ; masqué si inconnu).</string>
|
||||
<string name="settings_special_dates_show_year">Montrer l\'année</string>
|
||||
<string name="settings_special_dates_show_year_hint">Inclure {year} dans les titres lorsque l\'année est connue</string>
|
||||
<string name="settings_special_dates_sync_now">Synchroniser maintenant</string>
|
||||
@@ -412,7 +412,7 @@
|
||||
<string name="calendars_edit_title">Editer un agenda</string>
|
||||
<string name="calendars_name_label">Nom</string>
|
||||
<string name="calendars_description_hint">Ajouter une description</string>
|
||||
<string name="calendars_delete_confirm_title">Supprimer ce calendrier ?</string>
|
||||
<string name="calendars_delete_confirm_title">Supprimer ce calendrier ?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" et tous ses événements seront définitivement retirés de cet appareil.</string>
|
||||
<string name="calendars_write_error">Impossible de sauvegarder les changements.</string>
|
||||
<string name="calendars_backup_hint">Les calendriers locaux ne sont synchronisés nulle part, alors exportez-les vers un fichier .ics pour en garder une copie.</string>
|
||||
@@ -515,16 +515,16 @@
|
||||
<string name="event_edit_timezone_all">Toutes les zones horaire</string>
|
||||
<string name="event_edit_timezone_none">Aucune zone horaire ne correspond à “%1$s”</string>
|
||||
<string name="event_edit_recurrence_incomplete">Entrez un nombre entre 1 et 999</string>
|
||||
<string name="event_edit_recurrence_next">Prochain : %1$s</string>
|
||||
<string name="event_edit_recurrence_next">Prochain : %1$s</string>
|
||||
<string name="event_detail_title">Titre</string>
|
||||
<string name="event_access_private_summary">Les autres ne voient seulement que vous êtes occupé</string>
|
||||
<string name="event_access_confidential_summary">Marqué confidentiel ; ce que cela veut dire est au bon vouloir de votre compte de calendrier</string>
|
||||
<string name="event_access_confidential_summary">Marqué confidentiel ; ce que cela veut dire est au bon vouloir de votre compte de calendrier</string>
|
||||
<string name="reminder_after">%1$s après</string>
|
||||
<string name="duration_custom_max">Au plus %1$s</string>
|
||||
<string name="onboarding_step_counter">Étape %1$d sur %2$d</string>
|
||||
<string name="settings_today_toolbar_summary">Afficher un bouton pour revenir au jour présent dans la barre d\'outils au lieu d\'un bouton flottant</string>
|
||||
<string name="settings_app_name">Nom de l\'application</string>
|
||||
<string name="settings_app_name_summary">Afficher Calendula comme \"Calendrier\" dans votre lanceur d\'applications. Le nom change seulement dans le lanceur ; l\'icône pourrait changer d\'emplacement après le renommage.</string>
|
||||
<string name="settings_app_name_summary">Afficher Calendula comme \"Calendrier\" dans votre lanceur d\'applications. Le nom change seulement dans le lanceur ; l\'icône pourrait changer d\'emplacement après le renommage.</string>
|
||||
<string name="settings_timeline_scale">Hauteur d\'une heure</string>
|
||||
<string name="timeline_scale_fit_day">Remplir complètement</string>
|
||||
<string name="timeline_scale_fit_day_summary">Affiche les 24 heures à l\'écran, pas de défilement</string>
|
||||
@@ -551,15 +551,15 @@
|
||||
<string name="event_access_public_summary">Toute personne ayant accès voit tous les détails</string>
|
||||
<string name="duration_hours_minutes">%1$s:%2$s</string>
|
||||
<string name="onboarding_backup_title">Vos événements ne sont accessibles qu\'ici</string>
|
||||
<string name="onboarding_backup_body">Aucune synchronisation n\'existe sur ce téléphone : le perdre signifierait perdre votre calendrier. Calendula peut faire des sauvegardes pour vous.</string>
|
||||
<string name="onboarding_backup_body">Aucune synchronisation n\'existe sur ce téléphone : le perdre signifierait perdre votre calendrier. Calendula peut faire des sauvegardes pour vous.</string>
|
||||
<string name="onboarding_backup_benefit_folder_title">Un dossier que vous choisissez</string>
|
||||
<string name="onboarding_backup_benefit_folder_body">Les sauvegardes sont juste des fichiers .ics — enregistrez-les dans un dossier synchronisé, ou bien sur une carte SD.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">Une fois par jour, automatiquement</string>
|
||||
<string name="onboarding_backup_benefit_daily_body">Calendula exporte vos calendriers locaux en arrière-plan. Changez la fréquence dans les réglages.</string>
|
||||
<string name="onboarding_backup_enable_button">Choisissez un dossier et sauvegarder</string>
|
||||
<string name="onboarding_backup_skip_button">Pas pour l\'instant</string>
|
||||
<string name="onboarding_view_title">Que doit s\'afficher en premier ?</string>
|
||||
<string name="onboarding_month_style_title">À quoi doivent ressembler les mois ?</string>
|
||||
<string name="onboarding_view_title">Que doit s\'afficher en premier ?</string>
|
||||
<string name="onboarding_month_style_title">À quoi doivent ressembler les mois ?</string>
|
||||
<string name="onboarding_view_continue_button">Continuer</string>
|
||||
<string name="onboarding_back">Retour</string>
|
||||
<string name="onboarding_visibility_button">Compris</string>
|
||||
@@ -579,9 +579,9 @@
|
||||
<string name="search_select_all">Tout sélectionner</string>
|
||||
<string name="search_delete_selected">Supprimer la sélection</string>
|
||||
<plurals name="search_delete_title">
|
||||
<item quantity="one">Supprimer %d événement ?</item>
|
||||
<item quantity="many">Supprimer %d événements ?</item>
|
||||
<item quantity="other">Supprimer %d événements ?</item>
|
||||
<item quantity="one">Supprimer %d événement ?</item>
|
||||
<item quantity="many">Supprimer %d événements ?</item>
|
||||
<item quantity="other">Supprimer %d événements ?</item>
|
||||
</plurals>
|
||||
<plurals name="search_delete_done">
|
||||
<item quantity="one">%d événement supprimé</item>
|
||||
@@ -595,9 +595,9 @@
|
||||
<string name="settings_font_specimen_heading">Jeudi 14 mai</string>
|
||||
<string name="settings_font_specimen_body">Revue d\'équipe à 10h00, puis déjeuner avec Robin au café de la Place du Marché.</string>
|
||||
<string name="settings_week_start_auto_summary">Actuellement %1$s</string>
|
||||
<string name="settings_today_toolbar">Bouton « aujourd\'hui » dans la barre d\'outils</string>
|
||||
<string name="settings_time_format_auto_summary">Suivant le système : %1$s</string>
|
||||
<string name="settings_timeline_scale_hint">Espace vertical occupé par une heure dans les vues « Semaine » et « Jour ». Ce paramètre s\'applique aux deux vues. Vous pouvez également étirer l\'affichage avec deux doigts pour utiliser n\'importe quelle hauteur.</string>
|
||||
<string name="settings_today_toolbar">Bouton « aujourd\'hui » dans la barre d\'outils</string>
|
||||
<string name="settings_time_format_auto_summary">Suivant le système : %1$s</string>
|
||||
<string name="settings_timeline_scale_hint">Espace vertical occupé par une heure dans les vues « Semaine » et « Jour ». Ce paramètre s\'applique aux deux vues. Vous pouvez également étirer l\'affichage avec deux doigts pour utiliser n\'importe quelle hauteur.</string>
|
||||
<string name="settings_widget_size_hint">Taille du texte affiché par le widget d\'agenda sur l\'écran d\'accueil. Le widget mensuel n\'a pas ce réglage — sa grille s\'adapte toujours à l\'espace que vous lui allouez.</string>
|
||||
<string name="settings_widget_size_small">Petit</string>
|
||||
<string name="settings_widget_size_medium">Moyen</string>
|
||||
@@ -637,7 +637,7 @@
|
||||
<string name="settings_week_day_header">Semaine & jour</string>
|
||||
<string name="settings_default_view_hint">La vue ouverte par Calendula au démarrage.</string>
|
||||
<string name="settings_week_start_hint">Le jour par lequel chaque semaine commence, dans toutes les vues et widgets.</string>
|
||||
<string name="settings_time_format_hint">Format d\'affichage de l\'heure dans l\'application. Le format « Automatique » suit les paramètres de votre système.</string>
|
||||
<string name="settings_time_format_hint">Format d\'affichage de l\'heure dans l\'application. Le format « Automatique » suit les paramètres de votre système.</string>
|
||||
<string name="settings_past_events_hint">Comment l\'agenda gère les événements déjà terminés.</string>
|
||||
<string name="settings_dynamic_color_summary">Les couleurs de l\'application proviennent de votre fond d\'écran.</string>
|
||||
<string name="settings_licences">Licences open source</string>
|
||||
@@ -646,16 +646,16 @@
|
||||
<string name="licences_title">Licences open source</string>
|
||||
<string name="licences_intro">Calendula comprend les projets ci-dessous. Appuyez sur n\'importe quelle entrée pour afficher son code source.</string>
|
||||
<string name="licences_footer">Chaque projet présenté ici est inclus tel quel, sans modifications, sous la licence indiquée.</string>
|
||||
<string name="calendars_visibility_hint">Désactivez un calendrier pour le masquer sur cet appareil : ses événements disparaîtront de l\'application et vous ne recevrez plus de rappels. Il s\'agit du même bouton que celui utilisé par vos autres applications de calendrier, ce qui signifie qu\'il sera aussi masqué sur ces dernières. Rien n\'est supprimé, aucun autre appareil n\'est affecté et vous pouvez le réactiver ici à tout moment.</string>
|
||||
<string name="calendars_visibility_hint">Désactivez un calendrier pour le masquer sur cet appareil : ses événements disparaîtront de l\'application et vous ne recevrez plus de rappels. Il s\'agit du même bouton que celui utilisé par vos autres applications de calendrier, ce qui signifie qu\'il sera aussi masqué sur ces dernières. Rien n\'est supprimé, aucun autre appareil n\'est affecté et vous pouvez le réactiver ici à tout moment.</string>
|
||||
<string name="calendars_visibility_a11y">Montrer \"%1$s\"</string>
|
||||
<string name="calendars_visibility_notice_title">Certains calendriers sont masqués</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula affiche les calendriers activés pour cet appareil, et certains des vôtres sont désactivés. Pour voir leurs événements, activez-les dans Réglages → Calendriers.</string>
|
||||
<string name="calendar_picker_missing_title">Il vous manque un calendrier ?</string>
|
||||
<string name="calendar_picker_missing_title">Il vous manque un calendrier ?</string>
|
||||
<string name="calendar_picker_missing_summary">Il est peut-être désactivé, en lecture seule ou rempli à partir de vos contacts — gérez vos calendriers ici.</string>
|
||||
<string name="calendars_state_read_only">Lecture seule</string>
|
||||
<string name="calendars_state_not_synced">Non synchronisé avec cet appareil</string>
|
||||
<string name="calendars_state_managed">Rempli à partir de vos contacts</string>
|
||||
<string name="calendars_managed_delete_locked">Ce calendrier est rempli à partir de vos contacts ; Calendula le recréera donc lors de la prochaine synchronisation. Pour le supprimer, désactivez les dates spéciales dans Paramètres → Dates spéciales.</string>
|
||||
<string name="calendars_managed_delete_locked">Ce calendrier est rempli à partir de vos contacts ; Calendula le recréera donc lors de la prochaine synchronisation. Pour le supprimer, désactivez les dates spéciales dans Paramètres → Dates spéciales.</string>
|
||||
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||
<string name="import_done_failed_label">Non ajouté</string>
|
||||
<string name="import_done_failed_note">Certains événements n\'ont pas pu être ajoutés et ont donc été omis.</string>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -373,6 +376,8 @@
|
||||
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||
<string name="reminder_action_snooze">Snooze</string>
|
||||
<string name="reminder_action_dismiss">Dismiss</string>
|
||||
<string name="reminder_ring_channel_name">Ringing reminders</string>
|
||||
<string name="reminder_ring_channel_description">Full-screen reminders that ring until you dismiss them. Calendula plays their sound itself.</string>
|
||||
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
|
||||
<string name="reminder_day_today">Today</string>
|
||||
<string name="reminder_day_tomorrow">Tomorrow</string>
|
||||
@@ -728,7 +733,32 @@
|
||||
<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_hint">Sound, vibration and pop-ups 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">Sound, vibration and pop-ups for this calendar, in system settings</string>
|
||||
<string name="settings_alert_style">Alert style</string>
|
||||
<string name="settings_alert_style_notification">Notification</string>
|
||||
<string name="settings_alert_style_notification_summary">A notification that pops up and waits in the shade</string>
|
||||
<string name="settings_alert_style_full_screen">Full-screen alert</string>
|
||||
<string name="settings_alert_style_full_screen_summary">Fills the screen, even on the lock screen, until you dismiss or snooze it</string>
|
||||
<string name="settings_alert_sound">Full-screen sound</string>
|
||||
<string name="settings_alert_sound_once">Play sound once</string>
|
||||
<string name="settings_alert_sound_once_summary">This calendar\'s notification sound, once</string>
|
||||
<string name="settings_alert_sound_ring">Ring until dismissed</string>
|
||||
<string name="settings_alert_sound_ring_summary">Your alarm sound and vibration, like an alarm, also in Do Not Disturb when alarms may sound</string>
|
||||
<string name="settings_calendar_notification_sound_ring_hint">Ringing reminders use your alarm sound. This sets the sound of the ones handed back to the shade.</string>
|
||||
<string name="settings_full_screen_held_back">Full-screen alerts held back</string>
|
||||
<string name="settings_ring_blocked_hint">Blocked in system settings, so this calendar\'s reminders only sound once — tap to allow</string>
|
||||
<string name="settings_full_screen_permission">Allow full-screen alerts</string>
|
||||
<string name="settings_full_screen_permission_hint">Android blocks Calendula\'s full-screen alerts, so they only pop up as notifications. Tap to allow them.</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>
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderAlertStyle
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
@@ -601,6 +602,31 @@ 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 `calendar alert styles default to notifications and persist per calendar`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
assertThat(prefs.reminderAlertStyles.first()).isEmpty()
|
||||
prefs.setCalendarAlertStyle(7L, ReminderAlertStyle.FullScreenRing)
|
||||
prefs.setCalendarAlertStyle(9L, ReminderAlertStyle.FullScreen)
|
||||
prefs.setCalendarAlertStyle(9L, ReminderAlertStyle.Notification)
|
||||
assertThat(prefs.reminderAlertStyles.first()).containsExactly(7L, ReminderAlertStyle.FullScreenRing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage per-calendar duration entries are dropped, the rest survive`(
|
||||
@TempDir tempDir: Path,
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
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 `a switched-off ring channel is reported while the app may notify`() {
|
||||
val ringOff = mapOf(RING_CHANNEL_ID to ReminderChannelState(blocked = true, groupId = null))
|
||||
assertThat(reminderChannelStatus(listOf(1L), appEnabled = true, ringOff, emptySet()).ringBlocked).isTrue()
|
||||
assertThat(reminderChannelStatus(listOf(1L), appEnabled = false, ringOff, emptySet()).ringBlocked).isFalse()
|
||||
assertThat(reminderChannelStatus(listOf(1L), appEnabled = true, emptyMap(), emptySet()).ringBlocked).isFalse()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DESC = "Reminders"
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ReminderAlertStyleTest {
|
||||
|
||||
@Test
|
||||
fun `styles round-trip through their stored form`() {
|
||||
val styles = mapOf(9L to ReminderAlertStyle.FullScreenRing, 3L to ReminderAlertStyle.FullScreen)
|
||||
|
||||
assertThat(parseAlertStyles(styles.toStoredAlertStyles())).isEqualTo(styles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plain notifications are not stored`() {
|
||||
val stored = mapOf(1L to ReminderAlertStyle.Notification, 2L to ReminderAlertStyle.FullScreen).toStoredAlertStyles()
|
||||
|
||||
assertThat(stored).isEqualTo("2=full_screen")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbled entries are dropped, the rest survive`() {
|
||||
assertThat(parseAlertStyles("x=full_screen;4=loud;5;6=full_screen_ring"))
|
||||
.containsExactly(6L, ReminderAlertStyle.FullScreenRing)
|
||||
assertThat(parseAlertStyles(null)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calendar without a style uses notifications`() {
|
||||
val styles = mapOf(1L to ReminderAlertStyle.FullScreen)
|
||||
|
||||
assertThat(styles.alertStyleFor(1L)).isEqualTo(ReminderAlertStyle.FullScreen)
|
||||
assertThat(styles.alertStyleFor(2L)).isEqualTo(ReminderAlertStyle.Notification)
|
||||
assertThat(ReminderAlertStyle.Notification.isFullScreen).isFalse()
|
||||
assertThat(ReminderAlertStyle.FullScreenRing.isFullScreen).isTrue()
|
||||
}
|
||||
}
|
||||
+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,52 @@ 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 only deep-links into
|
||||
the system's channel screens (#340); the app never sets a sound itself. Separate
|
||||
from both, `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.
|
||||
|
||||
**Full-screen alerts (#171).** Settings → Notifications gives each calendar an
|
||||
alert style (`SettingsPrefs.reminderAlertStyles`): a plain notification, a
|
||||
full-screen alert that sounds once, or one that rings until handled. Only
|
||||
`ReminderNotifier.post` reads it, and `ReminderChannels.channelFor(id, style)`
|
||||
picks the channel. "Sound once" stays on the calendar's own channel, so its
|
||||
sound is still the one chosen in system settings, and adds a full-screen intent
|
||||
to `ReminderAlertActivity`. "Ring" starts `ReminderRingService`, which goes
|
||||
foreground on the shared silent `reminders_ring` channel and loops the alarm
|
||||
sound with vibration through floret-kit's `ring` module — the same player
|
||||
Clockula's alarms use. One reminder rings at a time; a newer one takes over and
|
||||
hands the older back to the shade quietly, as does a ring left unanswered for
|
||||
ten minutes, and switching the calendar away from ringing. Dismiss and Snooze,
|
||||
from the screen, the notification or a swipe, all go through
|
||||
`ReminderActionReceiver`, so the ring stops and the screen closes
|
||||
(`FullScreenAlerts`) however the reminder was handled. Starts and stops reach
|
||||
the service as commands, so a dismiss sent while a newer ring is on its way
|
||||
applies in order rather than killing it, and the service re-reads the settings
|
||||
once a ring arrives, so a style change or mute that raced a scan still wins. A
|
||||
blocked calendar, account group or ring channel never rings (Settings flags a
|
||||
blocked ring channel next to each ringing calendar, beside #145's blocked status), the tone pauses
|
||||
for a phone call, and a ring restarted after a process death only rings out
|
||||
what was left of its ten minutes. When Android 14+
|
||||
withholds `USE_FULL_SCREEN_INTENT`, the intent is left off and the alert is a
|
||||
heads-up; Settings shows a row leading to the system switch. When Android
|
||||
refuses the ring service, the reminder falls back to sounding once.
|
||||
|
||||
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
|
||||
|
||||
+2
-3
@@ -96,9 +96,8 @@ The settings screen contains links to the source code, the licence, the issue tr
|
||||
- `USE_EXACT_ALARM`, `SCHEDULE_EXACT_ALARM`: deliver reminders at the exact time, including after snoozing.
|
||||
- `RECEIVE_BOOT_COMPLETED`: re-register pending reminders after a restart.
|
||||
- `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`: only to open the system dialog for the “Reliable delivery” setting.
|
||||
- `WAKE_LOCK`, `FOREGROUND_SERVICE`, `ACCESS_NETWORK_STATE`: required by the Android system component used for scheduled background work (WorkManager).
|
||||
|
||||
`ACCESS_NETWORK_STATE` allows reading *whether* a network connection exists. It does **not** permit using one. Without `INTERNET`, no connection is possible.
|
||||
- `USE_FULL_SCREEN_INTENT`, `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED`, `VIBRATE`: for calendars you set to full-screen alerts — showing the alert over the lock screen and, with “Ring until dismissed”, ringing and vibrating until you dismiss or snooze it.
|
||||
- `WAKE_LOCK`: keeps the phone awake while a full-screen alert rings, and lets the Android component that runs scheduled background work (WorkManager) finish it.
|
||||
|
||||
## 7. Distribution channels
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
الجديد
|
||||
• نمط كلاسيكي لأداة جدول الأعمال.
|
||||
• شريط تمرير لشفافية خلفية الأدوات.
|
||||
|
||||
الإصلاحات
|
||||
• أرقام الأسابيع كانت أقل بواحد عندما يبدأ الأسبوع يوم الأحد.
|
||||
• أداة الشهر تعرض الأحداث بأي ارتفاع.
|
||||
• الأحداث الباهتة في الأدوات الداكنة مقروءة من جديد.
|
||||
• الأدوات المضبوطة على فاتح أو داكن تطابق ألوان النظام.
|
||||
|
||||
التغييرات
|
||||
• أذونات أقل.
|
||||
@@ -0,0 +1,6 @@
|
||||
الإصلاحات
|
||||
• لم تعد الأحداث المنقولة بسرعة متتالية تضيع: تُحفظ عمليات النقل بالترتيب.
|
||||
• تظهر رسالة عند تعذّر حفظ عملية نقل.
|
||||
|
||||
التغييرات
|
||||
• يعرض عنوان عرض اليوم الشهر.
|
||||
@@ -0,0 +1,12 @@
|
||||
Новае
|
||||
• Класічны стыль для віджэта спісу падзей.
|
||||
• Паўзунок непразрыстасці для фону віджэтаў.
|
||||
|
||||
Выпраўлена
|
||||
• Нумары тыдняў былі на адзін меншыя, калі тыдзень пачынаецца ў нядзелю.
|
||||
• Віджэт месяца паказвае падзеі пры любой вышыні.
|
||||
• Прыглушаныя падзеі ў цёмных віджэтах зноў чытэльныя.
|
||||
• Віджэты са светлай ці цёмнай тэмай адпавядаюць колерам сістэмы.
|
||||
|
||||
Зменена
|
||||
• Менш дазволаў.
|
||||
@@ -0,0 +1,6 @@
|
||||
Выпраўлена
|
||||
• Падзеі, перанесеныя хутка адна за адной, больш не губляюцца: пераносы захоўваюцца па чарзе.
|
||||
• Паведамленне паказвае, калі перанос не ўдалося захаваць.
|
||||
|
||||
Зменена
|
||||
• Загаловак дзённага прагляду паказвае месяц.
|
||||
@@ -0,0 +1,12 @@
|
||||
Novinky
|
||||
• Klasický styl widgetu agendy.
|
||||
• Posuvník průhlednosti pozadí widgetů.
|
||||
|
||||
Opraveno
|
||||
• Čísla týdnů byla o jedno nižší, když týden začíná nedělí.
|
||||
• Widget měsíce zobrazí události v jakékoli výšce.
|
||||
• Ztlumené události v tmavých widgetech jsou opět čitelné.
|
||||
• Widgety se světlým nebo tmavým motivem odpovídají barvám systému.
|
||||
|
||||
Změněno
|
||||
• Méně oprávnění.
|
||||
@@ -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,12 @@
|
||||
Neu
|
||||
• Klassischer Stil für das Terminliste-Widget.
|
||||
• Deckkraft-Regler für Widget-Hintergründe.
|
||||
|
||||
Behoben
|
||||
• Kalenderwochen waren bei Wochenstart Sonntag eine zu niedrig.
|
||||
• Monats-Widget zeigt Termine in jeder Höhe.
|
||||
• Abgedunkelte Termine in dunklen Widgets wieder lesbar.
|
||||
• Widgets mit Hell oder Dunkel passen zu den Systemfarben.
|
||||
|
||||
Geändert
|
||||
• Weniger Berechtigungen.
|
||||
@@ -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,12 @@
|
||||
New
|
||||
• Classic style for the agenda widget.
|
||||
• Opacity slider for widget backgrounds.
|
||||
|
||||
Fixed
|
||||
• Week numbers were one too low when weeks start on Sunday.
|
||||
• The month widget shows events at every height.
|
||||
• Dimmed events in dark widgets are readable again.
|
||||
• Widgets set to Light or Dark match the system colours.
|
||||
|
||||
Changed
|
||||
• Fewer permissions.
|
||||
@@ -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,12 @@
|
||||
Novedades
|
||||
• Estilo clásico para el widget de agenda.
|
||||
• Control de opacidad para el fondo de los widgets.
|
||||
|
||||
Correcciones
|
||||
• Los números de semana salían uno menos con semanas que empiezan en domingo.
|
||||
• El widget de mes muestra eventos con cualquier altura.
|
||||
• Los eventos atenuados en widgets oscuros vuelven a leerse bien.
|
||||
• Los widgets en claro u oscuro coinciden con los colores del sistema.
|
||||
|
||||
Cambios
|
||||
• Menos permisos.
|
||||
@@ -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,12 @@
|
||||
Nouveautés
|
||||
• Style classique pour le widget agenda.
|
||||
• Curseur d’opacité pour le fond des widgets.
|
||||
|
||||
Corrections
|
||||
• Les numéros de semaine avaient un de moins avec des semaines commençant le dimanche.
|
||||
• Le widget mois affiche les événements à toutes les hauteurs.
|
||||
• Les événements estompés sont de nouveau lisibles dans les widgets sombres.
|
||||
• Les widgets en clair ou sombre suivent les couleurs du système.
|
||||
|
||||
Modifications
|
||||
• Moins d’autorisations.
|
||||
@@ -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,12 @@
|
||||
Újdonságok
|
||||
• Klasszikus stílus a napirend widgethez.
|
||||
• Átlátszóság-csúszka a widgetek hátteréhez.
|
||||
|
||||
Javítva
|
||||
• A hetek száma eggyel kevesebb volt, ha a hét vasárnap kezdődik.
|
||||
• A havi widget bármilyen magasságban mutatja az eseményeket.
|
||||
• A halványított események ismét olvashatók a sötét widgeteken.
|
||||
• A világos vagy sötét widgetek a rendszer színeit követik.
|
||||
|
||||
Változások
|
||||
• Kevesebb engedély.
|
||||
@@ -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,12 @@
|
||||
Novità
|
||||
• Stile classico per il widget agenda.
|
||||
• Cursore di opacità per lo sfondo dei widget.
|
||||
|
||||
Correzioni
|
||||
• I numeri delle settimane erano uno in meno con settimane che iniziano di domenica.
|
||||
• Il widget mese mostra gli eventi a ogni altezza.
|
||||
• Gli eventi attenuati nei widget scuri sono di nuovo leggibili.
|
||||
• I widget in chiaro o scuro seguono i colori di sistema.
|
||||
|
||||
Modifiche
|
||||
• Meno autorizzazioni.
|
||||
@@ -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,12 @@
|
||||
新機能
|
||||
• 予定リストウィジェットにクラシックスタイルを追加。
|
||||
• ウィジェット背景の不透明度スライダーを追加。
|
||||
|
||||
修正
|
||||
• 週の始まりが日曜日のとき、週番号が1つ少なく表示されていた問題。
|
||||
• 月のウィジェットがどの高さでも予定を表示するように。
|
||||
• 暗いウィジェットで薄く表示された予定が再び読みやすく。
|
||||
• ライト/ダーク固定のウィジェットがシステムの色に合うように。
|
||||
|
||||
変更
|
||||
• 必要な権限を削減。
|
||||
@@ -0,0 +1,6 @@
|
||||
修正
|
||||
• 続けてすばやく移動した予定が失われなくなりました。移動は順番に保存されます。
|
||||
• 移動を保存できなかったときはメッセージが表示されます。
|
||||
|
||||
変更
|
||||
• 日表示のタイトルに月が表示されるようになりました。
|
||||
@@ -0,0 +1,12 @@
|
||||
Nieuw
|
||||
• Klassieke stijl voor de agendawidget.
|
||||
• Schuifregelaar voor de dekking van widgetachtergronden.
|
||||
|
||||
Opgelost
|
||||
• Weeknummers waren één te laag als de week op zondag begint.
|
||||
• De maandwidget toont afspraken op elke hoogte.
|
||||
• Gedimde afspraken in donkere widgets zijn weer leesbaar.
|
||||
• Widgets op Licht of Donker passen bij de systeemkleuren.
|
||||
|
||||
Gewijzigd
|
||||
• Minder machtigingen.
|
||||
@@ -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,12 @@
|
||||
Nowości
|
||||
• Klasyczny styl widżetu agendy.
|
||||
• Suwak przezroczystości tła widżetów.
|
||||
|
||||
Poprawki
|
||||
• Numery tygodni były o jeden za niskie, gdy tydzień zaczyna się w niedzielę.
|
||||
• Widżet miesiąca pokazuje wydarzenia przy każdej wysokości.
|
||||
• Przyciemnione wydarzenia w ciemnych widżetach znów są czytelne.
|
||||
• Widżety z jasnym lub ciemnym motywem pasują do kolorów systemu.
|
||||
|
||||
Zmiany
|
||||
• Mniej uprawnień.
|
||||
@@ -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,12 @@
|
||||
Novidades
|
||||
• Estilo clássico para o widget de agenda.
|
||||
• Controle de opacidade para o fundo dos widgets.
|
||||
|
||||
Correções
|
||||
• Os números das semanas ficavam um a menos com semanas começando no domingo.
|
||||
• O widget de mês mostra eventos em qualquer altura.
|
||||
• Eventos esmaecidos em widgets escuros voltam a ser legíveis.
|
||||
• Widgets em claro ou escuro seguem as cores do sistema.
|
||||
|
||||
Alterações
|
||||
• Menos permissões.
|
||||
@@ -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,12 @@
|
||||
Novidades
|
||||
• Estilo clássico para o widget de agenda.
|
||||
• Controlo de opacidade para o fundo dos widgets.
|
||||
|
||||
Correções
|
||||
• Os números das semanas ficavam um abaixo com semanas a começar ao domingo.
|
||||
• O widget de mês mostra eventos em qualquer altura.
|
||||
• Eventos esbatidos em widgets escuros voltam a ser legíveis.
|
||||
• Widgets em claro ou escuro seguem as cores do sistema.
|
||||
|
||||
Alterações
|
||||
• Menos permissões.
|
||||
@@ -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,12 @@
|
||||
Новое
|
||||
• Классический стиль для виджета повестки.
|
||||
• Ползунок непрозрачности для фона виджетов.
|
||||
|
||||
Исправлено
|
||||
• Номера недель были на единицу меньше, если неделя начинается с воскресенья.
|
||||
• Виджет месяца показывает события при любой высоте.
|
||||
• Приглушённые события в тёмных виджетах снова читаемы.
|
||||
• Виджеты со светлой или тёмной темой соответствуют цветам системы.
|
||||
|
||||
Изменено
|
||||
• Меньше разрешений.
|
||||
@@ -0,0 +1,6 @@
|
||||
Исправлено
|
||||
• События, быстро перенесённые одно за другим, больше не теряются: переносы сохраняются по очереди.
|
||||
• Сообщение показывает, если перенос не удалось сохранить.
|
||||
|
||||
Изменено
|
||||
• Заголовок дневного вида показывает месяц.
|
||||
@@ -0,0 +1,12 @@
|
||||
Novinky
|
||||
• Klasický štýl widgetu agendy.
|
||||
• Posuvník priehľadnosti pozadia widgetov.
|
||||
|
||||
Opravené
|
||||
• Čísla týždňov boli o jedno nižšie, keď týždeň začína nedeľou.
|
||||
• Widget mesiaca zobrazí udalosti v akejkoľvek výške.
|
||||
• Stlmené udalosti v tmavých widgetoch sú opäť čitateľné.
|
||||
• Widgety so svetlým alebo tmavým motívom zodpovedajú farbám systému.
|
||||
|
||||
Zmenené
|
||||
• Menej povolení.
|
||||
@@ -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,12 @@
|
||||
新功能
|
||||
• 日程列表小部件新增经典样式。
|
||||
• 小部件背景新增不透明度滑块。
|
||||
|
||||
修复
|
||||
• 每周从星期日开始时,周数少了一。
|
||||
• 月份小部件在任何高度都会显示日程。
|
||||
• 深色小部件中变暗的日程重新清晰可读。
|
||||
• 设为浅色或深色的小部件与系统颜色一致。
|
||||
|
||||
变更
|
||||
• 减少了所需权限。
|
||||
@@ -0,0 +1,6 @@
|
||||
修复
|
||||
• 快速连续移动的日程不再丢失:移动会按顺序保存。
|
||||
• 移动无法保存时会显示提示。
|
||||
|
||||
变更
|
||||
• 日视图标题显示月份。
|
||||
@@ -0,0 +1,12 @@
|
||||
新功能
|
||||
• 行程列表小工具新增經典樣式。
|
||||
• 小工具背景新增不透明度滑桿。
|
||||
|
||||
修正
|
||||
• 每週從星期日開始時,週數少了一。
|
||||
• 月份小工具在任何高度都會顯示行程。
|
||||
• 深色小工具中變暗的行程重新清晰可讀。
|
||||
• 設為淺色或深色的小工具與系統顏色一致。
|
||||
|
||||
變更
|
||||
• 減少了所需權限。
|
||||
@@ -0,0 +1,6 @@
|
||||
修正
|
||||
• 快速連續移動的活動不再遺失:移動會依序儲存。
|
||||
• 移動無法儲存時會顯示訊息。
|
||||
|
||||
變更
|
||||
• 日檢視標題會顯示月份。
|
||||
+1
-1
Submodule floret-kit updated: a039ab5bd4...2fc730dd44.
@@ -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