commit 85ae8d23b9312908e10f6fa503909bb07a21dd47 parent 83aea35e6110655d42e7bb57670d9e9aedef5a69 Author: MTRNord <MTRNord@users.noreply.github.com> Date: Fri, 1 Aug 2025 21:58:01 +0200 Initial refactoring of the data model Diffstat:
9 files changed, 584 insertions(+), 405 deletions(-)
diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/components/DateRangeHeader.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/components/DateRangeHeader.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import space.midnightthoughts.nordiccalendar.R import space.midnightthoughts.nordiccalendar.getCurrentAppLocale -import space.midnightthoughts.nordiccalendar.viewmodels.CalendarViewModel +import space.midnightthoughts.nordiccalendar.viewmodels.BaseCalendarViewModel import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Calendar @@ -39,7 +39,7 @@ import java.util.Date * 0 = month view, 1 = week view, 2 = day view. * * @param selectedTab The currently selected tab (0=month, 1=week, 2=day). - * @param calendarViewModel The CalendarViewModel providing start and end millis for the range. + * @param calendarViewModel The BaseCalendarViewModel providing start and end millis for the range. * @param onPrev Callback for navigating to the previous period. * @param onNext Callback for navigating to the next period. * @param onToday Callback for jumping to today. @@ -47,7 +47,7 @@ import java.util.Date @Composable fun DateRangeHeader( selectedTab: Int, - calendarViewModel: CalendarViewModel, + calendarViewModel: BaseCalendarViewModel, onPrev: () -> Unit, onNext: () -> Unit, onToday: () -> Unit @@ -102,26 +102,27 @@ fun DateRangeHeader( } 1 -> { - val cal = Calendar.getInstance() - val today = cal.timeInMillis - today >= startMillis.value && today <= endMillis.value + val today = Calendar.getInstance() + val todayMillis = today.timeInMillis + todayMillis >= startMillis.value && todayMillis <= endMillis.value } 2 -> { - val cal = Calendar.getInstance() - val startDay = Calendar.getInstance().apply { timeInMillis = startMillis.value } - cal.get(Calendar.YEAR) == startDay.get(Calendar.YEAR) && - cal.get(Calendar.DAY_OF_YEAR) == startDay.get(Calendar.DAY_OF_YEAR) + val today = Calendar.getInstance() + val startCal = Calendar.getInstance().apply { timeInMillis = startMillis.value } + today.get(Calendar.YEAR) == startCal.get(Calendar.YEAR) && + today.get(Calendar.DAY_OF_YEAR) == startCal.get(Calendar.DAY_OF_YEAR) } else -> false } + Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { IconButton( onClick = onPrev, @@ -129,19 +130,28 @@ fun DateRangeHeader( ) { Icon( Icons.Filled.ChevronLeft, - contentDescription = - stringResource(R.string.previous_period) + contentDescription = stringResource(R.string.previous_period) ) } - Row(verticalAlignment = Alignment.CenterVertically) { - Text(rangeText, style = MaterialTheme.typography.titleMedium) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = rangeText, + style = MaterialTheme.typography.titleMedium, + ) + + if (!isToday) { - Spacer(Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(8.dp)) TextButton(onClick = onToday) { Text(stringResource(R.string.today)) } } } + IconButton( onClick = onNext, modifier = Modifier.size(48.dp) diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/components/SidebarDrawer.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/components/SidebarDrawer.kt @@ -1,6 +1,5 @@ package space.midnightthoughts.nordiccalendar.components -import Destinations import android.util.Log import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -27,6 +26,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.NavGraph.Companion.findStartDestination import kotlinx.coroutines.launch +import space.midnightthoughts.nordiccalendar.Destinations import space.midnightthoughts.nordiccalendar.R import space.midnightthoughts.nordiccalendar.viewmodels.SidebarDrawerViewModel diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/CalendarScreen.kt @@ -48,32 +48,47 @@ fun CalendarScreen( navController: NavController, ) { /** - * ViewModel for calendar data and state. + * ViewModel coordinator for calendar data and state. */ val calendarViewModel: CalendarViewModel = hiltViewModel() /** + * State holding the currently selected tab (0=month, 1=week, 2=day). + */ + val selectedTab = remember(calendarViewModel) { + calendarViewModel.selectedTab + }.collectAsState(initial = 0) + + /** + * 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 + } + + /** * State holding the list of events for the current view. */ - val events = remember(calendarViewModel) { - calendarViewModel.events + 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()) /** * State indicating whether a refresh is in progress. */ - val isRefreshing = remember(calendarViewModel) { - calendarViewModel.isRefreshing + val isRefreshing = remember(activeViewModel) { + activeViewModel.isRefreshing }.collectAsState(initial = false) /** - * State holding the currently selected tab (0=month, 1=week, 2=day). - */ - val selectedTab = remember(calendarViewModel) { - calendarViewModel.selectedTab - }.collectAsState(initial = 0) - - /** * State for pull-to-refresh gesture. */ val pullToRefreshState = rememberPullToRefreshState() @@ -81,7 +96,7 @@ fun CalendarScreen( state = pullToRefreshState, modifier = modifier, onRefresh = { - calendarViewModel.refreshEvents() + activeViewModel.refreshEvents() }, isRefreshing = isRefreshing.value, indicator = { @@ -100,54 +115,36 @@ fun CalendarScreen( onTabSelected = { calendarViewModel.setTab(it) }) DateRangeHeader( selectedTab = selectedTab.value, - calendarViewModel = calendarViewModel, - onPrev = { - when (selectedTab.value) { - 0 -> calendarViewModel.prevMonth() - 1 -> calendarViewModel.prevWeek() - 2 -> calendarViewModel.prevDay() - } - }, - onNext = { - when (selectedTab.value) { - 0 -> calendarViewModel.nextMonth() - 1 -> calendarViewModel.nextWeek() - 2 -> calendarViewModel.nextDay() - } - }, - onToday = { - when (selectedTab.value) { - 0 -> calendarViewModel.setTodayMonth() - 1 -> calendarViewModel.setTodayWeek() - 2 -> calendarViewModel.setTodayDay() - } - } + calendarViewModel = activeViewModel, + onPrev = { activeViewModel.navigateToPrevious() }, + onNext = { activeViewModel.navigateToNext() }, + onToday = { activeViewModel.navigateToToday() } ) if (events.value.isEmpty()) { Text(stringResource(R.string.no_events_found), modifier = Modifier.padding(16.dp)) - } else if (selectedTab.value == 0) { - MonthView( + } else when (selectedTab.value) { + 0 -> MonthView( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp), navController, - calendarViewModel, + calendarViewModel.monthViewModel, ) - } else if (selectedTab.value == 1) { - WeekView( + + 1 -> WeekView( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp), navController, - calendarViewModel, + calendarViewModel.weekViewModel, ) - } else if (selectedTab.value == 2) { - DayView( + + 2 -> DayView( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp), navController, - calendarViewModel, + 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 @@ -54,7 +54,6 @@ import androidx.navigation.NavController import kotlinx.coroutines.delay import space.midnightthoughts.nordiccalendar.getCurrentAppLocale import space.midnightthoughts.nordiccalendar.util.Event -import space.midnightthoughts.nordiccalendar.viewmodels.CalendarViewModel import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Date @@ -197,21 +196,21 @@ private fun NowBar( * * @param modifier Modifier for styling and layout. * @param navController NavController for navigation actions. - * @param calendarViewModel ViewModel providing calendar data and state. + * @param dayViewModel Specialized ViewModel for day view operations. */ @SuppressLint("UnusedBoxWithConstraintsScope") @Composable fun DayView( modifier: Modifier = Modifier, navController: NavController, - calendarViewModel: CalendarViewModel, + dayViewModel: space.midnightthoughts.nordiccalendar.viewmodels.DayViewModel, ) { val hourHeightDp = 64.dp val timeColumnWidth = 64.dp val density = LocalDensity.current val hourHeightPx = with(density) { hourHeightDp.toPx() } - val dayStart = remember(calendarViewModel) { calendarViewModel.startMillis }.collectAsState() - val dayEnd = remember(calendarViewModel) { calendarViewModel.endMillis }.collectAsState() + val dayStart = remember(dayViewModel) { dayViewModel.startMillis }.collectAsState() + val dayEnd = remember(dayViewModel) { dayViewModel.endMillis }.collectAsState() val appLocale = getCurrentAppLocale(LocalContext.current) val hourFormat = DateTimeFormatter.ofPattern("HH:mm", appLocale) var now by remember { mutableLongStateOf(System.currentTimeMillis()) } @@ -230,7 +229,7 @@ fun DayView( val scrollTo = (nowOffsetY - visibleHeightPx / 2).toInt().coerceAtLeast(0) val scrollState = rememberScrollState(initial = scrollTo) - val events = remember(calendarViewModel) { calendarViewModel.events }.collectAsState() + val events = remember(dayViewModel) { dayViewModel.events }.collectAsState() BoxWithConstraints( modifier = Modifier diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/MonthView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/MonthView.kt @@ -33,7 +33,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController -import space.midnightthoughts.nordiccalendar.viewmodels.CalendarViewModel import java.time.DayOfWeek import java.time.Instant import java.time.LocalDate @@ -48,7 +47,7 @@ import java.util.Locale * * @param modifier Modifier for styling and layout. * @param navController NavController for navigation actions. - * @param calendarViewModel ViewModel providing calendar data and state. + * @param monthViewModel Specialized ViewModel for month view operations. */ @SuppressLint("UnusedBoxWithConstraintsScope") @OptIn(ExperimentalMaterial3Api::class) @@ -56,10 +55,10 @@ import java.util.Locale fun MonthView( modifier: Modifier = Modifier, navController: NavController, - calendarViewModel: CalendarViewModel, + monthViewModel: space.midnightthoughts.nordiccalendar.viewmodels.MonthViewModel, ) { - val events by calendarViewModel.events.collectAsState() - val startMillis by calendarViewModel.startMillis.collectAsState() + val events by monthViewModel.events.collectAsState() + val startMillis by monthViewModel.startMillis.collectAsState() val today = LocalDate.now() val zoneId = ZoneId.systemDefault() val firstDay = Instant.ofEpochMilli(startMillis).atZone(zoneId).toLocalDate() diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/WeekView.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/screens/WeekView.kt @@ -12,7 +12,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation.NavController -import space.midnightthoughts.nordiccalendar.viewmodels.CalendarViewModel +import space.midnightthoughts.nordiccalendar.viewmodels.WeekViewModel /** * WeekView displays a list of events for the current week. @@ -20,17 +20,17 @@ import space.midnightthoughts.nordiccalendar.viewmodels.CalendarViewModel * * @param modifier Modifier for styling and layout. * @param navController NavController for navigation actions. - * @param calendarViewModel ViewModel providing calendar data and state. + * @param weekViewModel Specialized ViewModel for week view operations. */ @Composable fun WeekView( modifier: Modifier = Modifier, navController: NavController, - calendarViewModel: CalendarViewModel, + weekViewModel: WeekViewModel, ) { // TODO: Implement WeekView - val events = remember(calendarViewModel) { - calendarViewModel.events + val events = remember(weekViewModel) { + weekViewModel.events }.collectAsState(initial = emptyList()) LazyColumn(modifier = Modifier.fillMaxSize()) { diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/BaseCalendarViewModel.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/BaseCalendarViewModel.kt @@ -0,0 +1,136 @@ +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, + * refreshing, and reminder scheduling. + * + * @property repository The CalendarRepository for accessing calendar and event data. + */ +abstract class BaseCalendarViewModel( + protected val repository: CalendarRepository +) : ViewModel() { + + /** + * Backing property for the current start time in milliseconds. + */ + protected val _startMillis = MutableStateFlow(System.currentTimeMillis()) + + /** + * StateFlow of the current start time in milliseconds. + */ + val startMillis: StateFlow<Long> = _startMillis.asStateFlow() + + /** + * Backing property for the current end time in milliseconds. + */ + protected val _endMillis = MutableStateFlow(System.currentTimeMillis()) + + /** + * StateFlow of the current end time in milliseconds. + */ + val endMillis: StateFlow<Long> = _endMillis.asStateFlow() + + /** + * StateFlow indicating if a refresh is in progress. + */ + private val _isRefreshing = MutableStateFlow(false) + 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. + * + * @param startMillis The start time in milliseconds. + * @param endMillis The end time in milliseconds. + */ + protected open fun setTimeRange(startMillis: Long, endMillis: Long) { + if (endMillis <= startMillis) { + Log.w( + "BaseCalendarViewModel", + "Invalid time range: end ($endMillis) <= start ($startMillis)" + ) + return + } + _startMillis.value = startMillis + _endMillis.value = endMillis + } + + /** + * Refreshes the events by triggering a recomposition of the events flow. + */ + fun refreshEvents() { + _isRefreshing.value = true + // Force recomposition by slightly adjusting the time range + val currentStart = _startMillis.value + val currentEnd = _endMillis.value + _startMillis.value = currentStart + 1 + _startMillis.value = currentStart + _isRefreshing.value = false + } + + /** + * 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. + */ + abstract fun navigateToToday() + + /** + * Navigates to the next time period for this view type. + * Subclasses must implement this to define navigation behavior. + */ + abstract fun navigateToNext() + + /** + * Navigates to the previous time period for this view type. + * Subclasses must implement this to define navigation behavior. + */ + abstract fun navigateToPrevious() + + /** + * Initializes the view with the default time range for this view type. + * Subclasses must implement this to set their appropriate default range. + */ + abstract fun initializeDefaultTimeRange() +} 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,32 +1,26 @@ 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.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import space.midnightthoughts.nordiccalendar.data.CalendarRepository -import java.time.LocalDate import javax.inject.Inject /** - * CalendarViewModel is the main ViewModel for managing calendar state, event data, and navigation logic. - * It handles tab selection, date range management, event refreshing, and reminder scheduling. + * CalendarViewModel coordinates between the three specialized view models (Month, Week, Day) + * 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 events StateFlow of the current list of events. - * @property startMillis StateFlow of the current start time in milliseconds. - * @property endMillis StateFlow of the current end time in milliseconds. - * @property isRefreshing StateFlow indicating if a refresh is in progress. + * @property monthViewModel Specialized ViewModel for month view operations. + * @property weekViewModel Specialized ViewModel for week view operations. + * @property dayViewModel Specialized ViewModel for day view operations. * @property selectedTab StateFlow of the currently selected tab (0=month, 1=week, 2=day). */ @HiltViewModel @@ -35,29 +29,27 @@ class CalendarViewModel @Inject constructor( private val repository: CalendarRepository, savedStateHandle: SavedStateHandle ) : ViewModel() { - /** - * StateFlow of the current list of events. - */ - val events = repository.eventsFlow.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000), - emptyList() - ) /** - * StateFlow of the current start time in milliseconds. + * Specialized ViewModel for month view with month-specific time range management. */ - val startMillis = repository.startMillis + val monthViewModel: MonthViewModel by lazy { + MonthViewModel(context, repository, savedStateHandle) + } /** - * StateFlow of the current end time in milliseconds. + * Specialized ViewModel for week view with week-specific time range management. */ - val endMillis = repository.endMillis + val weekViewModel: WeekViewModel by lazy { + WeekViewModel(context, repository, savedStateHandle) + } /** - * StateFlow indicating if a refresh is in progress. + * Specialized ViewModel for day view with day-specific time range management. */ - val isRefreshing = MutableStateFlow(false) + val dayViewModel: DayViewModel by lazy { + DayViewModel(context, repository, savedStateHandle) + } /** * Backing property for the currently selected tab (0=month, 1=week, 2=day). @@ -70,339 +62,74 @@ class CalendarViewModel @Inject constructor( val selectedTab: StateFlow<Int> = _selectedTab.asStateFlow() /** - * Backing property for the date argument (if provided via navigation). + * Returns the currently active ViewModel based on the selected tab. */ - private val _dateArg = MutableStateFlow<String?>(null) + val currentViewModel: BaseCalendarViewModel + get() = when (_selectedTab.value) { + 0 -> monthViewModel + 1 -> weekViewModel + 2 -> dayViewModel + else -> monthViewModel + } /** - * Initializes the ViewModel, sets up tab and date from navigation arguments, and starts reminder scheduling. + * Convenience properties that delegate to the current view model. */ + 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 + init { val tab = savedStateHandle.get<Int?>("tab") if (tab != null) { setTab(tab) } - val dateArg = savedStateHandle.get<String?>("date") - if (dateArg != null) { - setDayFromString(dateArg) - _dateArg.value = dateArg - } - // Always keep reminder notifications up to date (also on init) - viewModelScope.launch { - repository.eventsFlow.collect { events -> - Log.d("CalendarViewModel", "Scheduling reminders for ${events.size} events") - repository.scheduleRemindersForEvents(context, events) - } - } } /** - * Sets the currently selected tab (0=month, 1=week, 2=day) and updates the time range accordingly. - * If a date argument is present, it is used instead of the default range. + * Sets the currently selected tab and ensures the corresponding view model is active. * - * @param tab The tab index to select. + * @param tab The tab index to select (0=month, 1=week, 2=day). */ fun setTab(tab: Int) { - _selectedTab.value = tab - if (_dateArg.value == null) { - val start = getDefaultStartMillis(tab) - val end = getDefaultEndMillis(tab) - repository.setTimeRange(start, end) - } - } - - /** - * Returns the default start time in milliseconds for the given tab (month, week, or day). - * - * @param tab The tab index (0=month, 1=week, 2=day). - * @return The start time in milliseconds. - */ - private fun getDefaultStartMillis(tab: Int): Long { - val cal = java.util.Calendar.getInstance() - when (tab) { - 0 -> { // Month - cal.set(java.util.Calendar.DAY_OF_MONTH, 1) - cal.set(java.util.Calendar.HOUR_OF_DAY, 0) - cal.set(java.util.Calendar.MINUTE, 0) - cal.set(java.util.Calendar.SECOND, 0) - return cal.timeInMillis - } - - 1 -> { // Week - cal.set(java.util.Calendar.DAY_OF_WEEK, cal.firstDayOfWeek) - cal.set(java.util.Calendar.HOUR_OF_DAY, 0) - cal.set(java.util.Calendar.MINUTE, 0) - cal.set(java.util.Calendar.SECOND, 0) - return cal.timeInMillis - } - - 2 -> { // Day - cal.set(java.util.Calendar.HOUR_OF_DAY, 0) - cal.set(java.util.Calendar.MINUTE, 0) - cal.set(java.util.Calendar.SECOND, 0) - return cal.timeInMillis - } - - else -> return cal.timeInMillis - } - } - - /** - * Returns the default end time in milliseconds for the given tab (month, week, or day). - * - * @param tab The tab index (0=month, 1=week, 2=day). - * @return The end time in milliseconds. - */ - private fun getDefaultEndMillis(tab: Int): Long { - val cal = java.util.Calendar.getInstance() - when (tab) { - 0 -> { // Month - cal.set( - java.util.Calendar.DAY_OF_MONTH, - cal.getActualMaximum(java.util.Calendar.DAY_OF_MONTH) - ) - cal.set(java.util.Calendar.HOUR_OF_DAY, 23) - cal.set(java.util.Calendar.MINUTE, 59) - cal.set(java.util.Calendar.SECOND, 59) - return cal.timeInMillis - } - - 1 -> { // Week - cal.set(java.util.Calendar.DAY_OF_WEEK, cal.firstDayOfWeek + 6) - cal.set(java.util.Calendar.HOUR_OF_DAY, 23) - cal.set(java.util.Calendar.MINUTE, 59) - cal.set(java.util.Calendar.SECOND, 59) - return cal.timeInMillis - } - - 2 -> { // Day - cal.set(java.util.Calendar.HOUR_OF_DAY, 23) - cal.set(java.util.Calendar.MINUTE, 59) - cal.set(java.util.Calendar.SECOND, 59) - return cal.timeInMillis - } - - else -> return cal.timeInMillis + if (tab in 0..2) { + _selectedTab.value = tab } } - // The following methods only change the time period in the repository - /** - * Advances the day view to the next day and updates the time range. - */ - fun nextDay() { - if (_selectedTab.value != 2) return - val cal = java.util.Calendar.getInstance().apply { timeInMillis = startMillis.value } - cal.add(java.util.Calendar.DAY_OF_MONTH, 1) - val start = cal.timeInMillis - cal.set(java.util.Calendar.HOUR_OF_DAY, 23) - cal.set(java.util.Calendar.MINUTE, 59) - cal.set(java.util.Calendar.SECOND, 59) - val end = cal.timeInMillis - repository.setTimeRange(start, end) - } - /** - * Moves the day view to the previous day and updates the time range. + * Delegates navigation to the previous period to the current view model. */ - fun prevDay() { - if (_selectedTab.value != 2) return - val cal = java.util.Calendar.getInstance().apply { timeInMillis = startMillis.value } - cal.add(java.util.Calendar.DAY_OF_MONTH, -1) - val start = cal.timeInMillis - cal.set(java.util.Calendar.HOUR_OF_DAY, 23) - cal.set(java.util.Calendar.MINUTE, 59) - cal.set(java.util.Calendar.SECOND, 59) - val end = cal.timeInMillis - repository.setTimeRange(start, end) + fun navigateToPrevious() { + currentViewModel.navigateToPrevious() } /** - * Advances the week view to the next week and updates the time range. + * Delegates navigation to the next period to the current view model. */ - fun nextWeek() { - if (_selectedTab.value != 1) return - val cal = java.util.Calendar.getInstance().apply { timeInMillis = startMillis.value } - cal.add(java.util.Calendar.WEEK_OF_YEAR, 1) - val start = cal.timeInMillis - cal.add(java.util.Calendar.DAY_OF_WEEK, 6) - cal.set(java.util.Calendar.HOUR_OF_DAY, 23) - cal.set(java.util.Calendar.MINUTE, 59) - cal.set(java.util.Calendar.SECOND, 59) - val end = cal.timeInMillis - repository.setTimeRange(start, end) + fun navigateToNext() { + currentViewModel.navigateToNext() } /** - * Moves the week view to the previous week and updates the time range. + * Delegates navigation to today to the current view model. */ - fun prevWeek() { - if (_selectedTab.value != 1) return - val cal = java.util.Calendar.getInstance().apply { timeInMillis = startMillis.value } - cal.add(java.util.Calendar.WEEK_OF_YEAR, -1) - val start = cal.timeInMillis - cal.add(java.util.Calendar.DAY_OF_WEEK, 6) - cal.set(java.util.Calendar.HOUR_OF_DAY, 23) - cal.set(java.util.Calendar.MINUTE, 59) - cal.set(java.util.Calendar.SECOND, 59) - val end = cal.timeInMillis - repository.setTimeRange(start, end) + fun navigateToToday() { + currentViewModel.navigateToToday() } /** - * Advances the month view to the next month and updates the time range. - */ - fun nextMonth() { - if (_selectedTab.value != 0) return - val cal = java.util.Calendar.getInstance().apply { timeInMillis = startMillis.value } - cal.add(java.util.Calendar.MONTH, 1) - cal.set(java.util.Calendar.DAY_OF_MONTH, 1) - val start = cal.timeInMillis - cal.set( - java.util.Calendar.DAY_OF_MONTH, - cal.getActualMaximum(java.util.Calendar.DAY_OF_MONTH) - ) - cal.set(java.util.Calendar.HOUR_OF_DAY, 23) - cal.set(java.util.Calendar.MINUTE, 59) - cal.set(java.util.Calendar.SECOND, 59) - val end = cal.timeInMillis - repository.setTimeRange(start, end) - } - - /** - * Moves the month view to the previous month and updates the time range. - */ - fun prevMonth() { - if (_selectedTab.value != 0) return - val cal = java.util.Calendar.getInstance().apply { timeInMillis = startMillis.value } - cal.add(java.util.Calendar.MONTH, -1) - cal.set(java.util.Calendar.DAY_OF_MONTH, 1) - val start = cal.timeInMillis - cal.set( - java.util.Calendar.DAY_OF_MONTH, - cal.getActualMaximum(java.util.Calendar.DAY_OF_MONTH) - ) - cal.set(java.util.Calendar.HOUR_OF_DAY, 23) - cal.set(java.util.Calendar.MINUTE, 59) - cal.set(java.util.Calendar.SECOND, 59) - val end = cal.timeInMillis - repository.setTimeRange(start, end) - } - - /** - * Sets the week view to the current week and updates the time range. - */ - fun setTodayWeek() { - if (_selectedTab.value != 1) return - val cal = java.util.Calendar.getInstance() - val start = cal.apply { - set(java.util.Calendar.DAY_OF_WEEK, firstDayOfWeek) - set(java.util.Calendar.HOUR_OF_DAY, 0) - set(java.util.Calendar.MINUTE, 0) - set(java.util.Calendar.SECOND, 0) - }.timeInMillis - val end = cal.apply { - add(java.util.Calendar.DAY_OF_WEEK, 6) - set(java.util.Calendar.HOUR_OF_DAY, 23) - set(java.util.Calendar.MINUTE, 59) - set(java.util.Calendar.SECOND, 59) - }.timeInMillis - repository.setTimeRange(start, end) - } - - /** - * Sets the month view to the current month and updates the time range. - */ - fun setTodayMonth() { - if (_selectedTab.value != 0) return - val cal = java.util.Calendar.getInstance() - val start = cal.apply { - set(java.util.Calendar.DAY_OF_MONTH, 1) - set(java.util.Calendar.HOUR_OF_DAY, 0) - set(java.util.Calendar.MINUTE, 0) - set(java.util.Calendar.SECOND, 0) - }.timeInMillis - val end = cal.apply { - set( - java.util.Calendar.DAY_OF_MONTH, - getActualMaximum(java.util.Calendar.DAY_OF_MONTH) - ) - set(java.util.Calendar.HOUR_OF_DAY, 23) - set(java.util.Calendar.MINUTE, 59) - set(java.util.Calendar.SECOND, 59) - }.timeInMillis - repository.setTimeRange(start, end) - } - - /** - * Sets the day view to the current day and updates the time range. - */ - fun setTodayDay() { - if (_selectedTab.value != 2) return - val cal = java.util.Calendar.getInstance() - val start = cal.apply { - set(java.util.Calendar.HOUR_OF_DAY, 0) - set(java.util.Calendar.MINUTE, 0) - set(java.util.Calendar.SECOND, 0) - }.timeInMillis - val end = cal.apply { - set(java.util.Calendar.HOUR_OF_DAY, 23) - set(java.util.Calendar.MINUTE, 59) - set(java.util.Calendar.SECOND, 59) - }.timeInMillis - repository.setTimeRange(start, end) - } - - /** - * Refreshes the events from the repository and updates reminders. - * Sets the isRefreshing state to true during the operation. + * Delegates refresh to the current view model. */ fun refreshEvents() { - isRefreshing.value = true - repository.refreshEvents(context) - // After updating the events, set reminders - val events = repository.eventsFlow.value - repository.scheduleRemindersForEvents(context, events) - isRefreshing.value = false - } - - /** - * Sets the current day for the day view tab (tab 2) and updates the time range accordingly. - * - * @param date The LocalDate to set as the current day. - */ - fun setDay(date: LocalDate) { - if (_selectedTab.value != 2) { - setTab(2) - } - val cal = java.util.Calendar.getInstance() - cal.set(java.util.Calendar.YEAR, date.year) - cal.set(java.util.Calendar.MONTH, date.monthValue - 1) - cal.set(java.util.Calendar.DAY_OF_MONTH, date.dayOfMonth) - cal.set(java.util.Calendar.HOUR_OF_DAY, 0) - cal.set(java.util.Calendar.MINUTE, 0) - cal.set(java.util.Calendar.SECOND, 0) - val start = cal.timeInMillis - cal.set(java.util.Calendar.HOUR_OF_DAY, 23) - cal.set(java.util.Calendar.MINUTE, 59) - cal.set(java.util.Calendar.SECOND, 59) - val end = cal.timeInMillis - repository.setTimeRange(start, end) - } - - /** - * Parses a date string and sets the current day for the day view tab. - * If the string is invalid, the error is ignored. - * - * @param dateString The date string to parse (in ISO format). - */ - fun setDayFromString(dateString: String?) { - if (dateString == null) return - try { - val date = LocalDate.parse(dateString) - setDay(date) - } catch (_: Exception) { - // Ignore invalid date - } + currentViewModel.refreshEvents() } } diff --git a/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/SpecializedCalendarViewModels.kt b/app/src/main/java/space/midnightthoughts/nordiccalendar/viewmodels/SpecializedCalendarViewModels.kt @@ -0,0 +1,311 @@ +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 + +/** + * ViewModel specifically for the month view, ensuring month-appropriate time ranges. + * Manages navigation between months and enforces month boundaries. + */ +@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() { + navigateToToday() + } + + override fun navigateToToday() { + val cal = java.util.Calendar.getInstance() + setMonthRange(cal) + } + + override fun navigateToNext() { + val cal = java.util.Calendar.getInstance().apply { + timeInMillis = startMillis.value + } + cal.add(java.util.Calendar.MONTH, 1) + setMonthRange(cal) + } + + override fun navigateToPrevious() { + val cal = java.util.Calendar.getInstance().apply { + timeInMillis = startMillis.value + } + cal.add(java.util.Calendar.MONTH, -1) + setMonthRange(cal) + } + + /** + * Sets the time range to encompass the entire month containing the given calendar. + * Ensures the range always covers a complete month from day 1 to the last day. + */ + private fun setMonthRange(cal: java.util.Calendar) { + // Start: First day of month at 00:00:00 + cal.set(java.util.Calendar.DAY_OF_MONTH, 1) + cal.set(java.util.Calendar.HOUR_OF_DAY, 0) + cal.set(java.util.Calendar.MINUTE, 0) + cal.set(java.util.Calendar.SECOND, 0) + cal.set(java.util.Calendar.MILLISECOND, 0) + val start = cal.timeInMillis + + // End: Last day of month at 23:59:59.999 + cal.set( + java.util.Calendar.DAY_OF_MONTH, + cal.getActualMaximum(java.util.Calendar.DAY_OF_MONTH) + ) + cal.set(java.util.Calendar.HOUR_OF_DAY, 23) + cal.set(java.util.Calendar.MINUTE, 59) + cal.set(java.util.Calendar.SECOND, 59) + cal.set(java.util.Calendar.MILLISECOND, 999) + val end = cal.timeInMillis + + Log.d( + "MonthViewModel", + "Setting month range: ${java.util.Date(start)} to ${java.util.Date(end)}" + ) + setTimeRange(start, end) + } + + /** + * Navigates to a specific month and year. + */ + fun navigateToMonth(year: Int, month: Int) { + val cal = java.util.Calendar.getInstance() + cal.set(java.util.Calendar.YEAR, year) + cal.set(java.util.Calendar.MONTH, month - 1) // Calendar months are 0-based + setMonthRange(cal) + } +} + +/** + * ViewModel specifically for the week view, ensuring week-appropriate time ranges. + * Manages navigation between weeks and enforces week boundaries. + */ +@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() { + navigateToToday() + } + + override fun navigateToToday() { + val cal = java.util.Calendar.getInstance() + setWeekRange(cal) + } + + override fun navigateToNext() { + val cal = java.util.Calendar.getInstance().apply { + timeInMillis = startMillis.value + } + cal.add(java.util.Calendar.WEEK_OF_YEAR, 1) + setWeekRange(cal) + } + + override fun navigateToPrevious() { + val cal = java.util.Calendar.getInstance().apply { + timeInMillis = startMillis.value + } + cal.add(java.util.Calendar.WEEK_OF_YEAR, -1) + setWeekRange(cal) + } + + /** + * Sets the time range to encompass the entire week containing the given calendar. + * Week starts on Monday and ends on Sunday. + */ + private fun setWeekRange(cal: java.util.Calendar) { + // Start: Monday of the week at 00:00:00 + cal.set(java.util.Calendar.DAY_OF_WEEK, cal.firstDayOfWeek) + cal.set(java.util.Calendar.HOUR_OF_DAY, 0) + cal.set(java.util.Calendar.MINUTE, 0) + cal.set(java.util.Calendar.SECOND, 0) + cal.set(java.util.Calendar.MILLISECOND, 0) + val start = cal.timeInMillis + + // End: Sunday of the week at 23:59:59.999 + cal.add(java.util.Calendar.DAY_OF_WEEK, 6) + cal.set(java.util.Calendar.HOUR_OF_DAY, 23) + cal.set(java.util.Calendar.MINUTE, 59) + cal.set(java.util.Calendar.SECOND, 59) + cal.set(java.util.Calendar.MILLISECOND, 999) + val end = cal.timeInMillis + + Log.d( + "WeekViewModel", + "Setting week range: ${java.util.Date(start)} to ${java.util.Date(end)}" + ) + setTimeRange(start, end) + } + + /** + * Navigates to the week containing a specific date. + */ + fun navigateToWeekContaining(date: LocalDate) { + val cal = java.util.Calendar.getInstance() + cal.set(java.util.Calendar.YEAR, date.year) + cal.set(java.util.Calendar.MONTH, date.monthValue - 1) + cal.set(java.util.Calendar.DAY_OF_MONTH, date.dayOfMonth) + setWeekRange(cal) + } +} + +/** + * ViewModel specifically for the day view, ensuring day-appropriate time ranges. + * Manages navigation between days and enforces day boundaries. + */ +@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") + if (dateArg != null) { + navigateToDateFromString(dateArg) + } else { + initializeDefaultTimeRange() + } + // Schedule reminders when events change + viewModelScope.launch { + events.collect { eventList -> + scheduleRemindersForEvents(context, eventList) + } + } + } + + override fun initializeDefaultTimeRange() { + navigateToToday() + } + + override fun navigateToToday() { + val cal = java.util.Calendar.getInstance() + setDayRange(cal) + } + + override fun navigateToNext() { + val cal = java.util.Calendar.getInstance().apply { + timeInMillis = startMillis.value + } + cal.add(java.util.Calendar.DAY_OF_MONTH, 1) + setDayRange(cal) + } + + override fun navigateToPrevious() { + val cal = java.util.Calendar.getInstance().apply { + timeInMillis = startMillis.value + } + cal.add(java.util.Calendar.DAY_OF_MONTH, -1) + setDayRange(cal) + } + + /** + * Sets the time range to encompass the entire day containing the given calendar. + * Ensures the range covers from 00:00:00 to 23:59:59.999 of the same day. + */ + private fun setDayRange(cal: java.util.Calendar) { + // Start: Beginning of day at 00:00:00 + cal.set(java.util.Calendar.HOUR_OF_DAY, 0) + cal.set(java.util.Calendar.MINUTE, 0) + cal.set(java.util.Calendar.SECOND, 0) + cal.set(java.util.Calendar.MILLISECOND, 0) + val start = cal.timeInMillis + + // End: End of day at 23:59:59.999 + cal.set(java.util.Calendar.HOUR_OF_DAY, 23) + cal.set(java.util.Calendar.MINUTE, 59) + cal.set(java.util.Calendar.SECOND, 59) + cal.set(java.util.Calendar.MILLISECOND, 999) + val end = cal.timeInMillis + + Log.d( + "DayViewModel", + "Setting day range: ${java.util.Date(start)} to ${java.util.Date(end)}" + ) + setTimeRange(start, end) + } + + /** + * Navigates to a specific date. + */ + fun navigateToDate(date: LocalDate) { + val cal = java.util.Calendar.getInstance() + cal.set(java.util.Calendar.YEAR, date.year) + cal.set(java.util.Calendar.MONTH, date.monthValue - 1) + cal.set(java.util.Calendar.DAY_OF_MONTH, date.dayOfMonth) + setDayRange(cal) + } + + /** + * Parses a date string and navigates to that date. + * If the string is invalid, the error is ignored and today is used. + */ + fun navigateToDateFromString(dateString: String?) { + if (dateString == null) { + navigateToToday() + return + } + try { + val date = LocalDate.parse(dateString) + navigateToDate(date) + } catch (e: Exception) { + Log.w("DayViewModel", "Invalid date string: $dateString, using today instead") + navigateToToday() + } + } +}