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 <noreply@anthropic.com>
This commit is contained in:
parent
79cc6925e8
commit
3d0f6589a9
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,16 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name=".notifications.ReminderReceiver"
|
||||
android:exported="false" />
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<String>): Flow<List<ItemEntity>>
|
||||
|
||||
@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<String>): Flow<List<ItemEntity>>
|
||||
|
||||
@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<String>): Flow<List<ItemEntity>>
|
||||
|
||||
@Query("SELECT * FROM items WHERE href = :href LIMIT 1")
|
||||
|
|
@ -56,6 +59,12 @@ interface ItemDao {
|
|||
@Query("SELECT * FROM items")
|
||||
suspend fun getAllOnce(): List<ItemEntity>
|
||||
|
||||
@Query("SELECT * FROM items WHERE calendarUrl = :calendarUrl AND syncStatus != 'PENDING_DELETE' ORDER BY COALESCE(start, due) ASC")
|
||||
suspend fun getForCalendarOnce(calendarUrl: String): List<ItemEntity>
|
||||
|
||||
@Query("SELECT * FROM items WHERE syncStatus != 'SYNCED'")
|
||||
suspend fun getPendingSync(): List<ItemEntity>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAll(items: List<ItemEntity>)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<DayOfWeek> = 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()
|
||||
|
|
|
|||
|
|
@ -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<CalendarItem?>(null)
|
||||
val lastDeleted: StateFlow<CalendarItem?> = _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<Int> =
|
||||
if (isBlank()) emptyList() else split(",").mapNotNull { it.trim().toIntOrNull() }
|
||||
|
||||
private fun String.toDayOfWeekSet(): Set<java.time.DayOfWeek> =
|
||||
if (isBlank()) emptySet() else split(",").mapNotNull { runCatching { java.time.DayOfWeek.valueOf(it.trim()) }.getOrNull() }.toSet()
|
||||
|
||||
private fun List<Int>.toCsv(): String = joinToString(",")
|
||||
|
||||
/** Server sometimes returns relative hrefs; normalize against the calendar home. */
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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<Uri?>(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<CalendarCollection>,
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -14,18 +14,20 @@ import kotlinx.coroutines.launch
|
|||
data class CollectionsUiState(
|
||||
val collections: List<CalendarCollection> = 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<String?>(null)
|
||||
private val importStatus = MutableStateFlow<String?>(null)
|
||||
|
||||
val state: StateFlow<CollectionsUiState> = 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RecurrenceFrequency?, String>(
|
||||
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<DayOfWeek>, 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<Int>, 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<Int>, 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ConflictField> = emptyList(),
|
||||
val conflictChoices: Map<String, Boolean> = 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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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<String> = _query.asStateFlow()
|
||||
|
||||
private val allItems = repo.observeCollections()
|
||||
.flatMapLatest { collections ->
|
||||
val urls = collections.filter { it.enabled }.map { it.url }
|
||||
repo.observeItems(urls)
|
||||
}
|
||||
|
||||
val results: StateFlow<List<CalendarItem>> = 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CalendarItem>,
|
||||
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"))
|
||||
}
|
||||
|
|
@ -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<LocalDate, List<CalendarItem>> = 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<String?>(null)
|
||||
|
||||
private val allItems = repo.observeCollections()
|
||||
.flatMapLatest { collections ->
|
||||
val urls = collections.filter { it.enabled }.map { it.url }
|
||||
repo.observeItems(urls)
|
||||
}
|
||||
|
||||
val state: StateFlow<WeekUiState> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<java.time.DayOfWeek> =
|
||||
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<CalendarItem> {
|
||||
val ical = runCatching { Biweekly.parse(ics).first() }.getOrNull() ?: return emptyList()
|
||||
val items = mutableListOf<CalendarItem>()
|
||||
|
||||
// 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<CalendarItem>): 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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Long> = 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<DayOfWeek> = 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<DayOfWeek>,
|
||||
until: ZonedDateTime?,
|
||||
maxCount: Int?,
|
||||
exceptionDates: Set<Long>
|
||||
): 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="exports" path="exports/" />
|
||||
</paths>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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") })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue