fix: single branded splash (invisible system icon), December title, first-open lag

- System splash now uses a transparent icon, so there is no system icon that
  shrinks into the in-app splash: just black, then the branded screen (icon +
  Calendarr name + copyright), held until data is loaded
- Month/quarter title uses TextStyle.FULL (format style) with a hard fallback;
  FULL_STANDALONE had the same desugar bug as the LLLL pattern (December showed
  only the year)
- First open: load the full +/- cacheMonths window behind the splash and only
  then reveal, so the app no longer enters mid-load and stutters

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-05-31 15:17:01 +02:00
parent cb51284bef
commit bc93964e05
5 changed files with 45 additions and 27 deletions

View File

@@ -12,21 +12,30 @@ import java.time.format.TextStyle
private val zone: ZoneId = ZoneId.systemDefault()
/** Localized month name with a hard fallback (guards against empty/numeric results). */
private fun monthName(date: LocalDate, style: TextStyle, loc: java.util.Locale): String {
val name = date.month.getDisplayName(style, loc)
val safe = if (name.isBlank() || name.all { it.isDigit() }) {
date.month.getDisplayName(TextStyle.FULL, java.util.Locale.ENGLISH)
} else {
name
}
return safe.replaceFirstChar { it.uppercase(loc) }
}
fun titleForView(viewType: CalViewType, date: LocalDate, lang: String): String {
val loc = L10n.locale(lang)
return when (viewType) {
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) }
// Use the FORMAT month style (TextStyle.FULL). The standalone style
// (FULL_STANDALONE / the "LLLL" pattern) drops the name for some
// months under desugared java.time (e.g. December showed only "2026").
val month = monthName(date, TextStyle.FULL, loc)
"$month ${date.year}"
}
CalViewType.QUARTER -> {
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}"
"${monthName(date, TextStyle.SHORT, loc)} ${date.year} ${monthName(endM, TextStyle.SHORT, loc)} ${endM.year}"
}
CalViewType.WEEK -> {
val start = date

View File

@@ -64,9 +64,24 @@ class CalendarViewModel @Inject constructor(
private var allCachedEvents: List<CalEvent> = emptyList()
init {
loadVisible()
loadWritableCalendars()
prefetchBackground()
initialLoad()
}
/**
* Load the full ±cacheMonths window once, behind the splash, and only then
* mark ready. Entering the app fully-loaded avoids the first-open jank of
* loading a big batch while the user is already scrolling.
*/
private fun initialLoad() {
val months = settingsStore.cacheMonths.toLong()
val today = LocalDate.now().withDayOfMonth(1)
val start = instant(today.minusMonths(months))
val end = instant(today.plusMonths(months + 1))
viewModelScope.launch {
loadRange(start, end, background = false)
markReady()
}
}
private fun initialState(): CalendarUiState {
@@ -188,15 +203,6 @@ class CalendarViewModel @Inject constructor(
_ready.value = true
}
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 { loadRange(start, end, background = true) }
}
private fun isCached(start: Instant, end: Instant): Boolean {
val cs = cachedStart ?: return false
val ce = cachedEnd ?: return false
@@ -231,8 +237,7 @@ class CalendarViewModel @Inject constructor(
fun syncWithServer() {
invalidateCache()
loadVisible(force = true)
prefetchBackground()
initialLoad()
}
fun clearError() = _state.update { it.copy(error = null) }
@@ -280,8 +285,7 @@ class CalendarViewModel @Inject constructor(
private fun afterMutation() {
invalidateCache()
loadVisible(force = true)
prefetchBackground()
initialLoad()
}
fun saveEvent(

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Invisible system-splash icon: the system splash is just black, so the
branded in-app splash (icon + name + copyright) is the only thing seen. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/transparent" />
</shape>

View File

@@ -4,8 +4,5 @@
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowLightStatusBar">false</item>
<!-- Android 12+ system splash screen -->
<item name="android:windowSplashScreenBackground">@android:color/black</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
</style>
</resources>

View File

@@ -9,10 +9,11 @@
<item name="android:windowLightStatusBar">false</item>
</style>
<!-- System splash (core-splashscreen); covers the window from frame 0. -->
<!-- System splash (core-splashscreen): plain black with an invisible icon,
so the only branded screen the user sees is the in-app splash. -->
<style name="Theme.Calendarr.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@android:color/black</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_transparent</item>
<item name="postSplashScreenTheme">@style/Theme.Calendarr</item>
</style>
</resources>