diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/skilloverview/DashboardSkillOverviewWidgetUiTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/skilloverview/DashboardSkillOverviewWidgetUiTest.kt new file mode 100644 index 0000000000..af4e6de4f6 --- /dev/null +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/skilloverview/DashboardSkillOverviewWidgetUiTest.kt @@ -0,0 +1,171 @@ +package com.instructure.horizon.ui.features.dashboard.widget.skilloverview + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.instructure.horizon.R +import com.instructure.horizon.features.dashboard.DashboardItemState +import com.instructure.horizon.features.dashboard.widget.skilloverview.DashboardSkillOverviewSection +import com.instructure.horizon.features.dashboard.widget.skilloverview.DashboardSkillOverviewUiState +import com.instructure.horizon.features.dashboard.widget.skilloverview.card.DashboardSkillOverviewCardState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DashboardSkillOverviewWidgetUiTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun testLoadingStateDisplaysCorrectly() { + val uiState = DashboardSkillOverviewUiState( + state = DashboardItemState.LOADING + ) + + composeTestRule.setContent { + DashboardSkillOverviewSection(uiState, rememberNavController()) + } + + val title = context.getString(R.string.dashboardSkillOverviewTitle) + composeTestRule.onNodeWithText(title).assertIsDisplayed() + } + + @Test + fun testErrorStateDisplaysCorrectly() { + val uiState = DashboardSkillOverviewUiState( + state = DashboardItemState.ERROR, + onRefresh = { it() } + ) + + composeTestRule.setContent { + DashboardSkillOverviewSection(uiState, rememberNavController()) + } + + val title = context.getString(R.string.dashboardSkillOverviewTitle) + val errorMessage = context.getString(R.string.dashboardSkillOverviewErrorMessage) + val retryLabel = context.getString(R.string.dashboardSkillOverviewRetry) + + composeTestRule.onNodeWithText(title).assertIsDisplayed() + composeTestRule.onNodeWithText(errorMessage, substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText(retryLabel).assertIsDisplayed() + } + + @Test + fun testErrorStateRefreshButtonWorks() { + var refreshCalled = false + val uiState = DashboardSkillOverviewUiState( + state = DashboardItemState.ERROR, + onRefresh = { refreshCalled = true; it() } + ) + + composeTestRule.setContent { + DashboardSkillOverviewSection(uiState, rememberNavController()) + } + + val retryLabel = context.getString(R.string.dashboardSkillOverviewRetry) + composeTestRule.onNodeWithText(retryLabel).performClick() + + assert(refreshCalled) + } + + @Test + fun testNoDataStateDisplaysCorrectly() { + val uiState = DashboardSkillOverviewUiState( + state = DashboardItemState.SUCCESS, + cardState = DashboardSkillOverviewCardState(completedSkillCount = 0) + ) + + composeTestRule.setContent { + DashboardSkillOverviewSection(uiState, rememberNavController()) + } + + val title = context.getString(R.string.dashboardSkillOverviewTitle) + val noDataMessage = context.getString(R.string.dashboardSkillOverviewNoDataMessage) + + composeTestRule.onNodeWithText(title).assertIsDisplayed() + composeTestRule.onNodeWithText(noDataMessage).assertIsDisplayed() + } + + @Test + fun testSuccessStateWithSkillCountDisplaysCorrectly() { + val uiState = DashboardSkillOverviewUiState( + state = DashboardItemState.SUCCESS, + cardState = DashboardSkillOverviewCardState(completedSkillCount = 5) + ) + + composeTestRule.setContent { + DashboardSkillOverviewSection(uiState, rememberNavController()) + } + + val title = context.getString(R.string.dashboardSkillOverviewTitle) + val earnedLabel = context.getString(R.string.dashboardSkillOverviewEarnedLabel) + + composeTestRule.onNodeWithText(title).assertIsDisplayed() + composeTestRule.onNodeWithText("5").assertIsDisplayed() + composeTestRule.onNodeWithText(earnedLabel).assertIsDisplayed() + } + + @Test + fun testSuccessStateWithSingleSkill() { + val uiState = DashboardSkillOverviewUiState( + state = DashboardItemState.SUCCESS, + cardState = DashboardSkillOverviewCardState(completedSkillCount = 1) + ) + + composeTestRule.setContent { + DashboardSkillOverviewSection(uiState, rememberNavController()) + } + + val title = context.getString(R.string.dashboardSkillOverviewTitle) + val earnedLabel = context.getString(R.string.dashboardSkillOverviewEarnedLabel) + + composeTestRule.onNodeWithText(title).assertIsDisplayed() + composeTestRule.onNodeWithText("1").assertIsDisplayed() + composeTestRule.onNodeWithText(earnedLabel).assertIsDisplayed() + } + + @Test + fun testSuccessStateWithLargeSkillCount() { + val uiState = DashboardSkillOverviewUiState( + state = DashboardItemState.SUCCESS, + cardState = DashboardSkillOverviewCardState(completedSkillCount = 99) + ) + + composeTestRule.setContent { + DashboardSkillOverviewSection(uiState, rememberNavController()) + } + + val title = context.getString(R.string.dashboardSkillOverviewTitle) + val earnedLabel = context.getString(R.string.dashboardSkillOverviewEarnedLabel) + + composeTestRule.onNodeWithText(title).assertIsDisplayed() + composeTestRule.onNodeWithText("99").assertIsDisplayed() + composeTestRule.onNodeWithText(earnedLabel).assertIsDisplayed() + } + + @Test + fun testSuccessStateWithVeryLargeSkillCount() { + val uiState = DashboardSkillOverviewUiState( + state = DashboardItemState.SUCCESS, + cardState = DashboardSkillOverviewCardState(completedSkillCount = 999) + ) + + composeTestRule.setContent { + DashboardSkillOverviewSection(uiState, rememberNavController()) + } + + val title = context.getString(R.string.dashboardSkillOverviewTitle) + val earnedLabel = context.getString(R.string.dashboardSkillOverviewEarnedLabel) + + composeTestRule.onNodeWithText(title).assertIsDisplayed() + composeTestRule.onNodeWithText("999").assertIsDisplayed() + composeTestRule.onNodeWithText(earnedLabel).assertIsDisplayed() + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardCard.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardCard.kt index 37419e0d35..8afed41f2c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardCard.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardCard.kt @@ -17,6 +17,7 @@ package com.instructure.horizon.features.dashboard import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn @@ -27,10 +28,12 @@ import com.instructure.horizon.horizonui.foundation.HorizonColors import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius import com.instructure.horizon.horizonui.foundation.HorizonElevation import com.instructure.horizon.horizonui.foundation.horizonShadow +import com.instructure.pandautils.compose.modifiers.conditional @Composable fun DashboardCard( modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, content: @Composable () -> Unit ) { Box(modifier = modifier @@ -39,6 +42,9 @@ fun DashboardCard( .horizonShadow(HorizonElevation.level4, shape = HorizonCornerRadius.level4, clip = true) .background(color = HorizonColors.Surface.cardPrimary(), shape = HorizonCornerRadius.level4) .widthIn(max = 400.dp) + .conditional(onClick != null) { + clickable(onClick = { onClick?.invoke() }) + } ) { content() } 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 fd0a38d784..1f3e94d69c 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 @@ -66,6 +66,7 @@ import com.instructure.horizon.R import com.instructure.horizon.features.dashboard.course.DashboardCourseSection import com.instructure.horizon.features.dashboard.widget.myprogress.DashboardMyProgressWidget import com.instructure.horizon.features.dashboard.widget.skillhighlights.DashboardSkillHighlightsWidget +import com.instructure.horizon.features.dashboard.widget.skilloverview.DashboardSkillOverviewWidget import com.instructure.horizon.features.dashboard.widget.timespent.DashboardTimeSpentWidget import com.instructure.horizon.horizonui.animation.shimmerEffect import com.instructure.horizon.horizonui.foundation.HorizonColors @@ -173,12 +174,18 @@ fun DashboardScreen(uiState: DashboardUiState, mainNavController: NavHostControl .horizontalScroll(rememberScrollState()) .padding(start = 16.dp) ) { + DashboardMyProgressWidget( + shouldRefresh, + refreshStateFlow + ) + Spacer(modifier = Modifier.width(8.dp)) DashboardTimeSpentWidget( shouldRefresh, refreshStateFlow ) Spacer(modifier = Modifier.width(8.dp)) - DashboardMyProgressWidget( + DashboardSkillOverviewWidget( + homeNavController, shouldRefresh, refreshStateFlow ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardWidgetCard.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardWidgetCard.kt index 813cfd1928..acfa74147b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardWidgetCard.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardWidgetCard.kt @@ -56,9 +56,10 @@ fun DashboardWidgetCard( modifier: Modifier = Modifier, isLoading: Boolean = false, useMinWidth: Boolean = true, + onClick: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit ) { - DashboardCard(modifier) { + DashboardCard(modifier, onClick) { Column( modifier = Modifier .padding(24.dp) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewRepository.kt new file mode 100644 index 0000000000..843334ef1e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewRepository.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.dashboard.widget.skilloverview + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetSkillsManager +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Skill +import javax.inject.Inject + +class DashboardSkillOverviewRepository @Inject constructor( + private val getSkillsManager: GetSkillsManager +) { + suspend fun getSkills(completedOnly: Boolean?, forceNetwork: Boolean): List { + return getSkillsManager.getSkills(completedOnly = completedOnly, forceNetwork = forceNetwork) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewUiState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewUiState.kt new file mode 100644 index 0000000000..5cd5bf3701 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewUiState.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.dashboard.widget.skilloverview + +import com.instructure.horizon.features.dashboard.DashboardItemState +import com.instructure.horizon.features.dashboard.widget.skilloverview.card.DashboardSkillOverviewCardState + +data class DashboardSkillOverviewUiState( + val state: DashboardItemState = DashboardItemState.LOADING, + val cardState: DashboardSkillOverviewCardState = DashboardSkillOverviewCardState(), + val onRefresh: (() -> Unit) -> Unit = {} +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewViewModel.kt new file mode 100644 index 0000000000..990c16e632 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewViewModel.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.dashboard.widget.skilloverview + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.utils.weave.catch +import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.features.dashboard.DashboardItemState +import com.instructure.horizon.features.dashboard.widget.skilloverview.card.DashboardSkillOverviewCardState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@HiltViewModel +class DashboardSkillOverviewViewModel @Inject constructor( + private val repository: DashboardSkillOverviewRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow( + DashboardSkillOverviewUiState( + onRefresh = ::refresh + ) + ) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.tryLaunch { + loadSkillOverviewData() + } catch { + _uiState.update { it.copy(state = DashboardItemState.ERROR) } + } + } + + private suspend fun loadSkillOverviewData(forceNetwork: Boolean = false) { + _uiState.update { it.copy(state = DashboardItemState.LOADING) } + val skills = repository.getSkills(completedOnly = true, forceNetwork = forceNetwork) + + val completedSkillCount = skills.size + + _uiState.update { + it.copy( + state = DashboardItemState.SUCCESS, + cardState = DashboardSkillOverviewCardState( + completedSkillCount = completedSkillCount + ) + ) + } + } + + private fun refresh(onComplete: () -> Unit) { + viewModelScope.tryLaunch { + _uiState.update { it.copy(state = DashboardItemState.LOADING) } + loadSkillOverviewData(forceNetwork = true) + _uiState.update { it.copy(state = DashboardItemState.SUCCESS) } + onComplete() + } catch { + _uiState.update { it.copy(state = DashboardItemState.ERROR) } + onComplete() + } + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewWidget.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewWidget.kt new file mode 100644 index 0000000000..e8f53ecac8 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewWidget.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.dashboard.widget.skilloverview + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import com.instructure.horizon.features.dashboard.DashboardItemState +import com.instructure.horizon.features.dashboard.widget.skilloverview.card.DashboardSkillOverviewCardContent +import com.instructure.horizon.features.dashboard.widget.skilloverview.card.DashboardSkillOverviewCardError +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +@Composable +fun DashboardSkillOverviewWidget( + homeNavController: NavHostController, + shouldRefresh: Boolean, + refreshState: MutableStateFlow> +) { + val viewModel = hiltViewModel() + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(shouldRefresh) { + if (shouldRefresh) { + refreshState.update { it + true } + state.onRefresh { + refreshState.update { it - true } + } + } + } + + DashboardSkillOverviewSection(state, homeNavController) +} + +@Composable +fun DashboardSkillOverviewSection( + state: DashboardSkillOverviewUiState, + homeNavController: NavHostController, +) { + when (state.state) { + DashboardItemState.LOADING -> { + DashboardSkillOverviewCardContent( + state.cardState, + homeNavController, + isLoading = true + ) + } + DashboardItemState.ERROR -> { + DashboardSkillOverviewCardError( + { state.onRefresh {} } + ) + } + DashboardItemState.SUCCESS -> { + DashboardSkillOverviewCardContent( + state.cardState, + homeNavController, + isLoading = false + ) + } + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardContent.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardContent.kt new file mode 100644 index 0000000000..1dc9a7a6d6 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardContent.kt @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.dashboard.widget.skilloverview.card + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavGraph.Companion.findStartDestination +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.widget.DashboardWidgetCard +import com.instructure.horizon.features.home.HomeNavigationRoute +import com.instructure.horizon.horizonui.animation.shimmerEffect +import com.instructure.horizon.horizonui.foundation.HorizonColors +import com.instructure.horizon.horizonui.foundation.HorizonTypography + +@Composable +fun DashboardSkillOverviewCardContent( + state: DashboardSkillOverviewCardState, + homeNavController: NavHostController, + modifier: Modifier = Modifier, + isLoading: Boolean = false, +) { + DashboardWidgetCard( + title = stringResource(R.string.dashboardSkillOverviewTitle), + iconRes = R.drawable.hub, + widgetColor = HorizonColors.PrimitivesGreen.green12(), + isLoading = isLoading, + useMinWidth = true, + onClick = { + homeNavController.navigate(HomeNavigationRoute.Skillspace.route) { + popUpTo(homeNavController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + modifier = modifier + .widthIn(max = 300.dp) + .padding(bottom = 8.dp), + ) { + if (state.completedSkillCount == 0) { + Text( + text = stringResource(R.string.dashboardSkillOverviewNoDataMessage), + style = HorizonTypography.p2, + color = HorizonColors.Text.timestamp(), + modifier = Modifier + .width(IntrinsicSize.Max) + .shimmerEffect(isLoading) + ) + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = state.completedSkillCount.toString(), + style = HorizonTypography.h1.copy(fontSize = 38.sp, letterSpacing = 0.sp), + color = HorizonColors.Text.body(), + modifier = Modifier.shimmerEffect(isLoading) + ) + Text( + text = stringResource(R.string.dashboardSkillOverviewEarnedLabel), + style = HorizonTypography.labelMediumBold, + color = HorizonColors.Text.title(), + modifier = Modifier.shimmerEffect(isLoading) + ) + } + } + } +} + +@Composable +@Preview +private fun DashboardSkillOverviewCardContentPreview() { + ContextKeeper.appContext = LocalContext.current + DashboardSkillOverviewCardContent( + state = DashboardSkillOverviewCardState(completedSkillCount = 24), + rememberNavController() + ) +} + +@Composable +@Preview +private fun DashboardSkillOverviewCardContentNoDataPreview() { + ContextKeeper.appContext = LocalContext.current + DashboardSkillOverviewCardContent( + state = DashboardSkillOverviewCardState(completedSkillCount = 0), + rememberNavController() + ) +} + +@Composable +@Preview +private fun DashboardSkillOverviewLoadingPreview() { + ContextKeeper.appContext = LocalContext.current + DashboardSkillOverviewCardContent( + state = DashboardSkillOverviewCardState(completedSkillCount = 0), + rememberNavController(), + isLoading = true + ) +} + diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardError.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardError.kt new file mode 100644 index 0000000000..83ae43ecfa --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardError.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.dashboard.widget.skilloverview.card + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.instructure.horizon.R +import com.instructure.horizon.features.dashboard.widget.DashboardWidgetCard +import com.instructure.horizon.horizonui.foundation.HorizonColors +import com.instructure.horizon.horizonui.foundation.HorizonSpace +import com.instructure.horizon.horizonui.foundation.HorizonTypography +import com.instructure.horizon.horizonui.foundation.SpaceSize +import com.instructure.horizon.horizonui.molecules.Button +import com.instructure.horizon.horizonui.molecules.ButtonColor +import com.instructure.horizon.horizonui.molecules.ButtonHeight +import com.instructure.horizon.horizonui.molecules.ButtonIconPosition + +@Composable +fun DashboardSkillOverviewCardError( + onRetryClick: () -> Unit, + modifier: Modifier = Modifier +) { + DashboardWidgetCard( + title = stringResource(R.string.dashboardSkillOverviewTitle), + iconRes = R.drawable.hub, + widgetColor = HorizonColors.PrimitivesGreen.green12(), + useMinWidth = true, + modifier = modifier + ) { + Column( + modifier = Modifier.width(IntrinsicSize.Max) + ) { + Text( + text = stringResource(R.string.dashboardSkillOverviewErrorMessage), + style = HorizonTypography.p2, + color = HorizonColors.Text.timestamp() + ) + HorizonSpace(SpaceSize.SPACE_8) + Button( + label = stringResource(R.string.dashboardSkillOverviewRetry), + onClick = onRetryClick, + color = ButtonColor.WhiteWithOutline, + height = ButtonHeight.SMALL, + iconPosition = ButtonIconPosition.End(R.drawable.restart_alt) + ) + } + } +} + +@Composable +@Preview +private fun DashboardSkillOverviewCardErrorPreview() { + DashboardSkillOverviewCardError(onRetryClick = {}) +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardState.kt new file mode 100644 index 0000000000..8fabe83d8b --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skilloverview/card/DashboardSkillOverviewCardState.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.dashboard.widget.skilloverview.card + +data class DashboardSkillOverviewCardState( + val completedSkillCount: Int = 0 +) diff --git a/libs/horizon/src/main/res/values/strings.xml b/libs/horizon/src/main/res/values/strings.xml index e47894903a..dae5b9b85d 100644 --- a/libs/horizon/src/main/res/values/strings.xml +++ b/libs/horizon/src/main/res/values/strings.xml @@ -390,6 +390,11 @@ This widget will update once data becomes available. We weren\'t able to load this content.\nPlease try again. Refresh + Skills + earned + This widget will update once data becomes available. + We weren\'t able to load this content.\nPlease try again. + Refresh Select Course Close Expanded diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewRepositoryTest.kt new file mode 100644 index 0000000000..3c7b720dd6 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewRepositoryTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.dashboard.widget.skilloverview + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetSkillsManager +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Skill +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.util.Date + +class DashboardSkillOverviewRepositoryTest { + private val getSkillsManager: GetSkillsManager = mockk(relaxed = true) + + @Test + fun `Test successful skills retrieval with completedOnly true`() = runTest { + val skills = listOf( + Skill("1", "Completed Skill 1", "expert", Date(), Date()), + Skill("2", "Completed Skill 2", "advanced", Date(), Date()), + Skill("3", "Completed Skill 3", "proficient", Date(), Date()) + ) + coEvery { getSkillsManager.getSkills(true, false) } returns skills + + val result = getRepository().getSkills(completedOnly = true, forceNetwork = false) + + assertEquals(3, result.size) + assertEquals(skills, result) + coVerify { getSkillsManager.getSkills(true, false) } + } + + @Test + fun `Test skills retrieval with forceNetwork true`() = runTest { + val skills = listOf( + Skill("1", "Network Skill", "advanced", Date(), Date()) + ) + coEvery { getSkillsManager.getSkills(true, true) } returns skills + + val result = getRepository().getSkills(completedOnly = true, forceNetwork = true) + + assertEquals(1, result.size) + coVerify { getSkillsManager.getSkills(true, true) } + } + + @Test + fun `Test empty skills list is returned correctly`() = runTest { + coEvery { getSkillsManager.getSkills(true, false) } returns emptyList() + + val result = getRepository().getSkills(completedOnly = true, forceNetwork = false) + + assertEquals(0, result.size) + } + + @Test + fun `Test skills with null proficiency level`() = runTest { + val skills = listOf( + Skill("1", "Skill Without Level", null, Date(), Date()) + ) + coEvery { getSkillsManager.getSkills(true, false) } returns skills + + val result = getRepository().getSkills(completedOnly = true, forceNetwork = false) + + assertEquals(1, result.size) + assertEquals(null, result[0].proficiencyLevel) + } + + private fun getRepository(): DashboardSkillOverviewRepository { + return DashboardSkillOverviewRepository(getSkillsManager) + } +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewViewModelTest.kt new file mode 100644 index 0000000000..0f27f44c75 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/skilloverview/DashboardSkillOverviewViewModelTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2025 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.horizon.features.dashboard.widget.skilloverview + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Skill +import com.instructure.horizon.features.dashboard.DashboardItemState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.unmockkAll +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class DashboardSkillOverviewViewModelTest { + private val repository: DashboardSkillOverviewRepository = mockk(relaxed = true) + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `Test initialization loads skills data`() = runTest { + val skills = listOf( + Skill("1", "Completed Skill 1", "advanced", Date(), Date()), + Skill("2", "Completed Skill 2", "beginner", Date(), Date()), + Skill("3", "Completed Skill 3", "proficient", Date(), Date()) + ) + coEvery { repository.getSkills(true, false) } returns skills + + val viewModel = getViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(DashboardItemState.SUCCESS, state.state) + assertEquals(3, state.cardState.completedSkillCount) + coVerify { repository.getSkills(true, false) } + } + + @Test + fun `Test completed skill count is correct`() = runTest { + val skills = listOf( + Skill("1", "Skill 1", "expert", Date(), Date()), + Skill("2", "Skill 2", "advanced", Date(), Date()), + Skill("3", "Skill 3", "proficient", Date(), Date()), + Skill("4", "Skill 4", "beginner", Date(), Date()), + Skill("5", "Skill 5", "advanced", Date(), Date()) + ) + coEvery { repository.getSkills(true, false) } returns skills + + val viewModel = getViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(5, state.cardState.completedSkillCount) + } + + @Test + fun `Test zero completed skills`() = runTest { + coEvery { repository.getSkills(true, false) } returns emptyList() + + val viewModel = getViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(DashboardItemState.SUCCESS, state.state) + assertEquals(0, state.cardState.completedSkillCount) + } + + @Test + fun `Test error state when repository throws exception`() = runTest { + coEvery { repository.getSkills(true, false) } throws Exception("Network error") + + val viewModel = getViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(DashboardItemState.ERROR, state.state) + } + + @Test + fun `Test refresh calls repository with forceNetwork true`() = runTest { + val skills = listOf( + Skill("1", "Skill 1", "advanced", Date(), Date()), + Skill("2", "Skill 2", "proficient", Date(), Date()) + ) + coEvery { repository.getSkills(true, false) } returns skills + coEvery { repository.getSkills(true, true) } returns skills + + val viewModel = getViewModel() + advanceUntilIdle() + + var completed = false + viewModel.uiState.value.onRefresh { completed = true } + advanceUntilIdle() + + assertTrue(completed) + coVerify { repository.getSkills(true, true) } + } + + @Test + fun `Test refresh updates state to loading then success`() = runTest { + val skills = listOf( + Skill("1", "Skill 1", "expert", Date(), Date()), + Skill("2", "Skill 2", "advanced", Date(), Date()) + ) + coEvery { repository.getSkills(true, any()) } returns skills + + val viewModel = getViewModel() + advanceUntilIdle() + + var completed = false + viewModel.uiState.value.onRefresh { completed = true } + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(DashboardItemState.SUCCESS, state.state) + assertTrue(completed) + } + + @Test + fun `Test refresh with error sets error state`() = runTest { + val skills = listOf( + Skill("1", "Skill 1", "expert", Date(), Date()) + ) + coEvery { repository.getSkills(true, false) } returns skills + coEvery { repository.getSkills(true, true) } throws Exception("Refresh failed") + + val viewModel = getViewModel() + advanceUntilIdle() + + var completed = false + viewModel.uiState.value.onRefresh { completed = true } + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(DashboardItemState.ERROR, state.state) + assertTrue(completed) + } + + @Test + fun `Test large number of completed skills`() = runTest { + val skills = (1..100).map { + Skill(it.toString(), "Skill $it", "advanced", Date(), Date()) + } + coEvery { repository.getSkills(true, false) } returns skills + + val viewModel = getViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(100, state.cardState.completedSkillCount) + } + + private fun getViewModel(): DashboardSkillOverviewViewModel { + return DashboardSkillOverviewViewModel(repository) + } +}