From 3734e17c3ff173a1342ac4a55fda794433aa4edf Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 14:40:23 +0200 Subject: [PATCH] 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 --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 2 +- .../com/scarriffle/calendarr/MainActivity.kt | 4 + .../scarriffle/calendarr/ui/CalendarrRoot.kt | 21 +- .../ui/calendar/CalendarFormatting.kt | 10 +- .../calendarr/ui/calendar/CalendarScreen.kt | 68 ++++-- .../ui/calendar/CalendarViewModel.kt | 6 + .../calendarr/ui/calendar/MonthView.kt | 221 ++++++++++++------ app/src/main/res/values/themes.xml | 7 + 9 files changed, 232 insertions(+), 108 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f4833dc..f15059b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,6 +54,7 @@ android { dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") 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("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b7303f7..b7fb240 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ android:name=".MainActivity" android:exported="true" android:screenOrientation="portrait" - android:theme="@style/Theme.Calendarr"> + android:theme="@style/Theme.Calendarr.Starting"> diff --git a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt index db5e8e7..a558078 100644 --- a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt +++ b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt @@ -3,12 +3,16 @@ package com.scarriffle.calendarr import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.scarriffle.calendarr.ui.CalendarrRoot import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { 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) setContent { CalendarrRoot() diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt index c8e63fc..18e9afe 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt @@ -16,29 +16,37 @@ 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()) { val route by vm.route.collectAsState() val settings by vm.settings.collectAsState() - var showSplash by remember { mutableStateOf(true) } - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(1200) - showSplash = false - } - CalendarrTheme(settings) { CompositionLocalProvider( LocalLang provides L10n.resolved(settings.language), LocalAppSettings provides settings, ) { 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) { SplashScreen() return@Surface } + when (route) { AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured) AppRoute.LOGIN -> LoginScreen( @@ -47,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, diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt index 71c016c..0429f36 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt @@ -26,10 +26,14 @@ fun titleForView(viewType: CalViewType, date: LocalDate, lang: String): String { val start = date val fmt = DateTimeFormatter.ofPattern("d. MMM", 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) } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index 4dfe8f3..98d86f4 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -1,5 +1,10 @@ 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.Column import androidx.compose.foundation.layout.PaddingValues @@ -39,6 +44,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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.TextOverflow import androidx.compose.ui.unit.dp @@ -105,6 +111,7 @@ fun CalendarScreen( CompactTopBar( title = barTitle, viewType = state.viewType, + loading = state.isLoading || state.isBackgroundCaching, viewMenuOpen = viewMenuOpen, onMenu = { showMenu = true }, onPrev = { goPrev() }, @@ -126,9 +133,6 @@ fun CalendarScreen( }, ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { - if (state.isLoading || state.isBackgroundCaching) { - LinearProgressIndicator(Modifier.fillMaxWidth()) - } state.error?.let { err -> ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError) } @@ -172,23 +176,33 @@ fun CalendarScreen( ) } - detailEvent?.let { ev -> - EventDetailScreen( - event = ev, - onClose = { detailEvent = null }, - onEdit = { - detailEvent = null - editor = EditorRequest(ev, localDate(ev.startDate)) - }, - onCopy = { - detailEvent = null - editor = EditorRequest(existing = null, date = localDate(ev.startDate), prefill = ev) - }, - onDelete = { - vm.deleteEvent(ev) {} - detailEvent = null - }, - ) + // Keep the last event during the close animation. + var lastDetail by remember { mutableStateOf(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( + event = ev, + onClose = { detailEvent = null }, + onEdit = { + detailEvent = null + editor = EditorRequest(ev, localDate(ev.startDate)) + }, + onCopy = { + detailEvent = null + editor = EditorRequest(existing = null, date = localDate(ev.startDate), prefill = ev) + }, + onDelete = { + vm.deleteEvent(ev) {} + detailEvent = null + }, + ) + } } editor?.let { req -> @@ -279,6 +293,7 @@ private fun loadingPlaceholder() { private fun CompactTopBar( title: String, viewType: CalViewType, + loading: Boolean, viewMenuOpen: Boolean, onMenu: () -> Unit, onPrev: () -> Unit, @@ -288,6 +303,7 @@ private fun CompactTopBar( onViewMenuToggle: (Boolean) -> Unit, onSelectView: (CalViewType) -> Unit, ) { + val twoLine = viewType == CalViewType.WEEK || viewType == CalViewType.DAY Surface(color = MaterialTheme.colorScheme.background) { Row( Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp), @@ -302,10 +318,18 @@ private fun CompactTopBar( title, modifier = Modifier.weight(1f).padding(horizontal = 4.dp), textAlign = TextAlign.Center, - maxLines = 1, + maxLines = 2, 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")) Box { CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change")) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index d48bb08..3d9b50e 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -49,6 +49,10 @@ class CalendarViewModel @Inject constructor( private val _state = MutableStateFlow(initialState()) val state: StateFlow = _state.asStateFlow() + /** True once the first event load has completed — used to gate the splash. */ + private val _ready = MutableStateFlow(false) + val ready: StateFlow = _ready.asStateFlow() + // Cache bookkeeping private var cachedStart: Instant? = null private var cachedEnd: Instant? = null @@ -143,6 +147,7 @@ class CalendarViewModel @Inject constructor( val (start, end) = rangeForCurrentView() if (!force && isCached(start, end)) { refreshFromCache() + _ready.value = true return } viewModelScope.launch { @@ -156,6 +161,7 @@ class CalendarViewModel @Inject constructor( .onFailure { e -> _state.update { it.copy(isLoading = false, error = e.message) } } + _ready.value = true } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt index 3210a80..e55831f 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -52,11 +51,18 @@ import kotlinx.coroutines.flow.map private const val MONTHS_BACK = 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 } -/** 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 fun MonthView( state: CalendarUiState, @@ -79,6 +85,7 @@ fun MonthView( val labelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant) val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = gridLineOpacity(settings.lineContrast)) val secondaryText = MaterialTheme.colorScheme.onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast)) + val todayColor = colorFromHex(settings.todayColor) val firstVisible = remember(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()) { Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) { @@ -130,16 +138,17 @@ fun MonthView( LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { items(weekCount) { index -> + val weekStart = firstVisible.plusWeeks(index.toLong()) WeekRow( - weekStart = firstVisible.plusWeeks(index.toLong()), + weekStart = weekStart, today = today, - eventsByDay = eventsByDay, + weekEvents = eventsByWeek[weekStart] ?: emptyList(), lang = lang, dividerColor = dividerColor, gridColor = gridColor, labelColor = labelColor, secondaryText = secondaryText, - todayColor = colorFromHex(settings.todayColor), + todayColor = todayColor, onDayClick = onDayClick, onDayLongPress = onDayLongPress, onEventClick = onEventClick, @@ -153,7 +162,7 @@ fun MonthView( private fun WeekRow( weekStart: LocalDate, today: LocalDate, - eventsByDay: Map>, + weekEvents: List, lang: String, dividerColor: Color, gridColor: Color, @@ -169,8 +178,13 @@ private fun WeekRow( val rowStartsNewMonth = days[0].dayOfMonth == 1 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)) { val cellW = maxWidth / 7 + + // Layer 1: day cell backgrounds (borders, day number, KW, overflow count) Row(Modifier.fillMaxSize()) { days.forEachIndexed { idx, day -> val edge = when { @@ -178,13 +192,13 @@ private fun WeekRow( rowStartsNewMonth -> DividerEdge.TOP else -> DividerEdge.NONE } - DayCell( + DayCellBackground( day = day, isToday = day == today, isMonday = day.dayOfWeek == DayOfWeek.MONDAY, weekNumber = day.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR), cwLabel = cwLabel, - events = eventsByDay[day] ?: emptyList(), + overflow = packed.overflowPerCol[idx], lang = lang, edge = edge, dividerColor = dividerColor, @@ -194,11 +208,20 @@ private fun WeekRow( todayColor = todayColor, onClick = { onDayClick(day) }, onLongClick = { onDayLongPress(day) }, - onEventClick = onEventClick, 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"). if (boundaryCol != null) { 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) @Composable -private fun DayCell( +private fun DayCellBackground( day: LocalDate, isToday: Boolean, isMonday: Boolean, weekNumber: Int, cwLabel: String, - events: List, + overflow: Int, lang: String, edge: DividerEdge, dividerColor: Color, @@ -230,7 +281,6 @@ private fun DayCell( todayColor: Color, onClick: () -> Unit, onLongClick: () -> Unit, - onEventClick: (CalEvent) -> Unit, modifier: Modifier = Modifier, ) { val isFirst = day.dayOfMonth == 1 @@ -241,86 +291,65 @@ private fun DayCell( .drawBehind { val gridW = 0.5.dp.toPx() val divW = 1.5.dp.toPx() - // top border (thick divider on a month boundary, otherwise thin grid) if (edge == DividerEdge.TOP) { drawLine(dividerColor, Offset(0f, 0f), Offset(size.width, 0f), divW) } else { drawLine(gridColor, Offset(0f, 0f), Offset(size.width, 0f), gridW) } - // bottom border only when the month ends inside this row if (edge == DividerEdge.BOTTOM) { 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) } .combinedClickable(onClick = onClick, onLongClick = onLongClick) .padding(horizontal = 1.dp), ) { - Column( - Modifier.fillMaxSize().padding(top = 3.dp), - horizontalAlignment = Alignment.CenterHorizontally, + Row( + Modifier.fillMaxWidth().height(DAY_NUM_H).padding(top = 3.dp), + horizontalArrangement = Arrangement_Center, + verticalAlignment = Alignment.CenterVertically, ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (isFirst) { - Text( - day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)).uppercase(), - fontSize = 8.sp, - fontWeight = FontWeight.SemiBold, - color = labelColor, - modifier = Modifier.padding(end = 2.dp), - ) - } - Box(contentAlignment = Alignment.Center) { - if (isToday) { - Box(Modifier.height(18.dp).width(18.dp).clip(RoundedCornerShape(9.dp)).background(todayColor)) - } - Text( - "${day.dayOfMonth}", - fontSize = 11.sp, - fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, - color = if (isToday) todayColor.contrastingTextColor() else onBg, - ) - } + if (isFirst) { + Text( + day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)).uppercase(), + fontSize = 8.sp, + fontWeight = FontWeight.SemiBold, + color = labelColor, + modifier = Modifier.padding(end = 2.dp), + ) } - events.take(3).forEach { ev -> EventChip(ev) { onEventClick(ev) } } - if (events.size > 3) { - Text("+${events.size - 3}", fontSize = 8.sp, color = secondaryText) + Box(contentAlignment = Alignment.Center) { + if (isToday) { + Box(Modifier.height(18.dp).width(18.dp).clip(RoundedCornerShape(9.dp)).background(todayColor)) + } + Text( + "${day.dayOfMonth}", + fontSize = 11.sp, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + color = if (isToday) todayColor.contrastingTextColor() else onBg, + ) } } - // Calendar week number, bottom-right of the Monday cell. + if (overflow > 0) { + Text( + "+$overflow", + fontSize = 8.sp, + color = secondaryText, + modifier = Modifier.align(Alignment.BottomStart).padding(start = 3.dp, bottom = 1.dp), + ) + } if (isMonday) { Text( "$cwLabel $weekNumber", fontSize = 8.sp, 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 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 val Arrangement_Center = androidx.compose.foundation.layout.Arrangement.Center private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate { val dow = date.dayOfWeek.value @@ -328,20 +357,60 @@ private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate { return date.minusDays(offset.toLong()) } -/** Index events by each local day they overlap, for O(1) lookup while scrolling. */ -private fun buildEventsByDay(events: List): Map> { +/** Result of packing one week: placed bars + per-column overflow count. */ +private class WeekLayout(val bars: List, val overflowPerCol: IntArray) + +/** Columns [startCol, startCol+span-1] this event occupies within the given week. */ +private fun columnRange(weekStart: LocalDate, ev: CalEvent): Pair { + 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): WeekLayout { + val sorted = weekEvents.sortedWith(compareBy { it.startDate }.thenByDescending { it.endDate }) + val laneLastEnd = ArrayList() + val bars = ArrayList() + 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, mondayFirst: Boolean): Map> { val map = HashMap>() for (ev in events) { - val first = localDate(ev.startDate) - val last = localDate(ev.endDate.minusSeconds(1)).coerceAtLeast(first) - var d = first + val firstWeek = startOfWeek(localDate(ev.startDate), mondayFirst) + val lastDay = localDate(ev.endDate.minusSeconds(1)).let { if (it.isBefore(localDate(ev.startDate))) localDate(ev.startDate) else it } + val lastWeek = startOfWeek(lastDay, mondayFirst) + var w = firstWeek var guard = 0 - while (!d.isAfter(last) && guard < 400) { - map.getOrPut(d) { mutableListOf() }.add(ev) - d = d.plusDays(1) + while (!w.isAfter(lastWeek) && guard < 120) { + map.getOrPut(w) { mutableListOf() }.add(ev) + w = w.plusWeeks(1) guard++ } } - map.values.forEach { list -> list.sortBy { it.startDate } } return map } diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 4ff5eff..45c14a3 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -8,4 +8,11 @@ @android:color/black false + + +