diff --git a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt index a558078..67cdd5b 100644 --- a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt +++ b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt @@ -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() } diff --git a/app/src/main/java/com/scarriffle/calendarr/data/StartupState.kt b/app/src/main/java/com/scarriffle/calendarr/data/StartupState.kt new file mode 100644 index 0000000..8d9b4e4 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/StartupState.kt @@ -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 } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt index 18e9afe..3ef403e 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt @@ -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, diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt deleted file mode 100644 index 9bf141a..0000000 --- a/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt +++ /dev/null @@ -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), - ) - } -} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index 3d9b50e..9cc8767 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -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 = _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() { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt index e55831f..c4ecb00 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -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, 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, + 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, val overflowPerCol: IntArray) - /** Columns [startCol, startCol+span-1] this event occupies within the given week. */ private fun columnRange(weekStart: LocalDate, ev: CalEvent): Pair { 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 { 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): WeekLayout { val sorted = weekEvents.sortedWith(compareBy { it.startDate }.thenByDescending { it.endDate }) val laneLastEnd = ArrayList() @@ -389,7 +397,8 @@ private fun packEvents(weekStart: LocalDate, weekEvents: List): 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): WeekLa private fun buildEventsByWeek(events: List, mondayFirst: Boolean): Map> { val map = HashMap>() 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