diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
new file mode 100644
index 0000000..625d2c0
--- /dev/null
+++ b/.gitea/workflows/test.yml
@@ -0,0 +1,28 @@
+name: Tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ unit-tests:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: ncal
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+
+ - name: Set up Android SDK
+ uses: android-actions/setup-android@v3
+
+ - name: Run unit tests
+ run: ./gradlew test --stacktrace
diff --git a/ncal/app/build.gradle.kts b/ncal/app/build.gradle.kts
index fada0f2..2ea2630 100644
--- a/ncal/app/build.gradle.kts
+++ b/ncal/app/build.gradle.kts
@@ -118,4 +118,5 @@ dependencies {
implementation("net.zetetic:android-database-sqlcipher:4.5.4")
testImplementation("junit:junit:4.13.2")
+ testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
}
diff --git a/ncal/app/src/main/AndroidManifest.xml b/ncal/app/src/main/AndroidManifest.xml
index b59e295..1377317 100644
--- a/ncal/app/src/main/AndroidManifest.xml
+++ b/ncal/app/src/main/AndroidManifest.xml
@@ -28,6 +28,16 @@
+
+
+
+
diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt b/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt
index 3672f70..3c20558 100644
--- a/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt
+++ b/ncal/app/src/main/java/com/homelab/ncal/data/db/AppDatabase.kt
@@ -8,7 +8,7 @@ import net.sqlcipher.database.SupportFactory
@Database(
entities = [CollectionEntity::class, ItemEntity::class],
- version = 6,
+ version = 9,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
@@ -18,7 +18,18 @@ abstract class AppDatabase : RoomDatabase() {
companion object {
@Volatile private var instance: AppDatabase? = null
- /** [passphrase] encrypts the database file at rest via SQLCipher - see [com.homelab.ncal.data.prefs.SecurePrefs.databaseKey]. */
+ /**
+ * [passphrase] encrypts the database file at rest via SQLCipher - see
+ * [com.homelab.ncal.data.prefs.SecurePrefs.databaseKey].
+ *
+ * [fallbackToDestructiveMigration] has been safe so far because this DB is purely a
+ * re-fetchable mirror of server state - a schema bump just means one extra resync.
+ * That's no longer strictly true as of the `syncStatus` column (v7): a `PENDING_UPDATE`/
+ * `PENDING_DELETE` row is local-only data (an offline edit/delete not yet pushed) that a
+ * destructive wipe would genuinely lose, not just re-fetch. Worth revisiting with a real
+ * `Migration` if that turns out to matter in practice - for now the destructive fallback
+ * stays for consistency with the rest of the schema history.
+ */
fun get(context: Context, passphrase: ByteArray): AppDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt b/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt
index 96b64bb..f6c96aa 100644
--- a/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt
+++ b/ncal/app/src/main/java/com/homelab/ncal/data/db/ItemEntity.kt
@@ -32,22 +32,25 @@ data class ItemEntity(
val recurrenceInterval: Int,
val recurrenceCount: Int?,
val recurrenceUntil: Long?,
+ val recurrenceByDay: String, // comma-separated java.time.DayOfWeek names, "" if none (WEEKLY only)
val isRecurring: Boolean,
val recurrenceMasterStart: Long?,
val recurrenceMasterEnd: Long?,
val recurrenceMasterDue: Long?,
+ val occurrenceDate: Long?, // RECURRENCE-ID anchor for "edit/delete this event only" - see CalendarItem
+ val syncStatus: String, // SyncStatus name - PENDING_DELETE rows are hidden from every observeXxx query
val rawIcs: String?
)
@Dao
interface ItemDao {
- @Query("SELECT * FROM items WHERE calendarUrl IN (:calendarUrls) ORDER BY COALESCE(start, due) ASC")
+ @Query("SELECT * FROM items WHERE calendarUrl IN (:calendarUrls) AND syncStatus != 'PENDING_DELETE' ORDER BY COALESCE(start, due) ASC")
fun observeForCalendars(calendarUrls: List): Flow>
- @Query("SELECT * FROM items WHERE type = 'TASK' AND calendarUrl IN (:calendarUrls) ORDER BY completed ASC, COALESCE(due, 9223372036854775807) ASC")
+ @Query("SELECT * FROM items WHERE type = 'TASK' AND calendarUrl IN (:calendarUrls) AND syncStatus != 'PENDING_DELETE' ORDER BY completed ASC, COALESCE(due, 9223372036854775807) ASC")
fun observeTasks(calendarUrls: List): Flow>
- @Query("SELECT * FROM items WHERE type = 'EVENT' AND calendarUrl IN (:calendarUrls) ORDER BY start ASC")
+ @Query("SELECT * FROM items WHERE type = 'EVENT' AND calendarUrl IN (:calendarUrls) AND syncStatus != 'PENDING_DELETE' ORDER BY start ASC")
fun observeEvents(calendarUrls: List): Flow>
@Query("SELECT * FROM items WHERE href = :href LIMIT 1")
@@ -56,6 +59,12 @@ interface ItemDao {
@Query("SELECT * FROM items")
suspend fun getAllOnce(): List
+ @Query("SELECT * FROM items WHERE calendarUrl = :calendarUrl AND syncStatus != 'PENDING_DELETE' ORDER BY COALESCE(start, due) ASC")
+ suspend fun getForCalendarOnce(calendarUrl: String): List
+
+ @Query("SELECT * FROM items WHERE syncStatus != 'SYNCED'")
+ suspend fun getPendingSync(): List
+
@Upsert
suspend fun upsertAll(items: List)
diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt b/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt
index cdcdb3b..8cae10b 100644
--- a/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt
+++ b/ncal/app/src/main/java/com/homelab/ncal/data/model/CalendarItem.kt
@@ -1,5 +1,7 @@
package com.homelab.ncal.data.model
+import java.time.DayOfWeek
+
enum class ItemType { EVENT, TASK }
/** Mirrors the iCalendar VTODO STATUS values (tasks only). */
@@ -10,6 +12,16 @@ enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED }
* round-trips fine via [CalendarItem.rawIcs] - it just isn't editable through the picker. */
enum class RecurrenceFrequency { DAILY, WEEKLY, MONTHLY, YEARLY }
+/**
+ * Whether a local copy of an existing item matches the server (SYNCED), has local edits the
+ * server hasn't seen yet because the save happened while offline (PENDING_UPDATE), or is queued
+ * for deletion on the server once back online (PENDING_DELETE - hidden from every list in the
+ * meantime). Only applies to *existing* items (a real href already assigned); creating a brand
+ * new item still requires connectivity, since there's nothing to key an offline-only row on
+ * until the server assigns the real href.
+ */
+enum class SyncStatus { SYNCED, PENDING_UPDATE, PENDING_DELETE }
+
/**
* 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
@@ -43,6 +55,7 @@ data class CalendarItem(
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 recurrenceByDay: Set = emptySet(), // WEEKLY only: which days ("every Mon/Wed/Fri") - empty means every occurrence of the interval, i.e. just DTSTART's own weekday
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 - see recurrenceMaster* below for that
@@ -55,6 +68,15 @@ data class CalendarItem(
val recurrenceMasterEnd: Long? = null, // events only
val recurrenceMasterDue: Long? = null, // tasks only
+ // The RRULE-computed slot (RECURRENCE-ID) this specific occurrence falls on - only meaningful
+ // when isRecurring. Stable across edits to [start]/[due] (unlike those fields, which change
+ // when the user moves *this* occurrence), so it's the anchor used to write/find this
+ // occurrence's override VEVENT/VTODO for "edit/delete this event only". Null for a
+ // non-recurring item, or a recurring item whose series has already ended.
+ val occurrenceDate: Long? = null,
+
+ val syncStatus: SyncStatus = SyncStatus.SYNCED,
+
val rawIcs: String? = null
) {
val isNew: Boolean get() = href.isEmpty()
diff --git a/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt b/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt
index a929d19..3e3d1e3 100644
--- a/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt
+++ b/ncal/app/src/main/java/com/homelab/ncal/data/repository/NextcloudRepository.kt
@@ -8,6 +8,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.model.RecurrenceFrequency
+import com.homelab.ncal.data.model.SyncStatus
import com.homelab.ncal.data.model.TaskStatus
import com.homelab.ncal.data.network.CalDavClient
import com.homelab.ncal.data.network.CalDavException
@@ -18,16 +19,44 @@ import com.homelab.ncal.util.IcsMapper
import com.homelab.ncal.widget.MonthGridWidget
import com.homelab.ncal.widget.NextTasksWidget
import androidx.glance.appwidget.updateAll
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
+import java.io.IOException
+import java.util.UUID
class NextcloudRepository(context: Context) {
private val appContext = context.applicationContext
private val prefs = SecurePrefs(context)
private val db = AppDatabase.get(context, prefs.databaseKey)
+ private val repoScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ /**
+ * Serializes [syncItems] against [save]/[delete]/[undoDelete] so a pull from the server can
+ * never race a concurrent write. Without this, a full sync's `pruneMissing` step can act on a
+ * collection snapshot fetched *before* a concurrent write lands, and delete the just-written
+ * row right back out again - this is exactly what happens to an [undoDelete] recreate if it
+ * runs while the delete's own post-navigation refresh is still in flight.
+ */
+ private val writeMutex = Mutex()
+
+ /** The most recently deleted item, for the "Undo" snackbar - see [undoDelete]. Cleared
+ * automatically a few seconds after each delete, or immediately once undone. */
+ private val _lastDeleted = MutableStateFlow(null)
+ val lastDeleted: StateFlow = _lastDeleted.asStateFlow()
+ private var lastDeletedClearJob: Job? = null
private fun client(): CalDavClient {
val server = prefs.serverUrl ?: error("Not logged in")
@@ -104,8 +133,20 @@ class NextcloudRepository(context: Context) {
db.itemDao().getAllOnce().map { it.toModel() }
}
- /** Pulls fresh events + tasks for every enabled collection. */
+ /**
+ * Pulls fresh events + tasks for every enabled collection. Holds [writeMutex] for its whole
+ * duration - see the field doc for why a concurrent [save]/[delete]/[undoDelete] must never
+ * interleave with this.
+ */
suspend fun syncItems() = withContext(Dispatchers.IO) {
+ writeMutex.withLock {
+ // Push any offline edits/deletes first - otherwise the pull below would overwrite them
+ // with the (pre-edit) server copy. Anything that's still pending after this (still
+ // offline, or a conflict that needs the user's attention) is explicitly protected from
+ // the pull further down rather than silently clobbered.
+ flushPendingChanges()
+ val stillPendingHrefs = db.itemDao().getPendingSync().map { it.href }.toSet()
+
val c = client()
val collections = db.collectionDao().getAll().filter { it.enabled }
android.util.Log.d("NCalSync", "Syncing ${collections.size} enabled collection(s): ${collections.map { "${it.displayName}(events=${it.supportsEvents},tasks=${it.supportsTasks})" }}")
@@ -126,7 +167,7 @@ class NextcloudRepository(context: Context) {
keepHrefs += remote.map { it.href.toAbsolute() }
val entities = remote.mapNotNull { dav ->
IcsMapper.parse(dav.calendarData!!, dav.href.toAbsolute(), dav.etag, col.url)?.toEntity()
- }
+ }.filter { it.href !in stillPendingHrefs }
android.util.Log.d("NCalSync", "${col.displayName}: fetched ${remote.size} VEVENT(s), parsed ${entities.size}")
db.itemDao().upsertAll(entities)
}
@@ -135,7 +176,7 @@ class NextcloudRepository(context: Context) {
keepHrefs += remote.map { it.href.toAbsolute() }
val entities = remote.mapNotNull { dav ->
IcsMapper.parse(dav.calendarData!!, dav.href.toAbsolute(), dav.etag, col.url)?.toEntity()
- }
+ }.filter { it.href !in stillPendingHrefs }
android.util.Log.d("NCalSync", "${col.displayName}: fetched ${remote.size} VTODO(s), parsed ${entities.size}")
db.itemDao().upsertAll(entities)
}
@@ -146,6 +187,49 @@ class NextcloudRepository(context: Context) {
ReminderScheduler.rescheduleAll(appContext, db.itemDao().getAllOnce().map { it.toModel() })
updateWidgets()
+ }
+ }
+
+ /**
+ * Pushes every locally-pending offline edit/delete to the server. Called automatically at
+ * the start of every [syncItems] (app open, pull-to-refresh, periodic background sync), so
+ * an offline save/delete gets flushed the next time the device has connectivity without any
+ * extra user action. A row that's still pending afterwards - still offline, or a genuine
+ * conflict was hit - is left exactly as it was for the next attempt; conflicts aren't
+ * auto-resolved here since there's no UI to ask the user from a background sync.
+ */
+ private suspend fun flushPendingChanges() {
+ val pending = db.itemDao().getPendingSync().map { it.toModel() }
+ if (pending.isEmpty()) return
+ android.util.Log.d("NCalSync", "Flushing ${pending.size} pending offline change(s)")
+
+ val c = client()
+ for (item in pending) {
+ try {
+ when (item.syncStatus) {
+ SyncStatus.PENDING_UPDATE -> {
+ val ics = item.rawIcs ?: IcsMapper.toIcs(item)
+ val etag = c.updateItem(item.href, ics, item.etag)
+ val synced = item.copy(etag = etag, rawIcs = ics, syncStatus = SyncStatus.SYNCED)
+ db.itemDao().upsert(synced.toEntity())
+ ReminderScheduler.reschedule(appContext, synced)
+ }
+ SyncStatus.PENDING_DELETE -> {
+ c.deleteItem(item.href, item.etag)
+ db.itemDao().deleteByHref(item.href)
+ }
+ SyncStatus.SYNCED -> Unit // getPendingSync() excludes these; unreachable
+ }
+ } catch (e: CalDavException) {
+ // A conflict (412) or a real server error (403/500/etc) - leave it pending rather
+ // than guess. The user will see it again (and get the normal conflict dialog) the
+ // next time they open and re-save this item through the edit screen.
+ android.util.Log.w("NCalSync", "Pending change for ${item.href} could not be flushed (HTTP ${e.httpCode}) - leaving pending", e)
+ } catch (e: IOException) {
+ android.util.Log.d("NCalSync", "Still offline - ${item.href} stays pending")
+ }
+ }
+ updateWidgets()
}
suspend fun fullSync() {
@@ -156,21 +240,29 @@ class NextcloudRepository(context: Context) {
/**
* Creates or updates an item both on the server and in the local cache.
*
+ * If the write fails purely because the device has no connectivity ([IOException] other than
+ * a [CalDavException], which means an HTTP response - even an error one - was received),
+ * *and* the item already exists (a brand new item has no href to key an offline-only row on,
+ * so creating one still requires connectivity), the edit is saved locally as
+ * [SyncStatus.PENDING_UPDATE] instead of being lost, and gets pushed automatically the next
+ * time [syncItems] runs with connectivity back - see [flushPendingChanges].
+ *
* @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) {
+ writeMutex.withLock {
val c = client()
val ics = IcsMapper.toIcs(item)
val saved = try {
if (item.isNew) {
val (href, etag) = c.createItem(item.calendarUrl, ics, item.uid)
- item.copy(href = href, etag = etag, rawIcs = ics)
+ item.copy(href = href, etag = etag, rawIcs = ics, syncStatus = SyncStatus.SYNCED)
} else {
val etag = c.updateItem(item.href, ics, item.etag)
- item.copy(etag = etag, rawIcs = ics)
+ item.copy(etag = etag, rawIcs = ics, syncStatus = SyncStatus.SYNCED)
}
} catch (e: CalDavException) {
if (e.httpCode == 412 && !item.isNew) {
@@ -181,25 +273,218 @@ class NextcloudRepository(context: Context) {
throw SaveConflictException(localEdit = item, serverVersion = serverVersion, cause = e)
}
throw e
+ } catch (e: IOException) {
+ if (item.isNew) throw e
+ item.copy(rawIcs = ics, syncStatus = SyncStatus.PENDING_UPDATE)
}
db.itemDao().upsert(saved.toEntity())
ReminderScheduler.reschedule(appContext, saved)
updateWidgets()
saved
+ }
}
- suspend fun delete(item: CalendarItem) = withContext(Dispatchers.IO) {
- if (!item.isNew) {
- client().deleteItem(item.href, item.etag)
+ /**
+ * Deletes an item both on the server and in the local cache. If the device has no
+ * connectivity, an *existing* item is instead marked [SyncStatus.PENDING_DELETE] (hidden
+ * from every list immediately, actually removed once the delete reaches the server via
+ * [flushPendingChanges]) rather than the delete silently failing.
+ *
+ * @return true if the delete actually reached the server, false if it was only queued
+ * locally (offline) - lets the caller show "deleted" vs "will delete once back online".
+ */
+ suspend fun delete(item: CalendarItem): Boolean = withContext(Dispatchers.IO) {
+ writeMutex.withLock {
+ if (item.isNew) {
+ db.itemDao().deleteByHref(item.href)
+ ReminderScheduler.cancel(appContext, item)
+ updateWidgets()
+ return@withContext true
+ }
+ val deletedOnServer = try {
+ client().deleteItem(item.href, item.etag)
+ db.itemDao().deleteByHref(item.href)
+ true
+ } catch (e: CalDavException) {
+ throw e
+ } catch (e: IOException) {
+ db.itemDao().upsert(item.copy(syncStatus = SyncStatus.PENDING_DELETE).toEntity())
+ false
}
- db.itemDao().deleteByHref(item.href)
ReminderScheduler.cancel(appContext, item)
updateWidgets()
+ announceDeleted(item)
+ deletedOnServer
+ }
+ }
+
+ private fun announceDeleted(item: CalendarItem) {
+ lastDeletedClearJob?.cancel()
+ _lastDeleted.value = item
+ lastDeletedClearJob = repoScope.launch {
+ delay(6000)
+ _lastDeleted.value = null
+ }
+ }
+
+ /**
+ * Reverses the most recent [delete] (within its short display window). If the delete never
+ * actually reached the server (offline - the row is still sitting locally as
+ * [SyncStatus.PENDING_DELETE]), this is a free local-only flip back to synced. Otherwise the
+ * item is genuinely gone from the server, so it's recreated there as a brand new resource -
+ * with a *fresh* UID, not the original one. Reusing the old UID reliably fails on Nextcloud:
+ * its calendar trashbin keeps deleted objects (and their UID) around for 30 days, so the
+ * server rejects a same-UID recreate with an HTTP 500 unique-constraint violation. The user
+ * never sees the UID, so a new one is harmless here.
+ */
+ fun undoDelete() {
+ val item = _lastDeleted.value ?: return
+ lastDeletedClearJob?.cancel()
+ _lastDeleted.value = null
+ repoScope.launch {
+ // Also serialized against syncItems()/save()/delete() - same reasoning as the field
+ // doc on writeMutex: this must not read-then-write across a concurrent sync's own
+ // fetch/prune pass.
+ val restoredLocally = writeMutex.withLock {
+ val current = db.itemDao().getByHref(item.href)
+ if (current != null && current.syncStatus == SyncStatus.PENDING_DELETE.name) {
+ val restored = current.copy(syncStatus = SyncStatus.SYNCED.name)
+ db.itemDao().upsert(restored)
+ ReminderScheduler.reschedule(appContext, restored.toModel())
+ updateWidgets()
+ true
+ } else {
+ false
+ }
+ }
+ if (!restoredLocally) {
+ try {
+ save(item.copy(href = "", etag = null, uid = UUID.randomUUID().toString(), syncStatus = SyncStatus.SYNCED))
+ } catch (e: Exception) {
+ android.util.Log.e("NCalError", "undoDelete() failed to recreate uid=${item.uid}", e)
+ }
+ }
+ }
}
suspend fun toggleTaskComplete(item: CalendarItem): CalendarItem =
save(item.withStatus(if (item.completed) TaskStatus.NEEDS_ACTION else TaskStatus.COMPLETED))
+ // ---------- Single-occurrence ("this event only") edits ----------
+
+ /**
+ * Saves changes to just the currently-displayed occurrence of a recurring [item], via a
+ * RECURRENCE-ID override - see [IcsMapper.buildOccurrenceOverrideIcs]. The master (and every
+ * other occurrence in the series) is untouched. Unlike [save], there's no offline fallback
+ * here - an occurrence-level edit made while offline just fails with the usual connectivity
+ * error rather than queueing, since [SyncStatus.PENDING_UPDATE] was designed around whole-
+ * resource edits and doesn't have a way to represent "this one occurrence has an unsynced
+ * override" without a larger model change.
+ */
+ suspend fun saveOccurrence(item: CalendarItem): CalendarItem = withContext(Dispatchers.IO) {
+ writeMutex.withLock {
+ val c = client()
+ val ics = IcsMapper.buildOccurrenceOverrideIcs(item)
+ val saved = try {
+ val etag = c.updateItem(item.href, ics, item.etag)
+ item.copy(etag = etag, rawIcs = ics, syncStatus = SyncStatus.SYNCED)
+ } catch (e: CalDavException) {
+ if (e.httpCode == 412) {
+ 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()
+ saved
+ }
+ }
+
+ /**
+ * Deletes just the currently-displayed occurrence of a recurring [item], via EXDATE - see
+ * [IcsMapper.buildOccurrenceDeletionIcs]. Returns whatever the series' next occurrence now
+ * resolves to (so the caller can e.g. show it was deleted and the list moves on), or null if
+ * that was the last remaining occurrence in the series. No "Undo" snackbar for this path
+ * (unlike [delete]) - EXDATE/override edits aren't a fit for the whole-resource undo
+ * mechanism built around [lastDeleted].
+ */
+ suspend fun deleteOccurrence(item: CalendarItem): CalendarItem? = withContext(Dispatchers.IO) {
+ writeMutex.withLock {
+ val c = client()
+ val ics = IcsMapper.buildOccurrenceDeletionIcs(item)
+ val etag = try {
+ c.updateItem(item.href, ics, item.etag)
+ } catch (e: CalDavException) {
+ if (e.httpCode == 412) {
+ 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
+ }
+ val serverItem = IcsMapper.parse(ics, item.href, etag, item.calendarUrl)
+ if (serverItem != null) {
+ db.itemDao().upsert(serverItem.toEntity())
+ ReminderScheduler.reschedule(appContext, serverItem)
+ } else {
+ db.itemDao().deleteByHref(item.href)
+ ReminderScheduler.cancel(appContext, item)
+ }
+ updateWidgets()
+ serverItem
+ }
+ }
+
+ // ---------- Import / export ----------
+
+ /** Combines every cached item in [calendarUrl] into one multi-component .ics document. */
+ suspend fun exportIcs(calendarUrl: String): String = withContext(Dispatchers.IO) {
+ val items = db.itemDao().getForCalendarOnce(calendarUrl).map { it.toModel() }
+ IcsMapper.buildCombinedIcs(items)
+ }
+
+ data class ImportResult(val imported: Int, val skipped: Int, val failed: Int)
+
+ /**
+ * Imports every VEVENT/VTODO in [ics] as a brand-new item in [calendarUrl] - see
+ * [IcsMapper.parseAllForImport] for why these are always fresh creations (new UID, no href)
+ * rather than an attempt to preserve/merge with anything already on the server. An entry
+ * whose type the target calendar doesn't support (e.g. a VTODO imported into an events-only
+ * calendar) is counted as skipped, not failed.
+ */
+ suspend fun importIcs(ics: String, calendarUrl: String): ImportResult = withContext(Dispatchers.IO) {
+ val collection = db.collectionDao().getAll().firstOrNull { it.url == calendarUrl }
+ val candidates = IcsMapper.parseAllForImport(ics, calendarUrl)
+ var imported = 0
+ var skipped = 0
+ var failed = 0
+ for (item in candidates) {
+ val supported = when (item.type) {
+ ItemType.EVENT -> collection?.supportsEvents ?: true
+ ItemType.TASK -> collection?.supportsTasks ?: true
+ }
+ if (!supported) {
+ skipped++
+ continue
+ }
+ try {
+ save(item)
+ imported++
+ } catch (e: Exception) {
+ android.util.Log.e("NCalError", "importIcs: failed to save uid=${item.uid}", e)
+ failed++
+ }
+ }
+ ImportResult(imported, skipped, failed)
+ }
+
private suspend fun updateWidgets() {
MonthGridWidget().updateAll(appContext)
NextTasksWidget().updateAll(appContext)
@@ -221,8 +506,11 @@ class NextcloudRepository(context: Context) {
parentUid = parentUid, reminderMinutes = reminderMinutes.toMinutesList(),
recurrenceFrequency = recurrenceFrequency?.let { runCatching { RecurrenceFrequency.valueOf(it) }.getOrNull() },
recurrenceInterval = recurrenceInterval, recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil,
+ recurrenceByDay = recurrenceByDay.toDayOfWeekSet(),
isRecurring = isRecurring,
recurrenceMasterStart = recurrenceMasterStart, recurrenceMasterEnd = recurrenceMasterEnd, recurrenceMasterDue = recurrenceMasterDue,
+ occurrenceDate = occurrenceDate,
+ syncStatus = runCatching { SyncStatus.valueOf(syncStatus) }.getOrDefault(SyncStatus.SYNCED),
rawIcs = rawIcs
)
@@ -233,14 +521,20 @@ class NextcloudRepository(context: Context) {
status = status.name, percentComplete = percentComplete,
priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(),
recurrenceFrequency = recurrenceFrequency?.name, recurrenceInterval = recurrenceInterval,
- recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil, isRecurring = isRecurring,
+ recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil,
+ recurrenceByDay = recurrenceByDay.joinToString(",") { it.name }, isRecurring = isRecurring,
recurrenceMasterStart = recurrenceMasterStart, recurrenceMasterEnd = recurrenceMasterEnd, recurrenceMasterDue = recurrenceMasterDue,
+ occurrenceDate = occurrenceDate,
+ syncStatus = syncStatus.name,
rawIcs = rawIcs
)
private fun String.toMinutesList(): List =
if (isBlank()) emptyList() else split(",").mapNotNull { it.trim().toIntOrNull() }
+ private fun String.toDayOfWeekSet(): Set =
+ if (isBlank()) emptySet() else split(",").mapNotNull { runCatching { java.time.DayOfWeek.valueOf(it.trim()) }.getOrNull() }.toSet()
+
private fun List.toCsv(): String = joinToString(",")
/** Server sometimes returns relative hrefs; normalize against the calendar home. */
diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt
index a5c4f4e..4e8211a 100644
--- a/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt
+++ b/ncal/app/src/main/java/com/homelab/ncal/ui/agenda/AgendaScreen.kt
@@ -16,7 +16,9 @@ import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.ViewWeek
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -52,7 +54,9 @@ fun AgendaScreen(
onNewEvent: () -> Unit,
onOpenTasks: () -> Unit,
onOpenCollections: () -> Unit,
- onOpenMonth: () -> Unit
+ onOpenMonth: () -> Unit,
+ onOpenWeek: () -> Unit,
+ onOpenSearch: () -> Unit
) {
val state by viewModel.state.collectAsState()
@@ -65,6 +69,8 @@ fun AgendaScreen(
CenterAlignedTopAppBar(
title = { Text("Agenda") },
actions = {
+ IconButton(onClick = onOpenSearch) { Icon(Icons.Filled.Search, "Search") }
+ IconButton(onClick = onOpenWeek) { Icon(Icons.Filled.ViewWeek, "Week view") }
IconButton(onClick = onOpenMonth) { Icon(Icons.Filled.CalendarMonth, "Month view") }
IconButton(onClick = onOpenTasks) { Icon(Icons.Filled.List, "Tasks") }
IconButton(onClick = onOpenCollections) { Icon(Icons.Filled.Settings, "Calendars") }
diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsScreen.kt
index 1d9ce81..3955698 100644
--- a/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsScreen.kt
+++ b/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsScreen.kt
@@ -1,15 +1,25 @@
package com.homelab.ncal.ui.collections
+import android.content.Intent
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.FileDownload
+import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
@@ -20,13 +30,23 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
+import androidx.core.content.FileProvider
+import com.homelab.ncal.data.model.CalendarCollection
+import kotlinx.coroutines.launch
+import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -35,6 +55,14 @@ fun CollectionsScreen(
onBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ var pendingImportUri by remember { mutableStateOf(null) }
+
+ val openDocument = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) pendingImportUri = uri
+ }
Scaffold(
topBar = {
@@ -44,6 +72,9 @@ fun CollectionsScreen(
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) }
},
actions = {
+ IconButton(onClick = { openDocument.launch(arrayOf("text/calendar", "*/*")) }) {
+ Icon(Icons.Filled.FileUpload, "Import .ics")
+ }
IconButton(onClick = viewModel::refresh) { Icon(Icons.Filled.Refresh, "Refresh") }
}
)
@@ -56,6 +87,15 @@ fun CollectionsScreen(
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp))
}
+ state.importStatus?.let {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween
+ ) {
+ Text(it, color = MaterialTheme.colorScheme.primary)
+ TextButton(onClick = viewModel::dismissImportStatus) { Text("Dismiss") }
+ }
+ }
LazyColumn {
items(state.collections, key = { it.url }) { col ->
ListItem(
@@ -69,16 +109,83 @@ fun CollectionsScreen(
}
},
trailingContent = {
- Checkbox(
- checked = col.enabled,
- onCheckedChange = { viewModel.setEnabled(col.url, it) }
- )
+ Row {
+ IconButton(onClick = {
+ scope.launch {
+ val ics = viewModel.exportIcs(col.url)
+ shareIcs(context, ics, col.displayName)
+ }
+ }) {
+ Icon(Icons.Filled.FileDownload, "Export ${col.displayName}")
+ }
+ Checkbox(
+ checked = col.enabled,
+ onCheckedChange = { viewModel.setEnabled(col.url, it) }
+ )
+ }
}
)
}
}
}
}
+
+ pendingImportUri?.let { uri ->
+ ImportTargetDialog(
+ collections = state.collections,
+ onDismiss = { pendingImportUri = null },
+ onSelect = { calendarUrl ->
+ val text = runCatching {
+ context.contentResolver.openInputStream(uri)?.bufferedReader()?.readText()
+ }.getOrNull()
+ pendingImportUri = null
+ if (text != null) {
+ viewModel.importIcs(text, calendarUrl)
+ }
+ }
+ )
+ }
+}
+
+@Composable
+private fun ImportTargetDialog(
+ collections: List,
+ onDismiss: () -> Unit,
+ onSelect: (String) -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Import into which calendar?") },
+ text = {
+ Column {
+ collections.forEach { col ->
+ ListItem(
+ headlineContent = { Text(col.displayName) },
+ supportingContent = { Text(componentLabel(col.supportsEvents, col.supportsTasks)) },
+ modifier = Modifier.fillMaxWidth().clickable { onSelect(col.url) }
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text("Cancel") }
+ }
+ )
+}
+
+private fun shareIcs(context: android.content.Context, ics: String, calendarName: String) {
+ val dir = File(context.cacheDir, "exports").apply { mkdirs() }
+ val safeName = calendarName.replace(Regex("[^A-Za-z0-9._-]"), "_")
+ val file = File(dir, "$safeName.ics")
+ file.writeText(ics)
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/calendar"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, "Export $calendarName"))
}
private fun componentLabel(events: Boolean, tasks: Boolean): String = when {
diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsViewModel.kt
index 4f2a82d..11a9cb0 100644
--- a/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsViewModel.kt
+++ b/ncal/app/src/main/java/com/homelab/ncal/ui/collections/CollectionsViewModel.kt
@@ -14,18 +14,20 @@ import kotlinx.coroutines.launch
data class CollectionsUiState(
val collections: List = emptyList(),
val refreshing: Boolean = false,
- val error: String? = null
+ val error: String? = null,
+ val importStatus: String? = null
)
class CollectionsViewModel(private val repo: NextcloudRepository) : ViewModel() {
private val refreshing = MutableStateFlow(false)
private val error = MutableStateFlow(null)
+ private val importStatus = MutableStateFlow(null)
val state: StateFlow = combine(
- repo.observeCollections(), refreshing, error
- ) { collections, isRefreshing, err ->
- CollectionsUiState(collections, isRefreshing, err)
+ repo.observeCollections(), refreshing, error, importStatus
+ ) { collections, isRefreshing, err, importMsg ->
+ CollectionsUiState(collections, isRefreshing, err, importMsg)
}.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), CollectionsUiState())
fun refresh() {
@@ -59,4 +61,27 @@ class CollectionsViewModel(private val repo: NextcloudRepository) : ViewModel()
}
}
}
+
+ suspend fun exportIcs(url: String): String = repo.exportIcs(url)
+
+ fun importIcs(ics: String, calendarUrl: String) {
+ viewModelScope.launch {
+ try {
+ val result = repo.importIcs(ics, calendarUrl)
+ importStatus.value = buildString {
+ append("Imported ${result.imported} item(s)")
+ if (result.skipped > 0) append(", skipped ${result.skipped} (unsupported type)")
+ if (result.failed > 0) append(", ${result.failed} failed")
+ }
+ error.value = null
+ } catch (e: Exception) {
+ android.util.Log.e("NCalError", "importIcs failed for $calendarUrl", e)
+ error.value = e.toUserMessage()
+ }
+ }
+ }
+
+ fun dismissImportStatus() {
+ importStatus.value = null
+ }
}
diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt
index e2bdb19..bf9b3ff 100644
--- a/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt
+++ b/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditScreen.kt
@@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -27,6 +28,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -58,6 +60,8 @@ import com.homelab.ncal.util.priorityColor
import com.homelab.ncal.util.priorityLabel
import com.homelab.ncal.util.statusLabel
import java.text.SimpleDateFormat
+import java.time.DayOfWeek
+import java.time.temporal.WeekFields
import java.util.Calendar
import java.util.Date
import java.util.Locale
@@ -70,8 +74,16 @@ fun ItemEditScreen(
) {
val state by viewModel.state.collectAsState()
var showDeleteConfirm by remember { mutableStateOf(false) }
+ var showSaveScopeConfirm by remember { mutableStateOf(false) }
+ val context = androidx.compose.ui.platform.LocalContext.current
LaunchedEffect(state.done, state.deleted) {
+ if (state.savedOffline) {
+ android.widget.Toast.makeText(context, "Saved offline — will sync when you're back online", android.widget.Toast.LENGTH_LONG).show()
+ }
+ if (state.deletedOffline) {
+ android.widget.Toast.makeText(context, "Will delete once you're back online", android.widget.Toast.LENGTH_LONG).show()
+ }
if (state.done || state.deleted) onDone()
}
@@ -148,27 +160,21 @@ fun ItemEditScreen(
if (item.type == ItemType.EVENT) {
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
- if (item.isRecurring) {
- RecurringDateTimePicker(
- millis = item.recurrenceMasterStart,
- onChange = { v -> viewModel.update { it.copy(recurrenceMasterStart = v) } }
- )
- } else {
- DateTimePicker(
- millis = item.start,
- onChange = { v -> viewModel.update { it.copy(start = v) } }
- )
- }
+ DateTimePicker(
+ millis = item.start,
+ onChange = { v -> viewModel.update { it.copy(start = v) } }
+ )
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
+ DateTimePicker(
+ millis = item.end,
+ onChange = { v -> viewModel.update { it.copy(end = v) } }
+ )
if (item.isRecurring) {
- RecurringDateTimePicker(
- millis = item.recurrenceMasterEnd,
- onChange = { v -> viewModel.update { it.copy(recurrenceMasterEnd = v) } }
- )
- } else {
- DateTimePicker(
- millis = item.end,
- onChange = { v -> viewModel.update { it.copy(end = v) } }
+ Text(
+ "You'll be asked whether a date change applies to just this event or the whole series.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 4.dp)
)
}
@@ -184,27 +190,21 @@ fun ItemEditScreen(
}
} else {
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
- if (item.isRecurring) {
- RecurringDateTimePicker(
- millis = item.recurrenceMasterStart,
- onChange = { v -> viewModel.update { it.copy(recurrenceMasterStart = v) } }
- )
- } else {
- DateTimePicker(
- millis = item.start,
- onChange = { v -> viewModel.update { it.copy(start = v) } }
- )
- }
+ DateTimePicker(
+ millis = item.start,
+ onChange = { v -> viewModel.update { it.copy(start = v) } }
+ )
Text("Due", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
+ DateTimePicker(
+ millis = item.due,
+ onChange = { v -> viewModel.update { it.copy(due = v) } }
+ )
if (item.isRecurring) {
- RecurringDateTimePicker(
- millis = item.recurrenceMasterDue,
- onChange = { v -> viewModel.update { it.copy(recurrenceMasterDue = v) } }
- )
- } else {
- DateTimePicker(
- millis = item.due,
- onChange = { v -> viewModel.update { it.copy(due = v) } }
+ Text(
+ "You'll be asked whether a date change applies to just this task or the whole series.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 4.dp)
)
}
@@ -305,7 +305,10 @@ fun ItemEditScreen(
}
androidx.compose.material3.Button(
- onClick = viewModel::save,
+ onClick = {
+ val canSave = item.summary.isNotBlank() && item.calendarUrl.isNotBlank()
+ if (item.isRecurring && canSave) showSaveScopeConfirm = true else viewModel.save()
+ },
enabled = !state.saving,
modifier = Modifier.fillMaxWidth().padding(top = 24.dp)
) {
@@ -317,23 +320,74 @@ fun ItemEditScreen(
}
}
- if (showDeleteConfirm) {
+ if (showSaveScopeConfirm) {
AlertDialog(
- onDismissRequest = { showDeleteConfirm = false },
- title = { Text("Delete this ${if (item.type == ItemType.TASK) "task" else "event"}?") },
- text = { Text("This can't be undone.") },
- confirmButton = {
- TextButton(onClick = {
- showDeleteConfirm = false
- viewModel.delete()
- }) { Text("Delete") }
+ onDismissRequest = { showSaveScopeConfirm = false },
+ title = { Text("Save changes to...") },
+ text = {
+ Column {
+ TextButton(
+ onClick = { showSaveScopeConfirm = false; viewModel.saveThisOccurrenceOnly() },
+ modifier = Modifier.fillMaxWidth()
+ ) { Text("This event only") }
+ TextButton(
+ onClick = { showSaveScopeConfirm = false; viewModel.save() },
+ modifier = Modifier.fillMaxWidth()
+ ) { Text("All events") }
+ }
},
+ confirmButton = {},
dismissButton = {
- TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") }
+ TextButton(onClick = { showSaveScopeConfirm = false }) { Text("Cancel") }
}
)
}
+ if (showDeleteConfirm) {
+ if (item.isRecurring) {
+ AlertDialog(
+ onDismissRequest = { showDeleteConfirm = false },
+ title = { Text("Delete this ${if (item.type == ItemType.TASK) "task" else "event"}?") },
+ text = {
+ Column {
+ Text(
+ "This is part of a repeating series. You can undo this for a few seconds after deleting.",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(bottom = 12.dp)
+ )
+ TextButton(
+ onClick = { showDeleteConfirm = false; viewModel.deleteThisOccurrence() },
+ modifier = Modifier.fillMaxWidth()
+ ) { Text("This event only") }
+ TextButton(
+ onClick = { showDeleteConfirm = false; viewModel.deleteAllOccurrences() },
+ modifier = Modifier.fillMaxWidth()
+ ) { Text("All events") }
+ }
+ },
+ confirmButton = {},
+ dismissButton = {
+ TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") }
+ }
+ )
+ } else {
+ AlertDialog(
+ onDismissRequest = { showDeleteConfirm = false },
+ title = { Text("Delete this ${if (item.type == ItemType.TASK) "task" else "event"}?") },
+ text = { Text("You can undo this for a few seconds after deleting.") },
+ confirmButton = {
+ TextButton(onClick = {
+ showDeleteConfirm = false
+ viewModel.deleteAllOccurrences()
+ }) { Text("Delete") }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") }
+ }
+ )
+ }
+ }
+
state.conflict?.let { conflict ->
if (conflict.serverVersion != null && state.conflictFields.isNotEmpty()) {
ConflictMergeDialog(
@@ -520,22 +574,6 @@ private fun PriorityPicker(selected: Int, onSelect: (Int) -> Unit) {
}
}
-/** Edits a recurring item's true master date ([CalendarItem.recurrenceMasterStart] etc.) - NOT
- * the same field the non-recurring [DateTimePicker] call sites bind to, since that one holds
- * the resolved next occurrence rather than the series' actual start. */
-@Composable
-private fun RecurringDateTimePicker(millis: Long?, onChange: (Long?) -> Unit) {
- Column {
- DateTimePicker(millis = millis, onChange = onChange)
- Text(
- "This changes the whole series, not just one occurrence.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(bottom = 4.dp)
- )
- }
-}
-
private val recurrenceFrequencyLabels = linkedMapOf(
null to "Does not repeat",
RecurrenceFrequency.DAILY to "Daily",
@@ -586,9 +624,9 @@ private fun RecurrencePicker(
onClick = {
onUpdate { cur ->
if (freq == null) {
- cur.copy(recurrenceFrequency = null, recurrenceCount = null, recurrenceUntil = null)
+ cur.copy(recurrenceFrequency = null, recurrenceCount = null, recurrenceUntil = null, recurrenceByDay = emptySet())
} else {
- cur.copy(recurrenceFrequency = freq)
+ cur.copy(recurrenceFrequency = freq, recurrenceByDay = if (freq == RecurrenceFrequency.WEEKLY) cur.recurrenceByDay else emptySet())
}
}
expanded = false
@@ -614,6 +652,18 @@ private fun RecurrencePicker(
Text(intervalUnitLabel(freq, item.recurrenceInterval), modifier = Modifier.padding(start = 8.dp))
}
+ if (freq == RecurrenceFrequency.WEEKLY) {
+ Text("Repeats on", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
+ WeekdayChipRow(
+ selected = item.recurrenceByDay,
+ onToggle = { day ->
+ onUpdate { cur ->
+ cur.copy(recurrenceByDay = if (day in cur.recurrenceByDay) cur.recurrenceByDay - day else cur.recurrenceByDay + day)
+ }
+ }
+ )
+ }
+
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
RecurrenceEndPicker(
count = item.recurrenceCount,
@@ -625,6 +675,26 @@ private fun RecurrencePicker(
}
}
+/** Toggleable Mon-Sun (locale-ordered) chips for a WEEKLY rule's BYDAY. An empty [selected] set
+ * means "just DTSTART's own weekday" (no BYDAY written at all) - matches the pre-BYDAY behavior. */
+@Composable
+private fun WeekdayChipRow(selected: Set, onToggle: (DayOfWeek) -> Unit) {
+ val firstDow = WeekFields.of(Locale.getDefault()).firstDayOfWeek
+ val days = (0..6).map { firstDow.plus(it.toLong()) }
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ days.forEach { day ->
+ FilterChip(
+ selected = day in selected,
+ onClick = { onToggle(day) },
+ label = { Text(day.getDisplayName(java.time.format.TextStyle.NARROW, Locale.getDefault())) }
+ )
+ }
+ }
+}
+
private enum class RecurrenceEndMode { NEVER, AFTER_COUNT, ON_DATE }
@OptIn(ExperimentalMaterial3Api::class)
@@ -787,13 +857,21 @@ private fun reminderLabel(minutes: Int): String = when {
else -> "$minutes minutes before"
}
+private enum class ReminderUnit(val label: String, val minutes: Int) {
+ MINUTES("minutes", 1),
+ HOURS("hours", 60),
+ DAYS("days", 1440),
+ WEEKS("weeks", 10080)
+}
+
@Composable
private fun AddReminderButton(existing: List, onAdd: (Int) -> Unit) {
var expanded by remember { mutableStateOf(false) }
+ var showCustom by remember { mutableStateOf(false) }
val available = reminderPresets.filter { it !in existing }
Box {
- TextButton(onClick = { expanded = true }, enabled = available.isNotEmpty()) {
+ TextButton(onClick = { expanded = true }) {
Text("+ Add reminder")
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
@@ -806,6 +884,79 @@ private fun AddReminderButton(existing: List, onAdd: (Int) -> Unit) {
}
)
}
+ DropdownMenuItem(
+ text = { Text("Custom…") },
+ onClick = {
+ showCustom = true
+ expanded = false
+ }
+ )
+ }
+ }
+
+ if (showCustom) {
+ CustomReminderRow(
+ onAdd = { minutes ->
+ onAdd(minutes)
+ showCustom = false
+ },
+ onCancel = { showCustom = false }
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CustomReminderRow(onAdd: (Int) -> Unit, onCancel: () -> Unit) {
+ var amountText by remember { mutableStateOf("10") }
+ var unit by remember { mutableStateOf(ReminderUnit.MINUTES) }
+ var unitExpanded by remember { mutableStateOf(false) }
+ val amount = amountText.toIntOrNull()
+
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
+ OutlinedTextField(
+ value = amountText,
+ onValueChange = { v -> if (v.length <= 5) amountText = v.filter { it.isDigit() } },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.width(80.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ ExposedDropdownMenuBox(
+ expanded = unitExpanded,
+ onExpandedChange = { unitExpanded = it },
+ modifier = Modifier.width(150.dp)
+ ) {
+ OutlinedTextField(
+ value = unit.label,
+ onValueChange = {},
+ readOnly = true,
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitExpanded) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .menuAnchor(MenuAnchorType.PrimaryNotEditable)
+ )
+ ExposedDropdownMenu(expanded = unitExpanded, onDismissRequest = { unitExpanded = false }) {
+ ReminderUnit.entries.forEach { u ->
+ DropdownMenuItem(
+ text = { Text(u.label) },
+ onClick = { unit = u; unitExpanded = false }
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("before", modifier = Modifier.padding(end = 8.dp))
+ }
+ Row(modifier = Modifier.padding(top = 4.dp)) {
+ TextButton(
+ onClick = { amount?.let { onAdd(it * unit.minutes) } },
+ enabled = amount != null && amount > 0
+ ) {
+ Text("Add")
+ }
+ TextButton(onClick = onCancel) {
+ Text("Cancel")
}
}
}
diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditViewModel.kt
index 0c71ab3..7c0d02f 100644
--- a/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditViewModel.kt
+++ b/ncal/app/src/main/java/com/homelab/ncal/ui/detail/ItemEditViewModel.kt
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
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.SyncStatus
import com.homelab.ncal.data.repository.NextcloudRepository
import com.homelab.ncal.data.repository.SaveConflictException
import com.homelab.ncal.util.ConflictField
@@ -27,6 +28,8 @@ data class ItemEditUiState(
val error: String? = null,
val done: Boolean = false,
val deleted: Boolean = false,
+ val savedOffline: Boolean = false, // true if [done] happened via a local-only pending save (no connectivity)
+ val deletedOffline: Boolean = false, // true if [deleted] happened via a local-only pending delete
val conflict: SaveConflictException? = null,
val conflictFields: List = emptyList(),
val conflictChoices: Map = emptyMap() // field key -> true means "take theirs"
@@ -110,6 +113,12 @@ class ItemEditViewModel(
_state.value = _state.value.copy(item = _state.value.item?.let(transform))
}
+ /** True once a save/delete has actually gone out as a single-occurrence ("this event only")
+ * write, so a conflict retry ([keepMyChanges]/[resolveConflictMerge]) repeats the SAME scope
+ * instead of silently falling back to a whole-series edit. */
+ private var pendingScopeWasOccurrenceOnly = false
+
+ /** Saves the whole series (or a non-recurring item, the common case). */
fun save() {
val item = _state.value.item ?: return
if (item.summary.isBlank()) {
@@ -120,11 +129,54 @@ class ItemEditViewModel(
_state.value = _state.value.copy(error = "Pick a calendar first")
return
}
+ commitSave(applyMasterDateShift(item))
+ }
+
+ /** Saves changes to just this occurrence of a recurring item, via a RECURRENCE-ID override. */
+ fun saveThisOccurrenceOnly() {
+ val item = _state.value.item ?: return
+ if (item.summary.isBlank()) {
+ _state.value = _state.value.copy(error = "Title is required")
+ return
+ }
+ commitOccurrenceSave(item)
+ }
+
+ /**
+ * The visible Starts/Ends/Due fields are bound to *this occurrence's* date
+ * ([CalendarItem.start]/[end]/[due]), not the series' master date - editing them and then
+ * choosing "all events" needs to shift the master by however much the user just moved this
+ * occurrence, using [CalendarItem.occurrenceDate] (the occurrence's original, un-edited slot)
+ * as the anchor to measure that shift against. A pure non-date edit (title, etc.) leaves
+ * start == occurrenceDate, so the computed delta is zero and the master date is untouched.
+ */
+ private fun applyMasterDateShift(item: CalendarItem): CalendarItem {
+ val occurrenceDate = item.occurrenceDate ?: return item
+ return when (item.type) {
+ ItemType.EVENT -> {
+ val delta = (item.start ?: occurrenceDate) - occurrenceDate
+ if (delta == 0L) item else item.copy(
+ recurrenceMasterStart = item.recurrenceMasterStart?.plus(delta),
+ recurrenceMasterEnd = item.recurrenceMasterEnd?.plus(delta)
+ )
+ }
+ ItemType.TASK -> {
+ val delta = (item.due ?: occurrenceDate) - occurrenceDate
+ if (delta == 0L) item else item.copy(
+ recurrenceMasterStart = item.recurrenceMasterStart?.plus(delta),
+ recurrenceMasterDue = item.recurrenceMasterDue?.plus(delta)
+ )
+ }
+ }
+ }
+
+ private fun commitSave(item: CalendarItem) {
+ pendingScopeWasOccurrenceOnly = false
_state.value = _state.value.copy(saving = true, error = null)
viewModelScope.launch {
try {
- repo.save(item)
- _state.value = _state.value.copy(saving = false, done = true)
+ val saved = repo.save(item)
+ _state.value = _state.value.copy(saving = false, done = true, savedOffline = saved.syncStatus == SyncStatus.PENDING_UPDATE)
} catch (e: SaveConflictException) {
val fields = e.serverVersion?.let { diffConflictFields(item, it) } ?: emptyList()
android.util.Log.d(
@@ -139,16 +191,38 @@ class ItemEditViewModel(
}
}
+ private fun commitOccurrenceSave(item: CalendarItem) {
+ pendingScopeWasOccurrenceOnly = true
+ _state.value = _state.value.copy(saving = true, error = null)
+ viewModelScope.launch {
+ try {
+ repo.saveOccurrence(item)
+ _state.value = _state.value.copy(saving = false, done = true)
+ } catch (e: SaveConflictException) {
+ val fields = e.serverVersion?.let { diffConflictFields(item, it) } ?: emptyList()
+ android.util.Log.d(
+ "NCalConflict",
+ "saveThisOccurrenceOnly() 412 conflict for href=${item.href}, server version present=${e.serverVersion != null}, differing fields=${fields.map { it.key }}"
+ )
+ _state.value = _state.value.copy(saving = false, conflict = e, conflictFields = fields, conflictChoices = emptyMap())
+ } catch (e: Exception) {
+ android.util.Log.e("NCalError", "saveThisOccurrenceOnly() failed for href=${item.href} calendar=${item.calendarUrl}", e)
+ _state.value = _state.value.copy(saving = false, error = e.toUserMessage())
+ }
+ }
+ }
+
/** Conflict resolution (simple path - used when there's nothing to diff, e.g. the item was
* deleted server-side): retry the write with the server's current etag, overwriting
* whatever changed there with this edit. */
fun keepMyChanges() {
val conflict = _state.value.conflict ?: return
+ val retryItem = conflict.localEdit.copy(etag = conflict.serverVersion?.etag)
_state.value = _state.value.copy(
conflict = null, conflictFields = emptyList(), conflictChoices = emptyMap(),
- item = conflict.localEdit.copy(etag = conflict.serverVersion?.etag)
+ item = retryItem
)
- save()
+ if (pendingScopeWasOccurrenceOnly) commitOccurrenceSave(retryItem) else commitSave(retryItem)
}
/** Conflict resolution (simple path): discard this edit and load whatever's on the server
@@ -186,19 +260,40 @@ class ItemEditViewModel(
}
}
_state.value = _state.value.copy(conflict = null, conflictFields = emptyList(), conflictChoices = emptyMap(), item = merged)
- save()
+ if (pendingScopeWasOccurrenceOnly) commitOccurrenceSave(merged) else commitSave(merged)
}
- fun delete() {
+ /** Deletes the whole series (or a non-recurring item, the common case). */
+ fun deleteAllOccurrences() {
val item = _state.value.item ?: return
if (item.isNew) return
_state.value = _state.value.copy(saving = true, error = null)
viewModelScope.launch {
try {
- repo.delete(item)
- _state.value = _state.value.copy(saving = false, deleted = true)
+ val deletedOnServer = repo.delete(item)
+ _state.value = _state.value.copy(saving = false, deleted = true, deletedOffline = !deletedOnServer)
} catch (e: Exception) {
- android.util.Log.e("NCalError", "delete() failed for href=${item.href} calendar=${item.calendarUrl}", e)
+ android.util.Log.e("NCalError", "deleteAllOccurrences() failed for href=${item.href} calendar=${item.calendarUrl}", e)
+ _state.value = _state.value.copy(saving = false, error = e.toUserMessage())
+ }
+ }
+ }
+
+ /** Deletes just this occurrence of a recurring item, via EXDATE. The series (and any other
+ * occurrence) is untouched. */
+ fun deleteThisOccurrence() {
+ val item = _state.value.item ?: return
+ if (item.isNew || item.occurrenceDate == null) return
+ _state.value = _state.value.copy(saving = true, error = null)
+ viewModelScope.launch {
+ try {
+ repo.deleteOccurrence(item)
+ _state.value = _state.value.copy(saving = false, deleted = true)
+ } catch (e: SaveConflictException) {
+ val fields = e.serverVersion?.let { diffConflictFields(item, it) } ?: emptyList()
+ _state.value = _state.value.copy(saving = false, conflict = e, conflictFields = fields, conflictChoices = emptyMap())
+ } catch (e: Exception) {
+ android.util.Log.e("NCalError", "deleteThisOccurrence() failed for href=${item.href} calendar=${item.calendarUrl}", e)
_state.value = _state.value.copy(saving = false, error = e.toUserMessage())
}
}
diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt
index 674eed9..53525d6 100644
--- a/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt
+++ b/ncal/app/src/main/java/com/homelab/ncal/ui/nav/NcalNavGraph.kt
@@ -1,7 +1,21 @@
package com.homelab.ncal.ui.nav
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@@ -21,8 +35,12 @@ import com.homelab.ncal.ui.login.LoginScreen
import com.homelab.ncal.ui.login.LoginViewModel
import com.homelab.ncal.ui.month.MonthScreen
import com.homelab.ncal.ui.month.MonthViewModel
+import com.homelab.ncal.ui.search.SearchScreen
+import com.homelab.ncal.ui.search.SearchViewModel
import com.homelab.ncal.ui.tasks.TasksScreen
import com.homelab.ncal.ui.tasks.TasksViewModel
+import com.homelab.ncal.ui.week.WeekScreen
+import com.homelab.ncal.ui.week.WeekViewModel
import java.net.URLEncoder
private object Routes {
@@ -30,7 +48,9 @@ private object Routes {
const val AGENDA = "agenda"
const val TASKS = "tasks"
const val MONTH = "month"
+ const val WEEK = "week"
const val COLLECTIONS = "collections"
+ const val SEARCH = "search"
const val EDIT = "edit/{type}/{href}/{calendarUrl}"
}
@@ -56,69 +76,121 @@ fun NcalNavGraph(
}
}
- NavHost(navController = navController, startDestination = startDestination) {
-
- composable(Routes.LOGIN) {
- val vm: LoginViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> LoginViewModel(repo) })
- LoginScreen(
- viewModel = vm,
- onLoggedIn = {
- navController.navigate(Routes.AGENDA) {
- popUpTo(Routes.LOGIN) { inclusive = true }
- }
- }
- )
- }
-
- composable(Routes.AGENDA) {
- val vm: AgendaViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> AgendaViewModel(repo) })
- AgendaScreen(
- viewModel = vm,
- onOpenItem = { item: CalendarItem ->
- navController.navigate(editRoute(item.type.name, item.href))
- },
- onNewEvent = { navController.navigate(editRoute("EVENT", null)) },
- onOpenTasks = { navController.navigate(Routes.TASKS) },
- onOpenCollections = { navController.navigate(Routes.COLLECTIONS) },
- onOpenMonth = { navController.navigate(Routes.MONTH) }
- )
- }
-
- composable(Routes.MONTH) {
- val vm: MonthViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> MonthViewModel(repo) })
- MonthScreen(
- viewModel = vm,
- onOpenItem = { item -> navController.navigate(editRoute(item.type.name, item.href)) },
- onNewEvent = { navController.navigate(editRoute("EVENT", null)) },
- onBack = { navController.popBackStack() }
- )
- }
-
- composable(Routes.TASKS) {
- val vm: TasksViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> TasksViewModel(repo) })
- TasksScreen(
- viewModel = vm,
- onOpenTask = { item -> navController.navigate(editRoute("TASK", item.href)) },
- onNewTask = { navController.navigate(editRoute("TASK", null)) },
- onBack = { navController.popBackStack() }
- )
- }
-
- composable(Routes.COLLECTIONS) {
- val vm: CollectionsViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> CollectionsViewModel(repo) })
- CollectionsScreen(viewModel = vm, onBack = { navController.popBackStack() })
- }
-
- composable(
- route = Routes.EDIT,
- arguments = listOf(
- navArgument("type") { type = NavType.StringType },
- navArgument("href") { type = NavType.StringType },
- navArgument("calendarUrl") { type = NavType.StringType }
- )
- ) {
- val vm: ItemEditViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, handle -> ItemEditViewModel(repo, handle) })
- ItemEditScreen(viewModel = vm, onDone = { navController.popBackStack() })
+ // Central "Undo" snackbar for deletes - lives above the NavHost so it survives the
+ // pop-back-stack navigation that follows every delete (the screen that triggered the
+ // delete is usually gone by the time the user could tap Undo).
+ val snackbarHostState = remember { SnackbarHostState() }
+ val lastDeleted by app.repository.lastDeleted.collectAsState()
+ LaunchedEffect(lastDeleted) {
+ val item = lastDeleted ?: return@LaunchedEffect
+ val label = item.summary.ifBlank { "Deleted" }
+ val result = snackbarHostState.showSnackbar(
+ message = "Deleted \"$label\"",
+ actionLabel = "Undo",
+ duration = SnackbarDuration.Short
+ )
+ if (result == SnackbarResult.ActionPerformed) {
+ app.repository.undoDelete()
}
}
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ NavHost(navController = navController, startDestination = startDestination) {
+
+ composable(Routes.LOGIN) {
+ val vm: LoginViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> LoginViewModel(repo) })
+ LoginScreen(
+ viewModel = vm,
+ onLoggedIn = {
+ navController.navigate(Routes.AGENDA) {
+ popUpTo(Routes.LOGIN) { inclusive = true }
+ }
+ }
+ )
+ }
+
+ composable(Routes.AGENDA) {
+ val vm: AgendaViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> AgendaViewModel(repo) })
+ AgendaScreen(
+ viewModel = vm,
+ onOpenItem = { item: CalendarItem ->
+ navController.navigate(editRoute(item.type.name, item.href))
+ },
+ onNewEvent = { navController.navigate(editRoute("EVENT", null)) },
+ onOpenTasks = { navController.navigate(Routes.TASKS) },
+ onOpenCollections = { navController.navigate(Routes.COLLECTIONS) },
+ onOpenMonth = { navController.navigate(Routes.MONTH) },
+ onOpenWeek = { navController.navigate(Routes.WEEK) },
+ onOpenSearch = { navController.navigate(Routes.SEARCH) }
+ )
+ }
+
+ composable(Routes.SEARCH) {
+ val vm: SearchViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> SearchViewModel(repo) })
+ SearchScreen(
+ viewModel = vm,
+ onOpenItem = { item -> navController.navigate(editRoute(item.type.name, item.href)) },
+ onBack = { navController.popBackStack() }
+ )
+ }
+
+ composable(Routes.MONTH) {
+ val vm: MonthViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> MonthViewModel(repo) })
+ MonthScreen(
+ viewModel = vm,
+ onOpenItem = { item -> navController.navigate(editRoute(item.type.name, item.href)) },
+ onNewEvent = { navController.navigate(editRoute("EVENT", null)) },
+ onBack = { navController.popBackStack() }
+ )
+ }
+
+ composable(Routes.WEEK) {
+ val vm: WeekViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> WeekViewModel(repo) })
+ WeekScreen(
+ viewModel = vm,
+ onOpenItem = { item -> navController.navigate(editRoute(item.type.name, item.href)) },
+ onNewEvent = { navController.navigate(editRoute("EVENT", null)) },
+ onBack = { navController.popBackStack() }
+ )
+ }
+
+ composable(Routes.TASKS) {
+ val vm: TasksViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> TasksViewModel(repo) })
+ TasksScreen(
+ viewModel = vm,
+ onOpenTask = { item -> navController.navigate(editRoute("TASK", item.href)) },
+ onNewTask = { navController.navigate(editRoute("TASK", null)) },
+ onBack = { navController.popBackStack() }
+ )
+ }
+
+ composable(Routes.COLLECTIONS) {
+ val vm: CollectionsViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> CollectionsViewModel(repo) })
+ CollectionsScreen(viewModel = vm, onBack = { navController.popBackStack() })
+ }
+
+ composable(
+ route = Routes.EDIT,
+ arguments = listOf(
+ navArgument("type") { type = NavType.StringType },
+ navArgument("href") { type = NavType.StringType },
+ navArgument("calendarUrl") { type = NavType.StringType }
+ )
+ ) {
+ val vm: ItemEditViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, handle -> ItemEditViewModel(repo, handle) })
+ ItemEditScreen(viewModel = vm, onDone = { navController.popBackStack() })
+ }
+ }
+ SnackbarHost(
+ hostState = snackbarHostState,
+ // navigationBarsPadding() alone clears the gesture-nav strip but lands right on top
+ // of the Agenda/Month FABs (this snackbar lives above the NavHost, so it doesn't get
+ // Scaffold's usual automatic FAB-avoidance) - the extra fixed padding clears a
+ // standard-size FAB too.
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .navigationBarsPadding()
+ .padding(bottom = 80.dp)
+ )
+ }
}
diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/search/SearchScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/search/SearchScreen.kt
new file mode 100644
index 0000000..7c5bcd2
--- /dev/null
+++ b/ncal/app/src/main/java/com/homelab/ncal/ui/search/SearchScreen.kt
@@ -0,0 +1,137 @@
+package com.homelab.ncal.ui.search
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.Event
+import androidx.compose.material.icons.filled.RadioButtonUnchecked
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.dp
+import com.homelab.ncal.data.model.CalendarItem
+import com.homelab.ncal.data.model.ItemType
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SearchScreen(
+ viewModel: SearchViewModel,
+ onOpenItem: (CalendarItem) -> Unit,
+ onBack: () -> Unit
+) {
+ val query by viewModel.query.collectAsState()
+ val results by viewModel.results.collectAsState()
+ val focusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ Scaffold(
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = { Text("Search") },
+ navigationIcon = {
+ IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") }
+ }
+ )
+ }
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize()) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = viewModel::setQuery,
+ singleLine = true,
+ placeholder = { Text("Search events and tasks") },
+ trailingIcon = {
+ if (query.isNotEmpty()) {
+ IconButton(onClick = { viewModel.setQuery("") }) {
+ Icon(Icons.Filled.Clear, "Clear")
+ }
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .focusRequester(focusRequester)
+ )
+
+ when {
+ query.trim().length < 2 -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Type at least 2 characters to search", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ results.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("No matches", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ else -> LazyColumn {
+ items(results, key = { it.href.ifEmpty { it.uid } }) { result ->
+ SearchResultRow(item = result, onClick = { onOpenItem(result) })
+ HorizontalDivider()
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SearchResultRow(item: CalendarItem, onClick: () -> Unit) {
+ ListItem(
+ modifier = Modifier.clickable(onClick = onClick),
+ headlineContent = {
+ Text(item.summary, textDecoration = if (item.completed) TextDecoration.LineThrough else null)
+ },
+ supportingContent = {
+ val whenText = item.start?.let { formatDateTime(it, item.allDay) }
+ ?: item.due?.let { "Due " + formatDateTime(it, false) }
+ whenText?.let { Text(it) }
+ },
+ leadingContent = {
+ if (item.type == ItemType.TASK) {
+ Icon(
+ imageVector = if (item.completed) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ } else {
+ Icon(Icons.Filled.Event, contentDescription = null)
+ }
+ }
+ )
+}
+
+private fun formatDateTime(millis: Long, allDay: Boolean): String {
+ val zone = ZoneId.systemDefault()
+ val instant = Instant.ofEpochMilli(millis)
+ val pattern = if (allDay) "MMM d, yyyy" else "MMM d, yyyy h:mm a"
+ return instant.atZone(zone).format(DateTimeFormatter.ofPattern(pattern))
+}
diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/search/SearchViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/search/SearchViewModel.kt
new file mode 100644
index 0000000..0554e4a
--- /dev/null
+++ b/ncal/app/src/main/java/com/homelab/ncal/ui/search/SearchViewModel.kt
@@ -0,0 +1,46 @@
+package com.homelab.ncal.ui.search
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.homelab.ncal.data.model.CalendarItem
+import com.homelab.ncal.data.repository.NextcloudRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.stateIn
+
+class SearchViewModel(private val repo: NextcloudRepository) : ViewModel() {
+
+ private val _query = MutableStateFlow("")
+ val query: StateFlow = _query.asStateFlow()
+
+ private val allItems = repo.observeCollections()
+ .flatMapLatest { collections ->
+ val urls = collections.filter { it.enabled }.map { it.url }
+ repo.observeItems(urls)
+ }
+
+ val results: StateFlow> = combine(_query, allItems) { q, items ->
+ val needle = q.trim()
+ if (needle.length < 2) {
+ emptyList()
+ } else {
+ items.filter { matches(it, needle) }
+ .sortedByDescending { it.start ?: it.due ?: 0L }
+ }
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+
+ fun setQuery(q: String) {
+ _query.value = q
+ }
+
+ private fun matches(item: CalendarItem, needle: String): Boolean {
+ val n = needle.lowercase()
+ return item.summary.lowercase().contains(n) ||
+ item.description.lowercase().contains(n) ||
+ item.location.lowercase().contains(n)
+ }
+}
diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/week/WeekScreen.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/week/WeekScreen.kt
new file mode 100644
index 0000000..6c27065
--- /dev/null
+++ b/ncal/app/src/main/java/com/homelab/ncal/ui/week/WeekScreen.kt
@@ -0,0 +1,180 @@
+package com.homelab.ncal.ui.week
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.ChevronLeft
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.Event
+import androidx.compose.material.icons.filled.RadioButtonUnchecked
+import androidx.compose.material.icons.filled.Today
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.homelab.ncal.data.model.CalendarItem
+import com.homelab.ncal.data.model.ItemType
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WeekScreen(
+ viewModel: WeekViewModel,
+ onOpenItem: (CalendarItem) -> Unit,
+ onNewEvent: () -> Unit,
+ onBack: () -> Unit
+) {
+ val state by viewModel.state.collectAsState()
+
+ LaunchedEffect(Unit) {
+ viewModel.refresh()
+ }
+
+ Scaffold(
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = { Text(weekRangeLabel(state.weekStart)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) }
+ },
+ actions = {
+ IconButton(onClick = viewModel::goToToday) { Icon(Icons.Filled.Today, "Today") }
+ }
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(onClick = onNewEvent) { Icon(Icons.Filled.Add, "New event") }
+ }
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize()) {
+ state.error?.let {
+ Text(
+ it,
+ color = MaterialTheme.colorScheme.error,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ WeekHeader(onPrev = viewModel::prevWeek, onNext = viewModel::nextWeek)
+ PullToRefreshBox(
+ isRefreshing = state.refreshing,
+ onRefresh = viewModel::refresh,
+ modifier = Modifier.weight(1f)
+ ) {
+ LazyColumn {
+ items(state.itemsByDay.entries.toList(), key = { it.key.toString() }) { (day, dayItems) ->
+ DaySection(
+ day = day,
+ items = dayItems,
+ onOpenItem = onOpenItem,
+ onToggleTask = viewModel::toggleTask
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun WeekHeader(onPrev: () -> Unit, onNext: () -> Unit) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ IconButton(onClick = onPrev) { Icon(Icons.Filled.ChevronLeft, "Previous week") }
+ IconButton(onClick = onNext) { Icon(Icons.Filled.ChevronRight, "Next week") }
+ }
+}
+
+@Composable
+private fun DaySection(
+ day: LocalDate,
+ items: List,
+ onOpenItem: (CalendarItem) -> Unit,
+ onToggleTask: (CalendarItem) -> Unit
+) {
+ val today = LocalDate.now()
+ Column {
+ Text(
+ day.format(DateTimeFormatter.ofPattern("EEEE, MMM d")),
+ style = MaterialTheme.typography.titleSmall,
+ color = if (day == today) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
+ )
+ if (items.isEmpty()) {
+ Text(
+ "Nothing scheduled",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
+ )
+ }
+ items.forEach { item ->
+ ListItem(
+ modifier = Modifier.clickable { onOpenItem(item) },
+ headlineContent = {
+ Text(item.summary, textDecoration = if (item.completed) TextDecoration.LineThrough else null)
+ },
+ supportingContent = {
+ val time = item.start?.let { formatTime(it, item.allDay) }
+ ?: item.due?.let { "Due " + formatTime(it, false) }
+ time?.let { Text(it) }
+ },
+ leadingContent = {
+ if (item.type == ItemType.TASK) {
+ Icon(
+ imageVector = if (item.completed) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked,
+ contentDescription = "Toggle complete",
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.clickable { onToggleTask(item) }
+ )
+ } else {
+ Icon(Icons.Filled.Event, contentDescription = null)
+ }
+ }
+ )
+ }
+ HorizontalDivider()
+ }
+}
+
+private fun weekRangeLabel(weekStart: LocalDate): String {
+ val weekEnd = weekStart.plusDays(6)
+ val startFmt = if (weekStart.year == weekEnd.year) "MMM d" else "MMM d, yyyy"
+ val endFmt = if (weekStart.month == weekEnd.month && weekStart.year == weekEnd.year) "d, yyyy" else "MMM d, yyyy"
+ return "${weekStart.format(DateTimeFormatter.ofPattern(startFmt))} – ${weekEnd.format(DateTimeFormatter.ofPattern(endFmt))}"
+}
+
+private fun formatTime(millis: Long, allDay: Boolean): String {
+ val zone = java.time.ZoneId.systemDefault()
+ val instant = java.time.Instant.ofEpochMilli(millis)
+ return if (allDay) "All day" else instant.atZone(zone).format(DateTimeFormatter.ofPattern("h:mm a"))
+}
diff --git a/ncal/app/src/main/java/com/homelab/ncal/ui/week/WeekViewModel.kt b/ncal/app/src/main/java/com/homelab/ncal/ui/week/WeekViewModel.kt
new file mode 100644
index 0000000..db810db
--- /dev/null
+++ b/ncal/app/src/main/java/com/homelab/ncal/ui/week/WeekViewModel.kt
@@ -0,0 +1,87 @@
+package com.homelab.ncal.ui.week
+
+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.groupByAllOccupiedDays
+import com.homelab.ncal.util.toUserMessage
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import java.time.LocalDate
+import java.time.ZoneId
+import java.time.temporal.WeekFields
+import java.util.Locale
+
+data class WeekUiState(
+ val weekStart: LocalDate = LocalDate.now(),
+ val itemsByDay: Map> = emptyMap(),
+ val refreshing: Boolean = false,
+ val error: String? = null
+)
+
+class WeekViewModel(private val repo: NextcloudRepository) : ViewModel() {
+
+ private val zone = ZoneId.systemDefault()
+ private val firstDayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek
+
+ private fun alignToWeekStart(date: LocalDate): LocalDate {
+ var d = date
+ while (d.dayOfWeek != firstDayOfWeek) d = d.minusDays(1)
+ return d
+ }
+
+ private val weekStart = MutableStateFlow(alignToWeekStart(LocalDate.now()))
+ private val refreshing = MutableStateFlow(false)
+ private val error = MutableStateFlow(null)
+
+ private val allItems = repo.observeCollections()
+ .flatMapLatest { collections ->
+ val urls = collections.filter { it.enabled }.map { it.url }
+ repo.observeItems(urls)
+ }
+
+ val state: StateFlow = combine(allItems, weekStart, refreshing, error) { items, start, r, e ->
+ val grouped = items.groupByAllOccupiedDays(zone)
+ val itemsByDay = (0..6).associate { offset ->
+ val day = start.plusDays(offset.toLong())
+ day to (grouped[day] ?: emptyList()).sortedBy { it.start ?: it.due }
+ }
+ WeekUiState(weekStart = start, itemsByDay = itemsByDay, refreshing = r, error = e)
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WeekUiState(weekStart = weekStart.value))
+
+ fun nextWeek() { weekStart.value = weekStart.value.plusWeeks(1) }
+ fun prevWeek() { weekStart.value = weekStart.value.minusWeeks(1) }
+ fun goToToday() { weekStart.value = alignToWeekStart(LocalDate.now()) }
+
+ fun toggleTask(item: CalendarItem) {
+ viewModelScope.launch {
+ try {
+ repo.toggleTaskComplete(item)
+ error.value = null
+ } catch (e: Exception) {
+ android.util.Log.e("NCalError", "toggleTask failed for href=${item.href} calendar=${item.calendarUrl}", e)
+ error.value = e.toUserMessage()
+ }
+ }
+ }
+
+ fun refresh() {
+ refreshing.value = true
+ viewModelScope.launch {
+ try {
+ repo.fullSync()
+ error.value = null
+ } catch (e: Exception) {
+ android.util.Log.e("NCalError", "Week fullSync failed", e)
+ error.value = e.toUserMessage()
+ }
+ refreshing.value = false
+ }
+ }
+}
diff --git a/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt b/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt
index 02abf0d..6749339 100644
--- a/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt
+++ b/ncal/app/src/main/java/com/homelab/ncal/util/IcsMapper.kt
@@ -6,11 +6,14 @@ import biweekly.component.VAlarm
import biweekly.component.VEvent
import biweekly.component.VTodo
import biweekly.parameter.Related
+import biweekly.property.ExceptionDates
+import biweekly.property.RecurrenceId
import biweekly.property.RelatedTo
import biweekly.property.Status
import biweekly.property.Trigger
import biweekly.util.Duration
import biweekly.util.Frequency
+import biweekly.util.ICalDate
import biweekly.util.Recurrence
import com.homelab.ncal.data.model.CalendarItem
import com.homelab.ncal.data.model.ItemType
@@ -36,16 +39,19 @@ 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. */
+ /** null if this rule uses anything the picker can't represent (BYMONTH/BYMONTHDAY/etc, an
+ * ordinal BYDAY like "3rd Monday", BYDAY on anything but WEEKLY, or a frequency other than
+ * the 4 simple ones). A plain WEEKLY rule with a BYDAY of unordinaled days ("every Mon/Wed/
+ * Fri") *is* representable - see [recurrenceByDaySet] - so this must never treat "has BYDAY"
+ * as automatically unsupported like it once did, or a BYDAY edit would keep getting
+ * silently dropped back to rawIcs-only on the next unrelated save. */
private fun Recurrence?.toRecurrenceFrequency(): RecurrenceFrequency? {
if (this == null) return null
- val isSimple = byDay.isEmpty() && byMonth.isEmpty() && byMonthDay.isEmpty() &&
+ val isSimple = byMonth.isEmpty() && byMonthDay.isEmpty() &&
byYearDay.isEmpty() && byWeekNo.isEmpty() && bySetPos.isEmpty() &&
byHour.isEmpty() && byMinute.isEmpty() && bySecond.isEmpty()
if (!isSimple) return null
+ if (byDay.isNotEmpty() && (frequency != Frequency.WEEKLY || byDay.any { it.num != null })) return null
return when (frequency) {
Frequency.DAILY -> RecurrenceFrequency.DAILY
Frequency.WEEKLY -> RecurrenceFrequency.WEEKLY
@@ -55,6 +61,32 @@ object IcsMapper {
}
}
+ /** Only meaningful when [toRecurrenceFrequency] didn't return null - a plain (unordinaled)
+ * BYDAY list, translated from biweekly's own [biweekly.util.DayOfWeek] to [java.time.DayOfWeek]. */
+ private fun Recurrence?.toByDaySet(): Set =
+ this?.byDay?.mapNotNull { it.day?.toJavaDayOfWeek() }?.toSet() ?: emptySet()
+
+ private fun biweekly.util.DayOfWeek.toJavaDayOfWeek(): java.time.DayOfWeek? = when (this) {
+ biweekly.util.DayOfWeek.MONDAY -> java.time.DayOfWeek.MONDAY
+ biweekly.util.DayOfWeek.TUESDAY -> java.time.DayOfWeek.TUESDAY
+ biweekly.util.DayOfWeek.WEDNESDAY -> java.time.DayOfWeek.WEDNESDAY
+ biweekly.util.DayOfWeek.THURSDAY -> java.time.DayOfWeek.THURSDAY
+ biweekly.util.DayOfWeek.FRIDAY -> java.time.DayOfWeek.FRIDAY
+ biweekly.util.DayOfWeek.SATURDAY -> java.time.DayOfWeek.SATURDAY
+ biweekly.util.DayOfWeek.SUNDAY -> java.time.DayOfWeek.SUNDAY
+ else -> null
+ }
+
+ private fun java.time.DayOfWeek.toBiweeklyDayOfWeek(): biweekly.util.DayOfWeek = when (this) {
+ java.time.DayOfWeek.MONDAY -> biweekly.util.DayOfWeek.MONDAY
+ java.time.DayOfWeek.TUESDAY -> biweekly.util.DayOfWeek.TUESDAY
+ java.time.DayOfWeek.WEDNESDAY -> biweekly.util.DayOfWeek.WEDNESDAY
+ java.time.DayOfWeek.THURSDAY -> biweekly.util.DayOfWeek.THURSDAY
+ java.time.DayOfWeek.FRIDAY -> biweekly.util.DayOfWeek.FRIDAY
+ java.time.DayOfWeek.SATURDAY -> biweekly.util.DayOfWeek.SATURDAY
+ java.time.DayOfWeek.SUNDAY -> biweekly.util.DayOfWeek.SUNDAY
+ }
+
/** 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
@@ -70,6 +102,9 @@ object IcsMapper {
val builder = Recurrence.Builder(bwFreq).interval(item.recurrenceInterval.coerceAtLeast(1))
item.recurrenceCount?.let { builder.count(it) }
item.recurrenceUntil?.let { builder.until(Date(it), true) }
+ if (freq == RecurrenceFrequency.WEEKLY && item.recurrenceByDay.isNotEmpty()) {
+ builder.byDay(item.recurrenceByDay.map { it.toBiweeklyDayOfWeek() })
+ }
return builder.build()
}
@@ -81,30 +116,109 @@ object IcsMapper {
(-duration.toMillis() / 60_000L).toInt()
}.distinct().sorted()
- /** Parses a raw .ics blob (one VEVENT or VTODO) fetched from the server into our model. */
+ /**
+ * Splits a multi-component .ics document (e.g. exported from another calendar app, or from
+ * NCal's own export) into one freshly-created [CalendarItem] per VEVENT/VTODO, for bulk
+ * import into [calendarUrl]. Each result is a brand-new creation - no href/etag, and always a
+ * *fresh* UID rather than the source file's own, since reusing an existing UID risks a
+ * collision with the target calendar's own trashbin (see the same issue worked around in
+ * [com.homelab.ncal.data.repository.NextcloudRepository.undoDelete]). A recurring item's
+ * DTSTART/DTEND/DUE come from its true master date (not "resolved next occurrence") so the
+ * recreated series starts exactly where the original one did.
+ */
+ fun parseAllForImport(ics: String, calendarUrl: String): List {
+ val ical = runCatching { Biweekly.parse(ics).first() }.getOrNull() ?: return emptyList()
+ val items = mutableListOf()
+
+ // RECURRENCE-ID overrides are only meaningful attached to their master's UID/series -
+ // importing one on its own as an independent event would just create a confusing
+ // duplicate sitting at that occurrence's time slot, disconnected from any series.
+ ical.events.filter { it.recurrenceId == null }.forEach { ev ->
+ val single = ICalendar().apply { addEvent(ev) }
+ val singleIcs = Biweekly.write(single).go()
+ parse(singleIcs, href = "", etag = null, calendarUrl = calendarUrl)?.let { parsed ->
+ items += parsed.copy(
+ uid = UUID.randomUUID().toString(),
+ rawIcs = null,
+ start = if (parsed.isRecurring) parsed.recurrenceMasterStart else parsed.start,
+ end = if (parsed.isRecurring) parsed.recurrenceMasterEnd else parsed.end
+ )
+ }
+ }
+ ical.todos.filter { it.recurrenceId == null }.forEach { td ->
+ val single = ICalendar().apply { addTodo(td) }
+ val singleIcs = Biweekly.write(single).go()
+ parse(singleIcs, href = "", etag = null, calendarUrl = calendarUrl)?.let { parsed ->
+ items += parsed.copy(
+ uid = UUID.randomUUID().toString(),
+ rawIcs = null,
+ start = if (parsed.isRecurring) parsed.recurrenceMasterStart else parsed.start,
+ due = if (parsed.isRecurring) parsed.recurrenceMasterDue else parsed.due
+ )
+ }
+ }
+ return items
+ }
+
+ /** Combines every item's own component (reusing its already-fetched [CalendarItem.rawIcs]
+ * when present, so unmodeled properties survive) into one multi-component .ics document,
+ * for exporting a whole calendar. */
+ fun buildCombinedIcs(items: List): String {
+ val ical = ICalendar()
+ items.forEach { item ->
+ val singleIcs = item.rawIcs ?: toIcs(item)
+ val parsed = runCatching { Biweekly.parse(singleIcs).first() }.getOrNull() ?: return@forEach
+ parsed.events.forEach { ical.addEvent(it) }
+ parsed.todos.forEach { ical.addTodo(it) }
+ }
+ return Biweekly.write(ical).go()
+ }
+
+ /**
+ * Parses a raw .ics blob fetched from the server into our model. The blob may contain just a
+ * master VEVENT/VTODO, or a master plus one or more RECURRENCE-ID override components (same
+ * UID) representing single-occurrence edits ("this event only" - see [buildOccurrenceOverrideIcs]).
+ * When the resolved occurrence being displayed has a matching override, that override's
+ * fields (summary/description/etc. and its own possibly-moved start/end/due) are what's
+ * surfaced - the master's RRULE/recurrence metadata is still what's tracked for the series
+ * itself, via [CalendarItem.recurrenceMasterStart] etc.
+ */
fun parse(ics: String, href: String, etag: String?, calendarUrl: String): CalendarItem? {
val ical = Biweekly.parse(ics).first() ?: return null
- ical.events.firstOrNull()?.let { ev ->
+ (ical.events.firstOrNull { it.recurrenceId == null } ?: ical.events.firstOrNull())?.let { ev ->
val originalStart = ev.dateStart?.value?.time
val originalEnd = ev.dateEnd?.value?.time
val durationMillis = if (originalStart != null && originalEnd != null) originalEnd - originalStart else null
val recurrence = ev.recurrenceRule?.value
+ val exceptionDates = ev.exceptionDates.flatMap { it.values }.map { it.time }.toSet()
+
+ var occurrenceDate: Long? = null
+ var override: VEvent? = null
val (resolvedStart, resolvedEnd) = if (recurrence != null && originalStart != null) {
- val next = RecurrenceUtils.nextOccurrence(originalStart, recurrence, System.currentTimeMillis())
+ val next = RecurrenceUtils.nextOccurrence(originalStart, recurrence, System.currentTimeMillis(), exceptionDates)
if (next == null) {
android.util.Log.d("NCalParse", "EVENT '${ev.summary?.value}' recurrence ended (freq=${recurrence.frequency}, until=${recurrence.until}, count=${recurrence.count}) - dropping")
return null
}
- next to (durationMillis?.let { next + it } ?: next)
+ occurrenceDate = next
+ override = ical.events.firstOrNull { it !== ev && it.recurrenceId?.value?.time == next }
+ val ovStart = override?.dateStart?.value?.time
+ val ovEnd = override?.dateEnd?.value?.time
+ if (ovStart != null) {
+ ovStart to (ovEnd ?: ovStart)
+ } else {
+ next to (durationMillis?.let { next + it } ?: next)
+ }
} else {
originalStart to originalEnd
}
+ val display = override ?: ev
android.util.Log.d(
"NCalParse",
- "EVENT '${ev.summary?.value}' calendar=$calendarUrl hasRRULE=${recurrence != null} " +
+ "EVENT '${ev.summary?.value}' calendar=$calendarUrl hasRRULE=${recurrence != null} hasOverride=${override != null} " +
"originalStart=${originalStart?.let { java.util.Date(it) }} resolvedStart=${resolvedStart?.let { java.util.Date(it) }}"
)
@@ -114,38 +228,50 @@ object IcsMapper {
etag = etag,
calendarUrl = calendarUrl,
type = ItemType.EVENT,
- summary = ev.summary?.value ?: "(no title)",
- description = ev.description?.value ?: "",
- location = ev.location?.value ?: "",
- url = ev.url?.value ?: "",
+ summary = display.summary?.value ?: "(no title)",
+ description = display.description?.value ?: "",
+ location = display.location?.value ?: "",
+ url = display.url?.value ?: "",
start = resolvedStart,
end = resolvedEnd,
- allDay = ev.dateStart?.value?.let { !hasTimeComponent(ics, "DTSTART") } ?: false,
- priority = ev.priority?.value ?: 0,
- reminderMinutes = alarmMinutesBefore(ev.alarms),
+ allDay = ev.dateStart?.value?.let { !it.hasTime() } ?: false,
+ priority = display.priority?.value ?: 0,
+ reminderMinutes = alarmMinutesBefore(display.alarms),
recurrenceFrequency = recurrence.toRecurrenceFrequency(),
recurrenceInterval = recurrence?.interval ?: 1,
recurrenceCount = recurrence?.count,
recurrenceUntil = recurrence?.until?.time,
+ recurrenceByDay = recurrence.toByDaySet(),
isRecurring = recurrence != null,
recurrenceMasterStart = originalStart,
recurrenceMasterEnd = originalEnd,
+ occurrenceDate = occurrenceDate,
rawIcs = ics
)
}
- ical.todos.firstOrNull()?.let { td ->
+ (ical.todos.firstOrNull { it.recurrenceId == null } ?: ical.todos.firstOrNull())?.let { td ->
val originalDue = td.dateDue?.value?.time
val originalStart = td.dateStart?.value?.time
val startToDueMillis = if (originalStart != null && originalDue != null) originalDue - originalStart else null
val recurrence = td.recurrenceRule?.value
+ val exceptionDates = td.exceptionDates.flatMap { it.values }.map { it.time }.toSet()
+
+ var occurrenceDate: Long? = null
+ var override: VTodo? = null
val resolvedDue = if (recurrence != null && originalDue != null) {
- RecurrenceUtils.nextOccurrence(originalDue, recurrence, System.currentTimeMillis())
+ val next = RecurrenceUtils.nextOccurrence(originalDue, recurrence, System.currentTimeMillis(), exceptionDates)
+ occurrenceDate = next
+ if (next != null) override = ical.todos.firstOrNull { it !== td && it.recurrenceId?.value?.time == next }
+ override?.dateDue?.value?.time ?: next
} else {
originalDue
}
- val resolvedStart = if (recurrence != null && originalStart != null && resolvedDue != null) {
+ val display = override ?: td
+ val resolvedStart = if (override != null) {
+ display.dateStart?.value?.time
+ } else if (recurrence != null && originalStart != null && resolvedDue != null) {
startToDueMillis?.let { resolvedDue - it }
} else {
originalStart
@@ -153,13 +279,13 @@ object IcsMapper {
android.util.Log.d(
"NCalParse",
- "TASK '${td.summary?.value}' calendar=$calendarUrl hasRRULE=${recurrence != null} " +
+ "TASK '${td.summary?.value}' calendar=$calendarUrl hasRRULE=${recurrence != null} hasOverride=${override != null} " +
"originalDue=${originalDue?.let { java.util.Date(it) }} resolvedDue=${resolvedDue?.let { java.util.Date(it) }} " +
"originalStart=${originalStart?.let { java.util.Date(it) }} completed=${td.status?.isCompleted}"
)
- val status = td.status.toTaskStatus()
- val percentComplete = td.percentComplete?.value ?: (if (status == TaskStatus.COMPLETED) 100 else 0)
+ val status = display.status.toTaskStatus()
+ val percentComplete = display.percentComplete?.value ?: (if (status == TaskStatus.COMPLETED) 100 else 0)
return CalendarItem(
uid = td.uid?.value ?: UUID.randomUUID().toString(),
@@ -167,25 +293,27 @@ object IcsMapper {
etag = etag,
calendarUrl = calendarUrl,
type = ItemType.TASK,
- summary = td.summary?.value ?: "(no title)",
- description = td.description?.value ?: "",
- location = td.location?.value ?: "",
- url = td.url?.value ?: "",
+ summary = display.summary?.value ?: "(no title)",
+ description = display.description?.value ?: "",
+ location = display.location?.value ?: "",
+ url = display.url?.value ?: "",
start = resolvedStart,
due = resolvedDue,
completed = status == TaskStatus.COMPLETED || percentComplete >= 100,
status = status,
percentComplete = percentComplete,
- priority = td.priority?.value ?: 0,
- parentUid = td.relatedTo.firstOrNull { it.relationshipType == null || it.relationshipType.value.equals("PARENT", ignoreCase = true) }?.value,
- reminderMinutes = alarmMinutesBefore(td.alarms),
+ priority = display.priority?.value ?: 0,
+ parentUid = display.relatedTo.firstOrNull { it.relationshipType == null || it.relationshipType.value.equals("PARENT", ignoreCase = true) }?.value,
+ reminderMinutes = alarmMinutesBefore(display.alarms),
recurrenceFrequency = recurrence.toRecurrenceFrequency(),
recurrenceInterval = recurrence?.interval ?: 1,
recurrenceCount = recurrence?.count,
recurrenceUntil = recurrence?.until?.time,
+ recurrenceByDay = recurrence.toByDaySet(),
isRecurring = recurrence != null,
recurrenceMasterStart = originalStart,
recurrenceMasterDue = originalDue,
+ occurrenceDate = occurrenceDate,
rawIcs = ics
)
}
@@ -222,7 +350,9 @@ object IcsMapper {
val ical = item.rawIcs?.takeIf { it.isNotBlank() }?.let { Biweekly.parse(it).first() } ?: ICalendar()
when (item.type) {
ItemType.EVENT -> {
- val ev = ical.events.firstOrNull() ?: VEvent().also { ical.addEvent(it) }
+ // "All events" edits always target the master, never a RECURRENCE-ID override
+ // that might otherwise be sitting first in the list.
+ val ev = ical.events.firstOrNull { it.recurrenceId == null } ?: ical.events.firstOrNull() ?: VEvent().also { ical.addEvent(it) }
val originalRecurrence = ev.recurrenceRule?.value
val wasRecurring = originalRecurrence != null
@@ -249,7 +379,7 @@ object IcsMapper {
}
}
ItemType.TASK -> {
- val td = ical.todos.firstOrNull() ?: VTodo().also { ical.addTodo(it) }
+ val td = ical.todos.firstOrNull { it.recurrenceId == null } ?: ical.todos.firstOrNull() ?: VTodo().also { ical.addTodo(it) }
val originalRecurrence = td.recurrenceRule?.value
val wasRecurring = originalRecurrence != null
@@ -285,9 +415,97 @@ object IcsMapper {
return Biweekly.write(ical).go()
}
- /** Crude but effective: DTSTART/DTEND with a VALUE=DATE param (no time) means an all-day item. */
- private fun hasTimeComponent(ics: String, property: String): Boolean {
- val line = ics.lines().firstOrNull { it.startsWith(property) } ?: return true
- return !line.contains("VALUE=DATE")
+ /**
+ * Builds the full .ics document for "save changes to this event only" on a recurring [item] -
+ * adds or replaces a RECURRENCE-ID override VEVENT/VTODO for [CalendarItem.occurrenceDate]
+ * inside the existing document ([CalendarItem.rawIcs]), leaving the master component (and
+ * its RRULE) completely untouched. The override carries the item's *current* fields in full
+ * (RFC 5545 allows a full-component override, not just the properties that differ from the
+ * master - simpler and just as valid).
+ */
+ fun buildOccurrenceOverrideIcs(item: CalendarItem): String {
+ val occurrenceDate = requireNotNull(item.occurrenceDate) {
+ "buildOccurrenceOverrideIcs requires a recurring item with occurrenceDate set"
+ }
+ val ical = item.rawIcs?.takeIf { it.isNotBlank() }?.let { Biweekly.parse(it).first() } ?: ICalendar()
+
+ when (item.type) {
+ ItemType.EVENT -> {
+ ical.events.removeAll { it.recurrenceId?.value?.time == occurrenceDate }
+ val override = VEvent().apply {
+ setUid(item.uid)
+ setRecurrenceId(RecurrenceId(Date(occurrenceDate), !item.allDay))
+ setSummary(item.summary)
+ setDescription(item.description.takeIf { it.isNotBlank() })
+ setLocation(item.location.takeIf { it.isNotBlank() })
+ setUrl(item.url.takeIf { it.isNotBlank() })
+ setDateStart(item.start?.let { Date(it) }, !item.allDay)
+ setDateEnd(item.end?.let { Date(it) }, !item.allDay)
+ setPriority(item.priority.takeIf { it > 0 })
+ }
+ item.reminderMinutes.forEach { minutes ->
+ val duration = Duration.builder().prior(true).minutes(minutes).build()
+ override.addAlarm(VAlarm.display(Trigger(duration, Related.START), item.summary))
+ }
+ ical.addEvent(override)
+ }
+ ItemType.TASK -> {
+ ical.todos.removeAll { it.recurrenceId?.value?.time == occurrenceDate }
+ val override = VTodo().apply {
+ setUid(item.uid)
+ setRecurrenceId(RecurrenceId(Date(occurrenceDate), true))
+ setSummary(item.summary)
+ setDescription(item.description.takeIf { it.isNotBlank() })
+ setLocation(item.location.takeIf { it.isNotBlank() })
+ setUrl(item.url.takeIf { it.isNotBlank() })
+ setDateStart(item.start?.let { Date(it) })
+ setDateDue(item.due?.let { Date(it) })
+ setPriority(item.priority.takeIf { it > 0 })
+ setStatus(item.status.toIcsStatus())
+ setPercentComplete(item.percentComplete.takeIf { it > 0 })
+ }
+ item.parentUid?.takeIf { it.isNotBlank() }?.let { override.addRelatedTo(RelatedTo(it)) }
+ if (item.due != null) {
+ item.reminderMinutes.forEach { minutes ->
+ val duration = Duration.builder().prior(true).minutes(minutes).build()
+ override.addAlarm(VAlarm.display(Trigger(duration, Related.END), item.summary))
+ }
+ }
+ ical.addTodo(override)
+ }
+ }
+ return Biweekly.write(ical).go()
+ }
+
+ /**
+ * Builds the full .ics document for "delete this event only" on a recurring [item] - adds
+ * [CalendarItem.occurrenceDate] to the master's EXDATE list (so [RecurrenceUtils.nextOccurrence]
+ * skips straight over it on every future sync) and removes any existing RECURRENCE-ID
+ * override for that same date, since there's nothing left for it to override.
+ */
+ fun buildOccurrenceDeletionIcs(item: CalendarItem): String {
+ val occurrenceDate = requireNotNull(item.occurrenceDate) {
+ "buildOccurrenceDeletionIcs requires a recurring item with occurrenceDate set"
+ }
+ val rawIcs = requireNotNull(item.rawIcs?.takeIf { it.isNotBlank() }) {
+ "buildOccurrenceDeletionIcs requires the item's existing rawIcs"
+ }
+ val ical = Biweekly.parse(rawIcs).first()
+
+ when (item.type) {
+ ItemType.EVENT -> {
+ ical.events.removeAll { it.recurrenceId?.value?.time == occurrenceDate }
+ ical.events.firstOrNull { it.recurrenceId == null }?.addExceptionDates(
+ ExceptionDates().apply { values.add(ICalDate(Date(occurrenceDate), !item.allDay)) }
+ )
+ }
+ ItemType.TASK -> {
+ ical.todos.removeAll { it.recurrenceId?.value?.time == occurrenceDate }
+ ical.todos.firstOrNull { it.recurrenceId == null }?.addExceptionDates(
+ ExceptionDates().apply { values.add(ICalDate(Date(occurrenceDate), true)) }
+ )
+ }
+ }
+ return Biweekly.write(ical).go()
}
}
diff --git a/ncal/app/src/main/java/com/homelab/ncal/util/RecurrenceUtils.kt b/ncal/app/src/main/java/com/homelab/ncal/util/RecurrenceUtils.kt
index 4030d9f..a8e1def 100644
--- a/ncal/app/src/main/java/com/homelab/ncal/util/RecurrenceUtils.kt
+++ b/ncal/app/src/main/java/com/homelab/ncal/util/RecurrenceUtils.kt
@@ -2,9 +2,12 @@ package com.homelab.ncal.util
import biweekly.util.Frequency
import biweekly.util.Recurrence
+import java.time.DayOfWeek
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
+import java.time.temporal.ChronoUnit
+import java.time.temporal.TemporalAdjusters
/**
* Minimal RRULE evaluator. CalDAV servers return the *master* VEVENT/VTODO for a
@@ -13,21 +16,32 @@ import java.time.ZonedDateTime
* yearly birthday would show once, forever, on the date it was first created.
*
* This covers the common cases - FREQ=DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL, COUNT,
- * and UNTIL. It intentionally does not implement BYDAY/BYMONTH/BYSETPOS/etc - those cover
- * a small minority of everyday personal-calendar recurrences (e.g. "every 2nd Tuesday").
- * Anything using those rule parts still round-trips correctly on save/edit (the raw ICS
- * is preserved via [com.homelab.ncal.data.model.CalendarItem.rawIcs]); it just won't be
- * *re-dated* for display, and will show at its original occurrence like before.
+ * UNTIL, (WEEKLY only) an unordinaled BYDAY ("every Mon/Wed/Fri" - see
+ * [com.homelab.ncal.data.model.CalendarItem.recurrenceByDay]), and EXDATE (single-occurrence
+ * deletions - see [com.homelab.ncal.data.model.CalendarItem.occurrenceDate]). It intentionally
+ * does not implement BYMONTH/BYMONTHDAY/BYSETPOS/ordinaled BYDAY/etc - those cover a small
+ * minority of everyday personal-calendar recurrences (e.g. "every 2nd Tuesday"). Anything using
+ * those rule parts still round-trips correctly on save/edit (the raw ICS is preserved via
+ * [com.homelab.ncal.data.model.CalendarItem.rawIcs]); it just won't be *re-dated* for
+ * display, and will show at its original occurrence like before.
*/
object RecurrenceUtils {
private const val MAX_ITERATIONS = 10_000
+ private const val MAX_BYDAY_DAYS = 3660 // ~10 years of daily steps, an upper bound on the walk-forward search
/**
- * Returns the timestamp (epoch millis) of the next occurrence on/after [referenceMillis],
- * or null if the series has already ended (past UNTIL/COUNT) or uses an unsupported rule.
+ * Returns the timestamp (epoch millis) of the next occurrence on/after [referenceMillis]
+ * that isn't in [exceptionDates] (EXDATE - a single occurrence deleted via "delete this
+ * event only"), or null if the series has already ended (past UNTIL/COUNT) or uses an
+ * unsupported rule.
*/
- fun nextOccurrence(originalStartMillis: Long, recurrence: Recurrence, referenceMillis: Long): Long? {
+ fun nextOccurrence(
+ originalStartMillis: Long,
+ recurrence: Recurrence,
+ referenceMillis: Long,
+ exceptionDates: Set = emptySet()
+ ): Long? {
val zone = ZoneId.systemDefault()
val freq = recurrence.frequency ?: return null
if (freq !in SUPPORTED_FREQUENCIES) return originalStartMillis // fall back to showing the master as-is
@@ -38,14 +52,27 @@ object RecurrenceUtils {
}
val maxCount = recurrence.count
- var current = Instant.ofEpochMilli(originalStartMillis).atZone(zone)
+ val byDayOfWeek: Set = if (freq == Frequency.WEEKLY) {
+ recurrence.byDay.mapNotNull { it.day?.toJavaDayOfWeek() }.toSet()
+ } else {
+ emptySet()
+ }
+
+ val originalStart = Instant.ofEpochMilli(originalStartMillis).atZone(zone)
val reference = Instant.ofEpochMilli(referenceMillis).atZone(zone)
- if (!current.isBefore(reference)) return current.toInstant().toEpochMilli()
+ if (byDayOfWeek.isNotEmpty()) {
+ return nextWeeklyByDayOccurrence(originalStart, reference, interval, byDayOfWeek, until, maxCount, exceptionDates)
+ }
+ var current = originalStart
var occurrenceIndex = 1
var iterations = 0
- while (current.isBefore(reference)) {
+
+ // Keep advancing while the candidate is either before the reference point, or lands on
+ // an excluded (EXDATE'd) date - the latter is what makes "delete this occurrence" skip
+ // straight to the following one instead of getting stuck offering the deleted date back.
+ while (current.isBefore(reference) || current.toInstant().toEpochMilli() in exceptionDates) {
if (iterations++ > MAX_ITERATIONS) return null
current = when (freq) {
@@ -63,5 +90,58 @@ object RecurrenceUtils {
return current.toInstant().toEpochMilli()
}
+ /**
+ * Walks forward day-by-day from [originalStart]'s date (RFC 5545 occurrences never precede
+ * DTSTART, so this is the correct starting point even when DTSTART's own weekday isn't in
+ * [byDayOfWeek]), counting only days that are both a selected weekday and in a week that's a
+ * multiple of [interval] weeks from DTSTART's week - matching RFC 5545's WEEKLY+BYDAY+INTERVAL
+ * semantics (default WKST=MO). [exceptionDates] are counted toward COUNT (they were still
+ * generated by the rule) but never returned as "the next occurrence".
+ */
+ private fun nextWeeklyByDayOccurrence(
+ originalStart: ZonedDateTime,
+ reference: ZonedDateTime,
+ interval: Int,
+ byDayOfWeek: Set,
+ until: ZonedDateTime?,
+ maxCount: Int?,
+ exceptionDates: Set
+ ): Long? {
+ val startWeek = originalStart.toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
+ var occurrenceIndex = 0
+ var day = originalStart.toLocalDate()
+
+ repeat(MAX_BYDAY_DAYS) {
+ if (day.dayOfWeek in byDayOfWeek) {
+ val dayWeek = day.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
+ val weeksFromStart = ChronoUnit.WEEKS.between(startWeek, dayWeek)
+ if (weeksFromStart % interval == 0L) {
+ occurrenceIndex++
+ if (maxCount != null && occurrenceIndex > maxCount) return null
+ val occurrence = originalStart.withLocalDate(day)
+ if (until != null && occurrence.isAfter(until)) return null
+ val occurrenceMillis = occurrence.toInstant().toEpochMilli()
+ if (occurrenceMillis !in exceptionDates && !occurrence.isBefore(reference)) return occurrenceMillis
+ }
+ }
+ day = day.plusDays(1)
+ }
+ return null
+ }
+
+ private fun ZonedDateTime.withLocalDate(date: java.time.LocalDate): ZonedDateTime =
+ date.atTime(toLocalTime()).atZone(zone)
+
+ private fun biweekly.util.DayOfWeek.toJavaDayOfWeek(): DayOfWeek? = when (this) {
+ biweekly.util.DayOfWeek.MONDAY -> DayOfWeek.MONDAY
+ biweekly.util.DayOfWeek.TUESDAY -> DayOfWeek.TUESDAY
+ biweekly.util.DayOfWeek.WEDNESDAY -> DayOfWeek.WEDNESDAY
+ biweekly.util.DayOfWeek.THURSDAY -> DayOfWeek.THURSDAY
+ biweekly.util.DayOfWeek.FRIDAY -> DayOfWeek.FRIDAY
+ biweekly.util.DayOfWeek.SATURDAY -> DayOfWeek.SATURDAY
+ biweekly.util.DayOfWeek.SUNDAY -> DayOfWeek.SUNDAY
+ else -> null
+ }
+
private val SUPPORTED_FREQUENCIES = setOf(Frequency.DAILY, Frequency.WEEKLY, Frequency.MONTHLY, Frequency.YEARLY)
}
diff --git a/ncal/app/src/main/res/xml/file_paths.xml b/ncal/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..282e644
--- /dev/null
+++ b/ncal/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/ncal/app/src/test/java/com/homelab/ncal/data/network/CalDavClientTest.kt b/ncal/app/src/test/java/com/homelab/ncal/data/network/CalDavClientTest.kt
new file mode 100644
index 0000000..d24e47e
--- /dev/null
+++ b/ncal/app/src/test/java/com/homelab/ncal/data/network/CalDavClientTest.kt
@@ -0,0 +1,77 @@
+package com.homelab.ncal.data.network
+
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+import java.io.IOException
+
+/**
+ * The offline-save/delete fallback in NextcloudRepository depends entirely on being able to
+ * tell "the server rejected this" (CalDavException, has an HTTP status) apart from "there is no
+ * server to talk to right now" (a plain IOException - UnknownHostException/ConnectException/
+ * SocketTimeoutException/etc). This locks that distinction in against a real socket, not just an
+ * assumption about what OkHttp throws.
+ */
+class CalDavClientTest {
+
+ private val server = MockWebServer()
+
+ @After
+ fun tearDown() {
+ runCatching { server.shutdown() }
+ }
+
+ @Test
+ fun `an HTTP error response throws CalDavException carrying the status code`() {
+ server.enqueue(MockResponse().setResponseCode(412).setBody("Precondition Failed"))
+ server.start()
+ val client = CalDavClient(server.url("/").toString(), "user", "pass")
+ val href = server.url("/cal/item1.ics").toString()
+
+ try {
+ client.updateItem(href, "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", "\"some-etag\"")
+ fail("expected CalDavException")
+ } catch (e: CalDavException) {
+ assertEquals(412, e.httpCode)
+ }
+ }
+
+ @Test
+ fun `a connection failure throws a plain IOException, not CalDavException`() {
+ server.start()
+ val href = server.url("/cal/item1.ics").toString()
+ server.shutdown() // nothing is listening on this port anymore
+
+ val client = CalDavClient(href, "user", "pass")
+ try {
+ client.updateItem(href, "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", "\"some-etag\"")
+ fail("expected an IOException")
+ } catch (e: CalDavException) {
+ fail("a connection failure must NOT be a CalDavException, or the offline-save fallback would never trigger - got httpCode=${e.httpCode}")
+ } catch (e: IOException) {
+ assertFalse("must not accidentally be the CalDavException subtype", e is CalDavException)
+ }
+ }
+
+ @Test
+ fun `deleteItem also distinguishes connection failure from an HTTP error`() {
+ server.start()
+ val href = server.url("/cal/item1.ics").toString()
+ server.shutdown()
+
+ val client = CalDavClient(href, "user", "pass")
+ try {
+ client.deleteItem(href, "\"some-etag\"")
+ fail("expected an IOException")
+ } catch (e: CalDavException) {
+ fail("a connection failure must NOT be a CalDavException")
+ } catch (e: IOException) {
+ assertTrue(true)
+ }
+ }
+}
diff --git a/ncal/app/src/test/java/com/homelab/ncal/util/IcsMapperTest.kt b/ncal/app/src/test/java/com/homelab/ncal/util/IcsMapperTest.kt
index 55520c7..d14323f 100644
--- a/ncal/app/src/test/java/com/homelab/ncal/util/IcsMapperTest.kt
+++ b/ncal/app/src/test/java/com/homelab/ncal/util/IcsMapperTest.kt
@@ -84,14 +84,54 @@ class IcsMapperTest {
}
@Test
- fun `unsupported BYDAY-based rule is left completely untouched when the picker fields are null`() {
+ fun `a plain unordinaled WEEKLY BYDAY rule is representable by the picker`() {
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)
+ assertEquals("WEEKLY+BYDAY=MO is a plain rule the picker can represent", RecurrenceFrequency.WEEKLY, item.recurrenceFrequency)
+ assertEquals(setOf(java.time.DayOfWeek.MONDAY), item.recurrenceByDay)
+ assertTrue(item.isRecurring)
+
+ val rebuiltIcs = IcsMapper.toIcs(item.copy(summary = "renamed"))
+
+ assertTrue("BYDAY must still be present after a round-trip", rebuiltIcs.contains("BYDAY=MO"))
+ }
+
+ @Test
+ fun `editing which weekdays a WEEKLY BYDAY rule repeats on regenerates the RRULE`() {
+ val item = IcsMapper.parse(recurringEventIcs, href = "https://example.com/e1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
+
+ val edited = item.copy(recurrenceByDay = setOf(java.time.DayOfWeek.MONDAY, java.time.DayOfWeek.WEDNESDAY, java.time.DayOfWeek.FRIDAY))
+ val rebuiltIcs = IcsMapper.toIcs(edited)
+
+ assertTrue("RRULE must contain all three selected days", rebuiltIcs.contains("RRULE"))
+ val rruleLine = rebuiltIcs.lines().first { it.startsWith("RRULE") }
+ assertTrue(rruleLine.contains("MO"))
+ assertTrue(rruleLine.contains("WE"))
+ assertTrue(rruleLine.contains("FR"))
+ }
+
+ @Test
+ fun `an ordinaled BYDAY like 'the 2nd Monday' is still left completely untouched`() {
+ val ordinaledIcs = ics(
+ "BEGIN:VCALENDAR",
+ "VERSION:2.0",
+ "PRODID:-//Nextcloud//Test//EN",
+ "BEGIN:VEVENT",
+ "UID:test-event-ordinaled@example.com",
+ "DTSTAMP:20200106T090000Z",
+ "DTSTART:20200106T090000Z",
+ "DTEND:20200106T100000Z",
+ "SUMMARY:Monthly board meeting",
+ "RRULE:FREQ=MONTHLY;BYDAY=2MO",
+ "END:VEVENT",
+ "END:VCALENDAR"
+ )
+ val item = IcsMapper.parse(ordinaledIcs, href = "https://example.com/e6.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
+ assertEquals("an ordinaled BYDAY 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"))
+ assertTrue("original ordinaled BYDAY must be preserved verbatim", rebuiltIcs.contains("BYDAY=2MO"))
}
@Test
@@ -244,4 +284,205 @@ class IcsMapperTest {
assertTrue("RRULE must survive the edit", rebuiltIcs.contains("RRULE") && rebuiltIcs.contains("FREQ=WEEKLY"))
assertTrue("PRIORITY must reflect the edit", rebuiltIcs.contains("PRIORITY:3"))
}
+
+ @Test
+ fun `parseAllForImport splits a multi-component document into one item each, with fresh UIDs`() {
+ val multiIcs = ics(
+ "BEGIN:VCALENDAR",
+ "VERSION:2.0",
+ "PRODID:-//Nextcloud//Test//EN",
+ "BEGIN:VEVENT",
+ "UID:imported-event-1@example.com",
+ "DTSTAMP:20260101T090000Z",
+ "DTSTART:20260101T090000Z",
+ "DTEND:20260101T100000Z",
+ "SUMMARY:Imported Event",
+ "END:VEVENT",
+ "BEGIN:VTODO",
+ "UID:imported-task-1@example.com",
+ "DTSTAMP:20260102T090000Z",
+ "DUE:20260102T090000Z",
+ "SUMMARY:Imported Task",
+ "STATUS:NEEDS-ACTION",
+ "END:VTODO",
+ "END:VCALENDAR"
+ )
+
+ val items = IcsMapper.parseAllForImport(multiIcs, calendarUrl = "https://example.com/target/")
+
+ assertEquals(2, items.size)
+ val event = items.first { it.type == ItemType.EVENT }
+ val task = items.first { it.type == ItemType.TASK }
+ assertEquals("Imported Event", event.summary)
+ assertEquals("Imported Task", task.summary)
+ assertTrue("imported items must be brand new (no href)", event.isNew && task.isNew)
+ assertTrue("imported items must get a fresh UID, not the source file's", event.uid != "imported-event-1@example.com")
+ assertTrue("imported items must get a fresh UID, not the source file's", task.uid != "imported-task-1@example.com")
+ assertEquals("https://example.com/target/", event.calendarUrl)
+ }
+
+ @Test
+ fun `parseAllForImport preserves a recurring item's true master date, not a resolved next occurrence`() {
+ val recurringIcs = ics(
+ "BEGIN:VCALENDAR",
+ "VERSION:2.0",
+ "PRODID:-//Nextcloud//Test//EN",
+ "BEGIN:VEVENT",
+ "UID:imported-recurring@example.com",
+ "DTSTAMP:20200106T090000Z",
+ "DTSTART:20200106T090000Z",
+ "DTEND:20200106T100000Z",
+ "SUMMARY:Imported Weekly Standup",
+ "RRULE:FREQ=WEEKLY",
+ "END:VEVENT",
+ "END:VCALENDAR"
+ )
+
+ val items = IcsMapper.parseAllForImport(recurringIcs, calendarUrl = "https://example.com/target/")
+
+ assertEquals(1, items.size)
+ val item = items.first()
+ assertEquals("the recreated series must start on the ORIGINAL anchor date", 1578301200000L, item.start) // 2020-01-06T09:00:00Z
+ }
+
+ @Test
+ fun `buildCombinedIcs merges multiple cached items into one multi-component document`() {
+ val eventIcs = ics(
+ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Nextcloud//Test//EN",
+ "BEGIN:VEVENT", "UID:e1@example.com", "DTSTAMP:20260101T090000Z",
+ "DTSTART:20260101T090000Z", "DTEND:20260101T100000Z", "SUMMARY:Event One",
+ "END:VEVENT", "END:VCALENDAR"
+ )
+ val taskIcs = ics(
+ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Nextcloud//Test//EN",
+ "BEGIN:VTODO", "UID:t1@example.com", "DTSTAMP:20260101T090000Z",
+ "DUE:20260101T090000Z", "SUMMARY:Task One", "STATUS:NEEDS-ACTION",
+ "END:VTODO", "END:VCALENDAR"
+ )
+ val event = IcsMapper.parse(eventIcs, href = "https://example.com/e1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
+ val task = IcsMapper.parse(taskIcs, href = "https://example.com/t1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
+
+ val combined = IcsMapper.buildCombinedIcs(listOf(event, task))
+
+ assertTrue(combined.contains("SUMMARY:Event One"))
+ assertTrue(combined.contains("SUMMARY:Task One"))
+ assertTrue(combined.contains("BEGIN:VEVENT"))
+ assertTrue(combined.contains("BEGIN:VTODO"))
+ }
+
+ // ---------- Single-occurrence edits (RECURRENCE-ID overrides + EXDATE) ----------
+
+ private fun utc(millis: Long): String {
+ val fmt = java.text.SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", java.util.Locale.US)
+ fmt.timeZone = java.util.TimeZone.getTimeZone("UTC")
+ return fmt.format(java.util.Date(millis))
+ }
+
+ private val dailyEventIcs = ics(
+ "BEGIN:VCALENDAR",
+ "VERSION:2.0",
+ "PRODID:-//Nextcloud//Test//EN",
+ "BEGIN:VEVENT",
+ "UID:daily-series@example.com",
+ "DTSTAMP:20200106T090000Z",
+ "DTSTART:20200106T090000Z",
+ "DTEND:20200106T100000Z",
+ "SUMMARY:Daily Standup",
+ "RRULE:FREQ=DAILY",
+ "END:VEVENT",
+ "END:VCALENDAR"
+ )
+
+ @Test
+ fun `a RECURRENCE-ID override's fields are surfaced for the matching occurrence`() {
+ val master = IcsMapper.parse(dailyEventIcs, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
+ val occurrenceDate = master.occurrenceDate!!
+
+ val withOverride = ics(
+ "BEGIN:VCALENDAR",
+ "VERSION:2.0",
+ "PRODID:-//Nextcloud//Test//EN",
+ "BEGIN:VEVENT",
+ "UID:daily-series@example.com",
+ "DTSTAMP:20200106T090000Z",
+ "DTSTART:20200106T090000Z",
+ "DTEND:20200106T100000Z",
+ "SUMMARY:Daily Standup",
+ "RRULE:FREQ=DAILY",
+ "END:VEVENT",
+ "BEGIN:VEVENT",
+ "UID:daily-series@example.com",
+ "DTSTAMP:20200106T090000Z",
+ "RECURRENCE-ID:${utc(occurrenceDate)}",
+ "DTSTART:${utc(occurrenceDate + 3600_000)}", // moved 1 hour later
+ "DTEND:${utc(occurrenceDate + 7200_000)}",
+ "SUMMARY:Daily Standup (moved)",
+ "END:VEVENT",
+ "END:VCALENDAR"
+ )
+
+ val item = IcsMapper.parse(withOverride, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
+
+ assertEquals("the override's summary must be shown, not the master's", "Daily Standup (moved)", item.summary)
+ assertEquals("the override's own (moved) start must be shown", occurrenceDate + 3600_000, item.start)
+ assertEquals("occurrenceDate stays anchored to the RRULE slot, not the override's moved time", occurrenceDate, item.occurrenceDate)
+ assertEquals(RecurrenceFrequency.DAILY, item.recurrenceFrequency)
+ }
+
+ @Test
+ fun `buildOccurrenceOverrideIcs adds an override without touching the master's RRULE`() {
+ val master = IcsMapper.parse(dailyEventIcs, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
+ val edited = master.copy(summary = "Daily Standup (just today)", start = master.occurrenceDate!! + 1800_000)
+
+ val rebuilt = IcsMapper.buildOccurrenceOverrideIcs(edited)
+
+ assertEquals("master RRULE must be untouched", 1, rebuilt.lines().count { it.startsWith("RRULE") })
+ assertTrue(rebuilt.contains("RECURRENCE-ID"))
+ assertTrue(rebuilt.contains("SUMMARY:Daily Standup (just today)"))
+ assertTrue("the master's own summary must still be present, untouched", rebuilt.lines().any { it == "SUMMARY:Daily Standup" })
+
+ // Re-parsing confirms the override round-trips and is picked up as the current occurrence.
+ val reparsed = IcsMapper.parse(rebuilt, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
+ assertEquals("Daily Standup (just today)", reparsed.summary)
+ }
+
+ @Test
+ fun `saving this occurrence twice replaces the override instead of duplicating it`() {
+ val master = IcsMapper.parse(dailyEventIcs, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
+ val firstEdit = master.copy(summary = "First edit")
+ val onceEdited = IcsMapper.buildOccurrenceOverrideIcs(firstEdit)
+
+ val reparsed = IcsMapper.parse(onceEdited, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
+ val secondEdit = reparsed.copy(summary = "Second edit")
+ val twiceEdited = IcsMapper.buildOccurrenceOverrideIcs(secondEdit)
+
+ assertEquals("only one override VEVENT should exist for this occurrence", 2, twiceEdited.lines().count { it.startsWith("BEGIN:VEVENT") })
+ assertFalse(twiceEdited.contains("First edit"))
+ assertTrue(twiceEdited.contains("Second edit"))
+ }
+
+ @Test
+ fun `buildOccurrenceDeletionIcs excludes just that date and the next sync skips to the following occurrence`() {
+ val master = IcsMapper.parse(dailyEventIcs, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
+ val deleted = IcsMapper.buildOccurrenceDeletionIcs(master)
+
+ assertTrue(deleted.contains("EXDATE"))
+ // the master's RRULE is untouched - only an EXDATE was added alongside it.
+ assertEquals(1, deleted.lines().count { it.startsWith("RRULE") })
+
+ val next = IcsMapper.parse(deleted, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
+ assertTrue("the next occurrence must be strictly after the deleted one", next.occurrenceDate!! > master.occurrenceDate!!)
+ }
+
+ @Test
+ fun `deleting an already-overridden occurrence removes the stale override too`() {
+ val master = IcsMapper.parse(dailyEventIcs, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
+ val overridden = IcsMapper.buildOccurrenceOverrideIcs(master.copy(summary = "Moved just for today"))
+ val reparsed = IcsMapper.parse(overridden, href = "https://example.com/d1.ics", etag = null, calendarUrl = "https://example.com/cal/")!!
+
+ val deleted = IcsMapper.buildOccurrenceDeletionIcs(reparsed)
+
+ assertFalse("the override VEVENT must be gone, not just excluded", deleted.contains("Moved just for today"))
+ assertEquals(1, deleted.lines().count { it.startsWith("BEGIN:VEVENT") })
+ }
}
diff --git a/ncal/app/src/test/java/com/homelab/ncal/util/RecurrenceUtilsTest.kt b/ncal/app/src/test/java/com/homelab/ncal/util/RecurrenceUtilsTest.kt
new file mode 100644
index 0000000..7dc1f7e
--- /dev/null
+++ b/ncal/app/src/test/java/com/homelab/ncal/util/RecurrenceUtilsTest.kt
@@ -0,0 +1,99 @@
+package com.homelab.ncal.util
+
+import biweekly.util.DayOfWeek
+import biweekly.util.Frequency
+import biweekly.util.Recurrence
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import java.time.ZoneId
+import java.time.ZonedDateTime
+
+/**
+ * Covers the WEEKLY+BYDAY branch of [RecurrenceUtils.nextOccurrence] added alongside the
+ * Repeats picker's new day-of-week toggles - a plain period-jump (the pre-existing behavior for
+ * non-BYDAY rules) is the wrong algorithm for "every Mon/Wed/Fri": it would just keep recurring
+ * on DTSTART's own single weekday forever, silently ignoring the other selected days.
+ */
+class RecurrenceUtilsTest {
+
+ private val zone = ZoneId.systemDefault()
+
+ private fun millis(iso: String): Long = ZonedDateTime.parse(iso).withZoneSameInstant(zone).toInstant().toEpochMilli()
+
+ @Test
+ fun `weekly MonWedFri rule advances to the next selected weekday, not just 7 days later`() {
+ // DTSTART is a Monday 2024-01-01T09:00. Reference is the following Tuesday - the next
+ // occurrence should be Wednesday of that same week, not the following Monday.
+ val start = millis("2024-01-01T09:00:00Z")
+ val reference = millis("2024-01-02T12:00:00Z") // Tuesday noon
+ val recurrence = Recurrence.Builder(Frequency.WEEKLY)
+ .byDay(listOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY))
+ .build()
+
+ val next = RecurrenceUtils.nextOccurrence(start, recurrence, reference)
+
+ assertEquals(millis("2024-01-03T09:00:00Z"), next) // Wednesday same week
+ }
+
+ @Test
+ fun `weekly MonWedFri rule rolls into the following week once the current week is exhausted`() {
+ val start = millis("2024-01-01T09:00:00Z") // Monday
+ val reference = millis("2024-01-06T12:00:00Z") // Saturday, past Fri's occurrence
+ val recurrence = Recurrence.Builder(Frequency.WEEKLY)
+ .byDay(listOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY))
+ .build()
+
+ val next = RecurrenceUtils.nextOccurrence(start, recurrence, reference)
+
+ assertEquals(millis("2024-01-08T09:00:00Z"), next) // next Monday
+ }
+
+ @Test
+ fun `biweekly BYDAY rule skips the off week entirely`() {
+ // Every OTHER week, on Mon/Wed. DTSTART Monday 2024-01-01 is week 0 (active).
+ // Week of 2024-01-08 is week 1 (inactive, skipped). Week of 2024-01-15 is week 2 (active).
+ val start = millis("2024-01-01T09:00:00Z")
+ val reference = millis("2024-01-09T00:00:00Z") // during the inactive week
+ val recurrence = Recurrence.Builder(Frequency.WEEKLY)
+ .interval(2)
+ .byDay(listOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY))
+ .build()
+
+ val next = RecurrenceUtils.nextOccurrence(start, recurrence, reference)
+
+ assertEquals(millis("2024-01-15T09:00:00Z"), next) // Monday of the next ACTIVE week
+ }
+
+ @Test
+ fun `BYDAY rule respects COUNT even when counting by individual weekday occurrences`() {
+ // Mon+Wed, COUNT=3: Mon(1), Wed(2), next Mon(3) - then the series is over.
+ val start = millis("2024-01-01T09:00:00Z") // Monday, occurrence #1
+ val recurrence = Recurrence.Builder(Frequency.WEEKLY)
+ .byDay(listOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY))
+ .count(3)
+ .build()
+
+ val thirdOccurrence = millis("2024-01-08T09:00:00Z") // occurrence #3 (next Monday)
+ assertEquals(thirdOccurrence, RecurrenceUtils.nextOccurrence(start, recurrence, thirdOccurrence))
+
+ val pastTheEnd = millis("2024-01-08T09:00:01Z")
+ assertNull("the series only has 3 occurrences", RecurrenceUtils.nextOccurrence(start, recurrence, pastTheEnd))
+ }
+
+ @Test
+ fun `BYDAY rule starting mid-week only counts days at or after DTSTART`() {
+ // DTSTART is Wednesday, BYDAY=Mon,Wed,Fri - the Monday of DTSTART's own week must NOT
+ // count (it's before DTSTART), but Wed and Fri of that same week should.
+ val start = millis("2024-01-03T09:00:00Z") // Wednesday
+ val recurrence = Recurrence.Builder(Frequency.WEEKLY)
+ .byDay(listOf(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY))
+ .build()
+
+ val firstOccurrence = RecurrenceUtils.nextOccurrence(start, recurrence, start)
+ assertEquals("the first occurrence is DTSTART itself, not the preceding Monday", start, firstOccurrence)
+
+ val afterWednesday = millis("2024-01-03T10:00:00Z")
+ assertEquals(millis("2024-01-05T09:00:00Z"), RecurrenceUtils.nextOccurrence(start, recurrence, afterWednesday)) // Friday
+ }
+}