nordiccalendar

A simple Material 3 native calendar app fitting my personal requirements
git clone git://archive.git.mtrnord.blog/MTRNord/nordiccalendar.git
Log | Files | Refs | README | LICENSE

commit 97b116890132ecb990b41388be723957ac3d189d
parent f8436373490c6f0214c780d997c880d5e0dd1598
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Fri,  1 Aug 2025 23:06:36 +0200

Improve the month grid as well

Diffstat:
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/DayView.kt | 49++++++++++++++++++++++++++++++++++++++-----------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/MonthView.kt | 389++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/WeekView.kt | 588+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Aapp/src/main/java/space/midnightthoughts/nordiccalendar/util/ColorUtils.kt | 36++++++++++++++++++++++++++++++++++++
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt | 20+++++++++++++++++---
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/SpecializedCalendarViewModels.kt | 7+++++--
Mapp/src/main/res/values-en/strings.xml | 1+
Mapp/src/main/res/values/strings.xml | 1+
8 files changed, 941 insertions(+), 150 deletions(-)

diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/DayView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/DayView.kt @@ -53,15 +53,18 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import kotlinx.coroutines.delay import space.midnightthoughts.nordiccalendar.getCurrentAppLocale +import space.midnightthoughts.nordiccalendar.util.ColorUtils import space.midnightthoughts.nordiccalendar.util.Event +import space.midnightthoughts.nordiccalendar.viewmodels.DayViewModel +import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Date import java.util.PriorityQueue /** - * Assigns columns to events so that overlapping events are displayed side by side. - * Each event is assigned a column index and the total number of columns for its overlap group. + * Improved algorithm to assign columns to events so that overlapping events are displayed side by side. + * This is ported from WeekView and provides better column allocation. * * @param events List of events to assign columns to. * @return List of Triple<Event, columnIndex, maxColumns> for layout. @@ -73,7 +76,6 @@ private fun assignColumns(events: List<Event>): List<Triple<Event, Int, Int>> { val active = PriorityQueue(compareBy<ActiveEvent> { it.endTime }) val freeColumns = PriorityQueue<Int>() val result = mutableListOf<Triple<Event, Int, Int>>() - var maxColumns = 0 for (event in sorted) { // Remove expired events and free their columns @@ -82,10 +84,15 @@ private fun assignColumns(events: List<Event>): List<Triple<Event, Int, Int>> { } val col = if (freeColumns.isNotEmpty()) freeColumns.poll() else active.size active.add(ActiveEvent(event.endTime, col)) - maxColumns = maxOf(maxColumns, active.size) + + // Calculate maxColumns for all concurrent events + val maxColumns = active.size result.add(Triple(event, col, maxColumns)) } - return result + + // Update maxColumns for all events to ensure consistent layout + val maxColumnsGlobal = result.maxOfOrNull { it.third } ?: 1 + return result.map { (event, col, _) -> Triple(event, col, maxColumnsGlobal) } } /** @@ -203,8 +210,8 @@ private fun NowBar( fun DayView( modifier: Modifier = Modifier, navController: NavController, - dayViewModel: space.midnightthoughts.nordiccalendar.viewmodels.DayViewModel, - events: List<space.midnightthoughts.nordiccalendar.util.Event> = emptyList() + dayViewModel: DayViewModel, + events: List<Event> = emptyList() ) { val hourHeightDp = 64.dp val timeColumnWidth = 64.dp @@ -221,8 +228,15 @@ fun DayView( now = System.currentTimeMillis() delay(1000) } - val nowMinutes = ((now - dayStart.value) / 60000f) - val nowOffsetY = (nowMinutes / 60f) * hourHeightPx + + // Fix: Calculate now offset more precisely to align with grid + val zoneId = ZoneId.systemDefault() + val todayStart = LocalDate.now().atStartOfDay(zoneId).toInstant().toEpochMilli() + val nowMinutes = ((now - todayStart) / 60000f) + + // Align with grid by using exact same calculation as hour grid positions + val nowOffsetY = nowMinutes * (hourHeightPx / 60f) + BoxWithConstraints( modifier = modifier @@ -361,6 +375,14 @@ private fun EventCard( val endDate = remember(eventEndOverride.takeIf { it > 0L } ?: event.endTime) { Date(eventEndOverride.takeIf { it > 0L } ?: event.endTime) } + + // Use calendar color for background and calculate contrasting text color + val backgroundColor = + ColorUtils.longToColor(event.calendar.color) + val textColor = ColorUtils.getContrastingTextColor( + backgroundColor + ) + val shape = if (noTopCorners && noBottomCorners) { MaterialTheme.shapes.medium.copy( topStart = ZeroCornerSize, @@ -381,6 +403,7 @@ private fun EventCard( } else { MaterialTheme.shapes.medium } + Card( modifier = modifier .defaultMinSize(minHeight = 24.dp) @@ -391,7 +414,9 @@ private fun EventCard( ) } .then(if (onClick != null) Modifier.clickable { onClick() } else Modifier), - colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + colors = CardDefaults.cardColors( + containerColor = backgroundColor + ), border = BorderStroke( 1.dp, color = MaterialTheme.colorScheme.outline @@ -411,6 +436,7 @@ private fun EventCard( event.title, overflow = TextOverflow.Ellipsis, style = if (isCompact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyLarge, + color = textColor, modifier = Modifier.semantics { heading() } ) if (!isCompact) { @@ -428,13 +454,14 @@ private fun EventCard( Text( "$startTimeText - $endTimeText", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary + color = textColor.copy(alpha = 0.9f) ) Spacer(modifier = Modifier.height(8.dp)) if (!event.description.isNullOrBlank()) { Text( event.description, style = MaterialTheme.typography.bodyMedium, + color = textColor.copy(alpha = 0.8f), overflow = TextOverflow.Ellipsis ) } diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/MonthView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/MonthView.kt @@ -15,24 +15,29 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController +import space.midnightthoughts.nordiccalendar.R +import space.midnightthoughts.nordiccalendar.util.ColorUtils +import space.midnightthoughts.nordiccalendar.util.Event +import space.midnightthoughts.nordiccalendar.viewmodels.MonthViewModel import java.time.DayOfWeek import java.time.Instant import java.time.LocalDate @@ -41,6 +46,243 @@ import java.time.format.TextStyle import java.util.Locale /** + * Displays the weekday header for the month view. + * + * @param firstDayOfWeek First day of week (1=Monday, 7=Sunday). + */ +@Composable +private fun MonthWeekdayHeader( + firstDayOfWeek: Int +) { + val locale = Locale.getDefault() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + for (i in 0..6) { + val day = DayOfWeek.of(((i + firstDayOfWeek - 1) % 7) + 1) + Box( + modifier = Modifier.weight(1f), // Use weight instead of fixed width + contentAlignment = Alignment.Center + ) { + Text( + text = day.getDisplayName(TextStyle.SHORT, locale), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + } + + // Vertical divider between days (except after last day) + if (i < 6) { + VerticalDivider( + modifier = Modifier.height(32.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + } + } + } +} + +/** + * Displays the month grid with continuous lines and proper cell layout. + * Optimized version to reduce recomposition and improve performance. + * + * @param days List of dates for each cell (null for empty cells). + * @param eventsByDay Events grouped by day. + * @param today Today's date for highlighting. + * @param firstDay First day of the month for comparison. + * @param navController Navigation controller for event clicks. + */ +@Composable +private fun MonthGrid( + days: List<LocalDate?>, + eventsByDay: Map<LocalDate, List<Event>>, + today: LocalDate, + firstDay: LocalDate, + navController: NavController +) { + val rows = days.size / 7 + + // Pre-calculate all cell data to avoid recomposition + val cellData = remember(days, eventsByDay, today, firstDay) { + days.mapIndexed { index, date -> + val isToday = date == today + val isCurrentMonth = date != null && date.month == firstDay.month + val dayEvents = if (date != null) eventsByDay[date].orEmpty() else emptyList() + + Triple( + Triple(date, isToday, isCurrentMonth), + dayEvents, + index + ) + } + } + + Column(modifier = Modifier.fillMaxWidth()) { + for (row in 0 until rows) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) // Increase from 100dp to 120dp for more space + ) { + for (col in 0..6) { + val cellIndex = row * 7 + col + val (dateInfo, dayEvents, _) = cellData[cellIndex] + val (date, isToday, isCurrentMonth) = dateInfo + + Box( + modifier = Modifier.weight(1f) + ) { + // Horizontal divider at top of cell (except first row) + if (row > 0) { + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + } + + // Cell background and content + Box( + modifier = Modifier + .fillMaxSize() + .background( + if (isCurrentMonth) MaterialTheme.colorScheme.surface + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + .padding(4.dp) + ) { + MonthDayCell( + date = date, + isToday = isToday, + isCurrentMonth = isCurrentMonth, + dayEvents = dayEvents, + navController = navController, + modifier = Modifier.fillMaxSize() + ) + } + } + + // Vertical divider between columns (except after last column) + if (col < 6) { + VerticalDivider( + modifier = Modifier.height(120.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + } + } + } + } + } +} + +/** + * Displays the content of a single day cell. + * + * @param date The date for this cell (null if empty). + * @param isToday Whether this is today's date. + * @param isCurrentMonth Whether this date is in the current month. + * @param dayEvents Events for this day. + * @param navController Navigation controller for event clicks. + * @param modifier Modifier for styling. + */ +@Composable +private fun MonthDayCell( + date: LocalDate?, + isToday: Boolean, + isCurrentMonth: Boolean, + dayEvents: List<Event>, + navController: NavController, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(1.dp) // Use spacedBy instead of padding on individual chips + ) { + // Day number + Box( + modifier = Modifier + .size(28.dp) + .aspectRatio(1f) + .then( + if (isToday) Modifier.background( + MaterialTheme.colorScheme.primaryContainer, + RoundedCornerShape(50) + ) else Modifier + ), + contentAlignment = Alignment.Center + ) { + Text( + text = date?.dayOfMonth?.toString() ?: "", + color = when { + isToday -> MaterialTheme.colorScheme.onPrimaryContainer + isCurrentMonth -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + }, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 14.sp, + modifier = Modifier.padding(2.dp) + ) + } + + Spacer(Modifier.height(1.dp)) + + // Events as compact chips with correct colors + val maxEvents = 3 + dayEvents.take(maxEvents).forEach { event -> + // Use ColorUtils for consistent colors + val backgroundColor = ColorUtils.longToColor(event.calendar.color) + val textColor = ColorUtils.getContrastingTextColor(backgroundColor) + + CompactChip( + text = event.title, + backgroundColor = backgroundColor, + textColor = textColor, + modifier = Modifier + .fillMaxWidth() + .clickable { + navController.navigate("eventDetails/${event.eventId}") { + launchSingleTop = true + restoreState = true + } + } + ) + } + + if (dayEvents.size > maxEvents) { + CompactChip( + text = LocalContext.current.getString( + R.string.events_more, + dayEvents.size - maxEvents + ), + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + textColor = MaterialTheme.colorScheme.primary, + center = true, + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(4.dp) + ) + .clickable { + // Navigate to calendar with day view tab when clicking "+N more" + date?.let { + navController.navigate("calendar?tab=2&date=${it}") { + launchSingleTop = true + restoreState = true + } + } + }, + ) + } + } +} + +/** * MonthView displays the calendar in a monthly format, showing all days of the month * with events and provides navigation to view event details. * @@ -55,128 +297,44 @@ import java.util.Locale fun MonthView( modifier: Modifier = Modifier, navController: NavController, - monthViewModel: space.midnightthoughts.nordiccalendar.viewmodels.MonthViewModel, - events: List<space.midnightthoughts.nordiccalendar.util.Event> = emptyList() + monthViewModel: MonthViewModel, + events: List<Event> = emptyList() ) { val startMillis by monthViewModel.startMillis.collectAsState() val today = LocalDate.now() val zoneId = ZoneId.systemDefault() val firstDay = Instant.ofEpochMilli(startMillis).atZone(zoneId).toLocalDate() val daysInMonth = firstDay.lengthOfMonth() + // Week starts on Monday (1=Monday, 7=Sunday) val firstDayOfWeek = 1 val firstOfMonth = firstDay.withDayOfMonth(1) val firstOfMonthDayOfWeek = (firstOfMonth.dayOfWeek.value - firstDayOfWeek + 7) % 7 val totalCells = ((daysInMonth + firstOfMonthDayOfWeek + 6) / 7) * 7 + val days = (0 until totalCells).map { cell -> val dayOfMonth = cell - firstOfMonthDayOfWeek + 1 if (dayOfMonth in 1..daysInMonth) firstDay.withDayOfMonth(dayOfMonth) else null } + val eventsByDay = events.groupBy { event -> Instant.ofEpochMilli(event.startTime).atZone(zoneId).toLocalDate() } - Column(modifier) { - // Weekday names as grid row - Row(Modifier.fillMaxWidth()) { - val locale = Locale.getDefault() - for (i in 0..6) { - val day = DayOfWeek.of(((i + firstDayOfWeek - 1) % 7) + 1) - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.Center - ) { - Text( - day.getDisplayName(TextStyle.SHORT, locale), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) - } - } - } - // Month grid with lines (only inner lines) - LazyVerticalGrid( - columns = GridCells.Fixed(7), - modifier = Modifier.fillMaxWidth(), - userScrollEnabled = true - ) { - itemsIndexed(days) { idx, date -> - val isToday = date == today - val isCurrentMonth = date != null && date.month == firstDay.month - val dayEvents = if (date != null) eventsByDay[date].orEmpty() else emptyList() - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.TopStart - ) { - Column( - Modifier - .padding(4.dp) - .fillMaxSize(), - verticalArrangement = Arrangement.Top - ) { - // Day number - Box( - modifier = Modifier - .size(32.dp) - .aspectRatio(1f) - .then( - if (isToday) Modifier.background( - MaterialTheme.colorScheme.primaryContainer, - RoundedCornerShape(50) - ) else Modifier - ), - contentAlignment = Alignment.Center - ) { - Text( - text = date?.dayOfMonth?.toString() ?: "", - color = when { - isToday -> MaterialTheme.colorScheme.onPrimaryContainer - isCurrentMonth -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) - }, - fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, - fontSize = 13.sp, - lineHeight = 8.sp, - modifier = Modifier.padding(2.dp) - ) - } - Spacer(Modifier.size(2.dp)) - // Events as compact chips - val maxEvents = 3 - dayEvents.take(maxEvents).forEach { event -> - CompactChip( - text = event.title, - backgroundColor = Color(event.calendar.color.toInt() or 0xFF000000.toInt()), - textColor = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier - .padding(bottom = 1.dp) - .clickable { - navController.navigate("eventDetails/${event.eventId}") { - launchSingleTop = true - restoreState = true - } - } - ) - } - if (dayEvents.size > maxEvents) { - CompactChip( - text = "+${dayEvents.size - maxEvents} more", - backgroundColor = Color.Transparent, - textColor = MaterialTheme.colorScheme.primary, - center = true, - modifier = Modifier - .padding(bottom = 1.dp) - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(8.dp) - ), - ) - } - } - } - } - } + + Column(modifier = Modifier.fillMaxSize()) { + // Weekday names header + MonthWeekdayHeader( + firstDayOfWeek = firstDayOfWeek + ) + + // Month grid with continuous lines + MonthGrid( + days = days, + eventsByDay = eventsByDay, + today = today, + firstDay = firstDay, + navController = navController + ) } } @@ -187,7 +345,6 @@ fun MonthView( * @param backgroundColor The background color of the chip. * @param textColor The text color. * @param modifier Modifier for styling and layout. - * @param borderColor Optional border color for the chip. * @param center If true, centers the text inside the chip. */ @Composable @@ -196,22 +353,14 @@ fun CompactChip( backgroundColor: Color, textColor: Color, modifier: Modifier = Modifier, - borderColor: Color? = null, - center: Boolean? = false + center: Boolean = false ) { Box( modifier = modifier .background(backgroundColor, RoundedCornerShape(4.dp)) - .height(20.dp) - .fillMaxWidth() - .then( - if (borderColor != null) Modifier.border( - width = 1.dp, - color = borderColor, - shape = RoundedCornerShape(6.dp) - ) else Modifier - ), - contentAlignment = if (center == true) Alignment.Center else Alignment.CenterStart + .height(20.dp) // Back to a more readable height + .fillMaxWidth(), + contentAlignment = if (center) Alignment.Center else Alignment.CenterStart ) { Text( text = text, diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/WeekView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/WeekView.kt @@ -1,26 +1,335 @@ package space.midnightthoughts.nordiccalendar.screens +import android.annotation.SuppressLint +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.ZeroCornerSize +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.CollectionInfo +import androidx.compose.ui.semantics.CollectionItemInfo +import androidx.compose.ui.semantics.collectionInfo +import androidx.compose.ui.semantics.collectionItemInfo +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import kotlinx.coroutines.delay +import space.midnightthoughts.nordiccalendar.getCurrentAppLocale +import space.midnightthoughts.nordiccalendar.util.ColorUtils import space.midnightthoughts.nordiccalendar.util.Event import space.midnightthoughts.nordiccalendar.viewmodels.WeekViewModel +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.util.Date +import java.util.Locale +import java.util.PriorityQueue /** - * WeekView displays the calendar in a weekly format, showing events for the current week. + * Assigns columns to events within each day so that overlapping events are displayed side by side. + * Each event is assigned a column index and the total number of columns for its overlap group within that day. + * + * @param events List of events for a specific day. + * @return List of Triple<Event, columnIndex, maxColumns> for layout. + */ +private fun assignDayColumns(events: List<Event>): List<Triple<Event, Int, Int>> { + data class ActiveEvent(val endTime: Long, val col: Int) + + val sorted = events.sortedBy { it.startTime } + val active = PriorityQueue(compareBy<ActiveEvent> { it.endTime }) + val freeColumns = PriorityQueue<Int>() + val result = mutableListOf<Triple<Event, Int, Int>>() + + for (event in sorted) { + // Remove expired events and free their columns + while (active.isNotEmpty() && active.peek()?.endTime!! <= event.startTime) { + freeColumns.add(active.poll()?.col) + } + val col = if (freeColumns.isNotEmpty()) freeColumns.poll() else active.size + active.add(ActiveEvent(event.endTime, col)) + + // For each event, maxColumns is the number of currently active events (including this one) + val maxColumns = active.size + result.add(Triple(event, col, maxColumns)) + } + return result +} + +/** + * Displays the week header with day names and dates. + * + * @param weekStart The start of the week (Monday). + * @param dayColumnWidth Width of each day column. + * @param timeColumnWidth Width of the time label column. + */ +@Composable +private fun WeekHeader( + weekStart: LocalDate, + dayColumnWidth: Dp, + timeColumnWidth: Dp +) { + val locale = Locale.getDefault() + val today = LocalDate.now() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + // Empty space for time column + Spacer(modifier = Modifier.width(timeColumnWidth)) + + // Day headers + for (i in 0..6) { + val date = weekStart.plusDays(i.toLong()) + val isToday = date == today + + Box( + modifier = Modifier.width(dayColumnWidth), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = date.dayOfWeek.getDisplayName(TextStyle.SHORT, locale), + style = MaterialTheme.typography.labelMedium, + color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + Box( + modifier = Modifier + .then( + if (isToday) Modifier.background( + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.shapes.small + ) else Modifier + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = date.dayOfMonth.toString(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + color = if (isToday) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } +} + +/** + * Displays the hour grid for the week, with time labels and horizontal dividers for each hour. + * + * @param weekStart Start of the week. + * @param hourHeightDp Height of each hour row in dp. + * @param timeColumnWidth Width of the time label column in dp. + * @param dayColumnWidth Width of each day column in dp. + * @param hourFormat DateTimeFormatter for the hour labels. + */ +@Composable +private fun WeekHourGrid( + weekStart: LocalDate, + hourHeightDp: Dp, + timeColumnWidth: Dp, + dayColumnWidth: Dp, + hourFormat: DateTimeFormatter +) { + val zoneId = ZoneId.systemDefault() + val dayStartMillis = weekStart.atStartOfDay(zoneId).toInstant().toEpochMilli() + + Column(modifier = Modifier.fillMaxWidth()) { + for (hour in 0..23) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(hourHeightDp) + .fillMaxWidth() + ) { + // Time label + Box( + modifier = Modifier.width(timeColumnWidth), + contentAlignment = Alignment.TopEnd + ) { + Text( + text = Date(dayStartMillis + hour * 60 * 60 * 1000).toInstant() + .atZone(zoneId).toLocalDateTime().format(hourFormat), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 8.dp, top = 4.dp) + ) + } + + // Day columns with dividers + for (dayIndex in 0..6) { + Box( + modifier = Modifier.width(dayColumnWidth) + ) { + // Horizontal divider + if (hour > 0) { + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + } + } + + // Vertical divider between days (except after last day) + if (dayIndex < 6) { + VerticalDivider( + modifier = Modifier.height(hourHeightDp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + } + } + } + } + } +} + +/** + * Displays a red bar indicating the current time ("now") across all days in the week view. + * The bar is only shown if the current time is within the visible range and on a day within the week. + * + * @param weekStart Start of the week. + * @param nowOffsetY Vertical offset in pixels for the bar position. + * @param hourHeightPx Height of one hour in pixels. + * @param timeColumnWidth Width of the time column. + * @param dayColumnWidth Width of each day column. + * @param now Current time in milliseconds. + * @param hourFormat Formatter for displaying the time label. + */ +@Composable +private fun WeekNowBar( + weekStart: LocalDate, + nowOffsetY: Float, + hourHeightPx: Float, + timeColumnWidth: Dp, + dayColumnWidth: Dp, + now: Long, + hourFormat: DateTimeFormatter +) { + val lineColor = MaterialTheme.colorScheme.error + val today = LocalDate.now() + val todayIndex = today.toEpochDay() - weekStart.toEpochDay() + + // Only show if today is within the current week and within the hour range + if (todayIndex in 0..6 && nowOffsetY >= 0f && nowOffsetY <= hourHeightPx * 24f) { + Row( + modifier = Modifier + .fillMaxWidth() + .offset { IntOffset(0, nowOffsetY.toInt()) }, + verticalAlignment = Alignment.CenterVertically + ) { + // Time label + Box( + modifier = Modifier + .width(timeColumnWidth) + .padding(end = 8.dp), + contentAlignment = Alignment.CenterEnd + ) { + Box( + modifier = Modifier + .background( + color = lineColor, + shape = MaterialTheme.shapes.extraSmall + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = Date(now).toInstant() + .atZone(ZoneId.systemDefault()).toLocalDateTime() + .format(hourFormat), + color = MaterialTheme.colorScheme.onError, + style = MaterialTheme.typography.labelSmall + ) + } + } + + // Now line across all days + for (dayIndex in 0..6) { + Box(modifier = Modifier.width(dayColumnWidth)) { + if (dayIndex == todayIndex.toInt()) { + // Red line for today + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + ) { + drawLine( + color = lineColor, + start = Offset(0f, size.height / 2), + end = Offset(size.width, size.height / 2), + strokeWidth = size.height, + cap = StrokeCap.Round + ) + } + } + } + + // Space for vertical dividers + if (dayIndex < 6) { + Spacer(modifier = Modifier.width(1.dp)) + } + } + } + } +} + +/** + * WeekView displays the calendar in a weekly format, showing events for 7 days side by side. + * Similar to DayView but with multiple day columns arranged horizontally. * * @param modifier Modifier for styling and layout. * @param navController NavController for navigation actions. * @param weekViewModel Specialized ViewModel for week view operations. * @param events List of events to display. */ +@SuppressLint("UnusedBoxWithConstraintsScope") @Composable fun WeekView( modifier: Modifier = Modifier, @@ -28,19 +337,270 @@ fun WeekView( weekViewModel: WeekViewModel, events: List<Event> = emptyList() ) { - // TODO: Implement WeekView - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(events) { event -> - Text( - event.title, modifier = Modifier - .padding(8.dp) - .clickable { - navController.navigate("eventDetails/${event.eventId}?tab=1") { - launchSingleTop = true - restoreState = true + val hourHeightDp = 64.dp + val timeColumnWidth = 64.dp + val density = LocalDensity.current + val hourHeightPx = with(density) { hourHeightDp.toPx() } + + val weekStartMillis by weekViewModel.startMillis.collectAsState() + val weekEndMillis by weekViewModel.endMillis.collectAsState() + val appLocale = getCurrentAppLocale(LocalContext.current) + val hourFormat = DateTimeFormatter.ofPattern("HH:mm", appLocale) + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + + LaunchedEffect(now) { + now = System.currentTimeMillis() + delay(60000) // Update every minute for week view + } + + val zoneId = ZoneId.systemDefault() + val weekStart = Instant.ofEpochMilli(weekStartMillis).atZone(zoneId).toLocalDate() + val today = LocalDate.now() + + // Fix: Calculate now offset more precisely to align with grid + val todayStartMillis = today.atStartOfDay(zoneId).toInstant().toEpochMilli() + val nowMinutes = ((now - todayStartMillis) / 60000f) + + // Align with grid by using exact same calculation as hour grid positions + val nowOffsetY = nowMinutes * (hourHeightPx / 60f) + + + BoxWithConstraints(modifier = modifier) { + val totalWidth = maxWidth - timeColumnWidth + val dayColumnWidth = totalWidth / 7 + + val visibleHeightPx = with(density) { maxHeight.toPx() } + val scrollTo = (nowOffsetY - visibleHeightPx / 2).toInt().coerceAtLeast(0) + val scrollState = + rememberScrollState(initial = if (today >= weekStart && today <= weekStart.plusDays(6)) scrollTo else 0) + + Column(modifier = Modifier.fillMaxSize()) { + // Week header with day names and dates + WeekHeader( + weekStart = weekStart, + dayColumnWidth = dayColumnWidth, + timeColumnWidth = timeColumnWidth + ) + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .semantics { + collectionInfo = CollectionInfo( + rowCount = events.size, + columnCount = 7, + ) + } + ) { + // Hour grid background + WeekHourGrid( + weekStart = weekStart, + hourHeightDp = hourHeightDp, + timeColumnWidth = timeColumnWidth, + dayColumnWidth = dayColumnWidth, + hourFormat = hourFormat + ) + + // Group events by day + val eventsByDay = events.groupBy { event -> + Instant.ofEpochMilli(event.startTime).atZone(zoneId).toLocalDate() + } + + // Display events for each day + for (dayIndex in 0..6) { + val currentDay = weekStart.plusDays(dayIndex.toLong()) + val dayEvents = eventsByDay[currentDay] ?: emptyList() + val dayStartMillis = currentDay.atStartOfDay(zoneId).toInstant().toEpochMilli() + val dayEndMillis = + currentDay.plusDays(1).atStartOfDay(zoneId).toInstant().toEpochMilli() + + if (dayEvents.isNotEmpty()) { + val eventColumns = remember(dayEvents) { assignDayColumns(dayEvents) } + + dayEvents.forEach { event -> + val triple = + eventColumns.find { it.first.eventId == event.eventId && it.first.calendar.id == event.calendar.id } + if (triple != null) { + val (_, col, maxColumns) = triple + val shownStart = maxOf(event.startTime, dayStartMillis) + val shownEnd = minOf(event.endTime, dayEndMillis) + val startMinutes = ((shownStart - dayStartMillis) / 60000f) + val endMinutes = ((shownEnd - dayStartMillis) / 60000f) + val offsetY = (startMinutes / 60f) * hourHeightPx + val eventHeightPx = + ((endMinutes - startMinutes) / 60f) * hourHeightPx + val columnWidthPx = + with(density) { dayColumnWidth.toPx() } / maxColumns + val offsetX = + (timeColumnWidth + dayColumnWidth * dayIndex).value.let { baseX -> + with(density) { (baseX.dp + (col * columnWidthPx / density.density).dp).toPx() } + }.toInt() + + val minCardHeightDp = 40.dp + val isCompact = + with(density) { eventHeightPx.toDp() } < minCardHeightDp + + // Whether the event starts before the visible day + val noTopCorners = event.startTime < dayStartMillis + // Whether the event ends after the visible day + val noBottomCorners = event.endTime > dayEndMillis + + key(event.eventId, event.calendar.id, dayIndex) { + Box( + modifier = Modifier + .offset { IntOffset(offsetX, offsetY.toInt()) } + .width(with(density) { (columnWidthPx / density.density).dp }) + .height(with(density) { eventHeightPx.toDp() }) + ) { + WeekEventCard( + event = event, + isCompact = isCompact, + onClick = { + navController.navigate( + "eventDetails/${event.eventId}?tab=1" + ) { + launchSingleTop = true + restoreState = true + } + }, + noTopCorners = noTopCorners, + noBottomCorners = noBottomCorners, + eventStartOverride = shownStart, + eventEndOverride = shownEnd + ) + } + } + } } - }) + } + } + + // Now bar + WeekNowBar( + weekStart = weekStart, + nowOffsetY = nowOffsetY, + hourHeightPx = hourHeightPx, + timeColumnWidth = timeColumnWidth, + dayColumnWidth = dayColumnWidth, + now = now, + hourFormat = hourFormat + ) + } } } +} + +/** + * Card composable for displaying a single event in the week view. + * Optimized for smaller width compared to day view. + * + * @param event The event to display. + * @param isCompact Whether to use a compact layout (for short events). + * @param modifier Modifier for styling and layout. + * @param onClick Optional callback for click actions. + * @param noBottomCorners If true, bottom corners are not rounded. + * @param noTopCorners If true, top corners are not rounded. + * @param eventStartOverride Optional override for the event start time. + * @param eventEndOverride Optional override for the event end time. + */ +@Composable +private fun WeekEventCard( + event: Event, + isCompact: Boolean, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + noBottomCorners: Boolean = false, + noTopCorners: Boolean = false, + eventStartOverride: Long = 0L, + eventEndOverride: Long = 0L, +) { + val appLocale = getCurrentAppLocale(LocalContext.current) + val hourFormat = DateTimeFormatter.ofPattern("HH:mm", appLocale) + val startDate = remember(eventStartOverride.takeIf { it > 0L } ?: event.startTime) { + Date(eventStartOverride.takeIf { it > 0L } ?: event.startTime) + } + val endDate = remember(eventEndOverride.takeIf { it > 0L } ?: event.endTime) { + Date(eventEndOverride.takeIf { it > 0L } ?: event.endTime) + } + + // Use calendar color for background and calculate contrasting text color + val backgroundColor = + ColorUtils.longToColor(event.calendar.color) + val textColor = ColorUtils.getContrastingTextColor( + backgroundColor + ) + + val shape = if (noTopCorners && noBottomCorners) { + MaterialTheme.shapes.small.copy( + topStart = ZeroCornerSize, + topEnd = ZeroCornerSize, + bottomStart = ZeroCornerSize, + bottomEnd = ZeroCornerSize + ) + } else if (noBottomCorners) { + MaterialTheme.shapes.small.copy( + bottomStart = ZeroCornerSize, + bottomEnd = ZeroCornerSize + ) + } else if (noTopCorners) { + MaterialTheme.shapes.small.copy( + topStart = ZeroCornerSize, + topEnd = ZeroCornerSize + ) + } else { + MaterialTheme.shapes.small + } + Card( + modifier = modifier + .defaultMinSize(minHeight = 20.dp) + .padding(end = 2.dp, bottom = 1.dp) + .semantics { + collectionItemInfo = CollectionItemInfo(0, 0, 0, 0) + } + .then(if (onClick != null) Modifier.clickable { onClick() } else Modifier), + colors = CardDefaults.cardColors( + containerColor = backgroundColor + ), + border = BorderStroke( + 1.dp, + color = MaterialTheme.colorScheme.outline + ), + shape = shape + ) { + Column( + modifier = Modifier + .padding( + horizontal = 4.dp, + vertical = if (isCompact) 2.dp else 6.dp, + ) + .fillMaxSize(), + verticalArrangement = if (isCompact) Arrangement.Center else Arrangement.Top, + ) { + Text( + text = event.title, + overflow = TextOverflow.Ellipsis, + maxLines = if (isCompact) 1 else 2, + style = if (isCompact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium, + color = textColor, + modifier = Modifier.semantics { heading() } + ) + + if (!isCompact) { + val startTimeText = startDate.toInstant().atZone(ZoneId.systemDefault()) + .toLocalDateTime().format(hourFormat) + val endTimeText = endDate.toInstant().atZone(ZoneId.systemDefault()) + .toLocalDateTime().format(hourFormat) + + Text( + text = "$startTimeText-$endTimeText", + style = MaterialTheme.typography.labelSmall, + color = textColor.copy(alpha = 0.9f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/util/ColorUtils.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/util/ColorUtils.kt @@ -0,0 +1,36 @@ +package space.midnightthoughts.nordiccalendar.util + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance + +/** + * Utility functions for color manipulation and contrast calculation. + */ +object ColorUtils { + + /** + * Determines whether to use light or dark text based on the background color's luminance. + * Uses WCAG guidelines for accessibility. + * + * @param backgroundColor The background color to check + * @return Color.White for dark backgrounds, Color.Black for light backgrounds + */ + fun getContrastingTextColor(backgroundColor: Color): Color { + return if (backgroundColor.luminance() > 0.5f) { + Color.Black + } else { + Color.White + } + } + + + /** + * Converts a long color value to a Compose Color with guaranteed alpha. + * + * @param colorLong The long color value (may or may not include alpha) + * @return Color with full opacity + */ + fun longToColor(colorLong: Long): Color { + return Color((colorLong or 0xFF000000L).toInt()) + } +} diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt @@ -81,13 +81,27 @@ class CalendarViewModel @Inject constructor( /** * Centralized events flow that handles all event loading and reminder scheduling. + * Now properly reacts to tab changes and time range updates from all ViewModels. */ val events: StateFlow<List<Event>> = combine( repository.calendarsFlow, - currentViewModel.startMillis, - currentViewModel.endMillis - ) { calendars, start, end -> + _selectedTab, + combine( + monthViewModel.startMillis, + monthViewModel.endMillis + ) { start, end -> start to end }, + combine(weekViewModel.startMillis, weekViewModel.endMillis) { start, end -> start to end }, + combine(dayViewModel.startMillis, dayViewModel.endMillis) { start, end -> start to end } + ) { calendars, selectedTab, monthRange, weekRange, dayRange -> val selectedIds = calendars.filter { it.selected }.map { it.id } + + val (start, end) = when (selectedTab) { + 0 -> monthRange + 1 -> weekRange + 2 -> dayRange + else -> monthRange + } + repository.getEventsForCalendars(context, selectedIds, start, end) }.stateIn( viewModelScope, diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/SpecializedCalendarViewModels.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/SpecializedCalendarViewModels.kt @@ -132,8 +132,11 @@ class WeekViewModel @Inject constructor( * Week starts on Monday and ends on Sunday. */ private fun setWeekRange(cal: java.util.Calendar) { + // Set first day of week to Monday (2 = Monday in Calendar) + cal.firstDayOfWeek = java.util.Calendar.MONDAY + // Start: Monday of the week at 00:00:00 - cal.set(java.util.Calendar.DAY_OF_WEEK, cal.firstDayOfWeek) + cal.set(java.util.Calendar.DAY_OF_WEEK, java.util.Calendar.MONDAY) cal.set(java.util.Calendar.HOUR_OF_DAY, 0) cal.set(java.util.Calendar.MINUTE, 0) cal.set(java.util.Calendar.SECOND, 0) @@ -141,7 +144,7 @@ class WeekViewModel @Inject constructor( val start = cal.timeInMillis // End: Sunday of the week at 23:59:59.999 - cal.add(java.util.Calendar.DAY_OF_WEEK, 6) + cal.set(java.util.Calendar.DAY_OF_WEEK, java.util.Calendar.SUNDAY) cal.set(java.util.Calendar.HOUR_OF_DAY, 23) cal.set(java.util.Calendar.MINUTE, 59) cal.set(java.util.Calendar.SECOND, 59) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml @@ -42,6 +42,7 @@ <string name="nominatim_url_label">OpenStreetMap Nominatim URL</string> <string name="save">Save</string> <string name="event_duration">Duration: %1$s</string> + <string name="events_more">+%1$d more</string> <!-- Notification strings --> <string name="notification_channel_name">Calendar Reminders</string> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml @@ -42,6 +42,7 @@ <string name="nominatim_url_label">OpenStreetMap Nominatim URL</string> <string name="save">Speichern</string> <string name="event_duration">Dauer: %1$s</string> + <string name="events_more">+%1$d weitere</string> <!-- Notification strings --> <string name="notification_channel_name">Kalender-Erinnerungen</string>