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 f8436373490c6f0214c780d997c880d5e0dd1598
parent 85ae8d23b9312908e10f6fa503909bb07a21dd47
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Fri,  1 Aug 2025 22:12:51 +0200

Fix performance problems

Diffstat:
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt | 161++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/DayView.kt | 10+++++-----
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/MonthView.kt | 8++++----
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/screens/WeekView.kt | 14+++++---------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/BaseCalendarViewModel.kt | 35-----------------------------------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt | 59++++++++++++++++++++++++++++++++++++++++++++---------------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/SpecializedCalendarViewModels.kt | 42------------------------------------------
7 files changed, 161 insertions(+), 168 deletions(-)

diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -30,7 +31,11 @@ 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.util.Event import space.midnightthoughts.nordiccalendar.viewmodels.CalendarViewModel +import space.midnightthoughts.nordiccalendar.viewmodels.DayViewModel +import space.midnightthoughts.nordiccalendar.viewmodels.MonthViewModel +import space.midnightthoughts.nordiccalendar.viewmodels.WeekViewModel /** * CalendarScreen is the main composable for displaying the calendar view. @@ -55,54 +60,65 @@ fun CalendarScreen( /** * State holding the currently selected tab (0=month, 1=week, 2=day). */ - val selectedTab = remember(calendarViewModel) { - calendarViewModel.selectedTab - }.collectAsState(initial = 0) + val selectedTab by calendarViewModel.selectedTab.collectAsState() /** * Get the appropriate specialized view model based on selected tab. */ - val activeViewModel = when (selectedTab.value) { - 0 -> calendarViewModel.monthViewModel - 1 -> calendarViewModel.weekViewModel - 2 -> calendarViewModel.dayViewModel - else -> calendarViewModel.monthViewModel + val activeViewModel = remember(selectedTab) { + when (selectedTab) { + 0 -> calendarViewModel.monthViewModel + 1 -> calendarViewModel.weekViewModel + 2 -> calendarViewModel.dayViewModel + else -> calendarViewModel.monthViewModel + } } /** - * State holding the list of events for the current view. + * Collect events state from the main CalendarViewModel */ - val events = remember(selectedTab.value, calendarViewModel) { - when (selectedTab.value) { - 0 -> calendarViewModel.monthViewModel.events - 1 -> calendarViewModel.weekViewModel.events - 2 -> calendarViewModel.dayViewModel.events - else -> calendarViewModel.monthViewModel.events - } - }.collectAsState(initial = emptyList()) + val eventsList by calendarViewModel.events.collectAsState() /** * State indicating whether a refresh is in progress. */ - val isRefreshing = remember(activeViewModel) { - activeViewModel.isRefreshing - }.collectAsState(initial = false) + val isRefreshing by calendarViewModel.isRefreshing.collectAsState() /** * State for pull-to-refresh gesture. */ val pullToRefreshState = rememberPullToRefreshState() + + // Memoized callbacks to prevent recomposition + val onRefresh = remember(calendarViewModel) { + { calendarViewModel.refreshEvents() } + } + + val onTabSelected = remember(calendarViewModel) { + { tab: Int -> calendarViewModel.setTab(tab) } + } + + val onPrevious = remember(activeViewModel) { + { activeViewModel.navigateToPrevious() } + } + + val onNext = remember(activeViewModel) { + { activeViewModel.navigateToNext() } + } + + val onToday = remember(activeViewModel) { + { activeViewModel.navigateToToday() } + } + PullToRefreshBox( state = pullToRefreshState, modifier = modifier, - onRefresh = { - activeViewModel.refreshEvents() - }, - isRefreshing = isRefreshing.value, + onRefresh = onRefresh, + isRefreshing = isRefreshing, indicator = { Indicator( modifier = Modifier.align(Alignment.TopCenter), - isRefreshing = isRefreshing.value, + isRefreshing = isRefreshing, containerColor = MaterialTheme.colorScheme.primaryContainer, color = MaterialTheme.colorScheme.onPrimaryContainer, state = pullToRefreshState @@ -111,42 +127,71 @@ fun CalendarScreen( ) { Column(modifier = Modifier.fillMaxSize()) { CalendarTabBar( - selectedTab = selectedTab.value, - onTabSelected = { calendarViewModel.setTab(it) }) + selectedTab = selectedTab, + onTabSelected = onTabSelected + ) DateRangeHeader( - selectedTab = selectedTab.value, + selectedTab = selectedTab, calendarViewModel = activeViewModel, - onPrev = { activeViewModel.navigateToPrevious() }, - onNext = { activeViewModel.navigateToNext() }, - onToday = { activeViewModel.navigateToToday() } + onPrev = onPrevious, + onNext = onNext, + onToday = onToday + ) + + CalendarContent( + selectedTab = selectedTab, + eventsList = eventsList, + navController = navController, + monthViewModel = calendarViewModel.monthViewModel, + weekViewModel = calendarViewModel.weekViewModel, + dayViewModel = calendarViewModel.dayViewModel + ) + } + } +} + +@Composable +private fun CalendarContent( + selectedTab: Int, + eventsList: List<Event>, + navController: NavController, + monthViewModel: MonthViewModel, + weekViewModel: WeekViewModel, + dayViewModel: DayViewModel +) { + if (eventsList.isEmpty()) { + Text( + text = stringResource(R.string.no_events_found), + modifier = Modifier.padding(16.dp) + ) + } else { + when (selectedTab) { + 0 -> MonthView( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + navController = navController, + monthViewModel = monthViewModel, + events = eventsList + ) + + 1 -> WeekView( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + navController = navController, + weekViewModel = weekViewModel, + events = eventsList + ) + + 2 -> DayView( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + navController = navController, + dayViewModel = dayViewModel, + events = eventsList ) - if (events.value.isEmpty()) { - Text(stringResource(R.string.no_events_found), modifier = Modifier.padding(16.dp)) - } else when (selectedTab.value) { - 0 -> MonthView( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - navController, - calendarViewModel.monthViewModel, - ) - - 1 -> WeekView( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - navController, - calendarViewModel.weekViewModel, - ) - - 2 -> DayView( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - navController, - calendarViewModel.dayViewModel, - ) - } } } } diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/DayView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/DayView.kt @@ -204,11 +204,13 @@ fun DayView( modifier: Modifier = Modifier, navController: NavController, dayViewModel: space.midnightthoughts.nordiccalendar.viewmodels.DayViewModel, + events: List<space.midnightthoughts.nordiccalendar.util.Event> = emptyList() ) { val hourHeightDp = 64.dp val timeColumnWidth = 64.dp val density = LocalDensity.current val hourHeightPx = with(density) { hourHeightDp.toPx() } + val dayStart = remember(dayViewModel) { dayViewModel.startMillis }.collectAsState() val dayEnd = remember(dayViewModel) { dayViewModel.endMillis }.collectAsState() val appLocale = getCurrentAppLocale(LocalContext.current) @@ -229,15 +231,13 @@ fun DayView( val scrollTo = (nowOffsetY - visibleHeightPx / 2).toInt().coerceAtLeast(0) val scrollState = rememberScrollState(initial = scrollTo) - val events = remember(dayViewModel) { dayViewModel.events }.collectAsState() - BoxWithConstraints( modifier = Modifier .fillMaxSize() .verticalScroll(scrollState) .semantics { collectionInfo = CollectionInfo( - rowCount = events.value.size, + rowCount = events.size, columnCount = 1, ) } @@ -253,8 +253,8 @@ fun DayView( ) // Events (optimized as before) - val eventColumns = remember(events.value, maxWidthPx) { assignColumns(events.value) } - events.value.forEach { event -> + val eventColumns = remember(events, maxWidthPx) { assignColumns(events) } + events.forEach { event -> val triple = eventColumns.find { it.first.eventId == event.eventId && it.first.calendar.id == event.calendar.id } if (triple != null) { diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/MonthView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/MonthView.kt @@ -41,13 +41,13 @@ import java.time.format.TextStyle import java.util.Locale /** - * MonthView displays a monthly calendar grid with days and events. - * It highlights the current day, shows a grid of days for the month, and displays up to three events per day as compact chips. - * If there are more than three events, a "+N more" chip is shown. + * MonthView displays the calendar in a monthly format, showing all days of the month + * with events and provides navigation to view event details. * * @param modifier Modifier for styling and layout. * @param navController NavController for navigation actions. * @param monthViewModel Specialized ViewModel for month view operations. + * @param events List of events to display. */ @SuppressLint("UnusedBoxWithConstraintsScope") @OptIn(ExperimentalMaterial3Api::class) @@ -56,8 +56,8 @@ fun MonthView( modifier: Modifier = Modifier, navController: NavController, monthViewModel: space.midnightthoughts.nordiccalendar.viewmodels.MonthViewModel, + events: List<space.midnightthoughts.nordiccalendar.util.Event> = emptyList() ) { - val events by monthViewModel.events.collectAsState() val startMillis by monthViewModel.startMillis.collectAsState() val today = LocalDate.now() val zoneId = ZoneId.systemDefault() diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/WeekView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/WeekView.kt @@ -7,34 +7,30 @@ 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.util.Event import space.midnightthoughts.nordiccalendar.viewmodels.WeekViewModel /** - * WeekView displays a list of events for the current week. - * Each event is shown as a clickable text item that navigates to the event details view. + * WeekView displays the calendar in a weekly format, showing events for the current week. * * @param modifier Modifier for styling and layout. * @param navController NavController for navigation actions. * @param weekViewModel Specialized ViewModel for week view operations. + * @param events List of events to display. */ @Composable fun WeekView( modifier: Modifier = Modifier, navController: NavController, weekViewModel: WeekViewModel, + events: List<Event> = emptyList() ) { // TODO: Implement WeekView - val events = remember(weekViewModel) { - weekViewModel.events - }.collectAsState(initial = emptyList()) - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(events.value) { event -> + items(events) { event -> Text( event.title, modifier = Modifier .padding(8.dp) diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/BaseCalendarViewModel.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/BaseCalendarViewModel.kt @@ -1,18 +1,11 @@ package space.midnightthoughts.nordiccalendar.viewmodels -import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import space.midnightthoughts.nordiccalendar.data.CalendarRepository -import space.midnightthoughts.nordiccalendar.util.Event /** * Base class for calendar view models providing common functionality for event management, @@ -51,23 +44,6 @@ abstract class BaseCalendarViewModel( val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow() /** - * StateFlow of the current list of events for this view's time range. - * The context is passed as a parameter to avoid holding a reference to it. - */ - fun getEventsFlow(context: Context): StateFlow<List<Event>> = combine( - repository.calendarsFlow, - _startMillis, - _endMillis - ) { calendars, start, end -> - val selectedIds = calendars.filter { it.selected }.map { it.id } - repository.getEventsForCalendars(context, selectedIds, start, end) - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000), - emptyList() - ) - - /** * Sets the time range for this view and validates it. * Subclasses should override this to enforce their specific time range requirements. * @@ -100,17 +76,6 @@ abstract class BaseCalendarViewModel( } /** - * Schedules reminders for the given events. - * The context is passed as a parameter to avoid holding a reference to it. - */ - fun scheduleRemindersForEvents(context: Context, events: List<Event>) { - viewModelScope.launch { - Log.d("BaseCalendarViewModel", "Scheduling reminders for ${events.size} events") - repository.scheduleRemindersForEvents(context, events) - } - } - - /** * Navigates to the current time period (today) for this view type. * Subclasses must implement this to define what "today" means for their view. */ diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt @@ -3,12 +3,19 @@ package space.midnightthoughts.nordiccalendar.viewmodels import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import space.midnightthoughts.nordiccalendar.data.CalendarRepository +import space.midnightthoughts.nordiccalendar.util.Event import javax.inject.Inject /** @@ -34,21 +41,21 @@ class CalendarViewModel @Inject constructor( * Specialized ViewModel for month view with month-specific time range management. */ val monthViewModel: MonthViewModel by lazy { - MonthViewModel(context, repository, savedStateHandle) + MonthViewModel(repository, savedStateHandle) } /** * Specialized ViewModel for week view with week-specific time range management. */ val weekViewModel: WeekViewModel by lazy { - WeekViewModel(context, repository, savedStateHandle) + WeekViewModel(repository, savedStateHandle) } /** * Specialized ViewModel for day view with day-specific time range management. */ val dayViewModel: DayViewModel by lazy { - DayViewModel(context, repository, savedStateHandle) + DayViewModel(repository, savedStateHandle) } /** @@ -73,25 +80,45 @@ class CalendarViewModel @Inject constructor( } /** - * Convenience properties that delegate to the current view model. + * Centralized events flow that handles all event loading and reminder scheduling. */ - val events - get() = when (_selectedTab.value) { - 0 -> monthViewModel.events - 1 -> weekViewModel.events - 2 -> dayViewModel.events - else -> monthViewModel.events - } - - val startMillis get() = currentViewModel.startMillis - val endMillis get() = currentViewModel.endMillis - val isRefreshing get() = currentViewModel.isRefreshing + val events: StateFlow<List<Event>> = combine( + repository.calendarsFlow, + currentViewModel.startMillis, + currentViewModel.endMillis + ) { calendars, start, end -> + val selectedIds = calendars.filter { it.selected }.map { it.id } + repository.getEventsForCalendars(context, selectedIds, start, end) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList() + ) + + /** + * StateFlow indicating if a refresh is in progress. + */ + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow() init { val tab = savedStateHandle.get<Int?>("tab") if (tab != null) { setTab(tab) } + + // Schedule reminders in background when events change + viewModelScope.launch(Dispatchers.IO) { + events.collect { eventList -> + if (eventList.isNotEmpty()) { + try { + repository.scheduleRemindersForEvents(context, eventList) + } catch (e: Exception) { + // Handle exception + } + } + } + } } /** @@ -130,6 +157,8 @@ class CalendarViewModel @Inject constructor( * Delegates refresh to the current view model. */ fun refreshEvents() { + _isRefreshing.value = true currentViewModel.refreshEvents() + _isRefreshing.value = false } } diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/SpecializedCalendarViewModels.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/SpecializedCalendarViewModels.kt @@ -1,15 +1,9 @@ package space.midnightthoughts.nordiccalendar.viewmodels -import android.content.Context import android.util.Log import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch import space.midnightthoughts.nordiccalendar.data.CalendarRepository -import space.midnightthoughts.nordiccalendar.util.Event import java.time.LocalDate import javax.inject.Inject @@ -19,24 +13,12 @@ import javax.inject.Inject */ @HiltViewModel class MonthViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, repository: CalendarRepository, savedStateHandle: SavedStateHandle ) : BaseCalendarViewModel(repository) { - /** - * StateFlow of events for the current month. - */ - val events: StateFlow<List<Event>> by lazy { getEventsFlow(context) } - init { initializeDefaultTimeRange() - // Schedule reminders when events change - viewModelScope.launch { - events.collect { eventList -> - scheduleRemindersForEvents(context, eventList) - } - } } override fun initializeDefaultTimeRange() { @@ -112,24 +94,12 @@ class MonthViewModel @Inject constructor( */ @HiltViewModel class WeekViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, repository: CalendarRepository, savedStateHandle: SavedStateHandle ) : BaseCalendarViewModel(repository) { - /** - * StateFlow of events for the current week. - */ - val events: StateFlow<List<Event>> by lazy { getEventsFlow(context) } - init { initializeDefaultTimeRange() - // Schedule reminders when events change - viewModelScope.launch { - events.collect { eventList -> - scheduleRemindersForEvents(context, eventList) - } - } } override fun initializeDefaultTimeRange() { @@ -203,16 +173,10 @@ class WeekViewModel @Inject constructor( */ @HiltViewModel class DayViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, repository: CalendarRepository, savedStateHandle: SavedStateHandle ) : BaseCalendarViewModel(repository) { - /** - * StateFlow of events for the current day. - */ - val events: StateFlow<List<Event>> by lazy { getEventsFlow(context) } - init { // Check for date argument from navigation val dateArg = savedStateHandle.get<String?>("date") @@ -221,12 +185,6 @@ class DayViewModel @Inject constructor( } else { initializeDefaultTimeRange() } - // Schedule reminders when events change - viewModelScope.launch { - events.collect { eventList -> - scheduleRemindersForEvents(context, eventList) - } - } } override fun initializeDefaultTimeRange() {