Fix issues from the 2.23.0 branch review (#428)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped

Fixes from reviewing the 2.23.0 release branch, plus a second pass over those fixes.

- Moved recurring exceptions keep their colour when they change calendar, instead of losing their colour key.
- An external request (widget, edit intent, share) now resets the form it replaces. Forms reset only after their slide-out ends, so they never go blank on the way out.
- Import, duplicate and picker state survive rotation, and recreating the activity no longer replays the launch intent.
- A shared .ics attachment opens the import, even when the email app adds a subject line as text. Plain text still opens a new event.
- Stale import state no longer reopens the previous import's form.
- The theme and dynamic colour are cached so a cold start draws the right colours from the first frame.
- Write-permission prompts (tap to edit, delete, duplicate, selection actions, week drafts) keep their pending action across rotation, and tap to edit falls back to details when the prompt is refused.
- Zone and date sources share one app-scoped receiver instead of one per collector.
- Scrolling back over an empty agenda past grows the window by at most a year per step, not exponentially.
- RRULE part names and the RRULE: prefix are matched case-insensitively on import.
- Search closes when another overlay takes over.

Closes #306, #178, #387, #367

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/428
This commit is contained in:
Jean-Luc Makiola
2026-10-19 19:38:37 +02:00
co-authored by makiolaj
parent 17b3753ad3
commit 5665cb8c95
31 changed files with 352 additions and 204 deletions
+1 -1
View File
@@ -152,7 +152,7 @@
</intent-filter> </intent-filter>
<!-- Text shared or selected in another app opens a prefilled new <!-- Text shared or selected in another app opens a prefilled new
event (MainActivity.sharedTextExtrasOrNull, issue #306). --> event (MainActivity.sharedTextRequestOrNull, issue #306). -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@@ -6,6 +6,7 @@ import android.content.res.Configuration
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.CalendarContract import android.provider.CalendarContract
import android.provider.OpenableColumns
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.windowBackgroundRes
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@@ -124,10 +127,17 @@ class MainActivity : AppCompatActivity() {
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) } cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark) systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark)
applyEdgeToEdge() applyEdgeToEdge()
val appearance = MutableStateFlow(
Appearance(
themeMode = cachedTheme ?: ThemeMode.SYSTEM,
dynamicColor = WindowThemeCache.readDynamicColor(this),
),
)
lifecycleScope.launch { lifecycleScope.launch {
settingsPrefs.themeMode.collect { mode -> combine(settingsPrefs.themeMode, settingsPrefs.dynamicColor, ::Appearance).collect { stored ->
WindowThemeCache.write(this@MainActivity, mode) WindowThemeCache.write(this@MainActivity, stored.themeMode, stored.dynamicColor ?: true)
window.setBackgroundDrawableResource(mode.windowBackgroundRes() ?: R.color.window_background) window.setBackgroundDrawableResource(stored.themeMode.windowBackgroundRes() ?: R.color.window_background)
appearance.value = stored
} }
} }
// Android hands the intent that started the task back to onCreate every // 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 // is what carries one through a recreation — the create form only while
// it's still open. // it's still open.
val replaying = savedInstanceState != null || intent.isRelaunch() val replaying = savedInstanceState != null || intent.isRelaunch()
requests.replayed = savedInstanceState != null
if (!replaying) { if (!replaying) {
requests.detailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull() requests.detailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requests.editKey = intent.editEventKeyOrNull() requests.editKey = intent.editEventKeyOrNull()
@@ -161,7 +172,11 @@ class MainActivity : AppCompatActivity() {
// and the Settings screen, so a theme change applies app-wide at once. // and the Settings screen, so a theme change applies app-wide at once.
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val settings by settingsViewModel.state.collectAsStateWithLifecycle() val settings by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = settings.themeMode.isDark(isSystemInDarkTheme()) // 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 // onCreate can only see the night resource qualifier, not the in-app
// override — re-apply from the resolved theme so the bar icons follow // override — re-apply from the resolved theme so the bar icons follow
// the app's light/dark choice. // the app's light/dark choice.
@@ -199,8 +214,8 @@ class MainActivity : AppCompatActivity() {
} }
CalendulaTheme( CalendulaTheme(
darkTheme = darkTheme, darkTheme = darkTheme,
dynamicColor = settings.dynamicColor, dynamicColor = shownAppearance.dynamicColor ?: settings.dynamicColor,
pureBlack = settings.themeMode.isPureBlack, pureBlack = themeMode.isPureBlack,
typography = typography, typography = typography,
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@@ -273,6 +288,7 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
requests.replayed = false
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it } (intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it }
intent.navRequestOrNull()?.let { requests.nav = it } intent.navRequestOrNull()?.let { requests.nav = it }
intent.importUriOrNull()?.let { requests.importUri = it } intent.importUriOrNull()?.let { requests.importUri = it }
@@ -281,6 +297,13 @@ class MainActivity : AppCompatActivity() {
insertFormOpen = true insertFormOpen = true
} }
intent.editEventKeyOrNull()?.let { requests.editKey = it } intent.editEventKeyOrNull()?.let { requests.editKey = it }
// The user moved on into the app, so closing the create form no longer
// hands control back to the app that started it.
if (requests.detailKey != null || requests.nav != null || requests.importUri != null ||
requests.editKey != null
) {
launchedForInsert = false
}
} }
/** /**
@@ -301,9 +324,10 @@ class MainActivity : AppCompatActivity() {
private fun Intent.importUriOrNull(): Uri? { private fun Intent.importUriOrNull(): Uri? {
val uri = when (action) { val uri = when (action) {
Intent.ACTION_VIEW -> data Intent.ACTION_VIEW -> data
// Plain text is a create (#306), never a file to import. // Shared text is a create (#306); a text/plain file is imported only
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java) // when it is calendar data, e.g. an .ics attachment labelled text/plain.
.takeUnless { type == SHARED_TEXT_TYPE } Intent.ACTION_SEND -> sharedStreamOrNull()
?.takeUnless { type == SHARED_TEXT_TYPE && !it.isCalendarFile() }
else -> null else -> null
} ?: return null } ?: return null
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content; // The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
@@ -357,7 +381,7 @@ class MainActivity : AppCompatActivity() {
*/ */
private fun Intent.sharedTextRequestOrNull(): InsertRequest? { private fun Intent.sharedTextRequestOrNull(): InsertRequest? {
val shared = when (action) { val shared = when (action) {
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE) { Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE && sharedStreamOrNull()?.isCalendarFile() != true) {
sharedTextOrNull( sharedTextOrNull(
subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(), subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(),
text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(), text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(),
@@ -378,6 +402,23 @@ class MainActivity : AppCompatActivity() {
return InsertRequest(form, ImportSource.InsertOpenEnded) 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. */ /** A Long extra's value, or null when the extra is absent. */
private fun Intent.longExtraOrNull(key: String): Long? = private fun Intent.longExtraOrNull(key: String): Long? =
if (hasExtra(key)) getLongExtra(key, 0L) else null if (hasExtra(key)) getLongExtra(key, 0L) else null
@@ -504,6 +545,8 @@ class MainActivity : AppCompatActivity() {
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>. // ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar" private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
private const val SHARED_TEXT_TYPE = "text/plain" 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_INSERT_FORM_OPEN = "insert_form_open"
private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert" private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert"
@@ -596,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?)
@@ -725,7 +725,7 @@ class AndroidCalendarDataSource @Inject constructor(
), ),
null, null,
)?.use { c -> )?.use { c ->
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) } c.mapRows { EventColorOption(key = getString(0).orEmpty(), argb = getInt(1)) }
} }
?.filter { it.key.isNotEmpty() } ?.filter { it.key.isNotEmpty() }
?: emptyList() ?: emptyList()
@@ -1116,7 +1116,7 @@ class AndroidCalendarDataSource @Inject constructor(
?: throw WriteFailedException("read modified occurrence id=${ex.exceptionEventId}") ?: throw WriteFailedException("read modified occurrence id=${ex.exceptionEventId}")
val endMillis = ex.dtEndMillis ?: (ex.dtStartMillis + parseRfc2445DurationMillis(ex.duration)) val endMillis = ex.dtEndMillis ?: (ex.dtStartMillis + parseRfc2445DurationMillis(ex.duration))
val form = detail.toEditForm(ex.dtStartMillis, endMillis, zone) val form = detail.toEditForm(ex.dtStartMillis, endMillis, zone)
.copy(calendarId = targetCalendarId) .copy(calendarId = targetCalendarId, colorKey = null)
.toDetachedOccurrence() .toDetachedOccurrence()
detached += insertEvent(form, allDayReminderTimeMinutes) detached += insertEvent(form, allDayReminderTimeMinutes)
} }
@@ -1691,11 +1691,6 @@ class AndroidCalendarDataSource @Inject constructor(
null, null,
)?.use { c -> c.mapRows { toReminder() } } ?: emptyList() )?.use { c -> c.mapRows { toReminder() } } ?: emptyList()
/** Iterate every row and map; skips nothing. */
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
while (moveToNext()) add(mapper(this@mapAll))
}
/** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */ /** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
private fun mintUid(): String = "${UUID.randomUUID()}@calendula" 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.AndroidZoneSource
import de.jeanlucmakiola.calendula.data.time.TodaySource import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.ZoneSource import de.jeanlucmakiola.calendula.data.time.ZoneSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton import javax.inject.Singleton
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore( private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
@@ -85,4 +88,9 @@ object DataProvideModule {
@Singleton @Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> = fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.calendulaDataStore 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.Context
import android.content.Intent import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
import de.jeanlucmakiola.floret.android.broadcastTicks import de.jeanlucmakiola.floret.android.broadcastTicks
import de.jeanlucmakiola.floret.android.stateWhileSubscribed import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onEach 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 * 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. * 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) @OptIn(ExperimentalCoroutinesApi::class)
class AndroidTodaySource @Inject constructor( class AndroidTodaySource @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
@ApplicationScope scope: CoroutineScope,
) : TodaySource { ) : TodaySource {
override val today: Flow<LocalDate> = context override val today: Flow<LocalDate> = context
@@ -42,6 +47,7 @@ class AndroidTodaySource @Inject constructor(
} }
} }
.distinctUntilChanged() .distinctUntilChanged()
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
} }
private const val MINUTE_MILLIS = 60_000L private const val MINUTE_MILLIS = 60_000L
@@ -3,10 +3,14 @@ package de.jeanlucmakiola.calendula.data.time
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
import de.jeanlucmakiola.floret.android.broadcastTicks import de.jeanlucmakiola.floret.android.broadcastTicks
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import javax.inject.Inject import javax.inject.Inject
@@ -23,14 +27,16 @@ interface ZoneSource {
fun current(): TimeZone fun current(): TimeZone
} }
/** Follows the system's zone broadcast. */ /** Follows the system's zone broadcast, one receiver shared by every collector. */
class AndroidZoneSource @Inject constructor( class AndroidZoneSource @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
@ApplicationScope scope: CoroutineScope,
) : ZoneSource { ) : ZoneSource {
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED) override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
.map { current() } .map { current() }
.distinctUntilChanged() .distinctUntilChanged()
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
override fun current(): TimeZone = TimeZone.currentSystemDefault() override fun current(): TimeZone = TimeZone.currentSystemDefault()
} }
@@ -131,6 +131,10 @@ private class HtmlRenderer(private val html: String) {
text("<") text("<")
return lt + 1 return lt + 1
} }
if (!closing && isAutolink(html.substring(nameStart, end))) {
text(html.substring(lt, end + 1))
return end + 1
}
var nameEnd = nameStart var nameEnd = nameStart
while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++ while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++
val name = html.substring(nameStart, nameEnd).lowercase() val name = html.substring(nameStart, nameEnd).lowercase()
@@ -149,6 +153,11 @@ private class HtmlRenderer(private val html: String) {
return end + 1 return end + 1
} }
/** An angle-bracketed URL or address (`<https://…>`, `<a@b.c>`), kept as text. */
private fun isAutolink(inner: String): Boolean =
LINK_SCHEMES.any { inner.startsWith(it, ignoreCase = true) } ||
('@' in inner && inner.none { it.isWhitespace() || it == '=' })
/** The `>` closing the tag at [lt], skipping any inside quoted attributes. */ /** The `>` closing the tag at [lt], skipping any inside quoted attributes. */
private fun tagEnd(lt: Int): Int { private fun tagEnd(lt: Int): Int {
var quote: Char? = null var quote: Char? = null
@@ -66,7 +66,9 @@ data class SanitizedRrule(val rule: String?, val repaired: Boolean)
* rule at all — gives up entirely. * rule at all — gives up entirely.
*/ */
fun sanitizeRrule(raw: String?): SanitizedRrule { 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) if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
var freqSeen = false var freqSeen = false
@@ -84,8 +86,8 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost. // A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part // A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
// being dropped, so it falls through and marks the rule repaired. // being dropped, so it falls through and marks the rule repaired.
for (part in rruleParts(raw.orEmpty().trim())) { for (part in rruleParts(rule)) {
val key = part.key?.trim().orEmpty() val key = part.key?.trim()?.uppercase().orEmpty()
val value = part.value.trim() val value = part.value.trim()
val kept = when { val kept = when {
key.isEmpty() || value.isEmpty() -> null key.isEmpty() || value.isEmpty() -> null
@@ -7,6 +7,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.SeekableTransitionState import androidx.compose.animation.core.SeekableTransitionState
import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -34,6 +35,7 @@ import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.rememberHeld
import de.jeanlucmakiola.calendula.ui.common.screen import de.jeanlucmakiola.calendula.ui.common.screen
import de.jeanlucmakiola.calendula.ui.common.ViewScreen import de.jeanlucmakiola.calendula.ui.common.ViewScreen
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
@@ -66,7 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost
import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage
import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGateOr import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
@@ -243,20 +245,29 @@ fun CalendarHost(
// An opened/received .ics file. [ImportScreen] parses it and either opens // An opened/received .ics file. [ImportScreen] parses it and either opens
// the prefilled create form (one event → [importForm]) or its own bulk // the prefilled create form (one event → [importForm]) or its own bulk
// picker (many). A plain conditional overlay (no slide) — it's transient. // picker (many). A plain conditional overlay (no slide) — it's transient.
var importUri by remember { mutableStateOf<android.net.Uri?>(null) } var importUri by rememberSaveable { mutableStateOf<android.net.Uri?>(null) }
var importForm by remember { mutableStateOf<EventForm?>(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 // Which channel filled [importForm]: an .ics file (prompt to apply the default
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49. // 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. // 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); // Whether [importForm] came from an external create request (#30, #306);
// closing it then hands control back to whoever asked. // closing it then hands control back to whoever asked.
var importFromRequest by rememberSaveable { mutableStateOf(false) } var importFromRequest by rememberSaveable { mutableStateOf(false) }
// A second external request that arrived while a prefilled form was open: // A second external request that arrived while a prefilled form was open:
// it waits behind that form's unsaved-changes prompt, then opens. // it waits behind that form's unsaved-changes prompt, then opens.
var pendingInsert by remember { mutableStateOf<Pair<EventForm, ImportSource>?>(null) } 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) { fun openImportForm(form: EventForm, source: ImportSource, fromRequest: Boolean = false) {
importFormSource = source importFormSource = source
importForm = form importForm = form
@@ -280,7 +291,7 @@ fun CalendarHost(
// flow — picker + summary — even for a single-event file, because the intent // 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 // is "restore a backup", not "add this one event". An externally opened .ics
// keeps routing a single event straight into the prefilled create form. // 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 // 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 // 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 // one has to close them — without this, finishing a restore drops you on the
@@ -291,9 +302,20 @@ fun CalendarHost(
// what tells it a *re-import of the same file* is new work and not the run // 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. // it already finished; a rotation keeps the number and keeps the result.
var importSession by rememberSaveable { mutableStateOf(0) } var importSession by rememberSaveable { mutableStateOf(0) }
// 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 val requestedImportUri = requests.importUri
LaunchedEffect(requestedImportUri) { LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) { if (requestedImportUri != null) {
if (isReplayOfRestoredImport()) {
requests.importUri = null
return@LaunchedEffect
}
importUri = requestedImportUri importUri = requestedImportUri
importForceMany = false importForceMany = false
importSession++ importSession++
@@ -307,6 +329,10 @@ fun CalendarHost(
val requestedInsert = requests.insert val requestedInsert = requests.insert
LaunchedEffect(requestedInsert) { LaunchedEffect(requestedInsert) {
if (requestedInsert != null) { if (requestedInsert != null) {
if (isReplayOfRestoredImport()) {
requests.insert = null
return@LaunchedEffect
}
if (importForm == null) { if (importForm == null) {
openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true) openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
} else { } else {
@@ -318,16 +344,22 @@ fun CalendarHost(
// Close every overlay that can sit over the calendar, so an externally // Close every overlay that can sit over the calendar, so an externally
// requested destination (a widget/shortcut/QS-tile launch) is revealed on // requested destination (a widget/shortcut/QS-tile launch) is revealed on
// top instead of underneath whatever the user had open. // top instead of underneath whatever the user had open. The create form,
fun dismissCoveringOverlays() { // declared under the edit form, can stay for an edit to open over it.
fun dismissCoveringOverlays(keepCreate: Boolean = false) {
showSettings = false showSettings = false
showSearch = false
showCalendars = false showCalendars = false
showBackup = false showBackup = false
detailKey = null detailKey = null
editKey = null editKey = null
if (!keepCreate) createRequest = null
importUri = null importUri = null
importForm = null importForm = null
importFromRequest = false if (importFromRequest) {
importFromRequest = false
requests.onExternalFormClosed()
}
pendingInsert = null pendingInsert = null
importViewModel.reset() importViewModel.reset()
} }
@@ -340,7 +372,10 @@ fun CalendarHost(
val requestedEditKey = requests.editKey val requestedEditKey = requests.editKey
LaunchedEffect(requestedEditKey) { LaunchedEffect(requestedEditKey) {
if (requestedEditKey != null) { 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 editKey = requestedEditKey
requests.editKey = null requests.editKey = null
} }
@@ -361,7 +396,6 @@ fun CalendarHost(
// Drill the day view in over the widget's view: drop any overlay // Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form. // that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays() dismissCoveringOverlays()
createRequest = null
focus.navigateTo(LocalDate.parse(req.dateIso)) focus.navigateTo(LocalDate.parse(req.dateIso))
// No widget source (an external date tap) roots over the default // No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits. // home view, so backing out of the day returns home then exits.
@@ -372,7 +406,6 @@ fun CalendarHost(
// Root the stack in the widget's view, then open the occurrence // Root the stack in the widget's view, then open the occurrence
// detail over it (same key shape as a tapped event / reminder). // detail over it (same key shape as a tapped event / reminder).
dismissCoveringOverlays() dismissCoveringOverlays()
createRequest = null
viewStack = viewBaseStack(defaultView, req.source) viewStack = viewBaseStack(defaultView, req.source)
detailKey = longArrayOf(req.eventId, req.beginMillis, req.endMillis) detailKey = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
requests.nav = null requests.nav = null
@@ -383,7 +416,6 @@ fun CalendarHost(
// the stack on the target (null → the default home) over the // the stack on the target (null → the default home) over the
// default home — so backing out returns to the default, then exits. // default home — so backing out returns to the default, then exits.
dismissCoveringOverlays() dismissCoveringOverlays()
createRequest = null
focus.navigateTo(currentDate()) focus.navigateTo(currentDate())
viewStack = viewBaseStack(defaultView, req.view ?: defaultView) viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
requests.nav = null requests.nav = null
@@ -393,6 +425,7 @@ fun CalendarHost(
// widget) must land on top of whatever is open — the form overlay // widget) must land on top of whatever is open — the form overlay
// sits below Settings/calendars in the Box, so without this it // sits below Settings/calendars in the Box, so without this it
// would open hidden underneath them. // would open hidden underneath them.
if (createRequest != null) createViewModel.reset()
dismissCoveringOverlays() dismissCoveringOverlays()
openCreate(req.dateIso ?: currentDate().toString()) openCreate(req.dateIso ?: currentDate().toString())
requests.nav = null requests.nav = null
@@ -414,9 +447,22 @@ fun CalendarHost(
// tap time, so a tap before they've loaded still routes right. // tap time, so a tap before they've loaded still routes right.
val tapScope = rememberCoroutineScope() val tapScope = rememberCoroutineScope()
val tapContext = LocalContext.current val tapContext = LocalContext.current
val requireWriteOr = rememberWriteGateOr()
// A refused write upgrade is asked once; later taps just open the details. // A refused write upgrade is asked once; later taps just open the details.
var tapWriteRefused by rememberSaveable { mutableStateOf(false) } 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 editAwaitingTap = rememberWriteAction(
onDenied = {
tapAwaitingWrite?.let { key ->
tapWriteRefused = true
detailKey = key
}
tapAwaitingWrite = null
},
) {
tapAwaitingWrite?.let { editKey = it }
tapAwaitingWrite = null
}
val onEventClick: (EventInstance) -> Unit = { event -> val onEventClick: (EventInstance) -> Unit = { event ->
tapScope.launch { tapScope.launch {
val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId) val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId)
@@ -424,9 +470,9 @@ fun CalendarHost(
!toEditor -> onOpenDetail(event) !toEditor -> onOpenDetail(event)
tapContext.canWriteCalendar() -> onEditEvent(event) tapContext.canWriteCalendar() -> onEditEvent(event)
tapWriteRefused -> onOpenDetail(event) tapWriteRefused -> onOpenDetail(event)
else -> requireWriteOr({ onEditEvent(event) }) { else -> {
tapWriteRefused = true tapAwaitingWrite = event.occurrenceKey()
onOpenDetail(event) editAwaitingTap()
} }
} }
} }
@@ -674,24 +720,26 @@ fun CalendarHost(
// Event form (v1.2) — full-screen destination, slides over the calendar. // Event form (v1.2) — full-screen destination, slides over the calendar.
val shownCreate = rememberHeld(createRequest) val shownCreate = rememberHeld(createRequest)
SlideOverlay(visible = createRequest != null) { SlideOverlay(visible = createRequest != null, onHidden = createViewModel::reset) {
shownCreate?.let { create -> shownCreate?.let { create ->
EventEditScreen( key(create) {
initialDateIso = create.dateIso, EventEditScreen(
initialStartMinutes = create.startMinutes, initialDateIso = create.dateIso,
initialDraft = create.draftMillis, initialStartMinutes = create.startMinutes,
onClose = { createRequest = null }, initialDraft = create.draftMillis,
onSaved = { createRequest = null }, onClose = { createRequest = null },
onManageCalendars = { showCalendars = true }, onSaved = { createRequest = null },
visible = createRequest != null, onManageCalendars = { showCalendars = true },
viewModel = hiltViewModel(key = "create"), visible = createRequest != null,
) viewModel = createViewModel,
)
}
} }
} }
// Edit form (v1.3) — slides over the detail screen. // Edit form (v1.3) — slides over the detail screen.
val shownEditKey = rememberHeld(editKey) val shownEditKey = rememberHeld(editKey)
SlideOverlay(visible = editKey != null) { SlideOverlay(visible = editKey != null, onHidden = editViewModel::reset) {
shownEditKey?.let { key -> shownEditKey?.let { key ->
EventEditScreen( EventEditScreen(
initialDateIso = null, initialDateIso = null,
@@ -712,7 +760,7 @@ fun CalendarHost(
if (detailKey == null) detailKey = key if (detailKey == null) detailKey = key
}, },
visible = editKey != null, visible = editKey != null,
viewModel = hiltViewModel(key = "edit"), viewModel = editViewModel,
) )
} }
} }
@@ -830,33 +878,25 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
* snapping back and sliding off. * snapping back and sliding off.
*/ */
@Composable @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( AnimatedVisibility(
visible = visible, visibleState = state,
enter = slideInHorizontally(rememberCalendarSlideSpec()) { it } + fadeIn(), enter = slideInHorizontally(rememberCalendarSlideSpec()) { it } + fadeIn(),
exit = predictiveBackExit(), exit = predictiveBackExit(),
content = content, content = content,
) )
} }
/**
* [live], or after it goes null the last value it had, so an overlay keeps
* drawing what it showed through its slide-out instead of flashing empty. Never
* a placeholder: until [live] is first set this is null, so a destination never
* loads a bogus key on its first frame. Not saved — after a recreation the
* saveable [live] is the value again.
*/
@Composable
private fun <T : Any> rememberHeld(live: T?): T? {
val held = remember { HeldValue<T>() }
if (live != null) held.value = live
return live ?: held.value
}
private class HeldValue<T : Any> {
var value: T? = null
}
/** The detail and edit overlays' key for one occurrence: eventId, begin, end. */ /** The detail and edit overlays' key for one occurrence: eventId, begin, end. */
private fun EventInstance.occurrenceKey(): LongArray = private fun EventInstance.occurrenceKey(): LongArray =
longArrayOf(eventId, start.toEpochMilliseconds(), end.toEpochMilliseconds()) longArrayOf(eventId, start.toEpochMilliseconds(), end.toEpochMilliseconds())
@@ -36,6 +36,9 @@ class ExternalRequests(val onExternalFormClosed: () -> Unit = {}) {
/** An external "edit this event" (ACTION_EDIT), in the detail key's shape. */ /** An external "edit this event" (ACTION_EDIT), in the detail key's shape. */
var editKey by mutableStateOf<LongArray?>(null) 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. */ /** A prefilled create form from an external launch, with the source it came from. */
@@ -393,11 +393,8 @@ private fun AgendaList(
val itemDates = agendaItemDates(days) val itemDates = agendaItemDates(days)
val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0) val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0)
SideEffect { anchorIndex.intValue = anchorRow } SideEffect { anchorIndex.intValue = anchorRow }
// A new anchor opens the list on its row from the first frame, with past // A new anchor opens on its row from the first frame; keyed on the anchor
// days above. Keyed on the anchor the days arrived with, not the one just // the days arrived with, not the one requested. The first run may restore.
// requested: scrolling before the new rows compose leaves the list keyed to
// a row that reappears further down, and it follows it there. The first run
// may restore a remembered day instead.
val pendingRestore = remember { arrayOf(initialTopDate) } val pendingRestore = remember { arrayOf(initialTopDate) }
DisposableEffect(anchor) { DisposableEffect(anchor) {
val target = pendingRestore[0] val target = pendingRestore[0]
@@ -163,15 +163,17 @@ class AgendaViewModel @Inject constructor(
} }
/** /**
* Extend the window [PAST_CHUNK_DAYS] further back (#367). Ignored while the * Extend the window further back (#367), doubling what's loaded (at least
* previous extension is still loading, so a scroll that keeps asking only * [PAST_CHUNK_DAYS], at most [MAX_PAST_STEP_DAYS]) so a long scroll back
* moves one chunk at a time. * re-queries only a few times. Ignored while the previous extension is
* still loading.
*/ */
fun loadEarlier() { fun loadEarlier() {
val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return
val window = _window.value val window = _window.value
if (loaded != window.start) return if (loaded != window.start) return
_window.value = window.copy(pastDays = window.pastDays + PAST_CHUNK_DAYS) val step = window.pastDays.coerceIn(PAST_CHUNK_DAYS, MAX_PAST_STEP_DAYS)
_window.value = window.copy(pastDays = window.pastDays + step)
} }
fun rememberTopDay(date: LocalDate) { fun rememberTopDay(date: LocalDate) {
@@ -233,6 +235,9 @@ class AgendaViewModel @Inject constructor(
/** How many days each scroll back into the past loads (#367). */ /** How many days each scroll back into the past loads (#367). */
internal const val PAST_CHUNK_DAYS = 30 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. */ /** Inclusive instant range from the start of [anchor] through [days] days ahead. */
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> = internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> =
dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone) dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone)
@@ -26,8 +26,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
@@ -82,8 +80,7 @@ fun CalendarFabColumn(
) )
} }
// Held past the selection clearing, so the bar has something to fold away. // Held past the selection clearing, so the bar has something to fold away.
val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) } val heldBar = rememberHeld(selection?.bar)
if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar
// Held at the FAB's height so the taller bar can't shift the FAB. // Held at the FAB's height so the taller bar can't shift the FAB.
Row( Row(
modifier = Modifier.height(FabSize), modifier = Modifier.height(FabSize),
@@ -101,7 +98,7 @@ fun CalendarFabColumn(
shrinkTowards = Alignment.End, shrinkTowards = Alignment.End,
) + fadeOut(), ) + fadeOut(),
) { ) {
heldBar.value?.invoke() heldBar?.invoke()
} }
val label = stringResource( val label = stringResource(
when { when {
@@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -100,33 +101,28 @@ suspend fun CalendarRepository.tryDeleteEvent(
} }
/** /**
* Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first * [action], which writes to the calendar, as a handler that asks for
* where the install only holds read access (v1.0 installs). A grant continues * `WRITE_CALENDAR` first where the install only holds read access (v1.0
* straight into the action that asked for it. * installs). A grant continues into [action], also when it returns after a
* rotation; a refusal runs [onDenied].
*/ */
@Composable @Composable
fun rememberWriteGate(): (() -> Unit) -> Unit { fun rememberWriteAction(onDenied: () -> Unit = {}, action: () -> Unit): () -> Unit {
val gate = rememberWriteGateOr()
return { action -> gate(action) {} }
}
/** [rememberWriteGate], running the fallback instead when the user refuses. */
@Composable
fun rememberWriteGateOr(): (action: () -> Unit, fallback: () -> Unit) -> Unit {
val context = LocalContext.current val context = LocalContext.current
var pending by remember { mutableStateOf<Pair<() -> Unit, () -> Unit>?>(null) } val currentAction by rememberUpdatedState(action)
val currentDenied by rememberUpdatedState(onDenied)
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),
) { granted -> ) { granted ->
pending?.let { (action, fallback) -> if (granted) action() else fallback() } if (granted) currentAction() else currentDenied()
pending = null
} }
return { action, fallback -> return remember(context, launcher) {
if (context.canWriteCalendar()) { {
action() if (context.canWriteCalendar()) {
} else { currentAction()
pending = action to fallback } else {
launcher.launch(Manifest.permission.WRITE_CALENDAR) launcher.launch(Manifest.permission.WRITE_CALENDAR)
}
} }
} }
} }
@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
@@ -26,7 +27,6 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject import javax.inject.Inject
@@ -42,6 +42,7 @@ class EventActionsViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val icsExporter: IcsExporter, private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
) : ViewModel() { ) : ViewModel() {
/** Every calendar by id, for what a selected event's bar may offer. */ /** Every calendar by id, for what a selected event's bar may offer. */
@@ -64,7 +65,7 @@ class EventActionsViewModel @Inject constructor(
suspend fun duplicateForm(event: EventInstance): EventForm? { suspend fun duplicateForm(event: EventInstance): EventForm? {
val detail = load(event) ?: return null val detail = load(event) ?: return null
val canModify = calendars.value[event.calendarId]?.canModifyContents == true val canModify = calendars.value[event.calendarId]?.canModifyContents == true
return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault()) return detail.toDuplicateForm(canModify, zoneSource.current())
} }
/** A staged `.ics` for [event], or null — reported — when it can't be built. */ /** A staged `.ics` for [event], or null — reported — when it can't be built. */
@@ -32,7 +32,6 @@ fun rememberFabSelection(
val calendars by viewModel.calendars.collectAsStateWithLifecycle() val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val requireWrite = rememberWriteGate()
val chooserTitle = stringResource(R.string.event_share_chooser_title) val chooserTitle = stringResource(R.string.event_share_chooser_title)
// What the FAB has been told for this selection, over what the coverage says. // 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 currentEdit by rememberUpdatedState(onEdit)
val currentDuplicate by rememberUpdatedState(onDuplicate) val currentDuplicate by rememberUpdatedState(onDuplicate)
val currentDismissChip by rememberUpdatedState(onDismissChip) 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 val selection = host.selection ?: return null
selection.create?.let { create -> if (selection.create != null) {
return remember(selection, expanded, chipShown) { return remember(selection, expanded, chipShown) {
FabSelection( FabSelection(
expanded = expanded, expanded = expanded,
@@ -56,7 +73,7 @@ fun rememberFabSelection(
}, },
bar = { bar = {
DraftSelectionBar( DraftSelectionBar(
onCreate = { currentWrite { create() } }, onCreate = onCreate,
onDiscard = selection.dismiss, onDiscard = selection.dismiss,
) )
}, },
@@ -78,28 +95,15 @@ fun rememberFabSelection(
EventSelectionBar( EventSelectionBar(
canEdit = canEdit, canEdit = canEdit,
canDelete = canDelete, canDelete = canDelete,
onEdit = { onEdit = onEditSelected,
currentWrite { onDuplicate = onDuplicateSelected,
selection.dismiss()
currentEdit(event)
}
},
onDuplicate = {
currentWrite {
scope.launch {
val form = viewModel.duplicateForm(event) ?: return@launch
selection.dismiss()
currentDuplicate(form)
}
}
},
onShare = { onShare = {
scope.launch { scope.launch {
val uri = viewModel.shareUri(event) ?: return@launch val uri = viewModel.shareUri(event) ?: return@launch
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed() if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
} }
}, },
onDelete = { currentWrite { viewModel.requestDelete(event) } }, onDelete = onDeleteSelected,
) )
}, },
) )
@@ -20,7 +20,6 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -111,14 +110,13 @@ fun HourGutter(
} }
} }
// Held while it fades, so it goes out where it was rather than blinking off. // Held while it fades, so it goes out where it was rather than blinking off.
val badgeMin = remember { mutableStateOf<Int?>(null) } val badgeMin = rememberHeld(dragStartMin)
dragStartMin?.let { badgeMin.value = it }
val badgeIn by animateFloatAsState( val badgeIn by animateFloatAsState(
targetValue = if (dragStartMin != null) 1f else 0f, targetValue = if (dragStartMin != null) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(), animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
label = "gutterBadge", label = "gutterBadge",
) )
badgeMin.value?.takeIf { badgeIn > 0f }?.let { startMin -> badgeMin?.takeIf { badgeIn > 0f }?.let { startMin ->
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp) val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
Box( Box(
modifier = Modifier modifier = Modifier
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
/**
* [live], or after it goes null the last value it had, so an overlay keeps
* drawing what it showed through its slide-out instead of flashing empty. Never
* a placeholder: until [live] is first set this is null, so a destination never
* loads a bogus key on its first frame. Not saved — after a recreation the
* saveable [live] is the value again.
*/
@Composable
fun <T : Any> rememberHeld(live: T?): T? {
val held = remember { HeldValue<T>() }
if (live != null) held.value = live
return live ?: held.value
}
private class HeldValue<T : Any> {
var value: T? = null
}
@@ -95,7 +95,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog 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.sendEventShare
import de.jeanlucmakiola.calendula.ui.common.FieldCopier import de.jeanlucmakiola.calendula.ui.common.FieldCopier
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress 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 // v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place and continues straight into the tapped action. // upgrade in place and continues straight into the tapped action.
val requireWrite = rememberWriteGate() val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
val onDeleteClick = { requireWrite { showDeleteDialog = true } } val onEditClick = rememberWriteAction(action = onEdit)
val onEditClick = { requireWrite(onEdit) }
// Duplicate reads the loaded detail into a create form and hands it up; the // 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. // create still needs WRITE_CALENDAR even when the source is read-only.
val onDuplicateClick = { val onDuplicateClick = rememberWriteAction { viewModel.duplicateForm()?.let(onDuplicate) }
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
}
val deleteFailedMessage = stringResource(R.string.event_delete_failed) val deleteFailedMessage = stringResource(R.string.event_delete_failed)
val writeDeniedMessage = stringResource(R.string.event_delete_write_denied) val writeDeniedMessage = stringResource(R.string.event_delete_write_denied)
@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
@@ -31,7 +32,6 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject import javax.inject.Inject
@@ -45,6 +45,7 @@ class EventDetailViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val icsExporter: IcsExporter, private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
) : ViewModel() { ) : ViewModel() {
private val _target = MutableStateFlow<Target?>(null) private val _target = MutableStateFlow<Target?>(null)
@@ -141,7 +142,7 @@ class EventDetailViewModel @Inject constructor(
*/ */
fun duplicateForm(): EventForm? { fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null val loaded = state.value as? EventDetailUiState.Success ?: return null
return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault()) return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current())
} }
private suspend fun loadDetail(target: Target): EventDetailUiState = try { private suspend fun loadDetail(target: Target): EventDetailUiState = try {
@@ -41,7 +41,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import de.jeanlucmakiola.calendula.ui.common.sendEventShare import de.jeanlucmakiola.calendula.ui.common.sendEventShare
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar 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 de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
@@ -168,6 +168,7 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.floret.reminders.ReminderUnit import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
@@ -288,7 +289,6 @@ fun EventEditScreen(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val requireWrite = rememberWriteGate()
// Save keeps its own launcher: its result calls straight into the ViewModel, // Save keeps its own launcher: its result calls straight into the ViewModel,
// so a save waiting on the permission dialog survives a rotation. // so a save waiting on the permission dialog survives a rotation.
val savePermissionLauncher = rememberLauncherForActivityResult( val savePermissionLauncher = rememberLauncherForActivityResult(
@@ -313,14 +313,13 @@ fun EventEditScreen(
} }
} }
// The copy opens over this form; a form with nothing to lose steps aside. // The copy opens over this form; a form with nothing to lose steps aside.
val onDuplicateClick: () -> Unit = { val onDuplicateClick = rememberWriteAction {
requireWrite { viewModel.duplicateForm()?.let { form ->
viewModel.duplicateForm()?.let { form -> onDuplicate?.invoke(form)
onDuplicate?.invoke(form) if (!hasUnsavedChanges) close()
if (!hasUnsavedChanges) close()
}
} }
} }
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
val saveFailedMessage = stringResource(R.string.event_edit_save_failed) val saveFailedMessage = stringResource(R.string.event_edit_save_failed)
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied) val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
@@ -386,7 +385,7 @@ fun EventEditScreen(
canDuplicate = onDuplicate != null, canDuplicate = onDuplicate != null,
onDuplicate = onDuplicateClick, onDuplicate = onDuplicateClick,
onShare = onShareClick, onShare = onShareClick,
onDelete = { requireWrite { showDeleteDialog = true } }, onDelete = onDeleteClick,
) )
} }
Button( Button(
@@ -1128,13 +1127,7 @@ private fun WhenCard(
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} }
// The rows above edit a pinned event in *its own* zone, which is the // A pinned event's local equivalent, shown only when pinned elsewhere.
// time it was set at — but that says nothing about when it lands for
// whoever is reading it. Spell the local equivalent out rather than
// leaving the user to do the offset arithmetic. Absent unless the
// event is pinned somewhere other than here.
// Keyed rather than recomputed: the form recomposes on every title
// keystroke, and this would otherwise re-parse the zone each time.
val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) { val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) {
form.isPinnedAwayFrom(deviceZone) form.isPinnedAwayFrom(deviceZone)
} }
@@ -2421,9 +2414,8 @@ private fun accessLevelSummary(level: AccessLevel): Int = when (level) {
private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale: Locale): String { private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale: Locale): String {
val use24Hour = LocalUse24HourFormat.current val use24Hour = LocalUse24HourFormat.current
return remember(start, end, zone, locale, use24Hour) { return remember(start, end, zone, locale, use24Hour) {
val timeFormat = timeOfDayFormatter(use24Hour, locale)
fun format(at: Instant) = withRepeatedHourZone( fun format(at: Instant) = withRepeatedHourZone(
timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()), formatTimeOfDay(at, zone, use24Hour, locale),
at, at,
zone, zone,
locale, locale,
@@ -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. */ /** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
fun reset() { fun reset() {
session++ session++
@@ -90,8 +90,10 @@ fun ImportScreen(
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
// A single event isn't shown here — it opens the create form for review. // 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) { 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 // Hoisted target calendar so the always-visible top-bar Import action can
@@ -1,9 +1,6 @@
package de.jeanlucmakiola.calendula.ui.search package de.jeanlucmakiola.calendula.ui.search
import android.Manifest
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -59,7 +56,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -80,11 +76,11 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
import de.jeanlucmakiola.calendula.ui.common.declinedTitle import de.jeanlucmakiola.calendula.ui.common.declinedTitle
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
@@ -133,7 +129,6 @@ fun SearchScreen(
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle() val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current val keyboard = LocalSoftwareKeyboardController.current
val context = LocalContext.current
val inSelection = selection.isNotEmpty() val inSelection = selection.isNotEmpty()
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
@@ -155,24 +150,8 @@ fun SearchScreen(
keyboard?.show() keyboard?.show()
} }
// Same in-place WRITE_CALENDAR upgrade the detail screen does: a v1.0 install // A v1.0 install holds only READ_CALENDAR; granting continues into the held delete.
// holds only READ_CALENDAR, and granting continues into the held delete. val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) pendingWrite?.invoke()
pendingWrite = null
}
val requireWrite: (() -> Unit) -> Unit = { action ->
val granted = context.hasPermission(Manifest.permission.WRITE_CALENDAR)
if (granted) {
action()
} else {
pendingWrite = action
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
// Back leaves selection mode before it leaves the screen, and without the // Back leaves selection mode before it leaves the screen, and without the
// predictive-back scale-out — that would read as search closing and snapping // predictive-back scale-out — that would read as search closing and snapping
@@ -193,7 +172,7 @@ fun SearchScreen(
onBack = onBack, onBack = onBack,
onCloseSelection = viewModel::clearSelection, onCloseSelection = viewModel::clearSelection,
onSelectAll = viewModel::selectAll, onSelectAll = viewModel::selectAll,
onDelete = { requireWrite { showDeleteDialog = true } }, onDelete = onDeleteClick,
) )
}, },
) { padding -> ) { padding ->
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventSearch import de.jeanlucmakiola.calendula.domain.EventSearch
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.ui.common.deleteEvent
import de.jeanlucmakiola.calendula.domain.SearchResults import de.jeanlucmakiola.calendula.domain.SearchResults
import de.jeanlucmakiola.floret.android.stateWhileSubscribed import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher import de.jeanlucmakiola.floret.di.IoDispatcher
@@ -196,15 +197,11 @@ class SearchViewModel @Inject constructor(
} }
private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) { private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) {
val begin = event.start.toEpochMilliseconds() repository.deleteEvent(
when { eventId = event.eventId,
!event.isRecurring -> repository.deleteEvent(event.eventId) beginMillis = event.start.toEpochMilliseconds(),
scope == RecurringWriteScope.ThisEvent -> scope = if (event.isRecurring) scope else RecurringWriteScope.AllEvents,
repository.deleteOccurrence(event.eventId, begin) )
scope == RecurringWriteScope.ThisAndFollowing ->
repository.deleteEventFromOccurrence(event.eventId, begin)
else -> repository.deleteEvent(event.eventId)
}
} }
private suspend fun readOnlyCalendarIds(): Set<Long> = private suspend fun readOnlyCalendarIds(): Set<Long> =
@@ -7,8 +7,8 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.prefs.ThemeMode import de.jeanlucmakiola.floret.prefs.ThemeMode
/** /**
* A synchronous copy of the theme choice, for the window backdrop drawn before * A synchronous copy of the theme and dynamic-colour choices, for the window
* the first Compose frame. The real setting lives in DataStore, which only * 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 * answers asynchronously — too late for onCreate, so a Light, Dark or Black
* override flashed the night-qualifier backdrop on a cold start or a * override flashed the night-qualifier backdrop on a cold start or a
* recreation (#181). MainActivity mirrors every stored value here and reads it * 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 FILE = "window_theme"
private const val KEY_THEME_MODE = "theme_mode" 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. */ /** The last mirrored theme choice, or null before the first mirror. */
fun read(context: Context): ThemeMode? = fun read(context: Context): ThemeMode? =
@@ -25,10 +26,23 @@ internal object WindowThemeCache {
.getString(KEY_THEME_MODE, null) .getString(KEY_THEME_MODE, null)
?.let { name -> ThemeMode.entries.firstOrNull { it.name == name } } ?.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) val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
if (prefs.getString(KEY_THEME_MODE, null) == mode.name) return return if (prefs.contains(KEY_DYNAMIC_COLOR)) prefs.getBoolean(KEY_DYNAMIC_COLOR, true) else null
prefs.edit { putString(KEY_THEME_MODE, mode.name) } }
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.draftStartMin
import de.jeanlucmakiola.calendula.ui.common.isDraft import de.jeanlucmakiola.calendula.ui.common.isDraft
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface 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.OutOfViewEdges
import de.jeanlucmakiola.calendula.ui.common.wallClockInstant import de.jeanlucmakiola.calendula.ui.common.wallClockInstant
import de.jeanlucmakiola.calendula.ui.common.PickUp import de.jeanlucmakiola.calendula.ui.common.PickUp
@@ -638,10 +638,15 @@ private fun WeekContent(
delay(SELECTION_ORPHAN_GRACE_MILLIS) delay(SELECTION_ORPHAN_GRACE_MILLIS)
dragController.deselect() 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 -> val openEvent: (EventInstance) -> Unit = { event ->
if (event.isDraft) { if (event.isDraft) {
requireWrite { createDraft(event) } pendingDraft = event
createPendingDraft()
} else { } else {
dragController.deselect() dragController.deselect()
onEventClick(event) onEventClick(event)
@@ -19,6 +19,12 @@ class HtmlTextTest {
assertThat(looksLikeHtml("See <a href=\"https://x.y\">here</a>")).isTrue() assertThat(looksLikeHtml("See <a href=\"https://x.y\">here</a>")).isTrue()
} }
@Test
fun `angle-bracketed links and addresses stay text`() {
val html = "Join<br>here <https://teams.microsoft.com/l/x> or <a@b.c>"
assertThat(parseHtml(html).text).isEqualTo("Join\nhere <https://teams.microsoft.com/l/x> or <a@b.c>")
}
@Test @Test
fun `plain text passes through untouched`() { fun `plain text passes through untouched`() {
val text = " Agenda:\n\n\n- one \n- two" val text = " Agenda:\n\n\n- one \n- two"
@@ -18,6 +18,14 @@ class IcsRecurrenceTest {
assertThat(sanitizeRrule("RRULE:FREQ=DAILY").rule).isEqualTo("FREQ=DAILY") 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 @Test
fun `an empty list part is dropped`() { fun `an empty list part is dropped`() {
val bareByDay = sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=") val bareByDay = sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=")
@@ -5,6 +5,7 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.MainDispatcherExtension import de.jeanlucmakiola.calendula.MainDispatcherExtension
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.testDetail import de.jeanlucmakiola.calendula.testDetail
import de.jeanlucmakiola.calendula.testEvent import de.jeanlucmakiola.calendula.testEvent
@@ -56,7 +57,7 @@ class EventDetailViewModelTest {
settings = testSettings(tempDir, CoroutineScope(dispatcher)), settings = testSettings(tempDir, CoroutineScope(dispatcher)),
) )
// Only `shareUri()` touches the exporter, and nothing here shares. // Only `shareUri()` touches the exporter, and nothing here shares.
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher) return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher, FakeZoneSource())
} }
private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply { private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply {