Fix follow-ups from the review of the 2.23.0 fixes
Keep import and duplicate state across rotation without replaying the launch intent, route shared .ics attachments to import, cap agenda scroll-back growth, keep colours on moved exceptions, cache dynamic colour for cold start, keep write prompts across rotation and match RRULE parts case-insensitively. Refs #306, #178, #387, #367
This commit is contained in:
@@ -6,6 +6,7 @@ import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -61,6 +62,8 @@ import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
|
||||
import de.jeanlucmakiola.calendula.ui.theme.windowBackgroundRes
|
||||
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
@@ -124,10 +127,17 @@ class MainActivity : AppCompatActivity() {
|
||||
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
|
||||
systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark)
|
||||
applyEdgeToEdge()
|
||||
val appearance = MutableStateFlow(
|
||||
Appearance(
|
||||
themeMode = cachedTheme ?: ThemeMode.SYSTEM,
|
||||
dynamicColor = WindowThemeCache.readDynamicColor(this),
|
||||
),
|
||||
)
|
||||
lifecycleScope.launch {
|
||||
settingsPrefs.themeMode.collect { mode ->
|
||||
WindowThemeCache.write(this@MainActivity, mode)
|
||||
window.setBackgroundDrawableResource(mode.windowBackgroundRes() ?: R.color.window_background)
|
||||
combine(settingsPrefs.themeMode, settingsPrefs.dynamicColor, ::Appearance).collect { stored ->
|
||||
WindowThemeCache.write(this@MainActivity, stored.themeMode, stored.dynamicColor ?: true)
|
||||
window.setBackgroundDrawableResource(stored.themeMode.windowBackgroundRes() ?: R.color.window_background)
|
||||
appearance.value = stored
|
||||
}
|
||||
}
|
||||
// Android hands the intent that started the task back to onCreate every
|
||||
@@ -143,6 +153,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// is what carries one through a recreation — the create form only while
|
||||
// it's still open.
|
||||
val replaying = savedInstanceState != null || intent.isRelaunch()
|
||||
requests.replayed = savedInstanceState != null
|
||||
if (!replaying) {
|
||||
requests.detailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requests.editKey = intent.editEventKeyOrNull()
|
||||
@@ -161,10 +172,10 @@ class MainActivity : AppCompatActivity() {
|
||||
// and the Settings screen, so a theme change applies app-wide at once.
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
val settings by settingsViewModel.state.collectAsStateWithLifecycle()
|
||||
// Read on its own, seeded from the cache, so the first frame doesn't
|
||||
// draw the bars for SYSTEM before the stored choice arrives.
|
||||
val themeMode by settingsPrefs.themeMode
|
||||
.collectAsStateWithLifecycle(initialValue = cachedTheme ?: ThemeMode.SYSTEM)
|
||||
// Seeded from the cache, so the first frame doesn't draw the default
|
||||
// theme and palette before the stored choices arrive.
|
||||
val shownAppearance by appearance.collectAsStateWithLifecycle()
|
||||
val themeMode = shownAppearance.themeMode
|
||||
val darkTheme = themeMode.isDark(isSystemInDarkTheme())
|
||||
// onCreate can only see the night resource qualifier, not the in-app
|
||||
// override — re-apply from the resolved theme so the bar icons follow
|
||||
@@ -203,7 +214,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
CalendulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
dynamicColor = shownAppearance.dynamicColor ?: settings.dynamicColor,
|
||||
pureBlack = themeMode.isPureBlack,
|
||||
typography = typography,
|
||||
) {
|
||||
@@ -277,6 +288,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
requests.replayed = false
|
||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it }
|
||||
intent.navRequestOrNull()?.let { requests.nav = it }
|
||||
intent.importUriOrNull()?.let { requests.importUri = it }
|
||||
@@ -312,10 +324,10 @@ class MainActivity : AppCompatActivity() {
|
||||
private fun Intent.importUriOrNull(): Uri? {
|
||||
val uri = when (action) {
|
||||
Intent.ACTION_VIEW -> data
|
||||
// Shared text is a create (#306); a text/plain file without any is
|
||||
// still imported, e.g. an .ics attachment labelled text/plain.
|
||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
.takeUnless { type == SHARED_TEXT_TYPE && sharedTextRequestOrNull() != null }
|
||||
// Shared text is a create (#306); a text/plain file is imported only
|
||||
// when it is calendar data, e.g. an .ics attachment labelled text/plain.
|
||||
Intent.ACTION_SEND -> sharedStreamOrNull()
|
||||
?.takeUnless { type == SHARED_TEXT_TYPE && !it.isCalendarFile() }
|
||||
else -> null
|
||||
} ?: return null
|
||||
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
||||
@@ -369,7 +381,7 @@ class MainActivity : AppCompatActivity() {
|
||||
*/
|
||||
private fun Intent.sharedTextRequestOrNull(): InsertRequest? {
|
||||
val shared = when (action) {
|
||||
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE) {
|
||||
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE && sharedStreamOrNull()?.isCalendarFile() != true) {
|
||||
sharedTextOrNull(
|
||||
subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(),
|
||||
text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(),
|
||||
@@ -390,6 +402,23 @@ class MainActivity : AppCompatActivity() {
|
||||
return InsertRequest(form, ImportSource.InsertOpenEnded)
|
||||
}
|
||||
|
||||
private fun Intent.sharedStreamOrNull(): Uri? =
|
||||
IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
|
||||
/** Whether a shared file is iCalendar/vCalendar data, by its resolved type or its name. */
|
||||
private fun Uri.isCalendarFile(): Boolean {
|
||||
val resolvedType = runCatching { contentResolver.getType(this) }.getOrNull()
|
||||
if (resolvedType in CALENDAR_TYPES) return true
|
||||
val name = when (scheme) {
|
||||
"content" -> runCatching {
|
||||
contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||
}.getOrNull()
|
||||
else -> lastPathSegment
|
||||
} ?: return false
|
||||
return CALENDAR_EXTENSIONS.any { name.endsWith(it, ignoreCase = true) }
|
||||
}
|
||||
|
||||
/** A Long extra's value, or null when the extra is absent. */
|
||||
private fun Intent.longExtraOrNull(key: String): Long? =
|
||||
if (hasExtra(key)) getLongExtra(key, 0L) else null
|
||||
@@ -516,6 +545,8 @@ class MainActivity : AppCompatActivity() {
|
||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
||||
private const val SHARED_TEXT_TYPE = "text/plain"
|
||||
private val CALENDAR_TYPES = setOf("text/calendar", "text/x-vcalendar", "application/ics")
|
||||
private val CALENDAR_EXTENSIONS = listOf(".ics", ".vcs")
|
||||
private const val KEY_INSERT_FORM_OPEN = "insert_form_open"
|
||||
private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert"
|
||||
|
||||
@@ -608,3 +639,6 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The theme choices the window needs before the settings screen's state loads. */
|
||||
private data class Appearance(val themeMode: ThemeMode, val dynamicColor: Boolean?)
|
||||
|
||||
@@ -1116,7 +1116,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
?: throw WriteFailedException("read modified occurrence id=${ex.exceptionEventId}")
|
||||
val endMillis = ex.dtEndMillis ?: (ex.dtStartMillis + parseRfc2445DurationMillis(ex.duration))
|
||||
val form = detail.toEditForm(ex.dtStartMillis, endMillis, zone)
|
||||
.copy(calendarId = targetCalendarId, colorKey = null, color = null)
|
||||
.copy(calendarId = targetCalendarId, colorKey = null)
|
||||
.toDetachedOccurrence()
|
||||
detached += insertEvent(form, allDayReminderTimeMinutes)
|
||||
}
|
||||
@@ -1691,7 +1691,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
null,
|
||||
)?.use { c -> c.mapRows { toReminder() } } ?: emptyList()
|
||||
|
||||
/** Iterate every row and map; skips nothing. */
|
||||
/** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
|
||||
private fun mintUid(): String = "${UUID.randomUUID()}@calendula"
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.data.di
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
/** The process-wide `CoroutineScope` that app-lifetime singletons share their flows in. */
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class ApplicationScope
|
||||
@@ -24,6 +24,9 @@ import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
|
||||
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
@@ -85,4 +88,9 @@ object DataProvideModule {
|
||||
@Singleton
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.calendulaDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@ApplicationScope
|
||||
fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ package de.jeanlucmakiola.calendula.data.time
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.floret.android.broadcastTicks
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -27,10 +30,12 @@ interface TodaySource {
|
||||
/**
|
||||
* Re-reads the date on every minute boundary while collected, and at once on the
|
||||
* system's date, time and zone broadcasts, which can arrive late or not at all.
|
||||
* One receiver and tick loop is shared by every collector.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AndroidTodaySource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
) : TodaySource {
|
||||
|
||||
override val today: Flow<LocalDate> = context
|
||||
@@ -42,6 +47,7 @@ class AndroidTodaySource @Inject constructor(
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
|
||||
}
|
||||
|
||||
private const val MINUTE_MILLIS = 60_000L
|
||||
|
||||
@@ -3,10 +3,9 @@ package de.jeanlucmakiola.calendula.data.time
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.floret.android.broadcastTicks
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -31,16 +30,13 @@ interface ZoneSource {
|
||||
/** Follows the system's zone broadcast, one receiver shared by every collector. */
|
||||
class AndroidZoneSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
) : ZoneSource {
|
||||
|
||||
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
.map { current() }
|
||||
.distinctUntilChanged()
|
||||
.shareIn(
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
SharingStarted.WhileSubscribed(replayExpirationMillis = 0),
|
||||
replay = 1,
|
||||
)
|
||||
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
|
||||
|
||||
override fun current(): TimeZone = TimeZone.currentSystemDefault()
|
||||
}
|
||||
|
||||
@@ -66,7 +66,9 @@ data class SanitizedRrule(val rule: String?, val repaired: Boolean)
|
||||
* rule at all — gives up entirely.
|
||||
*/
|
||||
fun sanitizeRrule(raw: String?): SanitizedRrule {
|
||||
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
|
||||
val rule = raw?.trim()
|
||||
?.let { if (it.startsWith("RRULE:", ignoreCase = true)) it.drop("RRULE:".length) else it }
|
||||
?.trim().orEmpty()
|
||||
if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
|
||||
|
||||
var freqSeen = false
|
||||
@@ -85,7 +87,7 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
|
||||
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
|
||||
// being dropped, so it falls through and marks the rule repaired.
|
||||
for (part in rruleParts(rule)) {
|
||||
val key = part.key?.trim().orEmpty()
|
||||
val key = part.key?.trim()?.uppercase().orEmpty()
|
||||
val value = part.value.trim()
|
||||
val kept = when {
|
||||
key.isEmpty() || value.isEmpty() -> null
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.SeekableTransitionState
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -70,6 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection
|
||||
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
@@ -246,20 +245,27 @@ fun CalendarHost(
|
||||
// An opened/received .ics file. [ImportScreen] parses it and either opens
|
||||
// the prefilled create form (one event → [importForm]) or its own bulk
|
||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||
var importUri by rememberSaveable { mutableStateOf<android.net.Uri?>(null) }
|
||||
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
|
||||
// Saved as "open"; a recreation takes the form back from the ViewModel, which
|
||||
// still holds the user's edits.
|
||||
var importForm by rememberSaveable(
|
||||
stateSaver = Saver<EventForm?, Boolean>(
|
||||
save = { it != null },
|
||||
restore = { open -> if (open) importViewModel.currentForm() else null },
|
||||
),
|
||||
) { mutableStateOf<EventForm?>(null) }
|
||||
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
||||
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
||||
var importFormSource by remember { mutableStateOf(ImportSource.File) }
|
||||
var importFormSource by rememberSaveable { mutableStateOf(ImportSource.File) }
|
||||
// Bumped per open, so even an equal prefill starts a fresh form.
|
||||
var importFormId by remember { mutableIntStateOf(0) }
|
||||
var importFormId by rememberSaveable { mutableIntStateOf(0) }
|
||||
// Whether [importForm] came from an external create request (#30, #306);
|
||||
// closing it then hands control back to whoever asked.
|
||||
var importFromRequest by rememberSaveable { mutableStateOf(false) }
|
||||
// A second external request that arrived while a prefilled form was open:
|
||||
// it waits behind that form's unsaved-changes prompt, then opens.
|
||||
var pendingInsert by remember { mutableStateOf<Pair<EventForm, ImportSource>?>(null) }
|
||||
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
|
||||
val createViewModel: EventEditViewModel = hiltViewModel(key = "create")
|
||||
val editViewModel: EventEditViewModel = hiltViewModel(key = "edit")
|
||||
fun openImportForm(form: EventForm, source: ImportSource, fromRequest: Boolean = false) {
|
||||
@@ -285,7 +291,7 @@ fun CalendarHost(
|
||||
// flow — picker + summary — even for a single-event file, because the intent
|
||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||
// keeps routing a single event straight into the prefilled create form.
|
||||
var importForceMany by remember { mutableStateOf(false) }
|
||||
var importForceMany by rememberSaveable { mutableStateOf(false) }
|
||||
// Where a restore came from, so closing the import puts it back: the import
|
||||
// overlays are declared under Backup & restore and the manager, so starting
|
||||
// one has to close them — without this, finishing a restore drops you on the
|
||||
@@ -296,17 +302,20 @@ fun CalendarHost(
|
||||
// what tells it a *re-import of the same file* is new work and not the run
|
||||
// it already finished; a rotation keeps the number and keeps the result.
|
||||
var importSession by rememberSaveable { mutableStateOf(0) }
|
||||
// [importForm] doesn't survive a recreation; unless an intent replay is about
|
||||
// to reopen it, drop the form the ViewModel still holds so the next open
|
||||
// isn't swallowed by it.
|
||||
LaunchedEffect(Unit) {
|
||||
if (importForm == null && requests.insert == null && requests.importUri == null) {
|
||||
importViewModel.reset()
|
||||
}
|
||||
// A recreation re-reads the launch intent; when the import it carries came
|
||||
// back with the saved state, that replay is the same work, not a new one.
|
||||
fun isReplayOfRestoredImport(): Boolean {
|
||||
val replay = requests.replayed && (importUri != null || importForm != null)
|
||||
requests.replayed = false
|
||||
return replay
|
||||
}
|
||||
val requestedImportUri = requests.importUri
|
||||
LaunchedEffect(requestedImportUri) {
|
||||
if (requestedImportUri != null) {
|
||||
if (isReplayOfRestoredImport()) {
|
||||
requests.importUri = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
importUri = requestedImportUri
|
||||
importForceMany = false
|
||||
importSession++
|
||||
@@ -320,6 +329,10 @@ fun CalendarHost(
|
||||
val requestedInsert = requests.insert
|
||||
LaunchedEffect(requestedInsert) {
|
||||
if (requestedInsert != null) {
|
||||
if (isReplayOfRestoredImport()) {
|
||||
requests.insert = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (importForm == null) {
|
||||
openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
|
||||
} else {
|
||||
@@ -331,14 +344,16 @@ fun CalendarHost(
|
||||
|
||||
// Close every overlay that can sit over the calendar, so an externally
|
||||
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||
// top instead of underneath whatever the user had open.
|
||||
fun dismissCoveringOverlays() {
|
||||
// top instead of underneath whatever the user had open. The create form,
|
||||
// declared under the edit form, can stay for an edit to open over it.
|
||||
fun dismissCoveringOverlays(keepCreate: Boolean = false) {
|
||||
showSettings = false
|
||||
showSearch = false
|
||||
showCalendars = false
|
||||
showBackup = false
|
||||
detailKey = null
|
||||
editKey = null
|
||||
createRequest = null
|
||||
if (!keepCreate) createRequest = null
|
||||
importUri = null
|
||||
importForm = null
|
||||
if (importFromRequest) {
|
||||
@@ -347,8 +362,6 @@ fun CalendarHost(
|
||||
}
|
||||
pendingInsert = null
|
||||
importViewModel.reset()
|
||||
createViewModel.reset()
|
||||
editViewModel.reset()
|
||||
}
|
||||
|
||||
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
||||
@@ -359,7 +372,10 @@ fun CalendarHost(
|
||||
val requestedEditKey = requests.editKey
|
||||
LaunchedEffect(requestedEditKey) {
|
||||
if (requestedEditKey != null) {
|
||||
dismissCoveringOverlays()
|
||||
// An open edit form stays on screen, so it starts over here rather
|
||||
// than once it has slid away.
|
||||
if (editKey != null) editViewModel.reset()
|
||||
dismissCoveringOverlays(keepCreate = true)
|
||||
editKey = requestedEditKey
|
||||
requests.editKey = null
|
||||
}
|
||||
@@ -409,6 +425,7 @@ fun CalendarHost(
|
||||
// widget) must land on top of whatever is open — the form overlay
|
||||
// sits below Settings/calendars in the Box, so without this it
|
||||
// would open hidden underneath them.
|
||||
if (createRequest != null) createViewModel.reset()
|
||||
dismissCoveringOverlays()
|
||||
openCreate(req.dateIso ?: currentDate().toString())
|
||||
requests.nav = null
|
||||
@@ -434,17 +451,16 @@ fun CalendarHost(
|
||||
var tapWriteRefused by rememberSaveable { mutableStateOf(false) }
|
||||
// The tap waiting on the WRITE_CALENDAR prompt, saved so a rotation keeps it.
|
||||
var tapAwaitingWrite by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
val tapWriteLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
tapAwaitingWrite?.let { key ->
|
||||
if (granted) {
|
||||
editKey = key
|
||||
} else {
|
||||
val editAwaitingTap = rememberWriteAction(
|
||||
onDenied = {
|
||||
tapAwaitingWrite?.let { key ->
|
||||
tapWriteRefused = true
|
||||
detailKey = key
|
||||
}
|
||||
}
|
||||
tapAwaitingWrite = null
|
||||
},
|
||||
) {
|
||||
tapAwaitingWrite?.let { editKey = it }
|
||||
tapAwaitingWrite = null
|
||||
}
|
||||
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||
@@ -456,7 +472,7 @@ fun CalendarHost(
|
||||
tapWriteRefused -> onOpenDetail(event)
|
||||
else -> {
|
||||
tapAwaitingWrite = event.occurrenceKey()
|
||||
tapWriteLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
editAwaitingTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -704,7 +720,7 @@ fun CalendarHost(
|
||||
|
||||
// Event form (v1.2) — full-screen destination, slides over the calendar.
|
||||
val shownCreate = rememberHeld(createRequest)
|
||||
SlideOverlay(visible = createRequest != null) {
|
||||
SlideOverlay(visible = createRequest != null, onHidden = createViewModel::reset) {
|
||||
shownCreate?.let { create ->
|
||||
key(create) {
|
||||
EventEditScreen(
|
||||
@@ -723,7 +739,7 @@ fun CalendarHost(
|
||||
|
||||
// Edit form (v1.3) — slides over the detail screen.
|
||||
val shownEditKey = rememberHeld(editKey)
|
||||
SlideOverlay(visible = editKey != null) {
|
||||
SlideOverlay(visible = editKey != null, onHidden = editViewModel::reset) {
|
||||
shownEditKey?.let { key ->
|
||||
EventEditScreen(
|
||||
initialDateIso = null,
|
||||
@@ -862,9 +878,19 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||
* snapping back and sliding off.
|
||||
*/
|
||||
@Composable
|
||||
private fun SlideOverlay(visible: Boolean, content: @Composable AnimatedVisibilityScope.() -> Unit) {
|
||||
private fun SlideOverlay(
|
||||
visible: Boolean,
|
||||
onHidden: () -> Unit = {},
|
||||
content: @Composable AnimatedVisibilityScope.() -> Unit,
|
||||
) {
|
||||
val state = remember { MutableTransitionState(visible) }
|
||||
state.targetState = visible
|
||||
val hidden = state.isIdle && !state.currentState
|
||||
LaunchedEffect(hidden) {
|
||||
if (hidden) onHidden()
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
visibleState = state,
|
||||
enter = slideInHorizontally(rememberCalendarSlideSpec()) { it } + fadeIn(),
|
||||
exit = predictiveBackExit(),
|
||||
content = content,
|
||||
|
||||
@@ -36,6 +36,9 @@ class ExternalRequests(val onExternalFormClosed: () -> Unit = {}) {
|
||||
|
||||
/** An external "edit this event" (ACTION_EDIT), in the detail key's shape. */
|
||||
var editKey by mutableStateOf<LongArray?>(null)
|
||||
|
||||
/** Whether these came from re-reading the launch intent on a recreation. */
|
||||
var replayed = false
|
||||
}
|
||||
|
||||
/** A prefilled create form from an external launch, with the source it came from. */
|
||||
|
||||
@@ -164,14 +164,16 @@ class AgendaViewModel @Inject constructor(
|
||||
|
||||
/**
|
||||
* Extend the window further back (#367), doubling what's loaded (at least
|
||||
* [PAST_CHUNK_DAYS]) so a long scroll back re-queries only a few times.
|
||||
* Ignored while the previous extension is still loading.
|
||||
* [PAST_CHUNK_DAYS], at most [MAX_PAST_STEP_DAYS]) so a long scroll back
|
||||
* re-queries only a few times. Ignored while the previous extension is
|
||||
* still loading.
|
||||
*/
|
||||
fun loadEarlier() {
|
||||
val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return
|
||||
val window = _window.value
|
||||
if (loaded != window.start) return
|
||||
_window.value = window.copy(pastDays = window.pastDays + maxOf(PAST_CHUNK_DAYS, window.pastDays))
|
||||
val step = window.pastDays.coerceIn(PAST_CHUNK_DAYS, MAX_PAST_STEP_DAYS)
|
||||
_window.value = window.copy(pastDays = window.pastDays + step)
|
||||
}
|
||||
|
||||
fun rememberTopDay(date: LocalDate) {
|
||||
@@ -233,6 +235,9 @@ class AgendaViewModel @Inject constructor(
|
||||
/** How many days each scroll back into the past loads (#367). */
|
||||
internal const val PAST_CHUNK_DAYS = 30
|
||||
|
||||
/** The most one scroll back adds, so an empty past grows the window by years, not exponentially. */
|
||||
internal const val MAX_PAST_STEP_DAYS = 365
|
||||
|
||||
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
||||
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> =
|
||||
dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone)
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -100,26 +101,28 @@ suspend fun CalendarRepository.tryDeleteEvent(
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first
|
||||
* where the install only holds read access (v1.0 installs). A grant continues
|
||||
* straight into the action that asked for it.
|
||||
* [action], which writes to the calendar, as a handler that asks for
|
||||
* `WRITE_CALENDAR` first where the install only holds read access (v1.0
|
||||
* installs). A grant continues into [action], also when it returns after a
|
||||
* rotation; a refusal runs [onDenied].
|
||||
*/
|
||||
@Composable
|
||||
fun rememberWriteGate(): (() -> Unit) -> Unit {
|
||||
fun rememberWriteAction(onDenied: () -> Unit = {}, action: () -> Unit): () -> Unit {
|
||||
val context = LocalContext.current
|
||||
var pending by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val currentAction by rememberUpdatedState(action)
|
||||
val currentDenied by rememberUpdatedState(onDenied)
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) pending?.invoke()
|
||||
pending = null
|
||||
if (granted) currentAction() else currentDenied()
|
||||
}
|
||||
return { action ->
|
||||
if (context.canWriteCalendar()) {
|
||||
action()
|
||||
} else {
|
||||
pending = action
|
||||
launcher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
return remember(context, launcher) {
|
||||
{
|
||||
if (context.canWriteCalendar()) {
|
||||
currentAction()
|
||||
} else {
|
||||
launcher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ fun rememberFabSelection(
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val requireWrite = rememberWriteGate()
|
||||
val chooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||
|
||||
// What the FAB has been told for this selection, over what the coverage says.
|
||||
@@ -43,10 +42,28 @@ fun rememberFabSelection(
|
||||
val currentEdit by rememberUpdatedState(onEdit)
|
||||
val currentDuplicate by rememberUpdatedState(onDuplicate)
|
||||
val currentDismissChip by rememberUpdatedState(onDismissChip)
|
||||
val currentWrite by rememberUpdatedState(requireWrite)
|
||||
// Each reads the selection at grant time, which may come after a rotation.
|
||||
val onCreate = rememberWriteAction { host.selection?.create?.invoke() }
|
||||
val onEditSelected = rememberWriteAction {
|
||||
host.selection?.takeIf { it.create == null }?.let { selected ->
|
||||
selected.dismiss()
|
||||
currentEdit(selected.event)
|
||||
}
|
||||
}
|
||||
val onDuplicateSelected = rememberWriteAction {
|
||||
val selected = host.selection?.takeIf { it.create == null } ?: return@rememberWriteAction
|
||||
scope.launch {
|
||||
val form = viewModel.duplicateForm(selected.event) ?: return@launch
|
||||
selected.dismiss()
|
||||
currentDuplicate(form)
|
||||
}
|
||||
}
|
||||
val onDeleteSelected = rememberWriteAction {
|
||||
host.selection?.takeIf { it.create == null }?.let { viewModel.requestDelete(it.event) }
|
||||
}
|
||||
|
||||
val selection = host.selection ?: return null
|
||||
selection.create?.let { create ->
|
||||
if (selection.create != null) {
|
||||
return remember(selection, expanded, chipShown) {
|
||||
FabSelection(
|
||||
expanded = expanded,
|
||||
@@ -56,7 +73,7 @@ fun rememberFabSelection(
|
||||
},
|
||||
bar = {
|
||||
DraftSelectionBar(
|
||||
onCreate = { currentWrite { create() } },
|
||||
onCreate = onCreate,
|
||||
onDiscard = selection.dismiss,
|
||||
)
|
||||
},
|
||||
@@ -78,28 +95,15 @@ fun rememberFabSelection(
|
||||
EventSelectionBar(
|
||||
canEdit = canEdit,
|
||||
canDelete = canDelete,
|
||||
onEdit = {
|
||||
currentWrite {
|
||||
selection.dismiss()
|
||||
currentEdit(event)
|
||||
}
|
||||
},
|
||||
onDuplicate = {
|
||||
currentWrite {
|
||||
scope.launch {
|
||||
val form = viewModel.duplicateForm(event) ?: return@launch
|
||||
selection.dismiss()
|
||||
currentDuplicate(form)
|
||||
}
|
||||
}
|
||||
},
|
||||
onEdit = onEditSelected,
|
||||
onDuplicate = onDuplicateSelected,
|
||||
onShare = {
|
||||
scope.launch {
|
||||
val uri = viewModel.shareUri(event) ?: return@launch
|
||||
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
|
||||
}
|
||||
},
|
||||
onDelete = { currentWrite { viewModel.requestDelete(event) } },
|
||||
onDelete = onDeleteSelected,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -95,7 +95,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
||||
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
||||
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
||||
@@ -169,14 +169,11 @@ fun EventDetailScreen(
|
||||
|
||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||
// upgrade in place and continues straight into the tapped action.
|
||||
val requireWrite = rememberWriteGate()
|
||||
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||
val onEditClick = { requireWrite(onEdit) }
|
||||
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
|
||||
val onEditClick = rememberWriteAction(action = onEdit)
|
||||
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||
// create still needs WRITE_CALENDAR even when the source is read-only.
|
||||
val onDuplicateClick = {
|
||||
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||
}
|
||||
val onDuplicateClick = rememberWriteAction { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||
|
||||
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||
val writeDeniedMessage = stringResource(R.string.event_delete_write_denied)
|
||||
|
||||
@@ -41,7 +41,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
||||
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
@@ -289,7 +289,6 @@ fun EventEditScreen(
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val requireWrite = rememberWriteGate()
|
||||
// Save keeps its own launcher: its result calls straight into the ViewModel,
|
||||
// so a save waiting on the permission dialog survives a rotation.
|
||||
val savePermissionLauncher = rememberLauncherForActivityResult(
|
||||
@@ -314,14 +313,13 @@ fun EventEditScreen(
|
||||
}
|
||||
}
|
||||
// The copy opens over this form; a form with nothing to lose steps aside.
|
||||
val onDuplicateClick: () -> Unit = {
|
||||
requireWrite {
|
||||
viewModel.duplicateForm()?.let { form ->
|
||||
onDuplicate?.invoke(form)
|
||||
if (!hasUnsavedChanges) close()
|
||||
}
|
||||
val onDuplicateClick = rememberWriteAction {
|
||||
viewModel.duplicateForm()?.let { form ->
|
||||
onDuplicate?.invoke(form)
|
||||
if (!hasUnsavedChanges) close()
|
||||
}
|
||||
}
|
||||
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
|
||||
|
||||
val saveFailedMessage = stringResource(R.string.event_edit_save_failed)
|
||||
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
|
||||
@@ -387,7 +385,7 @@ fun EventEditScreen(
|
||||
canDuplicate = onDuplicate != null,
|
||||
onDuplicate = onDuplicateClick,
|
||||
onShare = onShareClick,
|
||||
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||
onDelete = onDeleteClick,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
|
||||
@@ -590,6 +590,9 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** The form as the user has it now, or null when none is open. */
|
||||
fun currentForm(): EventForm? = _form.value
|
||||
|
||||
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
|
||||
fun reset() {
|
||||
session++
|
||||
|
||||
@@ -90,8 +90,10 @@ fun ImportScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// A single event isn't shown here — it opens the create form for review.
|
||||
// Read live: the state composed with may be the previous import's result,
|
||||
// which the load above has already replaced.
|
||||
LaunchedEffect(state) {
|
||||
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||
(viewModel.state.value as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||
}
|
||||
|
||||
// Hoisted target calendar so the always-visible top-bar Import action can
|
||||
|
||||
@@ -80,7 +80,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
@@ -151,7 +151,7 @@ fun SearchScreen(
|
||||
}
|
||||
|
||||
// A v1.0 install holds only READ_CALENDAR; granting continues into the held delete.
|
||||
val requireWrite = rememberWriteGate()
|
||||
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
|
||||
|
||||
// Back leaves selection mode before it leaves the screen, and without the
|
||||
// predictive-back scale-out — that would read as search closing and snapping
|
||||
@@ -172,7 +172,7 @@ fun SearchScreen(
|
||||
onBack = onBack,
|
||||
onCloseSelection = viewModel::clearSelection,
|
||||
onSelectAll = viewModel::selectAll,
|
||||
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||
onDelete = onDeleteClick,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
|
||||
@@ -7,8 +7,8 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
|
||||
/**
|
||||
* A synchronous copy of the theme choice, for the window backdrop drawn before
|
||||
* the first Compose frame. The real setting lives in DataStore, which only
|
||||
* A synchronous copy of the theme and dynamic-colour choices, for the window
|
||||
* backdrop and the first Compose frame. The real setting lives in DataStore, which only
|
||||
* answers asynchronously — too late for onCreate, so a Light, Dark or Black
|
||||
* override flashed the night-qualifier backdrop on a cold start or a
|
||||
* recreation (#181). MainActivity mirrors every stored value here and reads it
|
||||
@@ -18,6 +18,7 @@ internal object WindowThemeCache {
|
||||
|
||||
private const val FILE = "window_theme"
|
||||
private const val KEY_THEME_MODE = "theme_mode"
|
||||
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
|
||||
|
||||
/** The last mirrored theme choice, or null before the first mirror. */
|
||||
fun read(context: Context): ThemeMode? =
|
||||
@@ -25,10 +26,23 @@ internal object WindowThemeCache {
|
||||
.getString(KEY_THEME_MODE, null)
|
||||
?.let { name -> ThemeMode.entries.firstOrNull { it.name == name } }
|
||||
|
||||
fun write(context: Context, mode: ThemeMode) {
|
||||
/** The last mirrored dynamic-colour choice, or null before the first mirror. */
|
||||
fun readDynamicColor(context: Context): Boolean? {
|
||||
val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
if (prefs.getString(KEY_THEME_MODE, null) == mode.name) return
|
||||
prefs.edit { putString(KEY_THEME_MODE, mode.name) }
|
||||
return if (prefs.contains(KEY_DYNAMIC_COLOR)) prefs.getBoolean(KEY_DYNAMIC_COLOR, true) else null
|
||||
}
|
||||
|
||||
fun write(context: Context, mode: ThemeMode, dynamicColor: Boolean) {
|
||||
val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
if (prefs.getString(KEY_THEME_MODE, null) == mode.name &&
|
||||
prefs.contains(KEY_DYNAMIC_COLOR) && prefs.getBoolean(KEY_DYNAMIC_COLOR, true) == dynamicColor
|
||||
) {
|
||||
return
|
||||
}
|
||||
prefs.edit {
|
||||
putString(KEY_THEME_MODE, mode.name)
|
||||
putBoolean(KEY_DYNAMIC_COLOR, dynamicColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ import de.jeanlucmakiola.calendula.ui.common.draftEvent
|
||||
import de.jeanlucmakiola.calendula.ui.common.draftStartMin
|
||||
import de.jeanlucmakiola.calendula.ui.common.isDraft
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdges
|
||||
import de.jeanlucmakiola.calendula.ui.common.wallClockInstant
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickUp
|
||||
@@ -638,10 +638,15 @@ private fun WeekContent(
|
||||
delay(SELECTION_ORPHAN_GRACE_MILLIS)
|
||||
dragController.deselect()
|
||||
}
|
||||
val requireWrite = rememberWriteGate()
|
||||
var pendingDraft by remember { mutableStateOf<EventInstance?>(null) }
|
||||
val createPendingDraft = rememberWriteAction {
|
||||
pendingDraft?.let(createDraft)
|
||||
pendingDraft = null
|
||||
}
|
||||
val openEvent: (EventInstance) -> Unit = { event ->
|
||||
if (event.isDraft) {
|
||||
requireWrite { createDraft(event) }
|
||||
pendingDraft = event
|
||||
createPendingDraft()
|
||||
} else {
|
||||
dragController.deselect()
|
||||
onEventClick(event)
|
||||
|
||||
@@ -18,6 +18,14 @@ class IcsRecurrenceTest {
|
||||
assertThat(sanitizeRrule("RRULE:FREQ=DAILY").rule).isEqualTo("FREQ=DAILY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `part names and the prefix are matched case-insensitively`() {
|
||||
val result = sanitizeRrule("rrule:freq=weekly;byday=mo")
|
||||
|
||||
assertThat(result.rule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
|
||||
assertThat(result.repaired).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty list part is dropped`() {
|
||||
val bareByDay = sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=")
|
||||
|
||||
Reference in New Issue
Block a user