From 5665cb8c95bfd9c915fd4b6308ac3b9700d16ea9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 19 Oct 2026 19:38:37 +0200 Subject: [PATCH] Fix issues from the 2.23.0 branch review (#428) 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 Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/428 --- app/src/main/AndroidManifest.xml | 2 +- .../jeanlucmakiola/calendula/MainActivity.kt | 66 ++++++-- .../data/calendar/CalendarDataSource.kt | 9 +- .../calendula/data/di/ApplicationScope.kt | 8 + .../calendula/data/di/DataModule.kt | 8 + .../calendula/data/time/TodaySource.kt | 6 + .../calendula/data/time/ZoneSource.kt | 8 +- .../calendula/domain/HtmlText.kt | 9 ++ .../calendula/domain/ics/IcsRecurrence.kt | 8 +- .../calendula/ui/CalendarHost.kt | 142 +++++++++++------- .../calendula/ui/ExternalRequests.kt | 3 + .../calendula/ui/agenda/AgendaScreen.kt | 7 +- .../calendula/ui/agenda/AgendaViewModel.kt | 13 +- .../calendula/ui/common/CalendarFabColumn.kt | 7 +- .../calendula/ui/common/EventActions.kt | 36 ++--- .../ui/common/EventActionsViewModel.kt | 5 +- .../calendula/ui/common/EventSelectionHost.kt | 44 +++--- .../calendula/ui/common/HourGutter.kt | 6 +- .../calendula/ui/common/RememberHeld.kt | 22 +++ .../calendula/ui/detail/EventDetailScreen.kt | 11 +- .../ui/detail/EventDetailViewModel.kt | 5 +- .../calendula/ui/edit/EventEditScreen.kt | 28 ++-- .../calendula/ui/edit/EventEditViewModel.kt | 3 + .../calendula/ui/imports/ImportScreen.kt | 4 +- .../calendula/ui/search/SearchScreen.kt | 29 +--- .../calendula/ui/search/SearchViewModel.kt | 15 +- .../calendula/ui/theme/WindowTheme.kt | 24 ++- .../calendula/ui/week/WeekScreen.kt | 11 +- .../calendula/domain/HtmlTextTest.kt | 6 + .../calendula/domain/ics/IcsRecurrenceTest.kt | 8 + .../ui/detail/EventDetailViewModelTest.kt | 3 +- 31 files changed, 352 insertions(+), 204 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/di/ApplicationScope.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RememberHeld.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e1f6110..b7cf5c5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -152,7 +152,7 @@ + event (MainActivity.sharedTextRequestOrNull, issue #306). --> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index d0d4fe5..e795433 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -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,7 +172,11 @@ 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() - 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 // override — re-apply from the resolved theme so the bar icons follow // the app's light/dark choice. @@ -199,8 +214,8 @@ class MainActivity : AppCompatActivity() { } CalendulaTheme( darkTheme = darkTheme, - dynamicColor = settings.dynamicColor, - pureBlack = settings.themeMode.isPureBlack, + dynamicColor = shownAppearance.dynamicColor ?: settings.dynamicColor, + pureBlack = themeMode.isPureBlack, typography = typography, ) { Box(modifier = Modifier.fillMaxSize()) { @@ -273,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 } @@ -281,6 +297,13 @@ class MainActivity : AppCompatActivity() { insertFormOpen = true } 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? { val uri = when (action) { Intent.ACTION_VIEW -> data - // Plain text is a create (#306), never a file to import. - Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java) - .takeUnless { type == SHARED_TEXT_TYPE } + // 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; @@ -357,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(), @@ -378,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 @@ -504,6 +545,8 @@ class MainActivity : AppCompatActivity() { // ACTION_VIEW on content://com.android.calendar/time/. 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" @@ -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?) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index adcfaeb..7d2101b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -725,7 +725,7 @@ class AndroidCalendarDataSource @Inject constructor( ), null, )?.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() } ?: emptyList() @@ -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) + .copy(calendarId = targetCalendarId, colorKey = null) .toDetachedOccurrence() detached += insertEvent(form, allDayReminderTimeMinutes) } @@ -1691,11 +1691,6 @@ class AndroidCalendarDataSource @Inject constructor( null, )?.use { c -> c.mapRows { toReminder() } } ?: emptyList() - /** Iterate every row and map; skips nothing. */ - private inline fun Cursor.mapAll(mapper: (Cursor) -> T): List = buildList { - while (moveToNext()) add(mapper(this@mapAll)) - } - /** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */ private fun mintUid(): String = "${UUID.randomUUID()}@calendula" diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/ApplicationScope.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/ApplicationScope.kt new file mode 100644 index 0000000..b79834c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/ApplicationScope.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt index bc72c81..3d5e580 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt @@ -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 by preferencesDataStore( @@ -85,4 +88,9 @@ object DataProvideModule { @Singleton fun provideDataStore(@ApplicationContext context: Context): DataStore = context.calendulaDataStore + + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/time/TodaySource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/time/TodaySource.kt index 83f03c3..bcbdb41 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/time/TodaySource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/time/TodaySource.kt @@ -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 = 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/time/ZoneSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/time/ZoneSource.kt index e4ca03f..cc9589c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/time/ZoneSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/time/ZoneSource.kt @@ -3,10 +3,14 @@ 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.flow.Flow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn import kotlinx.datetime.TimeZone import javax.inject.Inject @@ -23,14 +27,16 @@ interface ZoneSource { 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( @ApplicationContext private val context: Context, + @ApplicationScope scope: CoroutineScope, ) : ZoneSource { override val zone: Flow = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED) .map { current() } .distinctUntilChanged() + .shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1) override fun current(): TimeZone = TimeZone.currentSystemDefault() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/HtmlText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/HtmlText.kt index 87a44ce..e168c4e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/HtmlText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/HtmlText.kt @@ -131,6 +131,10 @@ private class HtmlRenderer(private val html: String) { text("<") return lt + 1 } + if (!closing && isAutolink(html.substring(nameStart, end))) { + text(html.substring(lt, end + 1)) + return end + 1 + } var nameEnd = nameStart while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++ val name = html.substring(nameStart, nameEnd).lowercase() @@ -149,6 +153,11 @@ private class HtmlRenderer(private val html: String) { return end + 1 } + /** An angle-bracketed URL or address (``, ``), 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. */ private fun tagEnd(lt: Int): Int { var quote: Char? = null diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt index 500f1f1..e8ccdae 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt @@ -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 @@ -84,8 +86,8 @@ fun sanitizeRrule(raw: String?): SanitizedRrule { // A stray separator ("FREQ=DAILY;") normalises away; nothing was lost. // 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(raw.orEmpty().trim())) { - val key = part.key?.trim().orEmpty() + for (part in rruleParts(rule)) { + val key = part.key?.trim()?.uppercase().orEmpty() val value = part.value.trim() val kept = when { key.isEmpty() || value.isEmpty() -> null diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 7894543..0734908 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -7,6 +7,7 @@ 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 @@ -34,6 +35,7 @@ import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.platform.LocalContext 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.ViewScreen 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.rememberFabSelection 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.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars @@ -243,20 +245,29 @@ 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(null) } - var importForm by remember { mutableStateOf(null) } + var importUri by rememberSaveable { mutableStateOf(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( + save = { it != null }, + restore = { open -> if (open) importViewModel.currentForm() else null }, + ), + ) { mutableStateOf(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?>(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) { importFormSource = source importForm = form @@ -280,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 @@ -291,9 +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) } + // 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++ @@ -307,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 { @@ -318,16 +344,22 @@ 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 + if (!keepCreate) createRequest = null importUri = null importForm = null - importFromRequest = false + if (importFromRequest) { + importFromRequest = false + requests.onExternalFormClosed() + } pendingInsert = null importViewModel.reset() } @@ -340,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 } @@ -361,7 +396,6 @@ fun CalendarHost( // 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. dismissCoveringOverlays() - createRequest = null focus.navigateTo(LocalDate.parse(req.dateIso)) // No widget source (an external date tap) roots over the default // 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 // detail over it (same key shape as a tapped event / reminder). dismissCoveringOverlays() - createRequest = null viewStack = viewBaseStack(defaultView, req.source) detailKey = longArrayOf(req.eventId, req.beginMillis, req.endMillis) requests.nav = null @@ -383,7 +416,6 @@ fun CalendarHost( // the stack on the target (null → the default home) over the // default home — so backing out returns to the default, then exits. dismissCoveringOverlays() - createRequest = null focus.navigateTo(currentDate()) viewStack = viewBaseStack(defaultView, req.view ?: defaultView) requests.nav = null @@ -393,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 @@ -414,9 +447,22 @@ fun CalendarHost( // tap time, so a tap before they've loaded still routes right. val tapScope = rememberCoroutineScope() val tapContext = LocalContext.current - val requireWriteOr = rememberWriteGateOr() // A refused write upgrade is asked once; later taps just open the details. 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(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 -> tapScope.launch { val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId) @@ -424,9 +470,9 @@ fun CalendarHost( !toEditor -> onOpenDetail(event) tapContext.canWriteCalendar() -> onEditEvent(event) tapWriteRefused -> onOpenDetail(event) - else -> requireWriteOr({ onEditEvent(event) }) { - tapWriteRefused = true - onOpenDetail(event) + else -> { + tapAwaitingWrite = event.occurrenceKey() + editAwaitingTap() } } } @@ -674,24 +720,26 @@ 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 -> - EventEditScreen( - initialDateIso = create.dateIso, - initialStartMinutes = create.startMinutes, - initialDraft = create.draftMillis, - onClose = { createRequest = null }, - onSaved = { createRequest = null }, - onManageCalendars = { showCalendars = true }, - visible = createRequest != null, - viewModel = hiltViewModel(key = "create"), - ) + key(create) { + EventEditScreen( + initialDateIso = create.dateIso, + initialStartMinutes = create.startMinutes, + initialDraft = create.draftMillis, + onClose = { createRequest = null }, + onSaved = { createRequest = null }, + onManageCalendars = { showCalendars = true }, + visible = createRequest != null, + viewModel = createViewModel, + ) + } } } // 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, @@ -712,7 +760,7 @@ fun CalendarHost( if (detailKey == null) detailKey = key }, visible = editKey != null, - viewModel = hiltViewModel(key = "edit"), + viewModel = editViewModel, ) } } @@ -830,33 +878,25 @@ private val viewStackSaver = listSaver, 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, ) } -/** - * [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 rememberHeld(live: T?): T? { - val held = remember { HeldValue() } - if (live != null) held.value = live - return live ?: held.value -} - -private class HeldValue { - var value: T? = null -} - /** The detail and edit overlays' key for one occurrence: eventId, begin, end. */ private fun EventInstance.occurrenceKey(): LongArray = longArrayOf(eventId, start.toEpochMilliseconds(), end.toEpochMilliseconds()) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/ExternalRequests.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/ExternalRequests.kt index 6402696..8993e10 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/ExternalRequests.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/ExternalRequests.kt @@ -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(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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 1972980..1b2871e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -393,11 +393,8 @@ private fun AgendaList( val itemDates = agendaItemDates(days) val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0) SideEffect { anchorIndex.intValue = anchorRow } - // A new anchor opens the list on its row from the first frame, with past - // days above. Keyed on the anchor the days arrived with, not the one just - // 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. + // A new anchor opens on its row from the first frame; keyed on the anchor + // the days arrived with, not the one requested. The first run may restore. val pendingRestore = remember { arrayOf(initialTopDate) } DisposableEffect(anchor) { val target = pendingRestore[0] diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index 06ddf5d..3832eaf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -163,15 +163,17 @@ class AgendaViewModel @Inject constructor( } /** - * Extend the window [PAST_CHUNK_DAYS] further back (#367). Ignored while the - * previous extension is still loading, so a scroll that keeps asking only - * moves one chunk at a time. + * Extend the window further back (#367), doubling what's loaded (at least + * [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 + 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) { @@ -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 = dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt index 63e576f..18a8fa6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt @@ -26,8 +26,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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. - val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) } - if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar + val heldBar = rememberHeld(selection?.bar) // Held at the FAB's height so the taller bar can't shift the FAB. Row( modifier = Modifier.height(FabSize), @@ -101,7 +98,7 @@ fun CalendarFabColumn( shrinkTowards = Alignment.End, ) + fadeOut(), ) { - heldBar.value?.invoke() + heldBar?.invoke() } val label = stringResource( when { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt index d4d461b..43de3e1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt @@ -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,33 +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 { - val gate = rememberWriteGateOr() - return { action -> gate(action) {} } -} - -/** [rememberWriteGate], running the fallback instead when the user refuses. */ -@Composable -fun rememberWriteGateOr(): (action: () -> Unit, fallback: () -> Unit) -> Unit { +fun rememberWriteAction(onDenied: () -> Unit = {}, action: () -> Unit): () -> Unit { val context = LocalContext.current - var pending by remember { mutableStateOf Unit, () -> Unit>?>(null) } + val currentAction by rememberUpdatedState(action) + val currentDenied by rememberUpdatedState(onDenied) val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { granted -> - pending?.let { (action, fallback) -> if (granted) action() else fallback() } - pending = null + if (granted) currentAction() else currentDenied() } - return { action, fallback -> - if (context.canWriteCalendar()) { - action() - } else { - pending = action to fallback - launcher.launch(Manifest.permission.WRITE_CALENDAR) + return remember(context, launcher) { + { + if (context.canWriteCalendar()) { + currentAction() + } else { + launcher.launch(Manifest.permission.WRITE_CALENDAR) + } } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt index e2da8e5..333c25d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActionsViewModel.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException 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.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm @@ -26,7 +27,6 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.datetime.TimeZone import kotlin.coroutines.cancellation.CancellationException import javax.inject.Inject @@ -42,6 +42,7 @@ class EventActionsViewModel @Inject constructor( private val repository: CalendarRepository, private val icsExporter: IcsExporter, @IoDispatcher private val io: CoroutineDispatcher, + private val zoneSource: ZoneSource, ) : ViewModel() { /** 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? { val detail = load(event) ?: return null 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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt index 88fb727..d2a1a6b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventSelectionHost.kt @@ -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, ) }, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt index 5b94dea..c7f0aef 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt @@ -20,7 +20,6 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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. - val badgeMin = remember { mutableStateOf(null) } - dragStartMin?.let { badgeMin.value = it } + val badgeMin = rememberHeld(dragStartMin) val badgeIn by animateFloatAsState( targetValue = if (dragStartMin != null) 1f else 0f, animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(), 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) Box( modifier = Modifier diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RememberHeld.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RememberHeld.kt new file mode 100644 index 0000000..a401271 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RememberHeld.kt @@ -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 rememberHeld(live: T?): T? { + val held = remember { HeldValue() } + if (live != null) held.value = live + return live ?: held.value +} + +private class HeldValue { + var value: T? = null +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index a91eefd..fc723ac 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index fdf3247..066ad4a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException 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.FailureReason import de.jeanlucmakiola.calendula.domain.RecurringWriteScope @@ -31,7 +32,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch -import kotlinx.datetime.TimeZone import kotlin.coroutines.cancellation.CancellationException import javax.inject.Inject @@ -45,6 +45,7 @@ class EventDetailViewModel @Inject constructor( private val repository: CalendarRepository, private val icsExporter: IcsExporter, @IoDispatcher private val io: CoroutineDispatcher, + private val zoneSource: ZoneSource, ) : ViewModel() { private val _target = MutableStateFlow(null) @@ -141,7 +142,7 @@ class EventDetailViewModel @Inject constructor( */ fun duplicateForm(): EventForm? { 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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 914f0ce..d920b15 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -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 @@ -168,6 +168,7 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.floret.reminders.ReminderUnit import de.jeanlucmakiola.floret.locale.currentLocale 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.withRepeatedHourZone import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel @@ -288,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( @@ -313,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) @@ -386,7 +385,7 @@ fun EventEditScreen( canDuplicate = onDuplicate != null, onDuplicate = onDuplicateClick, onShare = onShareClick, - onDelete = { requireWrite { showDeleteDialog = true } }, + onDelete = onDeleteClick, ) } Button( @@ -1128,13 +1127,7 @@ private fun WhenCard( color = MaterialTheme.colorScheme.error, ) } - // The rows above edit a pinned event in *its own* zone, which is the - // 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. + // A pinned event's local equivalent, shown only when pinned elsewhere. val pinnedAway = remember(form.timezone, form.isAllDay, 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 { val use24Hour = LocalUse24HourFormat.current return remember(start, end, zone, locale, use24Hour) { - val timeFormat = timeOfDayFormatter(use24Hour, locale) fun format(at: Instant) = withRepeatedHourZone( - timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()), + formatTimeOfDay(at, zone, use24Hour, locale), at, zone, locale, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index aee0e8d..8bfddab 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -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++ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt index 8205465..9fd95c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 3caa4c0..58f39a5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -1,9 +1,6 @@ package de.jeanlucmakiola.calendula.ui.search -import android.Manifest 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.animateColorAsState 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.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.pluralStringResource 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.SearchMonth import de.jeanlucmakiola.calendula.domain.isDeclined -import de.jeanlucmakiola.floret.android.hasPermission 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.rememberWriteAction import de.jeanlucmakiola.calendula.ui.common.declinedTitle import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField @@ -133,7 +129,6 @@ fun SearchScreen( val deleteState by viewModel.deleteState.collectAsStateWithLifecycle() val focusRequester = remember { FocusRequester() } val keyboard = LocalSoftwareKeyboardController.current - val context = LocalContext.current val inSelection = selection.isNotEmpty() var showDeleteDialog by remember { mutableStateOf(false) } @@ -155,24 +150,8 @@ fun SearchScreen( keyboard?.show() } - // Same in-place WRITE_CALENDAR upgrade the detail screen does: a v1.0 install - // holds only READ_CALENDAR, and granting continues into the held delete. - 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) - } - } + // A v1.0 install holds only READ_CALENDAR; granting continues into the held delete. + 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 @@ -193,7 +172,7 @@ fun SearchScreen( onBack = onBack, onCloseSelection = viewModel::clearSelection, onSelectAll = viewModel::selectAll, - onDelete = { requireWrite { showDeleteDialog = true } }, + onDelete = onDeleteClick, ) }, ) { padding -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt index 76d697f..858e3c0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt @@ -7,6 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventSearch import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import de.jeanlucmakiola.calendula.ui.common.deleteEvent import de.jeanlucmakiola.calendula.domain.SearchResults import de.jeanlucmakiola.floret.android.stateWhileSubscribed import de.jeanlucmakiola.floret.di.IoDispatcher @@ -196,15 +197,11 @@ class SearchViewModel @Inject constructor( } private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) { - val begin = event.start.toEpochMilliseconds() - when { - !event.isRecurring -> repository.deleteEvent(event.eventId) - scope == RecurringWriteScope.ThisEvent -> - repository.deleteOccurrence(event.eventId, begin) - scope == RecurringWriteScope.ThisAndFollowing -> - repository.deleteEventFromOccurrence(event.eventId, begin) - else -> repository.deleteEvent(event.eventId) - } + repository.deleteEvent( + eventId = event.eventId, + beginMillis = event.start.toEpochMilliseconds(), + scope = if (event.isRecurring) scope else RecurringWriteScope.AllEvents, + ) } private suspend fun readOnlyCalendarIds(): Set = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/WindowTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/WindowTheme.kt index f1ed764..31edbb4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/WindowTheme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/WindowTheme.kt @@ -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) + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index e19045b..43a668c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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(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) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/HtmlTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/HtmlTextTest.kt index 8c472d3..1bc0987 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/HtmlTextTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/HtmlTextTest.kt @@ -19,6 +19,12 @@ class HtmlTextTest { assertThat(looksLikeHtml("See here")).isTrue() } + @Test + fun `angle-bracketed links and addresses stay text`() { + val html = "Join
here or " + assertThat(parseHtml(html).text).isEqualTo("Join\nhere or ") + } + @Test fun `plain text passes through untouched`() { val text = " Agenda:\n\n\n- one \n- two" diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt index 6284708..42be76f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt @@ -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=") diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt index 302e71a..4513eca 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt @@ -5,6 +5,7 @@ import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.MainDispatcherExtension import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource import de.jeanlucmakiola.calendula.data.ics.IcsExporter +import de.jeanlucmakiola.calendula.data.time.FakeZoneSource import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.testDetail import de.jeanlucmakiola.calendula.testEvent @@ -56,7 +57,7 @@ class EventDetailViewModelTest { settings = testSettings(tempDir, CoroutineScope(dispatcher)), ) // 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 {