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)
/ncal/keystore/
/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
`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

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

View File

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

View File

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

View File

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

View File

@ -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<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 isNew: Boolean get() = href.isEmpty()

View File

@ -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<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?) {
val builder = Request.Builder()
.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.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,18 +153,35 @@ 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 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)
updateWidgets()
@ -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<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.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))
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))
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))
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))
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<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) {
TaskStatus.NEEDS_ACTION -> "Not started"
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.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<CalendarItem> 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

View File

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

View File

@ -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<TaskNode> = emptyList(),
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 ->
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<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) {
viewModelScope.launch {
try {

View File

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

View File

@ -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<VAlarm>): List<Int> =
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()

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.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<String>("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<Pair<LocalDate?, List<CalendarItem>>>) {
private fun NextTasksContent(nodes: List<TaskNode>) {
val context = LocalContext.current
GlanceTheme {
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),
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<Pair<LocalDate?, List<CalendarItem>>>)
}
@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(

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