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:
RomanNum3ral 2026-07-16 21:02:48 -04:00
parent 75f5e6024b
commit 14ee70d71d
Signed by: RomanNum3ral
GPG Key ID: B94562C9B7535805
22 changed files with 930 additions and 128 deletions

3
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -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")
} }

View File

@ -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)

View File

@ -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() {

View File

@ -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?
) )

View File

@ -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()

View File

@ -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)

View File

@ -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> =

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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()
}
}
}

View File

@ -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"

View File

@ -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

View File

@ -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)

View File

@ -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 {

View File

@ -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."

View File

@ -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()

View File

@ -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
}

View File

@ -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(

View File

@ -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"))
}
}

View File

@ -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)
}
}