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")
|
implementation("net.zetetic:android-database-sqlcipher:4.5.4")
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,16 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<receiver
|
||||||
android:name=".notifications.ReminderReceiver"
|
android:name=".notifications.ReminderReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import net.sqlcipher.database.SupportFactory
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [CollectionEntity::class, ItemEntity::class],
|
entities = [CollectionEntity::class, ItemEntity::class],
|
||||||
version = 6,
|
version = 9,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
@ -18,7 +18,18 @@ abstract class AppDatabase : RoomDatabase() {
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile private var instance: AppDatabase? = null
|
@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 =
|
fun get(context: Context, passphrase: ByteArray): AppDatabase =
|
||||||
instance ?: synchronized(this) {
|
instance ?: synchronized(this) {
|
||||||
instance ?: Room.databaseBuilder(
|
instance ?: Room.databaseBuilder(
|
||||||
|
|
|
||||||
|
|
@ -32,22 +32,25 @@ data class ItemEntity(
|
||||||
val recurrenceInterval: Int,
|
val recurrenceInterval: Int,
|
||||||
val recurrenceCount: Int?,
|
val recurrenceCount: Int?,
|
||||||
val recurrenceUntil: Long?,
|
val recurrenceUntil: Long?,
|
||||||
|
val recurrenceByDay: String, // comma-separated java.time.DayOfWeek names, "" if none (WEEKLY only)
|
||||||
val isRecurring: Boolean,
|
val isRecurring: Boolean,
|
||||||
val recurrenceMasterStart: Long?,
|
val recurrenceMasterStart: Long?,
|
||||||
val recurrenceMasterEnd: Long?,
|
val recurrenceMasterEnd: Long?,
|
||||||
val recurrenceMasterDue: 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?
|
val rawIcs: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface ItemDao {
|
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>>
|
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>>
|
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>>
|
fun observeEvents(calendarUrls: List<String>): Flow<List<ItemEntity>>
|
||||||
|
|
||||||
@Query("SELECT * FROM items WHERE href = :href LIMIT 1")
|
@Query("SELECT * FROM items WHERE href = :href LIMIT 1")
|
||||||
|
|
@ -56,6 +59,12 @@ interface ItemDao {
|
||||||
@Query("SELECT * FROM items")
|
@Query("SELECT * FROM items")
|
||||||
suspend fun getAllOnce(): List<ItemEntity>
|
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
|
@Upsert
|
||||||
suspend fun upsertAll(items: List<ItemEntity>)
|
suspend fun upsertAll(items: List<ItemEntity>)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package com.homelab.ncal.data.model
|
package com.homelab.ncal.data.model
|
||||||
|
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
|
||||||
enum class ItemType { EVENT, TASK }
|
enum class ItemType { EVENT, TASK }
|
||||||
|
|
||||||
/** Mirrors the iCalendar VTODO STATUS values (tasks only). */
|
/** 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. */
|
* round-trips fine via [CalendarItem.rawIcs] - it just isn't editable through the picker. */
|
||||||
enum class RecurrenceFrequency { DAILY, WEEKLY, MONTHLY, YEARLY }
|
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).
|
* A single VEVENT or VTODO. Times are stored as epoch millis (UTC).
|
||||||
* [rawIcs] retains the full round-tripped iCalendar object so fields we don't
|
* [rawIcs] retains the full round-tripped iCalendar object so fields we don't
|
||||||
|
|
@ -43,6 +55,7 @@ data class CalendarItem(
|
||||||
val recurrenceInterval: Int = 1, // "every N days/weeks/months/years"
|
val recurrenceInterval: Int = 1, // "every N days/weeks/months/years"
|
||||||
val recurrenceCount: Int? = null, // mutually exclusive with recurrenceUntil
|
val recurrenceCount: Int? = null, // mutually exclusive with recurrenceUntil
|
||||||
val recurrenceUntil: Long? = null, // mutually exclusive with recurrenceCount
|
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 -
|
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
|
// [start]/[end]/[due] are the resolved *next occurrence* when true, not
|
||||||
// the master date - see recurrenceMaster* below for that
|
// the master date - see recurrenceMaster* below for that
|
||||||
|
|
@ -55,6 +68,15 @@ data class CalendarItem(
|
||||||
val recurrenceMasterEnd: Long? = null, // events only
|
val recurrenceMasterEnd: Long? = null, // events only
|
||||||
val recurrenceMasterDue: Long? = null, // tasks 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 rawIcs: String? = null
|
||||||
) {
|
) {
|
||||||
val isNew: Boolean get() = href.isEmpty()
|
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.CalendarItem
|
||||||
import com.homelab.ncal.data.model.ItemType
|
import com.homelab.ncal.data.model.ItemType
|
||||||
import com.homelab.ncal.data.model.RecurrenceFrequency
|
import com.homelab.ncal.data.model.RecurrenceFrequency
|
||||||
|
import com.homelab.ncal.data.model.SyncStatus
|
||||||
import com.homelab.ncal.data.model.TaskStatus
|
import com.homelab.ncal.data.model.TaskStatus
|
||||||
import com.homelab.ncal.data.network.CalDavClient
|
import com.homelab.ncal.data.network.CalDavClient
|
||||||
import com.homelab.ncal.data.network.CalDavException
|
import com.homelab.ncal.data.network.CalDavException
|
||||||
|
|
@ -18,16 +19,44 @@ import com.homelab.ncal.util.IcsMapper
|
||||||
import com.homelab.ncal.widget.MonthGridWidget
|
import com.homelab.ncal.widget.MonthGridWidget
|
||||||
import com.homelab.ncal.widget.NextTasksWidget
|
import com.homelab.ncal.widget.NextTasksWidget
|
||||||
import androidx.glance.appwidget.updateAll
|
import androidx.glance.appwidget.updateAll
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
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.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
class NextcloudRepository(context: Context) {
|
class NextcloudRepository(context: Context) {
|
||||||
|
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
private val prefs = SecurePrefs(context)
|
private val prefs = SecurePrefs(context)
|
||||||
private val db = AppDatabase.get(context, prefs.databaseKey)
|
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 {
|
private fun client(): CalDavClient {
|
||||||
val server = prefs.serverUrl ?: error("Not logged in")
|
val server = prefs.serverUrl ?: error("Not logged in")
|
||||||
|
|
@ -104,8 +133,20 @@ class NextcloudRepository(context: Context) {
|
||||||
db.itemDao().getAllOnce().map { it.toModel() }
|
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) {
|
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 c = client()
|
||||||
val collections = db.collectionDao().getAll().filter { it.enabled }
|
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})" }}")
|
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() }
|
keepHrefs += remote.map { it.href.toAbsolute() }
|
||||||
val entities = remote.mapNotNull { dav ->
|
val entities = remote.mapNotNull { dav ->
|
||||||
IcsMapper.parse(dav.calendarData!!, dav.href.toAbsolute(), dav.etag, col.url)?.toEntity()
|
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}")
|
android.util.Log.d("NCalSync", "${col.displayName}: fetched ${remote.size} VEVENT(s), parsed ${entities.size}")
|
||||||
db.itemDao().upsertAll(entities)
|
db.itemDao().upsertAll(entities)
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +176,7 @@ class NextcloudRepository(context: Context) {
|
||||||
keepHrefs += remote.map { it.href.toAbsolute() }
|
keepHrefs += remote.map { it.href.toAbsolute() }
|
||||||
val entities = remote.mapNotNull { dav ->
|
val entities = remote.mapNotNull { dav ->
|
||||||
IcsMapper.parse(dav.calendarData!!, dav.href.toAbsolute(), dav.etag, col.url)?.toEntity()
|
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}")
|
android.util.Log.d("NCalSync", "${col.displayName}: fetched ${remote.size} VTODO(s), parsed ${entities.size}")
|
||||||
db.itemDao().upsertAll(entities)
|
db.itemDao().upsertAll(entities)
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +188,49 @@ class NextcloudRepository(context: Context) {
|
||||||
ReminderScheduler.rescheduleAll(appContext, db.itemDao().getAllOnce().map { it.toModel() })
|
ReminderScheduler.rescheduleAll(appContext, db.itemDao().getAllOnce().map { it.toModel() })
|
||||||
updateWidgets()
|
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() {
|
suspend fun fullSync() {
|
||||||
syncCollections()
|
syncCollections()
|
||||||
|
|
@ -156,21 +240,29 @@ class NextcloudRepository(context: Context) {
|
||||||
/**
|
/**
|
||||||
* Creates or updates an item both on the server and in the local cache.
|
* Creates or updates an item both on the server and in the local cache.
|
||||||
*
|
*
|
||||||
|
* 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
|
* @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
|
* 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.
|
* version so the caller can offer the user a real choice instead of just an error.
|
||||||
*/
|
*/
|
||||||
suspend fun save(item: CalendarItem): CalendarItem = withContext(Dispatchers.IO) {
|
suspend fun save(item: CalendarItem): CalendarItem = withContext(Dispatchers.IO) {
|
||||||
|
writeMutex.withLock {
|
||||||
val c = client()
|
val c = client()
|
||||||
val ics = IcsMapper.toIcs(item)
|
val ics = IcsMapper.toIcs(item)
|
||||||
|
|
||||||
val saved = try {
|
val saved = try {
|
||||||
if (item.isNew) {
|
if (item.isNew) {
|
||||||
val (href, etag) = c.createItem(item.calendarUrl, ics, item.uid)
|
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 {
|
} else {
|
||||||
val etag = c.updateItem(item.href, ics, item.etag)
|
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) {
|
} catch (e: CalDavException) {
|
||||||
if (e.httpCode == 412 && !item.isNew) {
|
if (e.httpCode == 412 && !item.isNew) {
|
||||||
|
|
@ -181,24 +273,217 @@ class NextcloudRepository(context: Context) {
|
||||||
throw SaveConflictException(localEdit = item, serverVersion = serverVersion, cause = e)
|
throw SaveConflictException(localEdit = item, serverVersion = serverVersion, cause = e)
|
||||||
}
|
}
|
||||||
throw 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
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())
|
db.itemDao().upsert(saved.toEntity())
|
||||||
ReminderScheduler.reschedule(appContext, saved)
|
ReminderScheduler.reschedule(appContext, saved)
|
||||||
updateWidgets()
|
updateWidgets()
|
||||||
saved
|
saved
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun delete(item: CalendarItem) = withContext(Dispatchers.IO) {
|
|
||||||
if (!item.isNew) {
|
|
||||||
client().deleteItem(item.href, item.etag)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
db.itemDao().deleteByHref(item.href)
|
||||||
ReminderScheduler.cancel(appContext, item)
|
ReminderScheduler.cancel(appContext, item)
|
||||||
|
}
|
||||||
updateWidgets()
|
updateWidgets()
|
||||||
|
serverItem
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun toggleTaskComplete(item: CalendarItem): CalendarItem =
|
// ---------- Import / export ----------
|
||||||
save(item.withStatus(if (item.completed) TaskStatus.NEEDS_ACTION else TaskStatus.COMPLETED))
|
|
||||||
|
/** 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() {
|
private suspend fun updateWidgets() {
|
||||||
MonthGridWidget().updateAll(appContext)
|
MonthGridWidget().updateAll(appContext)
|
||||||
|
|
@ -221,8 +506,11 @@ class NextcloudRepository(context: Context) {
|
||||||
parentUid = parentUid, reminderMinutes = reminderMinutes.toMinutesList(),
|
parentUid = parentUid, reminderMinutes = reminderMinutes.toMinutesList(),
|
||||||
recurrenceFrequency = recurrenceFrequency?.let { runCatching { RecurrenceFrequency.valueOf(it) }.getOrNull() },
|
recurrenceFrequency = recurrenceFrequency?.let { runCatching { RecurrenceFrequency.valueOf(it) }.getOrNull() },
|
||||||
recurrenceInterval = recurrenceInterval, recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil,
|
recurrenceInterval = recurrenceInterval, recurrenceCount = recurrenceCount, recurrenceUntil = recurrenceUntil,
|
||||||
|
recurrenceByDay = recurrenceByDay.toDayOfWeekSet(),
|
||||||
isRecurring = isRecurring,
|
isRecurring = isRecurring,
|
||||||
recurrenceMasterStart = recurrenceMasterStart, recurrenceMasterEnd = recurrenceMasterEnd, recurrenceMasterDue = recurrenceMasterDue,
|
recurrenceMasterStart = recurrenceMasterStart, recurrenceMasterEnd = recurrenceMasterEnd, recurrenceMasterDue = recurrenceMasterDue,
|
||||||
|
occurrenceDate = occurrenceDate,
|
||||||
|
syncStatus = runCatching { SyncStatus.valueOf(syncStatus) }.getOrDefault(SyncStatus.SYNCED),
|
||||||
rawIcs = rawIcs
|
rawIcs = rawIcs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -233,14 +521,20 @@ class NextcloudRepository(context: Context) {
|
||||||
status = status.name, percentComplete = percentComplete,
|
status = status.name, percentComplete = percentComplete,
|
||||||
priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(),
|
priority = priority, parentUid = parentUid, reminderMinutes = reminderMinutes.toCsv(),
|
||||||
recurrenceFrequency = recurrenceFrequency?.name, recurrenceInterval = recurrenceInterval,
|
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,
|
recurrenceMasterStart = recurrenceMasterStart, recurrenceMasterEnd = recurrenceMasterEnd, recurrenceMasterDue = recurrenceMasterDue,
|
||||||
|
occurrenceDate = occurrenceDate,
|
||||||
|
syncStatus = syncStatus.name,
|
||||||
rawIcs = rawIcs
|
rawIcs = rawIcs
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun String.toMinutesList(): List<Int> =
|
private fun String.toMinutesList(): List<Int> =
|
||||||
if (isBlank()) emptyList() else split(",").mapNotNull { it.trim().toIntOrNull() }
|
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(",")
|
private fun List<Int>.toCsv(): String = joinToString(",")
|
||||||
|
|
||||||
/** Server sometimes returns relative hrefs; normalize against the calendar home. */
|
/** 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.List
|
||||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
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.Settings
|
||||||
|
import androidx.compose.material.icons.filled.ViewWeek
|
||||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
|
@ -52,7 +54,9 @@ fun AgendaScreen(
|
||||||
onNewEvent: () -> Unit,
|
onNewEvent: () -> Unit,
|
||||||
onOpenTasks: () -> Unit,
|
onOpenTasks: () -> Unit,
|
||||||
onOpenCollections: () -> Unit,
|
onOpenCollections: () -> Unit,
|
||||||
onOpenMonth: () -> Unit
|
onOpenMonth: () -> Unit,
|
||||||
|
onOpenWeek: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
|
@ -65,6 +69,8 @@ fun AgendaScreen(
|
||||||
CenterAlignedTopAppBar(
|
CenterAlignedTopAppBar(
|
||||||
title = { Text("Agenda") },
|
title = { Text("Agenda") },
|
||||||
actions = {
|
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 = onOpenMonth) { Icon(Icons.Filled.CalendarMonth, "Month view") }
|
||||||
IconButton(onClick = onOpenTasks) { Icon(Icons.Filled.List, "Tasks") }
|
IconButton(onClick = onOpenTasks) { Icon(Icons.Filled.List, "Tasks") }
|
||||||
IconButton(onClick = onOpenCollections) { Icon(Icons.Filled.Settings, "Calendars") }
|
IconButton(onClick = onOpenCollections) { Icon(Icons.Filled.Settings, "Calendars") }
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,25 @@
|
||||||
package com.homelab.ncal.ui.collections
|
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.Canvas
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.FileDownload
|
||||||
|
import androidx.compose.material.icons.filled.FileUpload
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
|
@ -20,13 +30,23 @@ import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -35,6 +55,14 @@ fun CollectionsScreen(
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|
@ -44,6 +72,9 @@ fun CollectionsScreen(
|
||||||
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) }
|
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, null) }
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = { openDocument.launch(arrayOf("text/calendar", "*/*")) }) {
|
||||||
|
Icon(Icons.Filled.FileUpload, "Import .ics")
|
||||||
|
}
|
||||||
IconButton(onClick = viewModel::refresh) { Icon(Icons.Filled.Refresh, "Refresh") }
|
IconButton(onClick = viewModel::refresh) { Icon(Icons.Filled.Refresh, "Refresh") }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -56,6 +87,15 @@ fun CollectionsScreen(
|
||||||
state.error?.let {
|
state.error?.let {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp))
|
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 {
|
LazyColumn {
|
||||||
items(state.collections, key = { it.url }) { col ->
|
items(state.collections, key = { it.url }) { col ->
|
||||||
ListItem(
|
ListItem(
|
||||||
|
|
@ -69,16 +109,83 @@ fun CollectionsScreen(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
|
Row {
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val ics = viewModel.exportIcs(col.url)
|
||||||
|
shareIcs(context, ics, col.displayName)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Filled.FileDownload, "Export ${col.displayName}")
|
||||||
|
}
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = col.enabled,
|
checked = col.enabled,
|
||||||
onCheckedChange = { viewModel.setEnabled(col.url, it) }
|
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 {
|
private fun componentLabel(events: Boolean, tasks: Boolean): String = when {
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,20 @@ import kotlinx.coroutines.launch
|
||||||
data class CollectionsUiState(
|
data class CollectionsUiState(
|
||||||
val collections: List<CalendarCollection> = emptyList(),
|
val collections: List<CalendarCollection> = emptyList(),
|
||||||
val refreshing: Boolean = false,
|
val refreshing: Boolean = false,
|
||||||
val error: String? = null
|
val error: String? = null,
|
||||||
|
val importStatus: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
class CollectionsViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
class CollectionsViewModel(private val repo: NextcloudRepository) : ViewModel() {
|
||||||
|
|
||||||
private val refreshing = MutableStateFlow(false)
|
private val refreshing = MutableStateFlow(false)
|
||||||
private val error = MutableStateFlow<String?>(null)
|
private val error = MutableStateFlow<String?>(null)
|
||||||
|
private val importStatus = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
val state: StateFlow<CollectionsUiState> = combine(
|
val state: StateFlow<CollectionsUiState> = combine(
|
||||||
repo.observeCollections(), refreshing, error
|
repo.observeCollections(), refreshing, error, importStatus
|
||||||
) { collections, isRefreshing, err ->
|
) { collections, isRefreshing, err, importMsg ->
|
||||||
CollectionsUiState(collections, isRefreshing, err)
|
CollectionsUiState(collections, isRefreshing, err, importMsg)
|
||||||
}.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), CollectionsUiState())
|
}.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5000), CollectionsUiState())
|
||||||
|
|
||||||
fun refresh() {
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -27,6 +28,7 @@ import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.priorityLabel
|
||||||
import com.homelab.ncal.util.statusLabel
|
import com.homelab.ncal.util.statusLabel
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.temporal.WeekFields
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
@ -70,8 +74,16 @@ fun ItemEditScreen(
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
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) {
|
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()
|
if (state.done || state.deleted) onDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,28 +160,22 @@ fun ItemEditScreen(
|
||||||
|
|
||||||
if (item.type == ItemType.EVENT) {
|
if (item.type == ItemType.EVENT) {
|
||||||
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
|
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
|
||||||
if (item.isRecurring) {
|
|
||||||
RecurringDateTimePicker(
|
|
||||||
millis = item.recurrenceMasterStart,
|
|
||||||
onChange = { v -> viewModel.update { it.copy(recurrenceMasterStart = v) } }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
DateTimePicker(
|
DateTimePicker(
|
||||||
millis = item.start,
|
millis = item.start,
|
||||||
onChange = { v -> viewModel.update { it.copy(start = v) } }
|
onChange = { v -> viewModel.update { it.copy(start = v) } }
|
||||||
)
|
)
|
||||||
}
|
|
||||||
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
|
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
|
||||||
if (item.isRecurring) {
|
|
||||||
RecurringDateTimePicker(
|
|
||||||
millis = item.recurrenceMasterEnd,
|
|
||||||
onChange = { v -> viewModel.update { it.copy(recurrenceMasterEnd = v) } }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
DateTimePicker(
|
DateTimePicker(
|
||||||
millis = item.end,
|
millis = item.end,
|
||||||
onChange = { v -> viewModel.update { it.copy(end = v) } }
|
onChange = { v -> viewModel.update { it.copy(end = v) } }
|
||||||
)
|
)
|
||||||
|
if (item.isRecurring) {
|
||||||
|
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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -184,28 +190,22 @@ fun ItemEditScreen(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
|
Text("Starts", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 16.dp))
|
||||||
if (item.isRecurring) {
|
|
||||||
RecurringDateTimePicker(
|
|
||||||
millis = item.recurrenceMasterStart,
|
|
||||||
onChange = { v -> viewModel.update { it.copy(recurrenceMasterStart = v) } }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
DateTimePicker(
|
DateTimePicker(
|
||||||
millis = item.start,
|
millis = item.start,
|
||||||
onChange = { v -> viewModel.update { it.copy(start = v) } }
|
onChange = { v -> viewModel.update { it.copy(start = v) } }
|
||||||
)
|
)
|
||||||
}
|
|
||||||
Text("Due", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
|
Text("Due", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
|
||||||
if (item.isRecurring) {
|
|
||||||
RecurringDateTimePicker(
|
|
||||||
millis = item.recurrenceMasterDue,
|
|
||||||
onChange = { v -> viewModel.update { it.copy(recurrenceMasterDue = v) } }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
DateTimePicker(
|
DateTimePicker(
|
||||||
millis = item.due,
|
millis = item.due,
|
||||||
onChange = { v -> viewModel.update { it.copy(due = v) } }
|
onChange = { v -> viewModel.update { it.copy(due = v) } }
|
||||||
)
|
)
|
||||||
|
if (item.isRecurring) {
|
||||||
|
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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -305,7 +305,10 @@ fun ItemEditScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
androidx.compose.material3.Button(
|
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,
|
enabled = !state.saving,
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp)
|
modifier = Modifier.fillMaxWidth().padding(top = 24.dp)
|
||||||
) {
|
) {
|
||||||
|
|
@ -317,15 +320,65 @@ fun ItemEditScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSaveScopeConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
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 = { showSaveScopeConfirm = false }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showDeleteConfirm) {
|
if (showDeleteConfirm) {
|
||||||
|
if (item.isRecurring) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showDeleteConfirm = false },
|
onDismissRequest = { showDeleteConfirm = false },
|
||||||
title = { Text("Delete this ${if (item.type == ItemType.TASK) "task" else "event"}?") },
|
title = { Text("Delete this ${if (item.type == ItemType.TASK) "task" else "event"}?") },
|
||||||
text = { Text("This can't be undone.") },
|
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 = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
showDeleteConfirm = false
|
showDeleteConfirm = false
|
||||||
viewModel.delete()
|
viewModel.deleteAllOccurrences()
|
||||||
}) { Text("Delete") }
|
}) { Text("Delete") }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
|
|
@ -333,6 +386,7 @@ fun ItemEditScreen(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.conflict?.let { conflict ->
|
state.conflict?.let { conflict ->
|
||||||
if (conflict.serverVersion != null && state.conflictFields.isNotEmpty()) {
|
if (conflict.serverVersion != null && state.conflictFields.isNotEmpty()) {
|
||||||
|
|
@ -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>(
|
private val recurrenceFrequencyLabels = linkedMapOf<RecurrenceFrequency?, String>(
|
||||||
null to "Does not repeat",
|
null to "Does not repeat",
|
||||||
RecurrenceFrequency.DAILY to "Daily",
|
RecurrenceFrequency.DAILY to "Daily",
|
||||||
|
|
@ -586,9 +624,9 @@ private fun RecurrencePicker(
|
||||||
onClick = {
|
onClick = {
|
||||||
onUpdate { cur ->
|
onUpdate { cur ->
|
||||||
if (freq == null) {
|
if (freq == null) {
|
||||||
cur.copy(recurrenceFrequency = null, recurrenceCount = null, recurrenceUntil = null)
|
cur.copy(recurrenceFrequency = null, recurrenceCount = null, recurrenceUntil = null, recurrenceByDay = emptySet())
|
||||||
} else {
|
} else {
|
||||||
cur.copy(recurrenceFrequency = freq)
|
cur.copy(recurrenceFrequency = freq, recurrenceByDay = if (freq == RecurrenceFrequency.WEEKLY) cur.recurrenceByDay else emptySet())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expanded = false
|
expanded = false
|
||||||
|
|
@ -614,6 +652,18 @@ private fun RecurrencePicker(
|
||||||
Text(intervalUnitLabel(freq, item.recurrenceInterval), modifier = Modifier.padding(start = 8.dp))
|
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))
|
Text("Ends", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
|
||||||
RecurrenceEndPicker(
|
RecurrenceEndPicker(
|
||||||
count = item.recurrenceCount,
|
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 }
|
private enum class RecurrenceEndMode { NEVER, AFTER_COUNT, ON_DATE }
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -787,13 +857,21 @@ private fun reminderLabel(minutes: Int): String = when {
|
||||||
else -> "$minutes minutes before"
|
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
|
@Composable
|
||||||
private fun AddReminderButton(existing: List<Int>, onAdd: (Int) -> Unit) {
|
private fun AddReminderButton(existing: List<Int>, onAdd: (Int) -> Unit) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
var showCustom by remember { mutableStateOf(false) }
|
||||||
val available = reminderPresets.filter { it !in existing }
|
val available = reminderPresets.filter { it !in existing }
|
||||||
|
|
||||||
Box {
|
Box {
|
||||||
TextButton(onClick = { expanded = true }, enabled = available.isNotEmpty()) {
|
TextButton(onClick = { expanded = true }) {
|
||||||
Text("+ Add reminder")
|
Text("+ Add reminder")
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
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.CalendarCollection
|
||||||
import com.homelab.ncal.data.model.CalendarItem
|
import com.homelab.ncal.data.model.CalendarItem
|
||||||
import com.homelab.ncal.data.model.ItemType
|
import com.homelab.ncal.data.model.ItemType
|
||||||
|
import com.homelab.ncal.data.model.SyncStatus
|
||||||
import com.homelab.ncal.data.repository.NextcloudRepository
|
import com.homelab.ncal.data.repository.NextcloudRepository
|
||||||
import com.homelab.ncal.data.repository.SaveConflictException
|
import com.homelab.ncal.data.repository.SaveConflictException
|
||||||
import com.homelab.ncal.util.ConflictField
|
import com.homelab.ncal.util.ConflictField
|
||||||
|
|
@ -27,6 +28,8 @@ data class ItemEditUiState(
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val done: Boolean = false,
|
val done: Boolean = false,
|
||||||
val deleted: Boolean = false,
|
val deleted: Boolean = false,
|
||||||
|
val 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 conflict: SaveConflictException? = null,
|
||||||
val conflictFields: List<ConflictField> = emptyList(),
|
val conflictFields: List<ConflictField> = emptyList(),
|
||||||
val conflictChoices: Map<String, Boolean> = emptyMap() // field key -> true means "take theirs"
|
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))
|
_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() {
|
fun save() {
|
||||||
val item = _state.value.item ?: return
|
val item = _state.value.item ?: return
|
||||||
if (item.summary.isBlank()) {
|
if (item.summary.isBlank()) {
|
||||||
|
|
@ -120,11 +129,54 @@ class ItemEditViewModel(
|
||||||
_state.value = _state.value.copy(error = "Pick a calendar first")
|
_state.value = _state.value.copy(error = "Pick a calendar first")
|
||||||
return
|
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)
|
_state.value = _state.value.copy(saving = true, error = null)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repo.save(item)
|
val saved = repo.save(item)
|
||||||
_state.value = _state.value.copy(saving = false, done = true)
|
_state.value = _state.value.copy(saving = false, done = true, savedOffline = saved.syncStatus == SyncStatus.PENDING_UPDATE)
|
||||||
} catch (e: SaveConflictException) {
|
} catch (e: SaveConflictException) {
|
||||||
val fields = e.serverVersion?.let { diffConflictFields(item, it) } ?: emptyList()
|
val fields = e.serverVersion?.let { diffConflictFields(item, it) } ?: emptyList()
|
||||||
android.util.Log.d(
|
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
|
/** 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
|
* deleted server-side): retry the write with the server's current etag, overwriting
|
||||||
* whatever changed there with this edit. */
|
* whatever changed there with this edit. */
|
||||||
fun keepMyChanges() {
|
fun keepMyChanges() {
|
||||||
val conflict = _state.value.conflict ?: return
|
val conflict = _state.value.conflict ?: return
|
||||||
|
val retryItem = conflict.localEdit.copy(etag = conflict.serverVersion?.etag)
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
conflict = null, conflictFields = emptyList(), conflictChoices = emptyMap(),
|
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
|
/** 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)
|
_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
|
val item = _state.value.item ?: return
|
||||||
if (item.isNew) return
|
if (item.isNew) return
|
||||||
_state.value = _state.value.copy(saving = true, error = null)
|
_state.value = _state.value.copy(saving = true, error = null)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
repo.delete(item)
|
val deletedOnServer = repo.delete(item)
|
||||||
_state.value = _state.value.copy(saving = false, deleted = true)
|
_state.value = _state.value.copy(saving = false, deleted = true, deletedOffline = !deletedOnServer)
|
||||||
} catch (e: Exception) {
|
} 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())
|
_state.value = _state.value.copy(saving = false, error = e.toUserMessage())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
package com.homelab.ncal.ui.nav
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
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.login.LoginViewModel
|
||||||
import com.homelab.ncal.ui.month.MonthScreen
|
import com.homelab.ncal.ui.month.MonthScreen
|
||||||
import com.homelab.ncal.ui.month.MonthViewModel
|
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.TasksScreen
|
||||||
import com.homelab.ncal.ui.tasks.TasksViewModel
|
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
|
import java.net.URLEncoder
|
||||||
|
|
||||||
private object Routes {
|
private object Routes {
|
||||||
|
|
@ -30,7 +48,9 @@ private object Routes {
|
||||||
const val AGENDA = "agenda"
|
const val AGENDA = "agenda"
|
||||||
const val TASKS = "tasks"
|
const val TASKS = "tasks"
|
||||||
const val MONTH = "month"
|
const val MONTH = "month"
|
||||||
|
const val WEEK = "week"
|
||||||
const val COLLECTIONS = "collections"
|
const val COLLECTIONS = "collections"
|
||||||
|
const val SEARCH = "search"
|
||||||
const val EDIT = "edit/{type}/{href}/{calendarUrl}"
|
const val EDIT = "edit/{type}/{href}/{calendarUrl}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,6 +76,25 @@ fun NcalNavGraph(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
NavHost(navController = navController, startDestination = startDestination) {
|
||||||
|
|
||||||
composable(Routes.LOGIN) {
|
composable(Routes.LOGIN) {
|
||||||
|
|
@ -80,7 +119,18 @@ fun NcalNavGraph(
|
||||||
onNewEvent = { navController.navigate(editRoute("EVENT", null)) },
|
onNewEvent = { navController.navigate(editRoute("EVENT", null)) },
|
||||||
onOpenTasks = { navController.navigate(Routes.TASKS) },
|
onOpenTasks = { navController.navigate(Routes.TASKS) },
|
||||||
onOpenCollections = { navController.navigate(Routes.COLLECTIONS) },
|
onOpenCollections = { navController.navigate(Routes.COLLECTIONS) },
|
||||||
onOpenMonth = { navController.navigate(Routes.MONTH) }
|
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() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,6 +144,16 @@ fun NcalNavGraph(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
composable(Routes.TASKS) {
|
||||||
val vm: TasksViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> TasksViewModel(repo) })
|
val vm: TasksViewModel = viewModel(factory = NcalViewModelFactory(app) { repo, _ -> TasksViewModel(repo) })
|
||||||
TasksScreen(
|
TasksScreen(
|
||||||
|
|
@ -121,4 +181,16 @@ fun NcalNavGraph(
|
||||||
ItemEditScreen(viewModel = vm, onDone = { navController.popBackStack() })
|
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.VEvent
|
||||||
import biweekly.component.VTodo
|
import biweekly.component.VTodo
|
||||||
import biweekly.parameter.Related
|
import biweekly.parameter.Related
|
||||||
|
import biweekly.property.ExceptionDates
|
||||||
|
import biweekly.property.RecurrenceId
|
||||||
import biweekly.property.RelatedTo
|
import biweekly.property.RelatedTo
|
||||||
import biweekly.property.Status
|
import biweekly.property.Status
|
||||||
import biweekly.property.Trigger
|
import biweekly.property.Trigger
|
||||||
import biweekly.util.Duration
|
import biweekly.util.Duration
|
||||||
import biweekly.util.Frequency
|
import biweekly.util.Frequency
|
||||||
|
import biweekly.util.ICalDate
|
||||||
import biweekly.util.Recurrence
|
import biweekly.util.Recurrence
|
||||||
import com.homelab.ncal.data.model.CalendarItem
|
import com.homelab.ncal.data.model.CalendarItem
|
||||||
import com.homelab.ncal.data.model.ItemType
|
import com.homelab.ncal.data.model.ItemType
|
||||||
|
|
@ -36,16 +39,19 @@ object IcsMapper {
|
||||||
TaskStatus.CANCELLED -> Status.cancelled()
|
TaskStatus.CANCELLED -> Status.cancelled()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** null if this rule uses anything the picker can't represent (BYDAY/BYMONTH/etc, or a
|
/** null if this rule uses anything the picker can't represent (BYMONTH/BYMONTHDAY/etc, an
|
||||||
* frequency other than the 4 simple ones) - not just an unsupported FREQ. A rule can have
|
* ordinal BYDAY like "3rd Monday", BYDAY on anything but WEEKLY, or a frequency other than
|
||||||
* FREQ=WEEKLY and still be BYDAY-based ("every Mon/Wed/Fri"), which the picker must never
|
* the 4 simple ones). A plain WEEKLY rule with a BYDAY of unordinaled days ("every Mon/Wed/
|
||||||
* silently flatten into a plain weekly rule on an unrelated edit. */
|
* 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? {
|
private fun Recurrence?.toRecurrenceFrequency(): RecurrenceFrequency? {
|
||||||
if (this == null) return null
|
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() &&
|
byYearDay.isEmpty() && byWeekNo.isEmpty() && bySetPos.isEmpty() &&
|
||||||
byHour.isEmpty() && byMinute.isEmpty() && bySecond.isEmpty()
|
byHour.isEmpty() && byMinute.isEmpty() && bySecond.isEmpty()
|
||||||
if (!isSimple) return null
|
if (!isSimple) return null
|
||||||
|
if (byDay.isNotEmpty() && (frequency != Frequency.WEEKLY || byDay.any { it.num != null })) return null
|
||||||
return when (frequency) {
|
return when (frequency) {
|
||||||
Frequency.DAILY -> RecurrenceFrequency.DAILY
|
Frequency.DAILY -> RecurrenceFrequency.DAILY
|
||||||
Frequency.WEEKLY -> RecurrenceFrequency.WEEKLY
|
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
|
/** 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
|
* 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
|
* 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))
|
val builder = Recurrence.Builder(bwFreq).interval(item.recurrenceInterval.coerceAtLeast(1))
|
||||||
item.recurrenceCount?.let { builder.count(it) }
|
item.recurrenceCount?.let { builder.count(it) }
|
||||||
item.recurrenceUntil?.let { builder.until(Date(it), true) }
|
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()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,30 +116,109 @@ object IcsMapper {
|
||||||
(-duration.toMillis() / 60_000L).toInt()
|
(-duration.toMillis() / 60_000L).toInt()
|
||||||
}.distinct().sorted()
|
}.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? {
|
fun parse(ics: String, href: String, etag: String?, calendarUrl: String): CalendarItem? {
|
||||||
val ical = Biweekly.parse(ics).first() ?: return null
|
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 originalStart = ev.dateStart?.value?.time
|
||||||
val originalEnd = ev.dateEnd?.value?.time
|
val originalEnd = ev.dateEnd?.value?.time
|
||||||
val durationMillis = if (originalStart != null && originalEnd != null) originalEnd - originalStart else null
|
val durationMillis = if (originalStart != null && originalEnd != null) originalEnd - originalStart else null
|
||||||
|
|
||||||
val recurrence = ev.recurrenceRule?.value
|
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 (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) {
|
if (next == null) {
|
||||||
android.util.Log.d("NCalParse", "EVENT '${ev.summary?.value}' recurrence ended (freq=${recurrence.frequency}, until=${recurrence.until}, count=${recurrence.count}) - dropping")
|
android.util.Log.d("NCalParse", "EVENT '${ev.summary?.value}' recurrence ended (freq=${recurrence.frequency}, until=${recurrence.until}, count=${recurrence.count}) - dropping")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
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)
|
next to (durationMillis?.let { next + it } ?: next)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
originalStart to originalEnd
|
originalStart to originalEnd
|
||||||
}
|
}
|
||||||
|
val display = override ?: ev
|
||||||
|
|
||||||
android.util.Log.d(
|
android.util.Log.d(
|
||||||
"NCalParse",
|
"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) }}"
|
"originalStart=${originalStart?.let { java.util.Date(it) }} resolvedStart=${resolvedStart?.let { java.util.Date(it) }}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -114,38 +228,50 @@ object IcsMapper {
|
||||||
etag = etag,
|
etag = etag,
|
||||||
calendarUrl = calendarUrl,
|
calendarUrl = calendarUrl,
|
||||||
type = ItemType.EVENT,
|
type = ItemType.EVENT,
|
||||||
summary = ev.summary?.value ?: "(no title)",
|
summary = display.summary?.value ?: "(no title)",
|
||||||
description = ev.description?.value ?: "",
|
description = display.description?.value ?: "",
|
||||||
location = ev.location?.value ?: "",
|
location = display.location?.value ?: "",
|
||||||
url = ev.url?.value ?: "",
|
url = display.url?.value ?: "",
|
||||||
start = resolvedStart,
|
start = resolvedStart,
|
||||||
end = resolvedEnd,
|
end = resolvedEnd,
|
||||||
allDay = ev.dateStart?.value?.let { !hasTimeComponent(ics, "DTSTART") } ?: false,
|
allDay = ev.dateStart?.value?.let { !it.hasTime() } ?: false,
|
||||||
priority = ev.priority?.value ?: 0,
|
priority = display.priority?.value ?: 0,
|
||||||
reminderMinutes = alarmMinutesBefore(ev.alarms),
|
reminderMinutes = alarmMinutesBefore(display.alarms),
|
||||||
recurrenceFrequency = recurrence.toRecurrenceFrequency(),
|
recurrenceFrequency = recurrence.toRecurrenceFrequency(),
|
||||||
recurrenceInterval = recurrence?.interval ?: 1,
|
recurrenceInterval = recurrence?.interval ?: 1,
|
||||||
recurrenceCount = recurrence?.count,
|
recurrenceCount = recurrence?.count,
|
||||||
recurrenceUntil = recurrence?.until?.time,
|
recurrenceUntil = recurrence?.until?.time,
|
||||||
|
recurrenceByDay = recurrence.toByDaySet(),
|
||||||
isRecurring = recurrence != null,
|
isRecurring = recurrence != null,
|
||||||
recurrenceMasterStart = originalStart,
|
recurrenceMasterStart = originalStart,
|
||||||
recurrenceMasterEnd = originalEnd,
|
recurrenceMasterEnd = originalEnd,
|
||||||
|
occurrenceDate = occurrenceDate,
|
||||||
rawIcs = ics
|
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 originalDue = td.dateDue?.value?.time
|
||||||
val originalStart = td.dateStart?.value?.time
|
val originalStart = td.dateStart?.value?.time
|
||||||
val startToDueMillis = if (originalStart != null && originalDue != null) originalDue - originalStart else null
|
val startToDueMillis = if (originalStart != null && originalDue != null) originalDue - originalStart else null
|
||||||
|
|
||||||
val recurrence = td.recurrenceRule?.value
|
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) {
|
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 {
|
} else {
|
||||||
originalDue
|
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 }
|
startToDueMillis?.let { resolvedDue - it }
|
||||||
} else {
|
} else {
|
||||||
originalStart
|
originalStart
|
||||||
|
|
@ -153,13 +279,13 @@ object IcsMapper {
|
||||||
|
|
||||||
android.util.Log.d(
|
android.util.Log.d(
|
||||||
"NCalParse",
|
"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) }} " +
|
"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}"
|
"originalStart=${originalStart?.let { java.util.Date(it) }} completed=${td.status?.isCompleted}"
|
||||||
)
|
)
|
||||||
|
|
||||||
val status = td.status.toTaskStatus()
|
val status = display.status.toTaskStatus()
|
||||||
val percentComplete = td.percentComplete?.value ?: (if (status == TaskStatus.COMPLETED) 100 else 0)
|
val percentComplete = display.percentComplete?.value ?: (if (status == TaskStatus.COMPLETED) 100 else 0)
|
||||||
|
|
||||||
return CalendarItem(
|
return CalendarItem(
|
||||||
uid = td.uid?.value ?: UUID.randomUUID().toString(),
|
uid = td.uid?.value ?: UUID.randomUUID().toString(),
|
||||||
|
|
@ -167,25 +293,27 @@ object IcsMapper {
|
||||||
etag = etag,
|
etag = etag,
|
||||||
calendarUrl = calendarUrl,
|
calendarUrl = calendarUrl,
|
||||||
type = ItemType.TASK,
|
type = ItemType.TASK,
|
||||||
summary = td.summary?.value ?: "(no title)",
|
summary = display.summary?.value ?: "(no title)",
|
||||||
description = td.description?.value ?: "",
|
description = display.description?.value ?: "",
|
||||||
location = td.location?.value ?: "",
|
location = display.location?.value ?: "",
|
||||||
url = td.url?.value ?: "",
|
url = display.url?.value ?: "",
|
||||||
start = resolvedStart,
|
start = resolvedStart,
|
||||||
due = resolvedDue,
|
due = resolvedDue,
|
||||||
completed = status == TaskStatus.COMPLETED || percentComplete >= 100,
|
completed = status == TaskStatus.COMPLETED || percentComplete >= 100,
|
||||||
status = status,
|
status = status,
|
||||||
percentComplete = percentComplete,
|
percentComplete = percentComplete,
|
||||||
priority = td.priority?.value ?: 0,
|
priority = display.priority?.value ?: 0,
|
||||||
parentUid = td.relatedTo.firstOrNull { it.relationshipType == null || it.relationshipType.value.equals("PARENT", ignoreCase = true) }?.value,
|
parentUid = display.relatedTo.firstOrNull { it.relationshipType == null || it.relationshipType.value.equals("PARENT", ignoreCase = true) }?.value,
|
||||||
reminderMinutes = alarmMinutesBefore(td.alarms),
|
reminderMinutes = alarmMinutesBefore(display.alarms),
|
||||||
recurrenceFrequency = recurrence.toRecurrenceFrequency(),
|
recurrenceFrequency = recurrence.toRecurrenceFrequency(),
|
||||||
recurrenceInterval = recurrence?.interval ?: 1,
|
recurrenceInterval = recurrence?.interval ?: 1,
|
||||||
recurrenceCount = recurrence?.count,
|
recurrenceCount = recurrence?.count,
|
||||||
recurrenceUntil = recurrence?.until?.time,
|
recurrenceUntil = recurrence?.until?.time,
|
||||||
|
recurrenceByDay = recurrence.toByDaySet(),
|
||||||
isRecurring = recurrence != null,
|
isRecurring = recurrence != null,
|
||||||
recurrenceMasterStart = originalStart,
|
recurrenceMasterStart = originalStart,
|
||||||
recurrenceMasterDue = originalDue,
|
recurrenceMasterDue = originalDue,
|
||||||
|
occurrenceDate = occurrenceDate,
|
||||||
rawIcs = ics
|
rawIcs = ics
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +350,9 @@ object IcsMapper {
|
||||||
val ical = item.rawIcs?.takeIf { it.isNotBlank() }?.let { Biweekly.parse(it).first() } ?: ICalendar()
|
val ical = item.rawIcs?.takeIf { it.isNotBlank() }?.let { Biweekly.parse(it).first() } ?: ICalendar()
|
||||||
when (item.type) {
|
when (item.type) {
|
||||||
ItemType.EVENT -> {
|
ItemType.EVENT -> {
|
||||||
val ev = 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 originalRecurrence = ev.recurrenceRule?.value
|
||||||
val wasRecurring = originalRecurrence != null
|
val wasRecurring = originalRecurrence != null
|
||||||
|
|
||||||
|
|
@ -249,7 +379,7 @@ object IcsMapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ItemType.TASK -> {
|
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 originalRecurrence = td.recurrenceRule?.value
|
||||||
val wasRecurring = originalRecurrence != null
|
val wasRecurring = originalRecurrence != null
|
||||||
|
|
||||||
|
|
@ -285,9 +415,97 @@ object IcsMapper {
|
||||||
return Biweekly.write(ical).go()
|
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 {
|
* Builds the full .ics document for "save changes to this event only" on a recurring [item] -
|
||||||
val line = ics.lines().firstOrNull { it.startsWith(property) } ?: return true
|
* adds or replaces a RECURRENCE-ID override VEVENT/VTODO for [CalendarItem.occurrenceDate]
|
||||||
return !line.contains("VALUE=DATE")
|
* 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.Frequency
|
||||||
import biweekly.util.Recurrence
|
import biweekly.util.Recurrence
|
||||||
|
import java.time.DayOfWeek
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZonedDateTime
|
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
|
* 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.
|
* 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,
|
* 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
|
* UNTIL, (WEEKLY only) an unordinaled BYDAY ("every Mon/Wed/Fri" - see
|
||||||
* a small minority of everyday personal-calendar recurrences (e.g. "every 2nd Tuesday").
|
* [com.homelab.ncal.data.model.CalendarItem.recurrenceByDay]), and EXDATE (single-occurrence
|
||||||
* Anything using those rule parts still round-trips correctly on save/edit (the raw ICS
|
* deletions - see [com.homelab.ncal.data.model.CalendarItem.occurrenceDate]). It intentionally
|
||||||
* is preserved via [com.homelab.ncal.data.model.CalendarItem.rawIcs]); it just won't be
|
* does not implement BYMONTH/BYMONTHDAY/BYSETPOS/ordinaled BYDAY/etc - those cover a small
|
||||||
* *re-dated* for display, and will show at its original occurrence like before.
|
* 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 {
|
object RecurrenceUtils {
|
||||||
|
|
||||||
private const val MAX_ITERATIONS = 10_000
|
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],
|
* 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.
|
* 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 zone = ZoneId.systemDefault()
|
||||||
val freq = recurrence.frequency ?: return null
|
val freq = recurrence.frequency ?: return null
|
||||||
if (freq !in SUPPORTED_FREQUENCIES) return originalStartMillis // fall back to showing the master as-is
|
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
|
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)
|
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 occurrenceIndex = 1
|
||||||
var iterations = 0
|
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
|
if (iterations++ > MAX_ITERATIONS) return null
|
||||||
|
|
||||||
current = when (freq) {
|
current = when (freq) {
|
||||||
|
|
@ -63,5 +90,58 @@ object RecurrenceUtils {
|
||||||
return current.toInstant().toEpochMilli()
|
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)
|
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
|
@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/")!!
|
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)
|
assertTrue("but the item is still recognized as recurring", item.isRecurring)
|
||||||
|
|
||||||
val rebuiltIcs = IcsMapper.toIcs(item.copy(summary = "renamed"))
|
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
|
@Test
|
||||||
|
|
@ -244,4 +284,205 @@ class IcsMapperTest {
|
||||||
assertTrue("RRULE must survive the edit", rebuiltIcs.contains("RRULE") && rebuiltIcs.contains("FREQ=WEEKLY"))
|
assertTrue("RRULE must survive the edit", rebuiltIcs.contains("RRULE") && rebuiltIcs.contains("FREQ=WEEKLY"))
|
||||||
assertTrue("PRIORITY must reflect the edit", rebuiltIcs.contains("PRIORITY:3"))
|
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