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