Skip to content

Commit bfbe933

Browse files
domonkosadamclaude
andauthored
[CLX-3082][Horizon] Skill overview widget (#3321)
refs: CLX-3082 affects: Horizon release note: none --------- Co-authored-by: Claude <[email protected]>
1 parent 9b677c4 commit bfbe933

File tree

14 files changed

+909
-2
lines changed

14 files changed

+909
-2
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package com.instructure.horizon.ui.features.dashboard.widget.skilloverview
2+
3+
import androidx.compose.ui.test.assertIsDisplayed
4+
import androidx.compose.ui.test.junit4.createComposeRule
5+
import androidx.compose.ui.test.onNodeWithText
6+
import androidx.compose.ui.test.performClick
7+
import androidx.navigation.compose.rememberNavController
8+
import androidx.test.ext.junit.runners.AndroidJUnit4
9+
import androidx.test.platform.app.InstrumentationRegistry
10+
import com.instructure.horizon.R
11+
import com.instructure.horizon.features.dashboard.DashboardItemState
12+
import com.instructure.horizon.features.dashboard.widget.skilloverview.DashboardSkillOverviewSection
13+
import com.instructure.horizon.features.dashboard.widget.skilloverview.DashboardSkillOverviewUiState
14+
import com.instructure.horizon.features.dashboard.widget.skilloverview.card.DashboardSkillOverviewCardState
15+
import org.junit.Rule
16+
import org.junit.Test
17+
import org.junit.runner.RunWith
18+
19+
@RunWith(AndroidJUnit4::class)
20+
class DashboardSkillOverviewWidgetUiTest {
21+
@get:Rule
22+
val composeTestRule = createComposeRule()
23+
24+
private val context = InstrumentationRegistry.getInstrumentation().targetContext
25+
26+
@Test
27+
fun testLoadingStateDisplaysCorrectly() {
28+
val uiState = DashboardSkillOverviewUiState(
29+
state = DashboardItemState.LOADING
30+
)
31+
32+
composeTestRule.setContent {
33+
DashboardSkillOverviewSection(uiState, rememberNavController())
34+
}
35+
36+
val title = context.getString(R.string.dashboardSkillOverviewTitle)
37+
composeTestRule.onNodeWithText(title).assertIsDisplayed()
38+
}
39+
40+
@Test
41+
fun testErrorStateDisplaysCorrectly() {
42+
val uiState = DashboardSkillOverviewUiState(
43+
state = DashboardItemState.ERROR,
44+
onRefresh = { it() }
45+
)
46+
47+
composeTestRule.setContent {
48+
DashboardSkillOverviewSection(uiState, rememberNavController())
49+
}
50+
51+
val title = context.getString(R.string.dashboardSkillOverviewTitle)
52+
val errorMessage = context.getString(R.string.dashboardSkillOverviewErrorMessage)
53+
val retryLabel = context.getString(R.string.dashboardSkillOverviewRetry)
54+
55+
composeTestRule.onNodeWithText(title).assertIsDisplayed()
56+
composeTestRule.onNodeWithText(errorMessage, substring = true).assertIsDisplayed()
57+
composeTestRule.onNodeWithText(retryLabel).assertIsDisplayed()
58+
}
59+
60+
@Test
61+
fun testErrorStateRefreshButtonWorks() {
62+
var refreshCalled = false
63+
val uiState = DashboardSkillOverviewUiState(
64+
state = DashboardItemState.ERROR,
65+
onRefresh = { refreshCalled = true; it() }
66+
)
67+
68+
composeTestRule.setContent {
69+
DashboardSkillOverviewSection(uiState, rememberNavController())
70+
}
71+
72+
val retryLabel = context.getString(R.string.dashboardSkillOverviewRetry)
73+
composeTestRule.onNodeWithText(retryLabel).performClick()
74+
75+
assert(refreshCalled)
76+
}
77+
78+
@Test
79+
fun testNoDataStateDisplaysCorrectly() {
80+
val uiState = DashboardSkillOverviewUiState(
81+
state = DashboardItemState.SUCCESS,
82+
cardState = DashboardSkillOverviewCardState(completedSkillCount = 0)
83+
)
84+
85+
composeTestRule.setContent {
86+
DashboardSkillOverviewSection(uiState, rememberNavController())
87+
}
88+
89+
val title = context.getString(R.string.dashboardSkillOverviewTitle)
90+
val noDataMessage = context.getString(R.string.dashboardSkillOverviewNoDataMessage)
91+
92+
composeTestRule.onNodeWithText(title).assertIsDisplayed()
93+
composeTestRule.onNodeWithText(noDataMessage).assertIsDisplayed()
94+
}
95+
96+
@Test
97+
fun testSuccessStateWithSkillCountDisplaysCorrectly() {
98+
val uiState = DashboardSkillOverviewUiState(
99+
state = DashboardItemState.SUCCESS,
100+
cardState = DashboardSkillOverviewCardState(completedSkillCount = 5)
101+
)
102+
103+
composeTestRule.setContent {
104+
DashboardSkillOverviewSection(uiState, rememberNavController())
105+
}
106+
107+
val title = context.getString(R.string.dashboardSkillOverviewTitle)
108+
val earnedLabel = context.getString(R.string.dashboardSkillOverviewEarnedLabel)
109+
110+
composeTestRule.onNodeWithText(title).assertIsDisplayed()
111+
composeTestRule.onNodeWithText("5").assertIsDisplayed()
112+
composeTestRule.onNodeWithText(earnedLabel).assertIsDisplayed()
113+
}
114+
115+
@Test
116+
fun testSuccessStateWithSingleSkill() {
117+
val uiState = DashboardSkillOverviewUiState(
118+
state = DashboardItemState.SUCCESS,
119+
cardState = DashboardSkillOverviewCardState(completedSkillCount = 1)
120+
)
121+
122+
composeTestRule.setContent {
123+
DashboardSkillOverviewSection(uiState, rememberNavController())
124+
}
125+
126+
val title = context.getString(R.string.dashboardSkillOverviewTitle)
127+
val earnedLabel = context.getString(R.string.dashboardSkillOverviewEarnedLabel)
128+
129+
composeTestRule.onNodeWithText(title).assertIsDisplayed()
130+
composeTestRule.onNodeWithText("1").assertIsDisplayed()
131+
composeTestRule.onNodeWithText(earnedLabel).assertIsDisplayed()
132+
}
133+
134+
@Test
135+
fun testSuccessStateWithLargeSkillCount() {
136+
val uiState = DashboardSkillOverviewUiState(
137+
state = DashboardItemState.SUCCESS,
138+
cardState = DashboardSkillOverviewCardState(completedSkillCount = 99)
139+
)
140+
141+
composeTestRule.setContent {
142+
DashboardSkillOverviewSection(uiState, rememberNavController())
143+
}
144+
145+
val title = context.getString(R.string.dashboardSkillOverviewTitle)
146+
val earnedLabel = context.getString(R.string.dashboardSkillOverviewEarnedLabel)
147+
148+
composeTestRule.onNodeWithText(title).assertIsDisplayed()
149+
composeTestRule.onNodeWithText("99").assertIsDisplayed()
150+
composeTestRule.onNodeWithText(earnedLabel).assertIsDisplayed()
151+
}
152+
153+
@Test
154+
fun testSuccessStateWithVeryLargeSkillCount() {
155+
val uiState = DashboardSkillOverviewUiState(
156+
state = DashboardItemState.SUCCESS,
157+
cardState = DashboardSkillOverviewCardState(completedSkillCount = 999)
158+
)
159+
160+
composeTestRule.setContent {
161+
DashboardSkillOverviewSection(uiState, rememberNavController())
162+
}
163+
164+
val title = context.getString(R.string.dashboardSkillOverviewTitle)
165+
val earnedLabel = context.getString(R.string.dashboardSkillOverviewEarnedLabel)
166+
167+
composeTestRule.onNodeWithText(title).assertIsDisplayed()
168+
composeTestRule.onNodeWithText("999").assertIsDisplayed()
169+
composeTestRule.onNodeWithText(earnedLabel).assertIsDisplayed()
170+
}
171+
}

libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardCard.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.instructure.horizon.features.dashboard
1818

1919
import androidx.compose.foundation.background
20+
import androidx.compose.foundation.clickable
2021
import androidx.compose.foundation.layout.Box
2122
import androidx.compose.foundation.layout.padding
2223
import androidx.compose.foundation.layout.widthIn
@@ -27,10 +28,12 @@ import com.instructure.horizon.horizonui.foundation.HorizonColors
2728
import com.instructure.horizon.horizonui.foundation.HorizonCornerRadius
2829
import com.instructure.horizon.horizonui.foundation.HorizonElevation
2930
import com.instructure.horizon.horizonui.foundation.horizonShadow
31+
import com.instructure.pandautils.compose.modifiers.conditional
3032

3133
@Composable
3234
fun DashboardCard(
3335
modifier: Modifier = Modifier,
36+
onClick: (() -> Unit)? = null,
3437
content: @Composable () -> Unit
3538
) {
3639
Box(modifier = modifier
@@ -39,6 +42,9 @@ fun DashboardCard(
3942
.horizonShadow(HorizonElevation.level4, shape = HorizonCornerRadius.level4, clip = true)
4043
.background(color = HorizonColors.Surface.cardPrimary(), shape = HorizonCornerRadius.level4)
4144
.widthIn(max = 400.dp)
45+
.conditional(onClick != null) {
46+
clickable(onClick = { onClick?.invoke() })
47+
}
4248
) {
4349
content()
4450
}

libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/DashboardScreen.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import com.instructure.horizon.R
6666
import com.instructure.horizon.features.dashboard.course.DashboardCourseSection
6767
import com.instructure.horizon.features.dashboard.widget.myprogress.DashboardMyProgressWidget
6868
import com.instructure.horizon.features.dashboard.widget.skillhighlights.DashboardSkillHighlightsWidget
69+
import com.instructure.horizon.features.dashboard.widget.skilloverview.DashboardSkillOverviewWidget
6970
import com.instructure.horizon.features.dashboard.widget.timespent.DashboardTimeSpentWidget
7071
import com.instructure.horizon.horizonui.animation.shimmerEffect
7172
import com.instructure.horizon.horizonui.foundation.HorizonColors
@@ -173,12 +174,18 @@ fun DashboardScreen(uiState: DashboardUiState, mainNavController: NavHostControl
173174
.horizontalScroll(rememberScrollState())
174175
.padding(start = 16.dp)
175176
) {
177+
DashboardMyProgressWidget(
178+
shouldRefresh,
179+
refreshStateFlow
180+
)
181+
Spacer(modifier = Modifier.width(8.dp))
176182
DashboardTimeSpentWidget(
177183
shouldRefresh,
178184
refreshStateFlow
179185
)
180186
Spacer(modifier = Modifier.width(8.dp))
181-
DashboardMyProgressWidget(
187+
DashboardSkillOverviewWidget(
188+
homeNavController,
182189
shouldRefresh,
183190
refreshStateFlow
184191
)

libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/DashboardWidgetCard.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ fun DashboardWidgetCard(
5656
modifier: Modifier = Modifier,
5757
isLoading: Boolean = false,
5858
useMinWidth: Boolean = true,
59+
onClick: (() -> Unit)? = null,
5960
content: @Composable ColumnScope.() -> Unit
6061
) {
61-
DashboardCard(modifier) {
62+
DashboardCard(modifier, onClick) {
6263
Column(
6364
modifier = Modifier
6465
.padding(24.dp)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright (C) 2025 - present Instructure, Inc.
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, version 3 of the License.
7+
*
8+
* This program is distributed in the hope that it will be useful,
9+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
* GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License
14+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
15+
*
16+
*/
17+
package com.instructure.horizon.features.dashboard.widget.skilloverview
18+
19+
import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetSkillsManager
20+
import com.instructure.canvasapi2.managers.graphql.horizon.journey.Skill
21+
import javax.inject.Inject
22+
23+
class DashboardSkillOverviewRepository @Inject constructor(
24+
private val getSkillsManager: GetSkillsManager
25+
) {
26+
suspend fun getSkills(completedOnly: Boolean?, forceNetwork: Boolean): List<Skill> {
27+
return getSkillsManager.getSkills(completedOnly = completedOnly, forceNetwork = forceNetwork)
28+
}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright (C) 2025 - present Instructure, Inc.
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, version 3 of the License.
7+
*
8+
* This program is distributed in the hope that it will be useful,
9+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
* GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License
14+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
15+
*
16+
*/
17+
package com.instructure.horizon.features.dashboard.widget.skilloverview
18+
19+
import com.instructure.horizon.features.dashboard.DashboardItemState
20+
import com.instructure.horizon.features.dashboard.widget.skilloverview.card.DashboardSkillOverviewCardState
21+
22+
data class DashboardSkillOverviewUiState(
23+
val state: DashboardItemState = DashboardItemState.LOADING,
24+
val cardState: DashboardSkillOverviewCardState = DashboardSkillOverviewCardState(),
25+
val onRefresh: (() -> Unit) -> Unit = {}
26+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright (C) 2025 - present Instructure, Inc.
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, version 3 of the License.
7+
*
8+
* This program is distributed in the hope that it will be useful,
9+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
* GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License
14+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
15+
*
16+
*/
17+
package com.instructure.horizon.features.dashboard.widget.skilloverview
18+
19+
import androidx.lifecycle.ViewModel
20+
import androidx.lifecycle.viewModelScope
21+
import com.instructure.canvasapi2.utils.weave.catch
22+
import com.instructure.canvasapi2.utils.weave.tryLaunch
23+
import com.instructure.horizon.features.dashboard.DashboardItemState
24+
import com.instructure.horizon.features.dashboard.widget.skilloverview.card.DashboardSkillOverviewCardState
25+
import dagger.hilt.android.lifecycle.HiltViewModel
26+
import kotlinx.coroutines.flow.MutableStateFlow
27+
import kotlinx.coroutines.flow.asStateFlow
28+
import kotlinx.coroutines.flow.update
29+
import javax.inject.Inject
30+
31+
@HiltViewModel
32+
class DashboardSkillOverviewViewModel @Inject constructor(
33+
private val repository: DashboardSkillOverviewRepository
34+
) : ViewModel() {
35+
36+
private val _uiState = MutableStateFlow(
37+
DashboardSkillOverviewUiState(
38+
onRefresh = ::refresh
39+
)
40+
)
41+
val uiState = _uiState.asStateFlow()
42+
43+
init {
44+
viewModelScope.tryLaunch {
45+
loadSkillOverviewData()
46+
} catch {
47+
_uiState.update { it.copy(state = DashboardItemState.ERROR) }
48+
}
49+
}
50+
51+
private suspend fun loadSkillOverviewData(forceNetwork: Boolean = false) {
52+
_uiState.update { it.copy(state = DashboardItemState.LOADING) }
53+
val skills = repository.getSkills(completedOnly = true, forceNetwork = forceNetwork)
54+
55+
val completedSkillCount = skills.size
56+
57+
_uiState.update {
58+
it.copy(
59+
state = DashboardItemState.SUCCESS,
60+
cardState = DashboardSkillOverviewCardState(
61+
completedSkillCount = completedSkillCount
62+
)
63+
)
64+
}
65+
}
66+
67+
private fun refresh(onComplete: () -> Unit) {
68+
viewModelScope.tryLaunch {
69+
_uiState.update { it.copy(state = DashboardItemState.LOADING) }
70+
loadSkillOverviewData(forceNetwork = true)
71+
_uiState.update { it.copy(state = DashboardItemState.SUCCESS) }
72+
onComplete()
73+
} catch {
74+
_uiState.update { it.copy(state = DashboardItemState.ERROR) }
75+
onComplete()
76+
}
77+
}
78+
}

0 commit comments

Comments
 (0)