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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user