From 3d0f6589a9b8342ab7301505f463e8ac4a2e8596 Mon Sep 17 00:00:00 2001 From: RomanNum3ral Date: Fri, 17 Jul 2026 18:53:39 -0400 Subject: [PATCH] Add offline resilience, undo-delete, search, week view, BYDAY recurrence, import/export, and single-occurrence recurring edits Closes out the full post-launch feature backlog: - Offline write resilience for save/delete, with automatic flush on reconnect - Undo-after-delete via a snackbar, with a fresh-UID recreate (Nextcloud's trashbin rejects reusing a just-deleted item's UID) - Custom (non-preset) reminder offsets - Search across events and tasks - Week view alongside the existing Agenda/Month views - BYDAY support in the Repeats picker ("every Mon/Wed/Fri"), including a proper RRULE evaluator extension for it - Import/export .ics via the system share sheet and file picker - Single-occurrence recurring-event editing/deleting ("this event only" vs "all events") via RFC 5545 RECURRENCE-ID overrides and EXDATE A repository-wide write mutex now serializes sync against save/delete/undo, fixing a real race where a background sync could clobber a just-written item. Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/test.yml | 28 ++ ncal/app/build.gradle.kts | 1 + ncal/app/src/main/AndroidManifest.xml | 10 + .../com/homelab/ncal/data/db/AppDatabase.kt | 15 +- .../com/homelab/ncal/data/db/ItemEntity.kt | 15 +- .../homelab/ncal/data/model/CalendarItem.kt | 22 ++ .../data/repository/NextcloudRepository.kt | 314 +++++++++++++++++- .../homelab/ncal/ui/agenda/AgendaScreen.kt | 8 +- .../ncal/ui/collections/CollectionsScreen.kt | 115 ++++++- .../ui/collections/CollectionsViewModel.kt | 33 +- .../homelab/ncal/ui/detail/ItemEditScreen.kt | 287 ++++++++++++---- .../ncal/ui/detail/ItemEditViewModel.kt | 113 ++++++- .../com/homelab/ncal/ui/nav/NcalNavGraph.kt | 198 +++++++---- .../homelab/ncal/ui/search/SearchScreen.kt | 137 ++++++++ .../homelab/ncal/ui/search/SearchViewModel.kt | 46 +++ .../com/homelab/ncal/ui/week/WeekScreen.kt | 180 ++++++++++ .../com/homelab/ncal/ui/week/WeekViewModel.kt | 87 +++++ .../java/com/homelab/ncal/util/IcsMapper.kt | 290 ++++++++++++++-- .../com/homelab/ncal/util/RecurrenceUtils.kt | 102 +++++- ncal/app/src/main/res/xml/file_paths.xml | 4 + .../ncal/data/network/CalDavClientTest.kt | 77 +++++ .../com/homelab/ncal/util/IcsMapperTest.kt | 247 +++++++++++++- .../homelab/ncal/util/RecurrenceUtilsTest.kt | 99 ++++++ 23 files changed, 2214 insertions(+), 214 deletions(-) create mode 100644 .gitea/workflows/test.yml create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/search/SearchScreen.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/search/SearchViewModel.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/week/WeekScreen.kt create mode 100644 ncal/app/src/main/java/com/homelab/ncal/ui/week/WeekViewModel.kt create mode 100644 ncal/app/src/main/res/xml/file_paths.xml create mode 100644 ncal/app/src/test/java/com/homelab/ncal/data/network/CalDavClientTest.kt create mode 100644 ncal/app/src/test/java/com/homelab/ncal/util/RecurrenceUtilsTest.kt 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 + } +}