fix: branded splash back, wider month range, December title bug

- Restore the branded in-app splash (Calendarr name + icon + © Scarriffle),
  held until the first events load; logo sized to match the system splash so it
  doesn't visibly jump, name/copyright fade in
- Month calendar now scrolls 10 years back / 50 years ahead (was ±18 months)
- Fix month/quarter title: use Month.getDisplayName instead of the 'LLLL'
  pattern, which dropped the month name (e.g. December showed only '2026')

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-05-31 15:07:37 +02:00
parent ba7daf8559
commit cb51284bef
7 changed files with 95 additions and 48 deletions

View File

@@ -4,35 +4,16 @@ 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?) {
// Single, uniform splash: keep the system splash on screen until the // Covers the window from the first frame (incl. warm-start), then hands
// first events have loaded (or a timeout), so there is no two-stage // off to the in-app branded splash which stays until data is loaded.
// splash and no entering a half-loaded, janky app. installSplashScreen()
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

@@ -1,15 +0,0 @@
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,14 +5,20 @@ 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()) {
@@ -24,9 +30,23 @@ 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; the branded splash stays until ready.
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(500); minElapsed = true }
LaunchedEffect(Unit) { delay(6000); timedOut = true }
if (!minElapsed || (!dataReady && !timedOut)) {
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(
@@ -35,6 +55,7 @@ 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

@@ -0,0 +1,57 @@
package com.scarriffle.calendarr.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
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.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
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
/**
* Branded startup screen shown right after the system splash hands off (same
* black background + same centred logo, so the logo doesn't jump); the app name
* and copyright fade in. Stays until the first events have loaded.
*/
@Composable
fun SplashScreen() {
// Fade the texts in so the handoff from the (text-less) system splash is smooth.
val textAlpha by animateFloatAsState(targetValue = 1f, animationSpec = tween(350), label = "splashText")
Box(Modifier.fillMaxSize().background(Color.Black)) {
Text(
"Calendarr",
modifier = Modifier.align(Alignment.TopCenter).padding(top = 110.dp).alpha(textAlpha),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
// Match the system splash icon size (~150dp) so it stays put across the handoff.
Image(
painter = painterResource(R.drawable.ic_splash_logo),
contentDescription = null,
modifier = Modifier.align(Alignment.Center).size(150.dp).clip(RoundedCornerShape(34.dp)),
)
Text(
"© Scarriffle",
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 40.dp).alpha(textAlpha),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.6f),
)
}
}

View File

@@ -15,12 +15,18 @@ private val zone: ZoneId = ZoneId.systemDefault()
fun titleForView(viewType: CalViewType, date: LocalDate, lang: String): String { fun titleForView(viewType: CalViewType, date: LocalDate, lang: String): String {
val loc = L10n.locale(lang) val loc = L10n.locale(lang)
return when (viewType) { return when (viewType) {
CalViewType.MONTH -> CalViewType.MONTH -> {
DateTimeFormatter.ofPattern("LLLL yyyy", loc).format(date) // Use Month.getDisplayName (robust) instead of the "LLLL" pattern,
// which could drop the month name under desugared java.time.
val month = date.month.getDisplayName(TextStyle.FULL_STANDALONE, loc)
.replaceFirstChar { it.uppercase(loc) } .replaceFirstChar { it.uppercase(loc) }
"$month ${date.year}"
}
CalViewType.QUARTER -> { CalViewType.QUARTER -> {
val fmt = DateTimeFormatter.ofPattern("LLL yyyy", loc) val endM = date.plusMonths(2)
"${fmt.format(date)} ${fmt.format(date.plusMonths(2))}" val m1 = date.month.getDisplayName(TextStyle.SHORT_STANDALONE, loc).replaceFirstChar { it.uppercase(loc) }
val m2 = endM.month.getDisplayName(TextStyle.SHORT_STANDALONE, loc).replaceFirstChar { it.uppercase(loc) }
"$m1 ${date.year} $m2 ${endM.year}"
} }
CalViewType.WEEK -> { CalViewType.WEEK -> {
val start = date val start = date

View File

@@ -4,7 +4,6 @@ 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
@@ -45,7 +44,6 @@ 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()
@@ -188,7 +186,6 @@ class CalendarViewModel @Inject constructor(
private fun markReady() { private fun markReady() {
_ready.value = true _ready.value = true
startupState.markReady()
} }
private fun prefetchBackground() { private fun prefetchBackground() {

View File

@@ -55,8 +55,8 @@ import java.time.temporal.IsoFields
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
private const val MONTHS_BACK = 18L private const val MONTHS_BACK = 120L // 10 years back
private const val MONTHS_AHEAD = 18L private const val MONTHS_AHEAD = 600L // 50 years ahead
private const val MAX_LANES = 4 private const val MAX_LANES = 4
private val DAY_NUM_H = 22.dp private val DAY_NUM_H = 22.dp
private val LANE_H = 15.dp private val LANE_H = 15.dp