Fix issues from the 2.23.0 branch review

Drop colour keys on moved exceptions, reset edit/create forms when an
external request takes over, keep the tap-to-edit permission prompt across
rotation, keep autolinks in HTML descriptions, share one zone receiver and
tidy the duplicated helpers.
This commit is contained in:
2026-10-16 15:14:57 +02:00
parent 17b3753ad3
commit db56c6025a
20 changed files with 154 additions and 127 deletions
+1 -1
View File
@@ -152,7 +152,7 @@
</intent-filter>
<!-- Text shared or selected in another app opens a prefilled new
event (MainActivity.sharedTextExtrasOrNull, issue #306). -->
event (MainActivity.sharedTextRequestOrNull, issue #306). -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -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;
@@ -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 <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
while (moveToNext()) add(mapper(this@mapAll))
}
/** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
private fun mintUid(): String = "${UUID.randomUUID()}@calendula"
@@ -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<TimeZone> = 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()
}
@@ -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 (`<https://…>`, `<a@b.c>`), kept as text. */
private fun isAutolink(inner: String): Boolean =
LINK_SCHEMES.any { inner.startsWith(it, ignoreCase = true) } ||
('@' in inner && inner.none { it.isWhitespace() || it == '=' })
/** The `>` closing the tag at [lt], skipping any inside quoted attributes. */
private fun tagEnd(lt: Int): Int {
var quote: Char? = null
@@ -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 {
@@ -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<Pair<EventForm, ImportSource>?>(null) }
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
val createViewModel: EventEditViewModel = hiltViewModel(key = "create")
val editViewModel: EventEditViewModel = hiltViewModel(key = "edit")
fun openImportForm(form: EventForm, source: ImportSource, fromRequest: Boolean = false) {
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<LongArray?>(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 <T : Any> rememberHeld(live: T?): T? {
val held = remember { HeldValue<T>() }
if (live != null) held.value = live
return live ?: held.value
}
private class HeldValue<T : Any> {
var value: T? = null
}
/** The detail and edit overlays' key for one occurrence: eventId, begin, end. */
private fun EventInstance.occurrenceKey(): LongArray =
longArrayOf(eventId, start.toEpochMilliseconds(), end.toEpochMilliseconds())
@@ -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]
@@ -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) {
@@ -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 {
@@ -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<Pair<() -> 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)
}
}
@@ -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. */
@@ -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<Int?>(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
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
/**
* [live], or after it goes null the last value it had, so an overlay keeps
* drawing what it showed through its slide-out instead of flashing empty. Never
* a placeholder: until [live] is first set this is null, so a destination never
* loads a bogus key on its first frame. Not saved — after a recreation the
* saveable [live] is the value again.
*/
@Composable
fun <T : Any> rememberHeld(live: T?): T? {
val held = remember { HeldValue<T>() }
if (live != null) held.value = live
return live ?: held.value
}
private class HeldValue<T : Any> {
var value: T? = null
}
@@ -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<Target?>(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 {
@@ -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,
@@ -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
@@ -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<Long> =
@@ -19,6 +19,12 @@ class HtmlTextTest {
assertThat(looksLikeHtml("See <a href=\"https://x.y\">here</a>")).isTrue()
}
@Test
fun `angle-bracketed links and addresses stay text`() {
val html = "Join<br>here <https://teams.microsoft.com/l/x> or <a@b.c>"
assertThat(parseHtml(html).text).isEqualTo("Join\nhere <https://teams.microsoft.com/l/x> or <a@b.c>")
}
@Test
fun `plain text passes through untouched`() {
val text = " Agenda:\n\n\n- one \n- two"
@@ -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 {