Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() {
Expand All @@ -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)
}
}
}
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
Loading
Loading