diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 808e639..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,10 +172,10 @@ class MainActivity : AppCompatActivity() { // and the Settings screen, so a theme change applies app-wide at once. val settingsViewModel: SettingsViewModel = hiltViewModel() val settings by settingsViewModel.state.collectAsStateWithLifecycle() - // Read on its own, seeded from the cache, so the first frame doesn't - // draw the bars for SYSTEM before the stored choice arrives. - val themeMode by settingsPrefs.themeMode - .collectAsStateWithLifecycle(initialValue = cachedTheme ?: ThemeMode.SYSTEM) + // Seeded from the cache, so the first frame doesn't draw the default + // theme and palette before the stored choices arrive. + val shownAppearance by appearance.collectAsStateWithLifecycle() + val themeMode = shownAppearance.themeMode val darkTheme = themeMode.isDark(isSystemInDarkTheme()) // onCreate can only see the night resource qualifier, not the in-app // override — re-apply from the resolved theme so the bar icons follow @@ -203,7 +214,7 @@ class MainActivity : AppCompatActivity() { } CalendulaTheme( darkTheme = darkTheme, - dynamicColor = settings.dynamicColor, + dynamicColor = shownAppearance.dynamicColor ?: settings.dynamicColor, pureBlack = themeMode.isPureBlack, typography = typography, ) { @@ -277,6 +288,7 @@ class MainActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + requests.replayed = false (intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it } intent.navRequestOrNull()?.let { requests.nav = it } intent.importUriOrNull()?.let { requests.importUri = it } @@ -312,10 +324,10 @@ class MainActivity : AppCompatActivity() { private fun Intent.importUriOrNull(): Uri? { val uri = when (action) { Intent.ACTION_VIEW -> data - // Shared text is a create (#306); a text/plain file without any is - // still imported, e.g. an .ics attachment labelled text/plain. - Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java) - .takeUnless { type == SHARED_TEXT_TYPE && sharedTextRequestOrNull() != null } + // Shared text is a create (#306); a text/plain file is imported only + // when it is calendar data, e.g. an .ics attachment labelled text/plain. + Intent.ACTION_SEND -> sharedStreamOrNull() + ?.takeUnless { type == SHARED_TEXT_TYPE && !it.isCalendarFile() } else -> null } ?: return null // The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content; @@ -369,7 +381,7 @@ class MainActivity : AppCompatActivity() { */ private fun Intent.sharedTextRequestOrNull(): InsertRequest? { val shared = when (action) { - Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE) { + Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE && sharedStreamOrNull()?.isCalendarFile() != true) { sharedTextOrNull( subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(), text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(), @@ -390,6 +402,23 @@ class MainActivity : AppCompatActivity() { return InsertRequest(form, ImportSource.InsertOpenEnded) } + private fun Intent.sharedStreamOrNull(): Uri? = + IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java) + + /** Whether a shared file is iCalendar/vCalendar data, by its resolved type or its name. */ + private fun Uri.isCalendarFile(): Boolean { + val resolvedType = runCatching { contentResolver.getType(this) }.getOrNull() + if (resolvedType in CALENDAR_TYPES) return true + val name = when (scheme) { + "content" -> runCatching { + contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { c -> if (c.moveToFirst()) c.getString(0) else null } + }.getOrNull() + else -> lastPathSegment + } ?: return false + return CALENDAR_EXTENSIONS.any { name.endsWith(it, ignoreCase = true) } + } + /** A Long extra's value, or null when the extra is absent. */ private fun Intent.longExtraOrNull(key: String): Long? = if (hasExtra(key)) getLongExtra(key, 0L) else null @@ -516,6 +545,8 @@ class MainActivity : AppCompatActivity() { // ACTION_VIEW on content://com.android.calendar/time/. private const val CALENDAR_PROVIDER_HOST = "com.android.calendar" private const val SHARED_TEXT_TYPE = "text/plain" + private val CALENDAR_TYPES = setOf("text/calendar", "text/x-vcalendar", "application/ics") + private val CALENDAR_EXTENSIONS = listOf(".ics", ".vcs") private const val KEY_INSERT_FORM_OPEN = "insert_form_open" private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert" @@ -608,3 +639,6 @@ class MainActivity : AppCompatActivity() { } } } + +/** The theme choices the window needs before the settings screen's state loads. */ +private data class Appearance(val themeMode: ThemeMode, val dynamicColor: Boolean?) 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 24bda97..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 @@ -1116,7 +1116,7 @@ class AndroidCalendarDataSource @Inject constructor( ?: throw WriteFailedException("read modified occurrence id=${ex.exceptionEventId}") val endMillis = ex.dtEndMillis ?: (ex.dtStartMillis + parseRfc2445DurationMillis(ex.duration)) val form = detail.toEditForm(ex.dtStartMillis, endMillis, zone) - .copy(calendarId = targetCalendarId, colorKey = null, color = null) + .copy(calendarId = targetCalendarId, colorKey = null) .toDetachedOccurrence() detached += insertEvent(form, allDayReminderTimeMinutes) } @@ -1691,7 +1691,6 @@ class AndroidCalendarDataSource @Inject constructor( null, )?.use { c -> c.mapRows { toReminder() } } ?: emptyList() - /** Iterate every row and map; skips nothing. */ /** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */ private fun mintUid(): String = "${UUID.randomUUID()}@calendula" 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 3e20877..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,9 @@ package de.jeanlucmakiola.calendula.data.time import android.content.Context import android.content.Intent import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.calendula.data.di.ApplicationScope import de.jeanlucmakiola.floret.android.broadcastTicks import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged @@ -31,16 +30,13 @@ interface ZoneSource { /** Follows the system's zone broadcast, one receiver shared by every collector. */ class AndroidZoneSource @Inject constructor( @ApplicationContext private val context: Context, + @ApplicationScope scope: CoroutineScope, ) : ZoneSource { override val zone: Flow = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED) .map { current() } .distinctUntilChanged() - .shareIn( - CoroutineScope(SupervisorJob() + Dispatchers.Default), - SharingStarted.WhileSubscribed(replayExpirationMillis = 0), - replay = 1, - ) + .shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1) override fun current(): TimeZone = TimeZone.currentSystemDefault() } 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 02457ff..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 @@ -85,7 +87,7 @@ fun sanitizeRrule(raw: String?): SanitizedRrule { // A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part // being dropped, so it falls through and marks the rule repaired. for (part in rruleParts(rule)) { - val key = part.key?.trim().orEmpty() + val key = part.key?.trim()?.uppercase().orEmpty() val value = part.value.trim() val kept = when { key.isEmpty() || value.isEmpty() -> null 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 5e099ce..0734908 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -1,15 +1,13 @@ package de.jeanlucmakiola.calendula.ui -import android.Manifest import androidx.activity.compose.BackHandler import androidx.activity.compose.PredictiveBackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.SeekableTransitionState import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.fadeIn @@ -70,6 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar +import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars @@ -246,20 +245,27 @@ fun CalendarHost( // An opened/received .ics file. [ImportScreen] parses it and either opens // the prefilled create form (one event → [importForm]) or its own bulk // picker (many). A plain conditional overlay (no slide) — it's transient. - var importUri by remember { mutableStateOf(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) { @@ -285,7 +291,7 @@ fun CalendarHost( // flow — picker + summary — even for a single-event file, because the intent // is "restore a backup", not "add this one event". An externally opened .ics // keeps routing a single event straight into the prefilled create form. - var importForceMany by remember { mutableStateOf(false) } + var importForceMany by rememberSaveable { mutableStateOf(false) } // Where a restore came from, so closing the import puts it back: the import // overlays are declared under Backup & restore and the manager, so starting // one has to close them — without this, finishing a restore drops you on the @@ -296,17 +302,20 @@ fun CalendarHost( // what tells it a *re-import of the same file* is new work and not the run // it already finished; a rotation keeps the number and keeps the result. var importSession by rememberSaveable { mutableStateOf(0) } - // [importForm] doesn't survive a recreation; unless an intent replay is about - // to reopen it, drop the form the ViewModel still holds so the next open - // isn't swallowed by it. - LaunchedEffect(Unit) { - if (importForm == null && requests.insert == null && requests.importUri == null) { - importViewModel.reset() - } + // A recreation re-reads the launch intent; when the import it carries came + // back with the saved state, that replay is the same work, not a new one. + fun isReplayOfRestoredImport(): Boolean { + val replay = requests.replayed && (importUri != null || importForm != null) + requests.replayed = false + return replay } val requestedImportUri = requests.importUri LaunchedEffect(requestedImportUri) { if (requestedImportUri != null) { + if (isReplayOfRestoredImport()) { + requests.importUri = null + return@LaunchedEffect + } importUri = requestedImportUri importForceMany = false importSession++ @@ -320,6 +329,10 @@ fun CalendarHost( val requestedInsert = requests.insert LaunchedEffect(requestedInsert) { if (requestedInsert != null) { + if (isReplayOfRestoredImport()) { + requests.insert = null + return@LaunchedEffect + } if (importForm == null) { openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true) } else { @@ -331,14 +344,16 @@ fun CalendarHost( // Close every overlay that can sit over the calendar, so an externally // requested destination (a widget/shortcut/QS-tile launch) is revealed on - // top instead of underneath whatever the user had open. - fun dismissCoveringOverlays() { + // top instead of underneath whatever the user had open. The create form, + // declared under the edit form, can stay for an edit to open over it. + fun dismissCoveringOverlays(keepCreate: Boolean = false) { showSettings = false + showSearch = false showCalendars = false showBackup = false detailKey = null editKey = null - createRequest = null + if (!keepCreate) createRequest = null importUri = null importForm = null if (importFromRequest) { @@ -347,8 +362,6 @@ fun CalendarHost( } pendingInsert = null importViewModel.reset() - createViewModel.reset() - editViewModel.reset() } // An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or @@ -359,7 +372,10 @@ fun CalendarHost( val requestedEditKey = requests.editKey LaunchedEffect(requestedEditKey) { if (requestedEditKey != null) { - dismissCoveringOverlays() + // An open edit form stays on screen, so it starts over here rather + // than once it has slid away. + if (editKey != null) editViewModel.reset() + dismissCoveringOverlays(keepCreate = true) editKey = requestedEditKey requests.editKey = null } @@ -409,6 +425,7 @@ fun CalendarHost( // widget) must land on top of whatever is open — the form overlay // sits below Settings/calendars in the Box, so without this it // would open hidden underneath them. + if (createRequest != null) createViewModel.reset() dismissCoveringOverlays() openCreate(req.dateIso ?: currentDate().toString()) requests.nav = null @@ -434,17 +451,16 @@ fun CalendarHost( var tapWriteRefused by rememberSaveable { mutableStateOf(false) } // The tap waiting on the WRITE_CALENDAR prompt, saved so a rotation keeps it. var tapAwaitingWrite by rememberSaveable { mutableStateOf(null) } - val tapWriteLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { granted -> - tapAwaitingWrite?.let { key -> - if (granted) { - editKey = key - } else { + val editAwaitingTap = rememberWriteAction( + onDenied = { + tapAwaitingWrite?.let { key -> tapWriteRefused = true detailKey = key } - } + tapAwaitingWrite = null + }, + ) { + tapAwaitingWrite?.let { editKey = it } tapAwaitingWrite = null } val onEventClick: (EventInstance) -> Unit = { event -> @@ -456,7 +472,7 @@ fun CalendarHost( tapWriteRefused -> onOpenDetail(event) else -> { tapAwaitingWrite = event.occurrenceKey() - tapWriteLauncher.launch(Manifest.permission.WRITE_CALENDAR) + editAwaitingTap() } } } @@ -704,7 +720,7 @@ fun CalendarHost( // Event form (v1.2) — full-screen destination, slides over the calendar. val shownCreate = rememberHeld(createRequest) - SlideOverlay(visible = createRequest != null) { + SlideOverlay(visible = createRequest != null, onHidden = createViewModel::reset) { shownCreate?.let { create -> key(create) { EventEditScreen( @@ -723,7 +739,7 @@ fun CalendarHost( // Edit form (v1.3) — slides over the detail screen. val shownEditKey = rememberHeld(editKey) - SlideOverlay(visible = editKey != null) { + SlideOverlay(visible = editKey != null, onHidden = editViewModel::reset) { shownEditKey?.let { key -> EventEditScreen( initialDateIso = null, @@ -862,9 +878,19 @@ private val viewStackSaver = listSaver, 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, 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/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index 6f28d8c..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 @@ -164,14 +164,16 @@ class AgendaViewModel @Inject constructor( /** * Extend the window further back (#367), doubling what's loaded (at least - * [PAST_CHUNK_DAYS]) so a long scroll back re-queries only a few times. - * Ignored while the previous extension is still loading. + * [PAST_CHUNK_DAYS], at most [MAX_PAST_STEP_DAYS]) so a long scroll back + * re-queries only a few times. Ignored while the previous extension is + * still loading. */ fun loadEarlier() { val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return val window = _window.value if (loaded != window.start) return - _window.value = window.copy(pastDays = window.pastDays + maxOf(PAST_CHUNK_DAYS, window.pastDays)) + val step = window.pastDays.coerceIn(PAST_CHUNK_DAYS, MAX_PAST_STEP_DAYS) + _window.value = window.copy(pastDays = window.pastDays + step) } fun rememberTopDay(date: LocalDate) { @@ -233,6 +235,9 @@ class AgendaViewModel @Inject constructor( /** How many days each scroll back into the past loads (#367). */ internal const val PAST_CHUNK_DAYS = 30 +/** The most one scroll back adds, so an empty past grows the window by years, not exponentially. */ +internal const val MAX_PAST_STEP_DAYS = 365 + /** Inclusive instant range from the start of [anchor] through [days] days ahead. */ internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange = dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone) 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 b0e23a8..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,26 +101,28 @@ suspend fun CalendarRepository.tryDeleteEvent( } /** - * Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first - * where the install only holds read access (v1.0 installs). A grant continues - * straight into the action that asked for it. + * [action], which writes to the calendar, as a handler that asks for + * `WRITE_CALENDAR` first where the install only holds read access (v1.0 + * installs). A grant continues into [action], also when it returns after a + * rotation; a refusal runs [onDenied]. */ @Composable -fun rememberWriteGate(): (() -> Unit) -> Unit { +fun rememberWriteAction(onDenied: () -> Unit = {}, action: () -> Unit): () -> Unit { val context = LocalContext.current - var pending by remember { mutableStateOf<(() -> Unit)?>(null) } + val currentAction by rememberUpdatedState(action) + val currentDenied by rememberUpdatedState(onDenied) val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { granted -> - if (granted) pending?.invoke() - pending = null + if (granted) currentAction() else currentDenied() } - return { action -> - if (context.canWriteCalendar()) { - action() - } else { - pending = action - launcher.launch(Manifest.permission.WRITE_CALENDAR) + return remember(context, launcher) { + { + if (context.canWriteCalendar()) { + currentAction() + } else { + launcher.launch(Manifest.permission.WRITE_CALENDAR) + } } } } 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/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/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index e4f23ea..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 @@ -289,7 +289,6 @@ fun EventEditScreen( val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - val requireWrite = rememberWriteGate() // Save keeps its own launcher: its result calls straight into the ViewModel, // so a save waiting on the permission dialog survives a rotation. val savePermissionLauncher = rememberLauncherForActivityResult( @@ -314,14 +313,13 @@ fun EventEditScreen( } } // The copy opens over this form; a form with nothing to lose steps aside. - val onDuplicateClick: () -> Unit = { - requireWrite { - viewModel.duplicateForm()?.let { form -> - onDuplicate?.invoke(form) - if (!hasUnsavedChanges) close() - } + val onDuplicateClick = rememberWriteAction { + viewModel.duplicateForm()?.let { form -> + onDuplicate?.invoke(form) + if (!hasUnsavedChanges) close() } } + val onDeleteClick = rememberWriteAction { showDeleteDialog = true } val saveFailedMessage = stringResource(R.string.event_edit_save_failed) val writeDeniedMessage = stringResource(R.string.event_edit_write_denied) @@ -387,7 +385,7 @@ fun EventEditScreen( canDuplicate = onDuplicate != null, onDuplicate = onDuplicateClick, onShare = onShareClick, - onDelete = { requireWrite { showDeleteDialog = true } }, + onDelete = onDeleteClick, ) } Button( 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 47028d8..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 @@ -80,7 +80,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.predictiveBack -import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate +import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction import de.jeanlucmakiola.calendula.ui.common.declinedTitle import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField @@ -151,7 +151,7 @@ fun SearchScreen( } // A v1.0 install holds only READ_CALENDAR; granting continues into the held delete. - val requireWrite = rememberWriteGate() + val onDeleteClick = rememberWriteAction { showDeleteDialog = true } // Back leaves selection mode before it leaves the screen, and without the // predictive-back scale-out — that would read as search closing and snapping @@ -172,7 +172,7 @@ fun SearchScreen( onBack = onBack, onCloseSelection = viewModel::clearSelection, onSelectAll = viewModel::selectAll, - onDelete = { requireWrite { showDeleteDialog = true } }, + onDelete = onDeleteClick, ) }, ) { padding -> 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/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=")