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)
+ }
+}