diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e1f6110f..b7cf5c55 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 d0d4fe58..808e639d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -161,7 +161,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()) + // 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) + 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. @@ -200,7 +204,7 @@ class MainActivity : AppCompatActivity() { CalendulaTheme( darkTheme = darkTheme, dynamicColor = settings.dynamicColor, - pureBlack = settings.themeMode.isPureBlack, + pureBlack = themeMode.isPureBlack, typography = typography, ) { Box(modifier = Modifier.fillMaxSize()) { @@ -281,6 +285,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 +312,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. + // 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 } + .takeUnless { type == SHARED_TEXT_TYPE && sharedTextRequestOrNull() != null } else -> null } ?: return null // The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content; 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 adcfaeb9..24bda973 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, color = null) .toDetachedOccurrence() detached += insertEvent(form, allDayReminderTimeMinutes) } @@ -1692,10 +1692,6 @@ class AndroidCalendarDataSource @Inject constructor( )?.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/time/ZoneSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/time/ZoneSource.kt index e4ca03f5..3e208772 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 @@ -4,9 +4,14 @@ import android.content.Context import android.content.Intent import dagger.hilt.android.qualifiers.ApplicationContext 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 import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn import kotlinx.datetime.TimeZone import javax.inject.Inject @@ -23,7 +28,7 @@ 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, ) : ZoneSource { @@ -31,6 +36,11 @@ class AndroidZoneSource @Inject constructor( override val zone: Flow = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED) .map { current() } .distinctUntilChanged() + .shareIn( + CoroutineScope(SupervisorJob() + Dispatchers.Default), + 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 87a44ce6..e168c4ee 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 500f1f15..02457ff7 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 @@ -84,7 +84,7 @@ 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())) { + for (part in rruleParts(rule)) { val key = part.key?.trim().orEmpty() val value = part.value.trim() val kept = when { 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 7894543c..5e099ce9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -1,7 +1,10 @@ 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 @@ -34,6 +37,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 +70,6 @@ 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.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars @@ -257,6 +260,8 @@ fun CalendarHost( // 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 @@ -291,6 +296,14 @@ 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() + } + } val requestedImportUri = requests.importUri LaunchedEffect(requestedImportUri) { if (requestedImportUri != null) { @@ -325,11 +338,17 @@ fun CalendarHost( showBackup = false detailKey = null editKey = null + createRequest = null importUri = null importForm = null - importFromRequest = false + if (importFromRequest) { + importFromRequest = false + requests.onExternalFormClosed() + } pendingInsert = null importViewModel.reset() + createViewModel.reset() + editViewModel.reset() } // An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or @@ -361,7 +380,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 +390,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 +400,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 @@ -414,9 +430,23 @@ 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 tapWriteLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + tapAwaitingWrite?.let { key -> + if (granted) { + editKey = key + } else { + tapWriteRefused = true + detailKey = key + } + } + tapAwaitingWrite = null + } val onEventClick: (EventInstance) -> Unit = { event -> tapScope.launch { val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId) @@ -424,9 +454,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() + tapWriteLauncher.launch(Manifest.permission.WRITE_CALENDAR) } } } @@ -676,16 +706,18 @@ fun CalendarHost( val shownCreate = rememberHeld(createRequest) SlideOverlay(visible = createRequest != null) { 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, + ) + } } } @@ -712,7 +744,7 @@ fun CalendarHost( if (detailKey == null) detailKey = key }, visible = editKey != null, - viewModel = hiltViewModel(key = "edit"), + viewModel = editViewModel, ) } } @@ -839,24 +871,6 @@ private fun SlideOverlay(visible: Boolean, content: @Composable AnimatedVisibili ) } -/** - * [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/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 1972980d..1b2871e7 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 06ddf5d5..6f28d8c2 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,15 @@ 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]) 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) + _window.value = window.copy(pastDays = window.pastDays + maxOf(PAST_CHUNK_DAYS, window.pastDays)) } fun rememberTopDay(date: LocalDate) { 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 63e576fd..18a8fa64 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 d4d461b3..b0e23a88 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 @@ -106,26 +106,19 @@ suspend fun CalendarRepository.tryDeleteEvent( */ @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 { val context = LocalContext.current - var pending by remember { mutableStateOf Unit, () -> Unit>?>(null) } + var pending by remember { mutableStateOf<(() -> Unit)?>(null) } val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { granted -> - pending?.let { (action, fallback) -> if (granted) action() else fallback() } + if (granted) pending?.invoke() pending = null } - return { action, fallback -> + return { action -> if (context.canWriteCalendar()) { action() } else { - pending = action to fallback + pending = action 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 e2da8e52..333c25de 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/HourGutter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt index 5b94deac..c7f0aefe 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 00000000..a401271a --- /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/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index fdf32475..066ad4ad 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 914f0ce3..e4f23ea9 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 @@ -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 @@ -1128,13 +1129,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 +2416,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/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 3caa4c03..47028d8b 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.rememberWriteGate 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 requireWrite = rememberWriteGate() // Back leaves selection mode before it leaves the screen, and without the // predictive-back scale-out — that would read as search closing and snapping 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 76d697f8..858e3c05 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/test/java/de/jeanlucmakiola/calendula/domain/HtmlTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/HtmlTextTest.kt index 8c472d3e..1bc09871 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/ui/detail/EventDetailViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt index 302e71a9..4513eca5 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 {