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.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 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 com.scarriffle.calendarr.ui.CalendarrRoot
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject lateinit var startupState: StartupState
@Inject lateinit var credentialStore: CredentialStore
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// Covers the window from the first frame (no warm-start flash), then // Single, uniform splash: keep the system splash on screen until the
// hands off to the in-app splash which stays until data is loaded. // first events have loaded (or a timeout), so there is no two-stage
installSplashScreen() // splash and no entering a half-loaded, janky app.
val splash = installSplashScreen()
splash.setKeepOnScreenCondition { !startupState.ready.value }
super.onCreate(savedInstanceState) 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 { setContent {
CalendarrRoot() 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.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.ui.auth.LoginScreen import com.scarriffle.calendarr.ui.auth.LoginScreen
import com.scarriffle.calendarr.ui.auth.ServerSetupScreen import com.scarriffle.calendarr.ui.auth.ServerSetupScreen
import com.scarriffle.calendarr.ui.calendar.CalendarScreen import com.scarriffle.calendarr.ui.calendar.CalendarScreen
import com.scarriffle.calendarr.ui.calendar.CalendarViewModel
import com.scarriffle.calendarr.ui.theme.CalendarrTheme import com.scarriffle.calendarr.ui.theme.CalendarrTheme
import kotlinx.coroutines.delay
@Composable @Composable
fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
@@ -30,23 +24,9 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
LocalLang provides L10n.resolved(settings.language), LocalLang provides L10n.resolved(settings.language),
LocalAppSettings provides settings, 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) { 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) { when (route) {
AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured) AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured)
AppRoute.LOGIN -> LoginScreen( AppRoute.LOGIN -> LoginScreen(
@@ -55,7 +35,6 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
onBack = vm::switchServer, onBack = vm::switchServer,
) )
AppRoute.MAIN -> CalendarScreen( AppRoute.MAIN -> CalendarScreen(
vm = calendarVm!!,
onLogout = vm::logout, onLogout = vm::logout,
onSwitchServer = vm::switchServer, onSwitchServer = vm::switchServer,
onSettingsChanged = vm::applyLocalSettings, 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 androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.SettingsStore 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.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.domain.model.WritableCalendar 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.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
@@ -42,10 +45,14 @@ data class CalendarUiState(
class CalendarViewModel @Inject constructor( class CalendarViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val settingsStore: SettingsStore, private val settingsStore: SettingsStore,
private val startupState: StartupState,
) : ViewModel() { ) : ViewModel() {
private val zone: ZoneId = ZoneId.systemDefault() 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()) private val _state = MutableStateFlow(initialState())
val state: StateFlow<CalendarUiState> = _state.asStateFlow() val state: StateFlow<CalendarUiState> = _state.asStateFlow()
@@ -147,21 +154,12 @@ class CalendarViewModel @Inject constructor(
val (start, end) = rangeForCurrentView() val (start, end) = rangeForCurrentView()
if (!force && isCached(start, end)) { if (!force && isCached(start, end)) {
refreshFromCache() refreshFromCache()
_ready.value = true markReady()
return return
} }
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) } loadRange(start, end, background = false)
runCatching { repository.fetchEvents(start, end) } markReady()
.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
} }
} }
@@ -171,30 +169,35 @@ class CalendarViewModel @Inject constructor(
val start = instant(first.minusMonths(1)) val start = instant(first.minusMonths(1))
val end = instant(first.plusMonths(2)) val end = instant(first.plusMonths(2))
if (isCached(start, end)) return if (isCached(start, end)) return
viewModelScope.launch { viewModelScope.launch { loadRange(start, end, background = false) }
_state.update { it.copy(isLoading = true) } }
/** 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) } runCatching { repository.fetchEvents(start, end) }
.onSuccess { mergeIntoCache(it, start, end); refreshFromCache() } .onSuccess { mergeIntoCache(it, start, end); refreshFromCache() }
.onFailure { e -> _state.update { it.copy(error = e.message) } } .onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } }
_state.update { it.copy(isLoading = false) } _state.update { it.copy(isLoading = false, isBackgroundCaching = false) }
} }
} }
private fun markReady() {
_ready.value = true
startupState.markReady()
}
private fun prefetchBackground() { private fun prefetchBackground() {
val months = settingsStore.cacheMonths val months = settingsStore.cacheMonths
val today = LocalDate.now().withDayOfMonth(1) val today = LocalDate.now().withDayOfMonth(1)
val start = instant(today.minusMonths(months.toLong())) val start = instant(today.minusMonths(months.toLong()))
val end = instant(today.plusMonths((months + 1).toLong())) val end = instant(today.plusMonths((months + 1).toLong()))
if (isCached(start, end)) return if (isCached(start, end)) return
viewModelScope.launch { viewModelScope.launch { loadRange(start, end, background = true) }
_state.update { it.copy(isBackgroundCaching = true) }
runCatching { repository.fetchEvents(start, end) }
.onSuccess { fetched ->
mergeIntoCache(fetched, start, end)
refreshFromCache()
}
_state.update { it.copy(isBackgroundCaching = false) }
}
} }
private fun isCached(start: Instant, end: Instant): Boolean { private fun isCached(start: Instant, end: Instant): Boolean {
@@ -219,7 +222,8 @@ class CalendarViewModel @Inject constructor(
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
} }
_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() { private fun invalidateCache() {

View File

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