diff --git a/.gitignore b/.gitignore index aeacf3f..fe25c62 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ # Release signing - never commit these (see ncal/.gitignore for the enforced copy) /ncal/keystore/ /ncal/keystore.properties + +# Built APKs don't belong in source control +*.apk diff --git a/README.md b/README.md index 2898e21..d956983 100644 --- a/README.md +++ b/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 `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 - 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 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 @@ -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. 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 -fresh as the last time the app itself synced (on open, on manual pull-to-refresh, or -after an edit) — the widgets read from the local cache on their own ~30-minute OS -redraw timer, they don't independently hit the network. +the app, and independently of the app being open at all: a `WorkManager` periodic job +(`sync/SyncScheduler.kt`, `sync/SyncWorker.kt`) calls `repository.fullSync()` roughly +every 30 minutes whenever the device has network - matching the widgets' own OS-level +redraw floor, since syncing more often than a widget can even show an update buys +nothing. ## 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" and/or "Tasks". +Run the (small, growing) JVM unit test suite with `./gradlew test` — no emulator +needed. + ## Known limitations -- **Editing an existing recurring item drops its `RRULE`.** Saving rebuilds the - `.ics` from the edit form's fields, so an edited recurring item becomes a one-off. - Viewing/completing/deleting a recurring item is safe; editing its fields is not, - if you want the series to survive the save. `CalendarItem.rawIcs` retains the - original document for exactly this reason — wiring `toIcs()` to mutate the parsed - original in place is the natural fix if this turns out to matter in practice. -- **No recurrence-editing UI** — no picker to *set* a recurrence rule on a new item. -- **Subtask tree is app-side only.** The Month view and both widgets show tasks - flat/chronological, not nested — only the in-app Tasks screen renders the parent/ - child tree. -- **No background sync while the app is closed.** Sync happens on app open, manual - pull-to-refresh, or after an edit — there's no periodic `WorkManager` job calling - `repository.fullSync()` yet, so a widget can go stale if you never open the app. -- **Last-write-wins conflict handling.** A genuinely stale write fails loudly (a - `CalDavException` with the HTTP code) rather than silently clobbering a change made - elsewhere — but there's no merge UI, just an error surfaced to the edit screen. +- **No recurrence editing for a recurring item's start/end time.** You can set/change + *whether* something repeats and how (Daily/Weekly/Monthly/Yearly, interval, end + condition) via the Repeats picker, and edit all of a recurring item's other fields + freely - but the Starts/Ends/Due date fields become read-only the moment an item is + recurring, because [CalendarItem.start]/`end`/`due` hold the *resolved next + occurrence* for a recurring item, not the master date, and writing that back would + silently shift the whole series forward on every edit. Change the actual start date + of a recurring series in Nextcloud's web UI. A rule the picker can't represent + (`BYDAY`/`BYMONTH`/etc, or anything outside Daily/Weekly/Monthly/Yearly) is left + completely untouched rather than flattened into something simpler. Both guarantees + are regression-tested in `IcsMapperTest`. +- **Conflict resolution is a two-choice dialog, not a field-by-field merge.** A stale + write (HTTP 412 - the item changed or was deleted elsewhere since you opened it) + shows both versions and lets you keep your edit or take theirs; there's no way to + merge individual fields from each. ## 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 orchestration. - `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. ## Building a signed release diff --git a/ncal/app/build.gradle.kts b/ncal/app/build.gradle.kts index 64a7602..fada0f2 100644 --- a/ncal/app/build.gradle.kts +++ b/ncal/app/build.gradle.kts @@ -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 { sourceCompatibility = 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 implementation("net.zetetic:android-database-sqlcipher:4.5.4") + + testImplementation("junit:junit:4.13.2") } diff --git a/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt b/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt index 8a93d34..0cd14a5 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/NcalApplication.kt @@ -26,6 +26,7 @@ class NcalApplication : Application() { createReminderNotificationChannel() if (repository.isLoggedIn) { + repository.scheduleBackgroundSync() CoroutineScope(Dispatchers.IO).launch { ReminderScheduler.rescheduleAll(this@NcalApplication, repository.allCachedItems()) MonthGridWidget().updateAll(this@NcalApplication) diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt b/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt index 0e5da35..1d19bba 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt @@ -8,7 +8,7 @@ import net.sqlcipher.database.SupportFactory @Database( entities = [CollectionEntity::class, ItemEntity::class], - version = 4, + version = 5, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt b/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt index 9a73ca7..de40af2 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt @@ -28,6 +28,11 @@ data class ItemEntity( val priority: Int, val parentUid: String?, 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? ) diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt b/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt index be09071..4267dfd 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt @@ -5,10 +5,15 @@ enum class ItemType { EVENT, TASK } /** Mirrors the iCalendar VTODO STATUS values (tasks only). */ 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). * [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( 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 reminderMinutes: List = 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 isNew: Boolean get() = href.isEmpty() diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/network/CalDavClient.kt b/ncal/app/src/main/java/com/homelab/ncal/data/network/CalDavClient.kt index 93f462e..b56756a 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/data/network/CalDavClient.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/data/network/CalDavClient.kt @@ -166,6 +166,27 @@ class CalDavClient( 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? { + 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?) { val builder = Request.Builder() .url(href) diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt b/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt index 8e751e4..5327d8b 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt @@ -7,10 +7,13 @@ import com.homelab.ncal.data.db.ItemEntity import com.homelab.ncal.data.model.CalendarCollection import com.homelab.ncal.data.model.CalendarItem 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.network.CalDavClient +import com.homelab.ncal.data.network.CalDavException import com.homelab.ncal.data.prefs.SecurePrefs import com.homelab.ncal.notifications.ReminderScheduler +import com.homelab.ncal.sync.SyncScheduler import com.homelab.ncal.util.IcsMapper import com.homelab.ncal.widget.MonthGridWidget import com.homelab.ncal.widget.NextTasksWidget @@ -45,6 +48,12 @@ class NextcloudRepository(context: Context) { fun logout() { prefs.clear() + SyncScheduler.cancel(appContext) + } + + /** Starts (or confirms) the ~30-minute periodic background sync - see [SyncScheduler]. */ + fun scheduleBackgroundSync() { + SyncScheduler.schedule(appContext) } // ---------- Collections ---------- @@ -144,17 +153,34 @@ class NextcloudRepository(context: Context) { 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) { val c = client() val ics = IcsMapper.toIcs(item) - val saved = if (item.isNew) { - val (href, etag) = c.createItem(item.calendarUrl, ics, item.uid) - item.copy(href = href, etag = etag, rawIcs = ics) - } else { - val etag = c.updateItem(item.href, ics, item.etag) - item.copy(etag = etag, rawIcs = ics) + val saved = try { + if (item.isNew) { + val (href, etag) = c.createItem(item.calendarUrl, ics, item.uid) + item.copy(href = href, etag = etag, rawIcs = ics) + } else { + 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()) ReminderScheduler.reschedule(appContext, saved) @@ -192,7 +218,10 @@ class NextcloudRepository(context: Context) { start = start, end = end, allDay = allDay, due = due, completed = completed, status = runCatching { TaskStatus.valueOf(status) }.getOrDefault(TaskStatus.NEEDS_ACTION), 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( @@ -200,7 +229,9 @@ class NextcloudRepository(context: Context) { summary = summary, description = description, location = location, url = url, start = start, end = end, allDay = allDay, due = due, completed = completed, 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 = diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/repository/SaveConflictException.kt b/ncal/app/src/main/java/com/homelab/ncal/data/repository/SaveConflictException.kt new file mode 100644 index 0000000..0114467 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/data/repository/SaveConflictException.kt @@ -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) diff --git a/ncal/app/src/main/java/com/homelab/ncal/sync/SyncScheduler.kt b/ncal/app/src/main/java/com/homelab/ncal/sync/SyncScheduler.kt new file mode 100644 index 0000000..26850c8 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/sync/SyncScheduler.kt @@ -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(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) + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/sync/SyncWorker.kt b/ncal/app/src/main/java/com/homelab/ncal/sync/SyncWorker.kt new file mode 100644 index 0000000..447c584 --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/sync/SyncWorker.kt @@ -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() + } + } +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt index fa1d3ef..040e916 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt @@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons 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.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.KeyboardType 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.RecurrenceFrequency import com.homelab.ncal.data.model.TaskStatus import com.homelab.ncal.util.priorityColor import com.homelab.ncal.util.priorityLabel @@ -141,15 +146,23 @@ fun ItemEditScreen( if (item.type == ItemType.EVENT) { Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp)) - DateTimePicker( - millis = item.start, - onChange = { v -> viewModel.update { it.copy(start = v) } } - ) + if (item.isRecurring) { + RecurringDateNotice(item.start) + } 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)) - DateTimePicker( - millis = item.end, - onChange = { v -> viewModel.update { it.copy(end = v) } } - ) + if (item.isRecurring) { + RecurringDateNotice(item.end) + } else { + DateTimePicker( + millis = item.end, + onChange = { v -> viewModel.update { it.copy(end = v) } } + ) + } Row( verticalAlignment = Alignment.CenterVertically, @@ -163,15 +176,23 @@ fun ItemEditScreen( } } else { Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp)) - DateTimePicker( - millis = item.start, - onChange = { v -> viewModel.update { it.copy(start = v) } } - ) + if (item.isRecurring) { + RecurringDateNotice(item.start) + } 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)) - DateTimePicker( - millis = item.due, - onChange = { v -> viewModel.update { it.copy(due = v) } } - ) + if (item.isRecurring) { + RecurringDateNotice(item.due) + } else { + DateTimePicker( + millis = item.due, + onChange = { v -> viewModel.update { it.copy(due = v) } } + ) + } Text( "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( "Reminders", 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( + 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) { TaskStatus.NEEDS_ACTION -> "Not started" TaskStatus.IN_PROCESS -> "In progress" diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditViewModel.kt index 0c376ab..66fd24d 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditViewModel.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditViewModel.kt @@ -7,6 +7,7 @@ import com.homelab.ncal.data.model.CalendarCollection import com.homelab.ncal.data.model.CalendarItem import com.homelab.ncal.data.model.ItemType import com.homelab.ncal.data.repository.NextcloudRepository +import com.homelab.ncal.data.repository.SaveConflictException import com.homelab.ncal.util.toUserMessage import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -23,7 +24,8 @@ data class ItemEditUiState( val saving: Boolean = false, val error: String? = null, 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. */ val availableParentTasks: List get() { @@ -119,6 +121,9 @@ class ItemEditViewModel( try { repo.save(item) _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) { 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()) @@ -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() { val item = _state.value.item ?: return if (item.isNew) return diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginViewModel.kt index 4a985d3..922d940 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginViewModel.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/login/LoginViewModel.kt @@ -39,6 +39,7 @@ class LoginViewModel(private val repo: NextcloudRepository) : ViewModel() { val normalized = "https://" + s.serverUrl.removePrefix("https://").removePrefix("http://") repo.login(normalized, s.username, s.appPassword) repo.fullSync() + repo.scheduleBackgroundSync() _state.value = _state.value.copy(loading = false, success = true) } catch (e: Exception) { android.util.Log.e("NCalLogin", "Login/sync failed", e) diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksViewModel.kt index 363a0cd..6fa01d4 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksViewModel.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/ui/tasks/TasksViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.homelab.ncal.data.model.CalendarItem 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 kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -13,9 +15,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn 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( val tasks: List = emptyList(), val refreshing: Boolean = false, @@ -34,32 +33,9 @@ class TasksViewModel(private val repo: NextcloudRepository) : ViewModel() { } val state: StateFlow = combine(tasks, refreshing, error) { taskList, r, e -> - TasksUiState(buildTree(taskList), r, e) + TasksUiState(buildTaskTree(taskList), r, e) }.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): List { - val allUids = tasks.map { it.uid }.toSet() - val childrenByParent = tasks.groupBy { it.parentUid } - val visited = mutableSetOf() - val result = mutableListOf() - - 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) { viewModelScope.launch { try { diff --git a/ncal/app/src/main/java/com/homelab/ncal/util/ErrorMessages.kt b/ncal/app/src/main/java/com/homelab/ncal/util/ErrorMessages.kt index e13bb67..e667ff3 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/util/ErrorMessages.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/util/ErrorMessages.kt @@ -1,6 +1,7 @@ package com.homelab.ncal.util 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 @@ -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. */ 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) { 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." diff --git a/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt b/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt index 64c23fc..951e669 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt @@ -10,8 +10,11 @@ import biweekly.property.RelatedTo import biweekly.property.Status import biweekly.property.Trigger 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.ItemType +import com.homelab.ncal.data.model.RecurrenceFrequency import com.homelab.ncal.data.model.TaskStatus import java.util.Date import java.util.UUID @@ -33,6 +36,43 @@ object IcsMapper { 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. */ private fun alarmMinutesBefore(alarms: List): List = alarms.mapNotNull { alarm -> @@ -83,6 +123,11 @@ object IcsMapper { allDay = ev.dateStart?.value?.let { !hasTimeComponent(ics, "DTSTART") } ?: false, priority = ev.priority?.value ?: 0, reminderMinutes = alarmMinutesBefore(ev.alarms), + recurrenceFrequency = recurrence.toRecurrenceFrequency(), + recurrenceInterval = recurrence?.interval ?: 1, + recurrenceCount = recurrence?.count, + recurrenceUntil = recurrence?.until?.time, + isRecurring = recurrence != null, rawIcs = ics ) } @@ -132,6 +177,11 @@ object IcsMapper { priority = td.priority?.value ?: 0, parentUid = td.relatedTo.firstOrNull { it.relationshipType == null || it.relationshipType.value.equals("PARENT", ignoreCase = true) }?.value, reminderMinutes = alarmMinutesBefore(td.alarms), + recurrenceFrequency = recurrence.toRecurrenceFrequency(), + recurrenceInterval = recurrence?.interval ?: 1, + recurrenceCount = recurrence?.count, + recurrenceUntil = recurrence?.until?.time, + isRecurring = recurrence != null, rawIcs = ics ) } @@ -140,46 +190,85 @@ object IcsMapper { 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 { - val ical = ICalendar() + val ical = item.rawIcs?.takeIf { it.isNotBlank() }?.let { Biweekly.parse(it).first() } ?: ICalendar() when (item.type) { 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.setSummary(item.summary) - if (item.description.isNotBlank()) ev.setDescription(item.description) - if (item.location.isNotBlank()) ev.setLocation(item.location) - if (item.url.isNotBlank()) ev.setUrl(item.url) - item.start?.let { ev.setDateStart(Date(it), !item.allDay) } - item.end?.let { ev.setDateEnd(Date(it), !item.allDay) } - if (item.priority > 0) ev.setPriority(item.priority) + ev.setDescription(item.description.takeIf { it.isNotBlank() }) + ev.setLocation(item.location.takeIf { it.isNotBlank() }) + ev.setUrl(item.url.takeIf { it.isNotBlank() }) + if (!wasRecurring) { + ev.setDateStart(item.start?.let { Date(it) }, !item.allDay) + 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 -> val duration = Duration.builder().prior(true).minutes(minutes).build() ev.addAlarm(VAlarm.display(Trigger(duration, Related.START), item.summary)) } - ical.addEvent(ev) } 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.setSummary(item.summary) - if (item.description.isNotBlank()) td.setDescription(item.description) - if (item.location.isNotBlank()) td.setLocation(item.location) - if (item.url.isNotBlank()) td.setUrl(item.url) - item.start?.let { td.setDateStart(Date(it)) } - item.due?.let { td.setDateDue(Date(it)) } - if (item.priority > 0) td.setPriority(item.priority) + td.setDescription(item.description.takeIf { it.isNotBlank() }) + td.setLocation(item.location.takeIf { it.isNotBlank() }) + td.setUrl(item.url.takeIf { it.isNotBlank() }) + if (!wasRecurring) { + td.setDateStart(item.start?.let { Date(it) }) + td.setDateDue(item.due?.let { Date(it) }) + } + td.setPriority(item.priority.takeIf { it > 0 }) 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)) } + td.alarms.clear() if (item.due != null) { item.reminderMinutes.forEach { minutes -> val duration = Duration.builder().prior(true).minutes(minutes).build() td.addAlarm(VAlarm.display(Trigger(duration, Related.END), item.summary)) } } - ical.addTodo(td) } } return Biweekly.write(ical).go() diff --git a/ncal/app/src/main/java/com/homelab/ncal/util/TaskTree.kt b/ncal/app/src/main/java/com/homelab/ncal/util/TaskTree.kt new file mode 100644 index 0000000..3b58b5d --- /dev/null +++ b/ncal/app/src/main/java/com/homelab/ncal/util/TaskTree.kt @@ -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): List { + val allUids = tasks.map { it.uid }.toSet() + val childrenByParent = tasks.groupBy { it.parentUid } + val visited = mutableSetOf() + val result = mutableListOf() + + 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 +} diff --git a/ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidget.kt b/ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidget.kt index ddedeec..406729c 100644 --- a/ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidget.kt +++ b/ncal/app/src/main/java/com/homelab/ncal/widget/NextTasksWidget.kt @@ -36,41 +36,37 @@ import androidx.glance.text.TextStyle import com.homelab.ncal.MainActivity import com.homelab.ncal.NcalApplication 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 kotlinx.coroutines.flow.first import java.time.Instant -import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale internal val taskHrefKey = ActionParameters.Key("task_href") -/** Home screen widget listing incomplete tasks, grouped and ordered by due date - the widget - * equivalent of the in-app Tasks list, minus the subtask tree (kept flat/chronological here - * since the point of a widget is a quick "what's next" glance, not full hierarchy browsing). */ +/** Home screen widget listing incomplete tasks as a parent/child tree, ordered by due date at + * each level - the widget equivalent of the in-app Tasks list ([buildTaskTree] is shared by + * both, so they always render the same tree). Each row still shows its own due time inline. */ class NextTasksWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val repo = (context.applicationContext as NcalApplication).repository val urls = repo.observeCollections().first().filter { it.enabled }.map { it.url } - val zone = ZoneId.systemDefault() - val groups = 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 }) + // observeTasks() already orders by due date, which buildTaskTree preserves at each level. + val nodes = buildTaskTree(repo.observeTasks(urls).first().filter { !it.completed }) provideContent { - NextTasksContent(groups) + NextTasksContent(nodes) } } } @Composable -private fun NextTasksContent(groups: List>>) { +private fun NextTasksContent(nodes: List) { val context = LocalContext.current GlanceTheme { Column( @@ -85,30 +81,15 @@ private fun NextTasksContent(groups: List>>) style = TextStyle(color = GlanceTheme.colors.onSurface, fontWeight = FontWeight.Bold, fontSize = 14.sp), modifier = GlanceModifier.padding(bottom = 4.dp) ) - if (groups.isEmpty()) { + if (nodes.isEmpty()) { Text( "Nothing due", style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp) ) } else { LazyColumn(modifier = GlanceModifier.fillMaxSize()) { - groups.forEach { (date, tasksForDate) -> - item(itemId = date?.toEpochDay() ?: -1L) { - 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) - } + items(nodes, itemId = { it.item.href.hashCode().toLong() }) { node -> + TaskRow(node.item, node.depth) } } } @@ -117,7 +98,7 @@ private fun NextTasksContent(groups: List>>) } @Composable -private fun TaskRow(item: CalendarItem) { +private fun TaskRow(item: CalendarItem, depth: Int) { val context = LocalContext.current val intent = Intent(context, MainActivity::class.java).apply { putExtra(MainActivity.EXTRA_ITEM_TYPE, item.type.name) @@ -129,7 +110,7 @@ private fun TaskRow(item: CalendarItem) { .fillMaxWidth() .background(GlanceTheme.colors.background) .clickable(actionStartActivity(intent)) - .padding(vertical = 3.dp), + .padding(start = (depth * 12).dp, top = 3.dp, bottom = 3.dp), verticalAlignment = Alignment.CenterVertically ) { Box( diff --git a/ncal/app/src/test/java/com/homelab/ncal/util/IcsMapperTest.kt b/ncal/app/src/test/java/com/homelab/ncal/util/IcsMapperTest.kt new file mode 100644 index 0000000..f29f5c4 --- /dev/null +++ b/ncal/app/src/test/java/com/homelab/ncal/util/IcsMapperTest.kt @@ -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")) + } +} diff --git a/ncal/app/src/test/java/com/homelab/ncal/util/TaskTreeTest.kt b/ncal/app/src/test/java/com/homelab/ncal/util/TaskTreeTest.kt new file mode 100644 index 0000000..17f30dd --- /dev/null +++ b/ncal/app/src/test/java/com/homelab/ncal/util/TaskTreeTest.kt @@ -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) + } +}