diff --git a/app/src/main/java/at/techbee/jtx/database/ICalDatabaseDao.kt b/app/src/main/java/at/techbee/jtx/database/ICalDatabaseDao.kt index b7ace4821..993e89e8d 100644 --- a/app/src/main/java/at/techbee/jtx/database/ICalDatabaseDao.kt +++ b/app/src/main/java/at/techbee/jtx/database/ICalDatabaseDao.kt @@ -1229,6 +1229,16 @@ interface ICalDatabaseDao { suspend fun makeSeriesDirty(uid: String, lastModified: Long = System.currentTimeMillis()) + @Query("SELECT * FROM $TABLE_NAME_ICALOBJECT WHERE $COLUMN_RRULE IS NOT NULL AND $COLUMN_RECURID IS NULL AND $COLUMN_DELETED = 0") + fun getAllSeriesSync(): List + + /** + * Rebuilds the instances of every series, needed when the recurrence window changes. Instances + * are inserted with dirty = false and changed ones are kept, so nothing is uploaded. + */ + @Transaction + fun recreateAllRecurring() = getAllSeriesSync().forEach { recreateRecurring(it) } + @Transaction fun recreateRecurring(iCalObject: ICalObject) { diff --git a/app/src/main/java/at/techbee/jtx/database/ICalObject.kt b/app/src/main/java/at/techbee/jtx/database/ICalObject.kt index 6370d5817..c16287bb4 100644 --- a/app/src/main/java/at/techbee/jtx/database/ICalObject.kt +++ b/app/src/main/java/at/techbee/jtx/database/ICalObject.kt @@ -996,6 +996,14 @@ data class ICalObject( private fun buildPeriod( start: ZonedDateTime, frequency: Frequency + ): Period = when (val window = RecurrenceWindow.current) { + is RecurrenceWindow.Legacy -> legacyPeriod(start, frequency) + is RecurrenceWindow.AroundToday -> periodAroundToday(start, frequency, window) + } + + private fun legacyPeriod( + start: ZonedDateTime, + frequency: Frequency ): Period { val from = when (frequency) { @@ -1021,6 +1029,29 @@ data class ICalObject( return Period(from, to) } + private fun periodAroundToday( + start: ZonedDateTime, + frequency: Frequency, + window: RecurrenceWindow.AroundToday + ): Period { + + // A series starting in the future would materialise nothing, and the list hides a series + // that has an RRULE and a DTSTART in favour of its instances - so it would vanish. + val now = ZonedDateTime.now(start.zone) + val reference = if (start.isAfter(now)) start else now + + return when (frequency) { + // Three months of a SECONDLY rule is millions of rows wherever the window sits. + Frequency.SECONDLY -> Period(reference.minusHours(1), reference.plusHours(1)) + Frequency.MINUTELY -> Period(reference.minusDays(1), reference.plusDays(1)) + Frequency.HOURLY -> Period(reference.minusDays(30), reference.plusDays(30)) + else -> Period( + reference.minusMonths(window.monthsBack), + reference.plusMonths(window.monthsAhead) + ) + } + } + private fun normalizeRruleForAllDay() { if (rrule == null) return diff --git a/app/src/main/java/at/techbee/jtx/database/RecurrenceWindow.kt b/app/src/main/java/at/techbee/jtx/database/RecurrenceWindow.kt new file mode 100644 index 000000000..b545dd2a6 --- /dev/null +++ b/app/src/main/java/at/techbee/jtx/database/RecurrenceWindow.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) Techbee e.U. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package at.techbee.jtx.database + +/** + * How far around its reference point recurrence instances are materialised. + * + * [Legacy] centres the window on DTSTART, and since nothing is generated before DTSTART that + * reaches forward from the start of the series: for a weekly series older than a year the window + * ends before today and no current occurrence is created at all. + */ +sealed interface RecurrenceWindow { + + data object Legacy : RecurrenceWindow + + data class AroundToday(val monthsBack: Long, val monthsAhead: Long) : RecurrenceWindow + + companion object { + // Read from getInstancesFromRrule(), which is reached from Room DAO methods and from the + // sync content provider - neither carries a Context, so passing the value in would thread a + // parameter up into the ViewModels. + @Volatile + var current: RecurrenceWindow = Legacy + } +} diff --git a/app/src/main/java/at/techbee/jtx/ui/settings/DropdownSetting.kt b/app/src/main/java/at/techbee/jtx/ui/settings/DropdownSetting.kt index e35b74dd9..ca73b6b86 100644 --- a/app/src/main/java/at/techbee/jtx/ui/settings/DropdownSetting.kt +++ b/app/src/main/java/at/techbee/jtx/ui/settings/DropdownSetting.kt @@ -13,6 +13,7 @@ import android.os.Build import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Alarm import androidx.compose.material.icons.outlined.EditCalendar +import androidx.compose.material.icons.outlined.EventRepeat import androidx.compose.material.icons.outlined.Fingerprint import androidx.compose.material.icons.outlined.FontDownload import androidx.compose.material.icons.outlined.FormatPaint @@ -34,6 +35,19 @@ enum class DropdownSetting( val options: List, val default: DropdownSettingOption ) { + SETTING_RECUR_WINDOW( + key = "setting_recur_window", + icon = Icons.Outlined.EventRepeat, + title = R.string.settings_recur_window, + subtitle = R.string.settings_recur_window_sub, + options = listOf( + DropdownSettingOption.RECUR_WINDOW_LEGACY, + DropdownSettingOption.RECUR_WINDOW_1_MONTH, + DropdownSettingOption.RECUR_WINDOW_3_MONTHS, + DropdownSettingOption.RECUR_WINDOW_1_YEAR + ), + default = DropdownSettingOption.RECUR_WINDOW_LEGACY + ), SETTING_THEME( key = "settings_theme", icon = Icons.Outlined.FormatPaint, diff --git a/app/src/main/java/at/techbee/jtx/ui/settings/DropdownSettingOption.kt b/app/src/main/java/at/techbee/jtx/ui/settings/DropdownSettingOption.kt index 335dcb14d..3a1ed0188 100644 --- a/app/src/main/java/at/techbee/jtx/ui/settings/DropdownSettingOption.kt +++ b/app/src/main/java/at/techbee/jtx/ui/settings/DropdownSettingOption.kt @@ -17,6 +17,10 @@ enum class DropdownSettingOption( val key: String, @StringRes val text: Int ) { + RECUR_WINDOW_LEGACY("recur_window_legacy", R.string.settings_recur_window_legacy), + RECUR_WINDOW_1_MONTH("recur_window_1_month", R.string.settings_recur_window_1_month), + RECUR_WINDOW_3_MONTHS("recur_window_3_months", R.string.settings_recur_window_3_months), + RECUR_WINDOW_1_YEAR("recur_window_1_year", R.string.settings_recur_window_1_year), THEME_SYSTEM("system", R.string.settings_select_theme_system), THEME_LIGHT("light", R.string.settings_select_theme_light), THEME_DARK("dark", R.string.settings_select_theme_dark), diff --git a/app/src/main/java/at/techbee/jtx/ui/settings/SettingsScreen.kt b/app/src/main/java/at/techbee/jtx/ui/settings/SettingsScreen.kt index 22f6152e6..0894b0706 100644 --- a/app/src/main/java/at/techbee/jtx/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/at/techbee/jtx/ui/settings/SettingsScreen.kt @@ -75,6 +75,8 @@ import at.techbee.jtx.ui.settings.DropdownSetting.SETTING_FONT import at.techbee.jtx.ui.settings.DropdownSetting.SETTING_MAPS_PROVIDER import at.techbee.jtx.ui.settings.DropdownSetting.SETTING_PROGRESS_STEP import at.techbee.jtx.ui.settings.DropdownSetting.SETTING_PROTECT_BIOMETRIC +import at.techbee.jtx.database.ICalDatabase +import at.techbee.jtx.ui.settings.DropdownSetting.SETTING_RECUR_WINDOW import at.techbee.jtx.ui.settings.DropdownSetting.SETTING_THEME import at.techbee.jtx.ui.settings.SwitchSetting.SETTING_ACCESSIBILITY_MODE import at.techbee.jtx.ui.settings.SwitchSetting.SETTING_AUTO_EXPAND_ATTACHMENTS @@ -703,6 +705,19 @@ fun SettingsScreen( onToggle = { expandOrCollapse(SettingsScreenSection.ITEM_LIST) }, modifier = Modifier.fillMaxWidth() ) { + DropdownSettingElement( + setting = SETTING_RECUR_WINDOW, + selected = settingsStateHolder.settingRecurWindow.value, + onSelectionChanged = { selection -> + settingsStateHolder.settingRecurWindow.value = selection + SETTING_RECUR_WINDOW.saveSetting(selection, settingsStateHolder.prefs) + settingsStateHolder.applyRecurrenceWindow() + // The stored instances were materialised for the previous window. + scope.launch(Dispatchers.IO) { + ICalDatabase.getInstance(context).iCalDatabaseDao().recreateAllRecurring() + } + } + ) SwitchSettingElement( setting = SETTING_AUTO_EXPAND_SUBTASKS, checked = settingsStateHolder.settingAutoExpandSubtasks, diff --git a/app/src/main/java/at/techbee/jtx/ui/settings/SettingsStateHolder.kt b/app/src/main/java/at/techbee/jtx/ui/settings/SettingsStateHolder.kt index 79b021936..cbcc7dbb1 100644 --- a/app/src/main/java/at/techbee/jtx/ui/settings/SettingsStateHolder.kt +++ b/app/src/main/java/at/techbee/jtx/ui/settings/SettingsStateHolder.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.core.content.edit import androidx.preference.PreferenceManager import at.techbee.jtx.database.Module +import at.techbee.jtx.database.RecurrenceWindow import at.techbee.jtx.ui.detail.DetailTopAppBarMode class SettingsStateHolder(val context: Context) { @@ -57,6 +58,7 @@ class SettingsStateHolder(val context: Context) { var settingKeepStatusProgressCompletedInSync = mutableStateOf(SwitchSetting.SETTING_KEEP_STATUS_PROGRESS_COMPLETED_IN_SYNC.getSetting(prefs)) var settingProtectBiometric = mutableStateOf(DropdownSetting.SETTING_PROTECT_BIOMETRIC.getSetting(prefs)) var settingDisplayTimezone = mutableStateOf(DropdownSetting.SETTING_DISPLAY_TIMEZONE.getSetting(prefs)) + var settingRecurWindow = mutableStateOf(DropdownSetting.SETTING_RECUR_WINDOW.getSetting(prefs)) var settingSetDefaultCurrentLocationJournals = mutableStateOf(SwitchSetting.SETTING_JOURNALS_SET_DEFAULT_CURRENT_LOCATION.getSetting(prefs)) var settingSetDefaultCurrentLocationNotes = mutableStateOf(SwitchSetting.SETTING_NOTES_SET_DEFAULT_CURRENT_LOCATION.getSetting(prefs)) @@ -110,6 +112,21 @@ class SettingsStateHolder(val context: Context) { field = newValue } */ + + init { + applyRecurrenceWindow() + } + + fun applyRecurrenceWindow() { + // A month of history keeps a recently missed occurrence visible. + RecurrenceWindow.current = when (settingRecurWindow.value) { + DropdownSettingOption.RECUR_WINDOW_1_MONTH -> RecurrenceWindow.AroundToday(monthsBack = 1, monthsAhead = 1) + DropdownSettingOption.RECUR_WINDOW_3_MONTHS -> RecurrenceWindow.AroundToday(monthsBack = 1, monthsAhead = 3) + DropdownSettingOption.RECUR_WINDOW_1_YEAR -> RecurrenceWindow.AroundToday(monthsBack = 1, monthsAhead = 12) + else -> RecurrenceWindow.Legacy + } + } + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ecd57a6c6..f87b23b3a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -387,6 +387,12 @@ "An error occurred while exporting settings" "More" "Buy Pro" + "Occurrences of recurring entries" + "Which single occurrences of a series are created. From the start of the series reaches one year ahead for daily and weekly rules, ten years for monthly and a hundred for yearly - so a weekly series that began more than a year ago only gets occurrences from its first year, and none around today." + "From the start of the series" + "Around today (1 month)" + "Around today (3 months)" + "Around today (1 year)" "Select theme" "Dark" "Light"