Compare commits
19 Commits
release/v0
...
fix/provid
| Author | SHA1 | Date | |
|---|---|---|---|
| 47cf99af32 | |||
| 26628dc0bb | |||
| d2e3832ef2 | |||
| 93857135b3 | |||
| 36beb2d0ad | |||
| bc70ed3a9f | |||
| 3f166ef5f0 | |||
| 41bd49826a | |||
| 05c75bafa7 | |||
| cfa25b9730 | |||
| 4aa65edb45 | |||
| 976d496d21 | |||
| 245f1db536 | |||
| 623e533547 | |||
| 2e50356f81 | |||
| e6f503c02a | |||
| c53511196d | |||
| a8595e26b4 | |||
| 411e27659f |
@@ -370,11 +370,13 @@ jobs:
|
|||||||
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
||||||
- name: Publish release to Codeberg
|
- name: Publish release to Codeberg
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
continue-on-error: true
|
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
||||||
|
# 0.3.0, 0.3.1 and 0.3.2 while never once publishing, which is how a
|
||||||
|
# crash-fix release reached F-Droid but not the Codeberg/Obtainium
|
||||||
|
# users who needed it. A broken mirror must fail the release loudly.
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||||
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
|
||||||
SHA: ${{ github.sha }}
|
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
if [ -z "${TOKEN:-}" ]; then
|
if [ -z "${TOKEN:-}" ]; then
|
||||||
@@ -399,29 +401,48 @@ jobs:
|
|||||||
sed -i -e '/./,$!d' release-notes.md
|
sed -i -e '/./,$!d' release-notes.md
|
||||||
fi
|
fi
|
||||||
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
||||||
# The push mirror (sync_on_commit) usually syncs the tag to Codeberg
|
# Never mint the tag here. Gitea's push mirror owns getting it to
|
||||||
# before this step runs. Forgejo 500s on POST /releases with a
|
# Codeberg; this step's only job is to attach a release to a tag that
|
||||||
# target_commitish when the tag already exists — so only pass
|
# has already landed. That split matters because every way of creating
|
||||||
# target_commitish when we actually need the API to create the tag.
|
# a tag from here — git push, or a release POST carrying
|
||||||
TAG_CODE=$(curl -s -o /dev/null -w '%{http_code}' \
|
# target_commitish for a tag Codeberg lacks — is a ref WRITE, and ref
|
||||||
-H "Authorization: token $TOKEN" "$API/git/refs/tags/$TAG")
|
# writes are what fail on this repo ("cannot lock references" on push,
|
||||||
python3 - "$TAG" "$SHA" "$PRERELEASE" "$TAG_CODE" <<'PY' > cb-payload.json
|
# an empty-bodied 500 on the API). Attaching to a tag that is already
|
||||||
|
# present needs no ref write and succeeds.
|
||||||
|
#
|
||||||
|
# So: wait for the mirror, verify, then attach. If the tag never shows
|
||||||
|
# up, fail — do NOT fall back to creating it, which is what produced
|
||||||
|
# the silent breakage across 0.2.1 through 0.3.2.
|
||||||
|
TAG_OK=""
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if [ "$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $TOKEN" "$API/tags/$TAG")" = "200" ]; then
|
||||||
|
TAG_OK=1; echo "Codeberg has $TAG (after ~$((i*10))s)"; break
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
if [ -z "$TAG_OK" ]; then
|
||||||
|
echo "Codeberg never received $TAG from the push mirror (waited 300s)." >&2
|
||||||
|
echo "Not creating it here: ref writes to this repo fail, so that" >&2
|
||||||
|
echo "would 500. Check the mirror, then re-run once the tag is there." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# No target_commitish: the tag exists, so the API must attach to it
|
||||||
|
# rather than resolve a commit and mint one.
|
||||||
|
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
|
||||||
import json, sys
|
import json, sys
|
||||||
tag, sha, pre, tag_code = sys.argv[1:5]
|
tag, pre = sys.argv[1:3]
|
||||||
payload = {
|
print(json.dumps({
|
||||||
"tag_name": tag,
|
"tag_name": tag,
|
||||||
"name": tag,
|
"name": tag,
|
||||||
"body": open("release-notes.md").read(),
|
"body": open("release-notes.md").read(),
|
||||||
"draft": False,
|
"draft": False,
|
||||||
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
||||||
"prerelease": pre == "true",
|
"prerelease": pre == "true",
|
||||||
}
|
}))
|
||||||
# Only create the tag via the release when it isn't mirrored yet.
|
|
||||||
if tag_code != "200":
|
|
||||||
payload["target_commitish"] = sha
|
|
||||||
print(json.dumps(payload))
|
|
||||||
PY
|
PY
|
||||||
# Upsert (re-run safe).
|
# Upsert (re-run safe): a release already attached to this tag is
|
||||||
|
# PATCHed in place, so re-running never disturbs a published release.
|
||||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||||
if [ -n "$ID" ]; then
|
if [ -n "$ID" ]; then
|
||||||
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ All notable changes to this project are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.2] - 2026-07-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Releases reach the Codeberg download channel again. 0.3.1 published to
|
||||||
|
F-Droid but never appeared on Codeberg, so if you install from there — or
|
||||||
|
through Obtainium — this is the release that finally carries 0.3.0's
|
||||||
|
launch-crash fix. The app itself is unchanged from 0.3.1.
|
||||||
|
|
||||||
## [0.3.1] - 2026-07-20
|
## [0.3.1] - 2026-07-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ android {
|
|||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
||||||
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
||||||
versionCode = 301
|
versionCode = 302
|
||||||
versionName = "0.3.1"
|
versionName = "0.3.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFormField
|
|||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||||
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderLeadFor
|
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -39,15 +39,17 @@ data class Settings(
|
|||||||
val bottomAddBar: Boolean = false,
|
val bottomAddBar: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
|
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
|
||||||
* overrides the global default (a null value = no reminder); absent = inherit.
|
* overrides the global default (an empty list = no reminder); absent =
|
||||||
|
* inherit. Agendula offers a single reminder, so each override is a
|
||||||
|
* one-element (or empty) list.
|
||||||
*/
|
*/
|
||||||
val perListReminderOverride: Map<Long, Int?> = emptyMap(),
|
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||||
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
||||||
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
||||||
) {
|
) {
|
||||||
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
||||||
fun reminderLeadFor(listId: Long): Int? =
|
fun reminderLeadFor(listId: Long): Int? =
|
||||||
perListReminderOverride.reminderLeadFor(listId, reminderLeadMinutes)
|
perListReminderOverride.reminderLeadsFor(listId, listOf(reminderLeadMinutes)).firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.data.reminders
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.core.net.toUri
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
@@ -45,7 +46,14 @@ class DueReminderReceiver : BroadcastReceiver() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
||||||
|
|
||||||
fun intent(context: Context, taskId: Long): Intent =
|
/**
|
||||||
Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId)
|
* [triggerAt] rides in the intent *data*, not just an extra: PendingIntent
|
||||||
|
* identity ignores extras, so two occurrences of the same recurring task
|
||||||
|
* would otherwise collapse into one alarm under FLAG_UPDATE_CURRENT.
|
||||||
|
*/
|
||||||
|
fun intent(context: Context, taskId: Long, triggerAt: Long): Intent =
|
||||||
|
Intent(context, DueReminderReceiver::class.java)
|
||||||
|
.setData("agendula://reminder/$taskId/$triggerAt".toUri())
|
||||||
|
.putExtra(EXTRA_TASK_ID, taskId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package de.jeanlucmakiola.agendula.data.reminders
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -20,10 +22,25 @@ import javax.inject.Inject
|
|||||||
class ProviderChangeReceiver : BroadcastReceiver() {
|
class ProviderChangeReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
@Inject lateinit var scheduler: ReminderScheduler
|
@Inject lateinit var scheduler: ReminderScheduler
|
||||||
|
@Inject lateinit var providerResolver: ProviderResolver
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
// The receiver has to stay exported to hear the provider's broadcast, and
|
||||||
|
// the sender holds no permission we could require — so validate the
|
||||||
|
// broadcast itself. Without this, any installed app can spam a full
|
||||||
|
// re-sync (an unbounded provider read) by firing a matching intent.
|
||||||
|
if (intent.action != Intent.ACTION_PROVIDER_CHANGED) return
|
||||||
|
val authority = providerResolver.resolve()?.authority ?: return
|
||||||
|
if (intent.data?.host != authority) return
|
||||||
|
// External sync can fire these in bursts; one re-sync per burst is plenty.
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
synchronized(Companion) {
|
||||||
|
if (now - lastSyncAt < MIN_SYNC_INTERVAL_MS) return
|
||||||
|
lastSyncAt = now
|
||||||
|
}
|
||||||
|
|
||||||
val pending = goAsync()
|
val pending = goAsync()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -33,4 +50,11 @@ class ProviderChangeReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MIN_SYNC_INTERVAL_MS = 10_000L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var lastSyncAt = -MIN_SYNC_INTERVAL_MS
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,35 +43,57 @@ class ReminderScheduler @Inject constructor(
|
|||||||
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
||||||
.getOrElse { return@withContext }
|
.getOrElse { return@withContext }
|
||||||
|
|
||||||
|
// One reminder per *occurrence*: the instances view yields a row per
|
||||||
|
// occurrence, all sharing a taskId, so this is a Set rather than a
|
||||||
|
// taskId-keyed Map — keying by task would collapse a daily recurring task
|
||||||
|
// down to one arbitrary reminder (the query is unsorted, so which one
|
||||||
|
// survived was provider-defined).
|
||||||
|
// Per-task leads, stored as Alarm property rows. One query for all of them.
|
||||||
|
val perTask = runCatching { dataSource.alarms() }.getOrElse { emptyMap() }
|
||||||
|
|
||||||
val desired = tasks
|
val desired = tasks
|
||||||
.filter { !it.isClosed && it.due != null }
|
.filter { !it.isClosed && it.due != null }
|
||||||
.mapNotNull { task ->
|
.mapNotNull { task ->
|
||||||
// The task's list may override the global lead, or opt out entirely
|
// A reminder set on the task itself wins; otherwise the task's list
|
||||||
// (override = null), in which case it gets no reminder at all.
|
// may override the global lead, or opt out entirely (override =
|
||||||
val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null
|
// null), in which case it gets no reminder at all.
|
||||||
task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L)
|
val lead = perTask[task.taskId]
|
||||||
|
?: settings.reminderLeadFor(task.listId)
|
||||||
|
?: return@mapNotNull null
|
||||||
|
ScheduledReminder(
|
||||||
|
taskId = task.taskId,
|
||||||
|
triggerAt = task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.toMap()
|
// The lower bound trails `now` so a reminder missed while the device was
|
||||||
.filterValues { it in now..horizon }
|
// off still fires once on boot instead of being silently dropped —
|
||||||
|
// setExactAndAllowWhileIdle delivers a past trigger immediately. Anything
|
||||||
|
// already armed stays armed (the diff below), so it can't re-fire.
|
||||||
|
.filter { it.triggerAt in (now - MISSED_GRACE_MS)..horizon }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
val previous = store.all()
|
val previous = store.all()
|
||||||
(previous.keys - desired.keys).forEach { cancel(it) }
|
(previous - desired).forEach { cancel(it) }
|
||||||
desired.forEach { (taskId, triggerAt) ->
|
(desired - previous).forEach { schedule(it) }
|
||||||
if (previous[taskId] != triggerAt) schedule(taskId, triggerAt)
|
|
||||||
}
|
|
||||||
store.replace(desired)
|
store.replace(desired)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
|
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
|
||||||
|
|
||||||
private fun pendingIntent(taskId: Long, create: Boolean): PendingIntent? {
|
private fun pendingIntent(reminder: ScheduledReminder, create: Boolean): PendingIntent? {
|
||||||
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
|
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
return PendingIntent.getBroadcast(context, taskId.toInt(), DueReminderReceiver.intent(context, taskId), flags)
|
return PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
reminder.requestCode,
|
||||||
|
DueReminderReceiver.intent(context, reminder.taskId, reminder.triggerAt),
|
||||||
|
flags,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun schedule(taskId: Long, triggerAt: Long) {
|
private fun schedule(reminder: ScheduledReminder) {
|
||||||
val pi = pendingIntent(taskId, create = true) ?: return
|
val triggerAt = reminder.triggerAt
|
||||||
|
val pi = pendingIntent(reminder, create = true) ?: return
|
||||||
val am = alarmManager()
|
val am = alarmManager()
|
||||||
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||||
if (canExact) {
|
if (canExact) {
|
||||||
@@ -81,19 +103,21 @@ class ReminderScheduler @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cancel(taskId: Long) {
|
private fun cancel(reminder: ScheduledReminder) {
|
||||||
pendingIntent(taskId, create = false)?.let {
|
pendingIntent(reminder, create = false)?.let {
|
||||||
alarmManager().cancel(it)
|
alarmManager().cancel(it)
|
||||||
it.cancel()
|
it.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun clearAll() {
|
private suspend fun clearAll() {
|
||||||
store.all().keys.forEach { cancel(it) }
|
store.all().forEach { cancel(it) }
|
||||||
store.replace(emptyMap())
|
store.replace(emptySet())
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
|
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
|
||||||
|
/** How long after its trigger a missed reminder is still worth firing. */
|
||||||
|
const val MISSED_GRACE_MS = 6L * 60 * 60 * 1000 // 6 hours
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,25 +9,38 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remembers which task reminders are currently scheduled (taskId → trigger time),
|
* One armed alarm. A recurring task has many occurrences sharing a [taskId], so
|
||||||
* so [ReminderScheduler] can diff against a fresh computation and cancel only the
|
* the trigger time is part of the identity — keying by task alone would collapse
|
||||||
* alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings.
|
* a daily task down to a single reminder.
|
||||||
|
*/
|
||||||
|
data class ScheduledReminder(val taskId: Long, val triggerAt: Long) {
|
||||||
|
/**
|
||||||
|
* Request code for this alarm's PendingIntent. Derived from both fields so
|
||||||
|
* sibling occurrences don't share (and overwrite) one alarm slot.
|
||||||
|
*/
|
||||||
|
val requestCode: Int get() = (taskId * 31 + triggerAt).hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembers which task reminders are currently armed, so [ReminderScheduler] can
|
||||||
|
* diff against a fresh computation and touch only the alarms that changed.
|
||||||
|
* Persisted in DataStore as a set of `taskId|trigger` strings.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ScheduledReminderStore @Inject constructor(
|
class ScheduledReminderStore @Inject constructor(
|
||||||
private val dataStore: DataStore<Preferences>,
|
private val dataStore: DataStore<Preferences>,
|
||||||
) {
|
) {
|
||||||
suspend fun all(): Map<Long, Long> =
|
suspend fun all(): Set<ScheduledReminder> =
|
||||||
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
|
dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
|
||||||
val parts = entry.split('|')
|
val parts = entry.split('|')
|
||||||
val id = parts.getOrNull(0)?.toLongOrNull()
|
val id = parts.getOrNull(0)?.toLongOrNull()
|
||||||
val at = parts.getOrNull(1)?.toLongOrNull()
|
val at = parts.getOrNull(1)?.toLongOrNull()
|
||||||
if (id != null && at != null) id to at else null
|
if (id != null && at != null) ScheduledReminder(id, at) else null
|
||||||
}.toMap()
|
}.toSet()
|
||||||
|
|
||||||
suspend fun replace(scheduled: Map<Long, Long>) {
|
suspend fun replace(scheduled: Set<ScheduledReminder>) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet()
|
prefs[KEY] = scheduled.map { "${it.taskId}|${it.triggerAt}" }.toSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import android.os.Looper
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
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.TasksContract.Tasks
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
@@ -98,6 +99,50 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun updateInstance(instanceId: Long, form: TaskForm) {
|
||||||
|
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||||
|
val uri = TasksContract.instanceUri(authority(), instanceId)
|
||||||
|
val rows = resolver.update(uri, values.toContentValues(), null, null)
|
||||||
|
if (rows == 0) throw TaskWriteFailedException("update instance $instanceId")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 — and delete+insert
|
||||||
|
// means we never have to track property_id.
|
||||||
|
resolver.delete(
|
||||||
|
uri,
|
||||||
|
"${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?",
|
||||||
|
arrayOf(taskId.toString(), TasksContract.Alarm.MIMETYPE),
|
||||||
|
)
|
||||||
|
if (minutesBeforeDue != null) {
|
||||||
|
resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues())
|
||||||
|
?: throw TaskWriteFailedException("set alarm for task $taskId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun alarms(): Map<Long, Int> {
|
||||||
|
val uri = TasksContract.propertiesUri(authority())
|
||||||
|
val projection = arrayOf(Properties.TASK_ID, TasksContract.Alarm.MINUTES_BEFORE)
|
||||||
|
return resolver.query(
|
||||||
|
uri,
|
||||||
|
projection,
|
||||||
|
"${Properties.MIMETYPE} = ?",
|
||||||
|
arrayOf(TasksContract.Alarm.MIMETYPE),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
val reader = CursorColumnReader(c)
|
||||||
|
buildMap {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val id = reader.getLong(Properties.TASK_ID)
|
||||||
|
val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE)
|
||||||
|
if (id != null && minutes != null) put(id, minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
override fun setCompleted(taskId: Long, completed: Boolean) {
|
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||||
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
||||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||||
@@ -127,9 +172,16 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||||
override fun onChange(selfChange: Boolean) = onChange()
|
override fun onChange(selfChange: Boolean) = onChange()
|
||||||
}
|
}
|
||||||
|
// Register both or neither: if the second call throws, the first
|
||||||
|
// registration would otherwise leak (no AutoCloseable was handed back yet).
|
||||||
|
try {
|
||||||
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
|
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
|
||||||
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
|
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
|
||||||
return AutoCloseable { resolver.unregisterContentObserver(observer) }
|
} catch (e: RuntimeException) {
|
||||||
|
runCatching { resolver.unregisterContentObserver(observer) }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
return AutoCloseable { runCatching { resolver.unregisterContentObserver(observer) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Map<String, Any?>.toContentValues(): ContentValues {
|
private fun Map<String, Any?>.toContentValues(): ContentValues {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.retryWhen
|
||||||
|
|
||||||
|
private const val BASE_RETRY_MS = 1_000L
|
||||||
|
private const val MAX_RETRY_MS = 30_000L
|
||||||
|
|
||||||
|
/** 1s, 2s, 4s … capped at 30s, so a permanently-absent provider costs little. */
|
||||||
|
private fun retryDelayMs(attempt: Long): Long =
|
||||||
|
(BASE_RETRY_MS shl attempt.coerceAtMost(5).toInt()).coerceAtMost(MAX_RETRY_MS)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover a provider-backed flow without killing it.
|
||||||
|
*
|
||||||
|
* Provider reads fail for reasons that resolve on their own: the read permission
|
||||||
|
* isn't granted yet (first launch collects before the permission gate), or the
|
||||||
|
* provider app is mid-update. A terminal `catch` swallows the failure *and*
|
||||||
|
* cancels the upstream, so the flow never produces again — the screen stays empty
|
||||||
|
* until the process restarts, even after the user grants the permission.
|
||||||
|
*
|
||||||
|
* This emits [fallback] instead and keeps retrying with a capped backoff, so the
|
||||||
|
* collector recovers on its own once the provider becomes readable.
|
||||||
|
*/
|
||||||
|
fun <T> Flow<T>.recoveringFromProviderFailure(fallback: () -> T): Flow<T> =
|
||||||
|
retryWhen { _, attempt ->
|
||||||
|
emit(fallback())
|
||||||
|
delay(retryDelayMs(attempt))
|
||||||
|
true
|
||||||
|
}
|
||||||
@@ -38,7 +38,14 @@ object TaskMapper {
|
|||||||
listName = r.getString(Tasks.LIST_NAME),
|
listName = r.getString(Tasks.LIST_NAME),
|
||||||
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
||||||
parentId = r.getLong(Tasks.PARENT_ID),
|
parentId = r.getLong(Tasks.PARENT_ID),
|
||||||
isRecurring = r.getBoolean(Instances.IS_RECURRING),
|
// Derived from the rule columns rather than the `is_recurring` column
|
||||||
|
// alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is
|
||||||
|
// absent on tasks.org's bundled provider (DB 22), where reading it
|
||||||
|
// would silently report every recurring task as one-off — and route
|
||||||
|
// its edits onto the series anchor.
|
||||||
|
isRecurring = r.getString(Tasks.RRULE) != null ||
|
||||||
|
r.getString(Tasks.RDATE) != null ||
|
||||||
|
r.getBoolean(Instances.IS_RECURRING),
|
||||||
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
|
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
|
||||||
created = instant(Tasks.CREATED),
|
created = instant(Tasks.CREATED),
|
||||||
lastModified = instant(Tasks.LAST_MODIFIED),
|
lastModified = instant(Tasks.LAST_MODIFIED),
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
|
||||||
|
|
||||||
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
|
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
|
||||||
object TaskProjections {
|
object TaskProjections {
|
||||||
@@ -18,31 +16,9 @@ object TaskProjections {
|
|||||||
Lists.ACCOUNT_TYPE,
|
Lists.ACCOUNT_TYPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Read from the `instances` view (inherits all task columns). */
|
// No `instances` projection on purpose: that read passes `projection = null`
|
||||||
val INSTANCES: Array<String> = arrayOf(
|
// (all columns), because the view's shape differs across provider versions —
|
||||||
Tasks.ID,
|
// tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list
|
||||||
Instances.TASK_ID,
|
// here would drift out of sync with the by-name mapper and quietly drop
|
||||||
Tasks.LIST_ID,
|
// columns it depends on. See AndroidTasksDataSource.queryInstances.
|
||||||
Tasks.TITLE,
|
|
||||||
Tasks.DESCRIPTION,
|
|
||||||
Tasks.LOCATION,
|
|
||||||
Tasks.URL,
|
|
||||||
Tasks.PRIORITY,
|
|
||||||
Tasks.STATUS,
|
|
||||||
Tasks.PERCENT_COMPLETE,
|
|
||||||
Tasks.COMPLETED,
|
|
||||||
Tasks.IS_ALLDAY,
|
|
||||||
Tasks.TZ,
|
|
||||||
Instances.INSTANCE_START,
|
|
||||||
Instances.INSTANCE_DUE,
|
|
||||||
Tasks.TASK_COLOR,
|
|
||||||
Tasks.LIST_COLOR,
|
|
||||||
Tasks.LIST_NAME,
|
|
||||||
Tasks.ACCOUNT_NAME,
|
|
||||||
Tasks.PARENT_ID,
|
|
||||||
Instances.IS_RECURRING,
|
|
||||||
Instances.DISTANCE_FROM_CURRENT,
|
|
||||||
Tasks.CREATED,
|
|
||||||
Tasks.LAST_MODIFIED,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
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.TasksContract.Tasks
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.toICal
|
import de.jeanlucmakiola.agendula.domain.toICal
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
|
||||||
|
private fun Instant.forAllDay(allDay: Boolean): Instant =
|
||||||
|
if (!allDay) this
|
||||||
|
else Instant.fromEpochMilliseconds(
|
||||||
|
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
|
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
|
||||||
@@ -39,8 +51,17 @@ object TaskWriteMapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
|
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
|
||||||
put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
|
// All-day tasks are date-only in iCalendar. The provider reads them back
|
||||||
put(Tasks.DUE, form.due?.toEpochMilliseconds())
|
// through DateTime.toAllDay(), which drops the time-of-day and resolves the
|
||||||
|
// remaining date against UTC — so a local-midnight instant lands on the
|
||||||
|
// previous day for anyone west of UTC. Pin all-day values to UTC midnight.
|
||||||
|
put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
|
||||||
|
put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds())
|
||||||
|
// DUE and DURATION are mutually exclusive. The provider's Validating
|
||||||
|
// processor evaluates the *merged* row (supplied values over the stored
|
||||||
|
// ones), so writing DUE onto a task that already carries a DURATION throws
|
||||||
|
// "Only one of DUE or DURATION must be supplied." Clear it alongside.
|
||||||
|
put(Tasks.DURATION, null)
|
||||||
put(Tasks.PARENT_ID, form.parentId)
|
put(Tasks.PARENT_ID, form.parentId)
|
||||||
// The provider treats a null tz as local time; set it explicitly for
|
// The provider treats a null tz as local time; set it explicitly for
|
||||||
// timed tasks so the stored instant is unambiguous across zones.
|
// timed tasks so the stored instant is unambiguous across zones.
|
||||||
@@ -48,6 +69,16 @@ object TaskWriteMapper {
|
|||||||
put(Tasks.TZ, if (timed) tzId else null)
|
put(Tasks.TZ, if (timed) tzId else null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values for an update through the *instances* URI (a recurring occurrence).
|
||||||
|
* The provider clones the row into an override and strips list/recurrence
|
||||||
|
* fields as it goes, so LIST_ID and PARENT_ID are dropped here rather than
|
||||||
|
* written and silently ignored — moving one occurrence between lists or
|
||||||
|
* parents isn't a thing the override model expresses.
|
||||||
|
*/
|
||||||
|
fun instanceValues(form: TaskForm, tzId: String): Map<String, Any?> =
|
||||||
|
taskValues(form, tzId) - Tasks.LIST_ID - Tasks.PARENT_ID
|
||||||
|
|
||||||
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
|
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
|
||||||
if (completed) {
|
if (completed) {
|
||||||
mapOf(
|
mapOf(
|
||||||
@@ -63,6 +94,19 @@ object TaskWriteMapper {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reminder for [taskId], as an Alarm property row. The provider's validator
|
||||||
|
* requires MINUTES_BEFORE, REFERENCE (non-negative) and ALARM_TYPE on every
|
||||||
|
* write, so all three are always present.
|
||||||
|
*/
|
||||||
|
fun alarmValues(taskId: Long, minutesBeforeDue: Int): Map<String, Any?> = mapOf(
|
||||||
|
Properties.TASK_ID to taskId,
|
||||||
|
Properties.MIMETYPE to Alarm.MIMETYPE,
|
||||||
|
Alarm.MINUTES_BEFORE to minutesBeforeDue,
|
||||||
|
Alarm.REFERENCE to Alarm.REFERENCE_DUE,
|
||||||
|
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE,
|
||||||
|
)
|
||||||
|
|
||||||
fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
fun localListValues(name: String, color: Int): Map<String, Any?> = mapOf(
|
||||||
Lists.NAME to name.trim(),
|
Lists.NAME to name.trim(),
|
||||||
Lists.COLOR to color,
|
Lists.COLOR to color,
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ object TasksContract {
|
|||||||
const val IS_ALLDAY = "is_allday"
|
const val IS_ALLDAY = "is_allday"
|
||||||
const val TZ = "tz"
|
const val TZ = "tz"
|
||||||
const val RRULE = "rrule"
|
const val RRULE = "rrule"
|
||||||
|
const val RDATE = "rdate"
|
||||||
|
/** Set on an override row — the master occurrence this one replaces. */
|
||||||
|
const val ORIGINAL_INSTANCE_ID = "original_instance_id"
|
||||||
const val PARENT_ID = "parent_id"
|
const val PARENT_ID = "parent_id"
|
||||||
const val SORTING = "sorting"
|
const val SORTING = "sorting"
|
||||||
const val CREATED = "created"
|
const val CREATED = "created"
|
||||||
@@ -98,6 +101,50 @@ object TasksContract {
|
|||||||
const val IS_RECURRING = "is_recurring"
|
const val IS_RECURRING = "is_recurring"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The `properties` table — per-task side rows, discriminated by [Properties.MIMETYPE]. */
|
||||||
|
object Properties {
|
||||||
|
const val PATH = "properties"
|
||||||
|
const val PROPERTY_ID = "property_id"
|
||||||
|
const val TASK_ID = "task_id"
|
||||||
|
const val MIMETYPE = "mimetype"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An alarm property row — a per-task reminder lead.
|
||||||
|
*
|
||||||
|
* Storage and sync format *only*: the provider fires nothing (its alarm
|
||||||
|
* scheduling is commented out and the internal `alarms` table is never
|
||||||
|
* populated), so [de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler]
|
||||||
|
* still arms the real AlarmManager alarm. Writing it here is what makes the
|
||||||
|
* lead survive a sync and show up in other OpenTasks clients.
|
||||||
|
*
|
||||||
|
* The columns are the generic `dataN` slots; the meanings below are the
|
||||||
|
* Alarm property's contract for them.
|
||||||
|
*/
|
||||||
|
object Alarm {
|
||||||
|
const val MIMETYPE = "vnd.android.cursor.item/alarm"
|
||||||
|
|
||||||
|
/** `data0` — minutes from the reference date; positive means *before* it. */
|
||||||
|
const val MINUTES_BEFORE = "data0"
|
||||||
|
|
||||||
|
/** `data1` — which date to count from. */
|
||||||
|
const val REFERENCE = "data1"
|
||||||
|
|
||||||
|
/** `data2` — optional message shown with the alarm. */
|
||||||
|
const val MESSAGE = "data2"
|
||||||
|
|
||||||
|
/** `data3` — alarm kind. Must be present, and non-zero to count as an alarm. */
|
||||||
|
const val ALARM_TYPE = "data3"
|
||||||
|
|
||||||
|
const val REFERENCE_DUE = 1
|
||||||
|
const val REFERENCE_START = 2
|
||||||
|
|
||||||
|
/** 0 (NOTHING) is excluded from the provider's `has_alarms` count — use MESSAGE. */
|
||||||
|
const val TYPE_MESSAGE = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun propertiesUri(authority: String): Uri = Uri.parse("content://$authority/${Properties.PATH}")
|
||||||
|
|
||||||
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
||||||
const val STATUS_NEEDS_ACTION = 0
|
const val STATUS_NEEDS_ACTION = 0
|
||||||
const val STATUS_IN_PROCESS = 1
|
const val STATUS_IN_PROCESS = 1
|
||||||
@@ -112,6 +159,15 @@ object TasksContract {
|
|||||||
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
|
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
|
||||||
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
|
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single occurrence. Updating through this URI is how a *recurring* task is
|
||||||
|
* edited: the provider clones the row into an override task
|
||||||
|
* (`original_instance_id` set, recurrence fields stripped) instead of moving
|
||||||
|
* the series anchor, which is what writing to `tasks/<id>` would do.
|
||||||
|
*/
|
||||||
|
fun instanceUri(authority: String, instanceId: Long): Uri =
|
||||||
|
Uri.parse("content://$authority/${Instances.PATH}/$instanceId")
|
||||||
|
|
||||||
/** Append the sync-adapter params required to write local-account rows. */
|
/** Append the sync-adapter params required to write local-account rows. */
|
||||||
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
|
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
|
||||||
uri.buildUpon()
|
uri.buildUpon()
|
||||||
|
|||||||
@@ -23,6 +23,25 @@ interface TasksDataSource {
|
|||||||
|
|
||||||
fun insertTask(form: TaskForm): Long
|
fun insertTask(form: TaskForm): Long
|
||||||
fun updateTask(taskId: Long, form: TaskForm)
|
fun updateTask(taskId: Long, form: TaskForm)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single occurrence of a recurring task, addressed by its *instance*
|
||||||
|
* row id. The provider forks an override task rather than moving the series
|
||||||
|
* anchor — which is what [updateTask] would do, since a recurring task's
|
||||||
|
* start/due are read from the instances view.
|
||||||
|
*/
|
||||||
|
fun updateInstance(instanceId: Long, form: TaskForm)
|
||||||
|
/**
|
||||||
|
* Set (or clear, with `null`) the per-task reminder lead, stored as an Alarm
|
||||||
|
* property row. 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?)
|
||||||
|
|
||||||
|
/** Every task's reminder lead, by task id. One query, for the scheduler. */
|
||||||
|
fun alarms(): Map<Long, Int>
|
||||||
|
|
||||||
fun setCompleted(taskId: Long, completed: Boolean)
|
fun setCompleted(taskId: Long, completed: Boolean)
|
||||||
fun deleteTask(taskId: Long)
|
fun deleteTask(taskId: Long)
|
||||||
fun createLocalList(name: String, color: Int): Long
|
fun createLocalList(name: String, color: Int): Long
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ interface TasksRepository {
|
|||||||
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
|
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
|
||||||
suspend fun setCompleted(taskId: Long, completed: Boolean)
|
suspend fun setCompleted(taskId: Long, completed: Boolean)
|
||||||
suspend fun deleteTask(taskId: Long)
|
suspend fun deleteTask(taskId: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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?
|
||||||
suspend fun createLocalList(name: String, color: Int): Long
|
suspend fun createLocalList(name: String, color: Int): Long
|
||||||
|
|
||||||
/** Synchronous snapshot for the permission/onboarding gate. */
|
/** Synchronous snapshot for the permission/onboarding gate. */
|
||||||
|
|||||||
@@ -80,19 +80,40 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createTask(form: TaskForm): Long =
|
override suspend fun createTask(form: TaskForm): Long =
|
||||||
withContext(io) { dataSource.insertTask(form) }
|
withContext(io) {
|
||||||
|
val id = dataSource.insertTask(form)
|
||||||
|
form.reminderMinutesBeforeDue?.let { dataSource.setAlarm(id, it) }
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reminderFor(taskId: Long): Int? =
|
||||||
|
withContext(io) { runCatching { dataSource.alarms()[taskId] }.getOrNull() }
|
||||||
|
|
||||||
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
|
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
// Conflict-safe overwrite: re-read just before writing and bail if the
|
// Re-read just before writing: it settles the conflict check *and* tells
|
||||||
// provider's last_modified moved since the form captured it (external
|
// us which URI to write through.
|
||||||
// sync / another app). A null baseline means "force / overwrite anyway".
|
val current = dataSource.task(taskId)
|
||||||
|
// Conflict-safe overwrite: bail if the provider's last_modified moved
|
||||||
|
// since the form captured it (external sync / another app). A null
|
||||||
|
// baseline means "force / overwrite anyway".
|
||||||
if (expectedLastModified != null) {
|
if (expectedLastModified != null) {
|
||||||
val current = dataSource.task(taskId)?.lastModified
|
val seen = current?.lastModified
|
||||||
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId)
|
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
|
||||||
}
|
}
|
||||||
|
// Write the reminder first: forking a recurring occurrence copies the
|
||||||
|
// task's properties onto the new override row, so setting the alarm
|
||||||
|
// beforehand is what carries it across.
|
||||||
|
dataSource.setAlarm(taskId, form.reminderMinutesBeforeDue)
|
||||||
|
// A recurring task's start/due come from the instances view, so writing
|
||||||
|
// them back to tasks/<id> would re-anchor the whole series. Going through
|
||||||
|
// the occurrence lets the provider fork an override instead.
|
||||||
|
if (current != null && current.isRecurring) {
|
||||||
|
dataSource.updateInstance(current.id, form)
|
||||||
|
} else {
|
||||||
dataSource.updateTask(taskId, form)
|
dataSource.updateTask(taskId, form)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
|
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
|
||||||
withContext(io) { dataSource.setCompleted(taskId, completed) }
|
withContext(io) { dataSource.setCompleted(taskId, completed) }
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All-day tasks are date-only in iCalendar. OpenTasks reads them back through
|
||||||
|
* `DateTime.toAllDay()`, which discards the time-of-day and resolves the
|
||||||
|
* remaining date against UTC — so the storage convention is **UTC midnight of
|
||||||
|
* the intended calendar date, with a null timezone**. Timed tasks, by contrast,
|
||||||
|
* are ordinary instants rendered in the device's zone.
|
||||||
|
*
|
||||||
|
* These two conventions disagree about which day a given instant is, which is
|
||||||
|
* why every all-day value needs an explicit conversion rather than a raw
|
||||||
|
* `Instant` passed straight through.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** UTC midnight of [date] — the storage form for an all-day value. */
|
||||||
|
fun allDayInstantOf(date: java.time.LocalDate): Instant =
|
||||||
|
Instant.fromEpochMilliseconds(date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar date this instant denotes: read in UTC for [allDay] values,
|
||||||
|
* in [zone] for timed ones.
|
||||||
|
*/
|
||||||
|
fun Instant.calendarDate(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): java.time.LocalDate =
|
||||||
|
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
|
||||||
|
.atZone(if (allDay) ZoneOffset.UTC else zone)
|
||||||
|
.toLocalDate()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an instant across the two conventions when the all-day switch flips, so
|
||||||
|
* the day the user is looking at stays put. Without this, toggling all-day off
|
||||||
|
* turns a UTC-midnight value into "02:00" in Berlin (or the previous day, 19:00,
|
||||||
|
* in New York) — reading to the user as "the time reset itself".
|
||||||
|
*/
|
||||||
|
fun Instant.rebasedForAllDay(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): Instant =
|
||||||
|
if (allDay) allDayInstantOf(calendarDate(allDay = false, zone = zone))
|
||||||
|
else Instant.fromEpochMilliseconds(
|
||||||
|
calendarDate(allDay = true).atStartOfDay(zone).toInstant().toEpochMilli(),
|
||||||
|
)
|
||||||
@@ -48,6 +48,12 @@ data class Task(
|
|||||||
val listName: String?,
|
val listName: String?,
|
||||||
val accountName: String?,
|
val accountName: String?,
|
||||||
val parentId: Long?,
|
val parentId: Long?,
|
||||||
|
/**
|
||||||
|
* This row carries a recurrence rule, so [id] is one occurrence of a series
|
||||||
|
* and [start]/[due] are that occurrence's resolved times — *not* the master's
|
||||||
|
* anchor. Edits must go through the instances URI (see
|
||||||
|
* [de.jeanlucmakiola.agendula.data.tasks.TasksContract.instanceUri]).
|
||||||
|
*/
|
||||||
val isRecurring: Boolean,
|
val isRecurring: Boolean,
|
||||||
val distanceFromCurrent: Int?,
|
val distanceFromCurrent: Int?,
|
||||||
val created: Instant?,
|
val created: Instant?,
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
||||||
@@ -39,6 +43,18 @@ fun RootScreen(
|
|||||||
ActivityResultContracts.RequestMultiplePermissions(),
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
) { permissionViewModel.refresh() }
|
) { permissionViewModel.refresh() }
|
||||||
|
|
||||||
|
// Re-check on every resume, not just after the in-app request: the user may
|
||||||
|
// have granted the permission (or installed a provider) in system Settings and
|
||||||
|
// come back, and otherwise the gate would hold until the process restarts.
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) permissionViewModel.refresh()
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
|
||||||
when (permission.status) {
|
when (permission.status) {
|
||||||
ProviderStatus.NO_PROVIDER -> Gate(
|
ProviderStatus.NO_PROVIDER -> Gate(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.time.formatDateTime
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Clear
|
|
||||||
import androidx.compose.material.icons.rounded.Event
|
|
||||||
import androidx.compose.material3.DatePicker
|
|
||||||
import androidx.compose.material3.DatePickerDialog
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TimePicker
|
|
||||||
import androidx.compose.material3.rememberDatePickerState
|
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
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
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import de.jeanlucmakiola.agendula.R
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.ZoneOffset
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
private val zone: ZoneId get() = ZoneId.systemDefault()
|
|
||||||
|
|
||||||
internal fun Instant.toLocalDate(): LocalDate =
|
|
||||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
|
|
||||||
|
|
||||||
internal fun Instant.toLocalTime(): LocalTime =
|
|
||||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
|
|
||||||
|
|
||||||
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
|
|
||||||
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A labelled date(-time) field for the edit form: a tonal row showing the
|
|
||||||
* current value (or nothing), tappable to pick a date and — unless [allDay] —
|
|
||||||
* a time. A clear affordance appears once a value is set. Emits `null` when
|
|
||||||
* cleared. Styled to match the app's rounded tonal family.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun DateTimeField(
|
|
||||||
label: String,
|
|
||||||
value: Instant?,
|
|
||||||
allDay: Boolean,
|
|
||||||
onChange: (Instant?) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
var showDatePicker by remember { mutableStateOf(false) }
|
|
||||||
var showTimePicker by remember { mutableStateOf(false) }
|
|
||||||
var pendingDate by remember { mutableStateOf<LocalDate?>(null) }
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
onClick = { showDatePicker = true },
|
|
||||||
shape = RoundedCornerShape(22.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Rounded.Event, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = value?.formatDateTime(allDay) ?: stringResource(R.string.edit_set),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (value != null) {
|
|
||||||
IconButton(onClick = { onChange(null) }) {
|
|
||||||
Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDatePicker) {
|
|
||||||
val initialMillis = (value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()))
|
|
||||||
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
|
||||||
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
|
||||||
DatePickerDialog(
|
|
||||||
onDismissRequest = { showDatePicker = false },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
showDatePicker = false
|
|
||||||
val millis = dateState.selectedDateMillis ?: return@TextButton
|
|
||||||
val date = java.time.Instant.ofEpochMilli(millis)
|
|
||||||
.atZone(ZoneOffset.UTC).toLocalDate()
|
|
||||||
if (allDay) {
|
|
||||||
onChange(localToInstant(date, LocalTime.MIDNIGHT))
|
|
||||||
} else {
|
|
||||||
pendingDate = date
|
|
||||||
showTimePicker = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { Text(stringResource(android.R.string.ok)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showDatePicker = false }) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { DatePicker(state = dateState) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showTimePicker) {
|
|
||||||
val base = value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
|
||||||
val timeState = rememberTimePickerState(
|
|
||||||
initialHour = base.toLocalTime().hour,
|
|
||||||
initialMinute = base.toLocalTime().minute,
|
|
||||||
)
|
|
||||||
Dialog(onDismissRequest = { showTimePicker = false }) {
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
TimePicker(state = timeState)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
) {
|
|
||||||
TextButton(onClick = { showTimePicker = false }) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
TextButton(onClick = {
|
|
||||||
showTimePicker = false
|
|
||||||
val date = pendingDate ?: return@TextButton
|
|
||||||
onChange(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute)))
|
|
||||||
}) { Text(stringResource(android.R.string.ok)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zone helpers shared by the date/time pickers. All-day conversions live in
|
||||||
|
* [de.jeanlucmakiola.agendula.domain.AllDayTime] — these cover the timed case,
|
||||||
|
* where the device zone is the right frame of reference.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private val zone: ZoneId get() = ZoneId.systemDefault()
|
||||||
|
|
||||||
|
internal fun Instant.toLocalTime(): LocalTime =
|
||||||
|
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
|
||||||
|
|
||||||
|
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
|
||||||
|
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())
|
||||||
@@ -54,7 +54,9 @@ fun ReminderLeadPicker(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
presets: List<Int> = REMINDER_PRESETS,
|
presets: List<Int> = REMINDER_PRESETS,
|
||||||
) {
|
) {
|
||||||
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes
|
// Agendula is single-reminder: an override carries a one-element list, so
|
||||||
|
// take the single value for this single-select picker.
|
||||||
|
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes?.firstOrNull()
|
||||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||||
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
||||||
|
|
||||||
@@ -65,7 +67,7 @@ fun ReminderLeadPicker(
|
|||||||
val options = buildList {
|
val options = buildList {
|
||||||
if (allowInherit) add(ReminderOverride.Inherit)
|
if (allowInherit) add(ReminderOverride.Inherit)
|
||||||
if (allowNone) add(ReminderOverride.None)
|
if (allowNone) add(ReminderOverride.None)
|
||||||
presets.forEach { add(ReminderOverride.Minutes(it)) }
|
presets.forEach { add(ReminderOverride.Minutes(listOf(it))) }
|
||||||
}
|
}
|
||||||
val rowCount = options.size + 1 // + the custom row
|
val rowCount = options.size + 1 // + the custom row
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ fun ReminderLeadPicker(
|
|||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
onSelect(ReminderOverride.Minutes(minutes))
|
onSelect(ReminderOverride.Minutes(listOf(minutes)))
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -168,5 +170,5 @@ private fun CustomReminderEditor(
|
|||||||
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
|
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
|
||||||
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes.first())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -501,13 +501,16 @@ private fun taskWhenLines(task: Task): Pair<String, String?>? {
|
|||||||
val due = task.due
|
val due = task.due
|
||||||
return when {
|
return when {
|
||||||
start != null && due != null -> {
|
start != null && due != null -> {
|
||||||
val sameDay = start.formatDate() == due.formatDate()
|
val allDay = task.isAllDay
|
||||||
val primary = if (sameDay) due.formatDate() else "${start.formatDate()} – ${due.formatDate()}"
|
val sameDay = start.formatDate(allDay) == due.formatDate(allDay)
|
||||||
val secondary = if (task.isAllDay) null else "${start.formatTime()} – ${due.formatTime()}"
|
val primary =
|
||||||
|
if (sameDay) due.formatDate(allDay)
|
||||||
|
else "${start.formatDate(allDay)} – ${due.formatDate(allDay)}"
|
||||||
|
val secondary = if (allDay) null else "${start.formatTime()} – ${due.formatTime()}"
|
||||||
primary to secondary
|
primary to secondary
|
||||||
}
|
}
|
||||||
due != null -> due.formatDate() to if (task.isAllDay) null else due.formatTime()
|
due != null -> due.formatDate(task.isAllDay) to if (task.isAllDay) null else due.formatTime()
|
||||||
start != null -> start.formatDate() to if (task.isAllDay) null else start.formatTime()
|
start != null -> start.formatDate(task.isAllDay) to if (task.isAllDay) null else start.formatTime()
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
@@ -11,7 +12,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -42,7 +42,7 @@ class TaskDetailViewModel @Inject constructor(
|
|||||||
if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
|
if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail)
|
||||||
}
|
}
|
||||||
.onStart { emit(TaskDetailUiState.Loading) }
|
.onStart { emit(TaskDetailUiState.Loading) }
|
||||||
.catch { emit(TaskDetailUiState.NotFound) }
|
.recoveringFromProviderFailure { TaskDetailUiState.NotFound }
|
||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading)
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ import de.jeanlucmakiola.floret.time.formatTime
|
|||||||
import de.jeanlucmakiola.agendula.ui.common.localToInstant
|
import de.jeanlucmakiola.agendula.ui.common.localToInstant
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.agendula.ui.common.toLocalDate
|
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
||||||
|
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||||
import de.jeanlucmakiola.agendula.ui.common.toLocalTime
|
import de.jeanlucmakiola.agendula.ui.common.toLocalTime
|
||||||
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
@@ -178,7 +179,7 @@ private fun EditContent(
|
|||||||
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
|
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
|
var pickerTarget by rememberSaveable { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showListPicker by rememberSaveable { mutableStateOf(false) }
|
var showListPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showParentPicker by rememberSaveable { mutableStateOf(false) }
|
var showParentPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -653,7 +654,7 @@ private fun ScheduleRow(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = value.formatDate(),
|
text = value.formatDate(allDay),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = valueColor,
|
color = valueColor,
|
||||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
||||||
@@ -689,12 +690,15 @@ private fun DateTimePickerFlow(
|
|||||||
onResult: (Instant) -> Unit,
|
onResult: (Instant) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var pendingDate by remember { mutableStateOf<java.time.LocalDate?>(null) }
|
var pendingDate by rememberSaveable { mutableStateOf<java.time.LocalDate?>(null) }
|
||||||
var showTime by remember { mutableStateOf(false) }
|
var showTime by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
if (!showTime) {
|
if (!showTime) {
|
||||||
|
// M3's DatePicker speaks UTC millis. An all-day value is already UTC-based,
|
||||||
|
// a timed one is read in the device zone — calendarDate picks the right frame
|
||||||
|
// so the dialog opens on the day the rest of the UI shows.
|
||||||
val initialMillis = (initial ?: nowInstant())
|
val initialMillis = (initial ?: nowInstant())
|
||||||
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
.calendarDate(allDay).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
|
||||||
DatePickerDialog(
|
DatePickerDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -703,7 +707,7 @@ private fun DateTimePickerFlow(
|
|||||||
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
|
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
|
||||||
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
if (allDay) {
|
if (allDay) {
|
||||||
onResult(localToInstant(date, LocalTime.MIDNIGHT))
|
onResult(allDayInstantOf(date))
|
||||||
} else {
|
} else {
|
||||||
pendingDate = date
|
pendingDate = date
|
||||||
showTime = true
|
showTime = true
|
||||||
@@ -865,7 +869,7 @@ private fun ParentPickerSheet(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = task.title.ifBlank { stringResource(R.string.task_untitled) },
|
title = task.title.ifBlank { stringResource(R.string.task_untitled) },
|
||||||
position = positionOf(index, section.tasks.size),
|
position = positionOf(index, section.tasks.size),
|
||||||
summary = task.due?.formatDate(),
|
summary = task.due?.formatDate(task.isAllDay),
|
||||||
selected = task.taskId == selectedId,
|
selected = task.taskId == selectedId,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
onClick = { choose(task.taskId) },
|
onClick = { choose(task.taskId) },
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.domain.populatedFields
|
import de.jeanlucmakiola.agendula.domain.populatedFields
|
||||||
|
import de.jeanlucmakiola.agendula.domain.rebasedForAllDay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -70,6 +71,15 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
private var editingTaskId: Long? = null
|
private var editingTaskId: Long? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the form has already been populated. The host `LaunchedEffect`
|
||||||
|
* re-fires whenever the composition restarts — an Activity recreation
|
||||||
|
* (rotation, theme/font/display-size change, split-screen, unfolding) — while
|
||||||
|
* this ViewModel survives on the nav back stack. Without this guard the
|
||||||
|
* rebind would overwrite in-progress edits with the untouched provider row.
|
||||||
|
*/
|
||||||
|
private var bound = false
|
||||||
|
|
||||||
/** `last_modified` captured when the form loaded — the conflict-check baseline. */
|
/** `last_modified` captured when the form loaded — the conflict-check baseline. */
|
||||||
private var baselineLastModified: Instant? = null
|
private var baselineLastModified: Instant? = null
|
||||||
|
|
||||||
@@ -78,6 +88,8 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||||
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
|
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
|
||||||
|
if (bound) return
|
||||||
|
bound = true
|
||||||
editingTaskId = null
|
editingTaskId = null
|
||||||
baselineLastModified = null
|
baselineLastModified = null
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -103,6 +115,8 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** Load an existing task for editing. */
|
/** Load an existing task for editing. */
|
||||||
fun bindEdit(taskId: Long) {
|
fun bindEdit(taskId: Long) {
|
||||||
|
if (bound && editingTaskId == taskId) return
|
||||||
|
bound = true
|
||||||
editingTaskId = taskId
|
editingTaskId = taskId
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
defaultFields = settingsPrefs.settings.first().defaultEditFields
|
defaultFields = settingsPrefs.settings.first().defaultEditFields
|
||||||
@@ -126,6 +140,7 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
priority = task.priority,
|
priority = task.priority,
|
||||||
parentId = task.parentId,
|
parentId = task.parentId,
|
||||||
percentComplete = task.percentComplete,
|
percentComplete = task.percentComplete,
|
||||||
|
reminderMinutesBeforeDue = repository.reminderFor(taskId),
|
||||||
lists = lists,
|
lists = lists,
|
||||||
parentCandidates = loadParents(task.listId, selfId = taskId),
|
parentCandidates = loadParents(task.listId, selfId = taskId),
|
||||||
),
|
),
|
||||||
@@ -178,7 +193,19 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
|
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
|
||||||
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
|
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
|
||||||
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
fun onAllDayChange(value: Boolean) = update {
|
||||||
|
it.copy(
|
||||||
|
isAllDay = value,
|
||||||
|
start = it.start?.rebasedForAllDay(value),
|
||||||
|
due = it.due?.rebasedForAllDay(value),
|
||||||
|
)
|
||||||
|
}
|
||||||
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
|
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
|
||||||
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
|
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
|
||||||
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
|
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.floret.time.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
@@ -12,7 +13,6 @@ import de.jeanlucmakiola.agendula.domain.TaskFiltering
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -56,7 +56,7 @@ class ListsViewModel @Inject constructor(
|
|||||||
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
||||||
) { lists, openTasks, completedTasks ->
|
) { lists, openTasks, completedTasks ->
|
||||||
buildContent(lists, openTasks, completedTasks) as ListsUiState
|
buildContent(lists, openTasks, completedTasks) as ListsUiState
|
||||||
}.catch { emit(ListsUiState.Failure) }
|
}.recoveringFromProviderFailure { ListsUiState.Failure }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
||||||
|
|
||||||
private fun buildContent(
|
private fun buildContent(
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ import de.jeanlucmakiola.floret.components.positionOf
|
|||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
@@ -483,10 +484,10 @@ private fun RemindersScreen(
|
|||||||
if (showOffset) {
|
if (showOffset) {
|
||||||
ReminderLeadPicker(
|
ReminderLeadPicker(
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
selected = ReminderOverride.Minutes(state.settings.reminderLeadMinutes),
|
selected = ReminderOverride.Minutes(listOf(state.settings.reminderLeadMinutes)),
|
||||||
allowInherit = false,
|
allowInherit = false,
|
||||||
allowNone = false,
|
allowNone = false,
|
||||||
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) },
|
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes.first()) },
|
||||||
onDismiss = { showOffset = false },
|
onDismiss = { showOffset = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -504,14 +505,8 @@ private fun RemindersScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The stored override for [listId], as a picker choice (absent → inherit). */
|
/** The stored override for [listId], as a picker choice (absent → inherit). */
|
||||||
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride {
|
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride =
|
||||||
val map = state.settings.perListReminderOverride
|
state.settings.perListReminderOverride.reminderOverrideFor(listId)
|
||||||
return when {
|
|
||||||
!map.containsKey(listId) -> ReminderOverride.Inherit
|
|
||||||
map[listId] == null -> ReminderOverride.None
|
|
||||||
else -> ReminderOverride.Minutes(map.getValue(listId)!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Row summary for a list: its override, or the inherited global default. */
|
/** Row summary for a list: its override, or the inherited global default. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -519,7 +514,7 @@ private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): S
|
|||||||
ReminderOverride.Inherit ->
|
ReminderOverride.Inherit ->
|
||||||
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
||||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes.first())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import de.jeanlucmakiola.agendula.data.prefs.Settings
|
|||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -33,8 +33,14 @@ class SettingsViewModel @Inject constructor(
|
|||||||
repository: TasksRepository,
|
repository: TasksRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
// MainActivity collects this for the theme, above the permission gate and for
|
||||||
|
// the whole Activity lifetime — so the list flow must survive the pre-grant
|
||||||
|
// SecurityException and recover once permission is given, not die for good.
|
||||||
val state: StateFlow<SettingsUiState> =
|
val state: StateFlow<SettingsUiState> =
|
||||||
combine(prefs.settings, repository.taskLists().catch { emit(emptyList()) }) { settings, lists ->
|
combine(
|
||||||
|
prefs.settings,
|
||||||
|
repository.taskLists().recoveringFromProviderFailure { emptyList() },
|
||||||
|
) { settings, lists ->
|
||||||
SettingsUiState(settings, lists)
|
SettingsUiState(settings, lists)
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
@@ -12,7 +13,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
@@ -87,7 +87,10 @@ class TaskListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onStart { emit(TaskListUiState.Loading) }
|
.onStart { emit(TaskListUiState.Loading) }
|
||||||
.catch { emit(TaskListUiState.Failure) }
|
// Recover rather than terminate: a provider hiccup (mid-update,
|
||||||
|
// permission not yet granted) shows Failure but keeps retrying,
|
||||||
|
// so the screen heals itself instead of staying stuck.
|
||||||
|
.recoveringFromProviderFailure { TaskListUiState.Failure }
|
||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
|
||||||
|
|
||||||
@@ -112,6 +115,9 @@ class TaskListViewModel @Inject constructor(
|
|||||||
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
|
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Without this an exception here escapes stateIn's coroutine, past
|
||||||
|
// viewModelScope's SupervisorJob, and crashes the process.
|
||||||
|
.recoveringFromProviderFailure { emptyMap() }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||||
|
|
||||||
/** The screen reports which expanded parents need their children fetched. */
|
/** The screen reports which expanded parents need their children fetched. */
|
||||||
|
|||||||
@@ -50,6 +50,31 @@ class TaskMapperTest {
|
|||||||
assertThat(task.isSubtask).isTrue()
|
assertThat(task.isSubtask).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recurrence is detected from rrule when is_recurring is absent`() {
|
||||||
|
// tasks.org's bundled provider is DB 22 and has no `is_recurring` column;
|
||||||
|
// reading it alone would report the series as one-off and send its edits
|
||||||
|
// to the master row, re-anchoring the whole thing.
|
||||||
|
val task = TaskMapper.task(
|
||||||
|
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")),
|
||||||
|
)
|
||||||
|
assertThat(task.isRecurring).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recurrence is detected from rdate alone`() {
|
||||||
|
val task = TaskMapper.task(
|
||||||
|
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")),
|
||||||
|
)
|
||||||
|
assertThat(task.isRecurring).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a plain task is not recurring`() {
|
||||||
|
val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off")))
|
||||||
|
assertThat(task.isRecurring).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `falls back to instance id when task_id missing, and list color when no task color`() {
|
fun `falls back to instance id when task_id missing, and list color when no task color`() {
|
||||||
val task = TaskMapper.task(
|
val task = TaskMapper.task(
|
||||||
|
|||||||
@@ -85,6 +85,57 @@ class TaskWriteMapperTest {
|
|||||||
assertThat(values[Tasks.TZ]).isNull()
|
assertThat(values[Tasks.TZ]).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day timestamps are pinned to UTC midnight`() {
|
||||||
|
// 2026-07-20T22:00Z — i.e. local midnight on the 21st in Berlin (UTC+2).
|
||||||
|
// The provider resolves all-day dates against UTC, so storing this as-is
|
||||||
|
// would land the task on the 20th for anyone reading it back.
|
||||||
|
val berlinMidnight = Instant.fromEpochMilliseconds(1_784_412_000_000L)
|
||||||
|
val values = TaskWriteMapper.taskValues(
|
||||||
|
TaskForm(title = "Holiday", listId = 1L, start = berlinMidnight, due = berlinMidnight, isAllDay = true),
|
||||||
|
tzId = "Europe/Berlin",
|
||||||
|
)
|
||||||
|
|
||||||
|
val dayMs = 24L * 60 * 60 * 1000
|
||||||
|
assertThat(values[Tasks.DUE] as Long % dayMs).isEqualTo(0L)
|
||||||
|
assertThat(values[Tasks.DTSTART] as Long % dayMs).isEqualTo(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed timestamps are written untouched`() {
|
||||||
|
val at = Instant.fromEpochMilliseconds(1_784_412_345_678L)
|
||||||
|
val values = TaskWriteMapper.taskValues(
|
||||||
|
TaskForm(title = "Standup", listId = 1L, start = at, due = at),
|
||||||
|
tzId = "Europe/Berlin",
|
||||||
|
)
|
||||||
|
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L)
|
||||||
|
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duration is always cleared so it cannot collide with due`() {
|
||||||
|
// The provider validates the *merged* row and throws "Only one of DUE or
|
||||||
|
// DURATION must be supplied" if the stored row still carries a duration.
|
||||||
|
val values = TaskWriteMapper.taskValues(
|
||||||
|
TaskForm(title = "x", listId = 1L, due = Instant.fromEpochMilliseconds(5_000L)),
|
||||||
|
tzId = "UTC",
|
||||||
|
)
|
||||||
|
assertThat(values.containsKey(Tasks.DURATION)).isTrue()
|
||||||
|
assertThat(values[Tasks.DURATION]).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instance values drop list and parent, which an override cannot express`() {
|
||||||
|
val form = TaskForm(title = "x", listId = 4L, parentId = 7L, due = Instant.fromEpochMilliseconds(1_000L))
|
||||||
|
val values = TaskWriteMapper.instanceValues(form, tzId = "UTC")
|
||||||
|
|
||||||
|
assertThat(values.containsKey(Tasks.LIST_ID)).isFalse()
|
||||||
|
assertThat(values.containsKey(Tasks.PARENT_ID)).isFalse()
|
||||||
|
// …but still carries the edit itself.
|
||||||
|
assertThat(values[Tasks.TITLE]).isEqualTo("x")
|
||||||
|
assertThat(values[Tasks.DUE]).isEqualTo(1_000L)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `completion sets status, percent and timestamp, un-completion clears them`() {
|
fun `completion sets status, percent and timestamp, un-completion clears them`() {
|
||||||
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
|
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
|
||||||
@@ -97,6 +148,22 @@ class TaskWriteMapperTest {
|
|||||||
assertThat(undone[Tasks.COMPLETED]).isNull()
|
assertThat(undone[Tasks.COMPLETED]).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `alarm carries every column the provider's validator demands`() {
|
||||||
|
val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30)
|
||||||
|
|
||||||
|
assertThat(values[TasksContract.Properties.TASK_ID]).isEqualTo(12L)
|
||||||
|
assertThat(values[TasksContract.Properties.MIMETYPE])
|
||||||
|
.isEqualTo("vnd.android.cursor.item/alarm")
|
||||||
|
assertThat(values[TasksContract.Alarm.MINUTES_BEFORE]).isEqualTo(30)
|
||||||
|
// REFERENCE must be present and non-negative, ALARM_TYPE present and
|
||||||
|
// non-zero (0 is excluded from the provider's has_alarms count).
|
||||||
|
assertThat(values[TasksContract.Alarm.REFERENCE]).isEqualTo(TasksContract.Alarm.REFERENCE_DUE)
|
||||||
|
assertThat(values[TasksContract.Alarm.ALARM_TYPE]).isEqualTo(TasksContract.Alarm.TYPE_MESSAGE)
|
||||||
|
// property_id must be absent or the insert is rejected.
|
||||||
|
assertThat(values.containsKey(TasksContract.Properties.PROPERTY_ID)).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `local list uses the LOCAL account`() {
|
fun `local list uses the LOCAL account`() {
|
||||||
val values = TaskWriteMapper.localListValues("Inbox", 0x123)
|
val values = TaskWriteMapper.localListValues("Inbox", 0x123)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class AllDayTimeTest {
|
||||||
|
|
||||||
|
private val berlin = ZoneId.of("Europe/Berlin") // UTC+2 in July
|
||||||
|
private val newYork = ZoneId.of("America/New_York") // UTC-4 in July
|
||||||
|
private val julyTwentieth = LocalDate.of(2026, 7, 20)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day instant is UTC midnight of its date`() {
|
||||||
|
val instant = allDayInstantOf(julyTwentieth)
|
||||||
|
assertThat(instant.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0L)
|
||||||
|
assertThat(instant.calendarDate(allDay = true)).isEqualTo(julyTwentieth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day date reads the same everywhere, unlike a timed one`() {
|
||||||
|
val allDay = allDayInstantOf(julyTwentieth)
|
||||||
|
// The whole point: zone must not change which day an all-day value denotes.
|
||||||
|
assertThat(allDay.calendarDate(allDay = true, zone = berlin)).isEqualTo(julyTwentieth)
|
||||||
|
assertThat(allDay.calendarDate(allDay = true, zone = newYork)).isEqualTo(julyTwentieth)
|
||||||
|
// Read as a timed value in New York it would slip to the 19th — the bug.
|
||||||
|
assertThat(allDay.calendarDate(allDay = false, zone = newYork)).isEqualTo(julyTwentieth.minusDays(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggling all-day off keeps the day and lands on local midnight`() {
|
||||||
|
val allDay = allDayInstantOf(julyTwentieth)
|
||||||
|
val timed = allDay.rebasedForAllDay(allDay = false, zone = berlin)
|
||||||
|
|
||||||
|
assertThat(timed.calendarDate(allDay = false, zone = berlin)).isEqualTo(julyTwentieth)
|
||||||
|
val local = java.time.Instant.ofEpochMilli(timed.toEpochMilliseconds()).atZone(berlin)
|
||||||
|
assertThat(local.toLocalTime()).isEqualTo(java.time.LocalTime.MIDNIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggling all-day on keeps the day the user was looking at`() {
|
||||||
|
// 2026-07-20T23:30 in Berlin — late enough that a naive UTC read slips a day.
|
||||||
|
val lateEvening = Instant.fromEpochMilliseconds(
|
||||||
|
julyTwentieth.atTime(23, 30).atZone(berlin).toInstant().toEpochMilli(),
|
||||||
|
)
|
||||||
|
val allDay = lateEvening.rebasedForAllDay(allDay = true, zone = berlin)
|
||||||
|
|
||||||
|
assertThat(allDay.calendarDate(allDay = true)).isEqualTo(julyTwentieth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-tripping the toggle is stable`() {
|
||||||
|
val original = allDayInstantOf(julyTwentieth)
|
||||||
|
val there = original.rebasedForAllDay(allDay = false, zone = newYork)
|
||||||
|
val back = there.rebasedForAllDay(allDay = true, zone = newYork)
|
||||||
|
assertThat(back).isEqualTo(original)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
fastlane/metadata/android/en-US/changelogs/302.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/302.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
### Fixed
|
||||||
|
- Releases reach the Codeberg download channel again. 0.3.1 published to
|
||||||
|
F-Droid but never appeared on Codeberg, so if you install from there — or
|
||||||
|
through Obtainium — this is the release that finally carries 0.3.0's
|
||||||
|
launch-crash fix. The app itself is unchanged from 0.3.1.
|
||||||
|
|
||||||
Submodule floret-kit updated: 566caf4305...396e538903
5
release-notes.md
Normal file
5
release-notes.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
### Fixed
|
||||||
|
- Agendula no longer crashes on launch. Every 0.3.0 install was affected: the
|
||||||
|
release build stripped a constructor that the background-work scheduler needs
|
||||||
|
to open its database, and that happens before the app draws anything.
|
||||||
|
|
||||||
Reference in New Issue
Block a user