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