feat: multi-day event bars, readiness-gated splash, top-bar spinner, 2-line titles, detail animation
- Month view now draws multi-day events as continuous bars (lane packing, per-week column span clipped at week boundaries, +N overflow) instead of a chip on every day; events bucketed per week once + memoized packing for smooth scrolling - Startup: core-splashscreen covers the window from frame 0 (no warm-start flash); the in-app splash stays until the first events finish loading (loading starts behind the splash via an early-obtained CalendarViewModel + ready flow), so you no longer enter a laggy app - Background load shows a small spinner in the top bar (removed the linear bar) - Week/Day titles wrap to two smaller lines (no more truncation) - Event detail opens with a slide+fade animation Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,7 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||||
implementation("com.google.android.material:material:1.11.0")
|
implementation("com.google.android.material:material:1.11.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="portrait"
|
||||||
android:theme="@style/Theme.Calendarr">
|
android:theme="@style/Theme.Calendarr.Starting">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ package com.scarriffle.calendarr
|
|||||||
import android.os.Bundle
|
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 com.scarriffle.calendarr.ui.CalendarrRoot
|
import com.scarriffle.calendarr.ui.CalendarrRoot
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
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()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
CalendarrRoot()
|
CalendarrRoot()
|
||||||
|
|||||||
@@ -16,29 +16,37 @@ 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()) {
|
||||||
val route by vm.route.collectAsState()
|
val route by vm.route.collectAsState()
|
||||||
val settings by vm.settings.collectAsState()
|
val settings by vm.settings.collectAsState()
|
||||||
|
|
||||||
var showSplash by remember { mutableStateOf(true) }
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
kotlinx.coroutines.delay(1200)
|
|
||||||
showSplash = false
|
|
||||||
}
|
|
||||||
|
|
||||||
CalendarrTheme(settings) {
|
CalendarrTheme(settings) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalLang provides L10n.resolved(settings.language),
|
LocalLang provides L10n.resolved(settings.language),
|
||||||
LocalAppSettings provides settings,
|
LocalAppSettings provides settings,
|
||||||
) {
|
) {
|
||||||
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.
|
||||||
|
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) {
|
if (showSplash) {
|
||||||
SplashScreen()
|
SplashScreen()
|
||||||
return@Surface
|
return@Surface
|
||||||
}
|
}
|
||||||
|
|
||||||
when (route) {
|
when (route) {
|
||||||
AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured)
|
AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured)
|
||||||
AppRoute.LOGIN -> LoginScreen(
|
AppRoute.LOGIN -> LoginScreen(
|
||||||
@@ -47,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,
|
||||||
|
|||||||
@@ -26,10 +26,14 @@ fun titleForView(viewType: CalViewType, date: LocalDate, lang: String): String {
|
|||||||
val start = date
|
val start = date
|
||||||
val fmt = DateTimeFormatter.ofPattern("d. MMM", loc)
|
val fmt = DateTimeFormatter.ofPattern("d. MMM", loc)
|
||||||
val endFmt = DateTimeFormatter.ofPattern("d. MMM yyyy", loc)
|
val endFmt = DateTimeFormatter.ofPattern("d. MMM yyyy", loc)
|
||||||
"${fmt.format(start)} – ${endFmt.format(start.plusDays(6))}"
|
// Two lines for the compact top bar.
|
||||||
|
"${fmt.format(start)} –\n${endFmt.format(start.plusDays(6))}"
|
||||||
|
}
|
||||||
|
CalViewType.DAY -> {
|
||||||
|
val weekday = DateTimeFormatter.ofPattern("EEEE", loc).format(date)
|
||||||
|
val rest = DateTimeFormatter.ofPattern("d. MMMM yyyy", loc).format(date)
|
||||||
|
"$weekday\n$rest"
|
||||||
}
|
}
|
||||||
CalViewType.DAY ->
|
|
||||||
DateTimeFormatter.ofPattern("EEEE, d. MMMM yyyy", loc).format(date)
|
|
||||||
CalViewType.AGENDA -> L10n.t("view.agenda", lang)
|
CalViewType.AGENDA -> L10n.t("view.agenda", lang)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package com.scarriffle.calendarr.ui.calendar
|
package com.scarriffle.calendarr.ui.calendar
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -39,6 +44,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -105,6 +111,7 @@ fun CalendarScreen(
|
|||||||
CompactTopBar(
|
CompactTopBar(
|
||||||
title = barTitle,
|
title = barTitle,
|
||||||
viewType = state.viewType,
|
viewType = state.viewType,
|
||||||
|
loading = state.isLoading || state.isBackgroundCaching,
|
||||||
viewMenuOpen = viewMenuOpen,
|
viewMenuOpen = viewMenuOpen,
|
||||||
onMenu = { showMenu = true },
|
onMenu = { showMenu = true },
|
||||||
onPrev = { goPrev() },
|
onPrev = { goPrev() },
|
||||||
@@ -126,9 +133,6 @@ fun CalendarScreen(
|
|||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||||
if (state.isLoading || state.isBackgroundCaching) {
|
|
||||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
|
||||||
}
|
|
||||||
state.error?.let { err ->
|
state.error?.let { err ->
|
||||||
ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError)
|
ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError)
|
||||||
}
|
}
|
||||||
@@ -172,7 +176,16 @@ fun CalendarScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
detailEvent?.let { ev ->
|
// Keep the last event during the close animation.
|
||||||
|
var lastDetail by remember { mutableStateOf<CalEvent?>(null) }
|
||||||
|
detailEvent?.let { lastDetail = it }
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = detailEvent != null,
|
||||||
|
enter = slideInVertically(initialOffsetY = { it / 6 }) + fadeIn(),
|
||||||
|
exit = slideOutVertically(targetOffsetY = { it / 6 }) + fadeOut(),
|
||||||
|
) {
|
||||||
|
val ev = lastDetail
|
||||||
|
if (ev != null) {
|
||||||
EventDetailScreen(
|
EventDetailScreen(
|
||||||
event = ev,
|
event = ev,
|
||||||
onClose = { detailEvent = null },
|
onClose = { detailEvent = null },
|
||||||
@@ -190,6 +203,7 @@ fun CalendarScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
editor?.let { req ->
|
editor?.let { req ->
|
||||||
EventEditorSheet(
|
EventEditorSheet(
|
||||||
@@ -279,6 +293,7 @@ private fun loadingPlaceholder() {
|
|||||||
private fun CompactTopBar(
|
private fun CompactTopBar(
|
||||||
title: String,
|
title: String,
|
||||||
viewType: CalViewType,
|
viewType: CalViewType,
|
||||||
|
loading: Boolean,
|
||||||
viewMenuOpen: Boolean,
|
viewMenuOpen: Boolean,
|
||||||
onMenu: () -> Unit,
|
onMenu: () -> Unit,
|
||||||
onPrev: () -> Unit,
|
onPrev: () -> Unit,
|
||||||
@@ -288,6 +303,7 @@ private fun CompactTopBar(
|
|||||||
onViewMenuToggle: (Boolean) -> Unit,
|
onViewMenuToggle: (Boolean) -> Unit,
|
||||||
onSelectView: (CalViewType) -> Unit,
|
onSelectView: (CalViewType) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val twoLine = viewType == CalViewType.WEEK || viewType == CalViewType.DAY
|
||||||
Surface(color = MaterialTheme.colorScheme.background) {
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp),
|
Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp),
|
||||||
@@ -302,10 +318,18 @@ private fun CompactTopBar(
|
|||||||
title,
|
title,
|
||||||
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
|
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
maxLines = 1,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
fontSize = if (twoLine) 13.sp else 16.sp,
|
||||||
|
lineHeight = if (twoLine) 15.sp else 18.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
|
if (loading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp).padding(end = 2.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button"))
|
CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button"))
|
||||||
Box {
|
Box {
|
||||||
CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change"))
|
CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change"))
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ class CalendarViewModel @Inject constructor(
|
|||||||
private val _state = MutableStateFlow(initialState())
|
private val _state = MutableStateFlow(initialState())
|
||||||
val state: StateFlow<CalendarUiState> = _state.asStateFlow()
|
val state: StateFlow<CalendarUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
/** True once the first event load has completed — used to gate the splash. */
|
||||||
|
private val _ready = MutableStateFlow(false)
|
||||||
|
val ready: StateFlow<Boolean> = _ready.asStateFlow()
|
||||||
|
|
||||||
// Cache bookkeeping
|
// Cache bookkeeping
|
||||||
private var cachedStart: Instant? = null
|
private var cachedStart: Instant? = null
|
||||||
private var cachedEnd: Instant? = null
|
private var cachedEnd: Instant? = null
|
||||||
@@ -143,6 +147,7 @@ class CalendarViewModel @Inject constructor(
|
|||||||
val (start, end) = rangeForCurrentView()
|
val (start, end) = rangeForCurrentView()
|
||||||
if (!force && isCached(start, end)) {
|
if (!force && isCached(start, end)) {
|
||||||
refreshFromCache()
|
refreshFromCache()
|
||||||
|
_ready.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -156,6 +161,7 @@ class CalendarViewModel @Inject constructor(
|
|||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
_state.update { it.copy(isLoading = false, error = e.message) }
|
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||||
}
|
}
|
||||||
|
_ready.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -52,11 +51,18 @@ import kotlinx.coroutines.flow.map
|
|||||||
|
|
||||||
private const val MONTHS_BACK = 18L
|
private const val MONTHS_BACK = 18L
|
||||||
private const val MONTHS_AHEAD = 18L
|
private const val MONTHS_AHEAD = 18L
|
||||||
private val ROW_HEIGHT = 82.dp
|
private const val MAX_LANES = 4
|
||||||
|
private val DAY_NUM_H = 22.dp
|
||||||
|
private val LANE_H = 15.dp
|
||||||
|
private val LANE_SPACE = 2.dp
|
||||||
|
private val ROW_HEIGHT = DAY_NUM_H + (LANE_H + LANE_SPACE) * MAX_LANES + 6.dp
|
||||||
|
|
||||||
private enum class DividerEdge { NONE, TOP, BOTTOM }
|
private enum class DividerEdge { NONE, TOP, BOTTOM }
|
||||||
|
|
||||||
/** Continuous, vertically scrolling month calendar (matches the iOS app). */
|
/** 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)
|
||||||
|
|
||||||
|
/** Continuous, vertically scrolling month calendar with multi-day event bars (iOS-style). */
|
||||||
@Composable
|
@Composable
|
||||||
fun MonthView(
|
fun MonthView(
|
||||||
state: CalendarUiState,
|
state: CalendarUiState,
|
||||||
@@ -79,6 +85,7 @@ fun MonthView(
|
|||||||
val labelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant)
|
val labelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = gridLineOpacity(settings.lineContrast))
|
val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = gridLineOpacity(settings.lineContrast))
|
||||||
val secondaryText = MaterialTheme.colorScheme.onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast))
|
val secondaryText = MaterialTheme.colorScheme.onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast))
|
||||||
|
val todayColor = colorFromHex(settings.todayColor)
|
||||||
|
|
||||||
val firstVisible = remember(mondayFirst) {
|
val firstVisible = remember(mondayFirst) {
|
||||||
startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst)
|
startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst)
|
||||||
@@ -113,7 +120,8 @@ fun MonthView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val eventsByDay = remember(state.events) { buildEventsByDay(state.events) }
|
// 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()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
|
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
|
||||||
@@ -130,16 +138,17 @@ fun MonthView(
|
|||||||
|
|
||||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||||
items(weekCount) { index ->
|
items(weekCount) { index ->
|
||||||
|
val weekStart = firstVisible.plusWeeks(index.toLong())
|
||||||
WeekRow(
|
WeekRow(
|
||||||
weekStart = firstVisible.plusWeeks(index.toLong()),
|
weekStart = weekStart,
|
||||||
today = today,
|
today = today,
|
||||||
eventsByDay = eventsByDay,
|
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
|
||||||
lang = lang,
|
lang = lang,
|
||||||
dividerColor = dividerColor,
|
dividerColor = dividerColor,
|
||||||
gridColor = gridColor,
|
gridColor = gridColor,
|
||||||
labelColor = labelColor,
|
labelColor = labelColor,
|
||||||
secondaryText = secondaryText,
|
secondaryText = secondaryText,
|
||||||
todayColor = colorFromHex(settings.todayColor),
|
todayColor = todayColor,
|
||||||
onDayClick = onDayClick,
|
onDayClick = onDayClick,
|
||||||
onDayLongPress = onDayLongPress,
|
onDayLongPress = onDayLongPress,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
@@ -153,7 +162,7 @@ fun MonthView(
|
|||||||
private fun WeekRow(
|
private fun WeekRow(
|
||||||
weekStart: LocalDate,
|
weekStart: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
eventsByDay: Map<LocalDate, List<CalEvent>>,
|
weekEvents: List<CalEvent>,
|
||||||
lang: String,
|
lang: String,
|
||||||
dividerColor: Color,
|
dividerColor: Color,
|
||||||
gridColor: Color,
|
gridColor: Color,
|
||||||
@@ -169,8 +178,13 @@ private fun WeekRow(
|
|||||||
val rowStartsNewMonth = days[0].dayOfMonth == 1
|
val rowStartsNewMonth = days[0].dayOfMonth == 1
|
||||||
val cwLabel = tr("cal.cw")
|
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)) {
|
BoxWithConstraints(Modifier.fillMaxWidth().height(ROW_HEIGHT)) {
|
||||||
val cellW = maxWidth / 7
|
val cellW = maxWidth / 7
|
||||||
|
|
||||||
|
// Layer 1: day cell backgrounds (borders, day number, KW, overflow count)
|
||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
days.forEachIndexed { idx, day ->
|
days.forEachIndexed { idx, day ->
|
||||||
val edge = when {
|
val edge = when {
|
||||||
@@ -178,13 +192,13 @@ private fun WeekRow(
|
|||||||
rowStartsNewMonth -> DividerEdge.TOP
|
rowStartsNewMonth -> DividerEdge.TOP
|
||||||
else -> DividerEdge.NONE
|
else -> DividerEdge.NONE
|
||||||
}
|
}
|
||||||
DayCell(
|
DayCellBackground(
|
||||||
day = day,
|
day = day,
|
||||||
isToday = day == today,
|
isToday = day == today,
|
||||||
isMonday = day.dayOfWeek == DayOfWeek.MONDAY,
|
isMonday = day.dayOfWeek == DayOfWeek.MONDAY,
|
||||||
weekNumber = day.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR),
|
weekNumber = day.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR),
|
||||||
cwLabel = cwLabel,
|
cwLabel = cwLabel,
|
||||||
events = eventsByDay[day] ?: emptyList(),
|
overflow = packed.overflowPerCol[idx],
|
||||||
lang = lang,
|
lang = lang,
|
||||||
edge = edge,
|
edge = edge,
|
||||||
dividerColor = dividerColor,
|
dividerColor = dividerColor,
|
||||||
@@ -194,11 +208,20 @@ private fun WeekRow(
|
|||||||
todayColor = todayColor,
|
todayColor = todayColor,
|
||||||
onClick = { onDayClick(day) },
|
onClick = { onDayClick(day) },
|
||||||
onLongClick = { onDayLongPress(day) },
|
onLongClick = { onDayLongPress(day) },
|
||||||
onEventClick = onEventClick,
|
|
||||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Layer 2: event bars (absolute, span multiple columns)
|
||||||
|
packed.bars.forEach { bar ->
|
||||||
|
EventBar(
|
||||||
|
bar = bar,
|
||||||
|
cellW = cellW,
|
||||||
|
onClick = { onEventClick(bar.event) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Vertical connector at the month boundary column (the "step").
|
// Vertical connector at the month boundary column (the "step").
|
||||||
if (boundaryCol != null) {
|
if (boundaryCol != null) {
|
||||||
Box(
|
Box(
|
||||||
@@ -212,15 +235,43 @@ private fun WeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventBar(bar: PlacedBar, cellW: androidx.compose.ui.unit.Dp, onClick: () -> Unit) {
|
||||||
|
val color = colorFromHex(bar.event.effectiveColor)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.offset(
|
||||||
|
x = cellW * bar.startCol + 1.dp,
|
||||||
|
y = DAY_NUM_H + (LANE_H + LANE_SPACE) * bar.lane,
|
||||||
|
)
|
||||||
|
.width(cellW * bar.span - 2.dp)
|
||||||
|
.height(LANE_H)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(color)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
bar.event.title,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = color.contrastingTextColor(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayCell(
|
private fun DayCellBackground(
|
||||||
day: LocalDate,
|
day: LocalDate,
|
||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
isMonday: Boolean,
|
isMonday: Boolean,
|
||||||
weekNumber: Int,
|
weekNumber: Int,
|
||||||
cwLabel: String,
|
cwLabel: String,
|
||||||
events: List<CalEvent>,
|
overflow: Int,
|
||||||
lang: String,
|
lang: String,
|
||||||
edge: DividerEdge,
|
edge: DividerEdge,
|
||||||
dividerColor: Color,
|
dividerColor: Color,
|
||||||
@@ -230,7 +281,6 @@ private fun DayCell(
|
|||||||
todayColor: Color,
|
todayColor: Color,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
onEventClick: (CalEvent) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val isFirst = day.dayOfMonth == 1
|
val isFirst = day.dayOfMonth == 1
|
||||||
@@ -241,27 +291,24 @@ private fun DayCell(
|
|||||||
.drawBehind {
|
.drawBehind {
|
||||||
val gridW = 0.5.dp.toPx()
|
val gridW = 0.5.dp.toPx()
|
||||||
val divW = 1.5.dp.toPx()
|
val divW = 1.5.dp.toPx()
|
||||||
// top border (thick divider on a month boundary, otherwise thin grid)
|
|
||||||
if (edge == DividerEdge.TOP) {
|
if (edge == DividerEdge.TOP) {
|
||||||
drawLine(dividerColor, Offset(0f, 0f), Offset(size.width, 0f), divW)
|
drawLine(dividerColor, Offset(0f, 0f), Offset(size.width, 0f), divW)
|
||||||
} else {
|
} else {
|
||||||
drawLine(gridColor, Offset(0f, 0f), Offset(size.width, 0f), gridW)
|
drawLine(gridColor, Offset(0f, 0f), Offset(size.width, 0f), gridW)
|
||||||
}
|
}
|
||||||
// bottom border only when the month ends inside this row
|
|
||||||
if (edge == DividerEdge.BOTTOM) {
|
if (edge == DividerEdge.BOTTOM) {
|
||||||
drawLine(dividerColor, Offset(0f, size.height), Offset(size.width, size.height), divW)
|
drawLine(dividerColor, Offset(0f, size.height), Offset(size.width, size.height), divW)
|
||||||
}
|
}
|
||||||
// right grid line
|
|
||||||
drawLine(gridColor, Offset(size.width, 0f), Offset(size.width, size.height), gridW)
|
drawLine(gridColor, Offset(size.width, 0f), Offset(size.width, size.height), gridW)
|
||||||
}
|
}
|
||||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||||
.padding(horizontal = 1.dp),
|
.padding(horizontal = 1.dp),
|
||||||
) {
|
) {
|
||||||
Column(
|
Row(
|
||||||
Modifier.fillMaxSize().padding(top = 3.dp),
|
Modifier.fillMaxWidth().height(DAY_NUM_H).padding(top = 3.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalArrangement = Arrangement_Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
if (isFirst) {
|
if (isFirst) {
|
||||||
Text(
|
Text(
|
||||||
day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)).uppercase(),
|
day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)).uppercase(),
|
||||||
@@ -283,44 +330,26 @@ private fun DayCell(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
events.take(3).forEach { ev -> EventChip(ev) { onEventClick(ev) } }
|
if (overflow > 0) {
|
||||||
if (events.size > 3) {
|
Text(
|
||||||
Text("+${events.size - 3}", fontSize = 8.sp, color = secondaryText)
|
"+$overflow",
|
||||||
|
fontSize = 8.sp,
|
||||||
|
color = secondaryText,
|
||||||
|
modifier = Modifier.align(Alignment.BottomStart).padding(start = 3.dp, bottom = 1.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Calendar week number, bottom-right of the Monday cell.
|
|
||||||
if (isMonday) {
|
if (isMonday) {
|
||||||
Text(
|
Text(
|
||||||
"$cwLabel $weekNumber",
|
"$cwLabel $weekNumber",
|
||||||
fontSize = 8.sp,
|
fontSize = 8.sp,
|
||||||
color = secondaryText,
|
color = secondaryText,
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 3.dp, bottom = 2.dp),
|
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 3.dp, bottom = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private val Arrangement_Center = androidx.compose.foundation.layout.Arrangement.Center
|
||||||
private fun EventChip(event: CalEvent, onClick: () -> Unit) {
|
|
||||||
val color = colorFromHex(event.effectiveColor)
|
|
||||||
Surface(
|
|
||||||
color = color,
|
|
||||||
shape = RoundedCornerShape(3.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 1.dp, vertical = 0.5.dp)
|
|
||||||
.clickable(onClick = onClick),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
event.title,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
fontSize = 8.sp,
|
|
||||||
color = color.contrastingTextColor(),
|
|
||||||
modifier = Modifier.padding(horizontal = 3.dp, vertical = 1.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate {
|
private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate {
|
||||||
val dow = date.dayOfWeek.value
|
val dow = date.dayOfWeek.value
|
||||||
@@ -328,20 +357,60 @@ private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate {
|
|||||||
return date.minusDays(offset.toLong())
|
return date.minusDays(offset.toLong())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Index events by each local day they overlap, for O(1) lookup while scrolling. */
|
/** Result of packing one week: placed bars + per-column overflow count. */
|
||||||
private fun buildEventsByDay(events: List<CalEvent>): Map<LocalDate, List<CalEvent>> {
|
private class WeekLayout(val bars: List<PlacedBar>, val overflowPerCol: IntArray)
|
||||||
|
|
||||||
|
/** Columns [startCol, startCol+span-1] this event occupies within the given week. */
|
||||||
|
private fun columnRange(weekStart: LocalDate, ev: CalEvent): Pair<Int, Int> {
|
||||||
|
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)
|
||||||
|
val ec = ChronoUnit.DAYS.between(weekStart, evEndDay).toInt().coerceIn(0, 6)
|
||||||
|
return sc to (ec - sc + 1).coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Greedy first-fit lane packing (mirrors iOS packEvents). */
|
||||||
|
private fun packEvents(weekStart: LocalDate, weekEvents: List<CalEvent>): WeekLayout {
|
||||||
|
val sorted = weekEvents.sortedWith(compareBy<CalEvent> { it.startDate }.thenByDescending { it.endDate })
|
||||||
|
val laneLastEnd = ArrayList<Int>()
|
||||||
|
val bars = ArrayList<PlacedBar>()
|
||||||
|
val overflow = IntArray(7)
|
||||||
|
for (ev in sorted) {
|
||||||
|
val (sc, span) = columnRange(weekStart, ev)
|
||||||
|
val lastCol = (sc + span - 1).coerceAtMost(6)
|
||||||
|
var assigned = -1
|
||||||
|
for (i in laneLastEnd.indices) {
|
||||||
|
if (laneLastEnd[i] < sc) { laneLastEnd[i] = lastCol; assigned = i; break }
|
||||||
|
}
|
||||||
|
if (assigned == -1 && laneLastEnd.size < MAX_LANES) {
|
||||||
|
laneLastEnd.add(lastCol); assigned = laneLastEnd.size - 1
|
||||||
|
}
|
||||||
|
if (assigned >= 0) {
|
||||||
|
bars.add(PlacedBar(ev, assigned, sc, span))
|
||||||
|
} else {
|
||||||
|
for (c in sc..lastCol) overflow[c]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return WeekLayout(bars, overflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bucket events into every week (week-start key) they overlap. */
|
||||||
|
private fun buildEventsByWeek(events: List<CalEvent>, mondayFirst: Boolean): Map<LocalDate, List<CalEvent>> {
|
||||||
val map = HashMap<LocalDate, MutableList<CalEvent>>()
|
val map = HashMap<LocalDate, MutableList<CalEvent>>()
|
||||||
for (ev in events) {
|
for (ev in events) {
|
||||||
val first = localDate(ev.startDate)
|
val firstWeek = startOfWeek(localDate(ev.startDate), mondayFirst)
|
||||||
val last = localDate(ev.endDate.minusSeconds(1)).coerceAtLeast(first)
|
val lastDay = localDate(ev.endDate.minusSeconds(1)).let { if (it.isBefore(localDate(ev.startDate))) localDate(ev.startDate) else it }
|
||||||
var d = first
|
val lastWeek = startOfWeek(lastDay, mondayFirst)
|
||||||
|
var w = firstWeek
|
||||||
var guard = 0
|
var guard = 0
|
||||||
while (!d.isAfter(last) && guard < 400) {
|
while (!w.isAfter(lastWeek) && guard < 120) {
|
||||||
map.getOrPut(d) { mutableListOf() }.add(ev)
|
map.getOrPut(w) { mutableListOf() }.add(ev)
|
||||||
d = d.plusDays(1)
|
w = w.plusWeeks(1)
|
||||||
guard++
|
guard++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
map.values.forEach { list -> list.sortBy { it.startDate } }
|
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,11 @@
|
|||||||
<item name="android:navigationBarColor">@android:color/black</item>
|
<item name="android:navigationBarColor">@android:color/black</item>
|
||||||
<item name="android:windowLightStatusBar">false</item>
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- System splash (core-splashscreen); covers the window from frame 0. -->
|
||||||
|
<style name="Theme.Calendarr.Starting" parent="Theme.SplashScreen">
|
||||||
|
<item name="windowSplashScreenBackground">@android:color/black</item>
|
||||||
|
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
|
||||||
|
<item name="postSplashScreenTheme">@style/Theme.Calendarr</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user