Fix remaining known limitations: recurrence editing, subtask tree parity, conflict resolution
- Recurrence picker (Daily/Weekly/Monthly/Yearly, interval, end condition) for both events and tasks, backed by a real RRULE. Fixes a latent bug from the prior RRULE-preservation change: editing a recurring item was writing the resolved *next occurrence* back as the master DTSTART, silently shifting the whole series forward on every edit - start/end/due are now correctly read-only for recurring items instead. - Periodic background sync (WorkManager, ~30 min, network-gated) so widgets stay fresh without opening the app; auto-refresh on app open too. - Tasks widget now renders the same parent/child tree as the in-app Tasks screen (util/TaskTree.kt shared by both) instead of a flat due-date list. - Conflict resolution: a stale save (HTTP 412) shows both versions and lets you keep your edit or take theirs, instead of just an error banner. - First test suite for the project (IcsMapperTest, TaskTreeTest).
This commit is contained in:
parent
75f5e6024b
commit
14ee70d71d
|
|
@ -4,3 +4,6 @@
|
||||||
# Release signing - never commit these (see ncal/.gitignore for the enforced copy)
|
# Release signing - never commit these (see ncal/.gitignore for the enforced copy)
|
||||||
/ncal/keystore/
|
/ncal/keystore/
|
||||||
/ncal/keystore.properties
|
/ncal/keystore.properties
|
||||||
|
|
||||||
|
# Built APKs don't belong in source control
|
||||||
|
*.apk
|
||||||
|
|
|
||||||
56
README.md
56
README.md
|
|
@ -51,7 +51,14 @@ is the fourth option: one app, one account, one sync engine.
|
||||||
- **Subtasks** — assign any task a parent task (stored as the standard iCalendar
|
- **Subtasks** — assign any task a parent task (stored as the standard iCalendar
|
||||||
`RELATED-TO` property, so it round-trips with Nextcloud's own Tasks app and other
|
`RELATED-TO` property, so it round-trips with Nextcloud's own Tasks app and other
|
||||||
CalDAV clients). The parent picker excludes the task itself and its own descendants
|
CalDAV clients). The parent picker excludes the task itself and its own descendants
|
||||||
to prevent cycles.
|
to prevent cycles. The Tasks screen *and* the Tasks widget both render the real
|
||||||
|
parent/child tree (`util/TaskTree.kt` is shared by both).
|
||||||
|
- **Recurrence** — a Repeats picker (Daily/Weekly/Monthly/Yearly, interval, and an end
|
||||||
|
condition of never/after N times/on a date) for both events and tasks, backed by a
|
||||||
|
real iCal `RRULE`.
|
||||||
|
- **Conflict handling** — a stale save (someone else changed or deleted the item since
|
||||||
|
you opened it) surfaces both versions in a dialog and lets you keep your edit or
|
||||||
|
take theirs, instead of just failing with an error.
|
||||||
- **Reminders** — add one or more reminders to any event or task (at the scheduled
|
- **Reminders** — add one or more reminders to any event or task (at the scheduled
|
||||||
time, or a preset offset before it: 5 min up to 1 week). Reminders fire as real
|
time, or a preset offset before it: 5 min up to 1 week). Reminders fire as real
|
||||||
system notifications via `AlarmManager` exact alarms — not a best-effort background
|
system notifications via `AlarmManager` exact alarms — not a best-effort background
|
||||||
|
|
@ -85,10 +92,11 @@ compiles down to plain `RemoteViews`/`AppWidgetProvider` — no Google dependenc
|
||||||
each row (color-coded by priority) and tap-to-open on the row itself.
|
each row (color-coded by priority) and tap-to-open on the row itself.
|
||||||
|
|
||||||
Both widgets refresh automatically whenever you sync, save, or delete something in
|
Both widgets refresh automatically whenever you sync, save, or delete something in
|
||||||
the app — no manual refresh needed. Note this means a widget's content is only as
|
the app, and independently of the app being open at all: a `WorkManager` periodic job
|
||||||
fresh as the last time the app itself synced (on open, on manual pull-to-refresh, or
|
(`sync/SyncScheduler.kt`, `sync/SyncWorker.kt`) calls `repository.fullSync()` roughly
|
||||||
after an edit) — the widgets read from the local cache on their own ~30-minute OS
|
every 30 minutes whenever the device has network - matching the widgets' own OS-level
|
||||||
redraw timer, they don't independently hit the network.
|
redraw floor, since syncing more often than a widget can even show an update buys
|
||||||
|
nothing.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -127,24 +135,26 @@ redraw timer, they don't independently hit the network.
|
||||||
6. Optional: long-press your home screen → Widgets → NCal, then add "Calendar Month"
|
6. Optional: long-press your home screen → Widgets → NCal, then add "Calendar Month"
|
||||||
and/or "Tasks".
|
and/or "Tasks".
|
||||||
|
|
||||||
|
Run the (small, growing) JVM unit test suite with `./gradlew test` — no emulator
|
||||||
|
needed.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- **Editing an existing recurring item drops its `RRULE`.** Saving rebuilds the
|
- **No recurrence editing for a recurring item's start/end time.** You can set/change
|
||||||
`.ics` from the edit form's fields, so an edited recurring item becomes a one-off.
|
*whether* something repeats and how (Daily/Weekly/Monthly/Yearly, interval, end
|
||||||
Viewing/completing/deleting a recurring item is safe; editing its fields is not,
|
condition) via the Repeats picker, and edit all of a recurring item's other fields
|
||||||
if you want the series to survive the save. `CalendarItem.rawIcs` retains the
|
freely - but the Starts/Ends/Due date fields become read-only the moment an item is
|
||||||
original document for exactly this reason — wiring `toIcs()` to mutate the parsed
|
recurring, because [CalendarItem.start]/`end`/`due` hold the *resolved next
|
||||||
original in place is the natural fix if this turns out to matter in practice.
|
occurrence* for a recurring item, not the master date, and writing that back would
|
||||||
- **No recurrence-editing UI** — no picker to *set* a recurrence rule on a new item.
|
silently shift the whole series forward on every edit. Change the actual start date
|
||||||
- **Subtask tree is app-side only.** The Month view and both widgets show tasks
|
of a recurring series in Nextcloud's web UI. A rule the picker can't represent
|
||||||
flat/chronological, not nested — only the in-app Tasks screen renders the parent/
|
(`BYDAY`/`BYMONTH`/etc, or anything outside Daily/Weekly/Monthly/Yearly) is left
|
||||||
child tree.
|
completely untouched rather than flattened into something simpler. Both guarantees
|
||||||
- **No background sync while the app is closed.** Sync happens on app open, manual
|
are regression-tested in `IcsMapperTest`.
|
||||||
pull-to-refresh, or after an edit — there's no periodic `WorkManager` job calling
|
- **Conflict resolution is a two-choice dialog, not a field-by-field merge.** A stale
|
||||||
`repository.fullSync()` yet, so a widget can go stale if you never open the app.
|
write (HTTP 412 - the item changed or was deleted elsewhere since you opened it)
|
||||||
- **Last-write-wins conflict handling.** A genuinely stale write fails loudly (a
|
shows both versions and lets you keep your edit or take theirs; there's no way to
|
||||||
`CalDavException` with the HTTP code) rather than silently clobbering a change made
|
merge individual fields from each.
|
||||||
elsewhere — but there's no merge UI, just an error surfaced to the edit screen.
|
|
||||||
|
|
||||||
## Where to look first if you want to extend it
|
## Where to look first if you want to extend it
|
||||||
|
|
||||||
|
|
@ -156,6 +166,10 @@ redraw timer, they don't independently hit the network.
|
||||||
- `data/repository/NextcloudRepository.kt` — the sync/cache/reminder/widget
|
- `data/repository/NextcloudRepository.kt` — the sync/cache/reminder/widget
|
||||||
orchestration.
|
orchestration.
|
||||||
- `notifications/ReminderScheduler.kt` — reminder alarm scheduling.
|
- `notifications/ReminderScheduler.kt` — reminder alarm scheduling.
|
||||||
|
- `sync/SyncWorker.kt` / `sync/SyncScheduler.kt` — the periodic background sync job.
|
||||||
|
- `util/TaskTree.kt` — the parent/child ordering shared by the Tasks screen and widget.
|
||||||
|
- `util/RecurrenceUtils.kt` — re-dates a recurring item to its next occurrence for display.
|
||||||
|
- `data/repository/SaveConflictException.kt` — the 412-conflict data carried to the edit screen's dialog.
|
||||||
- `widget/` — the two Glance home screen widgets.
|
- `widget/` — the two Glance home screen widgets.
|
||||||
|
|
||||||
## Building a signed release
|
## Building a signed release
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,15 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
// IcsMapper.parse() logs via android.util.Log, which throws "not mocked" in a plain
|
||||||
|
// JVM unit test otherwise - return defaults instead of pulling in Robolectric just
|
||||||
|
// for that.
|
||||||
|
isReturnDefaultValues = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
|
@ -107,4 +116,6 @@ dependencies {
|
||||||
|
|
||||||
// Encrypts the local Room cache at rest (calendar/task content), same as credentials already are
|
// Encrypts the local Room cache at rest (calendar/task content), same as credentials already are
|
||||||
implementation("net.zetetic:android-database-sqlcipher:4.5.4")
|
implementation("net.zetetic:android-database-sqlcipher:4.5.4")
|
||||||
|
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ class NcalApplication : Application() {
|
||||||
createReminderNotificationChannel()
|
createReminderNotificationChannel()
|
||||||
|
|
||||||
if (repository.isLoggedIn) {
|
if (repository.isLoggedIn) {
|
||||||
|
repository.scheduleBackgroundSync()
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
ReminderScheduler.rescheduleAll(this@NcalApplication, repository.allCachedItems())
|
ReminderScheduler.rescheduleAll(this@NcalApplication, repository.allCachedItems())
|
||||||
MonthGridWidget().updateAll(this@NcalApplication)
|
MonthGridWidget().updateAll(this@NcalApplication)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import net.sqlcipher.database.SupportFactory
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [CollectionEntity::class, ItemEntity::class],
|
entities = [CollectionEntity::class, ItemEntity::class],
|
||||||
version = 4,
|
version = 5,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@ data class ItemEntity(
|
||||||
val priority: Int,
|
val priority: Int,
|
||||||
val parentUid: String?,
|
val parentUid: String?,
|
||||||
val reminderMinutes: String, // comma-separated minutes-before list, "" if none
|
val reminderMinutes: String, // comma-separated minutes-before list, "" if none
|
||||||
|
val recurrenceFrequency: String?, // RecurrenceFrequency name, null if not recurring/unsupported rule
|
||||||
|
val recurrenceInterval: Int,
|
||||||
|
val recurrenceCount: Int?,
|
||||||
|
val recurrenceUntil: Long?,
|
||||||
|
val isRecurring: Boolean,
|
||||||
val rawIcs: String?
|
val rawIcs: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,15 @@ enum class ItemType { EVENT, TASK }
|
||||||
/** Mirrors the iCalendar VTODO STATUS values (tasks only). */
|
/** Mirrors the iCalendar VTODO STATUS values (tasks only). */
|
||||||
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
|
enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
|
||||||
|
|
||||||
|
/** The RRULE FREQ values the app's recurrence picker and [com.homelab.ncal.util.RecurrenceUtils]
|
||||||
|
* support. Anything else (BYDAY/BYMONTH/etc-based rules, or SECONDLY/MINUTELY/HOURLY) still
|
||||||
|
* round-trips fine via [CalendarItem.rawIcs] - it just isn't editable through the picker. */
|
||||||
|
enum class RecurrenceFrequency { DAILY, WEEKLY, MONTHLY, YEARLY }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single VEVENT or VTODO. Times are stored as epoch millis (UTC).
|
* A single VEVENT or VTODO. Times are stored as epoch millis (UTC).
|
||||||
* [rawIcs] retains the full round-tripped iCalendar object so fields we don't
|
* [rawIcs] retains the full round-tripped iCalendar object so fields we don't
|
||||||
* explicitly model (RRULE, alarms, attendees, etc.) survive an edit-and-save cycle.
|
* explicitly model (attendees, X- properties, etc.) survive an edit-and-save cycle.
|
||||||
*/
|
*/
|
||||||
data class CalendarItem(
|
data class CalendarItem(
|
||||||
val uid: String,
|
val uid: String,
|
||||||
|
|
@ -34,6 +39,14 @@ data class CalendarItem(
|
||||||
val parentUid: String? = null, // tasks only: UID of the parent task (iCal RELATED-TO)
|
val parentUid: String? = null, // tasks only: UID of the parent task (iCal RELATED-TO)
|
||||||
val reminderMinutes: List<Int> = emptyList(), // minutes before start (events) / due (tasks) to notify
|
val reminderMinutes: List<Int> = emptyList(), // minutes before start (events) / due (tasks) to notify
|
||||||
|
|
||||||
|
val recurrenceFrequency: RecurrenceFrequency? = null, // null if not recurring, or a rule the picker can't represent
|
||||||
|
val recurrenceInterval: Int = 1, // "every N days/weeks/months/years"
|
||||||
|
val recurrenceCount: Int? = null, // mutually exclusive with recurrenceUntil
|
||||||
|
val recurrenceUntil: Long? = null, // mutually exclusive with recurrenceCount
|
||||||
|
val isRecurring: Boolean = false, // true for ANY RRULE, even ones recurrenceFrequency can't represent -
|
||||||
|
// [start]/[end]/[due] are the resolved *next occurrence* when true, not
|
||||||
|
// the master date, so the edit screen can't let those be edited here
|
||||||
|
|
||||||
val rawIcs: String? = null
|
val rawIcs: String? = null
|
||||||
) {
|
) {
|
||||||
val isNew: Boolean get() = href.isEmpty()
|
val isNew: Boolean get() = href.isEmpty()
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,27 @@ class CalDavClient(
|
||||||
return executeRawEtag(builder.build(), expectBodyOnError = true)
|
return executeRawEtag(builder.build(), expectBodyOnError = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the current server copy of a single item - used for conflict resolution after a
|
||||||
|
* 412 on [updateItem]. Returns null if the item no longer exists on the server (404), which
|
||||||
|
* is itself meaningful (deleted elsewhere) rather than an error.
|
||||||
|
*/
|
||||||
|
fun getItem(href: String): Pair<String, String?>? {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(href)
|
||||||
|
.header("Authorization", authHeader)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
http.newCall(request).execute().use { resp ->
|
||||||
|
if (resp.code == 404) return null
|
||||||
|
if (!resp.isSuccessful) {
|
||||||
|
throw CalDavException("HTTP ${resp.code} for GET ${request.url}: ${resp.message}", resp.code)
|
||||||
|
}
|
||||||
|
val body = resp.body?.string() ?: return null
|
||||||
|
return body to resp.header("ETag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun deleteItem(href: String, etag: String?) {
|
fun deleteItem(href: String, etag: String?) {
|
||||||
val builder = Request.Builder()
|
val builder = Request.Builder()
|
||||||
.url(href)
|
.url(href)
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,13 @@ import com.homelab.ncal.data.db.ItemEntity
|
||||||
import com.homelab.ncal.data.model.CalendarCollection
|
import com.homelab.ncal.data.model.CalendarCollection
|
||||||
import com.homelab.ncal.data.model.CalendarItem
|
import com.homelab.ncal.data.model.CalendarItem
|
||||||
import com.homelab.ncal.data.model.ItemType
|
import com.homelab.ncal.data.model.ItemType
|
||||||
|
import com.homelab.ncal.data.model.RecurrenceFrequency
|
||||||
import com.homelab.ncal.data.model.TaskStatus
|
import com.homelab.ncal.data.model.TaskStatus
|
||||||
import com.homelab.ncal.data.network.CalDavClient
|
import com.homelab.ncal.data.network.CalDavClient
|
||||||
|
import com.homelab.ncal.data.network.CalDavException
|
||||||
import com.homelab.ncal.data.prefs.SecurePrefs
|
import com.homelab.ncal.data.prefs.SecurePrefs
|
||||||
import com.homelab.ncal.notifications.ReminderScheduler
|
import com.homelab.ncal.notifications.ReminderScheduler
|
||||||
|
import com.homelab.ncal.sync.SyncScheduler
|
||||||
import com.homelab.ncal.util.IcsMapper
|
import com.homelab.ncal.util.IcsMapper
|
||||||
import com.homelab.ncal.widget.MonthGridWidget
|
import com.homelab.ncal.widget.MonthGridWidget
|
||||||
import com.homelab.ncal.widget.NextTasksWidget
|
import com.homelab.ncal.widget.NextTasksWidget
|
||||||
|
|
@ -45,6 +48,12 @@ class NextcloudRepository(context: Context) {
|
||||||
|
|
||||||
fun logout() {
|
fun logout() {
|
||||||
prefs.clear()
|
prefs.clear()
|
||||||
|
SyncScheduler.cancel(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts (or confirms) the ~30-minute periodic background sync - see [SyncScheduler]. */
|
||||||
|
fun scheduleBackgroundSync() {
|
||||||
|
SyncScheduler.schedule(appContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Collections ----------
|
// ---------- Collections ----------
|
||||||
|
|
@ -144,17 +153,34 @@ class NextcloudRepository(context: Context) {
|
||||||
syncItems()
|
syncItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates or updates an item both on the server and in the local cache. */
|
/**
|
||||||
|
* Creates or updates an item both on the server and in the local cache.
|
||||||
|
*
|
||||||
|
* @throws SaveConflictException if the server rejects the write with HTTP 412 (the item was
|
||||||
|
* changed or deleted elsewhere since this edit started) - carries the server's current
|
||||||
|
* version so the caller can offer the user a real choice instead of just an error.
|
||||||
|
*/
|
||||||
suspend fun save(item: CalendarItem): CalendarItem = withContext(Dispatchers.IO) {
|
suspend fun save(item: CalendarItem): CalendarItem = withContext(Dispatchers.IO) {
|
||||||
val c = client()
|
val c = client()
|
||||||
val ics = IcsMapper.toIcs(item)
|
val ics = IcsMapper.toIcs(item)
|
||||||
|
|
||||||
val saved = if (item.isNew) {
|
val saved = try {
|
||||||
val (href, etag) = c.createItem(item.calendarUrl, ics, item.uid)
|
if (item.isNew) {
|
||||||
item.copy(href = href, etag = etag, rawIcs = ics)
|
val (href, etag) = c.createItem(item.calendarUrl, ics, item.uid)
|
||||||
} else {
|
item.copy(href = href, etag = etag, rawIcs = ics)
|
||||||
val etag = c.updateItem(item.href, ics, item.etag)
|
} else {
|
||||||
item.copy(etag = etag, rawIcs = ics)
|
val etag = c.updateItem(item.href, ics, item.etag)
|
||||||
|
item.copy(etag = etag, rawIcs = ics)
|
||||||
|
}
|
||||||
|
} catch (e: CalDavException) {
|
||||||
|
if (e.httpCode == 412 && !item.isNew) {
|
||||||
|
val serverCopy = c.getItem(item.href)
|
||||||
|
val serverVersion = serverCopy?.let { (serverIcs, serverEtag) ->
|
||||||
|
IcsMapper.parse(serverIcs, item.href, serverEtag, item.calendarUrl)
|
||||||
|
}
|
||||||
|
throw SaveConflictException(localEdit = item, serverVersion = serverVersion, cause = e)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
db.itemDao().upsert(saved.toEntity())
|
db.itemDao().upsert(saved.toEntity())
|
||||||
ReminderScheduler.reschedule(appContext, saved)
|
ReminderScheduler.reschedule(appContext, saved)
|
||||||
|
|
@ -192,7 +218,10 @@ class NextcloudRepository(context: Context) {
|
||||||
start = start, end = end, allDay = allDay, due = due, completed = completed,
|
start = start, end = end, allDay = allDay, due = due, completed = completed,
|
||||||
status = runCatching { TaskStatus.valueOf(status) }.getOrDefault(TaskStatus.NEEDS_ACTION),
|
status = runCatching { TaskStatus.valueOf(status) }.getOrDefault(TaskStatus.NEEDS_ACTION),
|
||||||
percentComplete = percentComplete, priority = priority,
|
percentComplete = percentComplete, priority = priority,
|
||||||
parentUid = parentUid, reminderMinutes = reminderMinutes.toMinutesList(), rawIcs = rawIcs
|
parentUid = parentUid, reminderMinutes = reminderMinutes.toMinutesList(),
|
||||||
|
recurrenceFrequency = recurrenceFrequency?.let { runCatching { RecurrenceFrequency.valueOf(it) }.getOrNull() },
|
||||||
|
recurrenceInterval = recurrenceInterval, recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil,
|
||||||
|
isRecurring = isRecurring, rawIcs = rawIcs
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun CalendarItem.toEntity() = ItemEntity(
|
private fun CalendarItem.toEntity() = ItemEntity(
|
||||||
|
|
@ -200,7 +229,9 @@ class NextcloudRepository(context: Context) {
|
||||||
summary = summary, description = description, location = location, url = url,
|
summary = summary, description = description, location = location, url = url,
|
||||||
start = start, end = end, allDay = allDay, due = due, completed = completed,
|
start = start, end = end, allDay = allDay, due = due, completed = completed,
|
||||||
status = status.name, percentComplete = percentComplete,
|
status = status.name, percentComplete = percentComplete,
|
||||||
priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(), rawIcs = rawIcs
|
priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(),
|
||||||
|
recurrenceFrequency = recurrenceFrequency?.name, recurrenceInterval = recurrenceInterval,
|
||||||
|
recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil, isRecurring = isRecurring, rawIcs = rawIcs
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun String.toMinutesList(): List<Int> =
|
private fun String.toMinutesList(): List<Int> =
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.homelab.ncal.data.repository
|
||||||
|
|
||||||
|
import com.homelab.ncal.data.model.CalendarItem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by [NextcloudRepository.save] when the server rejects a PUT with HTTP 412 (the item was
|
||||||
|
* changed - or deleted - elsewhere since this edit started). Carries both versions so the UI can
|
||||||
|
* offer a real choice instead of just an error banner.
|
||||||
|
*/
|
||||||
|
class SaveConflictException(
|
||||||
|
val localEdit: CalendarItem,
|
||||||
|
val serverVersion: CalendarItem?, // null if the item was deleted on the server
|
||||||
|
cause: Throwable
|
||||||
|
) : Exception(cause)
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.homelab.ncal.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules [SyncWorker] to run roughly every 30 minutes - matches the widgets' own OS-level
|
||||||
|
* redraw floor (`updatePeriodMillis`), since syncing more often than a widget can even show an
|
||||||
|
* update buys nothing.
|
||||||
|
*/
|
||||||
|
object SyncScheduler {
|
||||||
|
private const val WORK_NAME = "ncal_periodic_sync"
|
||||||
|
|
||||||
|
fun schedule(context: Context) {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
val request = PeriodicWorkRequestBuilder<SyncWorker>(30, TimeUnit.MINUTES)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(context: Context) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.homelab.ncal.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.homelab.ncal.NcalApplication
|
||||||
|
|
||||||
|
/** Periodic background sync so widgets/reminders stay fresh even if the app is never opened. */
|
||||||
|
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val repo = (applicationContext as NcalApplication).repository
|
||||||
|
if (!repo.isLoggedIn) return Result.success()
|
||||||
|
return try {
|
||||||
|
repo.fullSync()
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("NCalSyncWorker", "Periodic background sync failed", e)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
|
@ -45,8 +47,11 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.homelab.ncal.data.model.CalendarItem
|
||||||
import com.homelab.ncal.data.model.ItemType
|
import com.homelab.ncal.data.model.ItemType
|
||||||
|
import com.homelab.ncal.data.model.RecurrenceFrequency
|
||||||
import com.homelab.ncal.data.model.TaskStatus
|
import com.homelab.ncal.data.model.TaskStatus
|
||||||
import com.homelab.ncal.util.priorityColor
|
import com.homelab.ncal.util.priorityColor
|
||||||
import com.homelab.ncal.util.priorityLabel
|
import com.homelab.ncal.util.priorityLabel
|
||||||
|
|
@ -141,15 +146,23 @@ fun ItemEditScreen(
|
||||||
|
|
||||||
if (item.type == ItemType.EVENT) {
|
if (item.type == ItemType.EVENT) {
|
||||||
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
|
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
|
||||||
DateTimePicker(
|
if (item.isRecurring) {
|
||||||
millis = item.start,
|
RecurringDateNotice(item.start)
|
||||||
onChange = { v -> viewModel.update { it.copy(start = v) } }
|
} else {
|
||||||
)
|
DateTimePicker(
|
||||||
|
millis = item.start,
|
||||||
|
onChange = { v -> viewModel.update { it.copy(start = v) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
|
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
|
||||||
DateTimePicker(
|
if (item.isRecurring) {
|
||||||
millis = item.end,
|
RecurringDateNotice(item.end)
|
||||||
onChange = { v -> viewModel.update { it.copy(end = v) } }
|
} else {
|
||||||
)
|
DateTimePicker(
|
||||||
|
millis = item.end,
|
||||||
|
onChange = { v -> viewModel.update { it.copy(end = v) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|
@ -163,15 +176,23 @@ fun ItemEditScreen(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
|
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
|
||||||
DateTimePicker(
|
if (item.isRecurring) {
|
||||||
millis = item.start,
|
RecurringDateNotice(item.start)
|
||||||
onChange = { v -> viewModel.update { it.copy(start = v) } }
|
} else {
|
||||||
)
|
DateTimePicker(
|
||||||
|
millis = item.start,
|
||||||
|
onChange = { v -> viewModel.update { it.copy(start = v) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
Text("Due", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
|
Text("Due", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
|
||||||
DateTimePicker(
|
if (item.isRecurring) {
|
||||||
millis = item.due,
|
RecurringDateNotice(item.due)
|
||||||
onChange = { v -> viewModel.update { it.copy(due = v) } }
|
} else {
|
||||||
)
|
DateTimePicker(
|
||||||
|
millis = item.due,
|
||||||
|
onChange = { v -> viewModel.update { it.copy(due = v) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Status",
|
"Status",
|
||||||
|
|
@ -220,6 +241,16 @@ fun ItemEditScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Repeats",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
|
||||||
|
)
|
||||||
|
RecurrencePicker(
|
||||||
|
item = item,
|
||||||
|
onUpdate = viewModel::update
|
||||||
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Reminders",
|
"Reminders",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
|
@ -288,6 +319,39 @@ fun ItemEditScreen(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.conflict?.let { conflict ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {}, // force an explicit choice - this isn't safely dismissible
|
||||||
|
title = { Text("This ${if (item.type == ItemType.TASK) "task" else "event"} changed elsewhere") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
if (conflict.serverVersion != null) {
|
||||||
|
"It was edited somewhere else (another device, or Nextcloud's own web UI) since you opened it here."
|
||||||
|
} else {
|
||||||
|
"It was deleted somewhere else since you opened it here."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Your version: “${conflict.localEdit.summary}”",
|
||||||
|
modifier = Modifier.padding(top = 12.dp)
|
||||||
|
)
|
||||||
|
conflict.serverVersion?.let {
|
||||||
|
Text("Their version: “${it.summary}”", modifier = Modifier.padding(top = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = viewModel::keepMyChanges) { Text("Keep my changes") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = viewModel::discardMyChanges) {
|
||||||
|
Text(if (conflict.serverVersion != null) "Use their version" else "Discard mine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -380,6 +444,175 @@ private fun PriorityPicker(selected: Int, onSelect: (Int) -> Unit) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecurringDateNotice(millis: Long?) {
|
||||||
|
val display = millis?.let { SimpleDateFormat("EEE, MMM d yyyy h:mm a", Locale.getDefault()).format(Date(it)) } ?: "Not set"
|
||||||
|
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||||
|
Text(display)
|
||||||
|
Text(
|
||||||
|
"This is a recurring item - changing when the series starts isn't supported here yet. Edit it in Nextcloud's web UI if you need to.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val recurrenceFrequencyLabels = linkedMapOf<RecurrenceFrequency?, String>(
|
||||||
|
null to "Does not repeat",
|
||||||
|
RecurrenceFrequency.DAILY to "Daily",
|
||||||
|
RecurrenceFrequency.WEEKLY to "Weekly",
|
||||||
|
RecurrenceFrequency.MONTHLY to "Monthly",
|
||||||
|
RecurrenceFrequency.YEARLY to "Yearly"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun intervalUnitLabel(freq: RecurrenceFrequency, interval: Int): String {
|
||||||
|
val plural = interval != 1
|
||||||
|
return when (freq) {
|
||||||
|
RecurrenceFrequency.DAILY -> if (plural) "days" else "day"
|
||||||
|
RecurrenceFrequency.WEEKLY -> if (plural) "weeks" else "week"
|
||||||
|
RecurrenceFrequency.MONTHLY -> if (plural) "months" else "month"
|
||||||
|
RecurrenceFrequency.YEARLY -> if (plural) "years" else "year"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun RecurrencePicker(
|
||||||
|
item: CalendarItem,
|
||||||
|
onUpdate: ((CalendarItem) -> CalendarItem) -> Unit
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = it },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = recurrenceFrequencyLabels[item.recurrenceFrequency] ?: "Does not repeat",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
recurrenceFrequencyLabels.forEach { (freq, label) ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(label) },
|
||||||
|
onClick = {
|
||||||
|
onUpdate { cur ->
|
||||||
|
if (freq == null) {
|
||||||
|
cur.copy(recurrenceFrequency = null, recurrenceCount = null, recurrenceUntil = null)
|
||||||
|
} else {
|
||||||
|
cur.copy(recurrenceFrequency = freq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val freq = item.recurrenceFrequency
|
||||||
|
if (freq != null) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) {
|
||||||
|
Text("Every", modifier = Modifier.padding(end = 8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = item.recurrenceInterval.toString(),
|
||||||
|
onValueChange = { v ->
|
||||||
|
v.toIntOrNull()?.takeIf { it in 1..999 }?.let { n -> onUpdate { it.copy(recurrenceInterval = n) } }
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.width(80.dp)
|
||||||
|
)
|
||||||
|
Text(intervalUnitLabel(freq, item.recurrenceInterval), modifier = Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
|
||||||
|
RecurrenceEndPicker(
|
||||||
|
count = item.recurrenceCount,
|
||||||
|
until = item.recurrenceUntil,
|
||||||
|
onNever = { onUpdate { it.copy(recurrenceCount = null, recurrenceUntil = null) } },
|
||||||
|
onAfterCount = { n -> onUpdate { it.copy(recurrenceCount = n, recurrenceUntil = null) } },
|
||||||
|
onOnDate = { millis -> onUpdate { it.copy(recurrenceUntil = millis, recurrenceCount = null) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class RecurrenceEndMode { NEVER, AFTER_COUNT, ON_DATE }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun RecurrenceEndPicker(
|
||||||
|
count: Int?,
|
||||||
|
until: Long?,
|
||||||
|
onNever: () -> Unit,
|
||||||
|
onAfterCount: (Int) -> Unit,
|
||||||
|
onOnDate: (Long?) -> Unit
|
||||||
|
) {
|
||||||
|
val mode = when {
|
||||||
|
count != null -> RecurrenceEndMode.AFTER_COUNT
|
||||||
|
until != null -> RecurrenceEndMode.ON_DATE
|
||||||
|
else -> RecurrenceEndMode.NEVER
|
||||||
|
}
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
val modeLabel = when (mode) {
|
||||||
|
RecurrenceEndMode.NEVER -> "Never"
|
||||||
|
RecurrenceEndMode.AFTER_COUNT -> "After a number of times"
|
||||||
|
RecurrenceEndMode.ON_DATE -> "On a date"
|
||||||
|
}
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = it },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = modeLabel,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(text = { Text("Never") }, onClick = { onNever(); expanded = false })
|
||||||
|
DropdownMenuItem(text = { Text("After a number of times") }, onClick = { onAfterCount(count ?: 10); expanded = false })
|
||||||
|
DropdownMenuItem(text = { Text("On a date") }, onClick = { onOnDate(until ?: System.currentTimeMillis()); expanded = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (mode) {
|
||||||
|
RecurrenceEndMode.AFTER_COUNT -> {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = (count ?: 1).toString(),
|
||||||
|
onValueChange = { v -> v.toIntOrNull()?.takeIf { it > 0 }?.let(onAfterCount) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.width(80.dp)
|
||||||
|
)
|
||||||
|
Text("time${if ((count ?: 1) != 1) "s" else ""}", modifier = Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RecurrenceEndMode.ON_DATE -> {
|
||||||
|
DateTimePicker(millis = until, onChange = onOnDate)
|
||||||
|
}
|
||||||
|
RecurrenceEndMode.NEVER -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun statusLabel(status: TaskStatus): String = when (status) {
|
private fun statusLabel(status: TaskStatus): String = when (status) {
|
||||||
TaskStatus.NEEDS_ACTION -> "Not started"
|
TaskStatus.NEEDS_ACTION -> "Not started"
|
||||||
TaskStatus.IN_PROCESS -> "In progress"
|
TaskStatus.IN_PROCESS -> "In progress"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import com.homelab.ncal.data.model.CalendarCollection
|
||||||
import com.homelab.ncal.data.model.CalendarItem
|
import com.homelab.ncal.data.model.CalendarItem
|
||||||
import com.homelab.ncal.data.model.ItemType
|
import com.homelab.ncal.data.model.ItemType
|
||||||
import com.homelab.ncal.data.repository.NextcloudRepository
|
import com.homelab.ncal.data.repository.NextcloudRepository
|
||||||
|
import com.homelab.ncal.data.repository.SaveConflictException
|
||||||
import com.homelab.ncal.util.toUserMessage
|
import com.homelab.ncal.util.toUserMessage
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -23,7 +24,8 @@ data class ItemEditUiState(
|
||||||
val saving: Boolean = false,
|
val saving: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val done: Boolean = false,
|
val done: Boolean = false,
|
||||||
val deleted: Boolean = false
|
val deleted: Boolean = false,
|
||||||
|
val conflict: SaveConflictException? = null
|
||||||
) {
|
) {
|
||||||
/** Tasks eligible to be set as this task's parent: same calendar, not itself, not one of its own descendants. */
|
/** Tasks eligible to be set as this task's parent: same calendar, not itself, not one of its own descendants. */
|
||||||
val availableParentTasks: List<CalendarItem> get() {
|
val availableParentTasks: List<CalendarItem> get() {
|
||||||
|
|
@ -119,6 +121,9 @@ class ItemEditViewModel(
|
||||||
try {
|
try {
|
||||||
repo.save(item)
|
repo.save(item)
|
||||||
_state.value = _state.value.copy(saving = false, done = true)
|
_state.value = _state.value.copy(saving = false, done = true)
|
||||||
|
} catch (e: SaveConflictException) {
|
||||||
|
android.util.Log.d("NCalConflict", "save() 412 conflict for href=${item.href}, server version present=${e.serverVersion != null}")
|
||||||
|
_state.value = _state.value.copy(saving = false, conflict = e)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("NCalError", "save() failed for href=${item.href} calendar=${item.calendarUrl}", e)
|
android.util.Log.e("NCalError", "save() failed for href=${item.href} calendar=${item.calendarUrl}", e)
|
||||||
_state.value = _state.value.copy(saving = false, error = e.toUserMessage())
|
_state.value = _state.value.copy(saving = false, error = e.toUserMessage())
|
||||||
|
|
@ -126,6 +131,28 @@ class ItemEditViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Conflict resolution: retry the write with the server's current etag, overwriting whatever
|
||||||
|
* changed there with this edit. */
|
||||||
|
fun keepMyChanges() {
|
||||||
|
val conflict = _state.value.conflict ?: return
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
conflict = null,
|
||||||
|
item = conflict.localEdit.copy(etag = conflict.serverVersion?.etag)
|
||||||
|
)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conflict resolution: discard this edit and load whatever's on the server now (or leave
|
||||||
|
* the screen entirely if it was deleted there) instead. */
|
||||||
|
fun discardMyChanges() {
|
||||||
|
val conflict = _state.value.conflict ?: return
|
||||||
|
_state.value = if (conflict.serverVersion != null) {
|
||||||
|
_state.value.copy(conflict = null, item = conflict.serverVersion)
|
||||||
|
} else {
|
||||||
|
_state.value.copy(conflict = null, deleted = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun delete() {
|
fun delete() {
|
||||||
val item = _state.value.item ?: return
|
val item = _state.value.item ?: return
|
||||||
if (item.isNew) return
|
if (item.isNew) return
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class LoginViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
||||||
val normalized = "https://" + s.serverUrl.removePrefix("https://").removePrefix("http://")
|
val normalized = "https://" + s.serverUrl.removePrefix("https://").removePrefix("http://")
|
||||||
repo.login(normalized, s.username, s.appPassword)
|
repo.login(normalized, s.username, s.appPassword)
|
||||||
repo.fullSync()
|
repo.fullSync()
|
||||||
|
repo.scheduleBackgroundSync()
|
||||||
_state.value = _state.value.copy(loading = false, success = true)
|
_state.value = _state.value.copy(loading = false, success = true)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("NCalLogin", "Login/sync failed", e)
|
android.util.Log.e("NCalLogin", "Login/sync failed", e)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.homelab.ncal.data.model.CalendarItem
|
import com.homelab.ncal.data.model.CalendarItem
|
||||||
import com.homelab.ncal.data.repository.NextcloudRepository
|
import com.homelab.ncal.data.repository.NextcloudRepository
|
||||||
|
import com.homelab.ncal.util.TaskNode
|
||||||
|
import com.homelab.ncal.util.buildTaskTree
|
||||||
import com.homelab.ncal.util.toUserMessage
|
import com.homelab.ncal.util.toUserMessage
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
|
@ -13,9 +15,6 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/** A task plus how many levels deep it is nested under its ancestors (0 = top-level). */
|
|
||||||
data class TaskNode(val item: CalendarItem, val depth: Int)
|
|
||||||
|
|
||||||
data class TasksUiState(
|
data class TasksUiState(
|
||||||
val tasks: List<TaskNode> = emptyList(),
|
val tasks: List<TaskNode> = emptyList(),
|
||||||
val refreshing: Boolean = false,
|
val refreshing: Boolean = false,
|
||||||
|
|
@ -34,32 +33,9 @@ class TasksViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
val state: StateFlow<TasksUiState> = combine(tasks, refreshing, error) { taskList, r, e ->
|
val state: StateFlow<TasksUiState> = combine(tasks, refreshing, error) { taskList, r, e ->
|
||||||
TasksUiState(buildTree(taskList), r, e)
|
TasksUiState(buildTaskTree(taskList), r, e)
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TasksUiState())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TasksUiState())
|
||||||
|
|
||||||
/** Orders tasks depth-first under their parent (via [CalendarItem.parentUid]), preserving each
|
|
||||||
* level's original sort order. Broken/cyclic parent references fall back to top-level. */
|
|
||||||
private fun buildTree(tasks: List<CalendarItem>): List<TaskNode> {
|
|
||||||
val allUids = tasks.map { it.uid }.toSet()
|
|
||||||
val childrenByParent = tasks.groupBy { it.parentUid }
|
|
||||||
val visited = mutableSetOf<String>()
|
|
||||||
val result = mutableListOf<TaskNode>()
|
|
||||||
|
|
||||||
fun addWithChildren(task: CalendarItem, depth: Int) {
|
|
||||||
if (!visited.add(task.uid)) return
|
|
||||||
result += TaskNode(task, depth)
|
|
||||||
childrenByParent[task.uid].orEmpty().forEach { addWithChildren(it, depth + 1) }
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.forEach { task ->
|
|
||||||
val isRoot = task.parentUid == null || task.parentUid !in allUids
|
|
||||||
if (isRoot) addWithChildren(task, 0)
|
|
||||||
}
|
|
||||||
tasks.forEach { task -> if (task.uid !in visited) addWithChildren(task, 0) }
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggle(item: CalendarItem) {
|
fun toggle(item: CalendarItem) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.homelab.ncal.util
|
package com.homelab.ncal.util
|
||||||
|
|
||||||
import com.homelab.ncal.data.network.CalDavException
|
import com.homelab.ncal.data.network.CalDavException
|
||||||
|
import com.homelab.ncal.data.repository.SaveConflictException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Short, user-facing summary for an error banner. [CalDavException] messages carry the full
|
* Short, user-facing summary for an error banner. [CalDavException] messages carry the full
|
||||||
|
|
@ -8,6 +9,13 @@ import com.homelab.ncal.data.network.CalDavException
|
||||||
* crash dump if shown directly in the UI) - this collapses that down to one line.
|
* crash dump if shown directly in the UI) - this collapses that down to one line.
|
||||||
*/
|
*/
|
||||||
fun Throwable.toUserMessage(): String = when (this) {
|
fun Throwable.toUserMessage(): String = when (this) {
|
||||||
|
// The edit screen has a real conflict-resolution dialog for this; other callers (e.g. the
|
||||||
|
// quick-toggle-complete flows) don't, so they fall back to this summary in their error banner.
|
||||||
|
is SaveConflictException -> if (serverVersion != null) {
|
||||||
|
"Couldn't save - this item was changed elsewhere. Reopen it to see the latest version."
|
||||||
|
} else {
|
||||||
|
"Couldn't save - this item was deleted elsewhere."
|
||||||
|
}
|
||||||
is CalDavException -> when (httpCode) {
|
is CalDavException -> when (httpCode) {
|
||||||
404 -> "Server couldn't find that item - it may have changed or been removed elsewhere."
|
404 -> "Server couldn't find that item - it may have changed or been removed elsewhere."
|
||||||
401 -> "Login rejected - your stored credentials may have expired. Try logging in again."
|
401 -> "Login rejected - your stored credentials may have expired. Try logging in again."
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,11 @@ import biweekly.property.RelatedTo
|
||||||
import biweekly.property.Status
|
import biweekly.property.Status
|
||||||
import biweekly.property.Trigger
|
import biweekly.property.Trigger
|
||||||
import biweekly.util.Duration
|
import biweekly.util.Duration
|
||||||
|
import biweekly.util.Frequency
|
||||||
|
import biweekly.util.Recurrence
|
||||||
import com.homelab.ncal.data.model.CalendarItem
|
import com.homelab.ncal.data.model.CalendarItem
|
||||||
import com.homelab.ncal.data.model.ItemType
|
import com.homelab.ncal.data.model.ItemType
|
||||||
|
import com.homelab.ncal.data.model.RecurrenceFrequency
|
||||||
import com.homelab.ncal.data.model.TaskStatus
|
import com.homelab.ncal.data.model.TaskStatus
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -33,6 +36,43 @@ object IcsMapper {
|
||||||
TaskStatus.CANCELLED -> Status.cancelled()
|
TaskStatus.CANCELLED -> Status.cancelled()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** null if this rule uses anything the picker can't represent (BYDAY/BYMONTH/etc, or a
|
||||||
|
* frequency other than the 4 simple ones) - not just an unsupported FREQ. A rule can have
|
||||||
|
* FREQ=WEEKLY and still be BYDAY-based ("every Mon/Wed/Fri"), which the picker must never
|
||||||
|
* silently flatten into a plain weekly rule on an unrelated edit. */
|
||||||
|
private fun Recurrence?.toRecurrenceFrequency(): RecurrenceFrequency? {
|
||||||
|
if (this == null) return null
|
||||||
|
val isSimple = byDay.isEmpty() && byMonth.isEmpty() && byMonthDay.isEmpty() &&
|
||||||
|
byYearDay.isEmpty() && byWeekNo.isEmpty() && bySetPos.isEmpty() &&
|
||||||
|
byHour.isEmpty() && byMinute.isEmpty() && bySecond.isEmpty()
|
||||||
|
if (!isSimple) return null
|
||||||
|
return when (frequency) {
|
||||||
|
Frequency.DAILY -> RecurrenceFrequency.DAILY
|
||||||
|
Frequency.WEEKLY -> RecurrenceFrequency.WEEKLY
|
||||||
|
Frequency.MONTHLY -> RecurrenceFrequency.MONTHLY
|
||||||
|
Frequency.YEARLY -> RecurrenceFrequency.YEARLY
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the Recurrence to write for [item], or null to clear any existing RRULE. Only
|
||||||
|
* reachable for the 4 frequencies the picker supports - anything else in the original
|
||||||
|
* document was already filtered out by [Recurrence.toRecurrenceFrequency] on parse, so this
|
||||||
|
* never accidentally overwrites a BYDAY-based rule the picker can't represent. */
|
||||||
|
private fun buildRecurrence(item: CalendarItem): Recurrence? {
|
||||||
|
val freq = item.recurrenceFrequency ?: return null
|
||||||
|
val bwFreq = when (freq) {
|
||||||
|
RecurrenceFrequency.DAILY -> Frequency.DAILY
|
||||||
|
RecurrenceFrequency.WEEKLY -> Frequency.WEEKLY
|
||||||
|
RecurrenceFrequency.MONTHLY -> Frequency.MONTHLY
|
||||||
|
RecurrenceFrequency.YEARLY -> Frequency.YEARLY
|
||||||
|
}
|
||||||
|
val builder = Recurrence.Builder(bwFreq).interval(item.recurrenceInterval.coerceAtLeast(1))
|
||||||
|
item.recurrenceCount?.let { builder.count(it) }
|
||||||
|
item.recurrenceUntil?.let { builder.until(Date(it), true) }
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
/** Reads the "minutes before" values out of any duration-relative VALARMs on the component. */
|
/** Reads the "minutes before" values out of any duration-relative VALARMs on the component. */
|
||||||
private fun alarmMinutesBefore(alarms: List<VAlarm>): List<Int> =
|
private fun alarmMinutesBefore(alarms: List<VAlarm>): List<Int> =
|
||||||
alarms.mapNotNull { alarm ->
|
alarms.mapNotNull { alarm ->
|
||||||
|
|
@ -83,6 +123,11 @@ object IcsMapper {
|
||||||
allDay = ev.dateStart?.value?.let { !hasTimeComponent(ics, "DTSTART") } ?: false,
|
allDay = ev.dateStart?.value?.let { !hasTimeComponent(ics, "DTSTART") } ?: false,
|
||||||
priority = ev.priority?.value ?: 0,
|
priority = ev.priority?.value ?: 0,
|
||||||
reminderMinutes = alarmMinutesBefore(ev.alarms),
|
reminderMinutes = alarmMinutesBefore(ev.alarms),
|
||||||
|
recurrenceFrequency = recurrence.toRecurrenceFrequency(),
|
||||||
|
recurrenceInterval = recurrence?.interval ?: 1,
|
||||||
|
recurrenceCount = recurrence?.count,
|
||||||
|
recurrenceUntil = recurrence?.until?.time,
|
||||||
|
isRecurring = recurrence != null,
|
||||||
rawIcs = ics
|
rawIcs = ics
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -132,6 +177,11 @@ object IcsMapper {
|
||||||
priority = td.priority?.value ?: 0,
|
priority = td.priority?.value ?: 0,
|
||||||
parentUid = td.relatedTo.firstOrNull { it.relationshipType == null || it.relationshipType.value.equals("PARENT", ignoreCase = true) }?.value,
|
parentUid = td.relatedTo.firstOrNull { it.relationshipType == null || it.relationshipType.value.equals("PARENT", ignoreCase = true) }?.value,
|
||||||
reminderMinutes = alarmMinutesBefore(td.alarms),
|
reminderMinutes = alarmMinutesBefore(td.alarms),
|
||||||
|
recurrenceFrequency = recurrence.toRecurrenceFrequency(),
|
||||||
|
recurrenceInterval = recurrence?.interval ?: 1,
|
||||||
|
recurrenceCount = recurrence?.count,
|
||||||
|
recurrenceUntil = recurrence?.until?.time,
|
||||||
|
isRecurring = recurrence != null,
|
||||||
rawIcs = ics
|
rawIcs = ics
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -140,46 +190,85 @@ object IcsMapper {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Builds a full .ics document ready to PUT to the server for the given item. */
|
/**
|
||||||
|
* Builds a full .ics document ready to PUT to the server for the given item.
|
||||||
|
*
|
||||||
|
* Mutates the original parsed document ([CalendarItem.rawIcs]) in place rather than
|
||||||
|
* rebuilding a fresh VEVENT/VTODO from scratch, so properties this app doesn't model -
|
||||||
|
* attendees, X- properties, etc. - survive an edit-and-save cycle instead of being silently
|
||||||
|
* dropped. Only the fields the app actually edits are touched; everything else in the
|
||||||
|
* original document is left untouched. `setXxx(null)` on a biweekly single-cardinality
|
||||||
|
* property removes it, which is what lets a cleared form field (e.g. description) actually
|
||||||
|
* clear the property here too, instead of just leaving the old value in place.
|
||||||
|
*
|
||||||
|
* Recurring items get two extra guards, both load-bearing:
|
||||||
|
* - [CalendarItem.start]/[due]/[end] hold the *resolved next occurrence* for a recurring
|
||||||
|
* item (see [parse]), not the master DTSTART/DTEND/DUE. Writing them back verbatim would
|
||||||
|
* shift the whole series forward on every single edit. So for an item that was *already*
|
||||||
|
* recurring going in, the master date(s) are left exactly as parsed - only a freshly
|
||||||
|
* recurring-for-the-first-time item (or a plain non-recurring one) takes its date from
|
||||||
|
* the model.
|
||||||
|
* - A rule the picker can't fully represent (BYDAY/BYMONTH/etc, or a frequency other than
|
||||||
|
* the 4 simple ones - see [Recurrence.toRecurrenceFrequency]) is left completely alone
|
||||||
|
* whenever the model's `recurrenceFrequency` is null, rather than being replaced by
|
||||||
|
* `buildRecurrence`'s (simpler) reconstruction or cleared outright.
|
||||||
|
*/
|
||||||
fun toIcs(item: CalendarItem): String {
|
fun toIcs(item: CalendarItem): String {
|
||||||
val ical = ICalendar()
|
val ical = item.rawIcs?.takeIf { it.isNotBlank() }?.let { Biweekly.parse(it).first() } ?: ICalendar()
|
||||||
when (item.type) {
|
when (item.type) {
|
||||||
ItemType.EVENT -> {
|
ItemType.EVENT -> {
|
||||||
val ev = VEvent()
|
val ev = ical.events.firstOrNull() ?: VEvent().also { ical.addEvent(it) }
|
||||||
|
val originalRecurrence = ev.recurrenceRule?.value
|
||||||
|
val wasRecurring = originalRecurrence != null
|
||||||
|
|
||||||
ev.setUid(item.uid)
|
ev.setUid(item.uid)
|
||||||
ev.setSummary(item.summary)
|
ev.setSummary(item.summary)
|
||||||
if (item.description.isNotBlank()) ev.setDescription(item.description)
|
ev.setDescription(item.description.takeIf { it.isNotBlank() })
|
||||||
if (item.location.isNotBlank()) ev.setLocation(item.location)
|
ev.setLocation(item.location.takeIf { it.isNotBlank() })
|
||||||
if (item.url.isNotBlank()) ev.setUrl(item.url)
|
ev.setUrl(item.url.takeIf { it.isNotBlank() })
|
||||||
item.start?.let { ev.setDateStart(Date(it), !item.allDay) }
|
if (!wasRecurring) {
|
||||||
item.end?.let { ev.setDateEnd(Date(it), !item.allDay) }
|
ev.setDateStart(item.start?.let { Date(it) }, !item.allDay)
|
||||||
if (item.priority > 0) ev.setPriority(item.priority)
|
ev.setDateEnd(item.end?.let { Date(it) }, !item.allDay)
|
||||||
|
}
|
||||||
|
ev.setPriority(item.priority.takeIf { it > 0 })
|
||||||
|
if (!(originalRecurrence.toRecurrenceFrequency() == null && wasRecurring && item.recurrenceFrequency == null)) {
|
||||||
|
ev.setRecurrenceRule(buildRecurrence(item))
|
||||||
|
}
|
||||||
|
ev.alarms.clear()
|
||||||
item.reminderMinutes.forEach { minutes ->
|
item.reminderMinutes.forEach { minutes ->
|
||||||
val duration = Duration.builder().prior(true).minutes(minutes).build()
|
val duration = Duration.builder().prior(true).minutes(minutes).build()
|
||||||
ev.addAlarm(VAlarm.display(Trigger(duration, Related.START), item.summary))
|
ev.addAlarm(VAlarm.display(Trigger(duration, Related.START), item.summary))
|
||||||
}
|
}
|
||||||
ical.addEvent(ev)
|
|
||||||
}
|
}
|
||||||
ItemType.TASK -> {
|
ItemType.TASK -> {
|
||||||
val td = VTodo()
|
val td = ical.todos.firstOrNull() ?: VTodo().also { ical.addTodo(it) }
|
||||||
|
val originalRecurrence = td.recurrenceRule?.value
|
||||||
|
val wasRecurring = originalRecurrence != null
|
||||||
|
|
||||||
td.setUid(item.uid)
|
td.setUid(item.uid)
|
||||||
td.setSummary(item.summary)
|
td.setSummary(item.summary)
|
||||||
if (item.description.isNotBlank()) td.setDescription(item.description)
|
td.setDescription(item.description.takeIf { it.isNotBlank() })
|
||||||
if (item.location.isNotBlank()) td.setLocation(item.location)
|
td.setLocation(item.location.takeIf { it.isNotBlank() })
|
||||||
if (item.url.isNotBlank()) td.setUrl(item.url)
|
td.setUrl(item.url.takeIf { it.isNotBlank() })
|
||||||
item.start?.let { td.setDateStart(Date(it)) }
|
if (!wasRecurring) {
|
||||||
item.due?.let { td.setDateDue(Date(it)) }
|
td.setDateStart(item.start?.let { Date(it) })
|
||||||
if (item.priority > 0) td.setPriority(item.priority)
|
td.setDateDue(item.due?.let { Date(it) })
|
||||||
|
}
|
||||||
|
td.setPriority(item.priority.takeIf { it > 0 })
|
||||||
td.setStatus(item.status.toIcsStatus())
|
td.setStatus(item.status.toIcsStatus())
|
||||||
if (item.percentComplete > 0) td.setPercentComplete(item.percentComplete)
|
td.setPercentComplete(item.percentComplete.takeIf { it > 0 })
|
||||||
|
if (!(originalRecurrence.toRecurrenceFrequency() == null && wasRecurring && item.recurrenceFrequency == null)) {
|
||||||
|
td.setRecurrenceRule(buildRecurrence(item))
|
||||||
|
}
|
||||||
|
td.relatedTo.clear()
|
||||||
item.parentUid?.takeIf { it.isNotBlank() }?.let { td.addRelatedTo(RelatedTo(it)) }
|
item.parentUid?.takeIf { it.isNotBlank() }?.let { td.addRelatedTo(RelatedTo(it)) }
|
||||||
|
td.alarms.clear()
|
||||||
if (item.due != null) {
|
if (item.due != null) {
|
||||||
item.reminderMinutes.forEach { minutes ->
|
item.reminderMinutes.forEach { minutes ->
|
||||||
val duration = Duration.builder().prior(true).minutes(minutes).build()
|
val duration = Duration.builder().prior(true).minutes(minutes).build()
|
||||||
td.addAlarm(VAlarm.display(Trigger(duration, Related.END), item.summary))
|
td.addAlarm(VAlarm.display(Trigger(duration, Related.END), item.summary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ical.addTodo(td)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Biweekly.write(ical).go()
|
return Biweekly.write(ical).go()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package com.homelab.ncal.util
|
||||||
|
|
||||||
|
import com.homelab.ncal.data.model.CalendarItem
|
||||||
|
|
||||||
|
/** A task plus how many levels deep it is nested under its ancestors (0 = top-level). */
|
||||||
|
data class TaskNode(val item: CalendarItem, val depth: Int)
|
||||||
|
|
||||||
|
/** Orders tasks depth-first under their parent (via [CalendarItem.parentUid]), preserving each
|
||||||
|
* level's original sort order. Broken/cyclic parent references fall back to top-level. Shared
|
||||||
|
* by the in-app Tasks screen and the "Tasks" home screen widget so both render the same tree. */
|
||||||
|
fun buildTaskTree(tasks: List<CalendarItem>): List<TaskNode> {
|
||||||
|
val allUids = tasks.map { it.uid }.toSet()
|
||||||
|
val childrenByParent = tasks.groupBy { it.parentUid }
|
||||||
|
val visited = mutableSetOf<String>()
|
||||||
|
val result = mutableListOf<TaskNode>()
|
||||||
|
|
||||||
|
fun addWithChildren(task: CalendarItem, depth: Int) {
|
||||||
|
if (!visited.add(task.uid)) return
|
||||||
|
result += TaskNode(task, depth)
|
||||||
|
childrenByParent[task.uid].orEmpty().forEach { addWithChildren(it, depth + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.forEach { task ->
|
||||||
|
val isRoot = task.parentUid == null || task.parentUid !in allUids
|
||||||
|
if (isRoot) addWithChildren(task, 0)
|
||||||
|
}
|
||||||
|
tasks.forEach { task -> if (task.uid !in visited) addWithChildren(task, 0) }
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -36,41 +36,37 @@ import androidx.glance.text.TextStyle
|
||||||
import com.homelab.ncal.MainActivity
|
import com.homelab.ncal.MainActivity
|
||||||
import com.homelab.ncal.NcalApplication
|
import com.homelab.ncal.NcalApplication
|
||||||
import com.homelab.ncal.data.model.CalendarItem
|
import com.homelab.ncal.data.model.CalendarItem
|
||||||
|
import com.homelab.ncal.util.TaskNode
|
||||||
|
import com.homelab.ncal.util.buildTaskTree
|
||||||
import com.homelab.ncal.util.priorityColor
|
import com.homelab.ncal.util.priorityColor
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
internal val taskHrefKey = ActionParameters.Key<String>("task_href")
|
internal val taskHrefKey = ActionParameters.Key<String>("task_href")
|
||||||
|
|
||||||
/** Home screen widget listing incomplete tasks, grouped and ordered by due date - the widget
|
/** Home screen widget listing incomplete tasks as a parent/child tree, ordered by due date at
|
||||||
* equivalent of the in-app Tasks list, minus the subtask tree (kept flat/chronological here
|
* each level - the widget equivalent of the in-app Tasks list ([buildTaskTree] is shared by
|
||||||
* since the point of a widget is a quick "what's next" glance, not full hierarchy browsing). */
|
* both, so they always render the same tree). Each row still shows its own due time inline. */
|
||||||
class NextTasksWidget : GlanceAppWidget() {
|
class NextTasksWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val repo = (context.applicationContext as NcalApplication).repository
|
val repo = (context.applicationContext as NcalApplication).repository
|
||||||
val urls = repo.observeCollections().first().filter { it.enabled }.map { it.url }
|
val urls = repo.observeCollections().first().filter { it.enabled }.map { it.url }
|
||||||
val zone = ZoneId.systemDefault()
|
|
||||||
|
|
||||||
val groups = repo.observeTasks(urls).first()
|
// observeTasks() already orders by due date, which buildTaskTree preserves at each level.
|
||||||
.filter { !it.completed }
|
val nodes = buildTaskTree(repo.observeTasks(urls).first().filter { !it.completed })
|
||||||
.sortedWith(compareBy({ it.due == null }, { it.due }))
|
|
||||||
.groupBy { it.due?.let { d -> Instant.ofEpochMilli(d).atZone(zone).toLocalDate() } }
|
|
||||||
.toList()
|
|
||||||
.sortedWith(compareBy(nullsLast()) { it.first })
|
|
||||||
|
|
||||||
provideContent {
|
provideContent {
|
||||||
NextTasksContent(groups)
|
NextTasksContent(nodes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NextTasksContent(groups: List<Pair<LocalDate?, List<CalendarItem>>>) {
|
private fun NextTasksContent(nodes: List<TaskNode>) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
GlanceTheme {
|
GlanceTheme {
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -85,30 +81,15 @@ private fun NextTasksContent(groups: List<Pair<LocalDate?, List<CalendarItem>>>)
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Bold, fontSize = 14.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Bold, fontSize = 14.sp),
|
||||||
modifier = GlanceModifier.padding(bottom = 4.dp)
|
modifier = GlanceModifier.padding(bottom = 4.dp)
|
||||||
)
|
)
|
||||||
if (groups.isEmpty()) {
|
if (nodes.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"Nothing due",
|
"Nothing due",
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp)
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
groups.forEach { (date, tasksForDate) ->
|
items(nodes, itemId = { it.item.href.hashCode().toLong() }) { node ->
|
||||||
item(itemId = date?.toEpochDay() ?: -1L) {
|
TaskRow(node.item, node.depth)
|
||||||
Text(
|
|
||||||
text = date?.let {
|
|
||||||
it.format(DateTimeFormatter.ofPattern("EEE, MMM d", Locale.getDefault()))
|
|
||||||
} ?: "No due date",
|
|
||||||
style = TextStyle(
|
|
||||||
color = GlanceTheme.colors.primary,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 12.sp
|
|
||||||
),
|
|
||||||
modifier = GlanceModifier.padding(top = 6.dp, bottom = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
items(tasksForDate, itemId = { it.href.hashCode().toLong() }) { task ->
|
|
||||||
TaskRow(task)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -117,7 +98,7 @@ private fun NextTasksContent(groups: List<Pair<LocalDate?, List<CalendarItem>>>)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TaskRow(item: CalendarItem) {
|
private fun TaskRow(item: CalendarItem, depth: Int) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val intent = Intent(context, MainActivity::class.java).apply {
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
putExtra(MainActivity.EXTRA_ITEM_TYPE, item.type.name)
|
putExtra(MainActivity.EXTRA_ITEM_TYPE, item.type.name)
|
||||||
|
|
@ -129,7 +110,7 @@ private fun TaskRow(item: CalendarItem) {
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(GlanceTheme.colors.background)
|
.background(GlanceTheme.colors.background)
|
||||||
.clickable(actionStartActivity(intent))
|
.clickable(actionStartActivity(intent))
|
||||||
.padding(vertical = 3.dp),
|
.padding(start = (depth * 12).dp, top = 3.dp, bottom = 3.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
package com.homelab.ncal.util
|
||||||
|
|
||||||
|
import com.homelab.ncal.data.model.CalendarItem
|
||||||
|
import com.homelab.ncal.data.model.ItemType
|
||||||
|
import com.homelab.ncal.data.model.RecurrenceFrequency
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage for the "editing a recurring item drops its RRULE" bug: toIcs() used to
|
||||||
|
* always rebuild a fresh VEVENT/VTODO from the app's modeled fields, silently discarding RRULE,
|
||||||
|
* attendees, and any other property the app doesn't model. It now mutates the originally-parsed
|
||||||
|
* document in place instead.
|
||||||
|
*/
|
||||||
|
class IcsMapperTest {
|
||||||
|
|
||||||
|
private fun ics(vararg lines: String) = lines.joinToString("\r\n") + "\r\n"
|
||||||
|
|
||||||
|
private val recurringEventIcs = ics(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//Nextcloud//Test//EN",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"UID:test-event-1@example.com",
|
||||||
|
"DTSTAMP:20200106T090000Z",
|
||||||
|
"DTSTART:20200106T090000Z",
|
||||||
|
"DTEND:20200106T100000Z",
|
||||||
|
"SUMMARY:Weekly Standup",
|
||||||
|
"DESCRIPTION:Team sync",
|
||||||
|
"RRULE:FREQ=WEEKLY;BYDAY=MO",
|
||||||
|
"ATTENDEE:mailto:someone@example.com",
|
||||||
|
"X-CUSTOM-PROP:keep-me",
|
||||||
|
"END:VEVENT",
|
||||||
|
"END:VCALENDAR"
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `editing a recurring event preserves its RRULE and unmodeled properties`() {
|
||||||
|
val item = IcsMapper.parse(recurringEventIcs, href = "https://example.com/e1.ics", etag = "\"abc\"", calendarUrl = "https://example.com/cal/")
|
||||||
|
assertNotNull("parse() should resolve an ongoing weekly recurrence", item)
|
||||||
|
|
||||||
|
// Simulate a real edit made through the app's edit form.
|
||||||
|
val edited = item!!.copy(summary = "Weekly Standup (renamed)")
|
||||||
|
val rebuiltIcs = IcsMapper.toIcs(edited)
|
||||||
|
|
||||||
|
assertTrue("RRULE must survive the edit", rebuiltIcs.contains("RRULE") && rebuiltIcs.contains("FREQ=WEEKLY"))
|
||||||
|
assertTrue("unmodeled ATTENDEE property must survive the edit", rebuiltIcs.contains("ATTENDEE"))
|
||||||
|
assertTrue("unmodeled X- property must survive the edit", rebuiltIcs.contains("X-CUSTOM-PROP"))
|
||||||
|
assertTrue("edited summary must be applied", rebuiltIcs.contains("Weekly Standup (renamed)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `editing a recurring event does NOT shift its master DTSTART to the resolved next occurrence`() {
|
||||||
|
// item.start after parse() is the *resolved next occurrence* (could be years after the
|
||||||
|
// original 2020-01-06 anchor by the time this test runs) - saving it back verbatim would
|
||||||
|
// silently shift the whole series forward on every single edit. Regression test for
|
||||||
|
// exactly that bug.
|
||||||
|
val item = IcsMapper.parse(recurringEventIcs, href = "https://example.com/e1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
|
||||||
|
assertTrue("resolved start should have moved past the 2020 anchor", item.start!! > 1578297600000L) // 2020-01-06
|
||||||
|
|
||||||
|
val edited = item.copy(summary = "renamed")
|
||||||
|
val rebuiltIcs = IcsMapper.toIcs(edited)
|
||||||
|
|
||||||
|
assertTrue("master DTSTART must stay exactly as originally authored", rebuiltIcs.contains("DTSTART:20200106T090000Z"))
|
||||||
|
assertTrue("master DTEND must stay exactly as originally authored", rebuiltIcs.contains("DTEND:20200106T100000Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unsupported BYDAY-based rule is left completely untouched when the picker fields are null`() {
|
||||||
|
val item = IcsMapper.parse(recurringEventIcs, href = "https://example.com/e1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
|
||||||
|
assertEquals("a BYDAY-based rule isn't representable by the picker", null, item.recurrenceFrequency)
|
||||||
|
assertTrue("but the item is still recognized as recurring", item.isRecurring)
|
||||||
|
|
||||||
|
val rebuiltIcs = IcsMapper.toIcs(item.copy(summary = "renamed"))
|
||||||
|
|
||||||
|
assertTrue("original BYDAY must be preserved verbatim", rebuiltIcs.contains("BYDAY=MO"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `adding a simple weekly recurrence via the picker to a previously non-recurring event`() {
|
||||||
|
val plainEventIcs = ics(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//Nextcloud//Test//EN",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"UID:test-event-3@example.com",
|
||||||
|
"DTSTAMP:20260101T090000Z",
|
||||||
|
"DTSTART:20260101T090000Z",
|
||||||
|
"DTEND:20260101T100000Z",
|
||||||
|
"SUMMARY:One-off meeting",
|
||||||
|
"END:VEVENT",
|
||||||
|
"END:VCALENDAR"
|
||||||
|
)
|
||||||
|
val item = IcsMapper.parse(plainEventIcs, href = "https://example.com/e3.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
|
||||||
|
assertFalse(item.isRecurring)
|
||||||
|
|
||||||
|
val madeRecurring = item.copy(recurrenceFrequency = RecurrenceFrequency.WEEKLY, recurrenceInterval = 2, recurrenceCount = 5)
|
||||||
|
val rebuiltIcs = IcsMapper.toIcs(madeRecurring)
|
||||||
|
|
||||||
|
assertTrue(rebuiltIcs.contains("RRULE"))
|
||||||
|
assertTrue(rebuiltIcs.contains("FREQ=WEEKLY"))
|
||||||
|
assertTrue(rebuiltIcs.contains("INTERVAL=2"))
|
||||||
|
assertTrue(rebuiltIcs.contains("COUNT=5"))
|
||||||
|
// a brand new recurrence has no prior master date to preserve - the model's date applies.
|
||||||
|
assertTrue(rebuiltIcs.contains("DTSTART:20260101T090000Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `changing recurrence interval on a simple recurring event updates RRULE and keeps master DTSTART`() {
|
||||||
|
val simpleRecurringIcs = ics(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//Nextcloud//Test//EN",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"UID:test-event-4@example.com",
|
||||||
|
"DTSTAMP:20200106T090000Z",
|
||||||
|
"DTSTART:20200106T090000Z",
|
||||||
|
"DTEND:20200106T100000Z",
|
||||||
|
"SUMMARY:Plain weekly sync",
|
||||||
|
"RRULE:FREQ=WEEKLY",
|
||||||
|
"END:VEVENT",
|
||||||
|
"END:VCALENDAR"
|
||||||
|
)
|
||||||
|
val item = IcsMapper.parse(simpleRecurringIcs, href = "https://example.com/e4.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
|
||||||
|
assertEquals(RecurrenceFrequency.WEEKLY, item.recurrenceFrequency)
|
||||||
|
|
||||||
|
val edited = item.copy(recurrenceInterval = 3)
|
||||||
|
val rebuiltIcs = IcsMapper.toIcs(edited)
|
||||||
|
|
||||||
|
assertTrue(rebuiltIcs.contains("INTERVAL=3"))
|
||||||
|
assertTrue("master DTSTART must stay exactly as originally authored", rebuiltIcs.contains("DTSTART:20200106T090000Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearing recurrence via the picker on a simple recurring event removes the RRULE`() {
|
||||||
|
val simpleRecurringIcs = ics(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//Nextcloud//Test//EN",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"UID:test-event-5@example.com",
|
||||||
|
"DTSTAMP:20200106T090000Z",
|
||||||
|
"DTSTART:20200106T090000Z",
|
||||||
|
"DTEND:20200106T100000Z",
|
||||||
|
"SUMMARY:Plain weekly sync",
|
||||||
|
"RRULE:FREQ=WEEKLY",
|
||||||
|
"END:VEVENT",
|
||||||
|
"END:VCALENDAR"
|
||||||
|
)
|
||||||
|
val item = IcsMapper.parse(simpleRecurringIcs, href = "https://example.com/e5.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
|
||||||
|
|
||||||
|
val stoppedRepeating = item.copy(recurrenceFrequency = null)
|
||||||
|
val rebuiltIcs = IcsMapper.toIcs(stoppedRepeating)
|
||||||
|
|
||||||
|
assertFalse("RRULE must be removed once the picker is set back to 'Does not repeat'", rebuiltIcs.contains("RRULE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearing a field on edit actually removes the property, not just leaves it unset`() {
|
||||||
|
val plainEventIcs = ics(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//Nextcloud//Test//EN",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"UID:test-event-2@example.com",
|
||||||
|
"DTSTAMP:20260101T090000Z",
|
||||||
|
"DTSTART:20260101T090000Z",
|
||||||
|
"DTEND:20260101T100000Z",
|
||||||
|
"SUMMARY:One-off meeting",
|
||||||
|
"DESCRIPTION:Has a description",
|
||||||
|
"END:VEVENT",
|
||||||
|
"END:VCALENDAR"
|
||||||
|
)
|
||||||
|
|
||||||
|
val item = IcsMapper.parse(plainEventIcs, href = "https://example.com/e2.ics", etag = null, calendarUrl = "https://example.com/cal/")
|
||||||
|
assertNotNull(item)
|
||||||
|
assertEquals("Has a description", item!!.description)
|
||||||
|
|
||||||
|
val cleared = item.copy(description = "")
|
||||||
|
val rebuiltIcs = IcsMapper.toIcs(cleared)
|
||||||
|
|
||||||
|
assertFalse("DESCRIPTION property must be removed when the field is cleared", rebuiltIcs.contains("DESCRIPTION"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a brand new item with no rawIcs still builds a fresh document`() {
|
||||||
|
val item = CalendarItem(
|
||||||
|
uid = "new-uid",
|
||||||
|
href = "",
|
||||||
|
etag = null,
|
||||||
|
calendarUrl = "https://example.com/cal/",
|
||||||
|
type = ItemType.EVENT,
|
||||||
|
summary = "Brand new event",
|
||||||
|
start = 1750000000000L,
|
||||||
|
end = 1750003600000L
|
||||||
|
)
|
||||||
|
val builtIcs = IcsMapper.toIcs(item)
|
||||||
|
|
||||||
|
assertTrue(builtIcs.contains("BEGIN:VEVENT"))
|
||||||
|
assertTrue(builtIcs.contains("SUMMARY:Brand new event"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `editing a task preserves its RRULE too`() {
|
||||||
|
val recurringTaskIcs = ics(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//Nextcloud//Test//EN",
|
||||||
|
"BEGIN:VTODO",
|
||||||
|
"UID:test-task-1@example.com",
|
||||||
|
"DTSTAMP:20200106T090000Z",
|
||||||
|
"DUE:20200106T090000Z",
|
||||||
|
"SUMMARY:Take out recycling",
|
||||||
|
"RRULE:FREQ=WEEKLY;BYDAY=TU",
|
||||||
|
"STATUS:NEEDS-ACTION",
|
||||||
|
"END:VTODO",
|
||||||
|
"END:VCALENDAR"
|
||||||
|
)
|
||||||
|
|
||||||
|
val item = IcsMapper.parse(recurringTaskIcs, href = "https://example.com/t1.ics", etag = null, calendarUrl = "https://example.com/cal/")
|
||||||
|
assertNotNull("parse() should resolve an ongoing weekly recurrence", item)
|
||||||
|
|
||||||
|
val edited = item!!.copy(priority = 3)
|
||||||
|
val rebuiltIcs = IcsMapper.toIcs(edited)
|
||||||
|
|
||||||
|
assertTrue("RRULE must survive the edit", rebuiltIcs.contains("RRULE") && rebuiltIcs.contains("FREQ=WEEKLY"))
|
||||||
|
assertTrue("PRIORITY must reflect the edit", rebuiltIcs.contains("PRIORITY:3"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
package com.homelab.ncal.util
|
||||||
|
|
||||||
|
import com.homelab.ncal.data.model.CalendarItem
|
||||||
|
import com.homelab.ncal.data.model.ItemType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TaskTreeTest {
|
||||||
|
|
||||||
|
private fun task(uid: String, parentUid: String? = null) = CalendarItem(
|
||||||
|
uid = uid,
|
||||||
|
href = "https://example.com/$uid.ics",
|
||||||
|
etag = null,
|
||||||
|
calendarUrl = "https://example.com/cal/",
|
||||||
|
type = ItemType.TASK,
|
||||||
|
summary = uid,
|
||||||
|
parentUid = parentUid
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `children are ordered depth-first right after their parent`() {
|
||||||
|
val tasks = listOf(
|
||||||
|
task("root-1"),
|
||||||
|
task("child-1a", parentUid = "root-1"),
|
||||||
|
task("root-2"),
|
||||||
|
task("child-1b", parentUid = "root-1"),
|
||||||
|
task("grandchild", parentUid = "child-1a")
|
||||||
|
)
|
||||||
|
|
||||||
|
val nodes = buildTaskTree(tasks)
|
||||||
|
|
||||||
|
assertEquals(listOf("root-1", "child-1a", "grandchild", "child-1b", "root-2"), nodes.map { it.item.uid })
|
||||||
|
assertEquals(listOf(0, 1, 2, 1, 0), nodes.map { it.depth })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a parentUid pointing at a task that doesn't exist falls back to top-level`() {
|
||||||
|
val tasks = listOf(task("orphan", parentUid = "does-not-exist"))
|
||||||
|
|
||||||
|
val nodes = buildTaskTree(tasks)
|
||||||
|
|
||||||
|
assertEquals(listOf("orphan"), nodes.map { it.item.uid })
|
||||||
|
assertEquals(listOf(0), nodes.map { it.depth })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a cyclic parent reference doesn't infinite-loop and still surfaces every task once`() {
|
||||||
|
val tasks = listOf(
|
||||||
|
task("a", parentUid = "b"),
|
||||||
|
task("b", parentUid = "a")
|
||||||
|
)
|
||||||
|
|
||||||
|
val nodes = buildTaskTree(tasks)
|
||||||
|
|
||||||
|
assertEquals(setOf("a", "b"), nodes.map { it.item.uid }.toSet())
|
||||||
|
assertEquals(2, nodes.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue