Release 1.2.0 (#103)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Renovate / renovate (push) Successful in 1m11s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 18m4s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m33s
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped

Stable 1.2.0, after 1.2.0-beta.1 and beta.2.

- Version bumped to 1.2.0 (1020099); the CHANGELOG entries moved from Unreleased to 1.2.0.
- What's New for every shipped locale, now including cs-CZ.
- Full store listing (title, short and full description) for every locale; new for cs-CZ, ar and fr-FR. Play only gets these through the `listing` lane.
- `main` is merged in with the branch side kept. #81 and the Weblate updates already came in through the #88 squash, and the only lines unique to `main` were string keys that #88 removed.

The #75 fix landed after beta.2, so no reporter has tested it yet.

Closes #72, closes #78, closes #75, closes #92

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/103
This commit is contained in:
Jean-Luc Makiolaandmakiolaj committed 2026-10-26 20:41:58 +01:00
1 parent 1d6c2a0d59
commit 08c297f30a
110 files changed
+6491 -784

No files matched your search

@@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.provider.ProviderSnapshotSource
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater
import de.jeanlucmakiola.floret.crash.CrashConfig
@@ -87,6 +88,7 @@ class AgendulaApp : Application(), Configuration.Provider {
scheduler.sync()
}
runCatching { entryPoint.taskWidgetUpdater().start() }
runCatching { entryPoint.providerSnapshotSource().start() }
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
}
}
@@ -122,5 +124,6 @@ class AgendulaApp : Application(), Configuration.Provider {
fun taskNotifier(): TaskNotifier
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
fun taskWidgetUpdater(): TaskWidgetUpdater
fun providerSnapshotSource(): ProviderSnapshotSource
}
}
@@ -14,7 +14,11 @@ import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.asTaskReminder
import de.jeanlucmakiola.agendula.domain.asReminderLead
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
import de.jeanlucmakiola.floret.reminders.ReminderLead
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
@@ -57,10 +61,10 @@ data class Settings(
val dynamicColor: Boolean = true,
/** The list a new task defaults to; `null` = first available. */
val defaultListId: Long? = null,
/** Default reminders, as minutes before due (0 = at due time); empty = none. */
val defaultReminderMinutes: List<Int> = listOf(0),
/** Default reminders for all-day tasks, as days-scale minutes before [allDayReminderMinuteOfDay]. */
val defaultAllDayReminderMinutes: List<Int> = listOf(0),
/** Default reminders (0 before due = at due time); empty = none. */
val defaultReminders: List<ReminderLead> = listOf(ReminderLead(0)),
/** Default reminders for all-day tasks, in whole days, rung at [allDayReminderMinuteOfDay]. */
val defaultAllDayReminders: List<ReminderLead> = listOf(ReminderLead(0)),
/** Local time (minutes from midnight) an all-day task's reminder counts back from. */
val allDayReminderMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
/** Master switch for due reminders; off clears every scheduled alarm. */
@@ -74,13 +78,13 @@ data class Settings(
*/
val bottomAddBar: Boolean = false,
/**
* Per-list overrides of [defaultReminderMinutes]: a list present in the map
* Per-list overrides of [defaultReminders]: a list present in the map
* overrides the global default (an empty list = no reminder); absent =
* inherit.
*/
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Per-list overrides of [defaultAllDayReminderMinutes], same shape as [perListReminderOverride]. */
val perListAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
val perListReminderOverride: Map<Long, List<ReminderLead>> = emptyMap(),
/** Per-list overrides of [defaultAllDayReminders], same shape as [perListReminderOverride]. */
val perListAllDayReminderOverride: Map<Long, List<ReminderLead>> = emptyMap(),
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
val defaultEditFields: Set<TaskFormField> = emptySet(),
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
@@ -109,16 +113,16 @@ data class Settings(
}
/** The lead times for a task in [listId]: its override if set, else the global default. */
fun reminderLeadsFor(listId: Long): List<Int> =
perListReminderOverride.reminderLeadsFor(listId, defaultReminderMinutes)
fun reminderLeadsFor(listId: Long): List<ReminderLead> =
perListReminderOverride.reminderLeadsFor(listId, defaultReminders)
/** The lead times for an all-day task in [listId]. */
fun allDayReminderLeadsFor(listId: Long): List<Int> =
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes)
fun allDayReminderLeadsFor(listId: Long): List<ReminderLead> =
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminders)
/** The default leads a task in [listId] takes — the one rule for which default applies. */
fun defaultLeadsFor(listId: Long, allDay: Boolean): List<Int> =
if (allDay) allDayReminderLeadsFor(listId) else reminderLeadsFor(listId)
/** The default reminders a task in [listId] takes — the one rule for which default applies. */
fun defaultRemindersFor(listId: Long, allDay: Boolean): List<TaskReminder> =
(if (allDay) allDayReminderLeadsFor(listId) else reminderLeadsFor(listId)).map { it.asTaskReminder() }
}
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@@ -134,16 +138,18 @@ class SettingsPrefs @Inject constructor(
dynamicColor = p[DYNAMIC_COLOR] ?: true,
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
// The single lead of earlier versions carries over until a list is saved.
defaultReminderMinutes = p[DEFAULT_REMINDERS]?.let(::parseMinutes)
?: listOf(p[REMINDER_LEAD] ?: 0),
defaultAllDayReminderMinutes = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseMinutes) ?: listOf(0),
defaultReminders = p[DEFAULT_REMINDERS]?.let(::parseLeads)
?: listOf(ReminderLead(p[REMINDER_LEAD] ?: 0)),
defaultAllDayReminders = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseLeads)?.inWholeDays()
?: listOf(ReminderLead(0)),
allDayReminderMinuteOfDay = p[ALL_DAY_REMINDER_MINUTE]?.takeIf { it in 0 until 24 * 60 }
?: DEFAULT_ALL_DAY_REMINDER_MINUTE,
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
perListAllDayReminderOverride = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]),
perListReminderOverride = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]),
perListAllDayReminderOverride = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE])
.mapValues { it.value.inWholeDays() },
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
.toSet(),
@@ -189,12 +195,12 @@ class SettingsPrefs @Inject constructor(
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
}
suspend fun setDefaultReminderMinutes(minutes: List<Int>) = dataStore.edit {
it[DEFAULT_REMINDERS] = minutes.normalizeReminders().joinToString(",")
suspend fun setDefaultReminders(leads: List<ReminderLead>) = dataStore.edit {
it[DEFAULT_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() }
}
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = dataStore.edit {
it[DEFAULT_ALL_DAY_REMINDERS] = minutes.normalizeReminders().joinToString(",")
suspend fun setDefaultAllDayReminders(leads: List<ReminderLead>) = dataStore.edit {
it[DEFAULT_ALL_DAY_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() }
}
suspend fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) =
@@ -247,16 +253,16 @@ class SettingsPrefs @Inject constructor(
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
val current = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
current.applyReminderOverride(listId, override)
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current)
}
/** Set (or clear) a list's all-day reminder override. */
suspend fun setListAllDayReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
val current = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap()
val current = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap()
current.applyReminderOverride(listId, override)
p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current)
}
suspend fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = dataStore.edit { p ->
@@ -334,6 +340,13 @@ class SettingsPrefs @Inject constructor(
*/
private val reminderCodec = ReminderOverrideCodec.DEFAULT
/** `5,30` → [5, 30]; an empty string is an explicit "no reminder". */
private fun parseMinutes(stored: String): List<Int> =
stored.split(',').mapNotNull { it.trim().toIntOrNull()?.takeIf { m -> m >= 0 } }.normalizeReminders()
/** `s5,30` → [5 before start, 30 before due]; an empty string is an explicit "no reminder". */
private fun parseLeads(stored: String): List<ReminderLead> =
stored.split(',').mapNotNull { ReminderLead.parse(it)?.takeIf { lead -> lead.minutes >= 0 } }.normalizeReminders()
/**
* All-day defaults as whole days. Before 1.2 an all-day default could be hours
* before the all-day time; a lead that is not whole days now names a time of day.
*/
private fun List<ReminderLead>.inWholeDays(): List<ReminderLead> =
map { it.asTaskReminder().inWholeDays().asReminderLead() }.normalizeReminders()
@@ -1,8 +1,9 @@
package de.jeanlucmakiola.agendula.data.reminders
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.AllDayReminder
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.calendarDate
import java.time.LocalTime
import java.time.ZoneId
@@ -36,23 +37,24 @@ internal object ReminderPlanner {
// One reminder per *occurrence* and lead: a recurring series yields a row
// per occurrence, all sharing a taskId, so this is a Set rather than a
// taskId-keyed Map.
.filter { !it.isClosed && it.due != null }
.filter { !it.isClosed && (it.due != null || it.start != null) }
.flatMap { task ->
// Reminders set on the task itself win — every one of them, not just
// the one the editor shows; otherwise the task's list may override the
// global lead, or opt out entirely (override = null). All-day tasks
// have their own day-scale defaults.
// Reminders set on the task itself win — every one of them;
// otherwise the task's list may override the global lead, or opt
// out entirely (override = null). All-day tasks have their own
// day-scale defaults.
//
// ⚠️ Present-but-empty is "none", not "the default": the store's
// answer for a task whose alarms another client turned off. A
// default here would keep ringing for what the user switched off.
val reminders = perTask[task.taskId]
?: settings.defaultLeadsFor(task.listId, task.isAllDay).map { TaskReminder(it) }
// A default counts only from a date the task has; its own reminders
// stand in each date for the other when the task lacks it.
val own = perTask[task.taskId]
val reminders = own ?: settings.defaultRemindersFor(task.listId, task.isAllDay)
.filter { (if (it.fromStart) task.start else task.due) != null }
val created = task.created?.toEpochMilliseconds()
reminders.mapNotNull { reminder ->
// A stored reminder says what it counts back from. Ours are always
// before due, but an imported dmfs alarm or another client's can be
// before *start*.
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due!!
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due ?: task.start!!
val trigger = triggerAt(
anchor,
task.isAllDay,
@@ -60,14 +62,19 @@ internal object ReminderPlanner {
settings.allDayReminderMinuteOfDay,
zone,
)
// A default never rings for a moment before the task existed:
// that is a task saved after its default had passed, not a
// reminder missed while the device was off.
if (own == null && created != null && trigger < created) return@mapNotNull null
// Dismissed on another client already: firing it here within
// the missed-reminder grace would ring for something done with.
//
// ⚠️ An all-day reminder is compared from the start of its day,
// not from this device's time of day: other clients ring it at
// midnight, or at their own hour, so a dismissal at 07:00 must
// still silence the 09:00 this device would ring it at.
val earliest = if (task.isAllDay) {
// ⚠️ An all-day reminder without a time of its own is compared
// from the start of its day, not from this device's time of
// day: other clients ring it at midnight, or at their own hour,
// so a dismissal at 07:00 must still silence the 09:00 this
// device would ring it at.
val earliest = if (task.isAllDay && reminder.allDay.minuteOfDay == null) {
triggerAt(anchor, true, reminder.minutesBefore, 0, zone)
} else {
trigger
@@ -96,8 +103,9 @@ internal object ReminderPlanner {
*
* ⚠️ An all-day anchor is UTC midnight of its date (see `AllDayTime.kt`), a
* storage convention and not a moment — firing off it rang at 02:00 in
* Berlin and the evening before in New York. It is read as its calendar date
* at [allDayMinuteOfDay] local time instead, and the lead counts back from that.
* Berlin and the evening before in New York. It is read as its calendar
* date instead, and the lead as [TaskReminder.allDay]: whole days ring at
* [allDayMinuteOfDay] local time, anything else at the time it names.
*/
fun triggerAt(
anchor: Instant,
@@ -106,12 +114,11 @@ internal object ReminderPlanner {
allDayMinuteOfDay: Int,
zone: ZoneId,
): Long {
val base = if (allDay) {
val time = LocalTime.of(allDayMinuteOfDay / 60 % 24, allDayMinuteOfDay % 60)
anchor.calendarDate(allDay = true).atTime(time).atZone(zone).toInstant().toEpochMilli()
} else {
anchor.toEpochMilliseconds()
}
return base - leadMinutes.coerceAtLeast(0) * 60_000L
if (!allDay) return anchor.toEpochMilliseconds() - leadMinutes.coerceAtLeast(0) * 60_000L
val (days, minuteOfDay) = AllDayReminder.of(leadMinutes)
val minute = minuteOfDay ?: allDayMinuteOfDay
val time = LocalTime.of(minute / 60 % 24, minute % 60)
return anchor.calendarDate(allDay = true).minusDays(days.toLong())
.atTime(time).atZone(zone).toInstant().toEpochMilli()
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.TaskReminder
import android.content.ContentProviderOperation
import android.content.ContentResolver
import android.content.ContentUris
@@ -85,6 +86,27 @@ class AndroidTasksDataSource @Inject constructor(
override fun subtasks(parentTaskId: Long): List<Task> =
queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
/**
* Read from the `tasks` table, so a recurring subtask with no occurrence in
* the window still counts. All columns and a lenient `_deleted`, as in
* [exportTasks], since the table's shape varies across providers.
*/
override fun subtaskIds(parentTaskId: Long): List<Long> = resolver.query(
TasksContract.tasksUri(authority()),
null,
"${Tasks.PARENT_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
arrayOf(parentTaskId.toString()),
null,
)?.use { c ->
val r = CursorColumnReader(c)
buildList {
while (c.moveToNext()) {
if (r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null) continue
r.getLong(Tasks.ID)?.takeIf { it != parentTaskId }?.let(::add)
}
}
} ?: emptyList()
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
val uri = TasksContract.instancesUri(authority())
// projection = null (all columns): the instances view differs across
@@ -158,22 +180,20 @@ class AndroidTasksDataSource @Inject constructor(
?: throw TaskWriteFailedException("insert task: no id")
}
override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) {
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) {
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
setCategories(taskId, form.tags)
writeDueReminders(taskId, dueReminders)
writeReminders(taskId, reminders)
}
/** [leads] as the task's before-due reminders; a start-relative one from elsewhere is kept. */
private fun writeDueReminders(taskId: Long, leads: List<Int>?) {
if (leads == null) return
val kept = alarmRows(taskId).map { it.second }.filter { it.fromStart }
setReminders(taskId, kept + leads.distinct().sorted().map { TaskReminder(it) })
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?) {
if (reminders == null) return
setReminders(taskId, reminders)
}
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) {
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
val occurrence = task(seriesId, occurrenceStart)
val anchors = resolver.query(
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
@@ -193,7 +213,7 @@ class AndroidTasksDataSource @Inject constructor(
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
setCategories(seriesId, form.tags)
writeDueReminders(seriesId, dueReminders)
writeReminders(seriesId, reminders)
}
/**
@@ -202,10 +222,10 @@ class AndroidTasksDataSource @Inject constructor(
* task. Per-occurrence edits past the split point are dropped rather than
* carried over — the provider offers no way to re-parent an exception.
*/
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long {
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long {
val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId")
if (spec.rrule == null || occurrenceStart <= spec.anchor) {
updateSeries(seriesId, occurrenceStart, form, dueReminders)
updateSeries(seriesId, occurrenceStart, form, reminders)
return seriesId
}
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
@@ -215,13 +235,12 @@ class AndroidTasksDataSource @Inject constructor(
).size
endSeriesBefore(seriesId, spec, occurrenceStart)
val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it }
val reminders = dueReminders?.distinct()?.sorted()?.map { TaskReminder(it) }
return insertTask(form.copy(rrule = rule, percentComplete = null), reminders)
}
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
val spec = seriesSpec(seriesId) ?: return
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteTask(seriesId)
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteWithSubtasks(seriesId)
endSeriesBefore(seriesId, spec, occurrenceStart)
}
@@ -282,15 +301,15 @@ class AndroidTasksDataSource @Inject constructor(
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
}
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) {
if (dueReminders == null) return writeInstance(taskId, occurrenceStart, form)
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
if (reminders == null) return writeInstance(taskId, occurrenceStart, form)
// ⚠️ Written to the series, and then put back. Forking an occurrence
// copies the task's *current* properties onto the new override row, so
// setting the alarm beforehand is the only way to carry it across — and
// leaving it there would silently give every other occurrence in the
// series the reminder the user set on one of them.
val seriesReminders = alarmRows(taskId).map { it.second }
writeDueReminders(taskId, dueReminders)
writeReminders(taskId, reminders)
try {
writeInstance(taskId, occurrenceStart, form)
} finally {
@@ -351,21 +370,6 @@ class AndroidTasksDataSource @Inject constructor(
}
}
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
val uri = TasksContract.propertiesUri(authority())
// Replace rather than update: the provider's AlarmHandler re-validates the
// whole row on every update, so a partial edit throws. Only the editable
// (last before-due) row goes — a start-relative or additional alarm
// another client wrote is not ours to drop.
alarmRows(taskId).lastOrNull { !it.second.fromStart }?.let { (propertyId, _) ->
resolver.delete(uri, "${Properties.PROPERTY_ID} = ?", arrayOf(propertyId.toString()))
}
if (minutesBeforeDue != null) {
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
?: throw TaskWriteFailedException("set alarm for task $taskId")
}
}
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
val uri = TasksContract.propertiesUri(authority())
resolver.delete(
@@ -382,9 +386,6 @@ class AndroidTasksDataSource @Inject constructor(
override fun reminders(): Map<Long, List<TaskReminder>> =
readAlarms(null).groupBy({ it.first }) { it.third }
override fun alarms(): Map<Long, TaskReminder> =
reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap()
/** `(property_id, reminder)` for [taskId]'s alarm rows, in stored order. */
private fun alarmRows(taskId: Long): List<Pair<Long, TaskReminder>> =
readAlarms(taskId).map { it.second to it.third }
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
@@ -34,24 +35,24 @@ class ModeRoutingTasksDataSource(
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
override fun task(taskId: Long): Task? = active().task(taskId)
override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart)
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
active().updateSeries(seriesId, occurrenceStart, form, dueReminders)
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long =
active().splitSeries(seriesId, occurrenceStart, form, dueReminders)
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
active().updateSeries(seriesId, occurrenceStart, form, reminders)
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long =
active().splitSeries(seriesId, occurrenceStart, form, reminders)
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) =
active().deleteOccurrence(seriesId, occurrenceStart)
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
active().deleteFollowing(seriesId, occurrenceStart)
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
override fun subtaskIds(parentTaskId: Long): List<Long> = active().subtaskIds(parentTaskId)
override fun deleteWithSubtasks(taskId: Long) = active().deleteWithSubtasks(taskId)
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = active().insertTask(form, reminders)
override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) =
active().updateTask(taskId, form, dueReminders)
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) =
active().updateTask(taskId, form, reminders)
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
active().updateInstance(taskId, occurrenceStart, form, dueReminders)
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
active().updateInstance(taskId, occurrenceStart, form, reminders)
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
override fun alarms(): Map<Long, TaskReminder> = active().alarms()
override fun reminders(): Map<Long, List<TaskReminder>> = active().reminders()
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) =
active().setReminders(taskId, reminders)
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskReminder
import java.time.ZoneId
import kotlin.time.Instant
internal object StoredReminders {
/**
* The reminders to store for [form], given the task as it was.
*
* A date the task gets in this write brings the list's or the app's
* default for it *as the task's own reminders*. The default is this
* device's setting, which nothing else can see; stored on the task, it
* travels with it to the server and fires on every client. The cost is
* tasks.org's: changing the default later does not reach the tasks that
* already have it.
*
* A task getting its first date takes the defaults only when the form
* names no reminder; one that already had a date takes those of the new
* date that no reminder of the form counts from. Otherwise the form is
* kept, so an edit that clears every reminder means "use the default
* here" as before.
*
* The form's reminders are already of its kind, timed or all-day — see
* [switched]. Reminders the editor [chose][TaskForm.remindersChosen] are
* kept as they are: it showed these defaults already.
*/
fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> {
val wanted = form.reminders.map { it.lead }.distinct()
if (form.remindersChosen) return wanted
return withDefaults(form, current?.start, current?.due, settings, now, zone)
}
/** [of] for a task whose dates were [start] and [due], whatever the form chose. */
fun withDefaults(
form: TaskForm,
start: Instant?,
due: Instant?,
settings: Settings,
now: Long,
zone: ZoneId,
): List<TaskReminder> {
val wanted = form.reminders.map { it.lead }.distinct()
val had = anchorsOf(start, due)
val gained = anchorsOf(form.start, form.due) - had
if (gained.isEmpty()) return wanted
if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) }
val named = wanted.map { it.fromStart }.toSet()
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in gained && it.fromStart !in named }
}
/**
* [reminders] for [form] once it has switched between timed and all-day.
* Reminders that are exactly the old kind's default become the new kind's;
* the rest keep what still means something: whole days, rounded to them on
* an all-day task.
*/
fun switched(
reminders: List<TaskReminder>,
form: TaskForm,
settings: Settings,
now: Long,
zone: ZoneId,
): List<TaskReminder> {
if (reminders.isEmpty()) return reminders
if (reminders.toSet() == settings.defaultRemindersFor(form.listId, !form.isAllDay).toSet()) {
return defaultsFor(form, settings, now, zone)
}
val kept = if (form.isAllDay) {
reminders.map { it.inWholeDays() }
} else {
reminders.filter { it.allDay.minuteOfDay == null }
}
return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) }
}
/** The dates a reminder can count from, as [TaskReminder.fromStart]. */
private fun anchorsOf(start: Any?, due: Any?): Set<Boolean> =
setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null })
/**
* The defaults [form] takes: those counting from a date it has. A one-off
* task leaves out what is already past — a start of "now" would ring at once.
*/
private fun defaultsFor(form: TaskForm, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> =
settings.defaultRemindersFor(form.listId, form.isAllDay).filter { reminder ->
val anchor = (if (reminder.fromStart) form.start else form.due) ?: return@filter false
form.rrule != null || ReminderPlanner.triggerAt(
anchor,
form.isAllDay,
reminder.minutesBefore,
settings.allDayReminderMinuteOfDay,
zone,
) >= now
}
}
@@ -86,6 +86,12 @@ object TasksContract {
const val UID = "_uid"
const val DELETED = "_deleted"
const val HAS_ALARMS = "has_alarms"
const val ORIGINAL_INSTANCE_ALLDAY = "original_instance_allday"
const val LIST_OWNER = "list_owner"
const val LIST_ACCESS_LEVEL = "list_access_level"
const val VISIBLE = "visible"
}
/**
@@ -103,6 +109,7 @@ object TasksContract {
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
const val DISTANCE_FROM_CURRENT = "distance_from_current"
const val IS_RECURRING = "is_recurring"
const val INSTANCE_DURATION = "instance_duration"
/**
* The occurrence's `RECURRENCE-ID` — the time this occurrence was
@@ -118,6 +125,10 @@ object TasksContract {
const val PROPERTY_ID = "property_id"
const val TASK_ID = "task_id"
const val MIMETYPE = "mimetype"
const val DATA0 = "data0"
const val DATA1 = "data1"
const val DATA2 = "data2"
const val DATA3 = "data3"
}
/**
@@ -164,6 +175,9 @@ object TasksContract {
/** `data1` — the tag as spelled. */
const val NAME = "data1"
/** `data2` — the tag's colour, if any. */
const val COLOR = "data2"
}
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
@@ -3,38 +3,9 @@ package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskReminder
import kotlin.time.Instant
/**
* A stored reminder: how long before, and what it counts back from.
*
* [fromStart] matters because both stores can hold a `START`-referenced alarm —
* the dmfs import preserves one, and an external provider's other clients write
* them — while Agendula's own UI only ever sets a before-due lead. Collapsing it
* to a number here is what silently fired those reminders off the wrong anchor.
*/
data class TaskReminder(
val minutesBefore: Int,
val fromStart: Boolean = false,
/**
* When another client last dismissed this alarm (RFC 9074 `ACKNOWLEDGED`):
* a trigger at or before it has been dealt with and is not fired here.
* Never part of what a reminder *is* — see [lead].
*/
val acknowledgedAt: Instant? = null,
) {
/** The reminder without its bookkeeping, for comparing two of them. */
val lead: TaskReminder get() = if (acknowledgedAt == null) this else copy(acknowledgedAt = null)
}
/**
* The one reminder Agendula's single-reminder editor shows and writes: the last
* before-due one, which is where a write lands, so it stays the same one across
* saves. Anything else a task carries — a start-relative alarm, or the extra ones
* another client or the dmfs import brought — is left to ride along.
*/
fun List<TaskReminder>.editable(): TaskReminder? = lastOrNull { !it.fromStart }
/** What to fetch from the provider. Smart-list date logic is applied above this. */
data class TaskQuery(
val listId: Long? = null,
@@ -58,17 +29,38 @@ interface TasksDataSource {
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
fun subtasks(parentTaskId: Long): List<Task>
/**
* The task rows directly under [parentTaskId]: one id per subtask, its series
* master when it recurs, whether or not any occurrence falls in the window
* [subtasks] expands.
*/
fun subtaskIds(parentTaskId: Long): List<Long> =
subtasks(parentTaskId).map { it.seriesId ?: it.taskId }.distinct().filter { it != parentTaskId }
/**
* Delete [taskId] and its subtasks, all the way down; left behind they would
* hang off a tombstone. Each task once, so a parent loop an import brought in
* ends instead of recursing forever.
*/
fun deleteWithSubtasks(taskId: Long) {
val seen = HashSet<Long>()
fun delete(id: Long) {
if (!seen.add(id)) return
subtaskIds(id).forEach(::delete)
deleteTask(id)
}
delete(taskId)
}
/**
* The write paths take the task's reminders in the same write as the task
* itself, so a sync can never upload one without the other.
*
* [insertTask] takes the new task's [reminders] whole. The updates take the
* before-due leads as `dueReminders`, and `null` leaves the reminders
* alone; a start-relative reminder the task already carries is kept — the
* editor never shows one, so it never sends one.
* Each takes the task's whole set of [reminders], before-start and
* before-due alike, and `null` leaves the stored ones alone.
*/
fun insertTask(form: TaskForm, reminders: List<TaskReminder>? = null): Long
fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>? = null)
fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>? = null)
/**
* Update a single occurrence of a recurring task, addressed by the task row and
@@ -81,27 +73,19 @@ interface TasksDataSource {
* instance row id keeps this seam independent of any one store's row
* numbering; External mode maps it back to an instance row itself.
*/
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>? = null)
/**
* Set (or clear, with `null`) the task's [editable] reminder lead, stored as an
* Alarm property row. Every other reminder on the task is left untouched. The
* provider never fires it — [de.jeanlucmakiola.agendula.data.reminders
* .ReminderScheduler] does — but persisting it here is what syncs the lead and
* shares it with other OpenTasks clients.
*/
fun setAlarm(taskId: Long, minutesBeforeDue: Int?)
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null)
/**
* Edit the whole series [seriesId] through its occurrence at [occurrenceStart]:
* content from [form], and the series moved by however far that occurrence moved.
*/
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>? = null)
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null)
/**
* "This and following": end [seriesId] before [occurrenceStart] and continue
* from there as a new series built from [form]. Returns the new series' id.
*/
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>? = null): Long
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null): Long
/** Delete one generated occurrence of [seriesId] (an `EXDATE`). */
fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant)
@@ -118,10 +102,7 @@ interface TasksDataSource {
* scheduler. A task missing from the map takes the default; one present
* with an empty list has none (see `TaskEntity.remindersOff`).
*/
fun reminders(): Map<Long, List<TaskReminder>> = alarms().mapValues { listOf(it.value) }
/** Every task's [editable] reminder, by task id. */
fun alarms(): Map<Long, TaskReminder>
fun reminders(): Map<Long, List<TaskReminder>>
/**
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
@@ -82,13 +83,6 @@ interface TasksRepository {
scope: RecurringScope = RecurringScope.AllOccurrences,
)
/**
* The per-task reminder lead in minutes before due, or `null` if the task has
* none (in which case the list's / global setting applies). Read when the edit
* form loads so saving can't silently drop it.
*/
suspend fun reminderFor(taskId: Long): Int?
/** Every reminder stored on the task, in order — including start-relative ones. */
suspend fun remindersFor(taskId: Long): List<TaskReminder>
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.time.DayWindow
@@ -110,40 +111,17 @@ class TasksRepositoryImpl @Inject constructor(
override suspend fun createTask(form: TaskForm): Long =
withContext(io) {
dataSource.insertTask(form, dueRemindersFor(form, current = null).distinct().sorted().map { TaskReminder(it) })
dataSource.insertTask(form, storedRemindersFor(form, current = null))
}
/**
* The before-due leads to store for [form], given the task as it was.
*
* A task that gets its due date in this write and names no reminder of its
* own takes the list's or the app's default *as its own reminders*. The
* default is this device's setting, which nothing else can see; stored on
* the task, it travels with it to the server and fires on every client.
* The cost is tasks.org's: changing the default later does not reach the
* tasks that already have it.
*
* A task that already had a due date keeps what the form says, so an
* edit that clears every reminder means "use the default here" as before —
* with one exception. Switching between timed and all-day swaps the
* default it was given for the other kind's, as long as the reminders are
* still exactly that default: a timed 15 minutes is no lead for an all-day
* task, which the planner used to sort out by itself while the default
* stayed unstored.
*/
private suspend fun dueRemindersFor(form: TaskForm, current: Task?): List<Int> {
if (form.due == null) return form.reminders
val hadDue = current?.due != null
if (!hadDue) {
if (form.reminders.isNotEmpty()) return form.reminders
return settingsPrefs.settings.first().defaultLeadsFor(form.listId, form.isAllDay)
}
if (current.isAllDay == form.isAllDay || form.reminders.isEmpty()) return form.reminders
val settings = settingsPrefs.settings.first()
val previous = settings.defaultLeadsFor(current.listId, current.isAllDay)
return if (form.reminders.sorted() == previous.sorted()) settings.defaultLeadsFor(form.listId, form.isAllDay)
else form.reminders
}
private suspend fun storedRemindersFor(form: TaskForm, current: Task?): List<TaskReminder> =
StoredReminders.of(
form,
current,
settingsPrefs.settings.first(),
Clock.System.now().toEpochMilliseconds(),
ZoneId.systemDefault(),
)
override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long =
withContext(io) {
@@ -179,9 +157,6 @@ class TasksRepositoryImpl @Inject constructor(
return id
}
override suspend fun reminderFor(taskId: Long): Int? =
withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() }
override suspend fun remindersFor(taskId: Long): List<TaskReminder> =
withContext(io) { runCatching { dataSource.reminders()[taskId].orEmpty() }.getOrDefault(emptyList()) }
@@ -205,7 +180,7 @@ class TasksRepositoryImpl @Inject constructor(
val seen = current?.lastModified
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
}
val reminders = dueRemindersFor(form, current)
val reminders = storedRemindersFor(form, current)
val seriesId = current?.seriesId
val occurrence = current?.occurrenceStart
if (seriesId != null && occurrence != null) {
@@ -260,7 +235,7 @@ class TasksRepositoryImpl @Inject constructor(
val current = dataSource.task(taskId, occurrenceStart)
val seriesId = current?.seriesId
val occurrence = current?.occurrenceStart
if (seriesId == null || occurrence == null) return@withContext deleteWithSubtasks(taskId)
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(taskId)
when (scope) {
RecurringScope.ThisOccurrence ->
// An override row deletes itself into an EXDATE; a generated
@@ -268,18 +243,10 @@ class TasksRepositoryImpl @Inject constructor(
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
else dataSource.deleteOccurrence(seriesId, occurrence)
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
RecurringScope.AllOccurrences -> deleteWithSubtasks(seriesId)
RecurringScope.AllOccurrences -> dataSource.deleteWithSubtasks(seriesId)
}
}
/** A parent goes with its subtasks; left behind they would hang off a tombstone. */
private fun deleteWithSubtasks(taskId: Long) {
dataSource.subtasks(taskId).map { it.taskId }.distinct()
.filter { it != taskId }
.forEach { dataSource.deleteTask(it) }
dataSource.deleteTask(taskId)
}
override suspend fun createLocalList(name: String, color: Int): Long =
withContext(io) { dataSource.createLocalList(name, color) }
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import de.jeanlucmakiola.agendula.domain.DAY_MINUTES
import de.jeanlucmakiola.agendula.domain.MIN_ALL_DAY_LEAD
import kotlin.math.roundToLong
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -31,8 +33,9 @@ import kotlin.time.Instant
* - location alarms (`X-APPLE-PROXIMITY`, RFC 9074 `PROXIMITY`, a nested
* component) and Apple's `19760401T005545Z` sentinel trigger they carry;
* - repeating alarms (`REPEAT`/`DURATION`) and RFC 9074 snoozes (`RELATED-TO`);
* - a trigger *after* its anchor. The planner coerces a negative lead to 0, so
* it would fire at the wrong time;
* - a trigger *after* its anchor — on an all-day task, after the end of its
* day. The planner coerces a negative lead to 0, so it would fire at the
* wrong time;
* - an absolute trigger on a recurring task, or on one with no `DUE` to count
* back from.
*
@@ -50,19 +53,18 @@ import kotlin.time.Instant
* `RELATED=END` counts from `DUE`. `RELATED=START` (the default) counts from
* `DTSTART` — except when `DTSTART` equals `DUE`, or when there is no
* `DTSTART` but there is a `DUE`. Apple writes the latter shape (against §3.8.6.3,
* which wants `DTSTART` present), and reading it as a before-due reminder is
* what makes it editable here: the editor only shows before-due rows.
* which wants `DTSTART` present).
*
* ## All-day tasks
*
* The local fire time of day is a device preference this mapper cannot see, so
* an all-day lead is whole days, written `-P<n>D` against `DUE`'s midnight. Read
* back, any trigger on an all-day task rounds to the nearest whole day.
* A relative trigger counts from the date's midnight, so the lead is the
* reminder as [de.jeanlucmakiola.agendula.domain.TaskReminder.allDay] reads it:
* `-P1D` is a day before at this device's all-day time, which this mapper cannot
* see, and `PT9H` is 09:00 on the day. An absolute trigger has no zone to read
* a time of day in, so it rounds to the nearest whole day.
*/
object AlarmCodec {
private const val DAY_MINUTES = 24 * 60
/** Apple's placeholder trigger for location alarms. Never a real time. */
private const val APPLE_SENTINEL = "19760401T005545Z"
@@ -114,16 +116,11 @@ object AlarmCodec {
else -> return null
}
val before = -seconds
val minutes = if (task.isAllDay) {
// Rounded before the sign check: `PT9H` on a date-only DUE is
// "09:00 on the day", which is this device's "on the day".
roundToDays(before)
} else {
// A lead in seconds is not one the planner can reproduce.
if (before % 60 != 0L) return null
before / 60
}
return minutes.toIntOrNull()?.let { Lead(it, reference) }
// A lead in seconds is not one the planner can reproduce.
if (before % 60 != 0L) return null
val minutes = before / 60
val latest = if (task.isAllDay) MIN_ALL_DAY_LEAD.toLong() else 0L
return minutes.takeIf { it in latest..Int.MAX_VALUE }?.let { Lead(it.toInt(), reference) }
}
private fun absoluteLead(trigger: ICalProperty, task: TaskEntity): Lead? {
@@ -143,6 +140,17 @@ object AlarmCodec {
return minutes.toIntOrNull()?.let { Lead(it, AlarmReference.DUE) }
}
/**
* [read] as builds before 1.2 stored it on an all-day task: rounded to whole
* days. Such a row keeps the other client's time of day instead of overwriting it.
*/
private fun readAsWholeDays(read: Lead, task: TaskEntity): Lead? =
if (task.isAllDay && read.minutesBefore % DAY_MINUTES != 0) {
read.copy(minutesBefore = roundToDays(read.minutesBefore * 60L).coerceAtLeast(0).toInt())
} else {
null
}
/** Nearest whole day, in minutes. */
private fun roundToDays(seconds: Long): Long =
(seconds / 86_400.0).roundToLong() * DAY_MINUTES
@@ -156,10 +164,10 @@ object AlarmCodec {
* too — because `RELATED=END` without `DUE` is a resource the server
* refuses ([VTodoMapper.validate]).
*
* ⚠️ A before-start row on a task that has lost its start is written
* against `DUE`, which is what the planner fires it from. Leaving it out
* would drop it from every other client, and the next download would then
* take it off this device too.
* ⚠️ A row whose anchor the task has lost is written against the other
* date, which is what the planner fires it from. Leaving it out would drop
* it from every other client, and the next download would then take it off
* this device too.
*
* [now] marks an authored alarm `ACKNOWLEDGED` once it is past even the
* planner's missed-reminder grace, so a client that honours RFC 9074 does
@@ -167,11 +175,15 @@ object AlarmCodec {
* next download and would silence the late reminder this device still owes.
*/
fun toVAlarm(row: TaskAlarmEntity, task: TaskEntity, now: Instant): ICalComponent? {
val reference = if (row.reference == AlarmReference.START && task.dtstart == null) AlarmReference.DUE
else row.reference
val reference = when {
row.reference == AlarmReference.START && task.dtstart == null -> AlarmReference.DUE
row.reference == AlarmReference.DUE && task.due == null -> AlarmReference.START
else -> row.reference
}
val lead = Lead(row.minutesBefore, reference)
row.raw?.let(::parse)?.let { original ->
if (resolve(original, task) == lead) return original
val read = resolve(original, task)
if (read == lead || read != null && readAsWholeDays(read, task) == lead) return original
}
val anchor = when (reference) {
@@ -179,12 +191,8 @@ object AlarmCodec {
AlarmReference.START -> task.dtstart
} ?: return null
val related = if (reference == AlarmReference.DUE) "END" else "START"
val minutes = row.minutesBefore.coerceAtLeast(0).toLong()
val seconds = if (task.isAllDay) {
-((minutes + DAY_MINUTES / 2) / DAY_MINUTES) * 86_400
} else {
-minutes * 60
}
val minutes = row.minutesBefore.coerceAtLeast(if (task.isAllDay) MIN_ALL_DAY_LEAD else 0).toLong()
val seconds = -minutes * 60
val properties = mutableListOf(
ICalProperty("UID", value = row.uid),
@@ -202,13 +210,18 @@ object AlarmCodec {
/**
* What [row] reads back as once it has been to the server and back — not
* always the row itself, because the wire is coarser: an all-day lead is
* whole days, and a before-start row on a task without a start goes out
* before-due. A download that brings back exactly this has not changed the
* always the row itself, because the wire is coarser: a lead past the end
* of its anchor is clamped, and a before-start row on a task without a
* start goes out before-due. A download that brings back exactly this has not changed the
* reminder, and the row's own lead must be kept rather than taken from it.
*
* ⚠️ As authored from the row, not re-emitted from [TaskAlarmEntity.raw]:
* a row rounded to whole days before 1.2 re-emits a time of day it does
* not hold, and would keep its rounded lead against the very alarm that
* carries it.
*/
fun wireLead(row: TaskAlarmEntity, task: TaskEntity): Lead? =
toVAlarm(row, task, Instant.DISTANT_PAST)?.let { resolve(it, task) }
toVAlarm(row.copy(raw = null), task, Instant.DISTANT_PAST)?.let { resolve(it, task) }
private fun parse(raw: String): ICalComponent? =
runCatching { ICalParser.parse(raw) }.getOrNull()?.takeIf { it.name.equals("VALARM", ignoreCase = true) }
@@ -0,0 +1,99 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.ContentProvider
import android.content.ContentProviderOperation
import android.content.ContentProviderResult
import android.content.ContentUris
import android.content.ContentValues
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
/**
* Agendula's own store, published under the OpenTasks contract (`tasklists`,
* `tasks`, `instances`, `properties`) at `<applicationId>.tasks`, so an app that
* already works with OpenTasks or tasks.org works with Agendula by swapping the
* authority. Reads are guarded by `<applicationId>.permission.READ_TASKS`, writes
* by `<applicationId>.permission.WRITE_TASKS`; [ProviderWriter] applies them. See
* `docs/CONTENT_PROVIDER.md`.
*/
class AgendulaTaskProvider : ContentProvider() {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ProviderEntryPoint {
fun snapshotSource(): ProviderSnapshotSource
fun writer(): ProviderWriter
}
private val entryPoint by lazy {
EntryPointAccessors.fromApplication(requireNotNull(context), ProviderEntryPoint::class.java)
}
private val source: ProviderSnapshotSource by lazy { entryPoint.snapshotSource() }
private val writer: ProviderWriter by lazy { entryPoint.writer() }
private val matcher by lazy {
UriMatcher(UriMatcher.NO_MATCH).apply {
ProviderTable.entries.forEach { table ->
addURI(source.authority, table.path, table.ordinal * 2)
addURI(source.authority, "${table.path}/#", table.ordinal * 2 + 1)
}
}
}
override fun onCreate(): Boolean = true
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor {
val (table, id) = match(uri)
return source.query(table, projection, table.selectionFor(id, selection), selectionArgs, sortOrder).apply {
setNotificationUri(requireNotNull(context).contentResolver, uri)
}
}
override fun getType(uri: Uri): String {
val (table, id) = match(uri)
return table.mimeType(single = id != null)
}
override fun insert(uri: Uri, values: ContentValues?): Uri {
val (table, id) = match(uri)
require(id == null) { "Insert into ${table.path}, not into one of its rows" }
return ContentUris.withAppendedId(uri, writer.insert(table, values.toMap()))
}
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
val (table, id) = match(uri)
return writer.update(table, values.toMap(), table.selectionFor(id, selection), selectionArgs)
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
val (table, id) = match(uri)
return writer.delete(table, table.selectionFor(id, selection), selectionArgs)
}
/** All or nothing, as against OpenTasks, and observers hear of it once. */
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> =
writer.batch { super.applyBatch(operations) }
private fun match(uri: Uri): Pair<ProviderTable, Long?> {
val code = matcher.match(uri)
require(code != UriMatcher.NO_MATCH) { "Unknown URI $uri" }
val table = ProviderTable.entries[code / 2]
if (code % 2 == 0) return table to null
val id = requireNotNull(uri.lastPathSegment?.toLongOrNull()) { "Unknown URI $uri" }
return table to id
}
private fun ContentValues?.toMap(): ProviderValues =
this?.keySet()?.associateWith { get(it) }.orEmpty()
}
@@ -0,0 +1,116 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
/** One table [AgendulaTaskProvider] serves, with its columns in cursor order. */
enum class ProviderTable(val path: String, val columns: List<Pair<String, String>>) {
LISTS(
Lists.PATH,
listOf(
Lists.ID to "INTEGER PRIMARY KEY",
Lists.NAME to "TEXT",
Lists.COLOR to "INTEGER",
Lists.ACCESS_LEVEL to "INTEGER",
Lists.VISIBLE to "INTEGER",
Lists.SYNC_ENABLED to "INTEGER",
Lists.OWNER to "TEXT",
Lists.ACCOUNT_NAME to "TEXT",
Lists.ACCOUNT_TYPE to "TEXT",
),
),
TASKS(Tasks.PATH, listOf(Tasks.ID to "INTEGER PRIMARY KEY") + TASK_COLUMNS),
INSTANCES(
Instances.PATH,
listOf(
Tasks.ID to "INTEGER PRIMARY KEY",
Instances.TASK_ID to "INTEGER",
Instances.INSTANCE_START to "INTEGER",
Instances.INSTANCE_START_SORTING to "INTEGER",
Instances.INSTANCE_DUE to "INTEGER",
Instances.INSTANCE_DUE_SORTING to "INTEGER",
Instances.INSTANCE_DURATION to "INTEGER",
Instances.INSTANCE_ORIGINAL_TIME to "INTEGER",
Instances.DISTANCE_FROM_CURRENT to "INTEGER",
Instances.IS_RECURRING to "INTEGER",
) + TASK_COLUMNS,
),
PROPERTIES(
Properties.PATH,
listOf(
Properties.PROPERTY_ID to "INTEGER PRIMARY KEY",
Properties.TASK_ID to "INTEGER",
Properties.MIMETYPE to "TEXT",
Properties.DATA0 to "TEXT",
Properties.DATA1 to "TEXT",
Properties.DATA2 to "TEXT",
Properties.DATA3 to "TEXT",
),
),
;
val idColumn: String get() = columns.first().first
/** [selection] narrowed to the row [id] names, for a `/<id>` URI. */
fun selectionFor(id: Long?, selection: String?): String? =
if (id == null) selection else listOfNotNull("$idColumn = $id", selection?.let { "($it)" }).joinToString(" AND ")
/** OpenTasks' MIME type for a whole table, or for one row of it. */
fun mimeType(single: Boolean): String =
"${if (single) "vnd.android.cursor.item" else "vnd.android.cursor.dir"}/org.dmfs.tasks.$path"
}
/** Every `tasks` column but `_id`; the instances view carries them too. */
private val TASK_COLUMNS: List<Pair<String, String>>
get() = listOf(
Tasks.LIST_ID to "INTEGER",
Tasks.LIST_NAME to "TEXT",
Tasks.LIST_COLOR to "INTEGER",
Tasks.LIST_OWNER to "TEXT",
Tasks.LIST_ACCESS_LEVEL to "INTEGER",
Tasks.VISIBLE to "INTEGER",
Tasks.ACCOUNT_NAME to "TEXT",
Tasks.ACCOUNT_TYPE to "TEXT",
Tasks.UID to "TEXT",
Tasks.DELETED to "INTEGER",
Tasks.TITLE to "TEXT",
Tasks.DESCRIPTION to "TEXT",
Tasks.LOCATION to "TEXT",
Tasks.URL to "TEXT",
Tasks.PRIORITY to "INTEGER",
Tasks.CLASSIFICATION to "INTEGER",
Tasks.STATUS to "INTEGER",
Tasks.PERCENT_COMPLETE to "INTEGER",
Tasks.COMPLETED to "INTEGER",
Tasks.IS_CLOSED to "INTEGER",
Tasks.IS_NEW to "INTEGER",
Tasks.TASK_COLOR to "INTEGER",
Tasks.DTSTART to "INTEGER",
Tasks.DUE to "INTEGER",
Tasks.DURATION to "TEXT",
Tasks.IS_ALLDAY to "INTEGER",
Tasks.TZ to "TEXT",
Tasks.RRULE to "TEXT",
Tasks.RDATE to "TEXT",
Tasks.EXDATE to "TEXT",
Tasks.ORIGINAL_INSTANCE_ID to "INTEGER",
Tasks.ORIGINAL_INSTANCE_TIME to "INTEGER",
Tasks.ORIGINAL_INSTANCE_ALLDAY to "INTEGER",
Tasks.PARENT_ID to "INTEGER",
Tasks.SORTING to "INTEGER",
Tasks.HAS_ALARMS to "INTEGER",
Tasks.CREATED to "INTEGER",
Tasks.LAST_MODIFIED to "INTEGER",
) + UNSUPPORTED_TASK_COLUMNS.map { it to "TEXT" }
/**
* OpenTasks columns Agendula has nothing for. Published as `NULL` so a client
* that projects them reads nothing rather than failing the query.
*/
private val UNSUPPORTED_TASK_COLUMNS = listOf(
"geo", "organizer", "pinned", "has_properties", "completed_is_allday", "original_instance_sync_id",
"version", "_dirty", "_sync_id", "sync_version",
"sync1", "sync2", "sync3", "sync4", "sync5", "sync6", "sync7", "sync8",
)
@@ -0,0 +1,267 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.toInt
import java.time.ZoneId
import kotlin.time.Instant
typealias ProviderRow = Map<String, Any?>
/**
* Ids that stay put for as long as what they name exists, so a client can read
* an id and write to it later, or several in one batch. The one exception is
* two tags on one task whose spellings hash alike (about one in 2³¹): removing
* the one that sorts first moves the other onto its id.
*
* An instance is its series and its original time; a plain task's one
* instance has none. An alarm property is the alarm row; a tag property is its
* task and its spelling. Alarms take even ids and tags odd ones, so the two
* never meet.
*/
object ProviderIds {
fun instance(seriesId: Long, originalTime: Instant?): Long =
(seriesId shl 32) or ((originalTime?.epochSeconds ?: 0L) and 0xFFFFFFFFL)
fun instanceSeries(instanceId: Long): Long = instanceId ushr 32
fun instanceSeconds(instanceId: Long): Long = instanceId and 0xFFFFFFFFL
fun alarm(alarmId: Long): Long = alarmId shl 1
fun isAlarm(propertyId: Long): Boolean = propertyId and 1L == 0L
fun alarmRow(propertyId: Long): Long = propertyId ushr 1
/** The property id of each of [taskId]'s [categories], by spelling. */
fun categories(taskId: Long, categories: List<String>): Map<String, Long> =
categories(taskId, categories, ::tagHash)
internal fun categories(taskId: Long, categories: List<String>, hash: (String) -> Long): Map<String, Long> {
val used = HashSet<Long>()
// Two spellings on one task with one hash: the one that sorts later takes
// the next free slot, wrapping inside the tag bits so it never reaches the
// task's. Sorting keeps that independent of the tags' order.
val ids = categories.sorted().associateWith { name ->
var bits = hash(name) and TAG_BITS
while (!used.add(bits)) bits = (bits + 1) and TAG_BITS
(((taskId shl 31) or bits) shl 1) or 1L
}
return categories.associateWith(ids::getValue)
}
fun categoryTask(propertyId: Long): Long = propertyId ushr 32
private const val TAG_BITS = 0x7FFFFFFFL
/** 64-bit FNV-1a over the spelling's UTF-8, folded to 31 bits. */
private fun tagHash(name: String): Long {
var hash = -0x340d631b7bdddcdbL
name.encodeToByteArray().forEach { byte ->
hash = (hash xor (byte.toLong() and 0xFF)) * 0x100000001b3L
}
return (hash xor (hash ushr 31) xor (hash ushr 62)) and TAG_BITS
}
}
/** The Room store laid out as OpenTasks' tables, ready to be queried. */
class ProviderSnapshot(val rows: Map<ProviderTable, List<ProviderRow>>) {
companion object {
val EMPTY = ProviderSnapshot(ProviderTable.entries.associateWith { emptyList() })
/**
* Lays out the store. [tasks] must hold no tombstones. [accountType] is
* what a list attached to an account reports; a device-only list reports
* OpenTasks' local account so clients treat it the way they already do.
*/
fun build(
lists: List<TaskListRow>,
tasks: List<TaskEntity>,
alarms: List<TaskAlarmEntity>,
accountType: String,
now: Instant,
zone: ZoneId = ZoneId.systemDefault(),
): ProviderSnapshot {
val listsById = lists.associateBy { it.list.id }
val alarmsByTask = alarms.groupBy { it.taskId }
val taskRows = tasks.mapNotNull { task ->
val list = listsById[task.listId] ?: return@mapNotNull null
task.id to taskColumns(task, list, alarmsByTask[task.id].orEmpty().isNotEmpty(), accountType)
}.toMap()
val overrides = tasks.filter { it.masterId != null }.groupBy { it.masterId }
val instances = tasks.filter { it.masterId == null && it.id in taskRows }.flatMap { task ->
occurrences(task, overrides[task.id].orEmpty(), now).mapNotNull { occurrence ->
val columns = taskRows[occurrence.row.id] ?: return@mapNotNull null
columns + instanceColumns(ProviderIds.instance(task.id, occurrence.originalTime), occurrence, zone)
}
}
return ProviderSnapshot(
mapOf(
ProviderTable.LISTS to lists.map { listColumns(it, accountType) },
ProviderTable.TASKS to taskRows.map { (id, columns) -> columns + (Tasks.ID to id) },
ProviderTable.INSTANCES to instances,
ProviderTable.PROPERTIES to properties(tasks.filter { it.id in taskRows }, alarms),
),
)
}
private fun listColumns(row: TaskListRow, accountType: String): ProviderRow = mapOf(
Lists.ID to row.list.id,
Lists.NAME to row.list.name,
Lists.COLOR to row.list.color,
Lists.ACCESS_LEVEL to 0,
Lists.VISIBLE to row.list.isVisible.bit,
Lists.SYNC_ENABLED to row.list.isSynced.bit,
Lists.OWNER to row.list.owner,
Lists.ACCOUNT_NAME to accountName(row),
Lists.ACCOUNT_TYPE to accountTypeOf(row, accountType),
)
private fun taskColumns(task: TaskEntity, list: TaskListRow, hasAlarms: Boolean, accountType: String) =
mapOf(
Tasks.LIST_ID to task.listId,
Tasks.LIST_NAME to list.list.name,
Tasks.LIST_COLOR to list.list.color,
Tasks.LIST_OWNER to list.list.owner,
Tasks.LIST_ACCESS_LEVEL to 0,
Tasks.VISIBLE to list.list.isVisible.bit,
Tasks.ACCOUNT_NAME to accountName(list),
Tasks.ACCOUNT_TYPE to accountTypeOf(list, accountType),
Tasks.UID to task.uid,
Tasks.DELETED to 0,
Tasks.TITLE to task.title,
Tasks.DESCRIPTION to task.description,
Tasks.LOCATION to task.location,
Tasks.URL to task.url,
Tasks.PRIORITY to task.priority,
Tasks.CLASSIFICATION to task.classification,
Tasks.STATUS to task.status.toInt(),
Tasks.PERCENT_COMPLETE to task.percentComplete,
Tasks.COMPLETED to task.completedAt?.toEpochMilliseconds(),
Tasks.IS_CLOSED to (task.status == TaskStatus.COMPLETED || task.status == TaskStatus.CANCELLED).bit,
Tasks.IS_NEW to (task.status == TaskStatus.NEEDS_ACTION && task.percentComplete == null).bit,
Tasks.TASK_COLOR to task.color,
Tasks.DTSTART to task.dtstart?.toEpochMilliseconds(),
Tasks.DUE to task.due?.toEpochMilliseconds(),
Tasks.DURATION to task.duration,
Tasks.IS_ALLDAY to task.isAllDay.bit,
Tasks.TZ to task.timezone,
Tasks.RRULE to task.rrule,
Tasks.RDATE to task.rdate,
Tasks.EXDATE to task.exdate,
Tasks.ORIGINAL_INSTANCE_ID to task.masterId,
Tasks.ORIGINAL_INSTANCE_TIME to task.recurrenceId?.toEpochMilliseconds(),
Tasks.ORIGINAL_INSTANCE_ALLDAY to task.masterId?.let { task.isAllDay.bit },
Tasks.PARENT_ID to task.parentId,
Tasks.SORTING to task.sortOrder,
Tasks.HAS_ALARMS to hasAlarms.bit,
Tasks.CREATED to task.createdAt?.toEpochMilliseconds(),
Tasks.LAST_MODIFIED to task.lastModified?.toEpochMilliseconds(),
)
/** One instance: the row it reads from, its times, and where it sits in its series. */
private class Occurrence(
val row: TaskEntity,
val start: Instant?,
val due: Instant?,
val originalTime: Instant?,
val distance: Int,
val recurring: Boolean,
)
private fun occurrences(master: TaskEntity, overrides: List<TaskEntity>, now: Instant): List<Occurrence> {
val series = seriesOccurrences(master, overrides, now)
?: return listOf(Occurrence(master, master.dtstart, master.due, null, 0, recurring = false))
return series.mapIndexed { index, occurrence ->
Occurrence(
row = occurrence.override ?: master,
start = occurrence.start,
due = occurrence.due,
originalTime = occurrence.anchor,
distance = occurrence.distance ?: index,
recurring = true,
)
}
}
private fun instanceColumns(id: Long, occurrence: Occurrence, zone: ZoneId): ProviderRow {
val allDay = occurrence.row.isAllDay
val start = occurrence.start?.toEpochMilliseconds()
val due = occurrence.due?.toEpochMilliseconds()
return mapOf(
Tasks.ID to id,
Instances.TASK_ID to occurrence.row.id,
Instances.INSTANCE_START to start,
Instances.INSTANCE_START_SORTING to start?.let { sorting(it, allDay, zone) },
Instances.INSTANCE_DUE to due,
Instances.INSTANCE_DUE_SORTING to due?.let { sorting(it, allDay, zone) },
Instances.INSTANCE_DURATION to if (start != null && due != null) due - start else null,
Instances.INSTANCE_ORIGINAL_TIME to occurrence.originalTime?.toEpochMilliseconds(),
Instances.DISTANCE_FROM_CURRENT to occurrence.distance,
Instances.IS_RECURRING to occurrence.recurring.bit,
)
}
/**
* An all-day date is stored as UTC midnight; OpenTasks' sorting columns
* move it to local midnight so it sorts among the timed tasks of that day.
*/
private fun sorting(millis: Long, allDay: Boolean, zone: ZoneId): Long {
if (!allDay) return millis
val offset = zone.rules.getOffset(java.time.Instant.ofEpochMilli(millis)).totalSeconds * 1000L
return millis - offset
}
private fun properties(tasks: List<TaskEntity>, alarms: List<TaskAlarmEntity>): List<ProviderRow> {
val live = tasks.mapTo(HashSet()) { it.id }
val alarmRows = alarms.filter { it.taskId in live }.map {
mapOf(
Properties.PROPERTY_ID to ProviderIds.alarm(it.id),
Properties.TASK_ID to it.taskId,
Properties.MIMETYPE to Alarm.MIMETYPE,
Alarm.MINUTES_BEFORE to it.minutesBefore.toString(),
Alarm.REFERENCE to when (it.reference) {
AlarmReference.DUE -> Alarm.REFERENCE_DUE
AlarmReference.START -> Alarm.REFERENCE_START
}.toString(),
Alarm.MESSAGE to it.message,
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE.toString(),
)
}
val categoryRows = tasks.flatMap { task ->
ProviderIds.categories(task.id, task.categories).map { (name, id) ->
mapOf(
Properties.PROPERTY_ID to id,
Properties.TASK_ID to task.id,
Properties.MIMETYPE to Category.MIMETYPE,
Category.NAME to name,
)
}
}
return alarmRows + categoryRows
}
private fun accountName(row: TaskListRow): String =
row.accountDisplayName ?: TasksContract.LOCAL_ACCOUNT_NAME
private fun accountTypeOf(row: TaskListRow, accountType: String): String =
if (row.list.accountId == null) TasksContract.LOCAL_ACCOUNT_TYPE else accountType
private val Boolean.bit: Int get() = if (this) 1 else 0
}
}
@@ -0,0 +1,182 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.Context
import android.database.Cursor
import android.os.Handler
import android.os.Looper
import androidx.room.InvalidationTracker
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.room.OBSERVED_TABLES
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/** Rebuilt at least this often, so the expansion window and `distance_from_current` follow the clock. */
private val MAX_AGE = 1.hours
private val STARTUP_WAIT = 10.seconds
private val NOTIFY_DELAY = 500.milliseconds
/**
* Serves [AgendulaTaskProvider]: keeps a [SnapshotDatabase] of the Room store,
* rebuilt lazily after a write or a storage-mode switch, and tells observers of
* the provider's authority when either happens.
*
* Empty in [StorageMode.EXTERNAL]: the Room store is not the user's data then,
* and clients should read OpenTasks or tasks.org directly.
*/
@Singleton
class ProviderSnapshotSource internal constructor(
private val context: Context,
private val database: TasksDatabase,
private val resolver: ProviderResolver,
private val awaitStartup: suspend () -> Unit,
private val clock: Clock = Clock.System,
private val startupWait: Duration = STARTUP_WAIT,
) {
@Inject constructor(
@ApplicationContext context: Context,
database: TasksDatabase,
resolver: ProviderResolver,
startupGate: StartupGate,
) : this(context, database, resolver, startupGate::awaitReady)
private val started = AtomicBoolean(false)
private val snapshot by lazy { SnapshotDatabase() }
private val generation = AtomicLong(1)
private var builtGeneration = 0L
private var builtAt: Instant? = null
private val handler = Handler(Looper.getMainLooper())
private val notify = Runnable {
context.contentResolver.notifyChange(TasksContract.authorityUri(authority), null)
}
val authority: String get() = "${context.packageName}.$AUTHORITY_SUFFIX"
/** Starts watching for changes. Idempotent; touches the database, so not from the main thread. */
fun start() {
if (!started.compareAndSet(false, true)) return
database.invalidationTracker.addObserver(
object : InvalidationTracker.Observer(OBSERVED_TABLES) {
override fun onInvalidated(tables: Set<String>) {
if (resolver.mode() == StorageMode.OWN) changed()
}
},
)
resolver.onModeChanged(::changed)
}
@Synchronized
fun query(
table: ProviderTable,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor {
refresh()
return snapshot.query(table, projection, selection, selectionArgs, sortOrder)
}
/**
* Runs [block] holding the snapshot's lock, once startup has settled which
* store is the user's. A write refuses to run without that, where a read
* falls back to answering from whichever store autoMode() picks.
*/
fun <T> locked(block: () -> T): T {
start()
checkNotNull(runBlocking { withTimeoutOrNull(startupWait) { awaitStartup() } }) {
"Agendula is still starting; try again"
}
return synchronized(this, block)
}
/** The [columns] of every row of [table] that [selection] matches, as of now. */
@Synchronized
fun rows(table: ProviderTable, columns: List<String>, selection: String?, selectionArgs: Array<out String>?): List<List<Any?>> =
query(table, columns.toTypedArray(), selection, selectionArgs, null).use { cursor ->
buildList {
while (cursor.moveToNext()) {
add(
List(cursor.columnCount) { column ->
when (cursor.getType(column)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(column)
else -> cursor.getString(column)
}
},
)
}
}
}
/**
* Marks the snapshot stale at once. A provider write calls this so the next
* operation of the same batch sees it, before Room's own invalidation lands.
*/
fun invalidate() {
generation.incrementAndGet()
}
@Synchronized
private fun refresh() {
start()
runBlocking { withTimeoutOrNull(startupWait) { awaitStartup() } }
val now = clock.now()
val current = generation.get()
val expired = builtAt.let { it == null || now - it > MAX_AGE }
if (current != builtGeneration || expired) {
snapshot.load(build(now))
builtGeneration = current
// Instance ids and distances move with the clock, so a timed rebuild is a change too.
if (expired && builtAt != null) notifyObservers()
builtAt = now
}
}
private fun build(now: Instant): ProviderSnapshot {
if (resolver.mode() != StorageMode.OWN) return ProviderSnapshot.EMPTY
// Read consistently in one transaction; expand outside it, so the app's
// own writes do not wait on recurrence expansion.
val (lists, tasks, alarms) = database.runInTransaction<Triple<List<TaskListRow>, List<TaskEntity>, List<TaskAlarmEntity>>> {
Triple(database.taskLists().lists(), database.tasks().live(), database.alarms().all())
}
return ProviderSnapshot.build(lists, tasks, alarms, context.getString(R.string.account_type), now)
}
private fun changed() {
generation.incrementAndGet()
notifyObservers()
}
/** Coalesced, so a sync committing many small transactions wakes each client once. */
private fun notifyObservers() {
handler.removeCallbacks(notify)
handler.postDelayed(notify, NOTIFY_DELAY.inWholeMilliseconds)
}
companion object {
const val AUTHORITY_SUFFIX = "tasks"
}
}
@@ -0,0 +1,304 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.forAllDay
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.statusFromInt
import org.dmfs.rfc5545.recur.RecurrenceRule
import java.time.ZoneId
import kotlin.time.Instant
/** A client's `ContentValues`, as plain Kotlin values. */
typealias ProviderValues = Map<String, Any?>
/** The `tasks` columns a client may write. Everything else the table publishes is ignored on a write. */
val WRITABLE_TASK_COLUMNS: Set<String> = setOf(
Tasks.LIST_ID, Tasks.UID, Tasks.TITLE, Tasks.DESCRIPTION, Tasks.LOCATION, Tasks.URL, Tasks.PRIORITY,
Tasks.CLASSIFICATION, Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED, Tasks.TASK_COLOR, Tasks.DTSTART,
Tasks.DUE, Tasks.DURATION, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.PARENT_ID,
Tasks.SORTING,
)
/** The columns that say whether a task, or one occurrence of it, is done. */
val COMPLETION_COLUMNS: Set<String> = setOf(Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED)
/** The columns that place a task in time; one occurrence of a series cannot be moved through the provider. */
val TIME_COLUMNS: Set<String> = setOf(
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
)
/** What a write to one occurrence of a series does to it. */
enum class OccurrenceChange { COMPLETE, CANCEL, REOPEN }
private val ICAL_DATE = Regex("""\d{8}(T\d{6}Z?)?""")
private val ICAL_DURATION = Regex("""[+-]?P(\d+W|(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?)""")
/**
* Turns a client's OpenTasks `ContentValues` into [TaskEntity] changes. Pure, so
* the rules are testable on the JVM. A bad value throws [IllegalArgumentException],
* which reaches the client as is.
*
* Only [WRITABLE_TASK_COLUMNS] change anything. The rest of what the provider
* publishes is accepted and ignored, so a client can write back a row it read;
* the fields Agendula keeps for CalDAV (`href`, `etag`, `sequence`, unknown
* properties) are not published and so cannot be written at all.
*/
object ProviderTaskWriter {
/**
* [values] narrowed to the columns in [writable]. A column [table] does not
* have at all is a client bug and throws.
*/
fun writable(table: ProviderTable, values: ProviderValues, writable: Set<String>): ProviderValues {
val known = table.columns.mapTo(HashSet()) { it.first }
val unknown = values.keys - known
require(unknown.isEmpty()) { "Unknown column(s) for ${table.path}: ${unknown.sorted().joinToString()}" }
return values.filterKeys { it in writable }
}
/** A new task from [values], which must name its list. */
fun newTask(values: ProviderValues, uid: String, now: Instant, tzId: String): TaskEntity {
val v = Values(values)
val listId = requireNotNull(v.long(Tasks.LIST_ID)) { "A new task needs a ${Tasks.LIST_ID}" }
val base = TaskEntity(listId = listId, uid = v.string(Tasks.UID)?.trim()?.ifEmpty { null } ?: uid, createdAt = now)
// As in OpenTasks, a new task inserted without a zone is floating.
val zoned = if (Tasks.TZ in v) values else values + (Tasks.TZ to null)
return applied(base, zoned - Tasks.LIST_ID - Tasks.UID, now, tzId)
}
/**
* [current] with [values] applied. Identity and sync columns are left alone,
* and [current] itself comes back when nothing changed, so a write that
* changes nothing owes the server nothing.
*/
fun patch(current: TaskEntity, values: ProviderValues, now: Instant, tzId: String): TaskEntity {
val edited = applied(current, values, now, tzId)
return if (edited.copy(lastModified = current.lastModified, isDirty = current.isDirty) == current) current else edited
}
/**
* The columns in [values] that would change [current] on their own. Writing
* back what a row published changes nothing, so a client that read a row can
* send all of it with the one value it meant to change.
*/
fun changedColumns(current: TaskEntity, values: ProviderValues, tzId: String): Set<String> {
val stamp = current.lastModified ?: Instant.DISTANT_PAST
return values.keys.filterTo(HashSet()) { key ->
val edited = runCatching { applied(current, mapOf(key to values[key]), stamp, tzId) }.getOrNull()
edited == null || edited.copy(lastModified = current.lastModified, isDirty = current.isDirty) != current
}
}
private fun applied(current: TaskEntity, values: ProviderValues, now: Instant, tzId: String): TaskEntity {
val v = Values(values)
val isOverride = current.masterId != null
if (Tasks.UID in v) require(v.string(Tasks.UID)?.trim() == current.uid) { "A task's ${Tasks.UID} cannot change" }
if (isOverride) {
// NULL is what the row publishes, so writing it back is fine.
require(listOf(Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE).none { it in v && v.text(it) != null }) {
"A modified occurrence carries no recurrence rule of its own"
}
if (Tasks.LIST_ID in v) require(v.long(Tasks.LIST_ID) == current.listId) { "A modified occurrence stays in its series' list" }
if (Tasks.PARENT_ID in v) require(v.long(Tasks.PARENT_ID) == current.parentId) { "A modified occurrence keeps its series' parent" }
}
val listId = if (Tasks.LIST_ID in v) requireNotNull(v.long(Tasks.LIST_ID)) { "${Tasks.LIST_ID} cannot be null" } else current.listId
val parentId = if (Tasks.PARENT_ID in v) v.long(Tasks.PARENT_ID)?.takeIf { it > 0 } else current.parentId
require(parentId == null || parentId != current.id || current.id == 0L) { "A task cannot be its own parent" }
val allDay = if (Tasks.IS_ALLDAY in v) v.boolean(Tasks.IS_ALLDAY) else current.isAllDay
val dtstart = (if (Tasks.DTSTART in v) v.instant(Tasks.DTSTART) else current.dtstart)?.forAllDay(allDay)
val due = (if (Tasks.DUE in v) v.instant(Tasks.DUE) else current.due)?.forAllDay(allDay)
val duration = when {
Tasks.DURATION in v -> v.string(Tasks.DURATION)?.trim()?.ifEmpty { null }
?.also { require(ICAL_DURATION.matches(it)) { "Not an iCalendar duration: $it" } }
Tasks.DUE in v && due != null -> null
else -> current.duration
}
require(due == null || duration == null) { "${Tasks.DUE} and ${Tasks.DURATION} are mutually exclusive" }
require(dtstart == null || due == null || due >= dtstart) { "${Tasks.DUE} is before ${Tasks.DTSTART}" }
val timezone = when {
allDay -> null
Tasks.TZ in v -> v.string(Tasks.TZ)?.also { zone ->
require(runCatching { ZoneId.of(zone) }.isSuccess) { "Unknown time zone: $zone" }
}
dtstart == null && due == null -> null
// A timed task without a zone is floating (RFC 5545 §3.3.5) and stays so.
current.dtstart != null || current.due != null -> if (current.isAllDay) tzId else current.timezone
else -> current.timezone ?: tzId
}
val rrule = if (Tasks.RRULE in v) normalizedRule(v.string(Tasks.RRULE)) else current.rrule
val ruleDropped = Tasks.RRULE in v && rrule == null && current.rrule != null
val rdate = if (Tasks.RDATE in v) dateList(Tasks.RDATE, v, current.rdate) else current.rdate.takeUnless { ruleDropped }
val exdate = if (Tasks.EXDATE in v) dateList(Tasks.EXDATE, v, current.exdate) else current.exdate.takeUnless { ruleDropped }
require(rrule == null || dtstart != null || due != null) { "A recurring task needs a ${Tasks.DTSTART} or ${Tasks.DUE}" }
val (status, percent, completedAt) = completion(current, v, now)
return current.copy(
listId = listId,
title = if (Tasks.TITLE in v) v.string(Tasks.TITLE)?.trim() else current.title,
description = if (Tasks.DESCRIPTION in v) v.text(Tasks.DESCRIPTION) else current.description,
location = if (Tasks.LOCATION in v) v.text(Tasks.LOCATION) else current.location,
url = if (Tasks.URL in v) v.text(Tasks.URL) else current.url,
priority = if (Tasks.PRIORITY in v) v.int(Tasks.PRIORITY, 0..9) ?: TasksContract.PRIORITY_NONE else current.priority,
classification = if (Tasks.CLASSIFICATION in v) v.int(Tasks.CLASSIFICATION, 0..2) else current.classification,
color = if (Tasks.TASK_COLOR in v) v.int(Tasks.TASK_COLOR) else current.color,
sortOrder = if (Tasks.SORTING in v) v.int(Tasks.SORTING) ?: 0 else current.sortOrder,
status = status,
percentComplete = percent,
completedAt = completedAt,
dtstart = dtstart,
due = due,
duration = duration,
isAllDay = allDay,
timezone = timezone,
rrule = rrule,
rdate = rdate,
exdate = exdate,
parentId = parentId,
lastModified = now,
isDirty = true,
)
}
/**
* What [values] do to one occurrence of a series, or `null` when they say
* nothing about its completion. An occurrence is done, cancelled or open;
* partial progress belongs to a task, not to one of its occurrences.
*/
fun occurrenceChange(values: ProviderValues): OccurrenceChange? {
val v = Values(values.filterKeys { it in COMPLETION_COLUMNS })
return when {
Tasks.STATUS in v -> when (v.int(Tasks.STATUS, 0..3)) {
TasksContract.STATUS_COMPLETED -> OccurrenceChange.COMPLETE
TasksContract.STATUS_CANCELLED -> OccurrenceChange.CANCEL
TasksContract.STATUS_NEEDS_ACTION, null -> OccurrenceChange.REOPEN
else -> throw IllegalArgumentException("An occurrence is completed, cancelled or open, not in process")
}
Tasks.PERCENT_COMPLETE in v -> when (v.int(Tasks.PERCENT_COMPLETE, 0..100)) {
100 -> OccurrenceChange.COMPLETE
0, null -> OccurrenceChange.REOPEN
else -> throw IllegalArgumentException("An occurrence is completed or open; partial progress belongs to its task")
}
Tasks.COMPLETED in v -> if (v.instant(Tasks.COMPLETED) != null) OccurrenceChange.COMPLETE else OccurrenceChange.REOPEN
else -> null
}
}
/**
* Status, progress and completion time after a write. They move together the
* way OpenTasks keeps them: a status implies its progress and time, progress
* implies a status, and a completion time alone completes the task. When a
* row is written back, the column that changed decides, not the ones it
* carries unchanged.
*/
private fun completion(current: TaskEntity, values: Values, now: Instant): Triple<TaskStatus, Int?, Instant?> {
val v = Values(values.map.filterKeys { it !in COMPLETION_COLUMNS || it in completionChanged(current, values) })
val percent = if (Tasks.PERCENT_COMPLETE in v) v.int(Tasks.PERCENT_COMPLETE, 0..100) else current.percentComplete
val completed = if (Tasks.COMPLETED in v) v.instant(Tasks.COMPLETED) else current.completedAt
return when {
Tasks.STATUS in v -> when (val status = statusFromInt(v.int(Tasks.STATUS, 0..3))) {
TaskStatus.COMPLETED -> Triple(status, if (Tasks.PERCENT_COMPLETE in v) percent else 100, completed ?: now)
TaskStatus.NEEDS_ACTION ->
Triple(status, if (Tasks.PERCENT_COMPLETE in v) percent?.takeIf { it < 100 } else null, null)
else -> Triple(status, percent, null)
}
Tasks.PERCENT_COMPLETE in v -> when {
percent == null -> Triple(current.status, null, current.completedAt)
percent >= 100 -> Triple(TaskStatus.COMPLETED, percent, completed ?: now)
percent > 0 -> Triple(TaskStatus.IN_PROCESS, percent, null)
else -> Triple(TaskStatus.NEEDS_ACTION, percent, null)
}
Tasks.COMPLETED in v -> when {
completed != null -> Triple(TaskStatus.COMPLETED, 100, completed)
// Clearing the time reopens a completed task; an open one has none to clear.
current.status == TaskStatus.COMPLETED -> Triple(TaskStatus.NEEDS_ACTION, null, null)
else -> Triple(current.status, current.percentComplete, null)
}
else -> Triple(current.status, current.percentComplete, current.completedAt)
}
}
/**
* The completion columns in [v] that differ from [current]. All of them when
* none does, so a write that only repeats the row still settles the rest.
*/
private fun completionChanged(current: TaskEntity, v: Values): Set<String> {
val present = COMPLETION_COLUMNS.filterTo(HashSet()) { it in v }
val changed = present.filterTo(HashSet()) { column ->
when (column) {
Tasks.STATUS -> statusFromInt(v.int(Tasks.STATUS, 0..3)) != current.status
Tasks.PERCENT_COMPLETE -> v.int(Tasks.PERCENT_COMPLETE, 0..100) != current.percentComplete
else -> v.instant(Tasks.COMPLETED) != current.completedAt
}
}
return changed.ifEmpty { present }
}
/**
* An `RDATE`/`EXDATE` value: DATE or DATE-TIME values, comma-separated, of
* one kind. [stored] written back as is stays, whatever shape it synced in.
*/
private fun dateList(column: String, v: Values, stored: String?): String? {
val raw = v.string(column)?.trim()?.ifEmpty { null } ?: return null
if (raw == stored?.trim()) return stored
val values = raw.split(',').map(String::trim)
require(values.all { ICAL_DATE.matches(it) }) { "$column takes iCalendar dates or date-times: $raw" }
// lib-recur reads the whole list or none of it, so it has to be one kind.
val kinds = values.map { if (it.length == 8) "date" else if (it.endsWith('Z')) "utc" else "local" }
require(kinds.distinct().size == 1) { "$column mixes value kinds: $raw" }
return values.joinToString(",")
}
private fun normalizedRule(raw: String?): String? {
val rule = raw?.trim()?.removePrefix("RRULE:")?.ifEmpty { null } ?: return null
require(runCatching { RecurrenceRule(rule) }.isSuccess) { "Not a recurrence rule: $rule" }
return rule
}
}
/** Lenient reads over [values]: a client may hand a number as a string or a flag as an integer. */
internal class Values(private val values: ProviderValues) {
val keys: Set<String> get() = values.keys
val map: ProviderValues get() = values
operator fun contains(key: String) = key in values
fun string(key: String): String? = values[key]?.toString()
/** A free-text column: trimmed, blank stored as nothing. */
fun text(key: String): String? = string(key)?.trim()?.ifEmpty { null }
fun long(key: String): Long? = when (val value = values[key]) {
null -> null
is Number -> value.toLong()
is Boolean -> if (value) 1L else 0L
is String -> requireNotNull(value.trim().toLongOrNull()) { "$key is not a number: $value" }
else -> throw IllegalArgumentException("$key is not a number: $value")
}
fun int(key: String, range: IntRange? = null): Int? = long(key)?.let { value ->
require(value in Int.MIN_VALUE..Int.MAX_VALUE) { "$key is out of range: $value" }
value.toInt().also { require(range == null || it in range) { "$key must be in $range: $it" } }
}
fun boolean(key: String): Boolean = when (val value = values[key]) {
null -> false
is Boolean -> value
is String -> when (value.trim().lowercase()) {
"1", "true" -> true
"0", "false", "" -> false
else -> throw IllegalArgumentException("$key is not a flag: $value")
}
else -> long(key) != 0L
}
fun instant(key: String): Instant? = long(key)?.let(Instant::fromEpochMilliseconds)
}
@@ -0,0 +1,441 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.database.sqlite.SQLiteConstraintException
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.normalizeTags
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.time.ZoneId
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Instant
/** One row by id, `<id column> = N` or `<id column> = ?`. */
private val SINGLE_ROW = Regex("""\s*(\w+)\s*=\s*(\d+|\?)\s*""")
/**
* Applies another app's writes to the own store for [AgendulaTaskProvider].
*
* A write's selection runs against the published snapshot, so it matches the
* rows the client can read, and each matched row is then written through
* [RoomTasksDataSource] under the rules the app's own edits follow. `tasklists`
* stays read-only. On `instances`, completion columns tick off that one
* occurrence of a series; other content columns edit the series, and a changed
* time column is refused, since one occurrence cannot be moved this way.
*
* Each call runs in one transaction, and a batch in one more around them.
*/
@Singleton
class ProviderWriter internal constructor(
private val database: TasksDatabase,
private val store: RoomTasksDataSource,
private val source: ProviderSnapshotSource,
private val resolver: ProviderResolver,
private val afterWrite: () -> Unit,
private val clock: Clock = Clock.System,
) {
@Inject constructor(
database: TasksDatabase,
store: RoomTasksDataSource,
source: ProviderSnapshotSource,
resolver: ProviderResolver,
scheduler: ReminderScheduler,
@ApplicationScope scope: CoroutineScope,
) : this(database, store, source, resolver, { scope.launch { runCatching { scheduler.sync() } } })
private var batchDepth = 0
private var batchWrote = false
/** Inserts one row and returns its published id. */
fun insert(table: ProviderTable, values: ProviderValues): Long = write {
when (table) {
ProviderTable.TASKS -> insertTask(values)
ProviderTable.PROPERTIES -> insertProperty(values)
else -> throw UnsupportedOperationException("${table.path} is read-only")
}
}
/** Updates every row [selection] matches and returns how many changed. */
fun update(table: ProviderTable, values: ProviderValues, selection: String?, args: Array<out String>?): Int = write {
when (table) {
ProviderTable.TASKS -> {
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
val matched = ids(table, selection, args)
(if (Tasks.LIST_ID in writable) parentsFirst(matched) else matched).count { updateTask(it, writable) }
}
ProviderTable.INSTANCES -> {
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
instances(selection, args).count { updateInstance(it, writable) }
}
ProviderTable.PROPERTIES -> ids(table, selection, args).count { updateProperty(it, values) }
else -> throw UnsupportedOperationException("${table.path} is read-only")
}
}
/** Deletes every row [selection] matches and returns how many went. */
fun delete(table: ProviderTable, selection: String?, args: Array<out String>?): Int = write {
when (table) {
ProviderTable.TASKS -> ids(table, selection, args).count { deleteTask(it) }
ProviderTable.INSTANCES -> instances(selection, args).count { deleteInstance(it) }
ProviderTable.PROPERTIES -> ids(table, selection, args).count { deleteProperty(it) }
else -> throw UnsupportedOperationException("${table.path} is read-only")
}
}
/** Runs a client's batch in one transaction, re-arming reminders once at the end. */
fun <T> batch(block: () -> T): T = source.locked {
batchDepth++
try {
database.runInTransaction<T> { block() }
} finally {
// A snapshot built mid-batch may hold rows a failure rolled back.
source.invalidate()
batchDepth--
if (batchDepth == 0 && batchWrote) {
batchWrote = false
afterWrite()
}
}
}
private fun <T> write(block: () -> T): T = source.locked {
check(resolver.mode() == StorageMode.OWN) {
"Agendula is working on top of an external tasks provider; write to that one instead"
}
val result = try {
database.runInTransaction<T> { block() }
} catch (e: TaskWriteFailedException) {
throw IllegalArgumentException(e.message, e)
} catch (e: SQLiteConstraintException) {
throw IllegalArgumentException(e.message, e)
} finally {
source.invalidate()
}
if (batchDepth == 0) afterWrite() else batchWrote = true
result
}
// --- tasks ------------------------------------------------------------------
private fun insertTask(values: ProviderValues): Long {
require(values[Tasks.ORIGINAL_INSTANCE_ID] == null && values[Tasks.ORIGINAL_INSTANCE_TIME] == null) {
"Modify an occurrence through ${Instances.PATH}, not by inserting one"
}
val writable = ProviderTaskWriter.writable(ProviderTable.TASKS, values, WRITABLE_TASK_COLUMNS)
val task = ProviderTaskWriter.newTask(writable, UUID.randomUUID().toString(), clock.now(), zone())
requireWritableList(task.listId)
// The unique index cannot catch this: SQLite treats the masters' NULL recurrence_id as distinct.
require(database.tasks().byUid(task.listId, task.uid) == null) { "List ${task.listId} already has a task ${task.uid}" }
requireParent(task)
return store.insertEntity(task)
}
private fun updateTask(taskId: Long, values: ProviderValues): Boolean {
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
requireWritableList(current.listId)
val edited = ProviderTaskWriter.patch(current, values, clock.now(), zone())
if (edited == current) return true
val moved = edited.listId != current.listId
if (moved) {
requireWritableList(edited.listId)
requireFreeUid(edited.listId, current.uid)
}
if (edited.parentId != current.parentId || moved) requireParent(edited)
store.replaceEntity(current, edited)
// A subtree moves as one: a subtask has to be in its parent's list.
if (moved) store.subtaskIds(taskId).forEach { moveSubtree(it, edited.listId, hashSetOf(taskId)) }
return true
}
private fun moveSubtree(taskId: Long, listId: Long, seen: MutableSet<Long> = HashSet()) {
if (!seen.add(taskId)) return
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return
if (current.listId != listId) {
requireFreeUid(listId, current.uid)
store.replaceEntity(current, current.copy(listId = listId, lastModified = clock.now(), isDirty = true))
}
store.subtaskIds(taskId).forEach { moveSubtree(it, listId, seen) }
}
/**
* [ids] with every parent before its subtasks, so a bulk move carries a
* subtree along before reaching its subtasks, whatever order they came in.
*/
private fun parentsFirst(ids: List<Long>): List<Long> {
if (ids.size < 2) return ids
fun depth(id: Long): Int {
val seen = HashSet<Long>()
var parent = database.tasks().entity(id)?.parentId
while (parent != null && seen.add(parent)) parent = database.tasks().entity(parent)?.parentId
return seen.size
}
val depths = ids.associateWith(::depth)
return ids.sortedBy(depths::getValue)
}
private fun deleteTask(taskId: Long): Boolean {
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
requireWritableList(current.listId)
// An override deletes itself into an EXDATE on its master.
if (current.masterId != null) store.deleteTask(taskId) else store.deleteWithSubtasks(taskId)
return true
}
// --- instances --------------------------------------------------------------
/** One occurrence: the row it reads from, its series, and when it was due to happen. */
private class InstanceRow(val taskId: Long, val seriesId: Long, val originalTime: Instant?, val recurring: Boolean)
private fun instances(selection: String?, args: Array<out String>?): List<InstanceRow> {
singleId(ProviderTable.INSTANCES, selection, args)?.let { return listOfNotNull(instanceRow(it)) }
return source.rows(
ProviderTable.INSTANCES,
listOf(Tasks.ID, Instances.TASK_ID, Instances.INSTANCE_ORIGINAL_TIME, Instances.IS_RECURRING),
selection,
args,
).map { (id, taskId, original, recurring) ->
InstanceRow(
taskId = taskId as Long,
seriesId = ProviderIds.instanceSeries(id as Long),
originalTime = (original as Long?)?.let(Instant::fromEpochMilliseconds),
recurring = recurring == 1L,
)
}
}
/** [instanceId] read straight from the store, without rebuilding the snapshot. */
private fun instanceRow(instanceId: Long): InstanceRow? {
val master = database.tasks().entity(ProviderIds.instanceSeries(instanceId))
?.takeIf { !it.isDeleted && it.masterId == null } ?: return null
val occurrences = seriesOccurrences(master, database.tasks().overrides(master.id), clock.now())
?: return InstanceRow(master.id, master.id, null, recurring = false)
.takeIf { ProviderIds.instanceSeconds(instanceId) == 0L }
val occurrence = occurrences.firstOrNull { ProviderIds.instance(master.id, it.anchor) == instanceId } ?: return null
return InstanceRow(occurrence.override?.id ?: master.id, master.id, occurrence.anchor, recurring = true)
}
private fun updateInstance(row: InstanceRow, values: ProviderValues): Boolean {
val occurrence = row.originalTime
if (!row.recurring || occurrence == null) return updateTask(row.taskId, values)
val shown = database.tasks().entity(row.taskId)?.takeUnless { it.isDeleted } ?: return false
requireWritableList(shown.listId)
// A row read back carries every column; only what differs from it is a write.
val changed = ProviderTaskWriter.changedColumns(shown, values, zone())
val moved = changed intersect TIME_COLUMNS
if (moved.isNotEmpty()) {
// A bad value reports itself rather than as a move.
ProviderTaskWriter.patch(shown, values.filterKeys { it in moved }, clock.now(), zone())
throw IllegalArgumentException(
"One occurrence of a recurring task cannot be moved (${moved.sorted().joinToString()}); write the series through ${Tasks.PATH}",
)
}
val content = values.filterKeys { it in changed && it !in COMPLETION_COLUMNS }
if (content.isNotEmpty()) {
updateTask(row.seriesId, content)
// A modified occurrence shows its own copy of the content, so it takes the write too.
if (row.taskId != row.seriesId) updateTask(row.taskId, content - Tasks.LIST_ID - Tasks.PARENT_ID)
}
if (changed.none { it in COMPLETION_COLUMNS }) return true
// The column the client changed decides, not the unchanged ones it carries along.
val change = ProviderTaskWriter.occurrenceChange(values.filterKeys { it in changed && it in COMPLETION_COLUMNS })
?: return true
val status = database.tasks().entity(row.taskId)?.status
when (change) {
OccurrenceChange.COMPLETE ->
if (status != TaskStatus.COMPLETED) store.setCompletedInstance(row.taskId, occurrence, true)
OccurrenceChange.CANCEL ->
if (status != TaskStatus.CANCELLED) store.setCancelledInstance(row.taskId, occurrence, true)
OccurrenceChange.REOPEN ->
if (status != TaskStatus.NEEDS_ACTION) store.setCompletedInstance(row.taskId, occurrence, false)
}
return true
}
private fun deleteInstance(row: InstanceRow): Boolean {
val occurrence = row.originalTime
if (!row.recurring || occurrence == null) return deleteTask(row.taskId)
val task = database.tasks().entity(row.taskId)?.takeUnless { it.isDeleted } ?: return false
requireWritableList(task.listId)
if (task.masterId != null) store.deleteTask(task.id) else store.deleteOccurrence(task.id, occurrence)
return true
}
// --- properties -------------------------------------------------------------
/** What a published property id stands for, read from the store. */
private sealed interface PropertyRef {
val task: TaskEntity
class AlarmRow(val alarm: TaskAlarmEntity, override val task: TaskEntity) : PropertyRef
class CategoryRow(val name: String, override val task: TaskEntity) : PropertyRef
}
private fun propertyRef(propertyId: Long): PropertyRef? {
if (ProviderIds.isAlarm(propertyId)) {
val alarm = database.alarms().entity(ProviderIds.alarmRow(propertyId)) ?: return null
val task = database.tasks().entity(alarm.taskId)?.takeUnless { it.isDeleted } ?: return null
return PropertyRef.AlarmRow(alarm, task)
}
val task = database.tasks().entity(ProviderIds.categoryTask(propertyId))?.takeUnless { it.isDeleted } ?: return null
val name = ProviderIds.categories(task.id, task.categories).entries.firstOrNull { it.value == propertyId }?.key
?: return null
return PropertyRef.CategoryRow(name, task)
}
private fun insertProperty(values: ProviderValues): Long {
val v = Values(ProviderTaskWriter.writable(ProviderTable.PROPERTIES, values, values.keys))
val taskId = requireNotNull(v.long(Properties.TASK_ID)) { "A property needs a ${Properties.TASK_ID}" }
val task = liveTask(taskId)
return when (v.string(Properties.MIMETYPE)) {
Alarm.MIMETYPE -> {
val alarm = alarm(TaskAlarmEntity(taskId = taskId, minutesBefore = 0), v, requireMinutes = true)
var id = 0L
store.writeAlarm(taskId) { id = database.alarms().insert(alarm) }
ProviderIds.alarm(id)
}
Category.MIMETYPE -> {
val name = requireNotNull(v.text(Category.NAME)) { "A category needs a ${Category.NAME}" }
setCategories(task, task.categories + name)
val categories = database.tasks().entity(taskId)!!.categories
ProviderIds.categories(taskId, categories).getValue(name)
}
else -> throw IllegalArgumentException("Only alarm and category properties can be written")
}
}
private fun updateProperty(propertyId: Long, values: ProviderValues): Boolean {
val v = Values(ProviderTaskWriter.writable(ProviderTable.PROPERTIES, values, values.keys - Properties.PROPERTY_ID))
val ref = propertyRef(propertyId) ?: return false
requireWritableList(ref.task.listId)
requireSame(v, Properties.TASK_ID, ref.task.id)
val mimetype = if (ref is PropertyRef.AlarmRow) Alarm.MIMETYPE else Category.MIMETYPE
if (Properties.MIMETYPE in v) require(v.string(Properties.MIMETYPE) == mimetype) { "A property's mimetype cannot change" }
when (ref) {
is PropertyRef.AlarmRow -> {
val edited = alarm(ref.alarm, v, requireMinutes = false)
if (edited != ref.alarm) store.writeAlarm(ref.task.id) { database.alarms().update(edited) }
}
is PropertyRef.CategoryRow -> if (Category.NAME in v) {
val name = requireNotNull(v.text(Category.NAME)) { "A category needs a ${Category.NAME}" }
setCategories(ref.task, ref.task.categories.map { if (it == ref.name) name else it })
}
}
return true
}
private fun deleteProperty(propertyId: Long): Boolean {
val ref = propertyRef(propertyId) ?: return false
requireWritableList(ref.task.listId)
when (ref) {
is PropertyRef.AlarmRow -> store.writeAlarm(ref.task.id) { database.alarms().delete(ref.alarm.id) }
is PropertyRef.CategoryRow -> setCategories(ref.task, ref.task.categories - ref.name)
}
return true
}
private fun alarm(base: TaskAlarmEntity, v: Values, requireMinutes: Boolean): TaskAlarmEntity {
if (requireMinutes) requireNotNull(v.int(Alarm.MINUTES_BEFORE)) { "An alarm needs ${Alarm.MINUTES_BEFORE}" }
val reference = when (if (Alarm.REFERENCE in v) v.int(Alarm.REFERENCE) else null) {
null -> base.reference
Alarm.REFERENCE_DUE -> AlarmReference.DUE
Alarm.REFERENCE_START -> AlarmReference.START
else -> throw IllegalArgumentException("${Alarm.REFERENCE} must be ${Alarm.REFERENCE_DUE} (due) or ${Alarm.REFERENCE_START} (start)")
}
return base.copy(
minutesBefore = if (Alarm.MINUTES_BEFORE in v) requireNotNull(v.int(Alarm.MINUTES_BEFORE)) {
"${Alarm.MINUTES_BEFORE} cannot be null"
} else base.minutesBefore,
reference = reference,
message = if (Alarm.MESSAGE in v) v.text(Alarm.MESSAGE) else base.message,
)
}
private fun setCategories(task: TaskEntity, categories: List<String>) {
val normalized = normalizeTags(categories)
if (normalized == task.categories) return
store.replaceEntity(task, task.copy(categories = normalized, lastModified = clock.now(), isDirty = true))
}
// --- checks -----------------------------------------------------------------
/**
* The ids [selection] matches. A row URI's plain `<id column> = N` is checked
* against the store directly; anything else runs against the snapshot.
*/
private fun ids(table: ProviderTable, selection: String?, args: Array<out String>?): List<Long> {
singleId(table, selection, args)?.let { id ->
val exists = when (table) {
ProviderTable.TASKS -> database.tasks().entity(id)?.isDeleted == false
ProviderTable.PROPERTIES -> propertyRef(id) != null
else -> true
}
return listOfNotNull(id.takeIf { exists })
}
return source.rows(table, listOf(table.idColumn), selection, args).map { it.single() as Long }
}
private fun singleId(table: ProviderTable, selection: String?, args: Array<out String>?): Long? {
val match = SINGLE_ROW.matchEntire(selection ?: return null) ?: return null
if (match.groupValues[1] != table.idColumn) return null
val value = match.groupValues[2]
return when {
value == "?" -> args?.singleOrNull()?.trim()?.toLongOrNull()
args.isNullOrEmpty() -> value.toLongOrNull()
else -> null
}
}
private fun requireFreeUid(listId: Long, uid: String) {
require(database.tasks().byUid(listId, uid)?.isDeleted != false) { "List $listId already has a task $uid" }
}
private fun liveTask(taskId: Long): TaskEntity {
val task = requireNotNull(database.tasks().entity(taskId)?.takeUnless { it.isDeleted }) { "No task $taskId" }
requireWritableList(task.listId)
return task
}
private fun requireWritableList(listId: Long) {
val list = requireNotNull(database.taskLists().entity(listId)) { "No task list $listId" }
if (list.isReadOnly) throw UnsupportedOperationException("Task list $listId is read-only")
}
/** A parent has to be a live, top-level task in the same list, and not one of the task's own subtasks. */
private fun requireParent(task: TaskEntity) {
var parentId = task.parentId ?: return
val parent = database.tasks().entity(parentId)?.takeUnless { it.isDeleted }
require(parent != null && parent.masterId == null) { "No task $parentId to be a parent" }
require(parent.listId == task.listId) { "A subtask has to be in its parent's list" }
val seen = HashSet<Long>()
while (true) {
require(parentId != task.id) { "Task ${task.id} cannot be its own ancestor" }
if (!seen.add(parentId)) break
parentId = database.tasks().entity(parentId)?.parentId ?: break
}
}
private fun requireSame(v: Values, column: String, expected: Long) {
if (column in v) require(v.long(column) == expected) { "A property's $column cannot change" }
}
private fun zone(): String = ZoneId.systemDefault().id
}
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.ContentValues
import android.database.Cursor
import android.database.MatrixCursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteQueryBuilder
import android.os.Build
import androidx.core.database.sqlite.transaction
/**
* An in-memory SQLite copy of a [ProviderSnapshot], so a client's own
* `selection`, `selectionArgs` and `sortOrder` run as SQL exactly as they would
* against OpenTasks. Holds only what the caller may already read.
*
* Not thread-safe; [ProviderSnapshotSource] serialises access.
*/
internal class SnapshotDatabase {
private val db: SQLiteDatabase = SQLiteDatabase.create(null).apply {
ProviderTable.entries.forEach { table ->
execSQL(
"CREATE TABLE ${table.path} (" +
table.columns.joinToString { (name, type) -> "$name $type" } + ")",
)
}
}
fun load(snapshot: ProviderSnapshot) {
db.transaction {
ProviderTable.entries.forEach { table ->
delete(table.path, null, null)
snapshot.rows[table].orEmpty().forEach { insertOrThrow(table.path, null, it.toContentValues()) }
}
}
}
/**
* Runs the query and copies the result out, so the cursor a client holds
* survives the next [load].
*/
fun query(
table: ProviderTable,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor {
val builder = SQLiteQueryBuilder().apply {
tables = table.path
isStrict = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
isStrictColumns = true
isStrictGrammar = true
}
projectionMap = table.columns.associate { (name, _) -> name to name }
}
builder.query(db, projection, selection, selectionArgs, null, null, sortOrder).use { source ->
val copy = MatrixCursor(source.columnNames, source.count)
while (source.moveToNext()) {
copy.addRow(
Array(source.columnCount) { column ->
when (source.getType(column)) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> source.getLong(column)
Cursor.FIELD_TYPE_FLOAT -> source.getDouble(column)
Cursor.FIELD_TYPE_BLOB -> source.getBlob(column)
else -> source.getString(column)
}
},
)
}
return copy
}
}
private fun ProviderRow.toContentValues() = ContentValues(size).also { values ->
forEach { (key, value) ->
when (value) {
null -> values.putNull(key)
is Long -> values.put(key, value)
is Int -> values.put(key, value)
is String -> values.put(key, value)
else -> values.put(key, value.toString())
}
}
}
}
@@ -2,8 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
import androidx.room.InvalidationTracker
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.editable
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
@@ -31,7 +31,51 @@ private val SERIES_LINES = listOf("EXDATE", "RDATE", "RRULE", "RECURRENCE-ID", "
/** Enough to count every occurrence before a split point without bounding a real series. */
internal const val SPLIT_COUNT_CEILING = 100_000
private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
/** The window every read of the store expands a series over. */
internal fun expansionWindow(now: Instant) = ExpansionWindow(
from = now - WINDOW_BACK,
until = now + WINDOW_FORWARD,
pivot = now,
)
/**
* One occurrence's resolved start and due. A timed series keeps each
* occurrence's duration; a due-anchored one has no start to offset from, so
* the anchor *is* the due date.
*/
internal fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair<Instant?, Instant?> {
if (master.dtstart == null) return null to anchor
val length = master.due?.let { it - master.dtstart }
return anchor to length?.let { anchor + it }
}
/** One occurrence of a series: its anchor, the override replacing it if any, and its resolved times. */
internal class SeriesOccurrence(
val anchor: Instant,
val override: TaskEntity?,
val start: Instant?,
val due: Instant?,
val distance: Int?,
)
/**
* Every occurrence of [master] inside the expansion window, with any
* `RECURRENCE-ID` override substituted for the occurrence it replaces, or
* `null` when [master] does not recur.
*/
internal fun seriesOccurrences(master: TaskEntity, overrides: List<TaskEntity>, now: Instant): List<SeriesOccurrence>? {
val spec = master.recurrenceSpec() ?: return null
val anchors = RecurrenceExpander.expand(spec, expansionWindow(now))
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
val byAnchor = overrides.associateBy { it.recurrenceId }
return anchors.mapIndexed { index, anchor ->
val override = byAnchor[anchor]
val (start, due) = if (override != null) override.dtstart to override.due else occurrenceTimes(master, anchor)
SeriesOccurrence(anchor, override, start, due, distances.getOrNull(index))
}
}
internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
private const val ACK_CACHE_SIZE = 512
@@ -104,6 +148,12 @@ class RoomTasksDataSource @Inject constructor(
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
}
/** One transaction, so a failure part-way leaves the subtree whole. */
override fun deleteWithSubtasks(taskId: Long) = tx { super.deleteWithSubtasks(taskId) }
override fun subtaskIds(parentTaskId: Long): List<Long> =
tasks.subtasks(parentTaskId).map { it.task.id }.filter { it != parentTaskId }
override fun exportTasks(listId: Long): List<ExportTask> =
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
@@ -125,9 +175,6 @@ class RoomTasksDataSource @Inject constructor(
}
}
override fun alarms(): Map<Long, TaskReminder> =
reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap()
/**
* Every occurrence of [row] inside the expansion window, with any
* `RECURRENCE-ID` override substituted for the occurrence it replaces.
@@ -136,54 +183,31 @@ class RoomTasksDataSource @Inject constructor(
* [Task.occurrenceStart], so it keys and edits by task id exactly as before.
*/
private fun occurrencesOf(row: TaskRow, overrides: List<TaskEntity>, now: Instant): List<Task> {
val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row))
val window = ExpansionWindow(
from = now - WINDOW_BACK,
until = now + WINDOW_FORWARD,
pivot = now,
)
val anchors = RecurrenceExpander.expand(spec, window)
if (anchors.isEmpty()) return emptyList()
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
val byAnchor = overrides.associateBy { it.recurrenceId }
return anchors.mapIndexedNotNull { index, anchor ->
val override = byAnchor[anchor]
val occurrences = seriesOccurrences(row.task, overrides, now) ?: return listOf(RoomTaskMapper.task(row))
return occurrences.map { occurrence ->
val override = occurrence.override
if (override != null) {
RoomTaskMapper.task(
row = row.copy(task = override),
occurrenceStart = anchor,
start = override.dtstart,
due = override.due,
distanceFromCurrent = distances[index],
occurrenceStart = occurrence.anchor,
start = occurrence.start,
due = occurrence.due,
distanceFromCurrent = occurrence.distance,
seriesId = row.task.id,
recurrenceRule = row.task.rrule,
)
} else {
val (start, due) = occurrenceTimes(row.task, anchor)
RoomTaskMapper.task(
row = row,
occurrenceStart = anchor,
start = start,
due = due,
distanceFromCurrent = distances[index],
occurrenceStart = occurrence.anchor,
start = occurrence.start,
due = occurrence.due,
distanceFromCurrent = occurrence.distance,
)
}
}
}
/**
* One occurrence's resolved start and due. A timed series keeps each
* occurrence's duration; a due-anchored one has no start to offset from, so
* the anchor *is* the due date.
*/
private fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair<Instant?, Instant?> {
if (master.dtstart == null) return null to anchor
val length = master.due?.let { it - master.dtstart }
return anchor to length?.let { anchor + it }
}
// --- writes ---------------------------------------------------------------
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long {
@@ -199,26 +223,24 @@ class RoomTasksDataSource @Inject constructor(
}
/**
* [leads] as [taskId]'s before-due reminders, inside the caller's
* transaction. A start-relative row is kept: the editor never shows one.
* [reminders] as [taskId]'s own, inside the caller's transaction.
*
* ⚠️ Part of the task write rather than a second call after it. Once
* reminders sync, a sync landing between the two would upload the task
* without them, or with the ones it is about to lose.
*/
private fun dueLeads(taskId: Long): List<Int> =
alarms.forTask(taskId).filter { it.reference == AlarmReference.DUE }.map { it.minutesBefore }.sorted()
private fun writeDueReminders(taskId: Long, leads: List<Int>?) {
if (leads == null) return
val kept = alarms.forTask(taskId).filter { it.reference == AlarmReference.START }.map { it.asReminder() }
alarms.syncForTask(taskId, kept + leads.distinct().sorted().map { TaskReminder(it) })
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?) {
if (reminders == null) return
alarms.syncForTask(taskId, reminders)
}
override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) = tx {
private fun storedReminders(taskId: Long): List<TaskReminder> =
alarms.forTask(taskId).map { it.asReminder() }.sortedWith(TaskReminderOrder)
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = tx {
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
val edited = TaskFormWriter.apply(current, form, clock.now(), zone())
writeDueReminders(taskId, dueReminders)
writeReminders(taskId, reminders)
if (current.masterId == null && edited.listId != current.listId) {
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
move(current, TaskFormWriter.withRule(edited, form.rrule))
@@ -257,20 +279,23 @@ class RoomTasksDataSource @Inject constructor(
plan.tombstone?.let { tasks.insert(it) }
}
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) = tx {
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) = tx {
val master = seriesMaster(seriesId)
// ⚠️ The overrides too, where they still carry the series' reminders.
// A fork copies them as a snapshot — completing one occurrence is
// enough — and each override uploads its own VALARMs, so leaving them
// would keep ringing those occurrences at the old lead everywhere. One
// whose reminders were set for that occurrence alone keeps them.
if (dueReminders != null) {
val before = dueLeads(master.id)
if (reminders != null) {
val before = storedReminders(master.id)
tasks.overrides(master.id)
.filter { dueLeads(it.id) == before }
.forEach { writeDueReminders(it.id, dueReminders) }
.filter { storedReminders(it.id) == before }
.forEach {
writeReminders(it.id, reminders)
if (form.remindersChosen && reminders.isEmpty()) alarms.setRemindersOff(it.id, true)
}
}
writeDueReminders(master.id, dueReminders)
writeReminders(master.id, reminders)
val (start, due) = resolvedTimes(master, occurrenceStart)
val edited = TaskFormWriter.seriesEdit(master, start, due, form, clock.now(), zone())
// A moved or re-ruled series no longer generates the anchors its
@@ -286,14 +311,14 @@ class RoomTasksDataSource @Inject constructor(
writes.onWritten(setOf(master.listId, edited.listId))
}
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long {
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long {
var created = seriesId
tx {
val master = seriesMaster(seriesId)
val spec = master.recurrenceSpec()
val anchor = master.recurrenceAnchor
if (spec == null || anchor == null || occurrenceStart <= anchor) {
updateSeries(seriesId, occurrenceStart, form, dueReminders)
updateSeries(seriesId, occurrenceStart, form, reminders)
return@tx
}
val now = clock.now()
@@ -338,7 +363,7 @@ class RoomTasksDataSource @Inject constructor(
val newId = tasks.insert(fresh)
// A new UID, so a new resource: the alarms are copies, not the same ones.
alarms.copyToTask(master.id, newId, sameResource = false)
writeDueReminders(newId, dueReminders)
writeReminders(newId, reminders)
val unmoved = fresh.dtstart == start && fresh.due == due && sameRule
tasks.overrides(master.id)
@@ -373,8 +398,7 @@ class RoomTasksDataSource @Inject constructor(
val master = tasks.entity(seriesId) ?: return@tx
val anchor = master.recurrenceAnchor
if (anchor == null || occurrenceStart <= anchor) {
tasks.subtasks(seriesId).forEach { deleteTask(it.task.id) }
deleteTask(seriesId)
deleteWithSubtasks(seriesId)
return@tx
}
tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, clock.now(), zone()))
@@ -403,7 +427,7 @@ class RoomTasksDataSource @Inject constructor(
* which is the model least compatible with sync; the override shares its
* master's UID, which is exactly what makes it an override.
*/
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) = tx {
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) = tx {
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId")
val now = clock.now()
val existing = tasks.override(taskId, occurrenceStart)
@@ -412,7 +436,7 @@ class RoomTasksDataSource @Inject constructor(
// master's, whatever the form was carrying.
val edited = TaskFormWriter.apply(existing, form, now, zone())
tasks.update(edited.copy(listId = master.listId, parentId = master.parentId))
writeDueReminders(existing.id, dueReminders)
writeReminders(existing.id, reminders)
writes.onWritten(setOf(master.listId))
return@tx
}
@@ -429,18 +453,10 @@ class RoomTasksDataSource @Inject constructor(
// The occurrence starts out with the series' reminders and then takes
// the form's — the master's own are never touched.
alarms.copyToTask(taskId, id, sameResource = true)
writeDueReminders(id, dueReminders)
writeReminders(id, reminders)
writes.onWritten(setOf(master.listId))
}
/** Through [setReminders], so a synced task is owed the PUT and the others keep their identity. */
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
val current = alarms.forTask(taskId).map { it.asReminder() }
val editable = current.indexOfLast { !it.fromStart }
val others = if (editable < 0) current else current.filterIndexed { index, _ -> index != editable }
setReminders(taskId, others + listOfNotNull(minutesBeforeDue?.let { TaskReminder(it) }))
}
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = tx {
if (!alarms.syncForTask(taskId, reminders)) return@tx
// The reminders are part of the VTODO now, so changing them is an edit.
@@ -566,6 +582,47 @@ class RoomTasksDataSource @Inject constructor(
writes.onWritten(setOf(current.listId))
}
// --- whole-row writes, for the published provider --------------------------
/** Inserts [entity] as it stands, for a client that wrote the row itself. */
internal fun insertEntity(entity: TaskEntity): Long {
if (lists.exists(entity.listId) == 0) throw TaskWriteFailedException("insert task: no list ${entity.listId}")
return tasks.insert(entity).also { writes.onWritten(setOf(entity.listId)) }
}
/**
* Writes [edited] over [current] under the rules [updateTask] and
* [updateSeries] keep: a task moves lists through [TaskMove], an override
* keeps its master's list and parent, and a series whose anchor or rule
* changed drops the overrides it no longer generates.
*/
internal fun replaceEntity(current: TaskEntity, edited: TaskEntity) {
tx {
val reshaped = current.masterId == null && current.recurrenceSpec() != null &&
(edited.dtstart != current.dtstart || edited.due != current.due || edited.rrule != current.rrule)
if (reshaped) tasks.overrides(current.id).forEach { tasks.delete(it.id) }
when {
current.masterId != null -> tasks.update(edited.copy(listId = current.listId, parentId = current.parentId))
edited.listId != current.listId -> {
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
move(current, edited)
}
else -> tasks.update(edited)
}
}
writes.onWritten(setOf(current.listId, edited.listId))
}
/** Adds, changes or removes one reminder, and marks its task changed so the reminder syncs. */
internal fun writeAlarm(taskId: Long, write: () -> Unit) {
val task = tasks.entity(taskId) ?: throw TaskWriteFailedException("alarm on task $taskId")
tx {
write()
tasks.update(task.copy(lastModified = clock.now(), isDirty = true))
}
writes.onWritten(setOf(task.listId))
}
override fun createLocalList(name: String, color: Int): Long =
lists.insert(TaskListEntity(name = name.trim(), color = color))
@@ -5,7 +5,7 @@ import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
import kotlin.time.Instant
@@ -25,18 +25,21 @@ interface TaskAlarmDao {
@Query("SELECT * FROM task_alarms WHERE task_id IN (:taskIds) ORDER BY id")
fun forTasks(taskIds: List<Long>): List<TaskAlarmEntity>
@Query("SELECT * FROM task_alarms WHERE id = :id")
fun entity(id: Long): TaskAlarmEntity?
@Insert
fun insert(alarm: TaskAlarmEntity): Long
@Update
fun update(alarm: TaskAlarmEntity): Int
@Query("DELETE FROM task_alarms WHERE id = :id")
fun delete(id: Long): Int
@Query("DELETE FROM task_alarms WHERE task_id = :taskId")
fun deleteForTask(taskId: Long): Int
@Update
fun update(alarm: TaskAlarmEntity)
/** See [TaskEntity.remindersOff]. */
@Query("UPDATE tasks SET reminders_off = :off WHERE id = :taskId")
fun setRemindersOff(taskId: Long, off: Boolean): Int
@@ -105,6 +105,10 @@ interface TaskDao {
@Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL")
fun exportTasks(listId: Long): List<TaskEntity>
/** Every row but the tombstones, overrides included — what the published provider lays out. */
@Query("SELECT * FROM tasks WHERE is_deleted = 0")
fun live(): List<TaskEntity>
@Query("SELECT * FROM tasks WHERE is_dirty = 1")
fun dirty(): List<TaskEntity>
@@ -63,6 +63,7 @@ object TaskFormWriter {
isAllDay = form.isAllDay,
timezone = if (timed) tzId else null,
parentId = form.parentId?.takeIf { it > 0 },
remindersOff = if (form.remindersChosen) form.reminders.isEmpty() else current.remindersOff,
lastModified = now,
isDirty = true,
)
@@ -7,7 +7,7 @@ import androidx.datastore.preferences.core.edit
import de.jeanlucmakiola.agendula.data.di.ExternalStore
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.toAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
@@ -18,8 +18,10 @@ data class TaskForm(
val parentId: Long? = null,
/** 0..100 progress; `null` leaves it unset (cleared). */
val percentComplete: Int? = null,
/** The task's own reminders, in minutes before [due]; empty = the list's or app default applies. */
val reminders: List<Int> = emptyList(),
/** The task's own reminders; empty = the list's or app default applies, unless [remindersChosen]. */
val reminders: List<TaskReminder> = emptyList(),
/** Whether [reminders] are exactly the task's, as the editor showed them: empty is then none. */
val remindersChosen: Boolean = false,
val location: String? = null,
val url: String? = null,
val tags: List<String> = emptyList(),
@@ -30,14 +32,14 @@ data class TaskForm(
if (title.isBlank()) add(TaskFormError.BLANK_TITLE)
if (listId <= 0L) add(TaskFormError.NO_LIST)
if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START)
if (reminders.isNotEmpty() && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE)
if (reminders.isNotEmpty() && start == null && due == null) add(TaskFormError.REMINDER_WITHOUT_DATE)
if (rrule != null && start == null && due == null) add(TaskFormError.RECURRENCE_WITHOUT_DATE)
}
val isValid: Boolean get() = validate().isEmpty()
}
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE, RECURRENCE_WITHOUT_DATE }
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DATE, RECURRENCE_WITHOUT_DATE }
/**
* Optional edit-form sections that can be shown or tucked behind "More fields"
@@ -0,0 +1,85 @@
package de.jeanlucmakiola.agendula.domain
import de.jeanlucmakiola.floret.reminders.ReminderLead
import kotlin.time.Instant
const val DAY_MINUTES = 24 * 60
/**
* The earliest lead an all-day reminder can have: 00:01 on the day. On an
* all-day task a lead that is not whole days counts back from the date's
* midnight, so `-540` is 09:00 on the day — see [TaskReminder.allDay].
*/
const val MIN_ALL_DAY_LEAD = -(DAY_MINUTES - 1)
/** A stored reminder: how long before the start ([fromStart]) or the due date. */
data class TaskReminder(
val minutesBefore: Int,
val fromStart: Boolean = false,
/**
* When another client last dismissed this alarm (RFC 9074 `ACKNOWLEDGED`):
* a trigger at or before it has been dealt with and is not fired here.
* Never part of what a reminder *is* — see [lead].
*/
val acknowledgedAt: Instant? = null,
) {
/** The reminder without its bookkeeping, for comparing two of them. */
val lead: TaskReminder get() = if (acknowledgedAt == null) this else copy(acknowledgedAt = null)
/** This reminder read as an all-day one. */
val allDay: AllDayReminder get() = AllDayReminder.of(minutesBefore)
/** This reminder's lead rounded to the nearest whole day, as the earlier all-day leads were. */
fun inWholeDays(): TaskReminder =
copy(minutesBefore = (minutesBefore.coerceAtLeast(0) + DAY_MINUTES / 2) / DAY_MINUTES * DAY_MINUTES)
companion object {
/**
* The all-day reminder [daysBefore] the date at [minuteOfDay], or at the
* device's all-day time when that is null. Midnight would read back as
* the latter, so it is 00:01 — see [allDayMinuteOfDay].
*/
fun allDay(daysBefore: Int, minuteOfDay: Int?, fromStart: Boolean = false) =
TaskReminder(daysBefore * DAY_MINUTES - (minuteOfDay?.let(::allDayMinuteOfDay) ?: 0), fromStart)
/** The time an all-day reminder can name: any but midnight, which is a whole-day lead. */
fun allDayMinuteOfDay(minuteOfDay: Int): Int = minuteOfDay.coerceAtLeast(1)
}
}
/** An all-day reminder as the day it rings on and, when it names one, its time. */
data class AllDayReminder(val daysBefore: Int, val minuteOfDay: Int?) {
companion object {
/** [minutesBefore] on an all-day task; anything after the day is clamped to its last minute. */
fun of(minutesBefore: Int): AllDayReminder {
val minutes = minutesBefore.coerceAtLeast(MIN_ALL_DAY_LEAD)
if (minutes % DAY_MINUTES == 0) return AllDayReminder(minutes / DAY_MINUTES, minuteOfDay = null)
val days = Math.floorDiv(minutes, DAY_MINUTES) + 1
return AllDayReminder(days, minuteOfDay = days * DAY_MINUTES - minutes)
}
}
}
/** A canonical order, for comparing two sets of reminders. */
val TaskReminderOrder: Comparator<TaskReminder> =
compareBy<TaskReminder>({ !it.fromStart }, { -it.minutesBefore })
/**
* The order the editor and the detail screen list reminders in: start's first,
* then earliest first. An all-day reminder without a time rings at [allDayMinuteOfDay].
*/
fun taskReminderDisplayOrder(allDay: Boolean, allDayMinuteOfDay: Int): Comparator<TaskReminder> =
compareBy<TaskReminder>({ !it.fromStart }, { reminder ->
if (!allDay) {
-reminder.minutesBefore
} else {
val (days, minuteOfDay) = reminder.allDay
(minuteOfDay ?: allDayMinuteOfDay) - days * DAY_MINUTES
}
})
/** A default reminder's lead as a reminder of the task's own. */
fun ReminderLead.asTaskReminder(): TaskReminder = TaskReminder(minutes, fromStart)
/** This reminder's lead, as default reminders are kept. */
fun TaskReminder.asReminderLead(): ReminderLead = ReminderLead(minutesBefore, fromStart)
@@ -5,6 +5,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.DAY_MINUTES
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.floret.reminders.ReminderUnit
/** Common reminder lead times offered as quick picks in the reminder pickers. */
@@ -33,9 +35,57 @@ fun reminderLeadTimeLabel(minutes: Int, allDay: Boolean = false): String = when
minutes <= 0 -> stringResource(R.string.reminder_at_due)
minutes % 10_080 == 0 ->
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
minutes % 1_440 == 0 ->
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
minutes % DAY_MINUTES == 0 ->
pluralStringResource(R.plurals.reminder_days, minutes / DAY_MINUTES, minutes / DAY_MINUTES)
minutes % 60 == 0 ->
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
}
/**
* [reminderLeadTimeLabel] naming the date the lead counts from, for a task
* that has both: "15 minutes before the start", "On the due date".
*/
@Composable
fun anchoredLeadTimeLabel(minutes: Int, allDay: Boolean, fromStart: Boolean): String {
@Composable
fun plural(start: Int, due: Int, count: Int) =
pluralStringResource(if (fromStart) start else due, count, count)
return when {
minutes <= 0 && allDay ->
stringResource(if (fromStart) R.string.reminder_on_start_date else R.string.reminder_on_due_date)
minutes <= 0 -> stringResource(if (fromStart) R.string.reminder_at_start else R.string.reminder_at_due_time)
minutes % 10_080 == 0 ->
plural(R.plurals.reminder_weeks_before_start, R.plurals.reminder_weeks_before_due, minutes / 10_080)
minutes % DAY_MINUTES == 0 ->
plural(R.plurals.reminder_days_before_start, R.plurals.reminder_days_before_due, minutes / DAY_MINUTES)
minutes % 60 == 0 ->
plural(R.plurals.reminder_hours_before_start, R.plurals.reminder_hours_before_due, minutes / 60)
else -> plural(R.plurals.reminder_minutes_before_start, R.plurals.reminder_minutes_before_due, minutes)
}
}
/** A lead's label, naming its date when [anchored]. */
@Composable
fun reminderLabel(minutes: Int, allDay: Boolean, fromStart: Boolean, anchored: Boolean): String =
if (anchored) anchoredLeadTimeLabel(minutes, allDay, fromStart) else reminderLeadTimeLabel(minutes, allDay)
/**
* One of a task's own reminders in one line, naming its date when [anchored].
* An all-day one names its day and the time it rings at — its own, or
* [allDayMinuteOfDay] when it has none.
*/
@Composable
fun taskReminderLabel(reminder: TaskReminder, allDay: Boolean, allDayMinuteOfDay: Int, anchored: Boolean): String {
if (!allDay) return reminderLabel(reminder.minutesBefore, false, reminder.fromStart, anchored)
val (days, minuteOfDay) = reminder.allDay
return stringResource(
R.string.reminder_at_time,
reminderLabel(days * DAY_MINUTES, true, reminder.fromStart, anchored),
formatMinuteOfDay(minuteOfDay ?: allDayMinuteOfDay, LocalUse24HourFormat.current),
)
}
@StringRes
fun reminderAnchorLabel(fromStart: Boolean): Int =
if (fromStart) R.string.reminder_anchor_start else R.string.reminder_anchor_due
@@ -1,12 +1,13 @@
package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.components.CustomAmountEditor
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
import de.jeanlucmakiola.floret.reminders.ReminderLead
import de.jeanlucmakiola.floret.reminders.reminderOverrideForLeads
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -16,27 +17,19 @@ import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes
import androidx.compose.animation.AnimatedVisibility
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -46,8 +39,10 @@ import de.jeanlucmakiola.agendula.R
* Reminder lead-time picker, full-screen: the grouped list of preset lead times
* plus a "Custom" row that expands an inline amount field + unit selector.
* [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
* a "None" row; [allDay] switches to day-scale presets and wording. Returns the
* choice as a [ReminderOverride]. Ported from Calendula's ReminderDefaultPicker.
* a "None" row; [allDay] switches to day-scale presets and wording, and its
* custom amounts to days and weeks. [header] sits above the rows, for whatever
* else the caller's reminder carries. Returns the choice as a
* [ReminderOverride]. Ported from Calendula's ReminderDefaultPicker.
*/
@Composable
fun ReminderLeadPicker(
@@ -59,6 +54,7 @@ fun ReminderLeadPicker(
onDismiss: () -> Unit,
allDay: Boolean = false,
presets: List<Int> = if (allDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
header: (@Composable ColumnScope.() -> Unit)? = null,
) {
// Agendula is single-reminder: an override carries a one-element list, so
// take the single value for this single-select picker.
@@ -68,7 +64,7 @@ fun ReminderLeadPicker(
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
var unit by rememberSaveable { mutableStateOf(seed.unit) }
var unit by rememberSaveable { mutableStateOf(seed.unit.takeIf { it in reminderUnits(allDay) } ?: reminderUnits(allDay).first()) }
val options = buildList {
if (allowInherit) add(ReminderOverride.Inherit)
@@ -78,6 +74,7 @@ fun ReminderLeadPicker(
val rowCount = options.size + 1 // + the custom row
FullScreenPicker(title = title, onDismiss = onDismiss) {
header?.invoke(this)
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
@@ -112,6 +109,8 @@ fun ReminderLeadPicker(
onAmountChange = { amountText = it },
unit = unit,
onUnitChange = { unit = it },
units = reminderUnits(allDay),
allDay = allDay,
onConfirm = { minutes ->
onSelect(ReminderOverride.Minutes(listOf(minutes)))
onDismiss()
@@ -129,7 +128,9 @@ fun ReminderLeadPicker(
* Clearing the last time falls back to "Use default" on a per-list picker and to
* "No reminder" on the global one. Changes apply live; the user leaves via back.
* [allDay] switches to day-scale presets, and [leadTimeSummary] adds a second
* line to each lead-time row. Ported from Calendula's ReminderDefaultPicker.
* line to each lead-time row. A start / due toggle picks which date the rows
* count from, and the choice carries it ([ReminderOverride.Minutes.fromStart]).
* Ported from Calendula's ReminderDefaultPicker.
*/
@Composable
fun ReminderDefaultPicker(
@@ -149,24 +150,27 @@ fun ReminderDefaultPicker(
LaunchedEffect(selected) { if (!userEdited) current = selected }
val inherits = current is ReminderOverride.Inherit
val isNone = current is ReminderOverride.None
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
val selectedLeads = (current as? ReminderOverride.Minutes)?.leads.orEmpty()
var fromStart by rememberSaveable { mutableStateOf(true) }
val selectedMinutes = selectedLeads.filter { it.fromStart == fromStart }.map { it.minutes }
// Custom values seen this session keep their row once unchecked.
val seenCustom = remember { mutableSetOf<Int>() }
seenCustom += selectedMinutes.filter { it !in presets }
val rows = presets + seenCustom.sorted()
val seenCustom = remember { mutableSetOf<ReminderLead>() }
seenCustom += selectedLeads.filter { it.minutes !in presets }
val rows = presets + seenCustom.filter { it.fromStart == fromStart }.map { it.minutes }.sorted()
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf("") }
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
var unit by rememberSaveable { mutableStateOf(reminderUnits(allDay).first()) }
fun apply(override: ReminderOverride) {
userEdited = true
current = override
onSelect(override)
}
fun emit(minutes: List<Int>) = apply(reminderOverrideForMinutes(minutes, allowInherit))
fun emit(leads: List<ReminderLead>) = apply(reminderOverrideForLeads(leads, allowInherit))
fun lead(minute: Int) = ReminderLead(minute, fromStart)
fun toggle(minute: Int) =
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
emit(if (minute in selectedMinutes) selectedLeads - lead(minute) else selectedLeads + lead(minute))
FullScreenPicker(title = title, onDismiss = onDismiss) {
if (allowInherit) {
@@ -186,6 +190,8 @@ fun ReminderDefaultPicker(
onClick = { apply(ReminderOverride.None) },
)
Spacer(Modifier.height(24.dp))
ReminderAnchorToggle(fromStart = fromStart, onChange = { fromStart = it })
Spacer(Modifier.height(16.dp))
val rowCount = rows.size + 1 // + the custom row
rows.forEachIndexed { index, minute ->
val checked = minute in selectedMinutes
@@ -213,8 +219,10 @@ fun ReminderDefaultPicker(
onAmountChange = { amountText = it },
unit = unit,
onUnitChange = { unit = it },
units = reminderUnits(allDay),
allDay = allDay,
onConfirm = { minutes ->
emit(selectedMinutes + minutes)
emit(selectedLeads + lead(minutes))
amountText = ""
customExpanded = false
},
@@ -223,57 +231,54 @@ fun ReminderDefaultPicker(
}
}
/** Which date a reminder counts back from. */
@Composable
fun ReminderAnchorToggle(fromStart: Boolean, onChange: (Boolean) -> Unit) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
listOf(true, false).forEachIndexed { index, start ->
SegmentedButton(
selected = fromStart == start,
onClick = { onChange(start) },
shape = SegmentedButtonDefaults.itemShape(index, 2),
label = { Text(stringResource(reminderAnchorLabel(start))) },
)
}
}
}
@Composable
private fun CustomReminderEditor(
amountText: String,
onAmountChange: (String) -> Unit,
unit: ReminderUnit,
onUnitChange: (ReminderUnit) -> Unit,
units: List<ReminderUnit>,
allDay: Boolean,
onConfirm: (Int) -> Unit,
) {
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ReminderUnit.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = unit == entry,
onClick = { onUnitChange(entry) },
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
label = { Text(stringResource(reminderUnitLabel(entry))) },
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(value = amountText, onValueChange = onAmountChange, placeholder = "10")
Spacer(Modifier.width(16.dp))
Text(
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
?: stringResource(R.string.reminder_custom_amount),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
FilledTonalButton(
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
enabled = amount != null,
) {
Text(stringResource(R.string.reminder_custom_set))
}
}
}
}
CustomAmountEditor(
amountText = amountText,
onAmountChange = onAmountChange,
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor, allDay) }
?: stringResource(R.string.reminder_custom_amount),
setLabel = stringResource(R.string.reminder_custom_set),
confirmEnabled = amount != null,
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
selectedUnit = units.indexOf(unit),
onUnitChange = { onUnitChange(units[it]) },
)
}
/**
* The units a custom lead is entered in. An all-day reminder's time of day is
* set on its own, so its lead is whole days: an hour count would read back as a
* time of day.
*/
private fun reminderUnits(allDay: Boolean): List<ReminderUnit> =
if (allDay) listOf(ReminderUnit.Days, ReminderUnit.Weeks) else ReminderUnit.entries
@Composable
private fun reminderOverrideLabel(override: ReminderOverride, allDay: Boolean): String = when (override) {
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
@@ -58,7 +58,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.pluralStringResource
import androidx.core.net.toUri
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
@@ -66,7 +66,7 @@ import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog
import de.jeanlucmakiola.agendula.ui.common.TagChip
import de.jeanlucmakiola.agendula.ui.common.linkified
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.agendula.ui.common.taskReminderLabel
import de.jeanlucmakiola.floret.locale.currentLocale
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -198,6 +198,7 @@ fun TaskDetailScreen(
detail = s.detail,
inner = inner,
reminders = s.reminders,
allDayMinuteOfDay = s.allDayMinuteOfDay,
onToggle = { viewModel.toggleComplete(s.detail.task) },
onToggleSubtask = { viewModel.toggleComplete(it) },
onAddSubtask = { viewModel.addSubtask(it) },
@@ -251,6 +252,7 @@ private fun DetailBody(
detail: TaskDetail,
inner: androidx.compose.foundation.layout.PaddingValues,
reminders: List<TaskReminder>,
allDayMinuteOfDay: Int,
onToggle: () -> Unit,
onToggleSubtask: (Task) -> Unit,
onAddSubtask: (String) -> Unit,
@@ -352,8 +354,12 @@ private fun DetailBody(
DetailCard(icon = Icons.Rounded.Notifications, iconContentDescription = stringResource(R.string.edit_reminder_label)) {
reminders.forEach { reminder ->
Text(
if (reminder.fromStart) stringResource(R.string.detail_reminder_before_start, reminderLeadTimeLabel(reminder.minutesBefore))
else reminderLeadTimeLabel(reminder.minutesBefore, task.isAllDay),
taskReminderLabel(
reminder,
task.isAllDay,
allDayMinuteOfDay,
anchored = task.start != null && task.due != null,
),
style = MaterialTheme.typography.titleMedium,
)
}
@@ -18,6 +18,11 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.domain.taskReminderDisplayOrder
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
@@ -30,7 +35,9 @@ sealed interface TaskDetailUiState {
data class Content(
val detail: TaskDetail,
/** The task's own reminders; empty when the list's or app default applies. */
val reminders: List<de.jeanlucmakiola.agendula.data.tasks.TaskReminder> = emptyList(),
val reminders: List<de.jeanlucmakiola.agendula.domain.TaskReminder> = emptyList(),
/** The time an all-day reminder without one of its own rings at. */
val allDayMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
) : TaskDetailUiState
}
@@ -38,6 +45,7 @@ sealed interface TaskDetailUiState {
@HiltViewModel
class TaskDetailViewModel @Inject constructor(
private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
@ApplicationScope private val appScope: CoroutineScope,
) : ViewModel() {
@@ -46,14 +54,18 @@ class TaskDetailViewModel @Inject constructor(
val state: StateFlow<TaskDetailUiState> =
target.filterNotNull()
.flatMapLatest { (id, occurrence) ->
val allDayMinute = settingsPrefs.settings.map { it.allDayReminderMinuteOfDay }.distinctUntilChanged()
repository.taskDetail(id, occurrence)
.map<TaskDetail?, TaskDetailUiState> { detail ->
if (detail == null) {
.map { detail -> detail?.let { it to repository.remindersFor(it.task.taskId) } }
.combine(allDayMinute) { loaded, minute ->
if (loaded == null) {
TaskDetailUiState.NotFound
} else {
val (detail, reminders) = loaded
TaskDetailUiState.Content(
detail = detail,
reminders = repository.remindersFor(detail.task.taskId),
reminders = reminders.sortedWith(taskReminderDisplayOrder(detail.task.isAllDay, minute)),
allDayMinuteOfDay = minute,
)
}
}
@@ -26,11 +26,14 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog
import de.jeanlucmakiola.agendula.ui.common.ReminderAnchorToggle
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
import de.jeanlucmakiola.agendula.ui.common.icon
import de.jeanlucmakiola.agendula.ui.common.labelRes
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.agendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.agendula.ui.common.taskReminderLabel
import de.jeanlucmakiola.floret.components.revealOnFocus
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
@@ -48,6 +51,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -123,6 +127,8 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.DAY_MINUTES
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.floret.components.GroupedRow
@@ -263,6 +269,8 @@ private fun EditContent(
// the title takes the target list's colour.
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
val gap = 12.dp
// A reminder's anchor only says something when the task has both to choose from.
val bothDates = state.start != null && state.due != null
var pickerTarget by rememberSaveable { mutableStateOf<PickerTarget?>(null) }
var showListPicker by rememberSaveable { mutableStateOf(false) }
@@ -559,23 +567,25 @@ private fun EditContent(
Spacer(Modifier.height(gap))
// One card per reminder, connected, the last row adding another.
val rows = state.reminders.size + 1
state.reminders.forEachIndexed { index, minutes ->
state.reminders.forEachIndexed { index, reminder ->
ReminderItemCard(
position = positionOf(index, rows),
title = reminderLeadTimeLabel(minutes, state.isAllDay),
onRemove = { viewModel.removeReminder(minutes) },
title = taskReminderLabel(reminder, state.isAllDay, state.allDayMinuteOfDay, anchored = bothDates),
onRemove = { viewModel.removeReminder(reminder) },
)
}
AddReminderCard(
position = positionOf(state.reminders.size, rows),
label = stringResource(
if (state.reminders.isEmpty()) R.string.edit_reminder_add_first else R.string.edit_reminder_add,
// Only a set the user has not chosen still falls back to the default.
if (state.reminders.isEmpty() && !state.remindersChosen) R.string.edit_reminder_add_first
else R.string.edit_reminder_add,
),
onClick = { showReminderPicker = true },
)
if (TaskFormError.REMINDER_WITHOUT_DUE in state.errors) {
if (TaskFormError.REMINDER_WITHOUT_DATE in state.errors) {
Spacer(Modifier.height(4.dp))
FieldError(R.string.error_reminder_without_due)
FieldError(R.string.error_reminder_without_date)
}
}
@@ -688,18 +698,73 @@ private fun EditContent(
}
if (showReminderPicker) {
var fromStart by rememberSaveable { mutableStateOf(state.start != null) }
var minuteOfDay by rememberSaveable { mutableIntStateOf(state.allDayMinuteOfDay) }
var pickingTime by rememberSaveable { mutableStateOf(false) }
ReminderLeadPicker(
title = stringResource(R.string.edit_reminder_add),
selected = ReminderOverride.Inherit,
allowInherit = false,
allowNone = false,
allDay = state.isAllDay,
header = if (bothDates || state.isAllDay) {
{
if (bothDates) {
ReminderAnchorToggle(fromStart = fromStart, onChange = { fromStart = it })
Spacer(Modifier.height(16.dp))
}
if (state.isAllDay) {
GroupedRow(
title = stringResource(R.string.reminder_time),
position = Position.Alone,
trailing = {
Text(
formatMinuteOfDay(minuteOfDay, LocalUse24HourFormat.current),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
},
onClick = { pickingTime = true },
)
Spacer(Modifier.height(24.dp))
}
}
} else {
null
},
onSelect = { choice ->
(choice as? ReminderOverride.Minutes)?.minutes?.firstOrNull()?.let(viewModel::addReminder)
(choice as? ReminderOverride.Minutes)?.minutes?.firstOrNull()?.let { minutes ->
// A task with one date counts from that one, whatever the toggle said.
val anchorStart = if (bothDates) fromStart else state.due == null
viewModel.addReminder(
if (state.isAllDay) {
// The default time is left unnamed, so the reminder follows the setting.
TaskReminder.allDay(
daysBefore = minutes / DAY_MINUTES,
minuteOfDay = minuteOfDay.takeIf { it != state.allDayMinuteOfDay },
fromStart = anchorStart,
)
} else {
TaskReminder(minutes, fromStart = anchorStart)
},
)
}
showReminderPicker = false
},
onDismiss = { showReminderPicker = false },
)
if (pickingTime) {
TimePickerAlert(
initial = LocalTime.of(minuteOfDay / 60, minuteOfDay % 60),
onConfirm = {
val picked = it.hour * 60 + it.minute
// The default time stays unnamed; any other is one an all-day reminder can name.
minuteOfDay = if (picked == state.allDayMinuteOfDay) picked else TaskReminder.allDayMinuteOfDay(picked)
pickingTime = false
},
onDismiss = { pickingTime = false },
)
}
}
if (showRecurrencePicker) {
@@ -819,6 +884,7 @@ private fun InlineField(
* affordance appears once a value is set. Tappable values read as links
* (primary); an error flips them to the error colour.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ScheduleRow(
label: String,
@@ -829,43 +895,76 @@ private fun ScheduleRow(
isError: Boolean = false,
) {
val valueColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (value == null) {
val valueModifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp)
LabelledValueLayout(
label = {
Text(
text = stringResource(R.string.edit_set),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 8.dp),
)
} else {
Text(
text = value.formatDate(allDay),
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
)
if (!allDay) {
Text(
text = value.formatTime(LocalUse24HourFormat.current),
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
)
}
IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Rounded.Clear,
contentDescription = stringResource(R.string.edit_clear),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
value = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (value == null) {
Text(
text = stringResource(R.string.edit_set),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = valueModifier,
)
} else {
FlowRow(
horizontalArrangement = Arrangement.End,
itemVerticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f, fill = false),
) {
Text(
text = value.formatDate(allDay),
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = valueModifier,
)
if (!allDay) {
Text(
text = value.formatTime(LocalUse24HourFormat.current),
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = valueModifier,
)
}
}
IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Rounded.Clear,
contentDescription = stringResource(R.string.edit_clear),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
},
)
}
/**
* [label] at the start and [value] at the end of one line when both fit, else
* [value] on its own line below, still end-aligned — so large font scales wrap
* rather than clip either side (#76).
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun LabelledValueLayout(label: @Composable () -> Unit, value: @Composable () -> Unit) {
FlowRow(
itemVerticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
label()
// Its whole width as its minimum, so the value wraps as one piece.
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Box(Modifier.width(IntrinsicSize.Max)) { value() }
}
}
}
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
@@ -16,6 +18,10 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.taskReminderDisplayOrder
import de.jeanlucmakiola.agendula.data.tasks.StoredReminders
import java.time.ZoneId
import de.jeanlucmakiola.agendula.domain.populatedFields
import de.jeanlucmakiola.agendula.domain.htmlToPlainText
import de.jeanlucmakiola.agendula.domain.looksLikeHtml
@@ -54,7 +60,13 @@ data class TaskEditUiState(
val priority: Priority = Priority.NONE,
val parentId: Long? = null,
val percentComplete: Int? = null,
val reminders: List<Int> = emptyList(),
val reminders: List<TaskReminder> = emptyList(),
/** Whether the user added or removed a reminder; until then the defaults follow the form. */
val remindersTouched: Boolean = false,
/** Whether [reminders] are stored as they are — see [TaskForm.remindersChosen]. */
val remindersChosen: Boolean = false,
/** The time an all-day reminder without one of its own rings at. */
val allDayMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
val location: String = "",
val url: String = "",
val tags: List<String> = emptyList(),
@@ -124,6 +136,20 @@ class TaskEditViewModel @Inject constructor(
/** Sections shown by default (a setting); the rest unfold via [revealField]. */
private var defaultFields: Set<TaskFormField> = emptySet()
/** The settings as the form was bound, for the defaults an all-day switch swaps in. */
private var settings: Settings = Settings()
/** The reminders before the last all-day switch, and what it made of them. */
private var lastSwitch: Pair<List<TaskReminder>, List<TaskReminder>>? = null
/** The reminders the task was loaded with, and the dates it had then. */
private var storedReminders: Set<TaskReminder> = emptySet()
/** [storedReminders] as the task's current kind, timed or all-day. */
private var loadedReminders: List<TaskReminder> = emptyList()
private var loadedStart: Instant? = null
private var loadedDue: Instant? = null
/** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(
presetListId: Long? = null,
@@ -138,6 +164,7 @@ class TaskEditViewModel @Inject constructor(
baselineLastModified = null
viewModelScope.launch {
val settings = settingsPrefs.settings.first()
this@TaskEditViewModel.settings = settings
defaultFields = settings.defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() }
@@ -157,6 +184,7 @@ class TaskEditViewModel @Inject constructor(
loading = false,
isNew = true,
autofocusTitle = settings.autofocusTitle,
allDayMinuteOfDay = settings.allDayReminderMinuteOfDay,
listId = defaultList,
// A preset due is all-day, so a default start has to be too, or
// today's start would land after a due of today.
@@ -174,7 +202,7 @@ class TaskEditViewModel @Inject constructor(
parentCandidates = loadParents(defaultList, selfId = null),
title = initialTitle.orEmpty(),
tags = (listOfNotNull(presetTag) + preset?.tags.orEmpty()).distinct(),
),
).withDefaultReminders(),
).also { baseline = it.toForm().copy(title = "") }
}
}
@@ -186,7 +214,9 @@ class TaskEditViewModel @Inject constructor(
editingTaskId = taskId
editingOccurrence = occurrenceStart
viewModelScope.launch {
defaultFields = settingsPrefs.settings.first().defaultEditFields
val settings = settingsPrefs.settings.first()
this@TaskEditViewModel.settings = settings
defaultFields = settings.defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val task = runCatching { repository.taskDetail(taskId, occurrenceStart).first()?.task }.getOrNull()
if (task == null) {
@@ -194,6 +224,10 @@ class TaskEditViewModel @Inject constructor(
return@launch
}
baselineLastModified = task.lastModified
loadedReminders = repository.remindersFor(taskId).map { it.lead }.distinct()
storedReminders = loadedReminders.toSet()
loadedStart = task.start
loadedDue = task.due
_state.value = withFields(
TaskEditUiState(
loading = false,
@@ -208,7 +242,9 @@ class TaskEditViewModel @Inject constructor(
priority = task.priority,
parentId = task.parentId,
percentComplete = task.percentComplete,
reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore },
reminders = loadedReminders
.sortedWith(taskReminderDisplayOrder(task.isAllDay, settings.allDayReminderMinuteOfDay)),
allDayMinuteOfDay = settings.allDayReminderMinuteOfDay,
location = task.location.orEmpty(),
url = task.url.orEmpty(),
tags = task.tags,
@@ -271,35 +307,90 @@ class TaskEditViewModel @Inject constructor(
/** Switching list invalidates the parent (it lived in the old list); reload candidates. */
fun onListChange(listId: Long) {
if (_state.value.listId == listId) return // re-picking the same list keeps the parent
update { it.copy(listId = listId, parentId = null, errors = emptySet()) }
update { it.copy(listId = listId, parentId = null, errors = emptySet()).withDefaultReminders() }
viewModelScope.launch {
val candidates = loadParents(listId, selfId = editingTaskId)
update { it.copy(parentCandidates = candidates) }
}
}
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
fun onStartChange(value: Instant?) = update { it.copy(start = value).withDefaultReminders() }
fun onDueChange(value: Instant?) = update { it.copy(due = value).withDefaultReminders() }
/**
* All-day and timed values use different conventions (UTC midnight vs. a real
* instant in the device zone), so the switch has to move the timestamps too —
* flipping the flag alone makes an all-day task read back as "02:00", which
* looks to the user like the time reset itself.
* looks to the user like the time reset itself. The reminders are made
* the new kind's here, so the editor shows what will be stored; switching
* straight back gives the old ones back.
*/
fun onAllDayChange(value: Boolean) = update {
it.copy(
if (it.isAllDay == value) return@update it
val switched = it.copy(
isAllDay = value,
start = it.start?.rebasedForAllDay(value),
due = it.due?.rebasedForAllDay(value),
)
if (!it.remindersTouched) {
loadedReminders = carried(loadedReminders, switched)
return@update switched.withDefaultReminders()
}
switched.copy(reminders = carried(it.reminders, switched).sortedForDisplay(switched))
}
/** [reminders] as [switched]'s kind; straight back after a switch, the ones before it. */
private fun carried(reminders: List<TaskReminder>, switched: TaskEditUiState): List<TaskReminder> {
val previous = lastSwitch
val carried = if (previous != null && previous.second == reminders) {
previous.first
} else {
StoredReminders.switched(reminders, switched.toForm(), settings, nowMillis(), ZoneId.systemDefault())
}
lastSwitch = reminders to carried
return carried
}
/**
* The reminders the task will be saved with while the user has not
* touched them: those it was loaded with, plus the defaults of the dates
* it has gained — all of them, for a new task. Shown so they are noted
* before saving rather than added unseen.
*/
private fun TaskEditUiState.withDefaultReminders(): TaskEditUiState {
if (remindersTouched) return this
val reminders = StoredReminders.withDefaults(
toForm().copy(reminders = loadedReminders),
loadedStart,
loadedDue,
settings,
nowMillis(),
ZoneId.systemDefault(),
).sortedForDisplay(this)
val visible = if (reminders.isEmpty()) visibleFields else visibleFields + TaskFormField.Reminder
return copy(
reminders = reminders,
// A new task's, or defaults added to the ones it had: stored as shown.
remindersChosen = isNew || reminders.toSet() != storedReminders,
visibleFields = visible,
hiddenFields = hiddenOf(visible),
)
}
private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds()
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
fun addReminder(minutesBeforeDue: Int) = update {
it.copy(reminders = (it.reminders + minutesBeforeDue).distinct().sorted(), errors = emptySet())
fun addReminder(reminder: TaskReminder) = update {
it.copy(
reminders = (it.reminders + reminder).distinct().sortedForDisplay(it),
remindersTouched = true,
remindersChosen = true,
errors = emptySet(),
)
}
fun removeReminder(reminder: TaskReminder) = update {
it.copy(reminders = it.reminders - reminder, remindersTouched = true, remindersChosen = true)
}
fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) }
fun onLocationChange(value: String) = update { it.copy(location = value) }
fun onUrlChange(value: String) = update { it.copy(url = value) }
fun addTag(name: String) = update { it.copy(tags = normalizeTags(it.tags + name), tagDraft = "") }
@@ -382,6 +473,10 @@ class TaskEditViewModel @Inject constructor(
}
}
/** The order the editor lists [state]'s reminders in. */
private fun List<TaskReminder>.sortedForDisplay(state: TaskEditUiState): List<TaskReminder> =
sortedWith(taskReminderDisplayOrder(state.isAllDay, state.allDayMinuteOfDay))
/** Snapshot the editable state as a validated [TaskForm]. */
private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
title = title,
@@ -394,6 +489,7 @@ private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
parentId = parentId,
percentComplete = percentComplete,
reminders = reminders,
remindersChosen = remindersChosen,
location = location.ifBlank { null },
url = url.ifBlank { null },
tags = withDraft(tagDraft),
@@ -70,6 +70,7 @@ private const val EXTERNAL_STORAGE = "com.android.externalstorage.documents"
@Composable
fun ExportScreen(
onBack: () -> Unit,
visible: Boolean,
modifier: Modifier = Modifier,
viewModel: ExportViewModel = hiltViewModel(),
) {
@@ -89,6 +90,8 @@ fun ExportScreen(
title = stringResource(R.string.settings_export),
onBack = onBack,
modifier = modifier,
predictiveBack = true,
visible = visible,
) {
Text(
text = stringResource(R.string.export_hint),
@@ -76,6 +76,8 @@ private val PICK_TYPES = arrayOf("text/calendar", "text/x-vcalendar", "applicati
fun ImportScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
predictiveBack: Boolean = false,
visible: Boolean? = null,
initialUri: Uri? = null,
onOpenStorage: (() -> Unit)? = null,
viewModel: ImportViewModel = hiltViewModel(),
@@ -105,6 +107,8 @@ fun ImportScreen(
title = stringResource(R.string.import_title),
onBack = onBack,
modifier = modifier,
predictiveBack = predictiveBack,
visible = visible,
) {
Text(
text = stringResource(R.string.import_hint),
@@ -25,12 +25,14 @@ import de.jeanlucmakiola.floret.components.positionOf
* source, which is the specific thing §3.2(a) requires us to point at.
*/
@Composable
internal fun LicencesScreen(onBack: () -> Unit) {
internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean) {
val uriHandler = LocalUriHandler.current
CollapsingScaffold(
title = stringResource(R.string.licences_title),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
Text(
stringResource(R.string.licences_intro),
@@ -3,12 +3,14 @@ package de.jeanlucmakiola.agendula.ui.navigation
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import de.jeanlucmakiola.agendula.domain.Task
import kotlin.time.Instant
@@ -27,6 +29,7 @@ import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListScreen
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
import de.jeanlucmakiola.floret.identity.predictiveBackExit
/**
* The single in-app back stack: lists → task list → detail / edit.
@@ -37,6 +40,7 @@ import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
* `SavedStateHandle`). Screens receive plain navigation lambdas so they stay
* unaware of the nav library.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AgendulaNavHost(
modifier: Modifier = Modifier,
@@ -70,19 +74,19 @@ fun AgendulaNavHost(
onNavRequestConsumed()
}
val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec<IntOffset>()
val overlayExit = predictiveBackExit()
NavHost(
navController = nav,
startDestination = Dest.LISTS,
modifier = modifier,
// Only the screen on top moves; the background is always held static.
// Forward is a plain fade-in over the held screen — calm, not a slide.
// Back is the predictive-back peek: navigation-compose composes the
// screen being revealed underneath and seeks popExit with the gesture, so
// the leaving screen scales down to reveal the (unmoved) previous screen.
enterTransition = { fadeIn() },
// Calendula's overlay motion. NavHost's own back handler seeks the exit
// over the previous screen, so destinations don't add a preview of their own.
enterTransition = { slideInHorizontally(slideSpec) { it } + fadeIn() },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() },
popExitTransition = { overlayExit },
) {
composable(Dest.LISTS) {
ReadableWidth {
@@ -34,6 +34,7 @@ internal fun AppearanceScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean,
) {
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
var showTheme by remember { mutableStateOf(false) }
@@ -42,7 +43,12 @@ internal fun AppearanceScreen(
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
val locale = currentLocale()
CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
GroupedRow(
title = stringResource(R.string.settings_theme),
summary = stringResource(themeLabel(state.settings.themeMode)),
@@ -0,0 +1,37 @@
package de.jeanlucmakiola.agendula.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
/** Backup & restore: the way into export and import, as its own entry (after Calendula). */
@Composable
internal fun BackupScreen(
onOpenExport: () -> Unit,
onOpenImport: () -> Unit,
onBack: () -> Unit,
visible: Boolean,
) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_backup),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
GroupedRow(
title = stringResource(R.string.settings_export),
summary = stringResource(R.string.settings_export_hint),
position = Position.Top,
onClick = onOpenExport,
)
GroupedRow(
title = stringResource(R.string.import_title),
summary = stringResource(R.string.settings_import_hint),
position = Position.Bottom,
onClick = onOpenImport,
)
}
}
@@ -41,6 +41,8 @@ import androidx.core.net.toUri
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.SNOOZE_PRESETS
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
import de.jeanlucmakiola.agendula.domain.asTaskReminder
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.agendula.ui.common.canScheduleExactAlarms
@@ -48,7 +50,7 @@ import de.jeanlucmakiola.agendula.ui.common.openExactAlarmSettings
import de.jeanlucmakiola.agendula.ui.common.ReminderDefaultPicker
import de.jeanlucmakiola.agendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.agendula.ui.common.reminderLabel
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
@@ -57,16 +59,18 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderLead
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import java.time.LocalTime
/** Whether due tasks notify at all, how far ahead, and which lists differ. */
@Composable
internal fun RemindersScreen(
internal fun NotificationsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean,
) {
val context = LocalContext.current
var notificationsAllowed by remember { mutableStateOf(context.notificationsAllowed()) }
@@ -119,7 +123,12 @@ internal fun RemindersScreen(
)
val lists = state.lists.filter { it.id > 0L }
CollapsingScaffold(title = stringResource(R.string.settings_section_reminders), onBack = onBack) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_reminders),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
if (state.settings.remindersEnabled && !notificationsAllowed) {
GroupedRow(
title = stringResource(R.string.settings_notifications_blocked),
@@ -153,13 +162,13 @@ internal fun RemindersScreen(
}
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = reminderChoiceLabel(state.settings.defaultReminderMinutes),
summary = reminderChoiceLabel(state.settings.defaultReminders),
position = Position.Middle,
onClick = { showOffset = true },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = reminderChoiceLabel(state.settings.defaultAllDayReminderMinutes, allDay = true),
summary = reminderChoiceLabel(state.settings.defaultAllDayReminders, allDay = true),
position = Position.Middle,
onClick = { showAllDay = true },
)
@@ -245,7 +254,7 @@ internal fun RemindersScreen(
title = stringResource(R.string.settings_default_reminder),
summary = listOverrideSummary(
state.settings.perListReminderOverride.reminderOverrideFor(list.id),
state.settings.defaultReminderMinutes,
state.settings.defaultReminders,
allDay = false,
),
position = Position.Middle,
@@ -255,7 +264,7 @@ internal fun RemindersScreen(
title = stringResource(R.string.settings_default_reminder_allday),
summary = listOverrideSummary(
state.settings.perListAllDayReminderOverride.reminderOverrideFor(list.id),
state.settings.defaultAllDayReminderMinutes,
state.settings.defaultAllDayReminders,
allDay = true,
),
position = Position.Middle,
@@ -278,24 +287,24 @@ internal fun RemindersScreen(
if (showOffset) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder),
selected = state.settings.defaultReminderMinutes.let {
if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(it)
selected = state.settings.defaultReminders.let {
if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes.of(it)
},
allowInherit = false,
onSelect = { viewModel.setDefaultReminderMinutes((it as? ReminderOverride.Minutes)?.minutes.orEmpty()) },
onSelect = { viewModel.setDefaultReminders((it as? ReminderOverride.Minutes)?.leads.orEmpty()) },
onDismiss = { showOffset = false },
)
}
if (showAllDay) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder_allday),
selected = state.settings.defaultAllDayReminderMinutes.let {
if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(it)
selected = state.settings.defaultAllDayReminders.let {
if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes.of(it)
},
allowInherit = false,
allDay = true,
leadTimeSummary = allDayFiresAt,
onSelect = { viewModel.setDefaultAllDayReminderMinutes((it as? ReminderOverride.Minutes)?.minutes.orEmpty()) },
onSelect = { viewModel.setDefaultAllDayReminders((it as? ReminderOverride.Minutes)?.leads.orEmpty()) },
onDismiss = { showAllDay = false },
)
}
@@ -350,19 +359,27 @@ private data class OverrideTarget(val listId: Long, val allDay: Boolean)
/** Row summary for a list: its override, or the inherited global default. */
@Composable
private fun listOverrideSummary(choice: ReminderOverride, globalDefault: List<Int>, allDay: Boolean): String =
private fun listOverrideSummary(choice: ReminderOverride, globalDefault: List<ReminderLead>, allDay: Boolean): String =
when (choice) {
ReminderOverride.Inherit ->
stringResource(R.string.settings_list_reminder_inherits, reminderChoiceLabel(globalDefault, allDay))
ReminderOverride.None -> stringResource(R.string.reminder_none)
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes, allDay)
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.leads, allDay)
}
/** "30 minutes before, At due time", or "No reminder" for an empty set. */
/**
* "30 minutes before, At due time", or "No reminder" for an empty set. Each
* names its date once any counts from the start.
*/
@Composable
private fun reminderChoiceLabel(minutes: List<Int>, allDay: Boolean = false): String =
if (minutes.isEmpty()) stringResource(R.string.reminder_none)
else minutes.map { reminderLeadTimeLabel(it, allDay) }.joinToString(", ")
private fun reminderChoiceLabel(leads: List<ReminderLead>, allDay: Boolean = false): String {
if (leads.isEmpty()) return stringResource(R.string.reminder_none)
val reminders = leads.map { it.asTaskReminder() }.sortedWith(TaskReminderOrder)
val anchored = reminders.any { it.fromStart }
return reminders.map { reminder ->
reminderLabel(reminder.minutesBefore, allDay, reminder.fromStart, anchored)
}.joinToString(", ")
}
/** SCHEDULE_EXACT_ALARM is user-granted from API 31; below that exact alarms need no grant. */
private fun showExactAlarmRow(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
@@ -1,11 +1,8 @@
package de.jeanlucmakiola.agendula.ui.settings
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
@@ -13,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Favorite
@@ -25,6 +23,7 @@ import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -37,6 +36,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -50,20 +50,22 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.LanguagePickerRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.openIssueTracker
import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.floret.identity.predictiveBackExit
/** The settings sub-screens reached from the hub's category rows. */
internal enum class SettingsSection {
Appearance,
TaskForm,
Reminders,
Notifications,
Storage,
Backup,
Export,
Import,
Accounts,
@@ -72,10 +74,10 @@ internal enum class SettingsSection {
Licences,
;
/** Where back goes: Export is opened from Storage, the account screens from Accounts. */
/** The section that stays composed underneath: Backup under export and import, Accounts under its screens. */
val parent: SettingsSection?
get() = when (this) {
Export, Import -> Storage
Export, Import -> Backup
AddAccount, Account -> Accounts
else -> null
}
@@ -88,8 +90,9 @@ internal enum class SettingsSection {
* A full-screen destination; [onBack] pops it. [initialSection] names a
* sub-screen to open on (see [de.jeanlucmakiola.agendula.ui.navigation.Dest.Settings]).
*
* Every sub-screen is its own file. This one owns the hub, the section enum and
* the back rules between them, and nothing else.
* Every sub-screen is its own file and handles its own back through the
* predictive-back preview. This one owns the hub, the section enum and the
* routing between them, and nothing else.
*/
@Composable
fun SettingsScreen(
@@ -106,10 +109,8 @@ fun SettingsScreen(
else SettingsSection.entries.firstOrNull { it.name == initialSection },
)
}
// Inside a sub-screen, system back (button or gesture) returns to the hub
// rather than popping the whole Settings destination to the lists overview.
BackHandler(enabled = section != null) { section = section?.parent }
// Import's "open storage" comes back to Import rather than the hub.
var storageReturn by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
Box(
modifier = modifier
@@ -118,34 +119,58 @@ fun SettingsScreen(
) {
SettingsHub(onBack = onBack, onOpenSection = { section = it })
SlideInSection(visible = section == SettingsSection.Appearance) {
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
val appearance = section == SettingsSection.Appearance
SlideInSection(visible = appearance) {
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = appearance)
}
SlideInSection(visible = section == SettingsSection.TaskForm) {
TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
val taskForm = section == SettingsSection.TaskForm
SlideInSection(visible = taskForm) {
TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = taskForm)
}
SlideInSection(visible = section == SettingsSection.Reminders) {
RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null })
val notifications = section == SettingsSection.Notifications
SlideInSection(visible = notifications) {
NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = notifications)
}
// Storage stays composed under Export, so the deeper screen slides over it.
val storageOpen = section == SettingsSection.Storage ||
section?.parent == SettingsSection.Storage
SlideInSection(visible = storageOpen) {
val storage = section == SettingsSection.Storage
SlideInSection(visible = storage) {
StorageScreen(
viewModel = viewModel,
onBack = {
section = storageReturn
storageReturn = null
},
visible = storage,
)
}
// Backup stays composed under export and import, so the deeper screen slides over it.
val backup = section == SettingsSection.Backup || section?.parent == SettingsSection.Backup
SlideInSection(visible = backup) {
BackupScreen(
onOpenExport = { section = SettingsSection.Export },
onOpenImport = { section = SettingsSection.Import },
onBack = { section = null },
visible = backup,
)
}
SlideInSection(visible = section == SettingsSection.Export) {
ExportScreen(onBack = { section = SettingsSection.Storage })
val exportOpen = section == SettingsSection.Export
SlideInSection(visible = exportOpen) {
ExportScreen(onBack = { section = SettingsSection.Backup }, visible = exportOpen)
}
SlideInSection(visible = section == SettingsSection.Import) {
ImportScreen(onBack = { section = SettingsSection.Storage })
val importOpen = section == SettingsSection.Import
SlideInSection(visible = importOpen) {
ImportScreen(
onBack = { section = SettingsSection.Backup },
onOpenStorage = {
storageReturn = SettingsSection.Import
section = SettingsSection.Storage
},
predictiveBack = true,
visible = importOpen,
)
}
SlideInSection(visible = section == SettingsSection.Licences) {
LicencesScreen(onBack = { section = null })
val licences = section == SettingsSection.Licences
SlideInSection(visible = licences) {
LicencesScreen(onBack = { section = null }, visible = licences)
}
AccountSections(
section = section,
@@ -155,12 +180,18 @@ fun SettingsScreen(
}
}
/**
* A sub-screen layered over the hub, with Calendula's overlay motion: it slides
* in on the motion scheme's spring and leaves from its predictive-back preview.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec<IntOffset>()
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally { it } + fadeIn(),
exit = slideOutHorizontally { it } + fadeOut(),
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = predictiveBackExit(),
) { content() }
}
@@ -208,7 +239,7 @@ private fun SettingsHub(
summary = stringResource(R.string.settings_reminders_subtitle),
position = Position.Bottom,
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Reminders) },
onClick = { onOpenSection(SettingsSection.Notifications) },
)
Spacer(Modifier.height(8.dp))
@@ -225,14 +256,41 @@ private fun SettingsHub(
GroupedRow(
title = stringResource(R.string.settings_section_storage),
summary = stringResource(R.string.settings_storage_subtitle),
position = if (BuildConfig.SYNC_ENABLED) Position.Bottom else Position.Alone,
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Secondary) },
position = if (BuildConfig.SYNC_ENABLED) Position.Middle else Position.Top,
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Storage) },
)
GroupedRow(
title = stringResource(R.string.settings_section_backup),
summary = stringResource(R.string.settings_backup_subtitle),
position = Position.Bottom,
leading = { CategoryIcon(Icons.Default.Backup, ChipAccent.Secondary) },
onClick = { onOpenSection(SettingsSection.Backup) },
)
Spacer(Modifier.height(8.dp))
GroupedSectionHeader(stringResource(R.string.settings_group_app))
LanguageRow(position = Position.Top)
LanguagePickerRow(
position = Position.Top,
title = stringResource(R.string.settings_language),
autoLabel = stringResource(R.string.settings_language_auto),
localesConfig = R.xml.locales_config,
predictiveBack = true,
// Invite contributions right where users pick their language.
header = {
val context = LocalContext.current
val translateUrl = stringResource(R.string.about_translate_url)
GroupedRow(
title = stringResource(R.string.settings_translate),
summary = stringResource(R.string.settings_translate_hint),
position = Position.Alone,
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
onClick = { openUrl(context, translateUrl) },
)
Spacer(Modifier.height(16.dp))
},
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) },
)
ReportProblemRow(position = Position.Bottom)
// Source, licence and privacy sit at the bottom as reference material
@@ -316,64 +374,6 @@ private fun AboutLinkRow(label: String, url: String, icon: ImageVector, position
)
}
/**
* The app-language row. Deliberately not floret-kit's `LanguagePickerRow`: the
* picker it opens carries a "Help translate" header, and inviting contributions
* right where a user goes looking for their language is app-specific framing,
* not a family primitive. Everything else matches that recipe.
*/
@Composable
private fun LanguageRow(position: Position) {
val context = LocalContext.current
val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) }
// Setting a locale recreates the activity; mirror the choice locally so the
// row updates instantly even before the recreation lands.
var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) }
var showDialog by remember { mutableStateOf(false) }
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
val options = remember(supported) { listOf<String?>(null) + supported }
GroupedRow(
title = stringResource(R.string.settings_language),
summary = languageLabel(current),
position = position,
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) },
onClick = { showDialog = true },
)
if (showDialog) {
OptionPicker(
title = stringResource(R.string.settings_language),
predictiveBack = true,
options = options,
selected = current,
label = { languageLabel(it) },
onSelect = {
current = it
AppLanguage.apply(it)
},
onDismiss = { showDialog = false },
// Invite contributions right where users pick their language.
header = {
val translateUrl = stringResource(R.string.about_translate_url)
GroupedRow(
title = stringResource(R.string.settings_translate),
summary = stringResource(R.string.settings_translate_hint),
position = Position.Alone,
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
onClick = { openUrl(context, translateUrl) },
)
Spacer(Modifier.height(16.dp))
},
)
}
}
@Composable
private fun languageLabel(tag: String?): String =
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
/**
* Opens the project's issue tracker to report a problem. If a crash report was
* captured (and not yet sent), it surfaces that report first via the same
@@ -22,6 +22,7 @@ import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferCounts
import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferResult
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.reminders.ReminderLead
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
@@ -219,12 +220,12 @@ class SettingsViewModel @Inject constructor(
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
fun setDefaultReminderMinutes(minutes: List<Int>) = viewModelScope.launch {
prefs.setDefaultReminderMinutes(minutes)
fun setDefaultReminders(leads: List<ReminderLead>) = viewModelScope.launch {
prefs.setDefaultReminders(leads)
resyncReminders()
}
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = viewModelScope.launch {
prefs.setDefaultAllDayReminderMinutes(minutes)
fun setDefaultAllDayReminders(leads: List<ReminderLead>) = viewModelScope.launch {
prefs.setDefaultAllDayReminders(leads)
resyncReminders()
}
fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) = viewModelScope.launch {
@@ -55,9 +55,8 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
@Composable
internal fun StorageScreen(
viewModel: SettingsViewModel,
onOpenExport: () -> Unit,
onOpenImport: () -> Unit,
onBack: () -> Unit,
visible: Boolean,
) {
val context = LocalContext.current
val storage by viewModel.storage.collectAsStateWithLifecycle()
@@ -85,11 +84,17 @@ internal fun StorageScreen(
// permission dialog too, and would wipe the refusal before it is read.
OnResume { viewModel.refreshStorage() }
CollapsingScaffold(title = stringResource(R.string.settings_section_storage), onBack = onBack) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_storage),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
val canCopy = storage?.canCopyFromExternal == true
GroupedRow(
title = stringResource(R.string.settings_task_store),
summary = storage?.let { storeLabel(it) },
position = Position.Top,
position = if (canCopy) Position.Top else Position.Alone,
// Nothing to pick until the stored mode has landed; opening the picker
// on the seeded state would offer the wrong store as the current one.
onClick = storage?.let {
@@ -99,31 +104,19 @@ internal fun StorageScreen(
}
},
)
if (storage?.canCopyFromExternal == true) {
if (canCopy) {
GroupedRow(
title = stringResource(
R.string.settings_copy_from_external,
storage?.let { externalTitle(it.external, it.externalLabel) }.orEmpty(),
),
summary = stringResource(R.string.settings_copy_from_external_hint),
position = Position.Middle,
position = Position.Bottom,
leading = { Icon(Icons.Rounded.MoveDown, contentDescription = null) },
// Inert while a copy is in flight; the receipt below says so.
onClick = if (transfer.running) null else viewModel::startCopyFromExternal,
)
}
GroupedRow(
title = stringResource(R.string.settings_export),
summary = stringResource(R.string.settings_export_hint),
position = Position.Middle,
onClick = onOpenExport,
)
GroupedRow(
title = stringResource(R.string.import_title),
summary = stringResource(R.string.settings_import_hint),
position = Position.Bottom,
onClick = onOpenImport,
)
TransferStatus(transfer)
@@ -42,10 +42,16 @@ internal fun TaskFormScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean,
) {
var showDefaultList by remember { mutableStateOf(false) }
CollapsingScaffold(title = stringResource(R.string.settings_section_task_form), onBack = onBack) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_task_form),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
SettingsHint(stringResource(R.string.settings_form_fields_hint))
Spacer(Modifier.height(8.dp))
val fields = TaskFormField.entries
@@ -183,12 +183,15 @@ private fun TaskWidgetBody(
modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp),
) {
TaskWidgetHeader(title, filter, ready?.singleListId)
Spacer(GlanceModifier.height(2.dp))
when (data) {
null -> Unit
TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable))
TaskWidgetData.Unavailable -> {
Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight))
WidgetMessage(context.getString(R.string.widget_unavailable))
}
is TaskWidgetData.Ready ->
if (data.rows.isEmpty()) {
Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight))
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
} else {
val allSections = widgetSections(
@@ -213,6 +216,12 @@ private fun TaskWidgetBody(
val redundantHeader = onlyHeader != null && redundantDueKind != null &&
onlyHeader.key == "due:${redundantDueKind.name}"
val sections = if (redundantHeader) allSections.filterNot { it is WidgetSection.Header } else allSections
val leadingGap = if (sections.firstOrNull() is WidgetSection.TaskItem) {
R.dimen.widget_tasks_header_gap
} else {
R.dimen.widget_tasks_header_gap_tight
}
Spacer(GlanceModifier.height(leadingGap))
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(sections.size) { index ->
when (val section = sections[index]) {