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 52070b7ae5e6ecca07e9ebbf2926306110eb804b
parent 496efe6698f74d8d0437db9b2fb5339e665dd2d8
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Wed, 30 Jul 2025 15:25:04 +0200

Fix file location

Diffstat:
Dapp/src/main/java/space/midnightthoughts/nordiccalendar/components/DayView.kt | 423-------------------------------------------------------------------------------
Dapp/src/main/java/space/midnightthoughts/nordiccalendar/components/MonthView.kt | 209-------------------------------------------------------------------------------
Dapp/src/main/java/space/midnightthoughts/nordiccalendar/components/WeekView.kt | 43-------------------------------------------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt | 3---
Aapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/DayView.kt | 425+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/MonthView.kt | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/WeekView.kt | 43+++++++++++++++++++++++++++++++++++++++++++
7 files changed, 677 insertions(+), 678 deletions(-)

diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/components/DayView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/components/DayView.kt @@ -1,423 +0,0 @@ -package space.midnightthoughts.nordiccalendar.components - -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.layout.width -import androidx.compose.foundation.rememberScrollState -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.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.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -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.Event -import space.midnightthoughts.nordiccalendar.viewmodels.CalendarViewModel -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.Date -import java.util.PriorityQueue - -private fun assignColumns(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>>() - var maxColumns = 0 - - for (event in sorted) { - // Entferne abgelaufene Events und gib deren Spalten frei - 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)) - maxColumns = maxOf(maxColumns, active.size) - result.add(Triple(event, col, maxColumns)) - } - return result -} - -@Composable -private fun HourLines( - dayStart: Long, - hourHeightDp: Dp, - timeColumnWidth: Dp, - hourFormat: DateTimeFormatter -) { - Column( - modifier = Modifier.fillMaxWidth() - ) { - for (hour in 0..24) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .height(hourHeightDp) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Text( - Date(dayStart + hour * 60 * 60 * 1000).toInstant() - .atZone(ZoneId.systemDefault()).toLocalDateTime().format(hourFormat), - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.width(timeColumnWidth) - ) - HorizontalDivider(modifier = Modifier.weight(1f)) - } - } - } -} - -@Composable -private fun NowBar( - nowOffsetY: Float, - hourHeightPx: Float, - now: Long, - hourFormat: DateTimeFormatter -) { - val lineColor = MaterialTheme.colorScheme.error - if (nowOffsetY >= 0f && nowOffsetY <= hourHeightPx * 24.5f) { - Row( - modifier = Modifier - .fillMaxWidth() - .offset { IntOffset(0, nowOffsetY.toInt()) }, - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .padding(start = 4.dp) - .background( - color = lineColor, - shape = MaterialTheme.shapes.small - ) - .padding(horizontal = 12.dp, vertical = 4.dp) - ) { - Text( - Date(now).toInstant() - .atZone(ZoneId.systemDefault()).toLocalDateTime() - .format(hourFormat), - color = MaterialTheme.colorScheme.onError, - style = MaterialTheme.typography.labelSmall - ) - } - Canvas( - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .padding(end = 16.dp) - ) { - drawLine( - color = lineColor, - start = Offset( - 0f, - size.height / 2 - ), - end = Offset( - size.width, - size.height / 2 - ), - strokeWidth = size.height, - cap = StrokeCap.Round - ) - } - } - } -} - -@SuppressLint("UnusedBoxWithConstraintsScope") -@Composable -fun DayView( - modifier: Modifier = Modifier, - navController: NavController, - calendarViewModel: CalendarViewModel, -) { - val hourHeightDp = 64.dp - val timeColumnWidth = 64.dp - val density = LocalDensity.current - val hourHeightPx = with(density) { hourHeightDp.toPx() } - val dayStart = remember(calendarViewModel) { calendarViewModel.startMillis }.collectAsState() - val dayEnd = remember(calendarViewModel) { calendarViewModel.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(1000) - } - val nowMinutes = ((now - dayStart.value) / 60000f) - val nowOffsetY = (nowMinutes / 60f) * hourHeightPx - - BoxWithConstraints( - modifier = modifier - ) { - val visibleHeightPx = with(density) { maxHeight.toPx() } - val scrollTo = (nowOffsetY - visibleHeightPx / 2).toInt().coerceAtLeast(0) - val scrollState = rememberScrollState(initial = scrollTo) - - val events = remember(calendarViewModel) { calendarViewModel.events }.collectAsState() - - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .semantics { - collectionInfo = CollectionInfo( - rowCount = events.value.size, - columnCount = 1, - ) - } - ) { - val maxWidthPx = with(density) { maxWidth.toPx() } - - // Stundenraster - HourLines( - dayStart = dayStart.value, - hourHeightDp = hourHeightDp, - timeColumnWidth = timeColumnWidth, - hourFormat = hourFormat - ) - - // Events (wie gehabt, optimiert) - val eventColumns = remember(events.value, maxWidthPx) { assignColumns(events.value) } - events.value.forEach { event -> - val triple = eventColumns.find { it.first.id == event.id } - if (triple != null) { - val (event, col, maxColumns) = triple - val shownStart = maxOf(event.startTime, dayStart.value) - val shownEnd = minOf(event.endTime, dayEnd.value) - val startMinutes = ((shownStart - dayStart.value) / 60000f) - val endMinutes = ((shownEnd - dayStart.value) / 60000f) - val offsetY = (startMinutes / 60f) * hourHeightPx + (hourHeightPx / 2f) - val eventHeightPx = ((endMinutes - startMinutes) / 60f) * hourHeightPx - val columnWidthPx = - (maxWidthPx - with(density) { timeColumnWidth.toPx() }) / maxColumns - val offsetX = - (col * columnWidthPx).toInt() + with(density) { timeColumnWidth.toPx() }.toInt() - val minCardHeightDp = 60.dp - val isCompact = with(density) { eventHeightPx.toDp() } < minCardHeightDp - - val noTopCorners = event.startTime < dayStart.value - val noBottomCorners = event.endTime > dayEnd.value - - val showStartTimeAsMidnight = shownStart == dayStart.value - val showEndTimeAsMidnight = shownEnd == dayEnd.value - - key(event.id) { - Box( - modifier = Modifier - .offset { IntOffset(offsetX, offsetY.toInt()) } - .width(with(density) { columnWidthPx.toDp() }) - .height(with(density) { eventHeightPx.toDp() }) - ) { - EventCard( - event = event, - isCompact = isCompact, - onClick = { - navController.navigate("eventDetails/${event.eventId}?tab=2") { - launchSingleTop = true - restoreState = true - } - }, - noTopCorners = noTopCorners, - noBottomCorners = noBottomCorners, - showStartTimeAsMidnight = showStartTimeAsMidnight, - showEndTimeAsMidnight = showEndTimeAsMidnight, - eventStartOverride = shownStart, - eventEndOverride = shownEnd - ) - } - } - } - } - - // Jetzt-Linie (optimiert als eigene Composable) - NowBar( - nowOffsetY = nowOffsetY, - hourHeightPx = hourHeightPx, - now = now, - hourFormat = hourFormat - ) - } - } -} - - -@Composable -private fun EventCard( - event: Event, - isCompact: Boolean, - modifier: Modifier = Modifier, - onClick: (() -> Unit)? = null, - index: Int = 0, - noBottomCorners: Boolean = false, - noTopCorners: Boolean = false, - showStartTimeAsMidnight: Boolean = false, - showEndTimeAsMidnight: 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) - } - val shape = if (noTopCorners && noBottomCorners) { - MaterialTheme.shapes.medium.copy( - topStart = androidx.compose.foundation.shape.ZeroCornerSize, - topEnd = androidx.compose.foundation.shape.ZeroCornerSize, - bottomStart = androidx.compose.foundation.shape.ZeroCornerSize, - bottomEnd = androidx.compose.foundation.shape.ZeroCornerSize - ) - } else if (noBottomCorners) { - MaterialTheme.shapes.medium.copy( - bottomStart = androidx.compose.foundation.shape.ZeroCornerSize, - bottomEnd = androidx.compose.foundation.shape.ZeroCornerSize - ) - } else if (noTopCorners) { - MaterialTheme.shapes.medium.copy( - topStart = androidx.compose.foundation.shape.ZeroCornerSize, - topEnd = androidx.compose.foundation.shape.ZeroCornerSize - ) - } else { - MaterialTheme.shapes.medium - } - Card( - modifier = modifier - .defaultMinSize(minHeight = 24.dp) - .padding(end = 8.dp) - .semantics { - collectionItemInfo = CollectionItemInfo( - index, 0, 0, 0, - ) - } - .then(if (onClick != null) Modifier.clickable { onClick() } else Modifier), - colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - border = BorderStroke( - 1.dp, - color = MaterialTheme.colorScheme.outline - ), - shape = shape - ) { - Column( - modifier = Modifier - .padding( - horizontal = 12.dp, - vertical = if (isCompact) 4.dp else 12.dp, - ) - .fillMaxSize(), - verticalArrangement = if (isCompact) Arrangement.Center else Arrangement.Top, - ) { - Text( - event.title, - overflow = TextOverflow.Ellipsis, - style = if (isCompact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyLarge, - modifier = Modifier.semantics { heading() } - ) - if (!isCompact) { - Spacer(modifier = Modifier.height(4.dp)) - val startTimeText = when { - showStartTimeAsMidnight -> "00:00" - else -> startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime() - .format(hourFormat) - } - val endTimeText = when { - showEndTimeAsMidnight -> "24:00" - else -> endDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime() - .format(hourFormat) - } - Text( - "$startTimeText - $endTimeText", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(8.dp)) - if (!event.description.isNullOrBlank()) { - Text( - event.description, - style = MaterialTheme.typography.bodyMedium, - overflow = TextOverflow.Ellipsis - ) - } - } - } - } -} - -@Preview -@Composable -fun EventCardPreview() { - val sampleEvent = Event( - id = 1L, - eventId = 1L, - title = "Beispiel Event", - description = "Dies ist eine Beschreibung des Beispiel-Events.", - startTime = System.currentTimeMillis(), - endTime = System.currentTimeMillis() + 3600000, // 1 Stunde später - calendarId = 1L, - location = "Beispielort", - allDay = false, - organizer = "Beispielorganisator", - calendar = space.midnightthoughts.nordiccalendar.util.Calendar( - id = 1L, - name = "Beispielkalender", - color = 0xFF6200EE, // Beispiel-Farbe - selected = true, - accountName = "Beispielkonto", - accountType = "com.example", - syncEvents = true, - visible = true, - displayName = "Beispielkalender", - ) - ) - EventCard( - event = sampleEvent, - isCompact = false, - ) -} diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/components/MonthView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/components/MonthView.kt @@ -1,208 +0,0 @@ -package space.midnightthoughts.nordiccalendar.components - -import android.annotation.SuppressLint -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -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.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -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.viewmodels.CalendarViewModel -import java.time.DayOfWeek -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.format.TextStyle -import java.util.Locale - -@SuppressLint("UnusedBoxWithConstraintsScope") -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MonthView( - modifier: Modifier = Modifier, - navController: NavController, - calendarViewModel: CalendarViewModel, -) { - val events by calendarViewModel.events.collectAsState() - val startMillis by calendarViewModel.startMillis.collectAsState() - val today = LocalDate.now() - val zoneId = ZoneId.systemDefault() - val firstDay = Instant.ofEpochMilli(startMillis).atZone(zoneId).toLocalDate() - val daysInMonth = firstDay.lengthOfMonth() - // Wochenstart auf Montag (1=Montag, 7=Sonntag) - 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) { - // Wochentagsnamen als Grid-Zeile - 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 - ) - } - } - } - // Monatsraster mit Linien (nur innere Linien) - 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 - ) { - // Tag-Nummer - 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 als kompakte 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} mehr", - 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) - ), - ) - } - } - } - } - } - } -} - -@Composable -fun CompactChip( - text: String, - backgroundColor: Color, - textColor: Color, - modifier: Modifier = Modifier, - borderColor: Color? = null, - 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 - ) { - Text( - text = text, - color = textColor, - lineHeight = 12.sp, - fontSize = 11.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 3.dp) - ) - } -} -\ No newline at end of file diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/components/WeekView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/components/WeekView.kt @@ -1,42 +0,0 @@ -package space.midnightthoughts.nordiccalendar.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import space.midnightthoughts.nordiccalendar.viewmodels.CalendarViewModel - -@Composable -fun WeekView( - modifier: Modifier = Modifier, - navController: NavController, - calendarViewModel: CalendarViewModel, -) { - // TODO: Implement WeekView - val events = remember(calendarViewModel) { - calendarViewModel.events - }.collectAsState(initial = emptyList()) - - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(events.value) { event -> - Text( - event.title, modifier = Modifier - .padding(8.dp) - .clickable { - navController.navigate("eventDetails/${event.eventId}?tab=1") { - launchSingleTop = true - restoreState = true - } - }) - } - } - -} -\ No newline at end of file diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt @@ -30,9 +30,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import space.midnightthoughts.nordiccalendar.R import space.midnightthoughts.nordiccalendar.components.DateRangeHeader -import space.midnightthoughts.nordiccalendar.components.DayView -import space.midnightthoughts.nordiccalendar.components.MonthView -import space.midnightthoughts.nordiccalendar.components.WeekView import space.midnightthoughts.nordiccalendar.viewmodels.CalendarViewModel @SuppressLint("UnusedBoxWithConstraintsScope") diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/DayView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/DayView.kt @@ -0,0 +1,425 @@ +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.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.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.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +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.Calendar +import space.midnightthoughts.nordiccalendar.util.Event +import space.midnightthoughts.nordiccalendar.viewmodels.CalendarViewModel +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Date +import java.util.PriorityQueue + +private fun assignColumns(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>>() + var maxColumns = 0 + + for (event in sorted) { + // Entferne abgelaufene Events und gib deren Spalten frei + 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)) + maxColumns = maxOf(maxColumns, active.size) + result.add(Triple(event, col, maxColumns)) + } + return result +} + +@Composable +private fun HourLines( + dayStart: Long, + hourHeightDp: Dp, + timeColumnWidth: Dp, + hourFormat: DateTimeFormatter +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + for (hour in 0..24) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(hourHeightDp) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + Date(dayStart + hour * 60 * 60 * 1000).toInstant() + .atZone(ZoneId.systemDefault()).toLocalDateTime().format(hourFormat), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.width(timeColumnWidth) + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun NowBar( + nowOffsetY: Float, + hourHeightPx: Float, + now: Long, + hourFormat: DateTimeFormatter +) { + val lineColor = MaterialTheme.colorScheme.error + if (nowOffsetY >= 0f && nowOffsetY <= hourHeightPx * 24.5f) { + Row( + modifier = Modifier + .fillMaxWidth() + .offset { IntOffset(0, nowOffsetY.toInt()) }, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .padding(start = 4.dp) + .background( + color = lineColor, + shape = MaterialTheme.shapes.small + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) { + Text( + Date(now).toInstant() + .atZone(ZoneId.systemDefault()).toLocalDateTime() + .format(hourFormat), + color = MaterialTheme.colorScheme.onError, + style = MaterialTheme.typography.labelSmall + ) + } + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .padding(end = 16.dp) + ) { + drawLine( + color = lineColor, + start = Offset( + 0f, + size.height / 2 + ), + end = Offset( + size.width, + size.height / 2 + ), + strokeWidth = size.height, + cap = StrokeCap.Round + ) + } + } + } +} + +@SuppressLint("UnusedBoxWithConstraintsScope") +@Composable +fun DayView( + modifier: Modifier = Modifier, + navController: NavController, + calendarViewModel: CalendarViewModel, +) { + val hourHeightDp = 64.dp + val timeColumnWidth = 64.dp + val density = LocalDensity.current + val hourHeightPx = with(density) { hourHeightDp.toPx() } + val dayStart = remember(calendarViewModel) { calendarViewModel.startMillis }.collectAsState() + val dayEnd = remember(calendarViewModel) { calendarViewModel.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(1000) + } + val nowMinutes = ((now - dayStart.value) / 60000f) + val nowOffsetY = (nowMinutes / 60f) * hourHeightPx + + BoxWithConstraints( + modifier = modifier + ) { + val visibleHeightPx = with(density) { maxHeight.toPx() } + val scrollTo = (nowOffsetY - visibleHeightPx / 2).toInt().coerceAtLeast(0) + val scrollState = rememberScrollState(initial = scrollTo) + + val events = remember(calendarViewModel) { calendarViewModel.events }.collectAsState() + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .semantics { + collectionInfo = CollectionInfo( + rowCount = events.value.size, + columnCount = 1, + ) + } + ) { + val maxWidthPx = with(density) { maxWidth.toPx() } + + // Stundenraster + HourLines( + dayStart = dayStart.value, + hourHeightDp = hourHeightDp, + timeColumnWidth = timeColumnWidth, + hourFormat = hourFormat + ) + + // Events (wie gehabt, optimiert) + val eventColumns = remember(events.value, maxWidthPx) { assignColumns(events.value) } + events.value.forEach { event -> + val triple = eventColumns.find { it.first.id == event.id } + if (triple != null) { + val (event, col, maxColumns) = triple + val shownStart = maxOf(event.startTime, dayStart.value) + val shownEnd = minOf(event.endTime, dayEnd.value) + val startMinutes = ((shownStart - dayStart.value) / 60000f) + val endMinutes = ((shownEnd - dayStart.value) / 60000f) + val offsetY = (startMinutes / 60f) * hourHeightPx + (hourHeightPx / 2f) + val eventHeightPx = ((endMinutes - startMinutes) / 60f) * hourHeightPx + val columnWidthPx = + (maxWidthPx - with(density) { timeColumnWidth.toPx() }) / maxColumns + val offsetX = + (col * columnWidthPx).toInt() + with(density) { timeColumnWidth.toPx() }.toInt() + val minCardHeightDp = 60.dp + val isCompact = with(density) { eventHeightPx.toDp() } < minCardHeightDp + + val noTopCorners = event.startTime < dayStart.value + val noBottomCorners = event.endTime > dayEnd.value + + val showStartTimeAsMidnight = shownStart == dayStart.value + val showEndTimeAsMidnight = shownEnd == dayEnd.value + + key(event.id) { + Box( + modifier = Modifier + .offset { IntOffset(offsetX, offsetY.toInt()) } + .width(with(density) { columnWidthPx.toDp() }) + .height(with(density) { eventHeightPx.toDp() }) + ) { + EventCard( + event = event, + isCompact = isCompact, + onClick = { + navController.navigate("eventDetails/${event.eventId}?tab=2") { + launchSingleTop = true + restoreState = true + } + }, + noTopCorners = noTopCorners, + noBottomCorners = noBottomCorners, + showStartTimeAsMidnight = showStartTimeAsMidnight, + showEndTimeAsMidnight = showEndTimeAsMidnight, + eventStartOverride = shownStart, + eventEndOverride = shownEnd + ) + } + } + } + } + + // Jetzt-Linie (optimiert als eigene Composable) + NowBar( + nowOffsetY = nowOffsetY, + hourHeightPx = hourHeightPx, + now = now, + hourFormat = hourFormat + ) + } + } +} + + +@Composable +private fun EventCard( + event: Event, + isCompact: Boolean, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + index: Int = 0, + noBottomCorners: Boolean = false, + noTopCorners: Boolean = false, + showStartTimeAsMidnight: Boolean = false, + showEndTimeAsMidnight: 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) + } + val shape = if (noTopCorners && noBottomCorners) { + MaterialTheme.shapes.medium.copy( + topStart = ZeroCornerSize, + topEnd = ZeroCornerSize, + bottomStart = ZeroCornerSize, + bottomEnd = ZeroCornerSize + ) + } else if (noBottomCorners) { + MaterialTheme.shapes.medium.copy( + bottomStart = ZeroCornerSize, + bottomEnd = ZeroCornerSize + ) + } else if (noTopCorners) { + MaterialTheme.shapes.medium.copy( + topStart = ZeroCornerSize, + topEnd = ZeroCornerSize + ) + } else { + MaterialTheme.shapes.medium + } + Card( + modifier = modifier + .defaultMinSize(minHeight = 24.dp) + .padding(end = 8.dp) + .semantics { + collectionItemInfo = CollectionItemInfo( + index, 0, 0, 0, + ) + } + .then(if (onClick != null) Modifier.clickable { onClick() } else Modifier), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + border = BorderStroke( + 1.dp, + color = MaterialTheme.colorScheme.outline + ), + shape = shape + ) { + Column( + modifier = Modifier + .padding( + horizontal = 12.dp, + vertical = if (isCompact) 4.dp else 12.dp, + ) + .fillMaxSize(), + verticalArrangement = if (isCompact) Arrangement.Center else Arrangement.Top, + ) { + Text( + event.title, + overflow = TextOverflow.Ellipsis, + style = if (isCompact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyLarge, + modifier = Modifier.semantics { heading() } + ) + if (!isCompact) { + Spacer(modifier = Modifier.height(4.dp)) + val startTimeText = when { + showStartTimeAsMidnight -> "00:00" + else -> startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime() + .format(hourFormat) + } + val endTimeText = when { + showEndTimeAsMidnight -> "24:00" + else -> endDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime() + .format(hourFormat) + } + Text( + "$startTimeText - $endTimeText", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + if (!event.description.isNullOrBlank()) { + Text( + event.description, + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +@Preview +@Composable +fun EventCardPreview() { + val sampleEvent = Event( + id = 1L, + eventId = 1L, + title = "Beispiel Event", + description = "Dies ist eine Beschreibung des Beispiel-Events.", + startTime = System.currentTimeMillis(), + endTime = System.currentTimeMillis() + 3600000, // 1 Stunde später + calendarId = 1L, + location = "Beispielort", + allDay = false, + organizer = "Beispielorganisator", + calendar = Calendar( + id = 1L, + name = "Beispielkalender", + color = 0xFF6200EE, // Beispiel-Farbe + selected = true, + accountName = "Beispielkonto", + accountType = "com.example", + syncEvents = true, + visible = true, + displayName = "Beispielkalender", + ) + ) + EventCard( + event = sampleEvent, + isCompact = false, + ) +} diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/MonthView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/MonthView.kt @@ -0,0 +1,208 @@ +package space.midnightthoughts.nordiccalendar.screens + +import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +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.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.viewmodels.CalendarViewModel +import java.time.DayOfWeek +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.TextStyle +import java.util.Locale + +@SuppressLint("UnusedBoxWithConstraintsScope") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MonthView( + modifier: Modifier = Modifier, + navController: NavController, + calendarViewModel: CalendarViewModel, +) { + val events by calendarViewModel.events.collectAsState() + val startMillis by calendarViewModel.startMillis.collectAsState() + val today = LocalDate.now() + val zoneId = ZoneId.systemDefault() + val firstDay = Instant.ofEpochMilli(startMillis).atZone(zoneId).toLocalDate() + val daysInMonth = firstDay.lengthOfMonth() + // Wochenstart auf Montag (1=Montag, 7=Sonntag) + 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) { + // Wochentagsnamen als Grid-Zeile + 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 + ) + } + } + } + // Monatsraster mit Linien (nur innere Linien) + 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 + ) { + // Tag-Nummer + 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 als kompakte 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} mehr", + 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) + ), + ) + } + } + } + } + } + } +} + +@Composable +fun CompactChip( + text: String, + backgroundColor: Color, + textColor: Color, + modifier: Modifier = Modifier, + borderColor: Color? = null, + 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 + ) { + Text( + text = text, + color = textColor, + lineHeight = 12.sp, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 3.dp) + ) + } +} +\ No newline at end of file diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/WeekView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/WeekView.kt @@ -0,0 +1,42 @@ +package space.midnightthoughts.nordiccalendar.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import space.midnightthoughts.nordiccalendar.viewmodels.CalendarViewModel + +@Composable +fun WeekView( + modifier: Modifier = Modifier, + navController: NavController, + calendarViewModel: CalendarViewModel, +) { + // TODO: Implement WeekView + val events = remember(calendarViewModel) { + calendarViewModel.events + }.collectAsState(initial = emptyList()) + + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(events.value) { event -> + Text( + event.title, modifier = Modifier + .padding(8.dp) + .clickable { + navController.navigate("eventDetails/${event.eventId}?tab=1") { + launchSingleTop = true + restoreState = true + } + }) + } + } + +} +\ No newline at end of file