feat: scrolling month calendar, green theme fixes, long-press create, event colors
- Month view rewritten as a continuous vertical scroll (iOS-style): no prev/next buttons, compact fixed-height week rows, month label on the 1st, top-bar title follows the visible month, Today scrolls back to today - Long-press a day opens the event editor pre-filled with that date - Theme: green container/tint colors so the FAB and accents are no longer the Material default purple - Event colors: parse #RGB shorthand, and fall back to a stable per-calendar palette colour instead of a constant blue when the server omits a colour - Top bar hides prev/next arrows in month view (scroll instead) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,10 +23,26 @@ data class CalEvent(
|
|||||||
val calendarColor: String,
|
val calendarColor: String,
|
||||||
val source: String,
|
val source: String,
|
||||||
) {
|
) {
|
||||||
/** Per-event override colour, falling back to the calendar's colour. */
|
/**
|
||||||
val effectiveColor: String get() = color?.takeIf { it.isNotBlank() } ?: calendarColor
|
* Per-event override colour, then the calendar's colour, then a stable
|
||||||
|
* per-calendar palette colour (so events never collapse to one default).
|
||||||
|
*/
|
||||||
|
val effectiveColor: String
|
||||||
|
get() = color?.takeIf { it.isNotBlank() }
|
||||||
|
?: calendarColor.takeIf { it.isNotBlank() }
|
||||||
|
?: fallbackColorFor("$source:$calendarId")
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private val FALLBACK_PALETTE = listOf(
|
||||||
|
"#34a853", "#4285f4", "#ea4335", "#fbbc05",
|
||||||
|
"#46bdc6", "#9c27b0", "#ff7043", "#7090c0",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fallbackColorFor(key: String): String {
|
||||||
|
val idx = (key.hashCode().and(Int.MAX_VALUE)) % FALLBACK_PALETTE.size
|
||||||
|
return FALLBACK_PALETTE[idx]
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse one event object from the `/api/caldav/events` aggregate response. */
|
/** Parse one event object from the `/api/caldav/events` aggregate response. */
|
||||||
fun fromJson(json: JSONObject): CalEvent? {
|
fun fromJson(json: JSONObject): CalEvent? {
|
||||||
val title = json.optString("title").takeIf { json.has("title") } ?: return null
|
val title = json.optString("title").takeIf { json.has("title") } ?: return null
|
||||||
@@ -63,7 +79,7 @@ data class CalEvent(
|
|||||||
color = colorRaw.takeIf { it.isNotBlank() },
|
color = colorRaw.takeIf { it.isNotBlank() },
|
||||||
calendarId = calendarId,
|
calendarId = calendarId,
|
||||||
calendarName = json.optString("calendar_name", ""),
|
calendarName = json.optString("calendar_name", ""),
|
||||||
calendarColor = json.optString("calendarColor", "#4285f4"),
|
calendarColor = json.optString("calendarColor", ""),
|
||||||
source = json.optString("source", "local"),
|
source = json.optString("source", "local"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -72,15 +74,19 @@ fun CalendarScreen(
|
|||||||
var editor by remember { mutableStateOf<EditorRequest?>(null) }
|
var editor by remember { mutableStateOf<EditorRequest?>(null) }
|
||||||
var overlay by remember { mutableStateOf(Overlay.NONE) }
|
var overlay by remember { mutableStateOf(Overlay.NONE) }
|
||||||
|
|
||||||
|
// Continuous month scrolling
|
||||||
|
val monthListState = rememberLazyListState()
|
||||||
|
var todaySignal by remember { mutableIntStateOf(0) }
|
||||||
|
var visibleMonth by remember { mutableStateOf(state.currentDate) }
|
||||||
|
val isMonth = state.viewType == CalViewType.MONTH
|
||||||
|
val barTitle = if (isMonth) titleForView(CalViewType.MONTH, visibleMonth, lang)
|
||||||
|
else titleForView(state.viewType, state.currentDate, lang)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(barTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
titleForView(state.viewType, state.currentDate, lang),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { showMenu = true }) {
|
IconButton(onClick = { showMenu = true }) {
|
||||||
@@ -88,15 +94,19 @@ fun CalendarScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
if (!isMonth) {
|
||||||
IconButton(onClick = vm::navigatePrev) {
|
IconButton(onClick = vm::navigatePrev) {
|
||||||
Icon(Icons.Filled.ChevronLeft, contentDescription = null)
|
Icon(Icons.Filled.ChevronLeft, contentDescription = null)
|
||||||
}
|
}
|
||||||
IconButton(onClick = vm::moveToToday) {
|
}
|
||||||
|
IconButton(onClick = { if (isMonth) todaySignal++ else vm.moveToToday() }) {
|
||||||
Icon(Icons.Filled.Today, contentDescription = tr("nav.today"))
|
Icon(Icons.Filled.Today, contentDescription = tr("nav.today"))
|
||||||
}
|
}
|
||||||
|
if (!isMonth) {
|
||||||
IconButton(onClick = vm::navigateNext) {
|
IconButton(onClick = vm::navigateNext) {
|
||||||
Icon(Icons.Filled.ChevronRight, contentDescription = null)
|
Icon(Icons.Filled.ChevronRight, contentDescription = null)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
IconButton(onClick = { showFilter = true }) {
|
IconButton(onClick = { showFilter = true }) {
|
||||||
Icon(Icons.Filled.FilterList, contentDescription = tr("filter.button"))
|
Icon(Icons.Filled.FilterList, contentDescription = tr("filter.button"))
|
||||||
}
|
}
|
||||||
@@ -121,7 +131,11 @@ fun CalendarScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(onClick = { editor = EditorRequest(null, state.currentDate) }) {
|
FloatingActionButton(
|
||||||
|
onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
) {
|
||||||
Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event"))
|
Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event"))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -137,9 +151,12 @@ fun CalendarScreen(
|
|||||||
CalendarBody(
|
CalendarBody(
|
||||||
state = state,
|
state = state,
|
||||||
vm = vm,
|
vm = vm,
|
||||||
|
monthListState = monthListState,
|
||||||
|
scrollToTodaySignal = todaySignal,
|
||||||
|
onVisibleMonthChange = { visibleMonth = it },
|
||||||
onEventClick = { detailEvent = it },
|
onEventClick = { detailEvent = it },
|
||||||
onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) },
|
onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) },
|
||||||
onEmptySlotClick = { date -> editor = EditorRequest(null, date) },
|
onDayLongPress = { date -> editor = EditorRequest(null, date) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,12 +232,24 @@ fun CalendarScreen(
|
|||||||
private fun CalendarBody(
|
private fun CalendarBody(
|
||||||
state: CalendarUiState,
|
state: CalendarUiState,
|
||||||
vm: CalendarViewModel,
|
vm: CalendarViewModel,
|
||||||
|
monthListState: androidx.compose.foundation.lazy.LazyListState,
|
||||||
|
scrollToTodaySignal: Int,
|
||||||
|
onVisibleMonthChange: (LocalDate) -> Unit,
|
||||||
onEventClick: (CalEvent) -> Unit,
|
onEventClick: (CalEvent) -> Unit,
|
||||||
onDayClick: (LocalDate) -> Unit,
|
onDayClick: (LocalDate) -> Unit,
|
||||||
onEmptySlotClick: (LocalDate) -> Unit,
|
onDayLongPress: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state.viewType) {
|
when (state.viewType) {
|
||||||
CalViewType.MONTH -> MonthView(state, vm, onDayClick, onEventClick)
|
CalViewType.MONTH -> MonthView(
|
||||||
|
state = state,
|
||||||
|
vm = vm,
|
||||||
|
listState = monthListState,
|
||||||
|
scrollToTodaySignal = scrollToTodaySignal,
|
||||||
|
onVisibleMonthChange = onVisibleMonthChange,
|
||||||
|
onDayClick = onDayClick,
|
||||||
|
onDayLongPress = onDayLongPress,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
)
|
||||||
CalViewType.WEEK -> WeekView(state, vm, onEventClick)
|
CalViewType.WEEK -> WeekView(state, vm, onEventClick)
|
||||||
CalViewType.DAY -> DayView(state, vm, onEventClick)
|
CalViewType.DAY -> DayView(state, vm, onEventClick)
|
||||||
CalViewType.QUARTER -> QuarterView(state, onDayClick)
|
CalViewType.QUARTER -> QuarterView(state, onDayClick)
|
||||||
|
|||||||
@@ -159,6 +159,21 @@ class CalendarViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** On-demand load for a month the user scrolled to in the continuous calendar. */
|
||||||
|
fun ensureMonthLoaded(monthAnchor: LocalDate) {
|
||||||
|
val first = monthAnchor.withDayOfMonth(1)
|
||||||
|
val start = instant(first.minusMonths(1))
|
||||||
|
val end = instant(first.plusMonths(2))
|
||||||
|
if (isCached(start, end)) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true) }
|
||||||
|
runCatching { repository.fetchEvents(start, end) }
|
||||||
|
.onSuccess { mergeIntoCache(it, start, end); refreshFromCache() }
|
||||||
|
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||||
|
_state.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun prefetchBackground() {
|
private fun prefetchBackground() {
|
||||||
val months = settingsStore.cacheMonths
|
val months = settingsStore.cacheMonths
|
||||||
val today = LocalDate.now().withDayOfMonth(1)
|
val today = LocalDate.now().withDayOfMonth(1)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.scarriffle.calendarr.ui.calendar
|
package com.scarriffle.calendarr.ui.calendar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
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.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -10,15 +12,23 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Divider
|
||||||
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.remember
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -28,111 +38,183 @@ import com.scarriffle.calendarr.ui.LocalLang
|
|||||||
import com.scarriffle.calendarr.util.colorFromHex
|
import com.scarriffle.calendarr.util.colorFromHex
|
||||||
import com.scarriffle.calendarr.util.contrastingTextColor
|
import com.scarriffle.calendarr.util.contrastingTextColor
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.temporal.IsoFields
|
import java.time.format.TextStyle
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
private const val MONTHS_BACK = 18L
|
||||||
|
private const val MONTHS_AHEAD = 18L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Continuous, vertically scrolling month calendar (matches the iOS app).
|
||||||
|
* There are no prev/next buttons — the user scrolls through weeks and the
|
||||||
|
* top-bar title follows the currently visible month.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MonthView(
|
fun MonthView(
|
||||||
state: CalendarUiState,
|
state: CalendarUiState,
|
||||||
vm: CalendarViewModel,
|
vm: CalendarViewModel,
|
||||||
|
listState: LazyListState,
|
||||||
|
scrollToTodaySignal: Int,
|
||||||
|
onVisibleMonthChange: (LocalDate) -> Unit,
|
||||||
onDayClick: (LocalDate) -> Unit,
|
onDayClick: (LocalDate) -> Unit,
|
||||||
|
onDayLongPress: (LocalDate) -> Unit,
|
||||||
onEventClick: (CalEvent) -> Unit,
|
onEventClick: (CalEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
val lang = LocalLang.current
|
val lang = LocalLang.current
|
||||||
val mondayFirst = state.weekStartsOnMonday
|
val mondayFirst = state.weekStartsOnMonday
|
||||||
val today = LocalDate.now()
|
val today = LocalDate.now()
|
||||||
val firstOfMonth = state.currentDate.withDayOfMonth(1)
|
|
||||||
val firstVisible = startOfWeekFor(firstOfMonth, mondayFirst)
|
val firstVisible = remember(mondayFirst) {
|
||||||
val weeks = (0 until 6).map { w -> (0 until 7).map { d -> firstVisible.plusDays((w * 7 + d).toLong()) } }
|
startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst)
|
||||||
|
}
|
||||||
|
val end = remember(mondayFirst) {
|
||||||
|
startOfWeek(today.withDayOfMonth(1).plusMonths(MONTHS_AHEAD), mondayFirst)
|
||||||
|
}
|
||||||
|
val weekCount = remember(firstVisible, end) {
|
||||||
|
(ChronoUnit.WEEKS.between(firstVisible, end).toInt() + 1).coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
val todayIndex = remember(firstVisible) {
|
||||||
|
ChronoUnit.WEEKS.between(firstVisible, startOfWeek(today, mondayFirst)).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial scroll to today's week.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
listState.scrollToItem((todayIndex - 1).coerceAtLeast(0))
|
||||||
|
}
|
||||||
|
// "Today" button.
|
||||||
|
LaunchedEffect(scrollToTodaySignal) {
|
||||||
|
if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0))
|
||||||
|
}
|
||||||
|
// Track visible month + trigger on-demand loads.
|
||||||
|
LaunchedEffect(listState, weekCount) {
|
||||||
|
snapshotFlow { listState.firstVisibleItemIndex }.collect { idx ->
|
||||||
|
val weekStart = firstVisible.plusWeeks(idx.toLong())
|
||||||
|
val month = weekStart.plusDays(3) // mid-week → representative month
|
||||||
|
onVisibleMonthChange(month)
|
||||||
|
vm.ensureMonthLoaded(month)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
// Header: CW + weekdays
|
// Fixed weekday header
|
||||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||||
Box(Modifier.width(28.dp))
|
|
||||||
weekdayLabels(mondayFirst, lang).forEach { label ->
|
weekdayLabels(mondayFirst, lang).forEach { label ->
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
weeks.forEach { week ->
|
Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
|
||||||
Row(Modifier.fillMaxWidth().weight(1f)) {
|
|
||||||
val cw = week.first().get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)
|
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||||
Box(Modifier.width(28.dp).fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
items(weekCount) { index ->
|
||||||
Text(
|
val weekStart = firstVisible.plusWeeks(index.toLong())
|
||||||
"$cw",
|
WeekRow(
|
||||||
style = MaterialTheme.typography.labelSmall,
|
weekStart = weekStart,
|
||||||
color = MaterialTheme.colorScheme.outline,
|
today = today,
|
||||||
fontSize = 9.sp,
|
events = state.events,
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
vm = vm,
|
||||||
|
lang = lang,
|
||||||
|
onDayClick = onDayClick,
|
||||||
|
onDayLongPress = onDayLongPress,
|
||||||
|
onEventClick = onEventClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
week.forEach { day ->
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun WeekRow(
|
||||||
|
weekStart: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
events: List<CalEvent>,
|
||||||
|
vm: CalendarViewModel,
|
||||||
|
lang: String,
|
||||||
|
onDayClick: (LocalDate) -> Unit,
|
||||||
|
onDayLongPress: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (CalEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
val days = (0 until 7).map { weekStart.plusDays(it.toLong()) }
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(78.dp),
|
||||||
|
) {
|
||||||
|
days.forEach { day ->
|
||||||
DayCell(
|
DayCell(
|
||||||
day = day,
|
day = day,
|
||||||
inMonth = day.month == firstOfMonth.month,
|
|
||||||
isToday = day == today,
|
isToday = day == today,
|
||||||
events = vm.eventsOn(day, state.events),
|
events = vm.eventsOn(day, events),
|
||||||
|
lang = lang,
|
||||||
onClick = { onDayClick(day) },
|
onClick = { onDayClick(day) },
|
||||||
|
onLongClick = { onDayLongPress(day) },
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayCell(
|
private fun DayCell(
|
||||||
day: LocalDate,
|
day: LocalDate,
|
||||||
inMonth: Boolean,
|
|
||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
events: List<CalEvent>,
|
events: List<CalEvent>,
|
||||||
|
lang: String,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
onEventClick: (CalEvent) -> Unit,
|
onEventClick: (CalEvent) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val todayColor = colorFromHex(LocalAppSettings.current.todayColor)
|
val settings = LocalAppSettings.current
|
||||||
|
val todayColor = colorFromHex(settings.todayColor)
|
||||||
|
val monthLabelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
val isFirst = day.dayOfMonth == 1
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(1.dp)
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||||
.clickable(onClick = onClick),
|
.padding(horizontal = 1.dp, vertical = 2.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(top = 2.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (isFirst) {
|
||||||
|
Text(
|
||||||
|
day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)),
|
||||||
|
fontSize = 8.sp,
|
||||||
|
color = monthLabelColor,
|
||||||
|
modifier = Modifier.padding(end = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
if (isToday) {
|
if (isToday) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.height(22.dp).width(22.dp)
|
.height(18.dp)
|
||||||
.clip(RoundedCornerShape(11.dp))
|
.width(18.dp)
|
||||||
|
.clip(RoundedCornerShape(9.dp))
|
||||||
.background(todayColor),
|
.background(todayColor),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"${day.dayOfMonth}",
|
"${day.dayOfMonth}",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
fontSize = 11.sp,
|
||||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||||
color = when {
|
color = if (isToday) todayColor.contrastingTextColor() else MaterialTheme.colorScheme.onBackground,
|
||||||
isToday -> todayColor.contrastingTextColor()
|
|
||||||
inMonth -> MaterialTheme.colorScheme.onBackground
|
|
||||||
else -> MaterialTheme.colorScheme.outline
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
events.take(3).forEach { ev ->
|
|
||||||
EventChip(ev, onClick = { onEventClick(ev) })
|
|
||||||
}
|
}
|
||||||
|
events.take(3).forEach { ev -> EventChip(ev) { onEventClick(ev) } }
|
||||||
if (events.size > 3) {
|
if (events.size > 3) {
|
||||||
Text(
|
Text("+${events.size - 3}", fontSize = 8.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
"+${events.size - 3}",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
fontSize = 8.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,26 +222,26 @@ private fun DayCell(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun EventChip(event: CalEvent, onClick: () -> Unit) {
|
private fun EventChip(event: CalEvent, onClick: () -> Unit) {
|
||||||
val color = colorFromHex(event.effectiveColor)
|
val color = colorFromHex(event.effectiveColor)
|
||||||
Box(
|
Surface(
|
||||||
Modifier
|
color = color,
|
||||||
|
shape = RoundedCornerShape(3.dp),
|
||||||
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 1.dp, vertical = 1.dp)
|
.padding(horizontal = 1.dp, vertical = 0.5.dp)
|
||||||
.clip(RoundedCornerShape(3.dp))
|
.clickable(onClick = onClick),
|
||||||
.background(color)
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(horizontal = 3.dp, vertical = 1.dp),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
event.title,
|
event.title,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
fontSize = 9.sp,
|
fontSize = 8.sp,
|
||||||
color = color.contrastingTextColor(),
|
color = color.contrastingTextColor(),
|
||||||
|
modifier = Modifier.padding(horizontal = 3.dp, vertical = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startOfWeekFor(date: LocalDate, mondayFirst: Boolean): LocalDate {
|
private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate {
|
||||||
val dow = date.dayOfWeek.value
|
val dow = date.dayOfWeek.value
|
||||||
val offset = if (mondayFirst) dow - 1 else dow % 7
|
val offset = if (mondayFirst) dow - 1 else dow % 7
|
||||||
return date.minusDays(offset.toLong())
|
return date.minusDays(offset.toLong())
|
||||||
|
|||||||
@@ -25,12 +25,22 @@ fun CalendarrTheme(
|
|||||||
) {
|
) {
|
||||||
val primary = BrandGreen
|
val primary = BrandGreen
|
||||||
|
|
||||||
|
val container = Color(0xFF14532D)
|
||||||
|
val onContainer = Color(0xFFB7F0C6)
|
||||||
val colors = darkColorScheme(
|
val colors = darkColorScheme(
|
||||||
primary = primary,
|
primary = primary,
|
||||||
onPrimary = primary.contrastingTextColor(),
|
onPrimary = primary.contrastingTextColor(),
|
||||||
|
primaryContainer = container,
|
||||||
|
onPrimaryContainer = onContainer,
|
||||||
secondary = primary,
|
secondary = primary,
|
||||||
onSecondary = primary.contrastingTextColor(),
|
onSecondary = primary.contrastingTextColor(),
|
||||||
|
secondaryContainer = container,
|
||||||
|
onSecondaryContainer = onContainer,
|
||||||
tertiary = primary,
|
tertiary = primary,
|
||||||
|
onTertiary = primary.contrastingTextColor(),
|
||||||
|
tertiaryContainer = container,
|
||||||
|
onTertiaryContainer = onContainer,
|
||||||
|
surfaceTint = primary,
|
||||||
background = Color(0xFF000000),
|
background = Color(0xFF000000),
|
||||||
onBackground = Color(0xFFF2F2F7),
|
onBackground = Color(0xFFF2F2F7),
|
||||||
surface = Color(0xFF1C1C1E),
|
surface = Color(0xFF1C1C1E),
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ import androidx.compose.ui.graphics.Color
|
|||||||
/** Parse a "#RRGGBB" (or "RRGGBB") hex string into a Compose [Color]. */
|
/** Parse a "#RRGGBB" (or "RRGGBB") hex string into a Compose [Color]. */
|
||||||
fun colorFromHex(hex: String?, fallback: Color = Color(0xFF4285F4)): Color {
|
fun colorFromHex(hex: String?, fallback: Color = Color(0xFF4285F4)): Color {
|
||||||
if (hex.isNullOrBlank()) return fallback
|
if (hex.isNullOrBlank()) return fallback
|
||||||
val clean = hex.trim().removePrefix("#")
|
val clean = hex.trim().removePrefix("#").filter { it.isLetterOrDigit() }
|
||||||
return when (clean.length) {
|
return when (clean.length) {
|
||||||
|
3 -> runCatching {
|
||||||
|
// #RGB shorthand -> expand each nibble
|
||||||
|
val r = clean[0].digitToInt(16) * 17
|
||||||
|
val g = clean[1].digitToInt(16) * 17
|
||||||
|
val b = clean[2].digitToInt(16) * 17
|
||||||
|
Color(r / 255f, g / 255f, b / 255f, 1f)
|
||||||
|
}.getOrDefault(fallback)
|
||||||
6 -> runCatching {
|
6 -> runCatching {
|
||||||
val v = clean.toLong(16)
|
val v = clean.toLong(16)
|
||||||
Color(
|
Color(
|
||||||
|
|||||||
Reference in New Issue
Block a user