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