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 78745436ad492341c09e276971ce6f127b0aa5d2
parent d0cab7a6ae631441e0b887aece983b4069e4127e
Author: MTRNord <MTRNord@users.noreply.github.com>
Date:   Sat,  2 Aug 2025 18:03:16 +0200

Update gradle and performance optimizing

Diffstat:
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/data/CalendarRepository.kt | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mapp/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/CalendarViewModel.kt | 62++++++++++++++++++++++++++++++++++++--------------------------
Mgradle/libs.versions.toml | 2+-
3 files changed, 131 insertions(+), 47 deletions(-)

diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/data/CalendarRepository.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/data/CalendarRepository.kt @@ -14,11 +14,13 @@ import androidx.core.content.edit import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import space.midnightthoughts.nordiccalendar.notifications.NotificationReceiver import space.midnightthoughts.nordiccalendar.util.Calendar import space.midnightthoughts.nordiccalendar.util.CalendarData @@ -35,7 +37,7 @@ import javax.inject.Singleton * @constructor Injects the application context for accessing system services and content providers. */ @Singleton -class CalendarRepository @Inject constructor(@ApplicationContext context: Context) { +class CalendarRepository @Inject constructor(@param:ApplicationContext private val context: Context) { /** * Holds the calendar data utility instance. */ @@ -185,6 +187,26 @@ class CalendarRepository @Inject constructor(@ApplicationContext context: Contex ) /** + * Asynchronous version of getEventsForCalendars that returns a Flow. + * This prevents blocking the main thread during database operations. + */ + fun getEventsForCalendarsAsync( + calendarIds: List<Long>, + startMillis: Long, + endMillis: Long + ): Flow<List<Event>> = kotlinx.coroutines.flow.flow { + val events = withContext(Dispatchers.IO) { + calendarData.getEventsForCalendars( + context.contentResolver, // Fixed: Use injected context + calendarIds, + startMillis, + endMillis + ) + } + emit(events) + } + + /** * Retrieves an event by its ID. * @param context The application context. * @param eventId The ID of the event to retrieve. @@ -334,41 +356,93 @@ class CalendarRepository @Inject constructor(@ApplicationContext context: Contex ) } } catch (e: SecurityException) { - e.printStackTrace() + Log.e( + "CalendarRepository", + "SecurityException while scheduling alarm for event ${event.eventId}: ${e.message}", + e + ) + } catch (e: Exception) { + Log.e( + "CalendarRepository", + "Unexpected error while scheduling alarm for event ${event.eventId}: ${e.message}", + e + ) } } } } /** + * Asynchronous version of scheduleRemindersForEvents. + */ + suspend fun scheduleRemindersForEventsAsync(events: List<Event>) { + withContext(Dispatchers.IO) { + scheduleRemindersForEvents(context, events) + } + } + + /** * Registers a ContentObserver to listen for calendar provider changes. + * Uses a background thread for better performance. * @param context The application context. */ fun registerCalendarContentObserver(context: Context) { if (calendarContentObserver != null) return // Only register once - val handler = Handler(Looper.getMainLooper()) - calendarContentObserver = object : ContentObserver(handler) { + + // Use IO thread instead of main thread for ContentObserver + val backgroundHandler = Handler(Looper.getMainLooper().let { + val backgroundThread = android.os.HandlerThread("CalendarObserver").apply { start() } + backgroundThread.looper + }) + + calendarContentObserver = object : ContentObserver(backgroundHandler) { override fun onChange(selfChange: Boolean) { super.onChange(selfChange) - refreshEvents(context) + Log.d("CalendarRepository", "Calendar data changed, refreshing events") + // Use coroutine scope to avoid blocking the observer thread + repoScope.launch { + try { + refreshEvents(context) + } catch (e: Exception) { + Log.e( + "CalendarRepository", + "Error refreshing events after calendar change", + e + ) + } + } } } + val cr = context.contentResolver - cr.registerContentObserver( - CalendarContract.Events.CONTENT_URI, - true, - calendarContentObserver!! - ) - cr.registerContentObserver( - CalendarContract.Instances.CONTENT_URI, - true, - calendarContentObserver!! - ) - cr.registerContentObserver( - CalendarContract.Reminders.CONTENT_URI, - true, - calendarContentObserver!! - ) + try { + cr.registerContentObserver( + CalendarContract.Events.CONTENT_URI, + true, + calendarContentObserver!! + ) + cr.registerContentObserver( + CalendarContract.Instances.CONTENT_URI, + true, + calendarContentObserver!! + ) + cr.registerContentObserver( + CalendarContract.Reminders.CONTENT_URI, + true, + calendarContentObserver!! + ) + Log.d("CalendarRepository", "Calendar ContentObserver registered successfully") + } catch (e: SecurityException) { + Log.e( + "CalendarRepository", + "Failed to register ContentObserver due to security exception", + e + ) + calendarContentObserver = null + } catch (e: Exception) { + Log.e("CalendarRepository", "Failed to register ContentObserver", e) + calendarContentObserver = null + } } /** 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,18 +1,18 @@ package space.midnightthoughts.nordiccalendar.viewmodels -import android.content.Context import android.util.Log 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.ExperimentalCoroutinesApi 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.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import space.midnightthoughts.nordiccalendar.data.CalendarRepository @@ -25,7 +25,6 @@ import javax.inject.Inject * and manages tab selection. This ensures each view maintains its own independent time range * while providing a unified interface for the CalendarScreen. * - * @property context The application context injected by Hilt. * @property repository The CalendarRepository for accessing calendar and event data. * @property monthViewModel Specialized ViewModel for month view operations. * @property weekViewModel Specialized ViewModel for week view operations. @@ -34,7 +33,6 @@ import javax.inject.Inject */ @HiltViewModel class CalendarViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, private val repository: CalendarRepository, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -82,29 +80,31 @@ class CalendarViewModel @Inject constructor( } /** - * Centralized events flow that handles all event loading and reminder scheduling. - * Now properly reacts to tab changes and time range updates from all ViewModels. + * Optimized events flow that only loads events for the currently active view. + * This prevents unnecessary database queries when switching tabs. */ + @OptIn(ExperimentalCoroutinesApi::class) val events: StateFlow<List<Event>> = combine( repository.calendarsFlow, - _selectedTab, - combine( - monthViewModel.startMillis, - monthViewModel.endMillis - ) { start, end -> start to end }, - combine(weekViewModel.startMillis, weekViewModel.endMillis) { start, end -> start to end }, - combine(dayViewModel.startMillis, dayViewModel.endMillis) { start, end -> start to end } - ) { calendars, selectedTab, monthRange, weekRange, dayRange -> + _selectedTab + ) { calendars, selectedTab -> val selectedIds = calendars.filter { it.selected }.map { it.id } - - val (start, end) = when (selectedTab) { - 0 -> monthRange - 1 -> weekRange - 2 -> dayRange - else -> monthRange + selectedTab to selectedIds + }.flatMapLatest { (selectedTab, selectedIds) -> + // Only observe the time range for the currently active view + val activeViewModel = when (selectedTab) { + 0 -> monthViewModel + 1 -> weekViewModel + 2 -> dayViewModel + else -> monthViewModel } - repository.getEventsForCalendars(context, selectedIds, start, end) + combine( + activeViewModel.startMillis, + activeViewModel.endMillis + ) { start, end -> + repository.getEventsForCalendarsAsync(selectedIds, start, end) + }.flatMapLatest { it } }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), @@ -128,9 +128,14 @@ class CalendarViewModel @Inject constructor( events.collect { eventList -> if (eventList.isNotEmpty()) { try { - repository.scheduleRemindersForEvents(context, eventList) + // Repository handles context internally now + repository.scheduleRemindersForEventsAsync(eventList) } catch (e: Exception) { - // Handle exception + Log.e( + "CalendarViewModel", + "Failed to schedule reminders for ${eventList.size} events", + e + ) } } } @@ -173,9 +178,14 @@ class CalendarViewModel @Inject constructor( * Delegates refresh to the current view model. */ fun refreshEvents() { - _isRefreshing.value = true - currentViewModel.refreshEvents() - _isRefreshing.value = false + viewModelScope.launch { + _isRefreshing.value = true + try { + currentViewModel.refreshEvents() + } finally { + _isRefreshing.value = false + } + } } fun setTabAndDate(tabFromNav: Int, dateFromNav: String?) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.11.1" +agp = "8.12.0" kotlin = "2.2.0" coreKtx = "1.16.0" junit = "4.13.2"