Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
a62ba5e
Time spent widget core
domonkosadam Oct 6, 2025
605890c
Fix time spent widget
domonkosadam Oct 7, 2025
95203dc
Refactor domain service apis
domonkosadam Oct 7, 2025
8b874db
Refactor api
domonkosadam Oct 7, 2025
fbbbb3e
Widget header component
domonkosadam Oct 7, 2025
c923241
UI improvements
domonkosadam Oct 8, 2025
841e172
UI improvements
domonkosadam Oct 8, 2025
02fe151
Add preview
domonkosadam Oct 8, 2025
e5c5f33
Fix course handling
domonkosadam Oct 8, 2025
f4951f2
Merge branch 'master' into CLX-2879-Skill-Highlights-widget
domonkosadam Oct 8, 2025
c7130d6
Implement skill highlights widget
domonkosadam Oct 8, 2025
f7701ea
Fix button style
domonkosadam Oct 8, 2025
e18386b
Implement widget tests
domonkosadam Oct 8, 2025
70f5f8c
Merge branch 'CLX-2848-Time-spent-widget' into CLX-2879-Skill-Highlig…
domonkosadam Oct 8, 2025
380a234
Merge branch 'master' into CLX-2848-Time-spent-widget
domonkosadam Oct 8, 2025
294d7f9
Fix tests
domonkosadam Oct 8, 2025
4f12892
Merge branch 'CLX-2848-Time-spent-widget' into CLX-2879-Skill-Highlig…
domonkosadam Oct 8, 2025
76fa122
Implement tests
domonkosadam Oct 8, 2025
dc16994
Fix tests
domonkosadam Oct 8, 2025
3c55980
Fix skill query parameter
domonkosadam Oct 8, 2025
5c82f8f
Clickable skills
domonkosadam Oct 9, 2025
e113be9
Implement skill overview widget
domonkosadam Oct 9, 2025
95b6ba6
Fix data handling
domonkosadam Oct 15, 2025
b3499ee
Refactor time spent widget unit tests for new API structure
domonkosadam Oct 15, 2025
51f7048
Add horizontal scrollable row for dashboard time spent widget
domonkosadam Oct 15, 2025
749a5d0
Implement empty state
domonkosadam Oct 15, 2025
b5c5ab4
Refactor loading
domonkosadam Oct 15, 2025
aa66c93
Set max width
domonkosadam Oct 15, 2025
2940655
Merge branch 'CLX-2848-Time-spent-widget' into CLX-2879-Skill-Highlig…
domonkosadam Oct 15, 2025
4b70cce
Fix loading ui
domonkosadam Oct 15, 2025
7a19f44
Add preview
domonkosadam Oct 15, 2025
3d7f4c1
Merge branch 'CLX-2879-Skill-Highlights-widget' into CLX-3082-Skill-o…
domonkosadam Oct 15, 2025
eee0d26
Refactor loading
domonkosadam Oct 15, 2025
834cc2f
Fix max width
domonkosadam Oct 16, 2025
47c1f79
Fix state handling
domonkosadam Oct 16, 2025
b5ea50b
Fix finding
domonkosadam Oct 20, 2025
b518dbc
Merge branch 'master' into CLX-3082-Skill-overview-widget
domonkosadam Oct 21, 2025
57ec92c
Fix test
domonkosadam Oct 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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<Skill> {
return getSkillsManager.getSkills(completedOnly = completedOnly, forceNetwork = forceNetwork)
}
}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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 = {}
)
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*
*/
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()
}
}
}
Loading
Loading