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 7c7e06bc0a8b8b1e67925c084b99ccc9ceb766ac
parent af6e3d3c33e4000bc31fa353cb7e9c642f1b3295
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Mon, 28 Jul 2025 18:13:02 +0200

Refactor CalendarScreen

Diffstat:
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/MainActivity.kt | 19+------------------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt | 366+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt | 8+++++---
3 files changed, 206 insertions(+), 187 deletions(-)

diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/MainActivity.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/MainActivity.kt @@ -233,12 +233,6 @@ fun CalendarView(backStackEntry: NavBackStackEntry, navController: NavHostContro val selectedTab = remember(calendarViewModel) { calendarViewModel.selectedTab }.collectAsState(initial = 0) - val events = remember(calendarViewModel) { - calendarViewModel.events - }.collectAsState(initial = emptyList()) - val isRefreshing = remember(calendarViewModel) { - calendarViewModel.isRefreshing - }.collectAsState(initial = false) val tabArg = backStackEntry.arguments?.getInt("tab") LaunchedEffect(tabArg) { @@ -264,20 +258,9 @@ fun CalendarView(backStackEntry: NavBackStackEntry, navController: NavHostContro CalendarScreen( selectedTab = selectedTab.value, onTabSelected = { calendarViewModel.setTab(it) }, - events = events.value, modifier = innerPadding, - isRefreshing = isRefreshing.value, - onRefresh = { calendarViewModel.updateEvents() }, navController = navController, - viewModel = calendarViewModel, - startMillis = calendarViewModel.run { - javaClass.getDeclaredField("startMillis") - .apply { isAccessible = true }.get(this) as Long - }, - endMillis = calendarViewModel.run { - javaClass.getDeclaredField("endMillis") - .apply { isAccessible = true }.get(this) as Long - } + calendarViewModel = calendarViewModel, ) } } diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember @@ -70,29 +71,37 @@ import java.util.Locale fun CalendarScreen( selectedTab: Int, onTabSelected: (Int) -> Unit, - events: List<Event>, modifier: Modifier = Modifier, - isRefreshing: Boolean, - onRefresh: (() -> Unit), navController: NavController, - viewModel: CalendarViewModel, // <-- ViewModel als Parameter - startMillis: Long, - endMillis: Long + calendarViewModel: CalendarViewModel, ) { + val events = remember(calendarViewModel) { + calendarViewModel.events + }.collectAsState(initial = emptyList()) + val startMillis = remember(calendarViewModel) { + calendarViewModel.startMillis + } + val endMillis = remember(calendarViewModel) { + calendarViewModel.endMillis + } + val isRefreshing = remember(calendarViewModel) { + calendarViewModel.isRefreshing + }.collectAsState(initial = false) + Log.d( "CalendarScreen", - "Rendering with selectedTab: $selectedTab, events count: ${events.size}, isRefreshing: $isRefreshing" + "Rendering with selectedTab: $selectedTab, events count: ${events.value.size}, isRefreshing: $isRefreshing" ) val pullToRefreshState = rememberPullToRefreshState() PullToRefreshBox( state = pullToRefreshState, modifier = modifier, - onRefresh = onRefresh, - isRefreshing = isRefreshing, + onRefresh = { calendarViewModel.updateEvents() }, + isRefreshing = isRefreshing.value, indicator = { Indicator( modifier = Modifier.align(Alignment.TopCenter), - isRefreshing = isRefreshing, + isRefreshing = isRefreshing.value, containerColor = MaterialTheme.colorScheme.primaryContainer, color = MaterialTheme.colorScheme.onPrimaryContainer, state = pullToRefreshState @@ -108,178 +117,196 @@ fun CalendarScreen( endMillis = endMillis, onPrev = { when (selectedTab) { - 0 -> viewModel.prevMonth() - 1 -> viewModel.prevWeek() - 2 -> viewModel.prevDay() + 0 -> calendarViewModel.prevMonth() + 1 -> calendarViewModel.prevWeek() + 2 -> calendarViewModel.prevDay() } }, onNext = { when (selectedTab) { - 0 -> viewModel.nextMonth() - 1 -> viewModel.nextWeek() - 2 -> viewModel.nextDay() + 0 -> calendarViewModel.nextMonth() + 1 -> calendarViewModel.nextWeek() + 2 -> calendarViewModel.nextDay() } }, onToday = { when (selectedTab) { - 0 -> viewModel.setTodayMonth() - 1 -> viewModel.setTodayWeek() - 2 -> viewModel.setTodayDay() + 0 -> calendarViewModel.setTodayMonth() + 1 -> calendarViewModel.setTodayWeek() + 2 -> calendarViewModel.setTodayDay() } } ) Spacer(Modifier.height(8.dp)) - if (events.isEmpty()) { + if (events.value.isEmpty()) { Text(stringResource(R.string.no_events_found), modifier = Modifier.padding(16.dp)) } else if (selectedTab == 2) { - val hourHeightDp = 64.dp - val timeColumnWidth = 64.dp - val density = LocalDensity.current - val hourHeightPx = with(density) { hourHeightDp.toPx() } - val dayStart = events.minOfOrNull { it.startTime }?.let { - val cal = Calendar.getInstance() - cal.timeInMillis = it - cal.set(Calendar.HOUR_OF_DAY, 0) - cal.set(Calendar.MINUTE, 0) - cal.set(Calendar.SECOND, 0) - cal.set(Calendar.MILLISECOND, 0) - cal.timeInMillis - } ?: Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, 0) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - }.timeInMillis - val hourFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) - val scrollState = rememberScrollState() - val coroutineScope = rememberCoroutineScope() - var now by remember { mutableLongStateOf(System.currentTimeMillis()) } - // Aktualisiere die "Jetzt"-Linie jede Sekunde - LaunchedEffect(selectedTab) { - while (selectedTab == 2) { - now = System.currentTimeMillis() - delay(1000) - } - } - val nowMinutes = ((now - dayStart) / 60000f) - val nowOffsetY = ((nowMinutes + 30f) / 60f) * hourHeightPx - LaunchedEffect(selectedTab) { - coroutineScope.launch { - val visibleHeightPx = with(density) { 600.dp.toPx() } - val scrollTo = (nowOffsetY - visibleHeightPx / 2).toInt().coerceAtLeast(0) - scrollState.scrollTo(scrollTo) - } - } - BoxWithConstraints( + DayView( + navController = navController, + calendarViewModel = calendarViewModel + ) + } else { + EventList( + navController = navController, + calendarViewModel = calendarViewModel + ) + } + } + } +} + +@SuppressLint("UnusedBoxWithConstraintsScope") +@Composable +fun DayView( + navController: NavController, + calendarViewModel: CalendarViewModel, +) { + val events = remember(calendarViewModel) { + calendarViewModel.events + }.collectAsState(initial = emptyList()) + + val hourHeightDp = 64.dp + val timeColumnWidth = 64.dp + val density = LocalDensity.current + val hourHeightPx = with(density) { hourHeightDp.toPx() } + val dayStart = events.value.minOfOrNull { it.startTime }?.let { + val cal = Calendar.getInstance() + cal.timeInMillis = it + cal.set(Calendar.HOUR_OF_DAY, 0) + cal.set(Calendar.MINUTE, 0) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + cal.timeInMillis + } ?: Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + val hourFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + + // Aktualisiere die "Jetzt"-Linie jede Sekunde + LaunchedEffect(now) { + now = System.currentTimeMillis() + delay(1000) + } + val nowMinutes = ((now - dayStart) / 60000f) + val nowOffsetY = ((nowMinutes + 30f) / 60f) * hourHeightPx + LaunchedEffect(nowOffsetY) { + coroutineScope.launch { + val visibleHeightPx = with(density) { 600.dp.toPx() } + val scrollTo = (nowOffsetY - visibleHeightPx / 2).toInt().coerceAtLeast(0) + scrollState.scrollTo(scrollTo) + } + } + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + ) { + val maxWidthPx = with(density) { maxWidth.toPx() } + val lineThickness = 4.dp + val lineColor = MaterialTheme.colorScheme.error + // Stunden-Divider + Column(modifier = Modifier.fillMaxWidth()) { + for (hour in 0..23) { + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) + .height(hourHeightDp) + .padding(horizontal = 8.dp, vertical = 4.dp) ) { - val maxWidthPx = with(density) { maxWidth.toPx() } - val lineThickness = 4.dp - val lineColor = MaterialTheme.colorScheme.error - // Stunden-Divider - Column(modifier = Modifier.fillMaxWidth()) { - for (hour in 0..23) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .height(hourHeightDp) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Text( - hourFormat.format(Date(dayStart + hour * 60 * 60 * 1000)), - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.width(timeColumnWidth) - ) - HorizontalDivider(modifier = Modifier.weight(1f)) - } - } - } - fun assignColumns(events: List<Event>): List<Triple<Event, Int, Int>> { - val sorted = events.sortedBy { it.startTime } - val active = mutableListOf<Pair<Event, Int>>() // Event, column - val result = - mutableListOf<Triple<Event, Int, Int>>() // Event, column, maxColumns - for (event in sorted) { - // Entferne abgelaufene Events - active.removeAll { it.first.endTime <= event.startTime } - // Finde freie Spalte - val usedColumns = active.map { it.second }.toSet() - val col = - (0..active.size).firstOrNull { it !in usedColumns } ?: active.size - active.add(event to col) - // maxColumns = aktuelle Anzahl aktiver Events - val maxColumns = active.size - result.add(Triple(event, col, maxColumns)) - } - return result - } + Text( + hourFormat.format(Date(dayStart + hour * 60 * 60 * 1000)), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.width(timeColumnWidth) + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + } + } + fun assignColumns(events: List<Event>): List<Triple<Event, Int, Int>> { + val sorted = events.sortedBy { it.startTime } + val active = mutableListOf<Pair<Event, Int>>() // Event, column + val result = + mutableListOf<Triple<Event, Int, Int>>() // Event, column, maxColumns + for (event in sorted) { + // Entferne abgelaufene Events + active.removeAll { it.first.endTime <= event.startTime } + // Finde freie Spalte + val usedColumns = active.map { it.second }.toSet() + val col = + (0..active.size).firstOrNull { it !in usedColumns } ?: active.size + active.add(event to col) + // maxColumns = aktuelle Anzahl aktiver Events + val maxColumns = active.size + result.add(Triple(event, col, maxColumns)) + } + return result + } - val eventColumns = assignColumns(events) - eventColumns.forEach { (event, col, maxColumns) -> - val startMinutes = ((event.startTime - dayStart) / 60000f) - val endMinutes = ((event.endTime - dayStart) / 60000f) - val offsetY = ((startMinutes + 30f) / 60f) * hourHeightPx - 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 - EventCard( - event = event, - isCompact = isCompact, - hourFormat = hourFormat, - onClick = { navController.navigate("eventDetails/${event.id}") }, - modifier = Modifier - .width(with(density) { columnWidthPx.toDp() }) - .defaultMinSize(minHeight = 24.dp) - .padding(end = 8.dp) - .offset { IntOffset(offsetX, offsetY.toInt()) } - .height(with(density) { eventHeightPx.toDp() }) - ) - } - // "Jetzt"-Linie und Label, nur wenn im Tagesbereich - if (nowOffsetY >= 0f && nowOffsetY <= hourHeightPx * 24) { - Row( - modifier = Modifier - .fillMaxWidth() - .offset { IntOffset(0, nowOffsetY.toInt()) }, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - stringResource(R.string.now), - color = lineColor, - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(start = 16.dp, end = 24.dp) - ) - androidx.compose.foundation.Canvas( - modifier = Modifier - .fillMaxWidth() - .height(lineThickness) - .padding(end = 16.dp) - ) { - drawLine( - color = lineColor, - start = androidx.compose.ui.geometry.Offset( - 0f, - size.height / 2 - ), - end = androidx.compose.ui.geometry.Offset( - size.width, - size.height / 2 - ), - strokeWidth = size.height, - cap = androidx.compose.ui.graphics.StrokeCap.Round - ) - } - } - } + val eventColumns = assignColumns(events.value) + eventColumns.forEach { (event, col, maxColumns) -> + val startMinutes = ((event.startTime - dayStart) / 60000f) + val endMinutes = ((event.endTime - dayStart) / 60000f) + val offsetY = ((startMinutes + 30f) / 60f) * hourHeightPx + 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 + EventCard( + event = event, + isCompact = isCompact, + hourFormat = hourFormat, + onClick = { navController.navigate("eventDetails/${event.id}") }, + modifier = Modifier + .width(with(density) { columnWidthPx.toDp() }) + .defaultMinSize(minHeight = 24.dp) + .padding(end = 8.dp) + .offset { IntOffset(offsetX, offsetY.toInt()) } + .height(with(density) { eventHeightPx.toDp() }) + ) + } + // "Jetzt"-Linie und Label, nur wenn im Tagesbereich + if (nowOffsetY >= 0f && nowOffsetY <= hourHeightPx * 24) { + Row( + modifier = Modifier + .fillMaxWidth() + .offset { IntOffset(0, nowOffsetY.toInt()) }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(R.string.now), + color = lineColor, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(start = 16.dp, end = 24.dp) + ) + androidx.compose.foundation.Canvas( + modifier = Modifier + .fillMaxWidth() + .height(lineThickness) + .padding(end = 16.dp) + ) { + drawLine( + color = lineColor, + start = androidx.compose.ui.geometry.Offset( + 0f, + size.height / 2 + ), + end = androidx.compose.ui.geometry.Offset( + size.width, + size.height / 2 + ), + strokeWidth = size.height, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) } - } else { - EventList(events = events) } } } @@ -456,9 +483,16 @@ fun EventCardPreview() { } @Composable -fun EventList(events: List<Event>) { +fun EventList( + navController: NavController, + calendarViewModel: CalendarViewModel +) { + val events = remember(calendarViewModel) { + calendarViewModel.events + }.collectAsState(initial = emptyList()) + LazyColumn(modifier = Modifier.fillMaxSize()) { - items(events) { event -> + items(events.value) { event -> Text(event.title, modifier = Modifier.padding(8.dp)) } } diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt @@ -27,8 +27,10 @@ class CalendarViewModel(app: Application) : AndroidViewModel(app) { private val _selectedTab = MutableStateFlow(0) val selectedTab: StateFlow<Int> = _selectedTab.asStateFlow() - private var startMillis: Long = getDefaultStartMillis(0) - private var endMillis: Long = getDefaultEndMillis(0) + var startMillis: Long = getDefaultStartMillis(0) + private set + var endMillis: Long = getDefaultEndMillis(0) + private set init { loadCalendars() @@ -47,7 +49,7 @@ class CalendarViewModel(app: Application) : AndroidViewModel(app) { fun toggleCalendar(calendar: Calendar) { // TODO: Store this somewhere so it is kept across app restarts Log.d("CalendarViewModel", "Toggling calendar: ${calendar.name}, ID: ${calendar.id}") - + val updatedCalendars = _calendars.value.map { if (it.id == calendar.id) { it.copy(selected = !it.selected)