diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeJourneyApiManager.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeJourneyApiManager.kt index 360dd74478..ae1c4ffdfe 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeJourneyApiManager.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/fakes/FakeJourneyApiManager.kt @@ -18,9 +18,11 @@ package com.instructure.canvas.espresso.mockcanvas.fakes import com.instructure.canvas.espresso.mockcanvas.MockCanvas import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetProgramsManager +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetSkillsManager import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetWidgetsManager import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Skill import com.instructure.canvasapi2.utils.DataResult import com.instructure.journey.GetWidgetDataQuery import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus @@ -103,4 +105,29 @@ class FakeGetWidgetsManager : GetWidgetsManager { ) ) } +} + +class FakeGetSkillsManager: GetSkillsManager { + override suspend fun getSkills( + completedOnly: Boolean?, + forceNetwork: Boolean + ): List { + return listOf( + Skill( + id = "1", + name = "Skill 1", + proficiencyLevel = "beginner", + createdAt = null, + updatedAt = null + ), + Skill( + id = "2", + name = "Skill 2", + proficiencyLevel = "expert", + createdAt = null, + updatedAt = null + ) + ) + } + } \ No newline at end of file diff --git a/libs/canvas-api-2/src/main/graphql/com/instructure/journey/GetSkills.graphql b/libs/canvas-api-2/src/main/graphql/com/instructure/journey/GetSkills.graphql new file mode 100644 index 0000000000..d06b41aa9d --- /dev/null +++ b/libs/canvas-api-2/src/main/graphql/com/instructure/journey/GetSkills.graphql @@ -0,0 +1,9 @@ +query GetSkills($completedOnly: Boolean) { + skills(completedOnly: $completedOnly) { + id + name + proficiencyLevel + createdAt + updatedAt + } +} diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/graphql/JourneyModule.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/graphql/JourneyModule.kt index 922cdd8b82..ded9706cb1 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/graphql/JourneyModule.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/graphql/JourneyModule.kt @@ -20,6 +20,8 @@ import com.apollographql.apollo.ApolloClient import com.instructure.canvasapi2.di.JourneyApolloClient import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetProgramManagerImpl import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetProgramsManager +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetSkillsManager +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetSkillsManagerImpl import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetWidgetsManager import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetWidgetsManagerImpl import dagger.Module @@ -44,4 +46,11 @@ class JourneyModule { return GetProgramManagerImpl(journeyClient) } + @Provides + fun provideSkillsManager( + @JourneyApolloClient journeyClient: ApolloClient + ): GetSkillsManager { + return GetSkillsManagerImpl(journeyClient) + } + } \ No newline at end of file diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/graphql/horizon/journey/GetSkillsManager.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/graphql/horizon/journey/GetSkillsManager.kt new file mode 100644 index 0000000000..f4bcc4b26a --- /dev/null +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/graphql/horizon/journey/GetSkillsManager.kt @@ -0,0 +1,62 @@ +/* + * 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.canvasapi2.managers.graphql.horizon.journey + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.Optional +import com.instructure.canvasapi2.enqueueQuery +import com.instructure.journey.GetSkillsQuery +import java.util.Date +import javax.inject.Inject + +data class Skill( + val id: String, + val name: String, + val proficiencyLevel: String?, + val createdAt: Date?, + val updatedAt: Date? +) + +interface GetSkillsManager { + suspend fun getSkills(completedOnly: Boolean?, forceNetwork: Boolean): List +} + +class GetSkillsManagerImpl @Inject constructor( + private val journeyClient: ApolloClient +) : GetSkillsManager { + override suspend fun getSkills( + completedOnly: Boolean?, + forceNetwork: Boolean + ): List { + val query = GetSkillsQuery( + completedOnly = Optional.presentIfNotNull(completedOnly) + ) + + val result = journeyClient.enqueueQuery(query, forceNetwork) + val skills = result.dataAssertNoErrors.skills + + return skills.map { skill -> + Skill( + id = skill.id, + name = skill.name, + proficiencyLevel = skill.proficiencyLevel, + createdAt = skill.createdAt, + updatedAt = skill.updatedAt + ) + } + } +} diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/dashboard/HorizonDashboardInteractionTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/dashboard/HorizonDashboardInteractionTest.kt index c5da472d1d..bfa6d7fa94 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/dashboard/HorizonDashboardInteractionTest.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/dashboard/HorizonDashboardInteractionTest.kt @@ -21,12 +21,14 @@ import com.instructure.canvas.espresso.mockcanvas.addItemToModule import com.instructure.canvas.espresso.mockcanvas.addModuleToCourse import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetHorizonCourseManager import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetProgramsManager +import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetSkillsManager import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetWidgetsManager import com.instructure.canvas.espresso.mockcanvas.init import com.instructure.canvasapi2.di.graphql.GetCoursesModule import com.instructure.canvasapi2.di.graphql.JourneyModule import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetProgramsManager +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetSkillsManager import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetWidgetsManager import com.instructure.canvasapi2.models.Page import com.instructure.horizon.espresso.HorizonTest @@ -42,6 +44,7 @@ class HorizonDashboardInteractionTest: HorizonTest() { private val fakeGetHorizonCourseManager = FakeGetHorizonCourseManager() private val fakeGetProgramsManager = FakeGetProgramsManager() private val fakeGetWidgetsManager = FakeGetWidgetsManager() + private val fakeGetSkillsManager = FakeGetSkillsManager() @BindValue @JvmField @@ -51,6 +54,10 @@ class HorizonDashboardInteractionTest: HorizonTest() { @JvmField val getWidgetsManager: GetWidgetsManager = fakeGetWidgetsManager + @BindValue + @JvmField + val getSkillsManager: GetSkillsManager = fakeGetSkillsManager + @BindValue @JvmField val getCoursesManager: HorizonGetCoursesManager = fakeGetHorizonCourseManager diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/home/HorizonHomeInteractionTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/home/HorizonHomeInteractionTest.kt index 215c1422e9..dc61631c0d 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/home/HorizonHomeInteractionTest.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/home/HorizonHomeInteractionTest.kt @@ -19,12 +19,14 @@ package com.instructure.horizon.interaction.features.home import com.instructure.canvas.espresso.mockcanvas.MockCanvas import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetHorizonCourseManager import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetProgramsManager +import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetSkillsManager import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetWidgetsManager import com.instructure.canvas.espresso.mockcanvas.init import com.instructure.canvasapi2.di.graphql.GetCoursesModule import com.instructure.canvasapi2.di.graphql.JourneyModule import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetProgramsManager +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetSkillsManager import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetWidgetsManager import com.instructure.horizon.espresso.HorizonTest import com.instructure.horizon.pages.HorizonHomePage @@ -39,6 +41,7 @@ class HorizonHomeInteractionTest : HorizonTest() { private val fakeGetHorizonCourseManager = FakeGetHorizonCourseManager() private val fakeGetProgramsManager = FakeGetProgramsManager() private val fakeGetWidgetsManager = FakeGetWidgetsManager() + private val fakeGetSkillsManager = FakeGetSkillsManager() @BindValue @JvmField @@ -48,6 +51,10 @@ class HorizonHomeInteractionTest : HorizonTest() { @JvmField val getWidgetsManager: GetWidgetsManager = fakeGetWidgetsManager + @BindValue + @JvmField + val getSkillsManager: GetSkillsManager = fakeGetSkillsManager + @BindValue @JvmField val getCoursesManager: HorizonGetCoursesManager = fakeGetHorizonCourseManager diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/notification/HorizonNotificationInteractionTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/notification/HorizonNotificationInteractionTest.kt index 4bc85f3a70..18a6af0362 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/notification/HorizonNotificationInteractionTest.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/interaction/features/notification/HorizonNotificationInteractionTest.kt @@ -20,12 +20,14 @@ import com.instructure.canvas.espresso.mockcanvas.MockCanvas import com.instructure.canvas.espresso.mockcanvas.addAccountNotification import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetHorizonCourseManager import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetProgramsManager +import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetSkillsManager import com.instructure.canvas.espresso.mockcanvas.fakes.FakeGetWidgetsManager import com.instructure.canvas.espresso.mockcanvas.init import com.instructure.canvasapi2.di.graphql.GetCoursesModule import com.instructure.canvasapi2.di.graphql.JourneyModule import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCoursesManager import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetProgramsManager +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetSkillsManager import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetWidgetsManager import com.instructure.horizon.espresso.HorizonTest import dagger.hilt.android.testing.BindValue @@ -39,6 +41,7 @@ class HorizonNotificationInteractionTest: HorizonTest() { private val fakeGetHorizonCourseManager = FakeGetHorizonCourseManager() private val fakeGetProgramsManager = FakeGetProgramsManager() private val fakeGetWidgetsManager = FakeGetWidgetsManager() + private val fakeGetSkillsManager = FakeGetSkillsManager() @BindValue @JvmField @@ -48,6 +51,10 @@ class HorizonNotificationInteractionTest: HorizonTest() { @JvmField val getWidgetsManager: GetWidgetsManager = fakeGetWidgetsManager + @BindValue + @JvmField + val getSkillsManager: GetSkillsManager = fakeGetSkillsManager + @BindValue @JvmField val getCoursesManager: HorizonGetCoursesManager = fakeGetHorizonCourseManager diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsWidgetUiTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsWidgetUiTest.kt new file mode 100644 index 0000000000..97c9559d1a --- /dev/null +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsWidgetUiTest.kt @@ -0,0 +1,182 @@ +package com.instructure.horizon.ui.features.dashboard.widget.skillhighlights + +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.skillhighlights.DashboardSkillHighlightsSection +import com.instructure.horizon.features.dashboard.widget.skillhighlights.DashboardSkillHighlightsUiState +import com.instructure.horizon.features.dashboard.widget.skillhighlights.card.DashboardSkillHighlightsCardState +import com.instructure.horizon.features.dashboard.widget.skillhighlights.card.SkillHighlight +import com.instructure.horizon.features.dashboard.widget.skillhighlights.card.SkillHighlightProficiencyLevel +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DashboardSkillHighlightsWidgetUiTest { + @get:Rule + val composeTestRule = createComposeRule() + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun testLoadingStateDisplaysCorrectly() { + val uiState = DashboardSkillHighlightsUiState( + state = DashboardItemState.LOADING + ) + + composeTestRule.setContent { + DashboardSkillHighlightsSection(uiState, rememberNavController()) + } + + val title = context.getString(R.string.dashboardSkillHighlightsTitle) + composeTestRule.onNodeWithText(title).assertIsDisplayed() + } + + @Test + fun testErrorStateDisplaysCorrectly() { + val uiState = DashboardSkillHighlightsUiState( + state = DashboardItemState.ERROR, + onRefresh = { it() } + ) + + composeTestRule.setContent { + DashboardSkillHighlightsSection(uiState, rememberNavController()) + } + + val title = context.getString(R.string.dashboardSkillHighlightsTitle) + val errorTitle = context.getString(R.string.dashboardSkillHighlightsErrorTitle) + val errorMessage = context.getString(R.string.dashboardSkillHighlightsErrorMessage) + val retryLabel = context.getString(R.string.dashboardSkillHighlightsRetry) + + composeTestRule.onNodeWithText(title).assertIsDisplayed() + composeTestRule.onNodeWithText(errorTitle).assertIsDisplayed() + composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed() + composeTestRule.onNodeWithText(retryLabel).assertIsDisplayed() + } + + @Test + fun testErrorStateRefreshButtonWorks() { + var refreshCalled = false + val uiState = DashboardSkillHighlightsUiState( + state = DashboardItemState.ERROR, + onRefresh = { refreshCalled = true; it() } + ) + + composeTestRule.setContent { + DashboardSkillHighlightsSection(uiState, rememberNavController()) + } + + val retryLabel = context.getString(R.string.dashboardSkillHighlightsRetry) + composeTestRule.onNodeWithText(retryLabel).performClick() + + assert(refreshCalled) + } + + @Test + fun testNoDataStateDisplaysCorrectly() { + val uiState = DashboardSkillHighlightsUiState( + state = DashboardItemState.SUCCESS, + cardState = DashboardSkillHighlightsCardState(skills = emptyList()) + ) + + composeTestRule.setContent { + DashboardSkillHighlightsSection(uiState, rememberNavController()) + } + + val title = context.getString(R.string.dashboardSkillHighlightsTitle) + val noDataTitle = context.getString(R.string.dashboardSkillHighlightsNoDataTitle) + val noDataMessage = context.getString(R.string.dashboardSkillHighlightsNoDataMessage) + + composeTestRule.onNodeWithText(title).assertIsDisplayed() + composeTestRule.onNodeWithText(noDataTitle).assertIsDisplayed() + composeTestRule.onNodeWithText(noDataMessage).assertIsDisplayed() + } + + @Test + fun testSuccessStateWithSkillsDisplaysCorrectly() { + val skills = listOf( + SkillHighlight("Advanced JavaScript", SkillHighlightProficiencyLevel.ADVANCED), + SkillHighlight("Python Programming", SkillHighlightProficiencyLevel.PROFICIENT), + SkillHighlight("Data Analysis", SkillHighlightProficiencyLevel.BEGINNER) + ) + val uiState = DashboardSkillHighlightsUiState( + state = DashboardItemState.SUCCESS, + cardState = DashboardSkillHighlightsCardState(skills = skills) + ) + + composeTestRule.setContent { + DashboardSkillHighlightsSection(uiState, rememberNavController()) + } + + val title = context.getString(R.string.dashboardSkillHighlightsTitle) + val advancedLabel = context.getString(R.string.dashboardSkillProficienyLevelAdvanced) + val proficientLabel = context.getString(R.string.dashboardSkillProficienyLevelProficient) + val beginnerLabel = context.getString(R.string.dashboardSkillProficienyLevelBeginner) + + composeTestRule.onNodeWithText(title).assertIsDisplayed() + composeTestRule.onNodeWithText("Advanced JavaScript").assertIsDisplayed() + composeTestRule.onNodeWithText("Python Programming").assertIsDisplayed() + composeTestRule.onNodeWithText("Data Analysis").assertIsDisplayed() + composeTestRule.onNodeWithText(advancedLabel).assertIsDisplayed() + composeTestRule.onNodeWithText(proficientLabel).assertIsDisplayed() + composeTestRule.onNodeWithText(beginnerLabel).assertIsDisplayed() + } + + @Test + fun testAllProficiencyLevelsDisplay() { + val skills = listOf( + SkillHighlight("Expert Skill", SkillHighlightProficiencyLevel.EXPERT), + SkillHighlight("Advanced Skill", SkillHighlightProficiencyLevel.ADVANCED), + SkillHighlight("Proficient Skill", SkillHighlightProficiencyLevel.PROFICIENT) + ) + val uiState = DashboardSkillHighlightsUiState( + state = DashboardItemState.SUCCESS, + cardState = DashboardSkillHighlightsCardState(skills = skills) + ) + + composeTestRule.setContent { + DashboardSkillHighlightsSection(uiState, rememberNavController()) + } + + val expertLabel = context.getString(R.string.dashboardSkillProficienyLevelExpert) + val advancedLabel = context.getString(R.string.dashboardSkillProficienyLevelAdvanced) + val proficientLabel = context.getString(R.string.dashboardSkillProficienyLevelProficient) + + composeTestRule.onNodeWithText(expertLabel).assertIsDisplayed() + composeTestRule.onNodeWithText(advancedLabel).assertIsDisplayed() + composeTestRule.onNodeWithText(proficientLabel).assertIsDisplayed() + } + + @Test + fun testLongSkillNameIsDisplayed() { + val skills = listOf( + SkillHighlight( + "This is a very long skill name that should be displayed correctly in the UI", + SkillHighlightProficiencyLevel.ADVANCED + ), + SkillHighlight("Short Skill", SkillHighlightProficiencyLevel.PROFICIENT), + SkillHighlight("Medium Length Skill Name", SkillHighlightProficiencyLevel.BEGINNER) + ) + val uiState = DashboardSkillHighlightsUiState( + state = DashboardItemState.SUCCESS, + cardState = DashboardSkillHighlightsCardState(skills = skills) + ) + + composeTestRule.setContent { + DashboardSkillHighlightsSection(uiState, rememberNavController()) + } + + // Text will be truncated but should still be findable by partial match + composeTestRule.onNodeWithText( + "This is a very long skill name that should be displayed correctly in the UI", + substring = true + ).assertIsDisplayed() + } +} \ No newline at end of file diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/timespent/DashboardTimeSpentWidgetUiTest.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/timespent/DashboardTimeSpentWidgetUiTest.kt index b248f6ced6..3e5e634f76 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/timespent/DashboardTimeSpentWidgetUiTest.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/ui/features/dashboard/widget/timespent/DashboardTimeSpentWidgetUiTest.kt @@ -254,7 +254,6 @@ class DashboardTimeSpentWidgetUiTest { @Test fun testCourseDropdownInteraction() { - var selectedCourseName: String? = null val state = DashboardTimeSpentUiState( state = DashboardItemState.SUCCESS, cardState = DashboardTimeSpentCardState( @@ -264,9 +263,7 @@ class DashboardTimeSpentWidgetUiTest { CourseOption(id = 2L, name = "Data Structures") ), selectedCourseId = null, - onCourseSelected = { courseName -> - selectedCourseName = courseName - } + onCourseSelected = { } ) ) 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 636906d8ee..fd0a38d784 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 @@ -26,6 +26,7 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding @@ -64,6 +65,7 @@ import com.instructure.canvasapi2.utils.ContextKeeper 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.timespent.DashboardTimeSpentWidget import com.instructure.horizon.horizonui.animation.shimmerEffect import com.instructure.horizon.horizonui.foundation.HorizonColors @@ -147,6 +149,7 @@ fun DashboardScreen(uiState: DashboardUiState, mainNavController: NavHostControl } ){ Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .padding(paddingValues) .verticalScroll(rememberScrollState()) @@ -166,6 +169,7 @@ fun DashboardScreen(uiState: DashboardUiState, mainNavController: NavHostControl Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier + .fillMaxWidth() .horizontalScroll(rememberScrollState()) .padding(start = 16.dp) ) { @@ -180,6 +184,11 @@ fun DashboardScreen(uiState: DashboardUiState, mainNavController: NavHostControl ) Spacer(modifier = Modifier.width(16.dp)) } + DashboardSkillHighlightsWidget( + 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 e5e4e38b6e..813cfd1928 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 @@ -46,6 +46,7 @@ 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.pandautils.compose.modifiers.conditional @Composable fun DashboardWidgetCard( @@ -54,13 +55,16 @@ fun DashboardWidgetCard( widgetColor: Color, modifier: Modifier = Modifier, isLoading: Boolean = false, + useMinWidth: Boolean = true, content: @Composable ColumnScope.() -> Unit ) { DashboardCard(modifier) { Column( modifier = Modifier .padding(24.dp) - .width(IntrinsicSize.Min) + .conditional(useMinWidth) { + width(IntrinsicSize.Min) + } ) { Row( horizontalArrangement = Arrangement.SpaceBetween, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsRepository.kt new file mode 100644 index 0000000000..7af3211741 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsRepository.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.skillhighlights + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.GetSkillsManager +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Skill +import javax.inject.Inject + +class DashboardSkillHighlightsRepository @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/skillhighlights/DashboardSkillHighlightsUiState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsUiState.kt new file mode 100644 index 0000000000..e5e08d0e3e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsUiState.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.skillhighlights + +import com.instructure.horizon.features.dashboard.DashboardItemState +import com.instructure.horizon.features.dashboard.widget.skillhighlights.card.DashboardSkillHighlightsCardState + +data class DashboardSkillHighlightsUiState( + val state: DashboardItemState = DashboardItemState.LOADING, + val cardState: DashboardSkillHighlightsCardState = DashboardSkillHighlightsCardState(), + val onRefresh: (() -> Unit) -> Unit = {} +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsViewModel.kt new file mode 100644 index 0000000000..d7d43ac40a --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsViewModel.kt @@ -0,0 +1,96 @@ +/* + * 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.skillhighlights + +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.skillhighlights.card.DashboardSkillHighlightsCardState +import com.instructure.horizon.features.dashboard.widget.skillhighlights.card.SkillHighlight +import com.instructure.horizon.features.dashboard.widget.skillhighlights.card.SkillHighlightProficiencyLevel +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 DashboardSkillHighlightsViewModel @Inject constructor( + private val repository: DashboardSkillHighlightsRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow( + DashboardSkillHighlightsUiState( + onRefresh = ::refresh + ) + ) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.tryLaunch{ + loadSkillsData() + } catch { + _uiState.update { it.copy(state = DashboardItemState.ERROR) } + } + } + + private suspend fun loadSkillsData(forceNetwork: Boolean = false) { + _uiState.update { it.copy(state = DashboardItemState.LOADING) } + val skills = repository.getSkills(completedOnly = true, forceNetwork = forceNetwork) + + val topSkills = skills + .map { skill -> + SkillHighlight( + name = skill.name, + proficiencyLevel = SkillHighlightProficiencyLevel.fromString(skill.proficiencyLevel) + ?: SkillHighlightProficiencyLevel.BEGINNER + ) + } + .sortedWith( + compareByDescending { it.proficiencyLevel.levelOrder } + .thenBy { it.name } + ) + .take(3) + + if (topSkills.size < 3) { + _uiState.update { it.copy(state = DashboardItemState.SUCCESS) } + } else { + _uiState.update { + it.copy( + state = DashboardItemState.SUCCESS, + cardState = DashboardSkillHighlightsCardState( + skills = topSkills + ) + ) + } + } + } + + private fun refresh(onComplete: () -> Unit) { + viewModelScope.tryLaunch { + _uiState.update { it.copy(state = DashboardItemState.LOADING) } + loadSkillsData(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/skillhighlights/DashboardSkillHighlightsWidget.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsWidget.kt new file mode 100644 index 0000000000..e16d424163 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsWidget.kt @@ -0,0 +1,84 @@ +/* + * 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.skillhighlights + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavHostController +import com.instructure.horizon.features.dashboard.DashboardItemState +import com.instructure.horizon.features.dashboard.widget.skillhighlights.card.DashboardSkillHighlightsCardContent +import com.instructure.horizon.features.dashboard.widget.skillhighlights.card.DashboardSkillHighlightsCardError +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +@Composable +fun DashboardSkillHighlightsWidget( + 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 } + } + } + } + + DashboardSkillHighlightsSection(state, homeNavController) +} + +@Composable +fun DashboardSkillHighlightsSection( + state: DashboardSkillHighlightsUiState, + homeNavController: NavHostController, +) { + when (state.state) { + DashboardItemState.LOADING -> { + DashboardSkillHighlightsCardContent( + state.cardState, + homeNavController, + Modifier.padding(horizontal = 16.dp), + true + ) + } + DashboardItemState.ERROR -> { + DashboardSkillHighlightsCardError( + { state.onRefresh {} }, + Modifier.padding(horizontal = 16.dp) + ) + } + DashboardItemState.SUCCESS -> { + DashboardSkillHighlightsCardContent( + state.cardState, + homeNavController, + Modifier.padding(horizontal = 16.dp), + false + ) + } + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardContent.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardContent.kt new file mode 100644 index 0000000000..c3b411801d --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardContent.kt @@ -0,0 +1,192 @@ +/* + * 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.skillhighlights.card + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.HorizonSpace +import com.instructure.horizon.horizonui.foundation.HorizonTypography +import com.instructure.horizon.horizonui.foundation.SpaceSize +import com.instructure.horizon.horizonui.molecules.Pill +import com.instructure.horizon.horizonui.molecules.PillCase +import com.instructure.horizon.horizonui.molecules.PillSize +import com.instructure.horizon.horizonui.molecules.PillStyle +import com.instructure.horizon.horizonui.molecules.PillType + +@Composable +fun DashboardSkillHighlightsCardContent( + state: DashboardSkillHighlightsCardState, + homeNavController: NavHostController, + modifier: Modifier = Modifier, + isLoading: Boolean = false +) { + DashboardWidgetCard( + title = stringResource(R.string.dashboardSkillHighlightsTitle), + iconRes = R.drawable.hub, + widgetColor = HorizonColors.PrimitivesGreen.green12(), + isLoading = isLoading, + useMinWidth = false, + modifier = modifier + ) { + if (state.skills.isEmpty()) { + Column { + HorizonSpace(SpaceSize.SPACE_8) + Text( + text = stringResource(R.string.dashboardSkillHighlightsNoDataTitle), + style = HorizonTypography.h4, + color = HorizonColors.Text.title(), + modifier = Modifier.shimmerEffect(isLoading) + ) + HorizonSpace(SpaceSize.SPACE_4) + Text( + text = stringResource(R.string.dashboardSkillHighlightsNoDataMessage), + style = HorizonTypography.p2, + color = HorizonColors.Text.timestamp(), + modifier = Modifier.shimmerEffect(isLoading) + ) + } + } else { + HorizonSpace(SpaceSize.SPACE_8) + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + state.skills.forEach { skill -> + SkillCard( + skill, + skill.proficiencyLevel.opacity(), + homeNavController, + modifier = Modifier.shimmerEffect( + isLoading, + backgroundColor = HorizonColors.PrimitivesGreen.green12().copy(alpha = 0.8f), + shimmerColor = HorizonColors.PrimitivesGreen.green12().copy(alpha = 0.5f) + ) + ) + } + } + } + } +} + +@Composable +private fun SkillCard( + skill: SkillHighlight, + opacity: Float, + homeNavController: NavHostController, + modifier: Modifier = Modifier +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(HorizonColors.PrimitivesGreen.green12().copy(alpha = opacity)) + .fillMaxWidth() + .clickable { + homeNavController.navigate(HomeNavigationRoute.Skillspace.route) { + popUpTo(homeNavController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + .padding(24.dp) + ) { + Text( + text = skill.name, + style = HorizonTypography.p2, + color = HorizonColors.Text.body(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Pill( + label = stringResource(skill.proficiencyLevel.skillProficiencyLevelRes), + style = PillStyle.SOLID, + type = PillType.INVERSE, + case = PillCase.TITLE, + size = PillSize.SMALL + ) + } +} + +private fun SkillHighlightProficiencyLevel.opacity(): Float { + return when (this) { + SkillHighlightProficiencyLevel.BEGINNER -> 0.4f + SkillHighlightProficiencyLevel.PROFICIENT -> 0.6f + SkillHighlightProficiencyLevel.ADVANCED -> 0.8f + SkillHighlightProficiencyLevel.EXPERT -> 1f + } +} + +@Composable +@Preview +private fun DashboardSkillHighlightsCardContentPreview() { + ContextKeeper.appContext = LocalContext.current + DashboardSkillHighlightsCardContent( + state = DashboardSkillHighlightsCardState( + skills = listOf( + SkillHighlight("Dolor sit amet adipiscing elit do long skill name", SkillHighlightProficiencyLevel.ADVANCED), + SkillHighlight("Dolor sit skill name", SkillHighlightProficiencyLevel.BEGINNER), + SkillHighlight("Adipiscing elit skill name", SkillHighlightProficiencyLevel.PROFICIENT) + ) + ), + rememberNavController() + ) +} + +@Composable +@Preview +private fun DashboardSkillHighlightsCardContentNoDataPreview() { + ContextKeeper.appContext = LocalContext.current + DashboardSkillHighlightsCardContent( + state = DashboardSkillHighlightsCardState(skills = emptyList()), + rememberNavController() + ) +} + +@Composable +@Preview +private fun DashboardSkillHighlightsLoadingPreview() { + ContextKeeper.appContext = LocalContext.current + DashboardSkillHighlightsCardContent( + state = DashboardSkillHighlightsCardState(skills = emptyList()), + rememberNavController(), + isLoading = true + ) +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardError.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardError.kt new file mode 100644 index 0000000000..ac1d291411 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardError.kt @@ -0,0 +1,80 @@ +/* + * 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.skillhighlights.card + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +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 DashboardSkillHighlightsCardError( + onRetryClick: () -> Unit, + modifier: Modifier = Modifier +) { + DashboardWidgetCard( + title = stringResource(R.string.dashboardSkillHighlightsTitle), + iconRes = R.drawable.hub, + widgetColor = HorizonColors.PrimitivesGreen.green12(), + useMinWidth = false, + modifier = modifier + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + HorizonSpace(SpaceSize.SPACE_8) + Text( + text = stringResource(R.string.dashboardSkillHighlightsErrorTitle), + style = HorizonTypography.h4, + color = HorizonColors.Text.title() + ) + HorizonSpace(SpaceSize.SPACE_4) + Text( + text = stringResource(R.string.dashboardSkillHighlightsErrorMessage), + style = HorizonTypography.p2, + color = HorizonColors.Text.timestamp() + ) + HorizonSpace(SpaceSize.SPACE_16) + Button( + label = stringResource(R.string.dashboardSkillHighlightsRetry), + onClick = onRetryClick, + color = ButtonColor.WhiteWithOutline, + height = ButtonHeight.SMALL, + iconPosition = ButtonIconPosition.End(R.drawable.restart_alt) + ) + } + } +} + +@Composable +@Preview +private fun DashboardSkillHighlightsCardErrorPreview() { + DashboardSkillHighlightsCardError(onRetryClick = {}) +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardState.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardState.kt new file mode 100644 index 0000000000..be8da4680e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/card/DashboardSkillHighlightsCardState.kt @@ -0,0 +1,46 @@ +/* + * 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.skillhighlights.card + +import androidx.annotation.StringRes +import com.instructure.horizon.R + +data class DashboardSkillHighlightsCardState( + val skills: List = emptyList() +) + +data class SkillHighlight( + val name: String, + val proficiencyLevel: SkillHighlightProficiencyLevel +) + +enum class SkillHighlightProficiencyLevel( + @StringRes val skillProficiencyLevelRes: Int, + val apiString: String, + val levelOrder: Int +) { + BEGINNER(R.string.dashboardSkillProficienyLevelBeginner, "beginner", 0), + PROFICIENT(R.string.dashboardSkillProficienyLevelProficient, "proficient", 1), + ADVANCED(R.string.dashboardSkillProficienyLevelAdvanced, "advanced", 2), + EXPERT(R.string.dashboardSkillProficienyLevelExpert, "expert", 3); + + companion object { + fun fromString(level: String?): SkillHighlightProficiencyLevel? { + return SkillHighlightProficiencyLevel.entries.firstOrNull { it.apiString.equals(level, ignoreCase = true) } + } + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/skillspace/SkillspaceRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/skillspace/SkillspaceRepository.kt new file mode 100644 index 0000000000..16be4b3459 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/skillspace/SkillspaceRepository.kt @@ -0,0 +1,41 @@ +/* + * 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.skillspace + +import com.instructure.canvasapi2.apis.OAuthAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.AuthenticatedSession +import com.instructure.canvasapi2.utils.ApiPrefs +import javax.inject.Inject + +class SkillspaceRepository @Inject constructor( + private val oAuthApi: OAuthAPI.OAuthInterface, + private val apiPrefs: ApiPrefs, +) { + suspend fun getAuthenticatedSession(): AuthenticatedSession? { + return oAuthApi.getAuthenticatedSession( + apiPrefs.fullDomain, + RestParams(isForceReadFromNetwork = true) + ).dataOrNull + } + + fun getEmbeddedSkillspaceUrl(): String { + val baseUrl = apiPrefs.fullDomain + val skillspaceUrl = "$baseUrl/career/skillspace" + return "$skillspaceUrl?embedded=true" + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/skillspace/SkillspaceViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/skillspace/SkillspaceViewModel.kt index 564ac354d9..a97846797f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/skillspace/SkillspaceViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/skillspace/SkillspaceViewModel.kt @@ -18,7 +18,6 @@ package com.instructure.horizon.features.skillspace import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.horizonui.platform.LoadingState @@ -30,7 +29,7 @@ import javax.inject.Inject @HiltViewModel class SkillspaceViewModel @Inject constructor( - private val apiPrefs: ApiPrefs + private val repository: SkillspaceRepository, ): ViewModel() { private val _uiState = MutableStateFlow(SkillspaceUiState( loadingState = LoadingState(onRefresh = ::refreshData) @@ -61,12 +60,14 @@ class SkillspaceViewModel @Inject constructor( } } - private fun fetchUrl() { - val baseUrl = apiPrefs.fullDomain.toHorizonUrl() - val skillspaceUrl = "$baseUrl/skillspace" - val embeddedUrl = "$skillspaceUrl?embedded=true" + private suspend fun fetchUrl() { + repository.getAuthenticatedSession()?.sessionUrl?.let { url -> + _uiState.update { it.copy(webviewUrl = url) } + } + repository.getEmbeddedSkillspaceUrl().let { url -> + _uiState.update { it.copy(webviewUrl = url) } + } - _uiState.update { it.copy(webviewUrl = embeddedUrl) } } private fun refreshData() { @@ -88,10 +89,4 @@ class SkillspaceViewModel @Inject constructor( } } } - - private fun String.toHorizonUrl(): String { - return this - .replace("horizon.cd.instructure.com", "dev.cd.canvashorizon.com") - .replace("instructure.com", "canvasforcareer.com") - } } \ No newline at end of file diff --git a/libs/horizon/src/main/res/values/strings.xml b/libs/horizon/src/main/res/values/strings.xml index 893d38c3be..e47894903a 100644 --- a/libs/horizon/src/main/res/values/strings.xml +++ b/libs/horizon/src/main/res/values/strings.xml @@ -374,6 +374,16 @@ hours in hours in your course all courses + Skill Highlights + No data yet + This widget will update once data becomes available. + We weren\'t able to load this content. + Please try again. + Refresh + Beginner + Proficient + Advanced + Expert Congrats! You\'ve completed your course. View your progress and scores on the Learn page. Activities completed diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsRepositoryTest.kt new file mode 100644 index 0000000000..83b81e1666 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsRepositoryTest.kt @@ -0,0 +1,100 @@ +/* + * 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.skillhighlights + +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 DashboardSkillHighlightsRepositoryTest { + private val getSkillsManager: GetSkillsManager = mockk(relaxed = true) + + @Test + fun `Test successful skills retrieval with completedOnly null`() = runTest { + val skills = listOf( + Skill("1", "Advanced Skill", "advanced", Date(), Date()), + Skill("2", "Beginner Skill", "beginner", Date(), Date()), + Skill("3", "Proficient Skill", "proficient", Date(), Date()) + ) + coEvery { getSkillsManager.getSkills(null, false) } returns skills + + val result = getRepository().getSkills(completedOnly = null, forceNetwork = false) + + assertEquals(3, result.size) + assertEquals(skills, result) + coVerify { getSkillsManager.getSkills(null, false) } + } + + @Test + fun `Test successful skills retrieval with completedOnly true`() = runTest { + val skills = listOf( + Skill("1", "Completed Skill", "expert", Date(), Date()) + ) + coEvery { getSkillsManager.getSkills(true, false) } returns skills + + val result = getRepository().getSkills(completedOnly = true, forceNetwork = false) + + assertEquals(1, 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(null, true) } returns skills + + val result = getRepository().getSkills(completedOnly = null, forceNetwork = true) + + assertEquals(1, result.size) + coVerify { getSkillsManager.getSkills(null, true) } + } + + @Test + fun `Test empty skills list is returned correctly`() = runTest { + coEvery { getSkillsManager.getSkills(null, false) } returns emptyList() + + val result = getRepository().getSkills(completedOnly = null, 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(null, false) } returns skills + + val result = getRepository().getSkills(completedOnly = null, forceNetwork = false) + + assertEquals(1, result.size) + assertEquals(null, result[0].proficiencyLevel) + } + + private fun getRepository(): DashboardSkillHighlightsRepository { + return DashboardSkillHighlightsRepository(getSkillsManager) + } +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsViewModelTest.kt new file mode 100644 index 0000000000..2aa9a85321 --- /dev/null +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/dashboard/widget/skillhighlights/DashboardSkillHighlightsViewModelTest.kt @@ -0,0 +1,274 @@ +/* + * 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.skillhighlights + +import com.instructure.canvasapi2.managers.graphql.horizon.journey.Skill +import com.instructure.horizon.features.dashboard.DashboardItemState +import com.instructure.horizon.features.dashboard.widget.skillhighlights.card.SkillHighlightProficiencyLevel +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 DashboardSkillHighlightsViewModelTest { + private val repository: DashboardSkillHighlightsRepository = 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", "Advanced Skill", "advanced", Date(), Date()), + Skill("2", "Beginner Skill", "beginner", Date(), Date()), + Skill("3", "Proficient Skill", "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.skills.size) + coVerify { repository.getSkills(true, false) } + } + + @Test + fun `Test skills are sorted by proficiency level then alphabetically`() = runTest { + val skills = listOf( + Skill("1", "Zebra Skill", "beginner", Date(), Date()), + Skill("2", "Apple Skill", "advanced", Date(), Date()), + Skill("3", "Banana Skill", "expert", Date(), Date()), + Skill("4", "Cherry Skill", "advanced", Date(), Date()) + ) + coEvery { repository.getSkills(true, false) } returns skills + + val viewModel = getViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(3, state.cardState.skills.size) + // Should be: Banana (expert), Apple (advanced), Cherry (advanced) + assertEquals("Banana Skill", state.cardState.skills[0].name) + assertEquals(SkillHighlightProficiencyLevel.EXPERT, state.cardState.skills[0].proficiencyLevel) + assertEquals("Apple Skill", state.cardState.skills[1].name) + assertEquals(SkillHighlightProficiencyLevel.ADVANCED, state.cardState.skills[1].proficiencyLevel) + assertEquals("Cherry Skill", state.cardState.skills[2].name) + assertEquals(SkillHighlightProficiencyLevel.ADVANCED, state.cardState.skills[2].proficiencyLevel) + } + + @Test + fun `Test only top 3 skills are displayed`() = runTest { + val skills = listOf( + Skill("1", "Skill 1", "expert", Date(), Date()), + Skill("2", "Skill 2", "expert", Date(), Date()), + Skill("3", "Skill 3", "advanced", Date(), Date()), + Skill("4", "Skill 4", "advanced", Date(), Date()), + Skill("5", "Skill 5", "beginner", Date(), Date()) + ) + coEvery { repository.getSkills(true, false) } returns skills + + val viewModel = getViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(3, state.cardState.skills.size) + } + + @Test + fun `Test no data state when fewer than 3 skills`() = runTest { + val skills = listOf( + Skill("1", "Skill 1", "expert", Date(), Date()), + Skill("2", "Skill 2", "advanced", Date(), Date()) + ) + coEvery { repository.getSkills(null, false) } returns skills + + val viewModel = getViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(DashboardItemState.SUCCESS, state.state) + assertTrue(state.cardState.skills.isEmpty()) + } + + @Test + fun `Test no data state when no skills`() = runTest { + coEvery { repository.getSkills(null, false) } returns emptyList() + + val viewModel = getViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(DashboardItemState.SUCCESS, state.state) + assertTrue(state.cardState.skills.isEmpty()) + } + + @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()), + Skill("3", "Skill 3", "beginner", 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()), + Skill("3", "Skill 3", "proficient", Date(), Date()) + ) + coEvery { repository.getSkills(null, 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()), + Skill("2", "Skill 2", "advanced", Date(), Date()), + Skill("3", "Skill 3", "proficient", 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 null proficiency level defaults to beginner`() = runTest { + val skills = listOf( + Skill("1", "Skill Without Level", null, Date(), Date()), + Skill("2", "Advanced Skill", "advanced", Date(), Date()), + Skill("3", "Proficient Skill", "proficient", Date(), Date()) + ) + coEvery { repository.getSkills(true, false) } returns skills + + val viewModel = getViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + val skillWithoutLevel = state.cardState.skills.find { it.name == "Skill Without Level" } + assertEquals(SkillHighlightProficiencyLevel.BEGINNER, skillWithoutLevel?.proficiencyLevel) + } + + @Test + fun `Test unknown proficiency level defaults to beginner`() = runTest { + val skills = listOf( + Skill("1", "Unknown Level Skill", "unknown", Date(), Date()), + Skill("2", "Advanced Skill", "advanced", Date(), Date()), + Skill("3", "Proficient Skill", "proficient", Date(), Date()) + ) + coEvery { repository.getSkills(true, false) } returns skills + + val viewModel = getViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + val unknownSkill = state.cardState.skills.find { it.name == "Unknown Level Skill" } + assertEquals(SkillHighlightProficiencyLevel.BEGINNER, unknownSkill?.proficiencyLevel) + } + + @Test + fun `Test proficiency level parsing is case insensitive`() = 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()) + ) + coEvery { repository.getSkills(true, false) } returns skills + + val viewModel = getViewModel() + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(SkillHighlightProficiencyLevel.EXPERT, state.cardState.skills[0].proficiencyLevel) + assertEquals(SkillHighlightProficiencyLevel.ADVANCED, state.cardState.skills[1].proficiencyLevel) + assertEquals(SkillHighlightProficiencyLevel.PROFICIENT, state.cardState.skills[2].proficiencyLevel) + } + + private fun getViewModel(): DashboardSkillHighlightsViewModel { + return DashboardSkillHighlightsViewModel(repository) + } +}