diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardEventHandler.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardEventHandler.kt new file mode 100644 index 0000000000..887f36f00b --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardEventHandler.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.features.dashboard + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +sealed interface DashboardEvent { + data object DashboardRefresh : DashboardEvent + data object ProgressRefresh : DashboardEvent + data class ShowSnackbar(val message: String) : DashboardEvent +} + +@Singleton +class DashboardEventHandler @Inject constructor() { + + private val _events = MutableSharedFlow(replay = 0) + val events = _events.asSharedFlow() + + suspend fun postEvent(event: DashboardEvent) { + _events.emit(event) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardScreen.kt index 1f3e94d69c..73ef739148 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState @@ -79,26 +80,14 @@ import com.instructure.horizon.horizonui.molecules.BadgeType import com.instructure.horizon.horizonui.molecules.IconButton import com.instructure.horizon.horizonui.molecules.IconButtonColor import com.instructure.horizon.navigation.MainNavigationRoute -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -const val DASHBOARD_REFRESH = "refreshDashboard" -const val DASHBOARD_SNACKBAR = "dashboardSnackbar" - @Composable fun DashboardScreen(uiState: DashboardUiState, mainNavController: NavHostController, homeNavController: NavHostController) { val snackbarHostState = remember { SnackbarHostState() } - val parentEntry = remember(mainNavController.currentBackStackEntry) { mainNavController.getBackStackEntry("home") } - val savedStateHandle = parentEntry.savedStateHandle - - val externalRefreshFlow = remember { savedStateHandle.getStateFlow(DASHBOARD_REFRESH, false) } - val externalRefreshState by externalRefreshFlow.collectAsState() var shouldRefresh by rememberSaveable { mutableStateOf(false) } - val snackbarFlow = remember { savedStateHandle.getStateFlow(DASHBOARD_SNACKBAR, "") } - val snackbar by snackbarFlow.collectAsState() - /* Using a list of booleans to represent each refreshing component. Components get the `shouldRefresh` flag to start refreshing on pull-to-refresh. @@ -110,20 +99,27 @@ fun DashboardScreen(uiState: DashboardUiState, mainNavController: NavHostControl NotificationPermissionRequest() - LaunchedEffect(shouldRefresh, externalRefreshState) { - if (shouldRefresh || externalRefreshState) { - savedStateHandle[DASHBOARD_REFRESH] = false - delay(50) + LaunchedEffect(shouldRefresh) { + if (shouldRefresh) { shouldRefresh = false } } - LaunchedEffect(snackbar) { - if (snackbar.isNotEmpty()) { - snackbarHostState.showSnackbar( - message = snackbar, + LaunchedEffect(uiState.externalShouldRefresh) { + if (uiState.externalShouldRefresh) { + shouldRefresh = true + uiState.updateExternalShouldRefresh(false) + } + } + + LaunchedEffect(uiState.snackbarMessage) { + if (uiState.snackbarMessage != null) { + val result = snackbarHostState.showSnackbar( + message = uiState.snackbarMessage, ) - savedStateHandle[DASHBOARD_SNACKBAR] = "" + if (result == SnackbarResult.Dismissed) { + uiState.onSnackbarDismiss() + } } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardUiState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardUiState.kt index 6e183aff07..21cae2251e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardUiState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardUiState.kt @@ -17,7 +17,11 @@ package com.instructure.horizon.features.dashboard data class DashboardUiState( val logoUrl: String = "", + val externalShouldRefresh: Boolean = false, + val updateExternalShouldRefresh: (Boolean) -> Unit = {}, val unreadCountState: DashboardUnreadState = DashboardUnreadState(), + val snackbarMessage: String? = null, + val onSnackbarDismiss: () -> Unit = {}, ) data class DashboardUnreadState( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardViewModel.kt index 1612f0bb60..6a84876469 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardViewModel.kt @@ -25,15 +25,17 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class DashboardViewModel @Inject constructor( private val dashboardRepository: DashboardRepository, - private val themePrefs: ThemePrefs + private val themePrefs: ThemePrefs, + private val dashboardEventHandler: DashboardEventHandler ) : ViewModel() { - private val _uiState = MutableStateFlow(DashboardUiState()) + private val _uiState = MutableStateFlow(DashboardUiState(onSnackbarDismiss = ::dismissSnackbar, updateExternalShouldRefresh = ::updateExternalShouldRefresh)) val uiState = _uiState.asStateFlow() init { @@ -43,6 +45,19 @@ class DashboardViewModel @Inject constructor( } catch { } + + viewModelScope.launch { + dashboardEventHandler.events.collect { event -> + when (event) { + is DashboardEvent.DashboardRefresh -> { + _uiState.update { it.copy(externalShouldRefresh = true) } + refresh() + } + is DashboardEvent.ShowSnackbar -> showSnackbar(event.message) + else -> { /* No-op */ } + } + } + } } private suspend fun loadLogo() { @@ -68,4 +83,30 @@ class DashboardViewModel @Inject constructor( ) } } + + fun refresh() { + viewModelScope.tryLaunch { + loadUnreadCount() + } catch { + + } + } + + private fun showSnackbar(message: String) { + _uiState.update { + it.copy(snackbarMessage = message) + } + } + + private fun dismissSnackbar() { + _uiState.update { + it.copy(snackbarMessage = null) + } + } + + private fun updateExternalShouldRefresh(value: Boolean) { + _uiState.update { + it.copy(externalShouldRefresh = value) + } + } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModel.kt index 4cdbd337c2..95d33adf9e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModel.kt @@ -22,6 +22,8 @@ import androidx.lifecycle.viewModelScope import com.instructure.canvasapi2.type.EnrollmentWorkflowState import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.features.dashboard.DashboardEvent +import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.horizon.features.dashboard.DashboardItemState import com.instructure.horizon.features.dashboard.course.card.CardClickAction import com.instructure.horizon.features.dashboard.course.card.DashboardCourseCardModuleItemState @@ -34,18 +36,31 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class DashboardCourseViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val repository: DashboardCourseRepository + private val repository: DashboardCourseRepository, + private val dashboardEventHandler: DashboardEventHandler ): ViewModel() { private val _uiState = MutableStateFlow(DashboardCourseUiState(onRefresh = ::onRefresh)) val uiState = _uiState.asStateFlow() init { loadData() + + viewModelScope.launch { + dashboardEventHandler.events.collect { event -> + when (event) { + is DashboardEvent.ProgressRefresh -> { + onRefresh() + } + else -> { /* No-op */ } + } + } + } } private fun loadData() { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/InboxEventHandler.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/InboxEventHandler.kt new file mode 100644 index 0000000000..a323f22b5e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/InboxEventHandler.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.features.inbox + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +sealed interface InboxEvent { + data object RefreshRequested : InboxEvent + data object AnnouncementRead : InboxEvent + data class ConversationCreated(val message: String) : InboxEvent +} + +@Singleton +class InboxEventHandler @Inject constructor() { + + private val _events = MutableSharedFlow(replay = 0) + val events = _events.asSharedFlow() + + suspend fun postEvent(event: InboxEvent) { + _events.emit(event) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt index 7680fca08e..47fc1f97e3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeScreen.kt @@ -58,9 +58,6 @@ import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.horizon.R import com.instructure.horizon.features.inbox.attachment.HorizonInboxAttachmentPicker import com.instructure.horizon.features.inbox.attachment.HorizonInboxAttachmentPickerUiState -import com.instructure.horizon.features.inbox.list.HORIZON_INBOX_LIST_NEW_CONVERSATION_CREATED -import com.instructure.horizon.features.inbox.list.HORIZON_REFRESH_INBOX_LIST -import com.instructure.horizon.features.inbox.navigation.HorizonInboxRoute import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius import com.instructure.horizon.horizonui.foundation.HorizonElevation @@ -411,23 +408,11 @@ private fun HorizonInboxComposeControlsSection(state: HorizonInboxComposeUiState ) } } else { - val listEntry = remember(navController.currentBackStackEntry) { - try { - navController.getBackStackEntry(HorizonInboxRoute.InboxList.route) - } catch (e: IllegalArgumentException) { - // If the back stack entry doesn't exist, we can safely ignore it - null - } - } Button( label = stringResource(R.string.inboxComposeSendLabel), color = ButtonColor.Institution, onClick = { state.onSendConversation { - listEntry?.savedStateHandle?.set( - HORIZON_REFRESH_INBOX_LIST, - HORIZON_INBOX_LIST_NEW_CONVERSATION_CREATED - ) navController.popBackStack() } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeViewModel.kt index a9e5650bb5..846ebdc660 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeViewModel.kt @@ -25,6 +25,8 @@ import com.instructure.canvasapi2.models.Recipient import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.features.inbox.InboxEvent +import com.instructure.horizon.features.inbox.InboxEventHandler import com.instructure.horizon.features.inbox.attachment.HorizonInboxAttachment import com.instructure.horizon.features.inbox.attachment.HorizonInboxAttachmentState import dagger.hilt.android.lifecycle.HiltViewModel @@ -36,13 +38,15 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @OptIn(FlowPreview::class) @HiltViewModel class HorizonInboxComposeViewModel @Inject constructor( private val repository: HorizonInboxComposeRepository, - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val inboxEventHandler: InboxEventHandler ): ViewModel() { private val _uiState = MutableStateFlow( HorizonInboxComposeUiState( @@ -151,6 +155,14 @@ class HorizonInboxComposeViewModel @Inject constructor( ) repository.invalidateConversationListCachedResponse() + viewModelScope.launch { + inboxEventHandler.postEvent( + InboxEvent.ConversationCreated( + context.getString(R.string.inboxListConversationCreatedMessage) + ) + ) + } + _uiState.update { it.copy(isSendLoading = false) } onFinished() } catch { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsScreen.kt index c1c35dc62d..62ec1e281e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsScreen.kt @@ -61,9 +61,6 @@ import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.horizon.R import com.instructure.horizon.features.inbox.attachment.HorizonInboxAttachmentPicker import com.instructure.horizon.features.inbox.attachment.HorizonInboxAttachmentPickerViewModel -import com.instructure.horizon.features.inbox.list.HORIZON_INBOX_LIST_ANNOUNCEMENT_READ -import com.instructure.horizon.features.inbox.list.HORIZON_REFRESH_INBOX_LIST -import com.instructure.horizon.features.inbox.navigation.HorizonInboxRoute import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius import com.instructure.horizon.horizonui.foundation.HorizonSpace @@ -101,21 +98,6 @@ fun HorizonInboxDetailsScreen( state: HorizonInboxDetailsUiState, navController: NavHostController ) { - val listEntry = remember(navController.currentBackStackEntry) { - try { - navController.getBackStackEntry(HorizonInboxRoute.InboxList.route) - } catch (e: IllegalArgumentException) { - // If the back stack entry doesn't exist, we can safely ignore it - null - } - } - - LaunchedEffect(state.announcementMarkedAsRead) { - if (state.announcementMarkedAsRead) { - listEntry?.savedStateHandle?.set(HORIZON_REFRESH_INBOX_LIST, HORIZON_INBOX_LIST_ANNOUNCEMENT_READ) - } - } - Scaffold( containerColor = HorizonColors.Surface.pagePrimary(), topBar = { HorizonInboxDetailsHeader(state.title, state.titleIcon, state, navController) }, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsUiState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsUiState.kt index 3ad95155f9..52012b1ba5 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsUiState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsUiState.kt @@ -30,7 +30,6 @@ data class HorizonInboxDetailsUiState( val items: List = emptyList(), val replyState: HorizonInboxReplyState? = null, val bottomLayout: Boolean = false, - val announcementMarkedAsRead: Boolean = false, ) data class HorizonInboxReplyState( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsViewModel.kt index 362efc7cdc..3dc5959de3 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsViewModel.kt @@ -30,6 +30,8 @@ import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R import com.instructure.horizon.features.inbox.HorizonInboxItemType +import com.instructure.horizon.features.inbox.InboxEvent +import com.instructure.horizon.features.inbox.InboxEventHandler import com.instructure.horizon.features.inbox.attachment.HorizonInboxAttachment import com.instructure.horizon.features.inbox.navigation.HorizonInboxRoute import com.instructure.horizon.horizonui.platform.LoadingState @@ -55,6 +57,7 @@ class HorizonInboxDetailsViewModel @Inject constructor( private val workManager: WorkManager, private val fileDownloadProgressDao: FileDownloadProgressDao, savedStateHandle: SavedStateHandle, + private val inboxEventHandler: InboxEventHandler ): ViewModel() { private val courseId: String? = savedStateHandle[HorizonInboxRoute.InboxDetails.COURSE_ID] private val typeStringValue: String? = savedStateHandle[HorizonInboxRoute.InboxDetails.TYPE] @@ -169,7 +172,7 @@ class HorizonInboxDetailsViewModel @Inject constructor( ) if (result.isSuccess) { - _uiState.update { it.copy(announcementMarkedAsRead = true) } + inboxEventHandler.postEvent(InboxEvent.AnnouncementRead) } // We need to refresh the announcement in the background, so the next time we open and it's opened from the cache it wouldn't show as unread diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListScreen.kt index 7e8a4fef20..ce3e5977c6 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListScreen.kt @@ -87,10 +87,6 @@ import com.instructure.pandautils.utils.getActivityOrNull import com.instructure.pandautils.utils.localisedFormat import java.util.Date -const val HORIZON_REFRESH_INBOX_LIST = "horizon_refresh_inbox_list" -const val HORIZON_INBOX_LIST_NEW_CONVERSATION_CREATED = "horizon_inbox_list_new_conversation_created" -const val HORIZON_INBOX_LIST_ANNOUNCEMENT_READ = "horizon_inbox_list_announcement_read" - @Composable fun HorizonInboxListScreen( state: HorizonInboxListUiState, @@ -112,21 +108,6 @@ fun HorizonInboxListScreen( } } - val listEntry = remember(navController.currentBackStackEntry) { navController.getBackStackEntry(HorizonInboxRoute.InboxList.route) } - val savedStateHandle = listEntry.savedStateHandle - val refreshFlow = remember { savedStateHandle.getStateFlow(HORIZON_REFRESH_INBOX_LIST, null) } - val refreshTrigger by refreshFlow.collectAsState() - val snackbarMessage = stringResource(R.string.inboxListConversationCreatedMessage) - LaunchedEffect(refreshTrigger) { - if (refreshTrigger != null) { - state.loadingState.onRefresh() - if (refreshTrigger == HORIZON_INBOX_LIST_NEW_CONVERSATION_CREATED) { - state.showSnackbar(snackbarMessage) - } - savedStateHandle[HORIZON_REFRESH_INBOX_LIST] = null - } - } - Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, containerColor = HorizonColors.Surface.pagePrimary(), diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListViewModel.kt index 7e7d9dd765..bb63970f0e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/inbox/list/HorizonInboxListViewModel.kt @@ -29,6 +29,8 @@ import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R import com.instructure.horizon.features.inbox.HorizonInboxItemType +import com.instructure.horizon.features.inbox.InboxEvent +import com.instructure.horizon.features.inbox.InboxEventHandler import com.instructure.horizon.horizonui.platform.LoadingState import com.instructure.pandautils.utils.orDefault import dagger.hilt.android.lifecycle.HiltViewModel @@ -40,13 +42,15 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @OptIn(FlowPreview::class) @HiltViewModel class HorizonInboxListViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val repository: HorizonInboxListRepository + private val repository: HorizonInboxListRepository, + private val inboxEventHandler: InboxEventHandler ): ViewModel() { private val _uiState = MutableStateFlow( HorizonInboxListUiState( @@ -81,6 +85,19 @@ class HorizonInboxListViewModel @Inject constructor( } catch { showErrorState() } + + viewModelScope.launch { + inboxEventHandler.events.collect { event -> + when (event) { + is InboxEvent.RefreshRequested -> refresh() + is InboxEvent.AnnouncementRead -> refresh() + is InboxEvent.ConversationCreated -> { + refresh() + showSnackbar(event.message) + } + } + } + } } private fun loadData() { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnEventHandler.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnEventHandler.kt new file mode 100644 index 0000000000..5d31ea7f04 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnEventHandler.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.features.learn + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +sealed interface LearnEvent { + data object RefreshRequested : LearnEvent +} + +@Singleton +class LearnEventHandler @Inject constructor() { + + private val _events = MutableSharedFlow(replay = 0) + val events = _events.asSharedFlow() + + suspend fun postEvent(event: LearnEvent) { + _events.emit(event) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt index 30b8191757..64bc5ff492 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/LearnViewModel.kt @@ -30,6 +30,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -37,6 +38,7 @@ class LearnViewModel @Inject constructor( @ApplicationContext val context: Context, private val repository: LearnRepository, savedStateHandle: SavedStateHandle, + private val learnEventHandler: LearnEventHandler ) : ViewModel() { private val _state = MutableStateFlow( @@ -52,6 +54,14 @@ class LearnViewModel @Inject constructor( init { loadData() + + viewModelScope.launch { + learnEventHandler.events.collect { event -> + when (event) { + is LearnEvent.RefreshRequested -> onRefresh() + } + } + } } private fun loadData() { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/progress/CourseProgressScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/progress/CourseProgressScreen.kt index 40d45f4e02..3f0ac5602a 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/progress/CourseProgressScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/progress/CourseProgressScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -42,7 +41,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController import com.instructure.canvasapi2.utils.ContextKeeper -import com.instructure.horizon.features.moduleitemsequence.SHOULD_REFRESH_LEARN_SCREEN import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonTypography import com.instructure.horizon.horizonui.organisms.cards.ModuleContainer @@ -72,17 +70,6 @@ fun CourseProgressScreen( } } - val parentEntry = remember(mainNavController.currentBackStackEntry) { mainNavController.getBackStackEntry("home") } - val savedStateHandle = parentEntry.savedStateHandle - val refreshFlow = remember { savedStateHandle.getStateFlow(SHOULD_REFRESH_LEARN_SCREEN, false) } - val shouldRefresh by refreshFlow.collectAsState() - LaunchedEffect(shouldRefresh) { - if (shouldRefresh) { - state.screenState.onRefresh() - savedStateHandle[SHOULD_REFRESH_LEARN_SCREEN] = false - } - } - LoadingStateWrapper(state.screenState) { LearnProgressContent( state, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/progress/CourseProgressViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/progress/CourseProgressViewModel.kt index 96c5e23075..29e6e90983 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/progress/CourseProgressViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/course/progress/CourseProgressViewModel.kt @@ -23,6 +23,8 @@ import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.features.learn.LearnEvent +import com.instructure.horizon.features.learn.LearnEventHandler import com.instructure.horizon.horizonui.organisms.cards.ModuleHeaderStateMapper import com.instructure.horizon.horizonui.organisms.cards.ModuleItemCardStateMapper import com.instructure.horizon.horizonui.platform.LoadingState @@ -32,6 +34,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -40,6 +43,7 @@ class CourseProgressViewModel @Inject constructor( private val repository: CourseProgressRepository, private val moduleHeaderStateMapper: ModuleHeaderStateMapper, private val moduleItemCardStateMapper: ModuleItemCardStateMapper, + private val learnEventHandler: LearnEventHandler ): ViewModel() { private val _uiState = MutableStateFlow( CourseProgressUiState( @@ -51,6 +55,16 @@ class CourseProgressViewModel @Inject constructor( ) val uiState = _uiState.asStateFlow() + init { + viewModelScope.launch { + learnEventHandler.events.collect { event -> + when (event) { + is LearnEvent.RefreshRequested -> refresh() + } + } + } + } + fun loadState(courseId: Long) { _uiState.update { it.copy( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/ProgramDetailsScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/ProgramDetailsScreen.kt index 21d745391f..672dbe3311 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/ProgramDetailsScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/ProgramDetailsScreen.kt @@ -35,7 +35,6 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.horizon.R -import com.instructure.horizon.features.dashboard.DASHBOARD_REFRESH import com.instructure.horizon.features.learn.program.components.CourseCardChipState import com.instructure.horizon.features.learn.program.components.CourseCardStatus import com.instructure.horizon.features.learn.program.components.ProgramCourseCardState @@ -66,15 +65,6 @@ fun ProgramDetailsScreen(uiState: ProgramDetailsUiState, mainNavController: NavH } } - val homeEntry = - remember(mainNavController.currentBackStackEntry) { mainNavController.getBackStackEntry(MainNavigationRoute.Home.route) } - LaunchedEffect(uiState.shouldRefreshDashboard) { - if (uiState.shouldRefreshDashboard) { - homeEntry.savedStateHandle[DASHBOARD_REFRESH] = true - uiState.onDashboardRefreshed() - } - } - LoadingStateWrapper(loadingState = uiState.loadingState) { Column( modifier = modifier diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/ProgramDetailsUiState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/ProgramDetailsUiState.kt index cb6c6bd522..890fdcb2f1 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/ProgramDetailsUiState.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/ProgramDetailsUiState.kt @@ -28,8 +28,6 @@ data class ProgramDetailsUiState( val programProgressState: ProgramProgressState = ProgramProgressState(courses = emptyList()), val navigateToCourseId: Long? = null, val onNavigateToCourse: () -> Unit = {}, - val shouldRefreshDashboard: Boolean = false, - val onDashboardRefreshed: () -> Unit = {}, ) data class ProgramDetailTag( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/ProgramDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/ProgramDetailsViewModel.kt index 747c68c276..2405a221da 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/ProgramDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/learn/program/ProgramDetailsViewModel.kt @@ -24,6 +24,8 @@ import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequir import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.features.dashboard.DashboardEvent +import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.horizon.features.learn.program.components.CourseCardChipState import com.instructure.horizon.features.learn.program.components.CourseCardStatus import com.instructure.horizon.features.learn.program.components.ProgramCourseCardState @@ -49,7 +51,8 @@ import kotlin.time.Duration @HiltViewModel class ProgramDetailsViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val repository: ProgramDetailsRepository + private val repository: ProgramDetailsRepository, + private val dashboardEventHandler: DashboardEventHandler ) : ViewModel() { private val _uiState = MutableStateFlow( @@ -58,8 +61,7 @@ class ProgramDetailsViewModel @Inject constructor( onRefresh = ::refreshProgram, onSnackbarDismiss = ::dismissSnackbar ), - onNavigateToCourse = ::onNavigateToCourse, - onDashboardRefreshed = ::dashboardRefreshed + onNavigateToCourse = ::onNavigateToCourse ) ) val state = _uiState.asStateFlow() @@ -354,7 +356,9 @@ class ProgramDetailsViewModel @Inject constructor( return@launch } try { - _uiState.update { it.copy(shouldRefreshDashboard = true) } + viewModelScope.launch { + dashboardEventHandler.postEvent(DashboardEvent.ProgressRefresh) + } loadData(forceNetwork = true) } catch (e: Exception) { updateCourseEnrollLoadingState(courseId, false) @@ -387,8 +391,4 @@ class ProgramDetailsViewModel @Inject constructor( ) } } - - private fun dashboardRefreshed() { - _uiState.update { it.copy(shouldRefreshDashboard = false) } - } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceScreen.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceScreen.kt index 30f63281d1..a526ca2f46 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceScreen.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceScreen.kt @@ -87,7 +87,6 @@ import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.horizon.R import com.instructure.horizon.features.aiassistant.AiAssistantScreen import com.instructure.horizon.features.aiassistant.common.model.AiAssistContextSource -import com.instructure.horizon.features.dashboard.DASHBOARD_REFRESH import com.instructure.horizon.features.moduleitemsequence.content.LockedContentScreen import com.instructure.horizon.features.moduleitemsequence.content.assessment.AssessmentContentScreen import com.instructure.horizon.features.moduleitemsequence.content.assessment.AssessmentViewModel @@ -133,8 +132,6 @@ import com.instructure.pandautils.utils.orDefault import kotlinx.coroutines.launch import kotlin.math.abs -const val SHOULD_REFRESH_LEARN_SCREEN = "shouldRefreshLearnScreen" - @Composable fun ModuleItemSequenceScreen(mainNavController: NavHostController, uiState: ModuleItemSequenceUiState) { val activity = LocalContext.current.getActivityOrNull() @@ -290,15 +287,6 @@ private fun ModuleItemSequenceContent( .padding(top = moduleHeaderHeight) ) { if (uiState.currentPosition != -1) { - val homeEntry = - remember(mainNavController.currentBackStackEntry) { mainNavController.getBackStackEntry(MainNavigationRoute.Home.route) } - LaunchedEffect(uiState.shouldRefreshPreviousScreen) { - if (uiState.shouldRefreshPreviousScreen) { - homeEntry.savedStateHandle[DASHBOARD_REFRESH] = true - homeEntry.savedStateHandle[SHOULD_REFRESH_LEARN_SCREEN] = true - } - } - val pagerState = rememberPagerState(initialPage = uiState.currentPosition, pageCount = { uiState.items.size }) var previousPosition by rememberSaveable { mutableStateOf(uiState.currentPosition) } LaunchedEffect(key1 = uiState.currentPosition) { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt index 65f1b2d568..0afcccbe11 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt @@ -31,6 +31,10 @@ import com.instructure.horizon.R import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider import com.instructure.horizon.features.aiassistant.common.model.AiAssistContext import com.instructure.horizon.features.aiassistant.common.model.AiAssistContextSource +import com.instructure.horizon.features.dashboard.DashboardEvent +import com.instructure.horizon.features.dashboard.DashboardEventHandler +import com.instructure.horizon.features.learn.LearnEvent +import com.instructure.horizon.features.learn.LearnEventHandler import com.instructure.horizon.features.moduleitemsequence.progress.ProgressPageItem import com.instructure.horizon.features.moduleitemsequence.progress.ProgressPageUiState import com.instructure.horizon.features.moduleitemsequence.progress.ProgressScreenUiState @@ -55,6 +59,8 @@ class ModuleItemSequenceViewModel @Inject constructor( private val moduleItemCardStateMapper: ModuleItemCardStateMapper, private val aiAssistContextProvider: AiAssistContextProvider, savedStateHandle: SavedStateHandle, + private val dashboardEventHandler: DashboardEventHandler, + private val learnEventHandler: LearnEventHandler ) : ViewModel() { private val courseId = savedStateHandle.toRoute().courseId private val moduleItemId = savedStateHandle.toRoute().moduleItemId @@ -577,6 +583,9 @@ class ModuleItemSequenceViewModel @Inject constructor( private fun courseProgressChanged() { courseProgressChanged = true - _uiState.update { it.copy(shouldRefreshPreviousScreen = true) } + viewModelScope.launch { + dashboardEventHandler.postEvent(DashboardEvent.ProgressRefresh) + learnEventHandler.postEvent(LearnEvent.RefreshRequested) + } } } \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/DashboardViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/DashboardViewModelTest.kt index 4a6c879d8d..d782bef568 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/DashboardViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/DashboardViewModelTest.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before @@ -36,6 +37,7 @@ import org.junit.Test class DashboardViewModelTest { private val repository: DashboardRepository = mockk(relaxed = true) private val themePrefs: ThemePrefs = mockk(relaxed = true) + private val dashboardEventHandler: DashboardEventHandler = DashboardEventHandler() private val testDispatcher = UnconfinedTestDispatcher() private val notificationCounts = listOf( @@ -89,7 +91,65 @@ class DashboardViewModelTest { assertEquals(logoUrl, state.logoUrl) } + @Test + fun `Refresh event triggers refresh and updates unread counts`() = runTest { + val viewModel = getViewModel() + + val updatedCounts = listOf( + UnreadNotificationCount( + type = "Message", + count = 8, + unreadCount = 15, + ), + UnreadNotificationCount( + type = "Conversation", + count = 3, + unreadCount = 8, + ), + ) + coEvery { repository.getUnreadCounts(any()) } returns updatedCounts + + dashboardEventHandler.postEvent(DashboardEvent.DashboardRefresh) + testScheduler.advanceUntilIdle() + + coVerify(atLeast = 2) { repository.getUnreadCounts(true) } + val state = viewModel.uiState.value + assertEquals(8, state.unreadCountState.unreadConversations) + assertEquals(15, state.unreadCountState.unreadNotifications) + } + + @Test + fun `ShowSnackbar event updates snackbar message in UI state`() = runTest { + val viewModel = getViewModel() + + val testMessage = "Test snackbar message" + dashboardEventHandler.postEvent(DashboardEvent.ShowSnackbar(testMessage)) + testScheduler.advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(testMessage, state.snackbarMessage) + } + + @Test + fun `Multiple refresh events update state correctly`() = runTest { + val viewModel = getViewModel() + + dashboardEventHandler.postEvent(DashboardEvent.DashboardRefresh) + testScheduler.advanceUntilIdle() + + val secondCounts = listOf( + UnreadNotificationCount(type = "Conversation", count = 10, unreadCount = 20) + ) + coEvery { repository.getUnreadCounts(any()) } returns secondCounts + + dashboardEventHandler.postEvent(DashboardEvent.DashboardRefresh) + testScheduler.advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(20, state.unreadCountState.unreadConversations) + } + private fun getViewModel(): DashboardViewModel { - return DashboardViewModel(repository, themePrefs) + return DashboardViewModel(repository, themePrefs, dashboardEventHandler) } } \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModelTest.kt index f574a9215f..914d9adac6 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/course/DashboardCourseViewModelTest.kt @@ -23,6 +23,7 @@ import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequir import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.type.EnrollmentWorkflowState +import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus import com.instructure.journey.type.ProgramVariantType import io.mockk.coEvery @@ -48,6 +49,7 @@ class DashboardCourseViewModelTest { private val context: Context = mockk(relaxed = true) private var repository: DashboardCourseRepository = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() + private val dashboardEventHandler = DashboardEventHandler() private val courses = listOf( GetCoursesQuery.Course( @@ -196,6 +198,6 @@ class DashboardCourseViewModelTest { } private fun getViewModel(): DashboardCourseViewModel { - return DashboardCourseViewModel(context, repository) + return DashboardCourseViewModel(context, repository, dashboardEventHandler) } } \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeViewModelTest.kt index 4d3ea77e83..8ea93b9ecb 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/inbox/compose/HorizonInboxComposeViewModelTest.kt @@ -20,6 +20,7 @@ import android.content.Context import androidx.compose.ui.text.input.TextFieldValue import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Recipient +import com.instructure.horizon.features.inbox.InboxEventHandler import com.instructure.horizon.features.inbox.attachment.HorizonInboxAttachment import com.instructure.horizon.features.inbox.attachment.HorizonInboxAttachmentState import io.mockk.coEvery @@ -46,6 +47,7 @@ import org.junit.Test class HorizonInboxComposeViewModelTest { private val context: Context = mockk(relaxed = true) private val repository: HorizonInboxComposeRepository = mockk(relaxed = true) + private val inboxEventHandler: InboxEventHandler = InboxEventHandler() private val testDispatcher = UnconfinedTestDispatcher() private val testCourses = listOf( @@ -277,6 +279,6 @@ class HorizonInboxComposeViewModelTest { } private fun getViewModel(): HorizonInboxComposeViewModel { - return HorizonInboxComposeViewModel(repository, context) + return HorizonInboxComposeViewModel(repository, context, inboxEventHandler) } } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsViewModelTest.kt index 84b2773251..a92a6e2aad 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/inbox/details/HorizonInboxDetailsViewModelTest.kt @@ -29,6 +29,7 @@ import com.instructure.canvasapi2.models.DiscussionTopicHeader import com.instructure.canvasapi2.models.Message import com.instructure.canvasapi2.utils.DataResult import com.instructure.horizon.features.inbox.HorizonInboxItemType +import com.instructure.horizon.features.inbox.InboxEventHandler import com.instructure.pandautils.room.appdatabase.daos.FileDownloadProgressDao import io.mockk.coEvery import io.mockk.coVerify @@ -59,6 +60,7 @@ class HorizonInboxDetailsViewModelTest { private val workManager: WorkManager = mockk(relaxed = true) private val fileDownloadProgressDao: FileDownloadProgressDao = mockk(relaxed = true) private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) + private val inboxEventHandler: InboxEventHandler = InboxEventHandler() private val testDispatcher = UnconfinedTestDispatcher() private val testConversation = Conversation( @@ -301,7 +303,8 @@ class HorizonInboxDetailsViewModelTest { repository, workManager, fileDownloadProgressDao, - savedStateHandle + savedStateHandle, + inboxEventHandler ) } } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/inbox/list/HorizonInboxListViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/inbox/list/HorizonInboxListViewModelTest.kt index 66fb4acf50..55d322a627 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/inbox/list/HorizonInboxListViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/inbox/list/HorizonInboxListViewModelTest.kt @@ -20,6 +20,8 @@ import android.content.Context import com.instructure.canvasapi2.apis.InboxApi import com.instructure.canvasapi2.models.Conversation import com.instructure.canvasapi2.models.Recipient +import com.instructure.horizon.features.inbox.InboxEvent +import com.instructure.horizon.features.inbox.InboxEventHandler import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -41,6 +43,7 @@ import org.junit.Test class HorizonInboxListViewModelTest { private val context: Context = mockk(relaxed = true) private val repository: HorizonInboxListRepository = mockk(relaxed = true) + private val inboxEventHandler: InboxEventHandler = InboxEventHandler() private val testDispatcher = UnconfinedTestDispatcher() private val testConversations = listOf( @@ -150,7 +153,44 @@ class HorizonInboxListViewModelTest { coVerify(atLeast = 2) { repository.getConversations(any(), any()) } } + @Test + fun `RefreshRequested event triggers refresh and reloads conversations`() = runTest { + val viewModel = getViewModel() + + val updatedConversations = listOf( + Conversation(id = 3L, subject = "Updated 1", lastMessage = "New Message 1"), + Conversation(id = 4L, subject = "Updated 2", lastMessage = "New Message 2") + ) + coEvery { repository.getConversations(any(), any()) } returns updatedConversations + + inboxEventHandler.postEvent(InboxEvent.RefreshRequested) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(atLeast = 2) { repository.getConversations(any(), any()) } + } + + @Test + fun `AnnouncementRead event triggers refresh`() = runTest { + val viewModel = getViewModel() + + inboxEventHandler.postEvent(InboxEvent.AnnouncementRead) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(atLeast = 2) { repository.getConversations(any(), any()) } + } + + @Test + fun `ConversationCreated event triggers refresh and shows snackbar`() = runTest { + val viewModel = getViewModel() + + val testMessage = "Conversation created successfully" + inboxEventHandler.postEvent(InboxEvent.ConversationCreated(testMessage)) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(atLeast = 2) { repository.getConversations(any(), any()) } + } + private fun getViewModel(): HorizonInboxListViewModel { - return HorizonInboxListViewModel(context, repository) + return HorizonInboxListViewModel(context, repository, inboxEventHandler) } } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/LearnViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/LearnViewModelTest.kt index d4cacd9d6b..a7b4af2c63 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/learn/LearnViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/learn/LearnViewModelTest.kt @@ -44,6 +44,7 @@ class LearnViewModelTest { private val context: Context = mockk(relaxed = true) private val repository: LearnRepository = mockk(relaxed = true) private val savedStateHandle: SavedStateHandle = SavedStateHandle() + private val learnEventHandler: LearnEventHandler = LearnEventHandler() private val testDispatcher = UnconfinedTestDispatcher() private val testCourses = listOf( @@ -160,12 +161,46 @@ class LearnViewModelTest { fun `Test learningItemId from saved state`() = runTest { val savedStateWithId = SavedStateHandle(mapOf("learningItemId" to "testId")) - val viewModel = LearnViewModel(context, repository, savedStateWithId) + val viewModel = LearnViewModel(context, repository, savedStateWithId, learnEventHandler) assertFalse(viewModel.state.value.screenState.isLoading) } + @Test + fun `RefreshRequested event triggers refresh and reloads data`() = runTest { + val viewModel = getViewModel() + + val updatedCourses = listOf( + CourseWithProgress(courseId = 4L, courseName = "Updated Course", courseSyllabus = "", progress = 90.0) + ) + coEvery { repository.getCoursesWithProgress(any()) } returns updatedCourses + + learnEventHandler.postEvent(LearnEvent.RefreshRequested) + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(viewModel.state.value.screenState.isRefreshing == false) + } + + @Test + fun `Multiple refresh events update state correctly`() = runTest { + val viewModel = getViewModel() + + learnEventHandler.postEvent(LearnEvent.RefreshRequested) + testDispatcher.scheduler.advanceUntilIdle() + + val secondCourses = listOf( + CourseWithProgress(courseId = 5L, courseName = "Second Update", courseSyllabus = "", progress = 100.0) + ) + coEvery { repository.getCoursesWithProgress(any()) } returns secondCourses + coEvery { repository.getPrograms(any()) } returns emptyList() + + learnEventHandler.postEvent(LearnEvent.RefreshRequested) + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(viewModel.state.value.screenState.isRefreshing) + } + private fun getViewModel(): LearnViewModel { - return LearnViewModel(context, repository, savedStateHandle) + return LearnViewModel(context, repository, savedStateHandle, learnEventHandler) } } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModelTest.kt index 18366d9a38..46785f31b6 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModelTest.kt @@ -24,6 +24,8 @@ import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleItemSequence import com.instructure.canvasapi2.models.ModuleObject import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider +import com.instructure.horizon.features.dashboard.DashboardEventHandler +import com.instructure.horizon.features.learn.LearnEventHandler import com.instructure.horizon.horizonui.organisms.cards.ModuleItemCardStateMapper import com.instructure.horizon.navigation.MainNavigationRoute import io.mockk.coEvery @@ -50,6 +52,8 @@ class ModuleItemSequenceViewModelTest { private val repository: ModuleItemSequenceRepository = mockk(relaxed = true) private val moduleItemCardStateMapper: ModuleItemCardStateMapper = mockk(relaxed = true) private val aiAssistContextProvider: AiAssistContextProvider = mockk(relaxed = true) + private val dashboardEventHandler: DashboardEventHandler = DashboardEventHandler() + private val learnEventHandler: LearnEventHandler = LearnEventHandler() private val testDispatcher = UnconfinedTestDispatcher() private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) @@ -143,7 +147,9 @@ class ModuleItemSequenceViewModelTest { repository, moduleItemCardStateMapper, aiAssistContextProvider, - savedStateHandle + savedStateHandle, + dashboardEventHandler, + learnEventHandler ) } }