perf: unified splash held until loaded, smoother month scrolling, serialized loads

- Single system splash (core-splashscreen) kept on screen until the first
  events load (StartupState), then reveals the ready app — removes the
  two-stage 'icon then title' splash and the laggy half-loaded entry
- Month scrolling: dropped per-row BoxWithConstraints (measured grid width once
  via onSizeChanged) and resolve bar colours once during packing instead of
  every recomposition → much smoother scroll
- Serialize all event loads behind a Mutex and skip redundant state writes so
  scroll-triggered month loads no longer pile up / thrash the UI

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-05-31 14:53:06 +02:00
parent 3734e17c3f
commit ba7daf8559
6 changed files with 112 additions and 130 deletions

View File

@@ -4,16 +4,35 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import com.scarriffle.calendarr.data.CredentialStore
import com.scarriffle.calendarr.data.StartupState
import com.scarriffle.calendarr.ui.CalendarrRoot
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var startupState: StartupState
@Inject lateinit var credentialStore: CredentialStore
override fun onCreate(savedInstanceState: Bundle?) {
// Covers the window from the first frame (no warm-start flash), then
// hands off to the in-app splash which stays until data is loaded.
installSplashScreen()
// Single, uniform splash: keep the system splash on screen until the
// first events have loaded (or a timeout), so there is no two-stage
// splash and no entering a half-loaded, janky app.
val splash = installSplashScreen()
splash.setKeepOnScreenCondition { !startupState.ready.value }
super.onCreate(savedInstanceState)
// Nothing to load before login → reveal immediately.
if (!credentialStore.isLoggedIn) startupState.markReady()
// Safety timeout so the splash can never hang.
lifecycleScope.launch { delay(6000); startupState.markReady() }
setContent {
CalendarrRoot()
}

View File

@@ -0,0 +1,15 @@
package com.scarriffle.calendarr.data
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Shared startup readiness flag used to keep the system splash screen on screen
* until the first events have loaded (so the app never appears mid-load).
*/
@Singleton
class StartupState @Inject constructor() {
val ready = MutableStateFlow(false)
fun markReady() { ready.value = true }
}

View File

@@ -5,20 +5,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.ui.auth.LoginScreen
import com.scarriffle.calendarr.ui.auth.ServerSetupScreen
import com.scarriffle.calendarr.ui.calendar.CalendarScreen
import com.scarriffle.calendarr.ui.calendar.CalendarViewModel
import com.scarriffle.calendarr.ui.theme.CalendarrTheme
import kotlinx.coroutines.delay
@Composable
fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
@@ -30,23 +24,9 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
LocalLang provides L10n.resolved(settings.language),
LocalAppSettings provides settings,
) {
// The system splash (installSplashScreen) covers startup until the
// first events are loaded, so there's no in-app splash here.
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
// Obtain the calendar VM early when logged in so events load *behind* the splash.
val calendarVm: CalendarViewModel? =
if (route == AppRoute.MAIN) hiltViewModel() else null
val dataReady = calendarVm?.ready?.collectAsState()?.value ?: true
var minElapsed by remember { mutableStateOf(false) }
var timedOut by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { delay(450); minElapsed = true }
LaunchedEffect(Unit) { delay(6000); timedOut = true }
val showSplash = !minElapsed || (!dataReady && !timedOut)
if (showSplash) {
SplashScreen()
return@Surface
}
when (route) {
AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured)
AppRoute.LOGIN -> LoginScreen(
@@ -55,7 +35,6 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
onBack = vm::switchServer,
)
AppRoute.MAIN -> CalendarScreen(
vm = calendarVm!!,
onLogout = vm::logout,
onSwitchServer = vm::switchServer,
onSettingsChanged = vm::applyLocalSettings,

View File

@@ -1,45 +0,0 @@
package com.scarriffle.calendarr.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.R
/** Custom startup screen: app name on top, crisp icon centered, copyright at the bottom. */
@Composable
fun SplashScreen() {
Box(Modifier.fillMaxSize().background(Color.Black)) {
Text(
"Calendarr",
modifier = Modifier.align(Alignment.TopCenter).padding(top = 96.dp),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
Image(
painter = painterResource(R.drawable.ic_splash_logo),
contentDescription = null,
modifier = Modifier.align(Alignment.Center).size(148.dp).clip(RoundedCornerShape(32.dp)),
)
Text(
"© Scarriffle",
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 40.dp),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.6f),
)
}
}

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.data.StartupState
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.domain.model.WritableCalendar
@@ -13,6 +14,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
@@ -42,10 +45,14 @@ data class CalendarUiState(
class CalendarViewModel @Inject constructor(
private val repository: CalendarRepository,
private val settingsStore: SettingsStore,
private val startupState: StartupState,
) : ViewModel() {
private val zone: ZoneId = ZoneId.systemDefault()
/** Serializes network loads so overlapping fetches don't thrash the UI. */
private val loadMutex = Mutex()
private val _state = MutableStateFlow(initialState())
val state: StateFlow<CalendarUiState> = _state.asStateFlow()
@@ -147,21 +154,12 @@ class CalendarViewModel @Inject constructor(
val (start, end) = rangeForCurrentView()
if (!force && isCached(start, end)) {
refreshFromCache()
_ready.value = true
markReady()
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
runCatching { repository.fetchEvents(start, end) }
.onSuccess { fetched ->
mergeIntoCache(fetched, start, end)
refreshFromCache()
_state.update { it.copy(isLoading = false) }
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
_ready.value = true
loadRange(start, end, background = false)
markReady()
}
}
@@ -171,30 +169,35 @@ class CalendarViewModel @Inject constructor(
val start = instant(first.minusMonths(1))
val end = instant(first.plusMonths(2))
if (isCached(start, end)) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
viewModelScope.launch { loadRange(start, end, background = false) }
}
/** Single serialized loader: avoids overlapping fetches that cause scroll jank. */
private suspend fun loadRange(start: Instant, end: Instant, background: Boolean) {
loadMutex.withLock {
// Another load (e.g. the background prefetch) may have covered this range.
if (isCached(start, end)) return
val flag = if (background) "bg" else "fg"
_state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) }
runCatching { repository.fetchEvents(start, end) }
.onSuccess { mergeIntoCache(it, start, end); refreshFromCache() }
.onFailure { e -> _state.update { it.copy(error = e.message) } }
_state.update { it.copy(isLoading = false) }
.onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } }
_state.update { it.copy(isLoading = false, isBackgroundCaching = false) }
}
}
private fun markReady() {
_ready.value = true
startupState.markReady()
}
private fun prefetchBackground() {
val months = settingsStore.cacheMonths
val today = LocalDate.now().withDayOfMonth(1)
val start = instant(today.minusMonths(months.toLong()))
val end = instant(today.plusMonths((months + 1).toLong()))
if (isCached(start, end)) return
viewModelScope.launch {
_state.update { it.copy(isBackgroundCaching = true) }
runCatching { repository.fetchEvents(start, end) }
.onSuccess { fetched ->
mergeIntoCache(fetched, start, end)
refreshFromCache()
}
_state.update { it.copy(isBackgroundCaching = false) }
}
viewModelScope.launch { loadRange(start, end, background = true) }
}
private fun isCached(start: Instant, end: Instant): Boolean {
@@ -219,7 +222,8 @@ class CalendarViewModel @Inject constructor(
val key = calendarKey(ev.source, ev.calendarId)
key !in hidden && key !in banished
}
_state.update { it.copy(events = visible) }
// Skip the state write (and resulting recomposition) when nothing changed.
_state.update { if (it.events == visible) it else it.copy(events = visible) }
}
private fun invalidateCache() {

View File

@@ -5,7 +5,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
@@ -22,7 +21,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -30,9 +32,13 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.scarriffle.calendarr.domain.model.CalEvent
@@ -59,8 +65,17 @@ private val ROW_HEIGHT = DAY_NUM_H + (LANE_H + LANE_SPACE) * MAX_LANES + 6.dp
private enum class DividerEdge { NONE, TOP, BOTTOM }
/** One placed event bar within a week: which lane and which columns it spans. */
private data class PlacedBar(val event: CalEvent, val lane: Int, val startCol: Int, val span: Int)
/** A placed event bar within a week (with colours resolved once, not per frame). */
private data class PlacedBar(
val event: CalEvent,
val lane: Int,
val startCol: Int,
val span: Int,
val color: Color,
val textColor: Color,
)
private class WeekLayout(val bars: List<PlacedBar>, val overflowPerCol: IntArray)
/** Continuous, vertically scrolling month calendar with multi-day event bars (iOS-style). */
@Composable
@@ -87,6 +102,12 @@ fun MonthView(
val secondaryText = MaterialTheme.colorScheme.onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast))
val todayColor = colorFromHex(settings.todayColor)
// Column width measured once → no per-row BoxWithConstraints (smooth scrolling).
val density = LocalDensity.current
val fallbackCellW = LocalConfiguration.current.screenWidthDp.dp / 7
var gridWidthPx by remember { mutableIntStateOf(0) }
val cellW: Dp = if (gridWidthPx > 0) with(density) { (gridWidthPx / 7f).toDp() } else fallbackCellW
val firstVisible = remember(mondayFirst) {
startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst)
}
@@ -120,7 +141,6 @@ fun MonthView(
}
}
// One pass: bucket events into the weeks they overlap (keyed by week-start).
val eventsByWeek = remember(state.events, mondayFirst) { buildEventsByWeek(state.events, mondayFirst) }
Column(Modifier.fillMaxSize()) {
@@ -136,13 +156,17 @@ fun MonthView(
}
}
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
) {
items(weekCount) { index ->
val weekStart = firstVisible.plusWeeks(index.toLong())
WeekRow(
weekStart = weekStart,
today = today,
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
cellW = cellW,
lang = lang,
dividerColor = dividerColor,
gridColor = gridColor,
@@ -163,6 +187,7 @@ private fun WeekRow(
weekStart: LocalDate,
today: LocalDate,
weekEvents: List<CalEvent>,
cellW: Dp,
lang: String,
dividerColor: Color,
gridColor: Color,
@@ -173,18 +198,14 @@ private fun WeekRow(
onDayLongPress: (LocalDate) -> Unit,
onEventClick: (CalEvent) -> Unit,
) {
val days = (0 until 7).map { weekStart.plusDays(it.toLong()) }
val days = remember(weekStart) { (0 until 7).map { weekStart.plusDays(it.toLong()) } }
val boundaryCol = (1 until 7).firstOrNull { days[it].dayOfMonth == 1 }
val rowStartsNewMonth = days[0].dayOfMonth == 1
val cwLabel = tr("cal.cw")
// Greedy first-fit lane packing for this week (memoized).
val packed = remember(weekStart, weekEvents) { packEvents(weekStart, weekEvents) }
BoxWithConstraints(Modifier.fillMaxWidth().height(ROW_HEIGHT)) {
val cellW = maxWidth / 7
// Layer 1: day cell backgrounds (borders, day number, KW, overflow count)
Box(Modifier.fillMaxWidth().height(ROW_HEIGHT)) {
Row(Modifier.fillMaxSize()) {
days.forEachIndexed { idx, day ->
val edge = when {
@@ -213,16 +234,10 @@ private fun WeekRow(
}
}
// Layer 2: event bars (absolute, span multiple columns)
packed.bars.forEach { bar ->
EventBar(
bar = bar,
cellW = cellW,
onClick = { onEventClick(bar.event) },
)
EventBar(bar = bar, cellW = cellW, onClick = { onEventClick(bar.event) })
}
// Vertical connector at the month boundary column (the "step").
if (boundaryCol != null) {
Box(
Modifier
@@ -236,8 +251,7 @@ private fun WeekRow(
}
@Composable
private fun EventBar(bar: PlacedBar, cellW: androidx.compose.ui.unit.Dp, onClick: () -> Unit) {
val color = colorFromHex(bar.event.effectiveColor)
private fun EventBar(bar: PlacedBar, cellW: Dp, onClick: () -> Unit) {
Box(
Modifier
.offset(
@@ -247,7 +261,7 @@ private fun EventBar(bar: PlacedBar, cellW: androidx.compose.ui.unit.Dp, onClick
.width(cellW * bar.span - 2.dp)
.height(LANE_H)
.clip(RoundedCornerShape(3.dp))
.background(color)
.background(bar.color)
.clickable(onClick = onClick)
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart,
@@ -258,7 +272,7 @@ private fun EventBar(bar: PlacedBar, cellW: androidx.compose.ui.unit.Dp, onClick
overflow = TextOverflow.Ellipsis,
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
color = color.contrastingTextColor(),
color = bar.textColor,
)
}
}
@@ -306,7 +320,7 @@ private fun DayCellBackground(
) {
Row(
Modifier.fillMaxWidth().height(DAY_NUM_H).padding(top = 3.dp),
horizontalArrangement = Arrangement_Center,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (isFirst) {
@@ -349,22 +363,16 @@ private fun DayCellBackground(
}
}
private val Arrangement_Center = androidx.compose.foundation.layout.Arrangement.Center
private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate {
val dow = date.dayOfWeek.value
val offset = if (mondayFirst) dow - 1 else dow % 7
return date.minusDays(offset.toLong())
}
/** Result of packing one week: placed bars + per-column overflow count. */
private class WeekLayout(val bars: List<PlacedBar>, val overflowPerCol: IntArray)
/** Columns [startCol, startCol+span-1] this event occupies within the given week. */
private fun columnRange(weekStart: LocalDate, ev: CalEvent): Pair<Int, Int> {
val weekEnd = weekStart.plusDays(7)
val evStartDay = maxOf(localDate(ev.startDate), weekStart)
// last day the event covers (end is exclusive): the day before the end instant
val lastDay = localDate(ev.endDate.minusSeconds(1))
val evEndDay = minOf(lastDay, weekEnd.minusDays(1))
val sc = ChronoUnit.DAYS.between(weekStart, evStartDay).toInt().coerceIn(0, 6)
@@ -372,7 +380,7 @@ private fun columnRange(weekStart: LocalDate, ev: CalEvent): Pair<Int, Int> {
return sc to (ec - sc + 1).coerceAtLeast(1)
}
/** Greedy first-fit lane packing (mirrors iOS packEvents). */
/** Greedy first-fit lane packing (mirrors iOS packEvents); colours resolved here. */
private fun packEvents(weekStart: LocalDate, weekEvents: List<CalEvent>): WeekLayout {
val sorted = weekEvents.sortedWith(compareBy<CalEvent> { it.startDate }.thenByDescending { it.endDate })
val laneLastEnd = ArrayList<Int>()
@@ -389,7 +397,8 @@ private fun packEvents(weekStart: LocalDate, weekEvents: List<CalEvent>): WeekLa
laneLastEnd.add(lastCol); assigned = laneLastEnd.size - 1
}
if (assigned >= 0) {
bars.add(PlacedBar(ev, assigned, sc, span))
val color = colorFromHex(ev.effectiveColor)
bars.add(PlacedBar(ev, assigned, sc, span, color, color.contrastingTextColor()))
} else {
for (c in sc..lastCol) overflow[c]++
}
@@ -401,8 +410,9 @@ private fun packEvents(weekStart: LocalDate, weekEvents: List<CalEvent>): WeekLa
private fun buildEventsByWeek(events: List<CalEvent>, mondayFirst: Boolean): Map<LocalDate, List<CalEvent>> {
val map = HashMap<LocalDate, MutableList<CalEvent>>()
for (ev in events) {
val firstWeek = startOfWeek(localDate(ev.startDate), mondayFirst)
val lastDay = localDate(ev.endDate.minusSeconds(1)).let { if (it.isBefore(localDate(ev.startDate))) localDate(ev.startDate) else it }
val startD = localDate(ev.startDate)
val firstWeek = startOfWeek(startD, mondayFirst)
val lastDay = localDate(ev.endDate.minusSeconds(1)).let { if (it.isBefore(startD)) startD else it }
val lastWeek = startOfWeek(lastDay, mondayFirst)
var w = firstWeek
var guard = 0