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