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:
Guido Schmit
2026-05-31 14:40:23 +02:00
parent b8eb6597ec
commit 3734e17c3f
9 changed files with 232 additions and 108 deletions

View File

@@ -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")

View File

@@ -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" />

View File

@@ -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()

View File

@@ -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,

View File

@@ -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)
} }
} }

View File

@@ -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,23 +176,33 @@ fun CalendarScreen(
) )
} }
detailEvent?.let { ev -> // Keep the last event during the close animation.
EventDetailScreen( var lastDetail by remember { mutableStateOf<CalEvent?>(null) }
event = ev, detailEvent?.let { lastDetail = it }
onClose = { detailEvent = null }, AnimatedVisibility(
onEdit = { visible = detailEvent != null,
detailEvent = null enter = slideInVertically(initialOffsetY = { it / 6 }) + fadeIn(),
editor = EditorRequest(ev, localDate(ev.startDate)) exit = slideOutVertically(targetOffsetY = { it / 6 }) + fadeOut(),
}, ) {
onCopy = { val ev = lastDetail
detailEvent = null if (ev != null) {
editor = EditorRequest(existing = null, date = localDate(ev.startDate), prefill = ev) EventDetailScreen(
}, event = ev,
onDelete = { onClose = { detailEvent = null },
vm.deleteEvent(ev) {} onEdit = {
detailEvent = null 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 -> editor?.let { req ->
@@ -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"))

View File

@@ -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
} }
} }

View File

@@ -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,86 +291,65 @@ 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(), fontSize = 8.sp,
fontSize = 8.sp, fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.SemiBold, color = labelColor,
color = labelColor, modifier = Modifier.padding(end = 2.dp),
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,
)
}
} }
events.take(3).forEach { ev -> EventChip(ev) { onEventClick(ev) } } Box(contentAlignment = Alignment.Center) {
if (events.size > 3) { if (isToday) {
Text("+${events.size - 3}", fontSize = 8.sp, color = secondaryText) 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) { 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
} }

View File

@@ -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>