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 94ad0a56e7efd8cf013c8f184821364c77997476
parent 25143143789adcf099983cc09562f945383b6b71
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Tue, 29 Jul 2025 21:30:55 +0200

Fix navigation with tabs and fix multi day event rendering

Diffstat:
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/MainActivity.kt | 28+++++++++++++++++++++++++---
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/components/AppScaffold.kt | 3++-
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt | 148+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/EventDetailsView.kt | 18++++++++++++++++++
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/EventDetailsViewModel.kt | 2+-
5 files changed, 142 insertions(+), 57 deletions(-)

diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/MainActivity.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/MainActivity.kt @@ -111,7 +111,12 @@ class MainActivity : ComponentActivity() { IntroScreen(navController) { // Callback nach Abschluss OnboardingPrefs.setOnboardingDone(this@MainActivity, currentVersion) navController.navigate(Destinations.Calendar.route) { - popUpTo(Destinations.Intro.route) { inclusive = true } + popUpTo(Destinations.Intro.route) { + inclusive = true + saveState = true + } + launchSingleTop = true + restoreState = true } } } @@ -129,7 +134,16 @@ class MainActivity : ComponentActivity() { navController = navController ) } - composable(Destinations.EventDetails.route) { backStackEntry -> + composable( + route = "eventDetails/{eventId}?tab={tab}", + arguments = listOf( + navArgument("eventId") { type = NavType.LongType }, + navArgument("tab") { + type = NavType.IntType + defaultValue = 0 + } + ) + ) { backStackEntry -> EventDetailsView( backStackEntry = backStackEntry, navController = navController @@ -158,7 +172,15 @@ class MainActivity : ComponentActivity() { @Composable fun IntroScreen(navController: NavHostController, onFinish: (() -> Unit)? = null) { val navigateToHome: () -> Unit = { - onFinish?.invoke() ?: navController.navigate(Destinations.Calendar.route) + onFinish?.invoke() ?: navController.navigate(Destinations.Calendar.route) { + popUpTo(Destinations.Calendar.route) { + inclusive = true + saveState = true + } + + launchSingleTop = true + restoreState = true + } } val pagerState = rememberPagerState(initialPage = 0, pageCount = { onBoardingData.size }) val coroutineScope = rememberCoroutineScope() diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/components/AppScaffold.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/components/AppScaffold.kt @@ -33,6 +33,7 @@ fun AppScaffold( selectedDestination: String, navController: NavController, floatingActionButton: (@Composable () -> Unit)? = null, + onBackClick: (() -> Unit)? = null, content: @Composable (Modifier) -> Unit ) { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) @@ -69,7 +70,7 @@ fun AppScaffold( IconButton( onClick = { scope.launch { drawerState.close() } - navController.popBackStack() + onBackClick?.invoke() ?: navController.popBackStack() }, modifier = Modifier.size(48.dp) ) { diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -70,7 +69,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import space.midnightthoughts.nordiccalendar.R import space.midnightthoughts.nordiccalendar.getCurrentAppLocale import space.midnightthoughts.nordiccalendar.util.Event @@ -80,7 +78,6 @@ import java.time.format.DateTimeFormatter import java.util.Calendar import java.util.Date - @SuppressLint("UnusedBoxWithConstraintsScope") @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -155,12 +152,13 @@ fun CalendarScreen( } else if (selectedTab == 2) { DayView( navController = navController, - calendarViewModel = calendarViewModel + calendarViewModel = calendarViewModel, ) } else { EventList( navController = navController, - calendarViewModel = calendarViewModel + calendarViewModel = calendarViewModel, + selectedTab = selectedTab ) } } @@ -181,40 +179,27 @@ fun DayView( 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 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) - 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 nowMinutes = ((now - dayStart.value) / 60000f) val nowOffsetY = ((nowMinutes + 30f) / 60f) * hourHeightPx - LaunchedEffect(Unit) { - coroutineScope.launch { - val visibleHeightPx = with(density) { 600.dp.toPx() } - val scrollTo = (nowOffsetY - visibleHeightPx / 2).toInt().coerceAtLeast(0) - scrollState.scrollTo(scrollTo) - } - } + + val visibleHeightPx = with(density) { 600.dp.toPx() } + val scrollTo = (nowOffsetY - visibleHeightPx / 2).toInt().coerceAtLeast(0) + val scrollState = rememberScrollState(initial = scrollTo) + BoxWithConstraints( modifier = Modifier .fillMaxSize() @@ -234,7 +219,7 @@ fun DayView( ) } ) { - for (hour in 0..23) { + for (hour in 0..24) { // Bis einschließlich 24, um 00:00 am Folgetag anzuzeigen Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -242,7 +227,7 @@ fun DayView( .padding(horizontal = 8.dp, vertical = 4.dp) ) { Text( - Date(dayStart + hour * 60 * 60 * 1000).toInstant() + Date(dayStart.value + hour * 60 * 60 * 1000).toInstant() .atZone(ZoneId.systemDefault()).toLocalDateTime().format(hourFormat), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(timeColumnWidth) @@ -273,8 +258,11 @@ fun DayView( val eventColumns = assignColumns(events.value) eventColumns.forEach { (event, col, maxColumns) -> - val startMinutes = ((event.startTime - dayStart) / 60000f) - val endMinutes = ((event.endTime - dayStart) / 60000f) + // Berechne die sichtbaren Grenzen für diesen Tag + 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 + 30f) / 60f) * hourHeightPx val eventHeightPx = ((endMinutes - startMinutes) / 60f) * hourHeightPx val columnWidthPx = @@ -283,16 +271,36 @@ fun DayView( (col * columnWidthPx).toInt() + with(density) { timeColumnWidth.toPx() }.toInt() val minCardHeightDp = 60.dp val isCompact = with(density) { eventHeightPx.toDp() } < minCardHeightDp + + // Rundung-Logik + val noTopCorners = event.startTime < dayStart.value + val noBottomCorners = event.endTime > dayEnd.value + + // Zeit-Label-Logik + val showStartTimeAsMidnight = shownStart == dayStart.value + val showEndTimeAsMidnight = shownEnd == dayEnd.value + EventCard( event = event, isCompact = isCompact, - onClick = { navController.navigate("eventDetails/${event.eventId}") }, + onClick = { + navController.navigate("eventDetails/${event.eventId}?tab=2") { + launchSingleTop = true + restoreState = true + } + }, 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() }) + .height(with(density) { eventHeightPx.toDp() }), + noTopCorners = noTopCorners, + noBottomCorners = noBottomCorners, + showStartTimeAsMidnight = showStartTimeAsMidnight, + showEndTimeAsMidnight = showEndTimeAsMidnight, + eventStartOverride = shownStart, + eventEndOverride = shownEnd ) } // "Jetzt"-Linie und Label, nur wenn im Tagesbereich @@ -486,17 +494,42 @@ fun EventCard( isCompact: Boolean, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, - index: Int = 0 + 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(event.startTime) { - Date(event.startTime) + val startDate = remember(eventStartOverride.takeIf { it > 0L } ?: event.startTime) { + Date(eventStartOverride.takeIf { it > 0L } ?: event.startTime) } - val endDate = remember(event.endTime) { - Date(event.endTime) + 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) @@ -511,7 +544,8 @@ fun EventCard( border = BorderStroke( 1.dp, color = MaterialTheme.colorScheme.outline - ) + ), + shape = shape ) { Column( modifier = Modifier @@ -530,14 +564,18 @@ fun EventCard( ) 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( - "${ - startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime() - .format(hourFormat) - } - ${ - endDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime() - .format(hourFormat) - }", + "$startTimeText - $endTimeText", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary ) @@ -589,7 +627,8 @@ fun EventCardPreview() { @Composable fun EventList( navController: NavController, - calendarViewModel: CalendarViewModel + calendarViewModel: CalendarViewModel, + selectedTab: Int // Tab-Index als neuen Parameter ) { val events = remember(calendarViewModel) { calendarViewModel.events @@ -600,7 +639,12 @@ fun EventList( Text( event.title, modifier = Modifier .padding(8.dp) - .clickable { navController.navigate("eventDetails/${event.eventId}") }) + .clickable { + navController.navigate("eventDetails/${event.eventId}?tab=$selectedTab") { + launchSingleTop = true + restoreState = true + } + }) } } } diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/EventDetailsView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/EventDetailsView.kt @@ -2,6 +2,7 @@ package space.midnightthoughts.nordiccalendar.screens import android.text.format.DateUtils.formatElapsedTime import android.util.Patterns +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -129,6 +130,22 @@ fun EventDetailsView( } } + val tabArg = backStackEntry.arguments?.getInt("tab") + + // Back-Handling: Wenn Tab-Argument vorhanden, gezielt zurück navigieren + val handleBack: () -> Unit = { + if (tabArg != null) { + navController.navigate("calendar?tab=$tabArg") { + popUpTo("calendar?tab=$tabArg") { inclusive = true } + launchSingleTop = true + restoreState = true + } + } else { + navController.popBackStack() + } + } + BackHandler { handleBack() } + AppScaffold( title = event.value?.title ?: stringResource( @@ -137,6 +154,7 @@ fun EventDetailsView( ), selectedDestination = "eventDetails", navController = navController, + onBackClick = handleBack // <-- Back-Logik an AppScaffold übergeben ) { innerPadding -> SelectionContainer(modifier = innerPadding) { Column( diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/EventDetailsViewModel.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/EventDetailsViewModel.kt @@ -81,7 +81,7 @@ class EventDetailsViewModel @Inject constructor( } init { - val eventId = savedStateHandle.get<String>("eventId")?.toLongOrNull() + val eventId = savedStateHandle.get<Long>("eventId") if (eventId != null) { loadEvent(eventId) }