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.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?) {
// 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 }
// Covers the window from the first frame (incl. warm-start), then hands
// off to the in-app branded splash which stays until data is loaded.
installSplashScreen()
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()
}

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.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()) {
@@ -24,9 +30,23 @@ 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; 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) {
AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured)
AppRoute.LOGIN -> LoginScreen(
@@ -35,6 +55,7 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
onBack = vm::switchServer,
)
AppRoute.MAIN -> CalendarScreen(
vm = calendarVm!!,
onLogout = vm::logout,
onSwitchServer = vm::switchServer,
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 {
val loc = L10n.locale(lang)
return when (viewType) {
CalViewType.MONTH ->
DateTimeFormatter.ofPattern("LLLL yyyy", loc).format(date)
CalViewType.MONTH -> {
// 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) }
"$month ${date.year}"
}
CalViewType.QUARTER -> {
val fmt = DateTimeFormatter.ofPattern("LLL yyyy", loc)
"${fmt.format(date)} ${fmt.format(date.plusMonths(2))}"
val endM = 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 -> {
val start = date

View File

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

View File

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