fix: safe performance and crash-safety fixes
- TimeGridView: remember Instant.now() (keyed on the day) and the day-header
DateTimeFormatter instead of rebuilding both on every recomposition/frame —
fixes flicker in the past-event dimming and avoids repeated allocation.
- CalendarViewModel.refreshFromCache: short-circuit the non-group filter pass
when both hiddenKeys and banishedKeys are empty, mirroring the existing
group-mode short-circuit.
- CalendarViewModel.setCalendarHiddenSynced: wrap the server PATCH in
runCatching and surface failures via state.error instead of silently
dropping them (the local toggle would otherwise diverge from the server
unnoticed).
- CalendarViewModel.afterMutation: refetch only the already-cached range
(force-bypassing loadRange's cache check) instead of invalidateCache() +
initialLoad(), which reloaded the entire ±cacheMonths window and froze the
UI on every single event save.
- CalendarrRoot: replace calendarVm!! with a null-safe ?.let, falling back to
the splash screen instead of crashing if the VM isn't ready yet.
- GroupsScreen: replace existing!!.icon!! / g.icon!! double force-unwraps
with safe-call chains (existing?.icon?.takeIf { GroupIcons.isKey(it) }).
Deliberately out of scope: R8/minify settings, MonthView's cache window
size (MONTHS_BACK/MONTHS_AHEAD), and mergeIntoCache's merge logic (already
reviewed as correct). No debounce added to setCalendarHiddenSynced — would
need new machinery beyond this fix's scope.
This commit is contained in:
@@ -54,13 +54,15 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
|
|||||||
onLoggedIn = vm::onLoggedIn,
|
onLoggedIn = vm::onLoggedIn,
|
||||||
onBack = vm::switchServer,
|
onBack = vm::switchServer,
|
||||||
)
|
)
|
||||||
AppRoute.MAIN -> CalendarScreen(
|
AppRoute.MAIN -> calendarVm?.let { cvm ->
|
||||||
vm = calendarVm!!,
|
CalendarScreen(
|
||||||
onLogout = vm::logout,
|
vm = cvm,
|
||||||
onSwitchServer = vm::switchServer,
|
onLogout = vm::logout,
|
||||||
onSettingsChanged = vm::applyLocalSettings,
|
onSwitchServer = vm::switchServer,
|
||||||
onSettingsSynced = vm::refreshSettings,
|
onSettingsChanged = vm::applyLocalSettings,
|
||||||
)
|
onSettingsSynced = vm::refreshSettings,
|
||||||
|
)
|
||||||
|
} ?: SplashScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,10 +229,12 @@ class CalendarViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Single serialized loader: avoids overlapping fetches that cause scroll jank. */
|
/** Single serialized loader: avoids overlapping fetches that cause scroll jank. */
|
||||||
private suspend fun loadRange(start: Instant, end: Instant, background: Boolean) {
|
private suspend fun loadRange(start: Instant, end: Instant, background: Boolean, force: Boolean = false) {
|
||||||
loadMutex.withLock {
|
loadMutex.withLock {
|
||||||
// Another load (e.g. the background prefetch) may have covered this range.
|
// Another load (e.g. the background prefetch) may have covered this range.
|
||||||
if (isCached(start, end)) return
|
// `force` bypasses this so callers can explicitly re-fetch an already-cached
|
||||||
|
// range (e.g. to pick up a mutation) without wiping the whole cache first.
|
||||||
|
if (!force && isCached(start, end)) return
|
||||||
val group = _state.value.activeGroup
|
val group = _state.value.activeGroup
|
||||||
val flag = if (background) "bg" else "fg"
|
val flag = if (background) "bg" else "fg"
|
||||||
_state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) }
|
_state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) }
|
||||||
@@ -305,7 +307,8 @@ class CalendarViewModel @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
val hidden = st.hiddenKeys
|
val hidden = st.hiddenKeys
|
||||||
val banished = st.banishedKeys
|
val banished = st.banishedKeys
|
||||||
allCachedEvents.filter { ev ->
|
if (hidden.isEmpty() && banished.isEmpty()) allCachedEvents
|
||||||
|
else allCachedEvents.filter { ev ->
|
||||||
val key = calendarKey(ev.source, ev.calendarId)
|
val key = calendarKey(ev.source, ev.calendarId)
|
||||||
key !in hidden && key !in banished
|
key !in hidden && key !in banished
|
||||||
}
|
}
|
||||||
@@ -346,7 +349,14 @@ class CalendarViewModel @Inject constructor(
|
|||||||
setCalendarHidden(key, hidden)
|
setCalendarHidden(key, hidden)
|
||||||
val id = key.substringAfter(":").toIntOrNull() ?: return
|
val id = key.substringAfter(":").toIntOrNull() ?: return
|
||||||
if (source in listOf("caldav", "google", "homeassistant")) {
|
if (source in listOf("caldav", "google", "homeassistant")) {
|
||||||
viewModelScope.launch { repository.setCalendarSidebarHidden(source, id, hidden) }
|
viewModelScope.launch {
|
||||||
|
runCatching { repository.setCalendarSidebarHidden(source, id, hidden) }
|
||||||
|
.onFailure { e ->
|
||||||
|
// The local toggle already applied; only the server write failed —
|
||||||
|
// surface it so the client doesn't silently diverge from server state.
|
||||||
|
_state.update { it.copy(error = e.message ?: "Fehler beim Speichern") }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,9 +437,23 @@ class CalendarViewModel @Inject constructor(
|
|||||||
|
|
||||||
// ---- Event mutations ----
|
// ---- Event mutations ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-fetch just the already-cached range after a create/update so the edit is
|
||||||
|
* reflected everywhere it's currently loaded, without nuking and reloading the
|
||||||
|
* whole ±cacheMonths window (which caused a full-screen freeze on every save).
|
||||||
|
* Falls back to [initialLoad] if nothing was cached yet.
|
||||||
|
*/
|
||||||
private fun afterMutation() {
|
private fun afterMutation() {
|
||||||
invalidateCache()
|
val start = cachedStart
|
||||||
initialLoad()
|
val end = cachedEnd
|
||||||
|
if (start == null || end == null) {
|
||||||
|
initialLoad()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
loadRange(start, end, background = false, force = true)
|
||||||
|
markReady()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveEvent(
|
fun saveEvent(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.material3.Divider
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
@@ -47,16 +48,21 @@ fun TimeGridView(
|
|||||||
) {
|
) {
|
||||||
val hourHeight = LocalAppSettings.current.hourHeight.coerceIn(28, 100).dp
|
val hourHeight = LocalAppSettings.current.hourHeight.coerceIn(28, 100).dp
|
||||||
val dimPast = LocalAppSettings.current.dimPastEvents
|
val dimPast = LocalAppSettings.current.dimPastEvents
|
||||||
val now = java.time.Instant.now()
|
|
||||||
val lang = LocalLang.current
|
|
||||||
val today = LocalDate.now()
|
val today = LocalDate.now()
|
||||||
|
// Recomputed once per day (not on every recomposition/frame) — Instant.now()
|
||||||
|
// was previously called fresh each recomposition, causing visible flicker in
|
||||||
|
// the "is this now" past-dimming and wasted allocation.
|
||||||
|
val now = remember(today) { java.time.Instant.now() }
|
||||||
|
val lang = LocalLang.current
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
// Day headers (only for multi-day / week view)
|
// Day headers (only for multi-day / week view)
|
||||||
if (days.size > 1) {
|
if (days.size > 1) {
|
||||||
Row(Modifier.fillMaxWidth()) {
|
Row(Modifier.fillMaxWidth()) {
|
||||||
Box(Modifier.width(GUTTER))
|
Box(Modifier.width(GUTTER))
|
||||||
val dayFmt = DateTimeFormatter.ofPattern("EEE d", com.scarriffle.calendarr.ui.L10n.locale(lang))
|
val dayFmt = remember(lang) {
|
||||||
|
DateTimeFormatter.ofPattern("EEE d", com.scarriffle.calendarr.ui.L10n.locale(lang))
|
||||||
|
}
|
||||||
days.forEach { day ->
|
days.forEach { day ->
|
||||||
Text(
|
Text(
|
||||||
dayFmt.format(day),
|
dayFmt.format(day),
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ private fun GroupEditSheet(
|
|||||||
) {
|
) {
|
||||||
val me = vm.currentUserId
|
val me = vm.currentUserId
|
||||||
var name by remember { mutableStateOf(existing?.name ?: "") }
|
var name by remember { mutableStateOf(existing?.name ?: "") }
|
||||||
var icon by remember { mutableStateOf(if (GroupIcons.isKey(existing?.icon)) existing!!.icon!! else "people") }
|
var icon by remember { mutableStateOf(existing?.icon?.takeIf { GroupIcons.isKey(it) } ?: "people") }
|
||||||
var selected by remember { mutableStateOf(setOf<Int>()) }
|
var selected by remember { mutableStateOf(setOf<Int>()) }
|
||||||
var existingMembers by remember { mutableStateOf(setOf<Int>()) }
|
var existingMembers by remember { mutableStateOf(setOf<Int>()) }
|
||||||
var detail by remember { mutableStateOf<Group?>(null) }
|
var detail by remember { mutableStateOf<Group?>(null) }
|
||||||
@@ -223,7 +223,7 @@ private fun GroupEditSheet(
|
|||||||
detail = g
|
detail = g
|
||||||
if (g != null) {
|
if (g != null) {
|
||||||
name = g.name
|
name = g.name
|
||||||
icon = if (GroupIcons.isKey(g.icon)) g.icon!! else "people"
|
icon = g.icon?.takeIf { GroupIcons.isKey(it) } ?: "people"
|
||||||
val members = g.members.map { it.id }.filter { it != me }.toSet()
|
val members = g.members.map { it.id }.filter { it != me }.toSet()
|
||||||
existingMembers = members
|
existingMembers = members
|
||||||
selected = members
|
selected = members
|
||||||
|
|||||||
Reference in New Issue
Block a user