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 3ec05bcefdd5614c48cb6e638531124d9b70947c
parent 900abe345637c11f288a83b84e956097dbea3d76
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Mon, 28 Jul 2025 18:50:12 +0200

Initial setup for Event Details

Diffstat:
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/MainActivity.kt | 27+--------------------------
Aapp/src/main/java/space/midnightthoughts/nordiccalendar/data/CalendarRepository.kt | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt | 10+++++++---
Aapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/EventDetailsView.kt | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/util/CalendarData.kt | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt | 98++++++++++++++++++++++++-------------------------------------------------------
Aapp/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/EventDetailsViewModel.kt | 35+++++++++++++++++++++++++++++++++++
7 files changed, 281 insertions(+), 108 deletions(-)

diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/MainActivity.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/MainActivity.kt @@ -13,7 +13,6 @@ 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -57,6 +56,7 @@ import space.midnightthoughts.nordiccalendar.components.AppScaffold import space.midnightthoughts.nordiccalendar.onboarding.OnBoardItem import space.midnightthoughts.nordiccalendar.onboarding.onBoardingData import space.midnightthoughts.nordiccalendar.screens.CalendarScreen +import space.midnightthoughts.nordiccalendar.screens.EventDetailsView import space.midnightthoughts.nordiccalendar.ui.theme.NordicCalendarTheme import space.midnightthoughts.nordiccalendar.util.OnboardingPrefs import space.midnightthoughts.nordiccalendar.viewmodels.CalendarViewModel @@ -267,31 +267,6 @@ fun CalendarView(backStackEntry: NavBackStackEntry, navController: NavHostContro @Composable -fun EventDetailsView( - backStackEntry: NavBackStackEntry, - navController: NavHostController -) { - val eventId = backStackEntry.arguments?.getString("eventId") - AppScaffold( - title = stringResource(R.string.event_details_title), - selectedDestination = "eventDetails", - navController = navController, - ) { innerPadding -> - Column( - modifier = innerPadding - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(stringResource(R.string.event_details, eventId ?: "")) - Spacer(modifier = Modifier.height(24.dp)) - // Weitere Event-Details hier - } - } -} - -@Composable fun SettingsView(navController: NavHostController) { AppScaffold( title = stringResource(R.string.settings), diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/data/CalendarRepository.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/data/CalendarRepository.kt @@ -0,0 +1,49 @@ +package space.midnightthoughts.nordiccalendar.data + +import android.content.ContentResolver +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import space.midnightthoughts.nordiccalendar.util.Calendar +import space.midnightthoughts.nordiccalendar.util.CalendarData +import space.midnightthoughts.nordiccalendar.util.Event + +class CalendarRepository(private val context: Context) { + private val calendarData = CalendarData() + private val prefs: SharedPreferences by lazy { + context.getSharedPreferences("calendar_prefs", Context.MODE_PRIVATE) + } + private val contentResolver: ContentResolver get() = context.contentResolver + + fun getCalendars(): List<Calendar> { + val calendars = calendarData.getCalendars(contentResolver) + val selectedIds = prefs.getStringSet("selected_calendar_ids", null) + return if (selectedIds != null) { + calendars.map { it.copy(selected = selectedIds.contains(it.id.toString())) } + } else { + calendars + } + } + + fun setCalendarSelected(calendarId: Long, selected: Boolean) { + val selectedIds = + prefs.getStringSet("selected_calendar_ids", null)?.toMutableSet() ?: mutableSetOf() + if (selected) { + selectedIds.add(calendarId.toString()) + } else { + selectedIds.remove(calendarId.toString()) + } + prefs.edit { putStringSet("selected_calendar_ids", selectedIds) } + } + + fun getEventsForCalendars( + calendarIds: List<Long>, + startMillis: Long, + endMillis: Long + ): List<Event> = + calendarData.getEventsForCalendars(contentResolver, calendarIds, startMillis, endMillis) + + fun getEventById(eventId: Long): Event? { + return calendarData.getEventById(contentResolver, eventId) + } +} diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt @@ -195,7 +195,7 @@ fun DayView( } val nowMinutes = ((now - dayStart) / 60000f) val nowOffsetY = ((nowMinutes + 30f) / 60f) * hourHeightPx - LaunchedEffect(nowOffsetY) { + LaunchedEffect(Unit) { coroutineScope.launch { val visibleHeightPx = with(density) { 600.dp.toPx() } val scrollTo = (nowOffsetY - visibleHeightPx / 2).toInt().coerceAtLeast(0) @@ -264,7 +264,7 @@ fun DayView( event = event, isCompact = isCompact, hourFormat = hourFormat, - onClick = { navController.navigate("eventDetails/${event.id}") }, + onClick = { navController.navigate("eventDetails/${event.eventId}") }, modifier = Modifier .width(with(density) { columnWidthPx.toDp() }) .defaultMinSize(minHeight = 24.dp) @@ -466,6 +466,7 @@ fun EventCard( fun EventCardPreview() { val sampleEvent = Event( id = 1L, + eventId = 1L, title = "Beispiel Event", description = "Dies ist eine Beschreibung des Beispiel-Events.", startTime = System.currentTimeMillis(), @@ -493,7 +494,10 @@ fun EventList( LazyColumn(modifier = Modifier.fillMaxSize()) { items(events.value) { event -> - Text(event.title, modifier = Modifier.padding(8.dp)) + Text( + event.title, modifier = Modifier + .padding(8.dp) + .clickable { navController.navigate("eventDetails/${event.eventId}") }) } } } diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/EventDetailsView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/EventDetailsView.kt @@ -0,0 +1,99 @@ +package space.midnightthoughts.nordiccalendar.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import space.midnightthoughts.nordiccalendar.R +import space.midnightthoughts.nordiccalendar.components.AppScaffold +import space.midnightthoughts.nordiccalendar.viewmodels.EventDetailsViewModel +import java.text.SimpleDateFormat +import java.util.Locale + +@Composable +fun EventDetailsView( + backStackEntry: NavBackStackEntry, + navController: NavHostController, +) { + val owner = LocalViewModelStoreOwner.current + val context = + androidx.compose.ui.platform.LocalContext.current.applicationContext as android.app.Application + val eventId = backStackEntry.arguments?.getString("eventId") + val viewModel: EventDetailsViewModel = if (owner != null && eventId != null) { + viewModel( + viewModelStoreOwner = owner, + key = "eventDetails_$eventId", + factory = object : androidx.lifecycle.ViewModelProvider.Factory { + override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T { + @Suppress("UNCHECKED_CAST") + return EventDetailsViewModel( + app = context, + savedStateHandle = androidx.lifecycle.SavedStateHandle(mapOf("eventId" to eventId)) + ) as T + } + } + ) + } else { + throw IllegalStateException("ViewModelStoreOwner or eventId is null") + } + val event = viewModel.event.collectAsState().value + val dateTimeFormat = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()) + AppScaffold( + title = stringResource(R.string.event_details_title), + selectedDestination = "eventDetails", + navController = navController, + ) { innerPadding -> + Column( + modifier = innerPadding + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (event != null) { + Text(event.title) + Spacer(modifier = Modifier.height(8.dp)) + if (!event.description.isNullOrBlank()) { + Text(event.description) + Spacer(modifier = Modifier.height(8.dp)) + } + Text( + "Start: " + dateTimeFormat.format(java.util.Date(event.startTime)) + ) + Text( + "Ende: " + dateTimeFormat.format(java.util.Date(event.endTime)) + ) + if (!event.location.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text("Ort: ${event.location}") + } + if (!event.organizer.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text("Organisator: ${event.organizer}") + } + } else { + Text( + stringResource( + R.string.event_details, + backStackEntry.arguments?.getString("eventId") ?: "" + ) + ) + Spacer(modifier = Modifier.height(24.dp)) + Text("Event nicht gefunden oder wird geladen...") + } + } + } +} +\ No newline at end of file diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/util/CalendarData.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/util/CalendarData.kt @@ -19,7 +19,8 @@ data class Calendar( ) data class Event( - val id: Long, + val id: Long, // Instanz-ID (Instances._ID) + val eventId: Long, // Event-ID (Instances.EVENT_ID) val title: String, val description: String?, val startTime: Long, @@ -117,6 +118,7 @@ class CalendarData { val INSTANCE_PROJECTION = arrayOf( CalendarContract.Instances._ID, + CalendarContract.Instances.EVENT_ID, CalendarContract.Instances.TITLE, CalendarContract.Instances.DESCRIPTION, CalendarContract.Instances.BEGIN, @@ -127,14 +129,15 @@ class CalendarData { CalendarContract.Instances.ORGANIZER ) val PROJECTION_ID_INDEX = 0 - val PROJECTION_TITLE_INDEX = 1 - val PROJECTION_DESCRIPTION_INDEX = 2 - val PROJECTION_BEGIN_INDEX = 3 - val PROJECTION_END_INDEX = 4 - val PROJECTION_CALENDAR_ID_INDEX = 5 - val PROJECTION_EVENT_LOCATION_INDEX = 6 - val PROJECTION_ALL_DAY_INDEX = 7 - val PROJECTION_ORGANIZER_INDEX = 8 + val PROJECTION_EVENT_ID_INDEX = 1 + val PROJECTION_TITLE_INDEX = 2 + val PROJECTION_DESCRIPTION_INDEX = 3 + val PROJECTION_BEGIN_INDEX = 4 + val PROJECTION_END_INDEX = 5 + val PROJECTION_CALENDAR_ID_INDEX = 6 + val PROJECTION_EVENT_LOCATION_INDEX = 7 + val PROJECTION_ALL_DAY_INDEX = 8 + val PROJECTION_ORGANIZER_INDEX = 9 val builder: Uri.Builder = CalendarContract.Instances.CONTENT_URI.buildUpon() ContentUris.appendId(builder, startMillis) @@ -149,6 +152,7 @@ class CalendarData { ) while (cur?.moveToNext() == true) { val id = cur.getLong(PROJECTION_ID_INDEX) + val eventId = cur.getLong(PROJECTION_EVENT_ID_INDEX) val title = cur.getString(PROJECTION_TITLE_INDEX) ?: "No Title" val description = cur.getString(PROJECTION_DESCRIPTION_INDEX) val startTime = cur.getLong(PROJECTION_BEGIN_INDEX) @@ -158,11 +162,10 @@ class CalendarData { val allDay = cur.getInt(PROJECTION_ALL_DAY_INDEX) == 1 val organizer = cur.getString(PROJECTION_ORGANIZER_INDEX) - // Fetch attendees if needed (not included in this example for brevity) - events.add( Event( id = id, + eventId = eventId, title = title, description = description, startTime = startTime, @@ -235,4 +238,49 @@ class CalendarData { ) } } + + fun getEventById(contentResolver: ContentResolver, eventId: Long): Event? { + val INSTANCE_PROJECTION = arrayOf( + CalendarContract.Instances._ID, + CalendarContract.Instances.EVENT_ID, + CalendarContract.Instances.TITLE, + CalendarContract.Instances.DESCRIPTION, + CalendarContract.Instances.BEGIN, + CalendarContract.Instances.END, + CalendarContract.Instances.CALENDAR_ID, + CalendarContract.Instances.EVENT_LOCATION, + CalendarContract.Instances.ALL_DAY, + CalendarContract.Instances.ORGANIZER + ) + val selection = "Instances.event_id = ?" + val selectionArgs = arrayOf(eventId.toString()) + val builder = CalendarContract.Instances.CONTENT_URI.buildUpon() + val minMillis = 0L + val maxMillis = Long.MAX_VALUE + ContentUris.appendId(builder, minMillis) + ContentUris.appendId(builder, maxMillis) + val cur = contentResolver.query( + builder.build(), + INSTANCE_PROJECTION, + selection, + selectionArgs, + null + ) + val event = if (cur?.moveToFirst() == true) { + Event( + id = cur.getLong(0), + eventId = cur.getLong(1), + title = cur.getString(2) ?: "No Title", + description = cur.getString(3), + startTime = cur.getLong(4), + endTime = cur.getLong(5), + calendarId = cur.getLong(6), + location = cur.getString(7), + allDay = cur.getInt(8) == 1, + organizer = cur.getString(9) + ) + } else null + cur?.close() + return event + } } \ No newline at end of file diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt @@ -1,22 +1,19 @@ package space.midnightthoughts.nordiccalendar.viewmodels import android.app.Application -import android.content.ContentResolver -import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import space.midnightthoughts.nordiccalendar.data.CalendarRepository import space.midnightthoughts.nordiccalendar.util.Calendar -import space.midnightthoughts.nordiccalendar.util.CalendarData import space.midnightthoughts.nordiccalendar.util.Event class CalendarViewModel(app: Application) : AndroidViewModel(app) { val isRefreshing = MutableStateFlow(false) - private val calendarData = CalendarData() - private val contentResolver: ContentResolver = app.contentResolver + private val repository = CalendarRepository(app) private val _calendars = MutableStateFlow<List<Calendar>>(emptyList()) val calendars: StateFlow<List<Calendar>> = _calendars.asStateFlow() @@ -39,7 +36,7 @@ class CalendarViewModel(app: Application) : AndroidViewModel(app) { private fun loadCalendars() { isRefreshing.value = true viewModelScope.launch { - val allCalendars = calendarData.getCalendars(contentResolver) + val allCalendars = repository.getCalendars() _calendars.value = allCalendars updateEvents() isRefreshing.value = false @@ -47,18 +44,10 @@ 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) - } else { - it - } + viewModelScope.launch { + repository.setCalendarSelected(calendar.id, !calendar.selected) + loadCalendars() } - _calendars.value = updatedCalendars - updateEvents() } fun setTab(tab: Int) { @@ -132,27 +121,21 @@ class CalendarViewModel(app: Application) : AndroidViewModel(app) { } fun updateEvents() { - isRefreshing.value = true - // Zeitintervall immer neu berechnen, damit Pull-to-Refresh in jedem Tab funktioniert - val tab = _selectedTab.value - startMillis = getDefaultStartMillis(tab) - endMillis = getDefaultEndMillis(tab) - Log.d( - "CalendarViewModel", - "Updating events for selected calendars: ${ - _calendars.value.filter { it.selected }.map { it.name } - }" - ) viewModelScope.launch { - val selectedIds = _calendars.value.filter { it.selected }.map { it.id } - Log.d("CalendarViewModel", "Selected calendar IDs: $selectedIds") - _events.value = calendarData.getEventsForCalendars( - contentResolver, - selectedIds, - startMillis, - endMillis - ) - isRefreshing.value = false + isRefreshing.value = true + try { + val tab = _selectedTab.value + startMillis = getDefaultStartMillis(tab) + endMillis = getDefaultEndMillis(tab) + val selectedIds = _calendars.value.filter { it.selected }.map { it.id } + _events.value = repository.getEventsForCalendars( + selectedIds, + startMillis, + endMillis + ) + } finally { + isRefreshing.value = false + } } } @@ -165,7 +148,7 @@ class CalendarViewModel(app: Application) : AndroidViewModel(app) { cal.set(java.util.Calendar.MINUTE, 59) cal.set(java.util.Calendar.SECOND, 59) endMillis = cal.timeInMillis - updateEventsCustom() + updateEvents() } fun prevDay() { @@ -177,7 +160,7 @@ class CalendarViewModel(app: Application) : AndroidViewModel(app) { cal.set(java.util.Calendar.MINUTE, 59) cal.set(java.util.Calendar.SECOND, 59) endMillis = cal.timeInMillis - updateEventsCustom() + updateEvents() } fun nextWeek() { @@ -190,7 +173,7 @@ class CalendarViewModel(app: Application) : AndroidViewModel(app) { cal.set(java.util.Calendar.MINUTE, 59) cal.set(java.util.Calendar.SECOND, 59) endMillis = cal.timeInMillis - updateEventsCustom() + updateEvents() } fun prevWeek() { @@ -203,7 +186,7 @@ class CalendarViewModel(app: Application) : AndroidViewModel(app) { cal.set(java.util.Calendar.MINUTE, 59) cal.set(java.util.Calendar.SECOND, 59) endMillis = cal.timeInMillis - updateEventsCustom() + updateEvents() } fun nextMonth() { @@ -220,7 +203,7 @@ class CalendarViewModel(app: Application) : AndroidViewModel(app) { cal.set(java.util.Calendar.MINUTE, 59) cal.set(java.util.Calendar.SECOND, 59) endMillis = cal.timeInMillis - updateEventsCustom() + updateEvents() } fun prevMonth() { @@ -237,30 +220,9 @@ class CalendarViewModel(app: Application) : AndroidViewModel(app) { cal.set(java.util.Calendar.MINUTE, 59) cal.set(java.util.Calendar.SECOND, 59) endMillis = cal.timeInMillis - updateEventsCustom() - } - - private fun updateEventsCustom() { - isRefreshing.value = true - Log.d( - "CalendarViewModel", - "Updating events for selected calendars: ${ - _calendars.value.filter { it.selected }.map { it.name } - }" - ) - viewModelScope.launch { - val selectedIds = _calendars.value.filter { it.selected }.map { it.id } - Log.d("CalendarViewModel", "Selected calendar IDs: $selectedIds") - _events.value = calendarData.getEventsForCalendars( - contentResolver, - selectedIds, - startMillis, - endMillis - ) - isRefreshing.value = false - } + updateEvents() } - + fun setTodayWeek() { if (_selectedTab.value != 1) return val cal = java.util.Calendar.getInstance() @@ -276,7 +238,7 @@ class CalendarViewModel(app: Application) : AndroidViewModel(app) { set(java.util.Calendar.MINUTE, 59) set(java.util.Calendar.SECOND, 59) }.timeInMillis - updateEventsCustom() + updateEvents() } fun setTodayMonth() { @@ -297,7 +259,7 @@ class CalendarViewModel(app: Application) : AndroidViewModel(app) { set(java.util.Calendar.MINUTE, 59) set(java.util.Calendar.SECOND, 59) }.timeInMillis - updateEventsCustom() + updateEvents() } fun setTodayDay() { @@ -313,6 +275,6 @@ class CalendarViewModel(app: Application) : AndroidViewModel(app) { set(java.util.Calendar.MINUTE, 59) set(java.util.Calendar.SECOND, 59) }.timeInMillis - updateEventsCustom() + updateEvents() } } diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/EventDetailsViewModel.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/EventDetailsViewModel.kt @@ -0,0 +1,35 @@ +package space.midnightthoughts.nordiccalendar.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import space.midnightthoughts.nordiccalendar.data.CalendarRepository +import space.midnightthoughts.nordiccalendar.util.Event + +class EventDetailsViewModel( + app: Application, + savedStateHandle: SavedStateHandle +) : AndroidViewModel(app) { + private val repository = CalendarRepository(app) + private val _event = MutableStateFlow<Event?>(null) + val event: StateFlow<Event?> = _event.asStateFlow() + + init { + val eventId = savedStateHandle.get<String>("eventId")?.toLongOrNull() + if (eventId != null) { + loadEvent(eventId) + } + } + + private fun loadEvent(eventId: Long) { + viewModelScope.launch { + val event = repository.getEventById(eventId) + _event.value = event + } + } +}