Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<DashboardEvent>(replay = 0)
val events = _events.asSharedFlow()

suspend fun postEvent(event: DashboardEvent) {
_events.emit(event)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,23 +73,12 @@ import com.instructure.horizon.horizonui.molecules.IconButtonColor
import com.instructure.horizon.navigation.MainNavigationRoute
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.
Expand All @@ -101,19 +90,24 @@ fun DashboardScreen(uiState: DashboardUiState, mainNavController: NavHostControl

NotificationPermissionRequest()

LaunchedEffect(shouldRefresh, externalRefreshState) {
if (shouldRefresh || externalRefreshState) {
savedStateHandle[DASHBOARD_REFRESH] = false
LaunchedEffect(shouldRefresh) {
if (shouldRefresh) {
shouldRefresh = false
}
}

LaunchedEffect(snackbar) {
if (snackbar.isNotEmpty()) {
LaunchedEffect(uiState.externalShouldRefresh) {
if (uiState.externalShouldRefresh) {
shouldRefresh = true
}
}

LaunchedEffect(uiState.snackbarMessage) {
if (uiState.snackbarMessage != null) {
snackbarHostState.showSnackbar(
message = snackbar,
message = uiState.snackbarMessage,
)
savedStateHandle[DASHBOARD_SNACKBAR] = ""
uiState.onSnackbarDismiss()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ package com.instructure.horizon.features.dashboard

data class DashboardUiState(
val logoUrl: String = "",
val externalShouldRefresh: Boolean = false,
val unreadCountState: DashboardUnreadState = DashboardUnreadState(),
val snackbarMessage: String? = null,
val onSnackbarDismiss: () -> Unit = {},
)

data class DashboardUnreadState(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,21 @@ import com.instructure.canvasapi2.utils.weave.tryLaunch
import com.instructure.pandautils.utils.ThemePrefs
import com.instructure.pandautils.utils.poll
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
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))
val uiState = _uiState.asStateFlow()

init {
Expand All @@ -43,6 +46,21 @@ class DashboardViewModel @Inject constructor(
} catch {

}

viewModelScope.launch {
dashboardEventHandler.events.collect { event ->
when (event) {
is DashboardEvent.DashboardRefresh -> {
_uiState.update { it.copy(externalShouldRefresh = true) }
refresh()
delay(50) // Give some time for the refresh to start
_uiState.update { it.copy(externalShouldRefresh = false) }
}
is DashboardEvent.ShowSnackbar -> showSnackbar(event.message)
else -> { /* No-op */ }
}
}
}
}

private suspend fun loadLogo() {
Expand All @@ -68,4 +86,24 @@ 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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<InboxEvent>(replay = 0)
val events = _events.asSharedFlow()

suspend fun postEvent(event: InboxEvent) {
_events.emit(event)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ data class HorizonInboxDetailsUiState(
val items: List<HorizonInboxDetailsItem> = emptyList(),
val replyState: HorizonInboxReplyState? = null,
val bottomLayout: Boolean = false,
val announcementMarkedAsRead: Boolean = false,
)

data class HorizonInboxReplyState(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading