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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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(
|
||||
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"))
|
||||
|
||||
@@ -49,6 +49,10 @@ class CalendarViewModel @Inject constructor(
|
||||
private val _state = MutableStateFlow(initialState())
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<LocalDate, List<CalEvent>>,
|
||||
weekEvents: List<CalEvent>,
|
||||
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<CalEvent>,
|
||||
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<CalEvent>): Map<LocalDate, List<CalEvent>> {
|
||||
/** Result of packing one week: placed bars + per-column overflow count. */
|
||||
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>>()
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user