diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/DefaultBindingsModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/DefaultBindingsModule.kt
index 0aafd06fe9..14133581b3 100644
--- a/apps/parent/src/main/java/com/instructure/parentapp/di/DefaultBindingsModule.kt
+++ b/apps/parent/src/main/java/com/instructure/parentapp/di/DefaultBindingsModule.kt
@@ -21,6 +21,8 @@ import com.instructure.canvasapi2.utils.pageview.PandataInfo
import com.instructure.pandautils.features.dashboard.edit.EditDashboardRepository
import com.instructure.pandautils.features.dashboard.edit.EditDashboardRouter
import com.instructure.pandautils.features.dashboard.notifications.DashboardRouter
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetBehavior
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetRouter
import com.instructure.pandautils.features.discussion.details.DiscussionDetailsWebViewFragmentBehavior
import com.instructure.pandautils.features.discussion.router.DiscussionRouteHelperRepository
import com.instructure.pandautils.features.discussion.router.DiscussionRouter
@@ -128,4 +130,14 @@ class DefaultBindingsModule {
fun provideSpeedGraderPostPolicyRouter(): SpeedGraderPostPolicyRouter {
throw NotImplementedError()
}
+
+ @Provides
+ fun provideCoursesWidgetRouter(): CoursesWidgetRouter {
+ throw NotImplementedError()
+ }
+
+ @Provides
+ fun provideCoursesWidgetBehavior(): CoursesWidgetBehavior {
+ throw NotImplementedError()
+ }
}
\ No newline at end of file
diff --git a/apps/student/src/main/java/com/instructure/student/di/feature/CoursesWidgetModule.kt b/apps/student/src/main/java/com/instructure/student/di/feature/CoursesWidgetModule.kt
new file mode 100644
index 0000000000..2c926ac29b
--- /dev/null
+++ b/apps/student/src/main/java/com/instructure/student/di/feature/CoursesWidgetModule.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.student.di.feature
+
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetBehavior
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetRouter
+import com.instructure.student.features.dashboard.widget.courses.StudentCoursesWidgetBehavior
+import com.instructure.student.features.dashboard.widget.courses.StudentCoursesWidgetRouter
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+
+@Module
+@InstallIn(ViewModelComponent::class)
+class CoursesWidgetModule {
+
+ @Provides
+ fun provideCoursesWidgetRouter(): CoursesWidgetRouter {
+ return StudentCoursesWidgetRouter()
+ }
+
+ @Provides
+ fun provideCoursesWidgetBehavior(
+ studentCoursesWidgetBehavior: StudentCoursesWidgetBehavior
+ ): CoursesWidgetBehavior {
+ return studentCoursesWidgetBehavior
+ }
+}
\ No newline at end of file
diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardFragment.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardFragment.kt
index 5e17ba3868..1b029125db 100644
--- a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardFragment.kt
+++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardFragment.kt
@@ -25,6 +25,8 @@ import com.instructure.canvasapi2.models.CanvasContext
import com.instructure.interactions.router.Route
import com.instructure.pandautils.compose.CanvasTheme
import com.instructure.pandautils.features.dashboard.notifications.DashboardRouter
+import com.instructure.pandautils.utils.ThemePrefs
+import com.instructure.pandautils.utils.ViewStyler
import com.instructure.student.fragment.ParentFragment
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@@ -40,7 +42,6 @@ class DashboardFragment : ParentFragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
- applyTheme()
return ComposeView(requireContext()).apply {
setContent {
CanvasTheme {
@@ -50,10 +51,16 @@ class DashboardFragment : ParentFragment() {
}
}
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ applyTheme()
+ }
+
override fun title(): String = ""
override fun applyTheme() {
navigation?.attachNavigationDrawer(this, null)
+ ViewStyler.setStatusBarDark(requireActivity(), ThemePrefs.primaryColor)
}
companion object {
diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardScreen.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardScreen.kt
index e4dfb14d3f..917b7ff6aa 100644
--- a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardScreen.kt
+++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardScreen.kt
@@ -59,8 +59,9 @@ import com.instructure.pandautils.compose.composables.Loading
import com.instructure.pandautils.features.dashboard.notifications.DashboardRouter
import com.instructure.pandautils.features.dashboard.widget.WidgetMetadata
import com.instructure.pandautils.features.dashboard.widget.courseinvitation.CourseInvitationsWidget
-import com.instructure.pandautils.features.dashboard.widget.welcome.WelcomeWidget
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidget
import com.instructure.pandautils.features.dashboard.widget.institutionalannouncements.InstitutionalAnnouncementsWidget
+import com.instructure.pandautils.features.dashboard.widget.welcome.WelcomeWidget
import com.instructure.student.R
import com.instructure.student.activity.NavigationActivity
import kotlinx.coroutines.flow.SharedFlow
@@ -110,7 +111,7 @@ fun DashboardScreenContent(
}
Scaffold(
- modifier = Modifier.background(colorResource(R.color.backgroundLightest)),
+ modifier = Modifier.background(colorResource(R.color.backgroundLight)),
topBar = {
CanvasThemedAppBar(
title = stringResource(id = R.string.dashboard),
@@ -125,7 +126,7 @@ fun DashboardScreenContent(
) { paddingValues ->
Box(
modifier = Modifier
- .background(colorResource(R.color.backgroundLightest))
+ .background(colorResource(R.color.backgroundLight))
.padding(paddingValues)
.pullRefresh(pullRefreshState)
.fillMaxSize()
@@ -230,6 +231,7 @@ private fun GetWidgetComposable(
) {
return when (widgetId) {
WidgetMetadata.WIDGET_ID_WELCOME -> WelcomeWidget(refreshSignal = refreshSignal)
+ WidgetMetadata.WIDGET_ID_COURSES -> CoursesWidget(refreshSignal = refreshSignal, columns = columns)
WidgetMetadata.WIDGET_ID_COURSE_INVITATIONS -> CourseInvitationsWidget(
refreshSignal = refreshSignal,
columns = columns,
diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/widget/courses/ObserveColorOverlayUseCase.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/widget/courses/ObserveColorOverlayUseCase.kt
new file mode 100644
index 0000000000..7b1a838503
--- /dev/null
+++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/widget/courses/ObserveColorOverlayUseCase.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.student.features.dashboard.widget.courses
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.instructure.pandautils.domain.usecase.BaseFlowUseCase
+import com.instructure.student.util.StudentPrefs
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import javax.inject.Inject
+
+class ObserveColorOverlayUseCase @Inject constructor(
+ @ApplicationContext private val context: Context
+) : BaseFlowUseCase() {
+
+ override fun execute(params: Unit): Flow = callbackFlow {
+ val sharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ if (key == KEY_HIDE_COURSE_COLOR_OVERLAY) {
+ trySend(!StudentPrefs.hideCourseColorOverlay)
+ }
+ }
+
+ sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
+ send(!StudentPrefs.hideCourseColorOverlay)
+
+ awaitClose {
+ sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
+ }
+ }
+
+ companion object {
+ private const val PREFS_NAME = "candroidSP"
+ private const val KEY_HIDE_COURSE_COLOR_OVERLAY = "hideCourseColorOverlay"
+ }
+}
\ No newline at end of file
diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/widget/courses/ObserveGradeVisibilityUseCase.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/widget/courses/ObserveGradeVisibilityUseCase.kt
new file mode 100644
index 0000000000..5a94a9af7b
--- /dev/null
+++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/widget/courses/ObserveGradeVisibilityUseCase.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.student.features.dashboard.widget.courses
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.instructure.pandautils.domain.usecase.BaseFlowUseCase
+import com.instructure.student.util.StudentPrefs
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import javax.inject.Inject
+
+class ObserveGradeVisibilityUseCase @Inject constructor(
+ @ApplicationContext private val context: Context
+) : BaseFlowUseCase() {
+
+ override fun execute(params: Unit): Flow = callbackFlow {
+ val sharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
+ if (key == KEY_SHOW_GRADES_ON_CARD) {
+ trySend(StudentPrefs.showGradesOnCard)
+ }
+ }
+
+ sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
+ send(StudentPrefs.showGradesOnCard)
+
+ awaitClose {
+ sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
+ }
+ }
+
+ companion object {
+ private const val PREFS_NAME = "candroidSP"
+ private const val KEY_SHOW_GRADES_ON_CARD = "showGradesOnCard"
+ }
+}
\ No newline at end of file
diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/widget/courses/StudentCoursesWidgetBehavior.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/widget/courses/StudentCoursesWidgetBehavior.kt
new file mode 100644
index 0000000000..abed27a11c
--- /dev/null
+++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/widget/courses/StudentCoursesWidgetBehavior.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.student.features.dashboard.widget.courses
+
+import androidx.fragment.app.FragmentActivity
+import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.models.Group
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetBehavior
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetRouter
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class StudentCoursesWidgetBehavior @Inject constructor(
+ private val observeGradeVisibilityUseCase: ObserveGradeVisibilityUseCase,
+ private val observeColorOverlayUseCase: ObserveColorOverlayUseCase,
+ private val router: CoursesWidgetRouter
+) : CoursesWidgetBehavior {
+
+ override fun observeGradeVisibility(): Flow {
+ return observeGradeVisibilityUseCase(Unit)
+ }
+
+ override fun observeColorOverlay(): Flow {
+ return observeColorOverlayUseCase(Unit)
+ }
+
+ override fun onCourseClick(activity: FragmentActivity, course: Course) {
+ router.routeToCourse(activity, course)
+ }
+
+ override fun onGroupClick(activity: FragmentActivity, group: Group) {
+ router.routeToGroup(activity, group)
+ }
+
+ override fun onManageOfflineContent(activity: FragmentActivity, course: Course) {
+ router.routeToManageOfflineContent(activity, course)
+ }
+
+ override fun onCustomizeCourse(activity: FragmentActivity, course: Course) {
+ router.routeToCustomizeCourse(activity, course)
+ }
+
+ override fun onAllCoursesClicked(activity: FragmentActivity) {
+ router.routeToAllCourses(activity)
+ }
+}
\ No newline at end of file
diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/widget/courses/StudentCoursesWidgetRouter.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/widget/courses/StudentCoursesWidgetRouter.kt
new file mode 100644
index 0000000000..0d15154aaa
--- /dev/null
+++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/widget/courses/StudentCoursesWidgetRouter.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.student.features.dashboard.widget.courses
+
+import androidx.fragment.app.FragmentActivity
+import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.models.Group
+import com.instructure.pandautils.features.dashboard.edit.EditDashboardFragment
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetRouter
+import com.instructure.student.features.coursebrowser.CourseBrowserFragment
+import com.instructure.student.router.RouteMatcher
+
+class StudentCoursesWidgetRouter : CoursesWidgetRouter {
+
+ override fun routeToCourse(activity: FragmentActivity, course: Course) {
+ RouteMatcher.route(activity, CourseBrowserFragment.makeRoute(course))
+ }
+
+ override fun routeToGroup(activity: FragmentActivity, group: Group) {
+ RouteMatcher.route(activity, CourseBrowserFragment.makeRoute(group))
+ }
+
+ override fun routeToManageOfflineContent(activity: FragmentActivity, course: Course) {
+ // TODO: Navigate to manage offline content screen
+ }
+
+ override fun routeToCustomizeCourse(activity: FragmentActivity, course: Course) {
+ // TODO: Navigate to customize course screen (color/nickname)
+ }
+
+ override fun routeToAllCourses(activity: FragmentActivity) {
+ RouteMatcher.route(activity, EditDashboardFragment.makeRoute())
+ }
+}
\ No newline at end of file
diff --git a/apps/student/src/test/java/com/instructure/student/features/dashboard/widget/courses/ObserveColorOverlayUseCaseTest.kt b/apps/student/src/test/java/com/instructure/student/features/dashboard/widget/courses/ObserveColorOverlayUseCaseTest.kt
new file mode 100644
index 0000000000..891a0dbe2f
--- /dev/null
+++ b/apps/student/src/test/java/com/instructure/student/features/dashboard/widget/courses/ObserveColorOverlayUseCaseTest.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.student.features.dashboard.widget.courses
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.instructure.student.util.StudentPrefs
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class ObserveColorOverlayUseCaseTest {
+
+ private val context: Context = mockk()
+ private val sharedPreferences: SharedPreferences = mockk(relaxed = true)
+
+ private lateinit var useCase: ObserveColorOverlayUseCase
+
+ @Before
+ fun setup() {
+ mockkObject(StudentPrefs)
+ every { context.getSharedPreferences(any(), any()) } returns sharedPreferences
+ useCase = ObserveColorOverlayUseCase(context)
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `emits true when hideCourseColorOverlay is false`() = runTest {
+ every { StudentPrefs.hideCourseColorOverlay } returns false
+
+ val result = useCase(Unit).first()
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `emits false when hideCourseColorOverlay is true`() = runTest {
+ every { StudentPrefs.hideCourseColorOverlay } returns true
+
+ val result = useCase(Unit).first()
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `registers preference change listener`() = runTest {
+ every { StudentPrefs.hideCourseColorOverlay } returns false
+
+ useCase(Unit).first()
+
+ verify { sharedPreferences.registerOnSharedPreferenceChangeListener(any()) }
+ }
+}
\ No newline at end of file
diff --git a/apps/student/src/test/java/com/instructure/student/features/dashboard/widget/courses/ObserveGradeVisibilityUseCaseTest.kt b/apps/student/src/test/java/com/instructure/student/features/dashboard/widget/courses/ObserveGradeVisibilityUseCaseTest.kt
new file mode 100644
index 0000000000..0242393a68
--- /dev/null
+++ b/apps/student/src/test/java/com/instructure/student/features/dashboard/widget/courses/ObserveGradeVisibilityUseCaseTest.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.student.features.dashboard.widget.courses
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.instructure.student.util.StudentPrefs
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class ObserveGradeVisibilityUseCaseTest {
+
+ private val context: Context = mockk()
+ private val sharedPreferences: SharedPreferences = mockk(relaxed = true)
+
+ private lateinit var useCase: ObserveGradeVisibilityUseCase
+
+ @Before
+ fun setup() {
+ mockkObject(StudentPrefs)
+ every { context.getSharedPreferences(any(), any()) } returns sharedPreferences
+ useCase = ObserveGradeVisibilityUseCase(context)
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `emits true when showGradesOnCard is true`() = runTest {
+ every { StudentPrefs.showGradesOnCard } returns true
+
+ val result = useCase(Unit).first()
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `emits false when showGradesOnCard is false`() = runTest {
+ every { StudentPrefs.showGradesOnCard } returns false
+
+ val result = useCase(Unit).first()
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `registers preference change listener`() = runTest {
+ every { StudentPrefs.showGradesOnCard } returns false
+
+ useCase(Unit).first()
+
+ verify { sharedPreferences.registerOnSharedPreferenceChangeListener(any()) }
+ }
+}
\ No newline at end of file
diff --git a/apps/student/src/test/java/com/instructure/student/features/dashboard/widget/courses/StudentCoursesWidgetBehaviorTest.kt b/apps/student/src/test/java/com/instructure/student/features/dashboard/widget/courses/StudentCoursesWidgetBehaviorTest.kt
new file mode 100644
index 0000000000..1dce0f6da8
--- /dev/null
+++ b/apps/student/src/test/java/com/instructure/student/features/dashboard/widget/courses/StudentCoursesWidgetBehaviorTest.kt
@@ -0,0 +1,142 @@
+/*
+ * 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.student.features.dashboard.widget.courses
+
+import androidx.fragment.app.FragmentActivity
+import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.models.Group
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetRouter
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class StudentCoursesWidgetBehaviorTest {
+
+ private val observeGradeVisibilityUseCase: ObserveGradeVisibilityUseCase = mockk()
+ private val observeColorOverlayUseCase: ObserveColorOverlayUseCase = mockk()
+ private val router: CoursesWidgetRouter = mockk(relaxed = true)
+
+ private lateinit var behavior: StudentCoursesWidgetBehavior
+
+ @Before
+ fun setup() {
+ behavior = StudentCoursesWidgetBehavior(
+ observeGradeVisibilityUseCase = observeGradeVisibilityUseCase,
+ observeColorOverlayUseCase = observeColorOverlayUseCase,
+ router = router
+ )
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `observeGradeVisibility returns flow from use case`() = runTest {
+ every { observeGradeVisibilityUseCase(Unit) } returns flowOf(true)
+
+ val result = behavior.observeGradeVisibility().first()
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `observeGradeVisibility returns false when use case returns false`() = runTest {
+ every { observeGradeVisibilityUseCase(Unit) } returns flowOf(false)
+
+ val result = behavior.observeGradeVisibility().first()
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `observeColorOverlay returns flow from use case`() = runTest {
+ every { observeColorOverlayUseCase(Unit) } returns flowOf(true)
+
+ val result = behavior.observeColorOverlay().first()
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `observeColorOverlay returns false when use case returns false`() = runTest {
+ every { observeColorOverlayUseCase(Unit) } returns flowOf(false)
+
+ val result = behavior.observeColorOverlay().first()
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `onCourseClick delegates to router`() {
+ val activity: FragmentActivity = mockk()
+ val course = Course(id = 1, name = "Test Course")
+
+ behavior.onCourseClick(activity, course)
+
+ verify { router.routeToCourse(activity, course) }
+ }
+
+ @Test
+ fun `onGroupClick delegates to router`() {
+ val activity: FragmentActivity = mockk()
+ val group = Group(id = 1, name = "Test Group")
+
+ behavior.onGroupClick(activity, group)
+
+ verify { router.routeToGroup(activity, group) }
+ }
+
+ @Test
+ fun `onManageOfflineContent delegates to router`() {
+ val activity: FragmentActivity = mockk()
+ val course = Course(id = 1, name = "Test Course")
+
+ behavior.onManageOfflineContent(activity, course)
+
+ verify { router.routeToManageOfflineContent(activity, course) }
+ }
+
+ @Test
+ fun `onCustomizeCourse delegates to router`() {
+ val activity: FragmentActivity = mockk()
+ val course = Course(id = 1, name = "Test Course")
+
+ behavior.onCustomizeCourse(activity, course)
+
+ verify { router.routeToCustomizeCourse(activity, course) }
+ }
+
+ @Test
+ fun `onAllCoursesClicked delegates to router`() {
+ val activity: FragmentActivity = mockk()
+
+ behavior.onAllCoursesClicked(activity)
+
+ verify { router.routeToAllCourses(activity) }
+ }
+}
\ No newline at end of file
diff --git a/apps/teacher/src/main/java/com/instructure/teacher/di/CoursesWidgetModule.kt b/apps/teacher/src/main/java/com/instructure/teacher/di/CoursesWidgetModule.kt
new file mode 100644
index 0000000000..2d7ebf6f2b
--- /dev/null
+++ b/apps/teacher/src/main/java/com/instructure/teacher/di/CoursesWidgetModule.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.teacher.di
+
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetBehavior
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetRouter
+import com.instructure.teacher.features.dashboard.widget.courses.TeacherCoursesWidgetBehavior
+import com.instructure.teacher.features.dashboard.widget.courses.TeacherCoursesWidgetRouter
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+
+@Module
+@InstallIn(ViewModelComponent::class)
+class CoursesWidgetModule {
+
+ @Provides
+ fun provideCoursesWidgetRouter(): CoursesWidgetRouter {
+ return TeacherCoursesWidgetRouter()
+ }
+
+ @Provides
+ fun provideCoursesWidgetBehavior(
+ teacherCoursesWidgetBehavior: TeacherCoursesWidgetBehavior
+ ): CoursesWidgetBehavior {
+ return teacherCoursesWidgetBehavior
+ }
+}
\ No newline at end of file
diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/dashboard/widget/courses/TeacherCoursesWidgetBehavior.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/dashboard/widget/courses/TeacherCoursesWidgetBehavior.kt
new file mode 100644
index 0000000000..d86636f655
--- /dev/null
+++ b/apps/teacher/src/main/java/com/instructure/teacher/features/dashboard/widget/courses/TeacherCoursesWidgetBehavior.kt
@@ -0,0 +1,59 @@
+/*
+ * 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.teacher.features.dashboard.widget.courses
+
+import androidx.fragment.app.FragmentActivity
+import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.models.Group
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetBehavior
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetRouter
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import javax.inject.Inject
+
+class TeacherCoursesWidgetBehavior @Inject constructor(
+ private val router: CoursesWidgetRouter
+) : CoursesWidgetBehavior {
+
+ override fun observeGradeVisibility(): Flow {
+ return flowOf(false)
+ }
+
+ override fun observeColorOverlay(): Flow {
+ return flowOf(true)
+ }
+
+ override fun onCourseClick(activity: FragmentActivity, course: Course) {
+ router.routeToCourse(activity, course)
+ }
+
+ override fun onGroupClick(activity: FragmentActivity, group: Group) {
+ router.routeToGroup(activity, group)
+ }
+
+ override fun onManageOfflineContent(activity: FragmentActivity, course: Course) {
+ throw NotImplementedError()
+ }
+
+ override fun onCustomizeCourse(activity: FragmentActivity, course: Course) {
+ throw NotImplementedError()
+ }
+
+ override fun onAllCoursesClicked(activity: FragmentActivity) {
+ router.routeToAllCourses(activity)
+ }
+}
\ No newline at end of file
diff --git a/apps/teacher/src/main/java/com/instructure/teacher/features/dashboard/widget/courses/TeacherCoursesWidgetRouter.kt b/apps/teacher/src/main/java/com/instructure/teacher/features/dashboard/widget/courses/TeacherCoursesWidgetRouter.kt
new file mode 100644
index 0000000000..d22153c723
--- /dev/null
+++ b/apps/teacher/src/main/java/com/instructure/teacher/features/dashboard/widget/courses/TeacherCoursesWidgetRouter.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.teacher.features.dashboard.widget.courses
+
+import androidx.fragment.app.FragmentActivity
+import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.models.Group
+import com.instructure.pandautils.features.dashboard.edit.EditDashboardFragment
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetRouter
+import com.instructure.teacher.fragments.CourseBrowserFragment
+import com.instructure.teacher.router.RouteMatcher
+
+class TeacherCoursesWidgetRouter : CoursesWidgetRouter {
+
+ override fun routeToCourse(activity: FragmentActivity, course: Course) {
+ RouteMatcher.route(activity, CourseBrowserFragment.makeRoute(course))
+ }
+
+ override fun routeToGroup(activity: FragmentActivity, group: Group) {
+ RouteMatcher.route(activity, CourseBrowserFragment.makeRoute(group))
+ }
+
+ override fun routeToManageOfflineContent(activity: FragmentActivity, course: Course) {
+ // TODO: Navigate to manage offline content screen
+ }
+
+ override fun routeToCustomizeCourse(activity: FragmentActivity, course: Course) {
+ // TODO: Navigate to customize course screen (color/nickname)
+ }
+
+ override fun routeToAllCourses(activity: FragmentActivity) {
+ RouteMatcher.route(activity, EditDashboardFragment.makeRoute())
+ }
+}
\ No newline at end of file
diff --git a/apps/teacher/src/test/java/com/instructure/teacher/features/dashboard/widget/courses/TeacherCoursesWidgetBehaviorTest.kt b/apps/teacher/src/test/java/com/instructure/teacher/features/dashboard/widget/courses/TeacherCoursesWidgetBehaviorTest.kt
new file mode 100644
index 0000000000..2c1e2fd0e5
--- /dev/null
+++ b/apps/teacher/src/test/java/com/instructure/teacher/features/dashboard/widget/courses/TeacherCoursesWidgetBehaviorTest.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.teacher.features.dashboard.widget.courses
+
+import androidx.fragment.app.FragmentActivity
+import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.models.Group
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetRouter
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class TeacherCoursesWidgetBehaviorTest {
+
+ private val router: CoursesWidgetRouter = mockk(relaxed = true)
+
+ private lateinit var behavior: TeacherCoursesWidgetBehavior
+
+ @Before
+ fun setup() {
+ behavior = TeacherCoursesWidgetBehavior(router = router)
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `observeGradeVisibility always returns false for teacher`() = runTest {
+ val result = behavior.observeGradeVisibility().first()
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `observeColorOverlay always returns true for teacher`() = runTest {
+ val result = behavior.observeColorOverlay().first()
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `onCourseClick delegates to router`() {
+ val activity: FragmentActivity = mockk()
+ val course = Course(id = 1, name = "Test Course")
+
+ behavior.onCourseClick(activity, course)
+
+ verify { router.routeToCourse(activity, course) }
+ }
+
+ @Test
+ fun `onGroupClick delegates to router`() {
+ val activity: FragmentActivity = mockk()
+ val group = Group(id = 1, name = "Test Group")
+
+ behavior.onGroupClick(activity, group)
+
+ verify { router.routeToGroup(activity, group) }
+ }
+
+ @Test
+ fun `onAllCoursesClicked delegates to router`() {
+ val activity: FragmentActivity = mockk()
+
+ behavior.onAllCoursesClicked(activity)
+
+ verify { router.routeToAllCourses(activity) }
+ }
+}
\ No newline at end of file
diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt
index 5ee97ffdd6..9a21f36fcf 100644
--- a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt
+++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/TestModule.kt
@@ -23,6 +23,7 @@ import com.instructure.pandautils.features.calendartodo.createupdate.CreateUpdat
import com.instructure.pandautils.features.calendartodo.createupdate.CreateUpdateToDoViewModelBehavior
import com.instructure.pandautils.features.calendartodo.details.ToDoRouter
import com.instructure.pandautils.features.calendartodo.details.ToDoViewModelBehavior
+import com.instructure.pandautils.features.dashboard.widget.courses.CoursesWidgetBehavior
import com.instructure.pandautils.features.dashboard.edit.EditDashboardRepository
import com.instructure.pandautils.features.dashboard.edit.EditDashboardRouter
import com.instructure.pandautils.features.dashboard.notifications.DashboardRouter
@@ -360,4 +361,9 @@ object HorizonTestModule {
fun provideToDoListViewModelBehavior(): ToDoListViewModelBehavior {
throw NotImplementedError("This is a test module. Implementation not required.")
}
+
+ @Provides
+ fun provideCoursesWidgetBehavior(): CoursesWidgetBehavior {
+ throw NotImplementedError("This is a test module. Implementation not required.")
+ }
}
\ No newline at end of file
diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml
index cafecc1c61..f83262d262 100644
--- a/libs/pandares/src/main/res/values/strings.xml
+++ b/libs/pandares/src/main/res/values/strings.xml
@@ -2131,6 +2131,10 @@
- %s / %s pt
- %s / %s pts
+
+ - Student
+ - Students
+
Late Penalty
None
Late
@@ -2349,4 +2353,9 @@
Announcements (%d)
+ Offline content available
+ More options
+ Customize Course
+ Favorite Courses and Groups
+ %1$s (%2$d)
diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetTest.kt
new file mode 100644
index 0000000000..9490289cd2
--- /dev/null
+++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetTest.kt
@@ -0,0 +1,686 @@
+/*
+ * 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.pandautils.features.dashboard.widget.courses
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasAnyAncestor
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.instructure.pandautils.features.dashboard.widget.courses.model.CourseCardItem
+import com.instructure.pandautils.features.dashboard.widget.courses.model.GradeDisplay
+import com.instructure.pandautils.features.dashboard.widget.courses.model.GroupCardItem
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class CoursesWidgetTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun testWidgetShowsLoadingShimmer() {
+ val uiState = CoursesWidgetUiState(
+ isLoading = true,
+ courses = emptyList(),
+ groups = emptyList()
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = uiState,
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ }
+
+ @Test
+ fun testWidgetShowsEmptyStateWhenNoCoursesOrGroups() {
+ val uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = emptyList(),
+ groups = emptyList()
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = uiState,
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("Courses").assertDoesNotExist()
+ composeTestRule.onNodeWithText("Groups").assertDoesNotExist()
+ }
+
+ @Test
+ fun testWidgetShowsSingleCourse() {
+ val courses = listOf(
+ CourseCardItem(
+ id = 1,
+ name = "Introduction to Computer Science",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Hidden,
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ )
+ )
+
+ val uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = courses,
+ groups = emptyList(),
+ isCoursesExpanded = true
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = uiState,
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("Courses (1)").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Introduction to Computer Science").assertIsDisplayed()
+ }
+
+ @Test
+ fun testWidgetShowsMultipleCourses() {
+ val courses = listOf(
+ CourseCardItem(
+ id = 1,
+ name = "Introduction to Computer Science",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Hidden,
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ ),
+ CourseCardItem(
+ id = 2,
+ name = "Advanced Mathematics",
+ courseCode = "MATH 201",
+ imageUrl = null,
+ grade = GradeDisplay.Hidden,
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ )
+ )
+
+ val uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = courses,
+ groups = emptyList(),
+ isCoursesExpanded = true
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = uiState,
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("Courses (2)").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Introduction to Computer Science").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Advanced Mathematics").assertIsDisplayed()
+ }
+
+ @Test
+ fun testWidgetShowsGroups() {
+ val groups = listOf(
+ GroupCardItem(
+ id = 1,
+ name = "Project Team Alpha",
+ parentCourseName = "Introduction to Computer Science",
+ memberCount = 5
+ )
+ )
+
+ val uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = emptyList(),
+ groups = groups,
+ isGroupsExpanded = true
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = uiState,
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("Groups (1)").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Project Team Alpha").assertIsDisplayed()
+ }
+
+ @Test
+ fun testWidgetShowsCoursesAndGroups() {
+ val courses = listOf(
+ CourseCardItem(
+ id = 1,
+ name = "Introduction to Computer Science",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Hidden,
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ )
+ )
+
+ val groups = listOf(
+ GroupCardItem(
+ id = 1,
+ name = "Project Team Alpha",
+ parentCourseName = "Introduction to Computer Science",
+ memberCount = 5
+ )
+ )
+
+ val uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = courses,
+ groups = groups,
+ isCoursesExpanded = true,
+ isGroupsExpanded = true
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = uiState,
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("Courses (1)").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Groups (1)").assertIsDisplayed()
+ composeTestRule.onNodeWithTag("CourseCard_1").assertIsDisplayed()
+ composeTestRule.onNode(
+ hasText("Introduction to Computer Science") and hasAnyAncestor(hasTestTag("CourseCard_1"))
+ ).assertIsDisplayed()
+ composeTestRule.onNodeWithText("Project Team Alpha").assertIsDisplayed()
+ }
+
+ @Test
+ fun testWidgetShowsGradeWhenEnabled() {
+ val courses = listOf(
+ CourseCardItem(
+ id = 1,
+ name = "Introduction to Computer Science",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Percentage("85%"),
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ )
+ )
+
+ val uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = courses,
+ groups = emptyList(),
+ isCoursesExpanded = true,
+ showGrades = true
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = uiState,
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("85%").assertIsDisplayed()
+ }
+
+ @Test
+ fun testWidgetShowsLetterGrade() {
+ val courses = listOf(
+ CourseCardItem(
+ id = 1,
+ name = "Introduction to Computer Science",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Letter("A-"),
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ )
+ )
+
+ val uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = courses,
+ groups = emptyList(),
+ isCoursesExpanded = true,
+ showGrades = true
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = uiState,
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("A-").assertIsDisplayed()
+ }
+
+ @Test
+ fun testWidgetHidesGradeWhenDisabled() {
+ val courses = listOf(
+ CourseCardItem(
+ id = 1,
+ name = "Introduction to Computer Science",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Percentage("85%"),
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ )
+ )
+
+ val uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = courses,
+ groups = emptyList(),
+ isCoursesExpanded = true,
+ showGrades = false
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = uiState,
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("85%").assertDoesNotExist()
+ }
+
+ @Test
+ fun testCoursesCollapsibleSectionToggle() {
+ var toggleCalled = false
+
+ val courses = listOf(
+ CourseCardItem(
+ id = 1,
+ name = "Introduction to Computer Science",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Hidden,
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ )
+ )
+
+ val uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = courses,
+ groups = emptyList(),
+ isCoursesExpanded = true,
+ onToggleCoursesExpanded = { toggleCalled = true }
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = uiState,
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("Courses (1)").performClick()
+
+ assert(toggleCalled)
+ }
+
+ @Test
+ fun testGroupsCollapsibleSectionToggle() {
+ var toggleCalled = false
+
+ val groups = listOf(
+ GroupCardItem(
+ id = 1,
+ name = "Project Team Alpha",
+ parentCourseName = "Introduction to Computer Science",
+ memberCount = 5
+ )
+ )
+
+ val uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = emptyList(),
+ groups = groups,
+ isGroupsExpanded = true,
+ onToggleGroupsExpanded = { toggleCalled = true }
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = uiState,
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("Groups (1)").performClick()
+
+ assert(toggleCalled)
+ }
+
+ @Test
+ fun testWidgetShowsSyncedIndicator() {
+ val courses = listOf(
+ CourseCardItem(
+ id = 1,
+ name = "Introduction to Computer Science",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Hidden,
+ announcementCount = 0,
+ isSynced = true,
+ isClickable = true
+ )
+ )
+
+ val uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = courses,
+ groups = emptyList(),
+ isCoursesExpanded = true
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = uiState,
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("Introduction to Computer Science").assertIsDisplayed()
+ }
+
+ @Test
+ fun testWidgetShowsAnnouncementCount() {
+ val courses = listOf(
+ CourseCardItem(
+ id = 1,
+ name = "Introduction to Computer Science",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Hidden,
+ announcementCount = 2,
+ isSynced = false,
+ isClickable = true
+ )
+ )
+
+ val uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = courses,
+ groups = emptyList(),
+ isCoursesExpanded = true
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = uiState,
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("2").assertIsDisplayed()
+ }
+
+ @Test
+ fun testCoursesCollapseOnHeaderClick() {
+ var isExpanded by mutableStateOf(true)
+
+ val courses = listOf(
+ CourseCardItem(
+ id = 1,
+ name = "Introduction to Computer Science",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Hidden,
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ )
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = courses,
+ groups = emptyList(),
+ isCoursesExpanded = isExpanded,
+ onToggleCoursesExpanded = { isExpanded = !isExpanded }
+ ),
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("CourseCard_1").assertIsDisplayed()
+
+ composeTestRule.onNodeWithText("Courses (1)").performClick()
+ composeTestRule.waitForIdle()
+
+ assert(!isExpanded)
+ composeTestRule.onNodeWithTag("CourseCard_1").assertDoesNotExist()
+ }
+
+ @Test
+ fun testCoursesExpandOnHeaderClick() {
+ var isExpanded by mutableStateOf(false)
+
+ val courses = listOf(
+ CourseCardItem(
+ id = 1,
+ name = "Introduction to Computer Science",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Hidden,
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ )
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = courses,
+ groups = emptyList(),
+ isCoursesExpanded = isExpanded,
+ onToggleCoursesExpanded = { isExpanded = !isExpanded }
+ ),
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("CourseCard_1").assertDoesNotExist()
+
+ composeTestRule.onNodeWithText("Courses (1)").performClick()
+ composeTestRule.waitForIdle()
+
+ assert(isExpanded)
+ composeTestRule.onNodeWithTag("CourseCard_1").assertIsDisplayed()
+ }
+
+ @Test
+ fun testGroupsCollapseOnHeaderClick() {
+ var isExpanded by mutableStateOf(true)
+
+ val groups = listOf(
+ GroupCardItem(
+ id = 1,
+ name = "Project Team Alpha",
+ parentCourseName = "Introduction to Computer Science",
+ memberCount = 5
+ )
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = emptyList(),
+ groups = groups,
+ isGroupsExpanded = isExpanded,
+ onToggleGroupsExpanded = { isExpanded = !isExpanded }
+ ),
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("GroupCard_1").assertIsDisplayed()
+
+ composeTestRule.onNodeWithText("Groups (1)").performClick()
+ composeTestRule.waitForIdle()
+
+ assert(!isExpanded)
+ composeTestRule.onNodeWithTag("GroupCard_1").assertDoesNotExist()
+ }
+
+ @Test
+ fun testGroupsExpandOnHeaderClick() {
+ var isExpanded by mutableStateOf(false)
+
+ val groups = listOf(
+ GroupCardItem(
+ id = 1,
+ name = "Project Team Alpha",
+ parentCourseName = "Introduction to Computer Science",
+ memberCount = 5
+ )
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = emptyList(),
+ groups = groups,
+ isGroupsExpanded = isExpanded,
+ onToggleGroupsExpanded = { isExpanded = !isExpanded }
+ ),
+ columns = 1
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithTag("GroupCard_1").assertDoesNotExist()
+
+ composeTestRule.onNodeWithText("Groups (1)").performClick()
+ composeTestRule.waitForIdle()
+
+ assert(isExpanded)
+ composeTestRule.onNodeWithTag("GroupCard_1").assertIsDisplayed()
+ }
+
+ @Test
+ fun testWidgetShowsMultipleColumnsOnTablet() {
+ val courses = listOf(
+ CourseCardItem(
+ id = 1,
+ name = "Course 1",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Hidden,
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ ),
+ CourseCardItem(
+ id = 2,
+ name = "Course 2",
+ courseCode = "CS 102",
+ imageUrl = null,
+ grade = GradeDisplay.Hidden,
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ ),
+ CourseCardItem(
+ id = 3,
+ name = "Course 3",
+ courseCode = "CS 103",
+ imageUrl = null,
+ grade = GradeDisplay.Hidden,
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ )
+ )
+
+ val uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = courses,
+ groups = emptyList(),
+ isCoursesExpanded = true
+ )
+
+ composeTestRule.setContent {
+ CoursesWidgetContent(
+ uiState = uiState,
+ columns = 3
+ )
+ }
+
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText("Course 1").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Course 2").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Course 3").assertIsDisplayed()
+ }
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/Shimmer.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/Shimmer.kt
new file mode 100644
index 0000000000..8104479fe7
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/Shimmer.kt
@@ -0,0 +1,115 @@
+/*
+ * 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.pandautils.compose.composables
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.unit.dp
+import com.instructure.pandautils.R
+
+@Composable
+fun ShimmerBox(
+ modifier: Modifier = Modifier,
+ shape: Shape = RoundedCornerShape(8.dp),
+ baseColor: Color = colorResource(R.color.backgroundLight),
+ highlightColor: Color = colorResource(R.color.backgroundLightest)
+) {
+ val transition = rememberInfiniteTransition(label = "shimmer")
+ val translateAnimation by transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1000f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 1200, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "shimmer"
+ )
+
+ val shimmerBrush = Brush.linearGradient(
+ colors = listOf(
+ baseColor,
+ highlightColor,
+ baseColor
+ ),
+ start = Offset(translateAnimation, translateAnimation),
+ end = Offset(translateAnimation + 200f, translateAnimation + 200f)
+ )
+
+ Box(
+ modifier = modifier
+ .clip(shape)
+ .background(shimmerBrush)
+ )
+}
+
+@Composable
+fun Shimmer(
+ modifier: Modifier = Modifier,
+ shape: Shape = RoundedCornerShape(8.dp)
+) {
+ Spacer(
+ modifier = modifier
+ .clip(shape)
+ .background(
+ shimmerBrush()
+ )
+ )
+}
+
+@Composable
+private fun shimmerBrush(
+ baseColor: Color = colorResource(R.color.backgroundLight),
+ highlightColor: Color = colorResource(R.color.backgroundLightest)
+): Brush {
+ val transition = rememberInfiniteTransition(label = "shimmer")
+ val translateAnimation by transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1000f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 1200, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "shimmer"
+ )
+
+ return Brush.linearGradient(
+ colors = listOf(
+ baseColor,
+ highlightColor,
+ baseColor
+ ),
+ start = Offset(translateAnimation, translateAnimation),
+ end = Offset(translateAnimation + 200f, translateAnimation + 200f)
+ )
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepository.kt
index 8ced92d40e..d0a0eeb2a2 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepository.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepository.kt
@@ -1,8 +1,12 @@
package com.instructure.pandautils.data.repository.course
import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.models.DashboardCard
import com.instructure.canvasapi2.utils.DataResult
interface CourseRepository {
suspend fun getCourse(courseId: Long, forceRefresh: Boolean): DataResult
+ suspend fun getCourses(forceRefresh: Boolean): DataResult>
+ suspend fun getFavoriteCourses(forceRefresh: Boolean): DataResult>
+ suspend fun getDashboardCards(forceRefresh: Boolean): DataResult>
}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepositoryImpl.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepositoryImpl.kt
index 2f48fabb51..fbea425798 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepositoryImpl.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/course/CourseRepositoryImpl.kt
@@ -3,7 +3,9 @@ package com.instructure.pandautils.data.repository.course
import com.instructure.canvasapi2.apis.CourseAPI
import com.instructure.canvasapi2.builders.RestParams
import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.models.DashboardCard
import com.instructure.canvasapi2.utils.DataResult
+import com.instructure.canvasapi2.utils.depaginate
class CourseRepositoryImpl(
private val courseApi: CourseAPI.CoursesInterface
@@ -13,4 +15,23 @@ class CourseRepositoryImpl(
val params = RestParams(isForceReadFromNetwork = forceRefresh)
return courseApi.getCourse(courseId, params)
}
+
+ override suspend fun getCourses(forceRefresh: Boolean): DataResult> {
+ val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceRefresh)
+ return courseApi.getFirstPageCourses(params).depaginate { nextUrl ->
+ courseApi.next(nextUrl, params)
+ }
+ }
+
+ override suspend fun getFavoriteCourses(forceRefresh: Boolean): DataResult> {
+ val params = RestParams(isForceReadFromNetwork = forceRefresh)
+ return courseApi.getFavoriteCourses(params).depaginate { nextUrl ->
+ courseApi.next(nextUrl, params)
+ }
+ }
+
+ override suspend fun getDashboardCards(forceRefresh: Boolean): DataResult> {
+ val params = RestParams(isForceReadFromNetwork = forceRefresh)
+ return courseApi.getDashboardCourses(params)
+ }
}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/group/GroupRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/group/GroupRepository.kt
new file mode 100644
index 0000000000..58a421f00b
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/group/GroupRepository.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.pandautils.data.repository.group
+
+import com.instructure.canvasapi2.models.Group
+import com.instructure.canvasapi2.utils.DataResult
+
+interface GroupRepository {
+ suspend fun getGroups(forceRefresh: Boolean): DataResult>
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/group/GroupRepositoryImpl.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/group/GroupRepositoryImpl.kt
new file mode 100644
index 0000000000..304bcae332
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/data/repository/group/GroupRepositoryImpl.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.pandautils.data.repository.group
+
+import com.instructure.canvasapi2.apis.GroupAPI
+import com.instructure.canvasapi2.builders.RestParams
+import com.instructure.canvasapi2.models.Group
+import com.instructure.canvasapi2.utils.DataResult
+import com.instructure.canvasapi2.utils.depaginate
+
+class GroupRepositoryImpl(
+ private val groupApi: GroupAPI.GroupInterface
+) : GroupRepository {
+
+ override suspend fun getGroups(forceRefresh: Boolean): DataResult> {
+ val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceRefresh)
+ return groupApi.getFirstPageGroups(params)
+ .depaginate { nextUrl -> groupApi.getNextPageGroups(nextUrl, params) }
+ }
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/RepositoryModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/RepositoryModule.kt
index bd158dbfa8..7d64fb22e6 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/RepositoryModule.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/RepositoryModule.kt
@@ -19,6 +19,7 @@ package com.instructure.pandautils.di
import com.instructure.canvasapi2.apis.AccountNotificationAPI
import com.instructure.canvasapi2.apis.CourseAPI
import com.instructure.canvasapi2.apis.EnrollmentAPI
+import com.instructure.canvasapi2.apis.GroupAPI
import com.instructure.canvasapi2.apis.UserAPI
import com.instructure.pandautils.data.repository.accountnotification.AccountNotificationRepository
import com.instructure.pandautils.data.repository.accountnotification.AccountNotificationRepositoryImpl
@@ -26,6 +27,8 @@ import com.instructure.pandautils.data.repository.course.CourseRepository
import com.instructure.pandautils.data.repository.course.CourseRepositoryImpl
import com.instructure.pandautils.data.repository.enrollment.EnrollmentRepository
import com.instructure.pandautils.data.repository.enrollment.EnrollmentRepositoryImpl
+import com.instructure.pandautils.data.repository.group.GroupRepository
+import com.instructure.pandautils.data.repository.group.GroupRepositoryImpl
import com.instructure.pandautils.data.repository.user.UserRepository
import com.instructure.pandautils.data.repository.user.UserRepositoryImpl
import dagger.Module
@@ -69,4 +72,12 @@ class RepositoryModule {
): UserRepository {
return UserRepositoryImpl(userApi)
}
+
+ @Provides
+ @Singleton
+ fun provideGroupRepository(
+ groupApi: GroupAPI.GroupInterface
+ ): GroupRepository {
+ return GroupRepositoryImpl(groupApi)
+ }
}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/courses/LoadCourseUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/courses/LoadCourseUseCase.kt
new file mode 100644
index 0000000000..8de12fb92b
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/courses/LoadCourseUseCase.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2025 - present Instructure, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.instructure.pandautils.domain.usecase.courses
+
+import com.instructure.canvasapi2.models.Course
+import com.instructure.pandautils.data.repository.course.CourseRepository
+import com.instructure.pandautils.domain.usecase.BaseUseCase
+import javax.inject.Inject
+
+data class LoadCourseUseCaseParams(
+ val courseId: Long,
+ val forceNetwork: Boolean = false
+)
+
+class LoadCourseUseCase @Inject constructor(private val courseRepository: CourseRepository) :
+ BaseUseCase() {
+ override suspend fun execute(params: LoadCourseUseCaseParams): Course {
+ return courseRepository.getCourse(params.courseId, params.forceNetwork).dataOrThrow
+ }
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/courses/LoadFavoriteCoursesUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/courses/LoadFavoriteCoursesUseCase.kt
new file mode 100644
index 0000000000..9c53f27e29
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/courses/LoadFavoriteCoursesUseCase.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.pandautils.domain.usecase.courses
+
+import com.instructure.canvasapi2.models.Course
+import com.instructure.pandautils.data.repository.course.CourseRepository
+import com.instructure.pandautils.domain.usecase.BaseUseCase
+import javax.inject.Inject
+
+data class LoadFavoriteCoursesParams(
+ val forceRefresh: Boolean = false
+)
+
+class LoadFavoriteCoursesUseCase @Inject constructor(
+ private val courseRepository: CourseRepository
+) : BaseUseCase>() {
+
+ override suspend fun execute(params: LoadFavoriteCoursesParams): List {
+ val courses = courseRepository.getCourses(params.forceRefresh).dataOrThrow
+ val dashboardCards = courseRepository.getDashboardCards(params.forceRefresh).dataOrThrow
+ val dashboardCardIds = dashboardCards.map { it.id }.toSet()
+
+ return courses
+ .filter { it.isFavorite && dashboardCardIds.contains(it.id) }
+ .sortedBy { course -> dashboardCards.find { it.id == course.id }?.position ?: Int.MAX_VALUE }
+ }
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/courses/LoadGroupsUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/courses/LoadGroupsUseCase.kt
new file mode 100644
index 0000000000..b5387aeef0
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/courses/LoadGroupsUseCase.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.pandautils.domain.usecase.courses
+
+import com.instructure.canvasapi2.models.Group
+import com.instructure.pandautils.data.repository.group.GroupRepository
+import com.instructure.pandautils.domain.usecase.BaseUseCase
+import javax.inject.Inject
+
+data class LoadGroupsParams(
+ val forceRefresh: Boolean = false
+)
+
+class LoadGroupsUseCase @Inject constructor(
+ private val groupRepository: GroupRepository
+) : BaseUseCase>() {
+
+ override suspend fun execute(params: LoadGroupsParams): List {
+ val groups = groupRepository.getGroups(params.forceRefresh).dataOrThrow
+
+ return groups.filter { it.isFavorite }
+ }
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/WidgetMetadata.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/WidgetMetadata.kt
index ee9e75f387..f996fd884f 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/WidgetMetadata.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/WidgetMetadata.kt
@@ -27,5 +27,6 @@ data class WidgetMetadata(
const val WIDGET_ID_COURSE_INVITATIONS = "course_invitations"
const val WIDGET_ID_INSTITUTIONAL_ANNOUNCEMENTS = "institutional_announcements"
const val WIDGET_ID_WELCOME = "welcome"
+ const val WIDGET_ID_COURSES = "courses"
}
}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsWidget.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsWidget.kt
index d3ecd080e8..27c70b5e63 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsWidget.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courseinvitation/CourseInvitationsWidget.kt
@@ -227,7 +227,7 @@ private fun InvitationCard(
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
- containerColor = colorResource(R.color.backgroundLightestElevated)
+ containerColor = colorResource(R.color.backgroundLightest)
)
) {
Column(
@@ -240,7 +240,7 @@ private fun InvitationCard(
.padding(top = 16.dp, bottom = 24.dp),
text = invitation.courseName,
fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
+ fontWeight = FontWeight.Medium,
overflow = TextOverflow.Ellipsis,
color = colorResource(R.color.textDarkest),
maxLines = 2
@@ -308,7 +308,7 @@ private fun InvitationCard(
@Preview(showBackground = true)
@Preview(
showBackground = true,
- backgroundColor = 0xFF0F1316,
+ backgroundColor = 0x1F2124,
uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES
)
@Composable
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CollapsibleSection.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CollapsibleSection.kt
new file mode 100644
index 0000000000..74723195e0
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CollapsibleSection.kt
@@ -0,0 +1,115 @@
+/*
+ * 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.pandautils.features.dashboard.widget.courses
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.instructure.pandautils.R
+
+@Composable
+fun CollapsibleSection(
+ title: String,
+ count: Int,
+ isExpanded: Boolean,
+ onToggleExpanded: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit
+) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onToggleExpanded() }
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.collapsibleSectionTitle, title, count),
+ fontSize = 14.sp,
+ color = colorResource(R.color.textDarkest)
+ )
+
+ val rotation by animateFloatAsState(
+ targetValue = if (isExpanded) 180f else 0f,
+ label = "chevron_rotation"
+ )
+
+ Icon(
+ painter = painterResource(R.drawable.ic_chevron_down),
+ contentDescription = if (isExpanded) {
+ stringResource(R.string.a11y_contentDescription_collapsePanel)
+ } else {
+ stringResource(R.string.a11y_contentDescription_expandPanel)
+ },
+ modifier = Modifier
+ .size(24.dp)
+ .rotate(rotation),
+ tint = colorResource(R.color.textDark)
+ )
+ }
+
+ AnimatedVisibility(
+ visible = isExpanded,
+ enter = expandVertically(),
+ exit = shrinkVertically()
+ ) {
+ content()
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CollapsibleSectionPreview() {
+ CollapsibleSection(
+ title = "Courses",
+ count = 5,
+ isExpanded = true,
+ onToggleExpanded = {}
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ Text("Course 1")
+ Spacer(modifier = Modifier.height(8.dp))
+ Text("Course 2")
+ }
+ }
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CourseCard.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CourseCard.kt
new file mode 100644
index 0000000000..f803ce2a10
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CourseCard.kt
@@ -0,0 +1,391 @@
+/*
+ * 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.pandautils.features.dashboard.widget.courses
+
+import android.content.res.Configuration
+import androidx.activity.compose.LocalActivity
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.fragment.app.FragmentActivity
+import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
+import com.bumptech.glide.integration.compose.GlideImage
+import com.instructure.canvasapi2.models.CanvasContext
+import com.instructure.canvasapi2.utils.ContextKeeper
+import com.instructure.pandautils.R
+import com.instructure.pandautils.compose.composables.Shimmer
+import com.instructure.pandautils.features.dashboard.widget.courses.model.CourseCardItem
+import com.instructure.pandautils.features.dashboard.widget.courses.model.GradeDisplay
+import com.instructure.pandautils.utils.color
+import com.instructure.pandautils.utils.getFragmentActivityOrNull
+
+@OptIn(ExperimentalGlideComposeApi::class)
+@Composable
+fun CourseCard(
+ courseCard: CourseCardItem,
+ showGrade: Boolean,
+ showColorOverlay: Boolean,
+ onCourseClick: (FragmentActivity, Long) -> Unit,
+ modifier: Modifier = Modifier,
+ onMenuClick: ((Long) -> Unit)? = null,
+ onManageOfflineContent: ((FragmentActivity, Long) -> Unit)? = null,
+ onCustomizeCourse: ((FragmentActivity, Long) -> Unit)? = null,
+) {
+ var showMenu by remember { mutableStateOf(false) }
+ val hasMenu = onManageOfflineContent != null && onCustomizeCourse != null
+
+ val activity = LocalActivity.current?.getFragmentActivityOrNull()
+
+ val cardShape = RoundedCornerShape(16.dp)
+
+ Box(modifier = modifier.testTag("CourseCard_${courseCard.id}")) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .alpha(if (courseCard.isClickable) 1f else 0.5f),
+ shape = cardShape,
+ colors = CardDefaults.cardColors(
+ containerColor = colorResource(R.color.backgroundLightest)
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(76.dp)
+ .clickable(enabled = courseCard.isClickable) { activity?.let { onCourseClick(it, courseCard.id) } },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .padding(start = 2.dp, top = 2.dp, bottom = 2.dp)
+ .size(72.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ color = Color(CanvasContext.emptyCourseContext(id = courseCard.id).color),
+ shape = RoundedCornerShape(14.dp)
+ )
+ )
+
+ if (courseCard.imageUrl != null) {
+ GlideImage(
+ model = courseCard.imageUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(RoundedCornerShape(14.dp))
+ .alpha(if (showColorOverlay) 0.4f else 1f),
+ contentScale = ContentScale.Crop
+ )
+ }
+
+ if (hasMenu) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(start = 8.dp, top = 8.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .clickable { showMenu = true }
+ .background(
+ color = colorResource(R.color.backgroundLightest),
+ shape = RoundedCornerShape(12.dp)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_kebab),
+ contentDescription = stringResource(R.string.a11y_contentDescription_moreOptions),
+ modifier = Modifier.size(16.dp),
+ tint = Color(CanvasContext.emptyCourseContext(id = courseCard.id).color)
+ )
+ }
+
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false },
+ modifier = Modifier.width(200.dp),
+ shape = RoundedCornerShape(8.dp),
+ containerColor = colorResource(R.color.backgroundLightest)
+ ) {
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = stringResource(R.string.course_menu_manage_offline_content),
+ fontSize = 16.sp,
+ color = colorResource(R.color.textDarkest)
+ )
+ },
+ onClick = {
+ showMenu = false
+ activity?.let { onManageOfflineContent.invoke(it, courseCard.id) }
+ }
+ )
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = stringResource(R.string.customizeCourse),
+ fontSize = 16.sp,
+ color = colorResource(R.color.textDarkest)
+ )
+ },
+ onClick = {
+ showMenu = false
+ activity?.let { onCustomizeCourse.invoke(it, courseCard.id) }
+ }
+ )
+ }
+ }
+ }
+
+ if (showGrade && courseCard.grade !is GradeDisplay.Hidden) {
+ GradeBadge(
+ grade = courseCard.grade,
+ courseColor = Color(CanvasContext.emptyCourseContext(id = courseCard.id).color),
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(bottom = 8.dp, start = 8.dp)
+ )
+ }
+ }
+
+ Text(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 16.dp, end = 8.dp),
+ text = courseCard.name,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ color = colorResource(R.color.textDarkest),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ lineHeight = 21.sp
+ )
+
+ if (courseCard.announcementCount > 0) {
+ Box(
+ modifier = Modifier.padding(start = 8.dp, end = 16.dp)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_announcement),
+ contentDescription = stringResource(R.string.announcements),
+ modifier = Modifier.requiredSize(24.dp),
+ tint = colorResource(R.color.textDark)
+ )
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .offset(x = 8.dp, y = (-8).dp)
+ .background(
+ color = Color(CanvasContext.emptyCourseContext(id = courseCard.id).color),
+ shape = RoundedCornerShape(12.dp)
+ )
+ .padding(horizontal = 5.dp, vertical = 2.dp)
+ ) {
+ Text(
+ text = courseCard.announcementCount.toString(),
+ fontSize = 10.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = Color.White,
+ lineHeight = 8.sp
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun GradeBadge(
+ grade: GradeDisplay,
+ courseColor: Color,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier,
+ shape = RoundedCornerShape(24.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = colorResource(R.color.backgroundLightest)
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Box(
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ when (grade) {
+ is GradeDisplay.Percentage -> {
+ Text(
+ text = grade.value,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = courseColor,
+ lineHeight = 19.sp
+ )
+ }
+ is GradeDisplay.Letter -> {
+ Text(
+ text = grade.grade,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = courseColor,
+ lineHeight = 19.sp
+ )
+ }
+ GradeDisplay.Locked -> {
+ Icon(
+ painter = painterResource(R.drawable.ic_lock),
+ contentDescription = stringResource(R.string.locked),
+ modifier = Modifier.size(14.dp),
+ tint = courseColor
+ )
+ }
+ GradeDisplay.NotAvailable -> {
+ Text(
+ text = stringResource(R.string.noGradeText),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = courseColor,
+ lineHeight = 19.sp
+ )
+ }
+ GradeDisplay.Hidden -> {
+ // Don't show anything for hidden grades
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CourseCardShimmer(
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = colorResource(R.color.backgroundLightest)
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(76.dp)
+ .padding(start = 2.dp, top = 2.dp, bottom = 2.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Shimmer(
+ modifier = Modifier.size(72.dp),
+ shape = RoundedCornerShape(14.dp)
+ )
+
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Shimmer(
+ modifier = Modifier
+ .fillMaxWidth(0.7f)
+ .height(16.dp)
+ )
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun CourseCardPreview() {
+ ContextKeeper.appContext = LocalContext.current
+ CourseCard(
+ courseCard = CourseCardItem(
+ id = 1L,
+ name = "Introduction to Computer Science",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Percentage("85%"),
+ announcementCount = 4,
+ isSynced = true,
+ isClickable = true
+ ),
+ showGrade = true,
+ showColorOverlay = true,
+ onCourseClick = {_, _ ->},
+ onMenuClick = {},
+ onCustomizeCourse = {_, _ ->},
+ onManageOfflineContent = {_, _ ->}
+ )
+}
+
+@Preview(showBackground = true)
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun CourseCardShimmerPreview() {
+ ContextKeeper.appContext = LocalContext.current
+ CourseCardShimmer()
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidget.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidget.kt
new file mode 100644
index 0000000000..d3cd5edd6a
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidget.kt
@@ -0,0 +1,345 @@
+/*
+ * 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.pandautils.features.dashboard.widget.courses
+
+import android.content.res.Configuration
+import androidx.activity.compose.LocalActivity
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import com.instructure.canvasapi2.utils.ContextKeeper
+import com.instructure.pandautils.R
+import com.instructure.pandautils.features.dashboard.widget.courses.model.CourseCardItem
+import com.instructure.pandautils.features.dashboard.widget.courses.model.GradeDisplay
+import com.instructure.pandautils.features.dashboard.widget.courses.model.GroupCardItem
+import com.instructure.pandautils.utils.ThemePrefs
+import com.instructure.pandautils.utils.getFragmentActivityOrNull
+import kotlinx.coroutines.flow.SharedFlow
+
+@Composable
+fun CoursesWidget(
+ refreshSignal: SharedFlow,
+ columns: Int,
+ modifier: Modifier = Modifier
+) {
+ val viewModel: CoursesWidgetViewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsState()
+
+ LaunchedEffect(refreshSignal) {
+ refreshSignal.collect {
+ viewModel.refresh()
+ }
+ }
+
+ CoursesWidgetContent(
+ modifier = modifier,
+ uiState = uiState,
+ columns = columns
+ )
+}
+
+@Composable
+fun CoursesWidgetContent(
+ modifier: Modifier = Modifier,
+ uiState: CoursesWidgetUiState,
+ columns: Int = 1
+) {
+ val activity = LocalActivity.current?.getFragmentActivityOrNull()
+ Column(modifier = modifier.fillMaxWidth()) {
+ if (uiState.isLoading) {
+ CoursesWidgetLoadingState(columns = columns)
+ } else {
+ if (uiState.courses.isNotEmpty()) {
+ CollapsibleSection(
+ title = stringResource(R.string.courses),
+ count = uiState.courses.size,
+ isExpanded = uiState.isCoursesExpanded,
+ onToggleExpanded = uiState.onToggleCoursesExpanded
+ ) {
+ NonLazyGrid(
+ columns = columns,
+ itemCount = uiState.courses.size,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ horizontalSpacing = 12.dp,
+ verticalSpacing = 12.dp
+ ) { index ->
+ val course = uiState.courses[index]
+ CourseCard(
+ courseCard = course,
+ showGrade = uiState.showGrades,
+ showColorOverlay = uiState.showColorOverlay,
+ onCourseClick = uiState.onCourseClick,
+ onManageOfflineContent = uiState.onManageOfflineContent,
+ onCustomizeCourse = uiState.onCustomizeCourse
+ )
+ }
+ }
+ }
+
+ if (uiState.groups.isNotEmpty()) {
+
+ CollapsibleSection(
+ title = stringResource(R.string.groups),
+ count = uiState.groups.size,
+ isExpanded = uiState.isGroupsExpanded,
+ onToggleExpanded = uiState.onToggleGroupsExpanded
+ ) {
+ NonLazyGrid(
+ columns = columns,
+ itemCount = uiState.groups.size,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ horizontalSpacing = 12.dp,
+ verticalSpacing = 12.dp
+ ) { index ->
+ val group = uiState.groups[index]
+ GroupCard(
+ groupCard = group,
+ onGroupClick = uiState.onGroupClick
+ )
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .padding(vertical = 8.dp)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceAround
+ ) {
+ Button(
+ onClick = { activity?.let { uiState.onAllCourses(it) } },
+ modifier = Modifier
+ .height(24.dp)
+ .padding(start = 16.dp),
+ contentPadding = PaddingValues(
+ top = 0.dp,
+ bottom = 0.dp,
+ start = 10.dp,
+ end = 6.dp
+ ),
+ shape = RoundedCornerShape(100.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color(ThemePrefs.buttonColor),
+ contentColor = Color(ThemePrefs.buttonTextColor)
+ )
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ stringResource(R.string.allCourses),
+ fontSize = 12.sp,
+ lineHeight = 14.sp,
+ modifier = Modifier.padding(top = 4.dp, bottom = 6.dp)
+ )
+ Icon(
+ painter = painterResource(R.drawable.ic_chevron_down_small),
+ contentDescription = null,
+ modifier = Modifier.rotate(270f)
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun NonLazyGrid(
+ columns: Int,
+ itemCount: Int,
+ modifier: Modifier = Modifier,
+ horizontalSpacing: Dp = 0.dp,
+ verticalSpacing: Dp = 0.dp,
+ content: @Composable (Int) -> Unit
+) {
+ val rows = (itemCount + columns - 1) / columns
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(verticalSpacing)
+ ) {
+ repeat(rows) { rowIndex ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(horizontalSpacing)
+ ) {
+ repeat(columns) { columnIndex ->
+ val itemIndex = rowIndex * columns + columnIndex
+ Box(modifier = Modifier.weight(1f)) {
+ if (itemIndex < itemCount) {
+ content(itemIndex)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CoursesWidgetLoadingState(columns: Int = 1) {
+ NonLazyGrid(
+ columns = columns,
+ itemCount = 3,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalSpacing = 12.dp,
+ verticalSpacing = 12.dp
+ ) {
+ CourseCardShimmer()
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(
+ showBackground = true,
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ backgroundColor = 0x1F2124
+)
+@Composable
+private fun CoursesWidgetContentPreview() {
+ ContextKeeper.appContext = LocalContext.current
+ CoursesWidgetContent(
+ uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = listOf(
+ CourseCardItem(
+ id = 1,
+ name = "Introduction to Computer Science",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Percentage("85%"),
+ announcementCount = 2,
+ isSynced = true,
+ isClickable = true
+ ),
+ CourseCardItem(
+ id = 2,
+ name = "Advanced Mathematics",
+ courseCode = "MATH 201",
+ imageUrl = null,
+ grade = GradeDisplay.Letter("A-"),
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ )
+ ),
+ groups = listOf(
+ GroupCardItem(
+ id = 1,
+ name = "Project Team Alpha",
+ parentCourseName = "Introduction to Computer Science",
+ memberCount = 5
+ )
+ ),
+ isCoursesExpanded = true,
+ isGroupsExpanded = true,
+ showGrades = true
+ )
+ )
+}
+
+@Preview(widthDp = 1260)
+@Preview(
+ widthDp = 1260,
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ backgroundColor = 0x1F2124,
+ showBackground = true
+)
+@Composable
+private fun CoursesWidgetTabletContentPreview() {
+ ContextKeeper.appContext = LocalContext.current
+ CoursesWidgetContent(
+ columns = 3,
+ uiState = CoursesWidgetUiState(
+ isLoading = false,
+ courses = listOf(
+ CourseCardItem(
+ id = 1,
+ name = "Introduction to Computer Science",
+ courseCode = "CS 101",
+ imageUrl = null,
+ grade = GradeDisplay.Percentage("85%"),
+ announcementCount = 2,
+ isSynced = true,
+ isClickable = true
+ ),
+ CourseCardItem(
+ id = 2,
+ name = "Advanced Mathematics",
+ courseCode = "MATH 201",
+ imageUrl = null,
+ grade = GradeDisplay.Letter("A-"),
+ announcementCount = 0,
+ isSynced = false,
+ isClickable = true
+ )
+ ),
+ groups = listOf(
+ GroupCardItem(
+ id = 1,
+ name = "Project Team Alpha",
+ parentCourseName = "Introduction to Computer Science",
+ memberCount = 5
+ )
+ ),
+ isCoursesExpanded = true,
+ isGroupsExpanded = true,
+ showGrades = true
+ )
+ )
+}
+
+@Preview(showBackground = true)
+@Preview(
+ showBackground = true,
+ uiMode = Configuration.UI_MODE_NIGHT_YES,
+ backgroundColor = 0x1F2124
+)
+@Composable
+private fun CoursesWidgetLoadingPreview() {
+ ContextKeeper.appContext = LocalContext.current
+ CoursesWidgetContent(
+ uiState = CoursesWidgetUiState(isLoading = true)
+ )
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetBehavior.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetBehavior.kt
new file mode 100644
index 0000000000..e1e5d10909
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetBehavior.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.pandautils.features.dashboard.widget.courses
+
+import androidx.fragment.app.FragmentActivity
+import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.models.Group
+import kotlinx.coroutines.flow.Flow
+
+interface CoursesWidgetBehavior {
+ fun observeGradeVisibility(): Flow
+ fun observeColorOverlay(): Flow
+ fun onCourseClick(activity: FragmentActivity, course: Course)
+ fun onGroupClick(activity: FragmentActivity, group: Group)
+ fun onManageOfflineContent(activity: FragmentActivity, course: Course)
+ fun onCustomizeCourse(activity: FragmentActivity, course: Course)
+ fun onAllCoursesClicked(activity: FragmentActivity)
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetRouter.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetRouter.kt
new file mode 100644
index 0000000000..21c3d020cd
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetRouter.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.pandautils.features.dashboard.widget.courses
+
+import android.app.Activity
+import androidx.fragment.app.FragmentActivity
+import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.models.Group
+
+interface CoursesWidgetRouter {
+ fun routeToCourse(activity: FragmentActivity, course: Course)
+ fun routeToGroup(activity: FragmentActivity, group: Group)
+ fun routeToManageOfflineContent(activity: FragmentActivity, course: Course)
+ fun routeToCustomizeCourse(activity: FragmentActivity, course: Course)
+ fun routeToAllCourses(activity: FragmentActivity)
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetUiState.kt
new file mode 100644
index 0000000000..dcdecda8a5
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetUiState.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.pandautils.features.dashboard.widget.courses
+
+import androidx.fragment.app.FragmentActivity
+import com.instructure.pandautils.features.dashboard.widget.courses.model.CourseCardItem
+import com.instructure.pandautils.features.dashboard.widget.courses.model.GroupCardItem
+
+data class CoursesWidgetUiState(
+ val isLoading: Boolean = false,
+ val isError: Boolean = false,
+ val courses: List = emptyList(),
+ val groups: List = emptyList(),
+ val isCoursesExpanded: Boolean = true,
+ val isGroupsExpanded: Boolean = true,
+ val showGrades: Boolean = false,
+ val showColorOverlay: Boolean = false,
+ val onCourseClick: (FragmentActivity, Long) -> Unit = { _, _ -> },
+ val onGroupClick: (FragmentActivity, Long) -> Unit = { _, _ -> },
+ val onToggleCoursesExpanded: () -> Unit = {},
+ val onToggleGroupsExpanded: () -> Unit = {},
+ val onManageOfflineContent: (FragmentActivity, Long) -> Unit = { _, _ -> },
+ val onCustomizeCourse: (FragmentActivity, Long) -> Unit = { _, _ -> },
+ val onAllCourses: (FragmentActivity) -> Unit = {}
+)
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModel.kt
new file mode 100644
index 0000000000..6f8f7ff361
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModel.kt
@@ -0,0 +1,280 @@
+/*
+ * 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.pandautils.features.dashboard.widget.courses
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import com.google.firebase.crashlytics.FirebaseCrashlytics
+import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.models.Group
+import com.instructure.pandautils.domain.usecase.courses.LoadCourseUseCase
+import com.instructure.pandautils.domain.usecase.courses.LoadCourseUseCaseParams
+import com.instructure.pandautils.domain.usecase.courses.LoadFavoriteCoursesParams
+import com.instructure.pandautils.domain.usecase.courses.LoadFavoriteCoursesUseCase
+import com.instructure.pandautils.domain.usecase.courses.LoadGroupsParams
+import com.instructure.pandautils.domain.usecase.courses.LoadGroupsUseCase
+import com.instructure.pandautils.features.dashboard.widget.courses.model.CourseCardItem
+import com.instructure.pandautils.features.dashboard.widget.courses.model.GradeDisplay
+import com.instructure.pandautils.features.dashboard.widget.courses.model.GroupCardItem
+import com.instructure.pandautils.room.offline.daos.CourseSyncSettingsDao
+import com.instructure.pandautils.utils.Const
+import com.instructure.pandautils.utils.FeatureFlagProvider
+import com.instructure.pandautils.utils.NetworkStateProvider
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class CoursesWidgetViewModel @Inject constructor(
+ private val loadFavoriteCoursesUseCase: LoadFavoriteCoursesUseCase,
+ private val loadGroupsUseCase: LoadGroupsUseCase,
+ private val loadCourseUseCase: LoadCourseUseCase,
+ private val sectionExpandedStateDataStore: SectionExpandedStateDataStore,
+ private val coursesWidgetBehavior: CoursesWidgetBehavior,
+ private val courseSyncSettingsDao: CourseSyncSettingsDao,
+ private val networkStateProvider: NetworkStateProvider,
+ private val featureFlagProvider: FeatureFlagProvider,
+ private val crashlytics: FirebaseCrashlytics,
+ private val localBroadcastManager: LocalBroadcastManager
+) : ViewModel() {
+
+ private var courses: List = emptyList()
+ private var groups: List = emptyList()
+
+ private val _uiState = MutableStateFlow(
+ CoursesWidgetUiState(
+ onCourseClick = ::onCourseClick,
+ onGroupClick = ::onGroupClick,
+ onToggleCoursesExpanded = ::toggleCoursesExpanded,
+ onToggleGroupsExpanded = ::toggleGroupsExpanded,
+ onManageOfflineContent = ::onManageOfflineContent,
+ onCustomizeCourse = ::onCustomizeCourse,
+ onAllCourses = ::onAllCourses
+ )
+ )
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val somethingChangedReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent?) {
+ if (intent?.extras?.getBoolean(Const.COURSE_FAVORITES) == true) {
+ refresh()
+ }
+ }
+ }
+
+ init {
+ loadData()
+ observeExpandedStates()
+ observeGradeVisibility()
+ observeColorOverlay()
+ localBroadcastManager.registerReceiver(somethingChangedReceiver, IntentFilter(Const.COURSE_THING_CHANGED))
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ localBroadcastManager.unregisterReceiver(somethingChangedReceiver)
+ }
+
+ private fun onCourseClick(activity: FragmentActivity, courseId: Long) {
+ val course = courses.find { it.id == courseId } ?: return
+ coursesWidgetBehavior.onCourseClick(activity, course)
+ }
+
+ private fun onGroupClick(activity: FragmentActivity, groupId: Long) {
+ val group = groups.find { it.id == groupId } ?: return
+ coursesWidgetBehavior.onGroupClick(activity, group)
+ }
+
+ private fun onManageOfflineContent(activity: FragmentActivity, courseId: Long) {
+ val course = courses.find { it.id == courseId } ?: return
+ coursesWidgetBehavior.onManageOfflineContent(activity, course)
+ }
+
+ private fun onCustomizeCourse(activity: FragmentActivity, courseId: Long) {
+ val course = courses.find { it.id == courseId } ?: return
+ coursesWidgetBehavior.onCustomizeCourse(activity, course)
+ }
+
+ fun refresh() {
+ loadData(forceRefresh = true)
+ }
+
+ private fun toggleCoursesExpanded() {
+ viewModelScope.launch {
+ val newState = !_uiState.value.isCoursesExpanded
+ sectionExpandedStateDataStore.setCoursesExpanded(newState)
+ }
+ }
+
+ private fun toggleGroupsExpanded() {
+ viewModelScope.launch {
+ val newState = !_uiState.value.isGroupsExpanded
+ sectionExpandedStateDataStore.setGroupsExpanded(newState)
+ }
+ }
+
+ private fun loadData(forceRefresh: Boolean = false) {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, isError = false) }
+
+ try {
+ courses = loadFavoriteCoursesUseCase(LoadFavoriteCoursesParams(forceRefresh))
+ groups = loadGroupsUseCase(LoadGroupsParams(forceRefresh))
+
+ val courseCards = mapCoursesToCardItems(courses)
+ val groupCards = mapGroupsToCardItems(groups)
+
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ courses = courseCards,
+ groups = groupCards
+ )
+ }
+ } catch (e: Exception) {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ isError = true
+ )
+ }
+ crashlytics.recordException(e)
+ }
+ }
+ }
+
+ private suspend fun mapCoursesToCardItems(courses: List): List {
+ val syncedIds = getSyncedCourseIds()
+ val isOnline = networkStateProvider.isOnline()
+
+ return courses.map { course ->
+ val isSynced = syncedIds.contains(course.id)
+ CourseCardItem(
+ id = course.id,
+ name = course.name,
+ courseCode = course.courseCode,
+ imageUrl = course.imageUrl,
+ grade = mapGrade(course),
+ announcementCount = 0,
+ isSynced = isSynced,
+ isClickable = isOnline || isSynced
+ )
+ }
+ }
+
+ private suspend fun mapGroupsToCardItems(groups: List): List {
+ return groups.map { group ->
+ val parentCourse = courses.find { it.id == group.courseId } ?: if (group.courseId != 0L) {
+ try {
+ loadCourseUseCase(LoadCourseUseCaseParams(group.courseId, false))
+ } catch (e: Exception) {
+ crashlytics.recordException(e)
+ null
+ }
+ } else {
+ null
+ }
+
+ GroupCardItem(
+ id = group.id,
+ name = group.name.orEmpty(),
+ parentCourseName = parentCourse?.name,
+ memberCount = group.membersCount
+ )
+ }
+ }
+
+ private suspend fun getSyncedCourseIds(): Set {
+ if (!featureFlagProvider.offlineEnabled()) return emptySet()
+
+ val courseSyncSettings = courseSyncSettingsDao.findAll()
+ return courseSyncSettings
+ .filter { it.anySyncEnabled }
+ .map { it.courseId }
+ .toSet()
+ }
+
+ private fun mapGrade(course: Course): GradeDisplay {
+ val courseGrade = course.getCourseGrade(false)
+
+ return when {
+ courseGrade == null -> GradeDisplay.Hidden
+ courseGrade.isLocked -> GradeDisplay.Locked
+ courseGrade.noCurrentGrade -> GradeDisplay.NotAvailable
+ courseGrade.currentGrade != null -> GradeDisplay.Letter(courseGrade.currentGrade!!)
+ courseGrade.currentScore != null -> {
+ val score = courseGrade.currentScore
+ GradeDisplay.Percentage("${score?.toInt()}%")
+ }
+ else -> GradeDisplay.NotAvailable
+ }
+ }
+
+ private fun observeExpandedStates() {
+ viewModelScope.launch {
+ combine(
+ sectionExpandedStateDataStore.observeCoursesExpanded(),
+ sectionExpandedStateDataStore.observeGroupsExpanded()
+ ) { coursesExpanded, groupsExpanded ->
+ Pair(coursesExpanded, groupsExpanded)
+ }.collect { (coursesExpanded, groupsExpanded) ->
+ _uiState.update {
+ it.copy(
+ isCoursesExpanded = coursesExpanded,
+ isGroupsExpanded = groupsExpanded
+ )
+ }
+ }
+ }
+ }
+
+ private fun observeGradeVisibility() {
+ viewModelScope.launch {
+ coursesWidgetBehavior.observeGradeVisibility()
+ .catch { }
+ .collect { showGrades ->
+ _uiState.update { it.copy(showGrades = showGrades) }
+ }
+ }
+ }
+
+ private fun observeColorOverlay() {
+ viewModelScope.launch {
+ coursesWidgetBehavior.observeColorOverlay()
+ .catch { }
+ .collect { showColorOverlay ->
+ _uiState.update { it.copy(showColorOverlay = showColorOverlay) }
+ }
+ }
+ }
+
+ private fun onAllCourses(activity: FragmentActivity) {
+ coursesWidgetBehavior.onAllCoursesClicked(activity)
+ }
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/GroupCard.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/GroupCard.kt
new file mode 100644
index 0000000000..d8bbbb22a5
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/GroupCard.kt
@@ -0,0 +1,235 @@
+/*
+ * 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.pandautils.features.dashboard.widget.courses
+
+import android.content.res.Configuration
+import androidx.activity.compose.LocalActivity
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.fragment.app.FragmentActivity
+import com.instructure.canvasapi2.models.CanvasContext
+import com.instructure.canvasapi2.utils.ContextKeeper
+import com.instructure.pandautils.R
+import com.instructure.pandautils.compose.composables.Shimmer
+import com.instructure.pandautils.features.dashboard.widget.courses.model.GroupCardItem
+import com.instructure.pandautils.utils.color
+import com.instructure.pandautils.utils.getFragmentActivityOrNull
+
+@Composable
+fun GroupCard(
+ groupCard: GroupCardItem,
+ onGroupClick: (FragmentActivity, Long) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val activity = LocalActivity.current?.getFragmentActivityOrNull()
+
+ val cardShape = RoundedCornerShape(16.dp)
+
+ Card(
+ modifier = modifier
+ .testTag("GroupCard_${groupCard.id}")
+ .fillMaxWidth(),
+ shape = cardShape,
+ colors = CardDefaults.cardColors(
+ containerColor = colorResource(R.color.backgroundLightest)
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(76.dp)
+ .clickable { activity?.let { onGroupClick(it, groupCard.id) } },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .padding(start = 2.dp, top = 2.dp, bottom = 2.dp)
+ .size(72.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(72.dp)
+ .background(
+ color = Color(CanvasContext.emptyGroupContext(id = groupCard.id).color),
+ shape = RoundedCornerShape(14.dp)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ modifier = Modifier.padding(8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+
+ Card(
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = colorResource(R.color.backgroundLightest)
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Text(
+ text = groupCard.memberCount.toString(),
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ color = Color(CanvasContext.emptyGroupContext(id = groupCard.id).color),
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
+ lineHeight = 21.sp
+ )
+ }
+
+ Text(
+ text = pluralStringResource(R.plurals.groupMemberCount, groupCard.memberCount),
+ fontSize = 14.sp,
+ color = colorResource(R.color.textLightest),
+ lineHeight = 19.sp
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 16.dp, top = 6.dp, bottom = 6.dp, end = 16.dp),
+ verticalArrangement = Arrangement.Center
+ ) {
+ groupCard.parentCourseName?.let { courseName ->
+ Text(
+ text = courseName,
+ fontSize = 14.sp,
+ color = Color(CanvasContext.emptyGroupContext(id = groupCard.id).color),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ lineHeight = 19.sp
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ }
+
+ Text(
+ text = groupCard.name,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = colorResource(R.color.textDarkest),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ lineHeight = 21.sp
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun GroupCardShimmer(
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = colorResource(R.color.backgroundLightest)
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(76.dp)
+ .padding(start = 2.dp, top = 2.dp, bottom = 2.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Shimmer(
+ modifier = Modifier.size(72.dp),
+ shape = RoundedCornerShape(14.dp)
+ )
+
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Shimmer(
+ modifier = Modifier
+ .fillMaxWidth(0.7f)
+ .height(16.dp)
+ )
+
+ Shimmer(
+ modifier = Modifier
+ .fillMaxWidth(0.5f)
+ .height(14.dp)
+ )
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun GroupCardPreview() {
+ ContextKeeper.appContext = LocalContext.current
+ GroupCard(
+ groupCard = GroupCardItem(
+ id = 1,
+ name = "Project Team Alpha",
+ parentCourseName = "Introduction to Computer Science",
+ memberCount = 5
+ ),
+ onGroupClick = {_, _ -> }
+ )
+}
+
+@Preview(showBackground = true)
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun GroupCardShimmerPreview() {
+ ContextKeeper.appContext = LocalContext.current
+ GroupCardShimmer()
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/SectionExpandedStateDataStore.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/SectionExpandedStateDataStore.kt
new file mode 100644
index 0000000000..121dda89bf
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/SectionExpandedStateDataStore.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.pandautils.features.dashboard.widget.courses
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.preferencesDataStore
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+private val Context.coursesWidgetDataStore by preferencesDataStore(name = SectionExpandedStateDataStore.STORE_NAME)
+
+class SectionExpandedStateDataStore @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ private val dataStore: DataStore = context.coursesWidgetDataStore
+ fun observeCoursesExpanded(): Flow {
+ return dataStore.data.map { preferences ->
+ preferences[COURSES_EXPANDED_KEY] ?: true
+ }
+ }
+
+ fun observeGroupsExpanded(): Flow {
+ return dataStore.data.map { preferences ->
+ preferences[GROUPS_EXPANDED_KEY] ?: true
+ }
+ }
+
+ suspend fun setCoursesExpanded(expanded: Boolean) {
+ dataStore.edit { preferences ->
+ preferences[COURSES_EXPANDED_KEY] = expanded
+ }
+ }
+
+ suspend fun setGroupsExpanded(expanded: Boolean) {
+ dataStore.edit { preferences ->
+ preferences[GROUPS_EXPANDED_KEY] = expanded
+ }
+ }
+
+ companion object {
+ const val STORE_NAME = "courses_widget_store"
+ private val COURSES_EXPANDED_KEY = booleanPreferencesKey("courses_expanded")
+ private val GROUPS_EXPANDED_KEY = booleanPreferencesKey("groups_expanded")
+ }
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/model/CourseCardItem.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/model/CourseCardItem.kt
new file mode 100644
index 0000000000..66a5cef83d
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/model/CourseCardItem.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.pandautils.features.dashboard.widget.courses.model
+
+data class CourseCardItem(
+ val id: Long,
+ val name: String,
+ val courseCode: String?,
+ val imageUrl: String?,
+ val grade: GradeDisplay,
+ val announcementCount: Int = 0,
+ val isSynced: Boolean = false,
+ val isClickable: Boolean = true
+)
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/model/GradeDisplay.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/model/GradeDisplay.kt
new file mode 100644
index 0000000000..876966b611
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/model/GradeDisplay.kt
@@ -0,0 +1,25 @@
+/*
+ * 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.pandautils.features.dashboard.widget.courses.model
+
+sealed class GradeDisplay {
+ data class Percentage(val value: String) : GradeDisplay()
+ data class Letter(val grade: String) : GradeDisplay()
+ data object NotAvailable : GradeDisplay()
+ data object Locked : GradeDisplay()
+ data object Hidden : GradeDisplay()
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/model/GroupCardItem.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/model/GroupCardItem.kt
new file mode 100644
index 0000000000..d0ae0086b8
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/model/GroupCardItem.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.pandautils.features.dashboard.widget.courses.model
+
+data class GroupCardItem(
+ val id: Long,
+ val name: String,
+ val parentCourseName: String?,
+ val memberCount: Int
+)
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidget.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidget.kt
index df568450b1..d9290f83ab 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidget.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/institutionalannouncements/InstitutionalAnnouncementsWidget.kt
@@ -17,7 +17,6 @@ package com.instructure.pandautils.features.dashboard.widget.institutionalannoun
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
-import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -30,9 +29,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -45,6 +44,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
@@ -58,7 +58,6 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
import com.bumptech.glide.integration.compose.GlideImage
-import androidx.compose.ui.graphics.Color
import com.instructure.canvasapi2.models.AccountNotification
import com.instructure.canvasapi2.utils.ContextKeeper
import com.instructure.pandautils.R
@@ -171,19 +170,20 @@ private fun AnnouncementCard(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
+ val cardShape = RoundedCornerShape(16.dp)
Card(
modifier = modifier
- .fillMaxWidth()
- .clickable(onClick = onClick),
- shape = RoundedCornerShape(16.dp),
+ .fillMaxWidth(),
+ shape = cardShape,
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
- containerColor = colorResource(R.color.backgroundLightestElevated)
+ containerColor = colorResource(R.color.backgroundLightest)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
+ .clickable(onClick = onClick)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top
@@ -222,7 +222,7 @@ private fun AnnouncementCard(
.size(16.dp)
.align(Alignment.TopStart)
.offset(x = (-8).dp, y = (-8).dp)
- .background(colorResource(R.color.backgroundLightestElevated), CircleShape)
+ .background(colorResource(R.color.backgroundLightest), CircleShape)
) {
Icon(
painter = painterResource(id = getIconResource(announcement.icon)),
@@ -270,7 +270,7 @@ private fun AnnouncementCard(
text = announcement.subject,
fontSize = 16.sp,
lineHeight = 22.sp,
- fontWeight = FontWeight.SemiBold,
+ fontWeight = FontWeight.Medium,
overflow = TextOverflow.Ellipsis,
color = colorResource(R.color.textDarkest),
maxLines = 2
@@ -306,7 +306,7 @@ private fun formatDateTime(date: Date): String {
@Preview(showBackground = true)
@Preview(
showBackground = true,
- backgroundColor = 0xFF0F1316,
+ backgroundColor = 0x1F2124,
uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES
)
@Composable
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCase.kt
index 2b95cf1ad4..b9be631468 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCase.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCase.kt
@@ -49,6 +49,12 @@ class EnsureDefaultWidgetsUseCase @Inject constructor(
id = WidgetMetadata.WIDGET_ID_WELCOME,
position = 2,
isVisible = true
+ ),
+ WidgetMetadata(
+ id = WidgetMetadata.WIDGET_ID_COURSES,
+ position = 3,
+ isVisible = true,
+ isFullWidth = true
)
)
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidget.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidget.kt
index 4f37e79851..205a84b783 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidget.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/welcome/WelcomeWidget.kt
@@ -19,7 +19,7 @@ package com.instructure.pandautils.features.dashboard.widget.welcome
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.material.Text
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -94,7 +94,11 @@ private fun WelcomeContent(
}
@Preview(showBackground = true)
-@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
+@Preview(
+ showBackground = true,
+ uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES,
+ backgroundColor = 0x1F2124
+)
@Composable
fun WelcomeContentPreview() {
WelcomeContent(
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ActivityExtensions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ActivityExtensions.kt
index bfc5097015..fd4b964278 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ActivityExtensions.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ActivityExtensions.kt
@@ -43,6 +43,15 @@ fun Context.getFragmentActivity(): FragmentActivity {
else throw IllegalStateException("Not FragmentActivity context")
}
+fun Context.getFragmentActivityOrNull(): FragmentActivity? {
+ var context = this
+ while (context is ContextWrapper) {
+ if (context is FragmentActivity) return context
+ context = context.baseContext
+ }
+ return null
+}
+
fun Context.getActivityOrNull(): Activity? {
var context = this
while (context is ContextWrapper) {
diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/course/CourseRepositoryTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/course/CourseRepositoryTest.kt
index b00e677a41..d9acf2ee3c 100644
--- a/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/course/CourseRepositoryTest.kt
+++ b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/course/CourseRepositoryTest.kt
@@ -18,6 +18,7 @@ package com.instructure.pandautils.data.repository.course
import com.instructure.canvasapi2.apis.CourseAPI
import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.models.DashboardCard
import com.instructure.canvasapi2.utils.DataResult
import io.mockk.coEvery
import io.mockk.coVerify
@@ -102,4 +103,161 @@ class CourseRepositoryTest {
assertEquals(expected, result)
}
+
+ @Test
+ fun `getCourses returns success with depaginated courses`() = runTest {
+ val courses = listOf(
+ Course(id = 1L, name = "Course 1"),
+ Course(id = 2L, name = "Course 2")
+ )
+ val expected = DataResult.Success(courses)
+ coEvery {
+ courseApi.getFirstPageCourses(any())
+ } returns expected
+ coEvery {
+ courseApi.next(any(), any())
+ } returns DataResult.Success(emptyList())
+
+ val result = repository.getCourses(forceRefresh = false)
+
+ assertEquals(expected, result)
+ coVerify {
+ courseApi.getFirstPageCourses(match {
+ !it.isForceReadFromNetwork && it.usePerPageQueryParam
+ })
+ }
+ }
+
+ @Test
+ fun `getCourses with forceRefresh passes correct params`() = runTest {
+ val courses = listOf(Course(id = 1L, name = "Course 1"))
+ val expected = DataResult.Success(courses)
+ coEvery {
+ courseApi.getFirstPageCourses(any())
+ } returns expected
+ coEvery {
+ courseApi.next(any(), any())
+ } returns DataResult.Success(emptyList())
+
+ val result = repository.getCourses(forceRefresh = true)
+
+ assertEquals(expected, result)
+ coVerify {
+ courseApi.getFirstPageCourses(match {
+ it.isForceReadFromNetwork && it.usePerPageQueryParam
+ })
+ }
+ }
+
+ @Test
+ fun `getCourses returns failure`() = runTest {
+ val expected = DataResult.Fail()
+ coEvery {
+ courseApi.getFirstPageCourses(any())
+ } returns expected
+
+ val result = repository.getCourses(forceRefresh = false)
+
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `getFavoriteCourses returns success with courses`() = runTest {
+ val courses = listOf(
+ Course(id = 1L, name = "Course 1"),
+ Course(id = 2L, name = "Course 2")
+ )
+ val expected = DataResult.Success(courses)
+ coEvery {
+ courseApi.getFavoriteCourses(any())
+ } returns expected
+ coEvery {
+ courseApi.next(any(), any())
+ } returns DataResult.Success(emptyList())
+
+ val result = repository.getFavoriteCourses(forceRefresh = false)
+
+ assertEquals(expected, result)
+ coVerify {
+ courseApi.getFavoriteCourses(match { !it.isForceReadFromNetwork })
+ }
+ }
+
+ @Test
+ fun `getFavoriteCourses with forceRefresh passes correct params`() = runTest {
+ val courses = listOf(Course(id = 1L, name = "Course 1"))
+ val expected = DataResult.Success(courses)
+ coEvery {
+ courseApi.getFavoriteCourses(any())
+ } returns expected
+ coEvery {
+ courseApi.next(any(), any())
+ } returns DataResult.Success(emptyList())
+
+ val result = repository.getFavoriteCourses(forceRefresh = true)
+
+ assertEquals(expected, result)
+ coVerify {
+ courseApi.getFavoriteCourses(match { it.isForceReadFromNetwork })
+ }
+ }
+
+ @Test
+ fun `getFavoriteCourses returns failure`() = runTest {
+ val expected = DataResult.Fail()
+ coEvery {
+ courseApi.getFavoriteCourses(any())
+ } returns expected
+
+ val result = repository.getFavoriteCourses(forceRefresh = false)
+
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `getDashboardCards returns success with cards`() = runTest {
+ val cards = listOf(
+ DashboardCard(id = 1L, position = 0),
+ DashboardCard(id = 2L, position = 1)
+ )
+ val expected = DataResult.Success(cards)
+ coEvery {
+ courseApi.getDashboardCourses(any())
+ } returns expected
+
+ val result = repository.getDashboardCards(forceRefresh = false)
+
+ assertEquals(expected, result)
+ coVerify {
+ courseApi.getDashboardCourses(match { !it.isForceReadFromNetwork })
+ }
+ }
+
+ @Test
+ fun `getDashboardCards with forceRefresh passes correct params`() = runTest {
+ val cards = listOf(DashboardCard(id = 1L, position = 0))
+ val expected = DataResult.Success(cards)
+ coEvery {
+ courseApi.getDashboardCourses(any())
+ } returns expected
+
+ val result = repository.getDashboardCards(forceRefresh = true)
+
+ assertEquals(expected, result)
+ coVerify {
+ courseApi.getDashboardCourses(match { it.isForceReadFromNetwork })
+ }
+ }
+
+ @Test
+ fun `getDashboardCards returns failure`() = runTest {
+ val expected = DataResult.Fail()
+ coEvery {
+ courseApi.getDashboardCourses(any())
+ } returns expected
+
+ val result = repository.getDashboardCards(forceRefresh = false)
+
+ assertEquals(expected, result)
+ }
}
\ No newline at end of file
diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/group/GroupRepositoryTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/group/GroupRepositoryTest.kt
new file mode 100644
index 0000000000..df3aedfc5b
--- /dev/null
+++ b/libs/pandautils/src/test/java/com/instructure/pandautils/data/repository/group/GroupRepositoryTest.kt
@@ -0,0 +1,133 @@
+/*
+ * 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.pandautils.data.repository.group
+
+import com.instructure.canvasapi2.apis.GroupAPI
+import com.instructure.canvasapi2.models.Group
+import com.instructure.canvasapi2.utils.DataResult
+import com.instructure.canvasapi2.utils.LinkHeaders
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class GroupRepositoryTest {
+
+ private val groupApi: GroupAPI.GroupInterface = mockk(relaxed = true)
+ private lateinit var repository: GroupRepository
+
+ @Before
+ fun setup() {
+ repository = GroupRepositoryImpl(groupApi)
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `getGroups returns success with groups`() = runTest {
+ val groups = listOf(
+ Group(id = 1L, name = "Group 1"),
+ Group(id = 2L, name = "Group 2")
+ )
+ val expected = DataResult.Success(groups)
+ coEvery {
+ groupApi.getFirstPageGroups(any())
+ } returns expected
+
+ val result = repository.getGroups(forceRefresh = false)
+
+ assertTrue(result is DataResult.Success)
+ assertEquals(groups, (result as DataResult.Success).data)
+ coVerify {
+ groupApi.getFirstPageGroups(match { !it.isForceReadFromNetwork && it.usePerPageQueryParam })
+ }
+ }
+
+ @Test
+ fun `getGroups with forceRefresh passes correct params`() = runTest {
+ val groups = listOf(Group(id = 1L, name = "Group 1"))
+ val expected = DataResult.Success(groups)
+ coEvery {
+ groupApi.getFirstPageGroups(any())
+ } returns expected
+
+ val result = repository.getGroups(forceRefresh = true)
+
+ assertTrue(result is DataResult.Success)
+ coVerify {
+ groupApi.getFirstPageGroups(match { it.isForceReadFromNetwork && it.usePerPageQueryParam })
+ }
+ }
+
+ @Test
+ fun `getGroups returns failure`() = runTest {
+ val expected = DataResult.Fail()
+ coEvery {
+ groupApi.getFirstPageGroups(any())
+ } returns expected
+
+ val result = repository.getGroups(forceRefresh = false)
+
+ assertTrue(result is DataResult.Fail)
+ }
+
+ @Test
+ fun `getGroups depaginates when next page exists`() = runTest {
+ val firstPageGroups = listOf(Group(id = 1L, name = "Group 1"))
+ val secondPageGroups = listOf(Group(id = 2L, name = "Group 2"))
+ val nextUrl = "https://example.com/api/v1/groups?page=2"
+
+ val firstPageResult = DataResult.Success(
+ firstPageGroups,
+ linkHeaders = LinkHeaders(nextUrl = nextUrl)
+ )
+ val secondPageResult = DataResult.Success(secondPageGroups)
+
+ coEvery { groupApi.getFirstPageGroups(any()) } returns firstPageResult
+ coEvery { groupApi.getNextPageGroups(nextUrl, any()) } returns secondPageResult
+
+ val result = repository.getGroups(forceRefresh = false)
+
+ assertTrue(result is DataResult.Success)
+ val allGroups = (result as DataResult.Success).data
+ assertEquals(2, allGroups.size)
+ assertEquals(1L, allGroups[0].id)
+ assertEquals(2L, allGroups[1].id)
+ }
+
+ @Test
+ fun `getGroups returns empty list when no groups`() = runTest {
+ val expected = DataResult.Success(emptyList())
+ coEvery {
+ groupApi.getFirstPageGroups(any())
+ } returns expected
+
+ val result = repository.getGroups(forceRefresh = false)
+
+ assertTrue(result is DataResult.Success)
+ assertTrue((result as DataResult.Success).data.isEmpty())
+ }
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/courses/LoadCourseUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/courses/LoadCourseUseCaseTest.kt
new file mode 100644
index 0000000000..3297caa7b6
--- /dev/null
+++ b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/courses/LoadCourseUseCaseTest.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2025 - present Instructure, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.instructure.pandautils.domain.usecase.courses
+
+import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.utils.DataResult
+import com.instructure.pandautils.data.repository.course.CourseRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+
+class LoadCourseUseCaseTest {
+
+ private val courseRepository: CourseRepository = mockk(relaxed = true)
+ private lateinit var useCase: LoadCourseUseCase
+
+ @Before
+ fun setup() {
+ useCase = LoadCourseUseCase(courseRepository)
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `execute returns course successfully`() = runTest {
+ val course = Course(id = 123L, name = "Test Course")
+ val expected = DataResult.Success(course)
+ coEvery { courseRepository.getCourse(any(), any()) } returns expected
+
+ val result = useCase(LoadCourseUseCaseParams(courseId = 123L))
+
+ assertEquals(course, result)
+ coVerify { courseRepository.getCourse(123L, false) }
+ }
+
+ @Test
+ fun `execute with forceNetwork passes correct params`() = runTest {
+ val course = Course(id = 456L, name = "Another Course")
+ val expected = DataResult.Success(course)
+ coEvery { courseRepository.getCourse(any(), any()) } returns expected
+
+ val result = useCase(LoadCourseUseCaseParams(courseId = 456L, forceNetwork = true))
+
+ assertEquals(course, result)
+ coVerify { courseRepository.getCourse(456L, true) }
+ }
+
+ @Test(expected = Exception::class)
+ fun `execute throws exception on failure`() = runTest {
+ val expected = DataResult.Fail()
+ coEvery { courseRepository.getCourse(any(), any()) } returns expected
+
+ useCase(LoadCourseUseCaseParams(courseId = 123L))
+ }
+}
diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/courses/LoadFavoriteCoursesUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/courses/LoadFavoriteCoursesUseCaseTest.kt
new file mode 100644
index 0000000000..61c1769239
--- /dev/null
+++ b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/courses/LoadFavoriteCoursesUseCaseTest.kt
@@ -0,0 +1,157 @@
+/*
+ * 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.pandautils.domain.usecase.courses
+
+import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.models.DashboardCard
+import com.instructure.canvasapi2.utils.DataResult
+import com.instructure.pandautils.data.repository.course.CourseRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class LoadFavoriteCoursesUseCaseTest {
+
+ private val courseRepository: CourseRepository = mockk(relaxed = true)
+ private lateinit var useCase: LoadFavoriteCoursesUseCase
+
+ @Before
+ fun setup() {
+ useCase = LoadFavoriteCoursesUseCase(courseRepository)
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `execute returns only favorite courses`() = runTest {
+ val courses = listOf(
+ Course(id = 1, name = "Favorite Course", isFavorite = true),
+ Course(id = 2, name = "Non-Favorite Course", isFavorite = false),
+ Course(id = 3, name = "Another Favorite", isFavorite = true)
+ )
+ val dashboardCards = listOf(
+ DashboardCard(id = 1, position = 0),
+ DashboardCard(id = 3, position = 1)
+ )
+
+ coEvery { courseRepository.getCourses(any()) } returns DataResult.Success(courses)
+ coEvery { courseRepository.getDashboardCards(any()) } returns DataResult.Success(dashboardCards)
+
+ val result = useCase(LoadFavoriteCoursesParams())
+
+ assertEquals(2, result.size)
+ assertTrue(result.all { it.isFavorite })
+ assertEquals(1L, result[0].id)
+ assertEquals(3L, result[1].id)
+ }
+
+ @Test
+ fun `execute sorts courses by dashboard card position`() = runTest {
+ val courses = listOf(
+ Course(id = 1, name = "Course A", isFavorite = true),
+ Course(id = 2, name = "Course B", isFavorite = true),
+ Course(id = 3, name = "Course C", isFavorite = true)
+ )
+ val dashboardCards = listOf(
+ DashboardCard(id = 1, position = 2),
+ DashboardCard(id = 2, position = 0),
+ DashboardCard(id = 3, position = 1)
+ )
+
+ coEvery { courseRepository.getCourses(any()) } returns DataResult.Success(courses)
+ coEvery { courseRepository.getDashboardCards(any()) } returns DataResult.Success(dashboardCards)
+
+ val result = useCase(LoadFavoriteCoursesParams())
+
+ assertEquals(3, result.size)
+ assertEquals(2L, result[0].id)
+ assertEquals(3L, result[1].id)
+ assertEquals(1L, result[2].id)
+ }
+
+ @Test
+ fun `execute excludes courses without dashboard card`() = runTest {
+ val courses = listOf(
+ Course(id = 1, name = "Course A", isFavorite = true),
+ Course(id = 2, name = "Course B", isFavorite = true),
+ Course(id = 3, name = "Course C", isFavorite = true)
+ )
+ val dashboardCards = listOf(
+ DashboardCard(id = 2, position = 0)
+ )
+
+ coEvery { courseRepository.getCourses(any()) } returns DataResult.Success(courses)
+ coEvery { courseRepository.getDashboardCards(any()) } returns DataResult.Success(dashboardCards)
+
+ val result = useCase(LoadFavoriteCoursesParams())
+
+ assertEquals(1, result.size)
+ assertEquals(2L, result[0].id)
+ }
+
+ @Test
+ fun `execute returns empty list when no favorite courses exist`() = runTest {
+ val courses = listOf(
+ Course(id = 1, name = "Course A", isFavorite = false),
+ Course(id = 2, name = "Course B", isFavorite = false)
+ )
+
+ coEvery { courseRepository.getCourses(any()) } returns DataResult.Success(courses)
+ coEvery { courseRepository.getDashboardCards(any()) } returns DataResult.Success(emptyList())
+
+ val result = useCase(LoadFavoriteCoursesParams())
+
+ assertTrue(result.isEmpty())
+ }
+
+ @Test
+ fun `execute passes forceRefresh parameter to repository`() = runTest {
+ coEvery { courseRepository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { courseRepository.getDashboardCards(any()) } returns DataResult.Success(emptyList())
+
+ useCase(LoadFavoriteCoursesParams(forceRefresh = true))
+
+ coVerify { courseRepository.getCourses(true) }
+ coVerify { courseRepository.getDashboardCards(true) }
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun `execute throws when repository returns failure for courses`() = runTest {
+ coEvery { courseRepository.getCourses(any()) } returns DataResult.Fail()
+ coEvery { courseRepository.getDashboardCards(any()) } returns DataResult.Success(emptyList())
+
+ useCase(LoadFavoriteCoursesParams())
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun `execute throws when repository returns failure for dashboard cards`() = runTest {
+ coEvery { courseRepository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { courseRepository.getDashboardCards(any()) } returns DataResult.Fail()
+
+ useCase(LoadFavoriteCoursesParams())
+ }
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/courses/LoadGroupsUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/courses/LoadGroupsUseCaseTest.kt
new file mode 100644
index 0000000000..07d6735fd4
--- /dev/null
+++ b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/courses/LoadGroupsUseCaseTest.kt
@@ -0,0 +1,128 @@
+/*
+ * 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.pandautils.domain.usecase.courses
+
+import com.instructure.canvasapi2.models.Group
+import com.instructure.canvasapi2.utils.DataResult
+import com.instructure.pandautils.data.repository.group.GroupRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class LoadGroupsUseCaseTest {
+
+ private val groupRepository: GroupRepository = mockk(relaxed = true)
+ private lateinit var useCase: LoadGroupsUseCase
+
+ @Before
+ fun setup() {
+ useCase = LoadGroupsUseCase(groupRepository)
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `execute returns only favorite groups`() = runTest {
+ val groups = listOf(
+ Group(id = 1, name = "Favorite Group", isFavorite = true),
+ Group(id = 2, name = "Non-Favorite Group", isFavorite = false),
+ Group(id = 3, name = "Another Favorite", isFavorite = true)
+ )
+
+ coEvery { groupRepository.getGroups(any()) } returns DataResult.Success(groups)
+
+ val result = useCase(LoadGroupsParams())
+
+ assertEquals(2, result.size)
+ assertTrue(result.all { it.isFavorite })
+ assertEquals(1L, result[0].id)
+ assertEquals(3L, result[1].id)
+ }
+
+ @Test
+ fun `execute returns empty list when no favorite groups exist`() = runTest {
+ val groups = listOf(
+ Group(id = 1, name = "Group A", isFavorite = false),
+ Group(id = 2, name = "Group B", isFavorite = false)
+ )
+
+ coEvery { groupRepository.getGroups(any()) } returns DataResult.Success(groups)
+
+ val result = useCase(LoadGroupsParams())
+
+ assertTrue(result.isEmpty())
+ }
+
+ @Test
+ fun `execute returns empty list when repository returns empty list`() = runTest {
+ coEvery { groupRepository.getGroups(any()) } returns DataResult.Success(emptyList())
+
+ val result = useCase(LoadGroupsParams())
+
+ assertTrue(result.isEmpty())
+ }
+
+ @Test
+ fun `execute passes forceRefresh parameter to repository`() = runTest {
+ coEvery { groupRepository.getGroups(any()) } returns DataResult.Success(emptyList())
+
+ useCase(LoadGroupsParams(forceRefresh = true))
+
+ coVerify { groupRepository.getGroups(true) }
+ }
+
+ @Test
+ fun `execute passes false forceRefresh by default`() = runTest {
+ coEvery { groupRepository.getGroups(any()) } returns DataResult.Success(emptyList())
+
+ useCase(LoadGroupsParams())
+
+ coVerify { groupRepository.getGroups(false) }
+ }
+
+ @Test(expected = IllegalStateException::class)
+ fun `execute throws when repository returns failure`() = runTest {
+ coEvery { groupRepository.getGroups(any()) } returns DataResult.Fail()
+
+ useCase(LoadGroupsParams())
+ }
+
+ @Test
+ fun `execute returns all groups when all are favorites`() = runTest {
+ val groups = listOf(
+ Group(id = 1, name = "Group A", isFavorite = true),
+ Group(id = 2, name = "Group B", isFavorite = true),
+ Group(id = 3, name = "Group C", isFavorite = true)
+ )
+
+ coEvery { groupRepository.getGroups(any()) } returns DataResult.Success(groups)
+
+ val result = useCase(LoadGroupsParams())
+
+ assertEquals(3, result.size)
+ }
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModelTest.kt
new file mode 100644
index 0000000000..5ee3255506
--- /dev/null
+++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModelTest.kt
@@ -0,0 +1,537 @@
+/*
+ * 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.pandautils.features.dashboard.widget.courses
+
+import android.content.BroadcastReceiver
+import android.content.Intent
+import android.os.Bundle
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.fragment.app.FragmentActivity
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import com.google.firebase.crashlytics.FirebaseCrashlytics
+import com.instructure.canvasapi2.models.Course
+import com.instructure.canvasapi2.models.Enrollment
+import com.instructure.canvasapi2.models.Group
+import com.instructure.pandautils.features.dashboard.widget.courses.model.GradeDisplay
+import com.instructure.pandautils.domain.usecase.courses.LoadCourseUseCase
+import com.instructure.pandautils.domain.usecase.courses.LoadFavoriteCoursesParams
+import com.instructure.pandautils.domain.usecase.courses.LoadFavoriteCoursesUseCase
+import com.instructure.pandautils.domain.usecase.courses.LoadGroupsParams
+import com.instructure.pandautils.domain.usecase.courses.LoadGroupsUseCase
+import com.instructure.pandautils.room.offline.daos.CourseSyncSettingsDao
+import com.instructure.pandautils.room.offline.entities.CourseSyncSettingsEntity
+import com.instructure.pandautils.utils.ColorKeeper
+import com.instructure.pandautils.utils.Const
+import com.instructure.pandautils.utils.FeatureFlagProvider
+import com.instructure.pandautils.utils.NetworkStateProvider
+import com.instructure.pandautils.utils.ThemedColor
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.slot
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+@ExperimentalCoroutinesApi
+class CoursesWidgetViewModelTest {
+
+ @get:Rule
+ var instantExecutorRule = InstantTaskExecutorRule()
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val loadFavoriteCoursesUseCase: LoadFavoriteCoursesUseCase = mockk()
+ private val loadGroupsUseCase: LoadGroupsUseCase = mockk()
+ private val loadCourseUseCase: LoadCourseUseCase = mockk()
+ private val sectionExpandedStateDataStore: SectionExpandedStateDataStore = mockk(relaxed = true)
+ private val coursesWidgetBehavior: CoursesWidgetBehavior = mockk(relaxed = true)
+ private val courseSyncSettingsDao: CourseSyncSettingsDao = mockk()
+ private val networkStateProvider: NetworkStateProvider = mockk()
+ private val featureFlagProvider: FeatureFlagProvider = mockk()
+ private val crashlytics: FirebaseCrashlytics = mockk(relaxed = true)
+ private val localBroadcastManager: LocalBroadcastManager = mockk()
+
+ private lateinit var viewModel: CoursesWidgetViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ mockkObject(ColorKeeper)
+ every { ColorKeeper.getOrGenerateColor(any()) } returns ThemedColor(0xFF0000, 0xFF0000)
+ every { ColorKeeper.getOrGenerateColor(any()) } returns ThemedColor(0x00FF00, 0x00FF00)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ unmockkAll()
+ }
+
+ private fun setupDefaultMocks() {
+ coEvery { loadFavoriteCoursesUseCase(any()) } returns emptyList()
+ coEvery { loadGroupsUseCase(any()) } returns emptyList()
+ every { sectionExpandedStateDataStore.observeCoursesExpanded() } returns flowOf(true)
+ every { sectionExpandedStateDataStore.observeGroupsExpanded() } returns flowOf(true)
+ every { coursesWidgetBehavior.observeGradeVisibility() } returns flowOf(false)
+ every { coursesWidgetBehavior.observeColorOverlay() } returns flowOf(false)
+ coEvery { featureFlagProvider.offlineEnabled() } returns false
+ every { networkStateProvider.isOnline() } returns true
+ every { localBroadcastManager.registerReceiver(any(), any()) } returns Unit
+ every { localBroadcastManager.unregisterReceiver(any()) } returns Unit
+ }
+
+ @Test
+ fun `init loads courses and groups successfully`() {
+ setupDefaultMocks()
+ val courses = listOf(
+ Course(id = 1, name = "Course 1", isFavorite = true),
+ Course(id = 2, name = "Course 2", isFavorite = true)
+ )
+ val groups = listOf(
+ Group(id = 1, name = "Group 1", isFavorite = true)
+ )
+ coEvery { loadFavoriteCoursesUseCase(any()) } returns courses
+ coEvery { loadGroupsUseCase(any()) } returns groups
+
+ viewModel = createViewModel()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.isLoading)
+ assertFalse(state.isError)
+ assertEquals(2, state.courses.size)
+ assertEquals(1, state.groups.size)
+ }
+
+ @Test
+ fun `init sets error state when loading fails`() {
+ setupDefaultMocks()
+ coEvery { loadFavoriteCoursesUseCase(any()) } throws RuntimeException("Network error")
+
+ viewModel = createViewModel()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.isLoading)
+ assertTrue(state.isError)
+ }
+
+ @Test
+ fun `refresh reloads data with forceRefresh`() {
+ setupDefaultMocks()
+ coEvery { loadFavoriteCoursesUseCase(any()) } returns emptyList()
+ coEvery { loadGroupsUseCase(any()) } returns emptyList()
+
+ viewModel = createViewModel()
+ viewModel.refresh()
+
+ coVerify { loadFavoriteCoursesUseCase(LoadFavoriteCoursesParams(forceRefresh = true)) }
+ coVerify { loadGroupsUseCase(LoadGroupsParams(forceRefresh = true)) }
+ }
+
+ @Test
+ fun `toggleCoursesExpanded updates expanded state`() {
+ setupDefaultMocks()
+
+ viewModel = createViewModel()
+ viewModel.uiState.value.onToggleCoursesExpanded()
+
+ coVerify { sectionExpandedStateDataStore.setCoursesExpanded(false) }
+ }
+
+ @Test
+ fun `toggleGroupsExpanded updates expanded state`() {
+ setupDefaultMocks()
+
+ viewModel = createViewModel()
+ viewModel.uiState.value.onToggleGroupsExpanded()
+
+ coVerify { sectionExpandedStateDataStore.setGroupsExpanded(false) }
+ }
+
+ @Test
+ fun `observeExpandedStates updates ui state`() {
+ setupDefaultMocks()
+ every { sectionExpandedStateDataStore.observeCoursesExpanded() } returns flowOf(false)
+ every { sectionExpandedStateDataStore.observeGroupsExpanded() } returns flowOf(false)
+
+ viewModel = createViewModel()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.isCoursesExpanded)
+ assertFalse(state.isGroupsExpanded)
+ }
+
+ @Test
+ fun `observeGradeVisibility updates showGrades in ui state`() {
+ setupDefaultMocks()
+ every { coursesWidgetBehavior.observeGradeVisibility() } returns flowOf(true)
+
+ viewModel = createViewModel()
+
+ val state = viewModel.uiState.value
+ assertTrue(state.showGrades)
+ }
+
+ @Test
+ fun `observeColorOverlay updates showColorOverlay in ui state`() {
+ setupDefaultMocks()
+ every { coursesWidgetBehavior.observeColorOverlay() } returns flowOf(true)
+
+ viewModel = createViewModel()
+
+ val state = viewModel.uiState.value
+ assertTrue(state.showColorOverlay)
+ }
+
+ @Test
+ fun `courses are mapped to CourseCardItems correctly`() {
+ setupDefaultMocks()
+ val enrollment = Enrollment(
+ type = Enrollment.EnrollmentType.Student,
+ computedCurrentGrade = "A",
+ computedCurrentScore = 95.0
+ )
+ val courses = listOf(
+ Course(
+ id = 1,
+ name = "Test Course",
+ courseCode = "TC101",
+ imageUrl = "https://example.com/image.jpg",
+ isFavorite = true,
+ enrollments = mutableListOf(enrollment)
+ )
+ )
+ coEvery { loadFavoriteCoursesUseCase(any()) } returns courses
+
+ viewModel = createViewModel()
+
+ val state = viewModel.uiState.value
+ assertEquals(1, state.courses.size)
+ val courseCard = state.courses[0]
+ assertEquals(1L, courseCard.id)
+ assertEquals("Test Course", courseCard.name)
+ assertEquals("TC101", courseCard.courseCode)
+ assertEquals("https://example.com/image.jpg", courseCard.imageUrl)
+ assertTrue(courseCard.isClickable)
+ }
+
+ @Test
+ fun `grade is mapped as Letter when currentGrade is available`() {
+ setupDefaultMocks()
+ val enrollment = Enrollment(
+ type = Enrollment.EnrollmentType.Student,
+ computedCurrentGrade = "B+",
+ computedCurrentScore = 88.0
+ )
+ val course = Course(id = 1, name = "Course", isFavorite = true, enrollments = mutableListOf(enrollment))
+ coEvery { loadFavoriteCoursesUseCase(any()) } returns listOf(course)
+
+ viewModel = createViewModel()
+
+ val grade = viewModel.uiState.value.courses[0].grade
+ assertTrue(grade is GradeDisplay.Letter)
+ assertEquals("B+", (grade as GradeDisplay.Letter).grade)
+ }
+
+ @Test
+ fun `grade is mapped as Percentage when only score is available`() {
+ setupDefaultMocks()
+ val enrollment = Enrollment(
+ type = Enrollment.EnrollmentType.Student,
+ computedCurrentScore = 75.0
+ )
+ val course = Course(id = 1, name = "Course", isFavorite = true, enrollments = mutableListOf(enrollment))
+ coEvery { loadFavoriteCoursesUseCase(any()) } returns listOf(course)
+
+ viewModel = createViewModel()
+
+ val grade = viewModel.uiState.value.courses[0].grade
+ assertTrue(grade is GradeDisplay.Percentage)
+ assertEquals("75%", (grade as GradeDisplay.Percentage).value)
+ }
+
+ @Test
+ fun `grade is mapped as Locked when hideFinalGrades is true and no grade available`() {
+ setupDefaultMocks()
+ val enrollment = Enrollment(type = Enrollment.EnrollmentType.Student)
+ val course = Course(
+ id = 1,
+ name = "Course",
+ isFavorite = true,
+ hideFinalGrades = true,
+ enrollments = mutableListOf(enrollment)
+ )
+ coEvery { loadFavoriteCoursesUseCase(any()) } returns listOf(course)
+
+ viewModel = createViewModel()
+
+ val grade = viewModel.uiState.value.courses[0].grade
+ assertTrue(grade is GradeDisplay.Locked)
+ }
+
+ @Test
+ fun `grade is mapped as Hidden when no enrollment`() {
+ setupDefaultMocks()
+ val course = Course(id = 1, name = "Course", isFavorite = true)
+ coEvery { loadFavoriteCoursesUseCase(any()) } returns listOf(course)
+
+ viewModel = createViewModel()
+
+ val grade = viewModel.uiState.value.courses[0].grade
+ assertTrue(grade is GradeDisplay.Hidden)
+ }
+
+ @Test
+ fun `courses are marked as not clickable when offline and not synced`() {
+ setupDefaultMocks()
+ coEvery { featureFlagProvider.offlineEnabled() } returns true
+ coEvery { courseSyncSettingsDao.findAll() } returns emptyList()
+ every { networkStateProvider.isOnline() } returns false
+
+ val courses = listOf(Course(id = 1, name = "Course", isFavorite = true))
+ coEvery { loadFavoriteCoursesUseCase(any()) } returns courses
+
+ viewModel = createViewModel()
+
+ val state = viewModel.uiState.value
+ assertFalse(state.courses[0].isClickable)
+ }
+
+ @Test
+ fun `courses are marked as clickable when offline but synced`() {
+ setupDefaultMocks()
+ coEvery { featureFlagProvider.offlineEnabled() } returns true
+ coEvery { courseSyncSettingsDao.findAll() } returns listOf(
+ CourseSyncSettingsEntity(courseId = 1, courseName = "Course", fullContentSync = true)
+ )
+ every { networkStateProvider.isOnline() } returns false
+
+ val courses = listOf(Course(id = 1, name = "Course", isFavorite = true))
+ coEvery { loadFavoriteCoursesUseCase(any()) } returns courses
+
+ viewModel = createViewModel()
+
+ val state = viewModel.uiState.value
+ assertTrue(state.courses[0].isClickable)
+ assertTrue(state.courses[0].isSynced)
+ }
+
+ @Test
+ fun `courses are always clickable when online`() {
+ setupDefaultMocks()
+ every { networkStateProvider.isOnline() } returns true
+
+ val courses = listOf(Course(id = 1, name = "Course", isFavorite = true))
+ coEvery { loadFavoriteCoursesUseCase(any()) } returns courses
+
+ viewModel = createViewModel()
+
+ val state = viewModel.uiState.value
+ assertTrue(state.courses[0].isClickable)
+ }
+
+ @Test
+ fun `groups are mapped to GroupCardItems correctly`() {
+ setupDefaultMocks()
+ val parentCourse = Course(id = 100, name = "Parent Course")
+ val groups = listOf(
+ Group(id = 1, name = "Study Group", courseId = 100, membersCount = 5, isFavorite = true)
+ )
+ coEvery { loadGroupsUseCase(any()) } returns groups
+ coEvery { loadCourseUseCase(any()) } returns parentCourse
+
+ viewModel = createViewModel()
+
+ val state = viewModel.uiState.value
+ assertEquals(1, state.groups.size)
+ val groupCard = state.groups[0]
+ assertEquals(1L, groupCard.id)
+ assertEquals("Study Group", groupCard.name)
+ assertEquals("Parent Course", groupCard.parentCourseName)
+ assertEquals(5, groupCard.memberCount)
+ }
+
+ @Test
+ fun `groups without parent course have null parentCourseName`() {
+ setupDefaultMocks()
+ val groups = listOf(
+ Group(id = 1, name = "Standalone Group", courseId = 0, isFavorite = true)
+ )
+ coEvery { loadGroupsUseCase(any()) } returns groups
+
+ viewModel = createViewModel()
+
+ val state = viewModel.uiState.value
+ assertEquals(1, state.groups.size)
+ assertEquals(null, state.groups[0].parentCourseName)
+ }
+
+ @Test
+ fun `onCourseClick delegates to behavior`() {
+ setupDefaultMocks()
+ val course = Course(id = 1, name = "Course", isFavorite = true)
+ coEvery { loadFavoriteCoursesUseCase(any()) } returns listOf(course)
+
+ viewModel = createViewModel()
+ val activity: FragmentActivity = mockk()
+ viewModel.uiState.value.onCourseClick(activity, 1)
+
+ verify { coursesWidgetBehavior.onCourseClick(activity, course) }
+ }
+
+ @Test
+ fun `onGroupClick delegates to behavior`() {
+ setupDefaultMocks()
+ val group = Group(id = 1, name = "Group", isFavorite = true)
+ coEvery { loadGroupsUseCase(any()) } returns listOf(group)
+
+ viewModel = createViewModel()
+ val activity: FragmentActivity = mockk()
+ viewModel.uiState.value.onGroupClick(activity, 1)
+
+ verify { coursesWidgetBehavior.onGroupClick(activity, group) }
+ }
+
+ @Test
+ fun `onManageOfflineContent delegates to behavior`() {
+ setupDefaultMocks()
+ val course = Course(id = 1, name = "Course", isFavorite = true)
+ coEvery { loadFavoriteCoursesUseCase(any()) } returns listOf(course)
+
+ viewModel = createViewModel()
+ val activity: FragmentActivity = mockk()
+ viewModel.uiState.value.onManageOfflineContent(activity, 1)
+
+ verify { coursesWidgetBehavior.onManageOfflineContent(activity, course) }
+ }
+
+ @Test
+ fun `onCustomizeCourse delegates to behavior`() {
+ setupDefaultMocks()
+ val course = Course(id = 1, name = "Course", isFavorite = true)
+ coEvery { loadFavoriteCoursesUseCase(any()) } returns listOf(course)
+
+ viewModel = createViewModel()
+ val activity: FragmentActivity = mockk()
+ viewModel.uiState.value.onCustomizeCourse(activity, 1)
+
+ verify { coursesWidgetBehavior.onCustomizeCourse(activity, course) }
+ }
+
+ @Test
+ fun `onAllCourses delegates to behavior`() {
+ setupDefaultMocks()
+
+ viewModel = createViewModel()
+ val activity: FragmentActivity = mockk()
+ viewModel.uiState.value.onAllCourses(activity)
+
+ verify { coursesWidgetBehavior.onAllCoursesClicked(activity) }
+ }
+
+ @Test
+ fun `broadcast receiver is registered on init`() {
+ setupDefaultMocks()
+
+ viewModel = createViewModel()
+
+ verify {
+ localBroadcastManager.registerReceiver(any(), any())
+ }
+ }
+
+ @Test
+ fun `broadcast with COURSE_FAVORITES true triggers refresh`() {
+ setupDefaultMocks()
+ val intent: Intent = mockk()
+ val extras: Bundle = mockk()
+ every { intent.extras } returns extras
+ every { extras.getBoolean(Const.COURSE_FAVORITES) } returns true
+
+ viewModel = createViewModel()
+ coEvery { loadFavoriteCoursesUseCase(LoadFavoriteCoursesParams(forceRefresh = true)) } returns emptyList()
+ coEvery { loadGroupsUseCase(LoadGroupsParams(forceRefresh = true)) } returns emptyList()
+
+ val receiverSlot = slot()
+ verify { localBroadcastManager.registerReceiver(capture(receiverSlot), any()) }
+
+ receiverSlot.captured.onReceive(mockk(), intent)
+
+ coVerify { loadFavoriteCoursesUseCase(LoadFavoriteCoursesParams(forceRefresh = true)) }
+ coVerify { loadGroupsUseCase(LoadGroupsParams(forceRefresh = true)) }
+ }
+
+ @Test
+ fun `broadcast without COURSE_FAVORITES does not trigger refresh`() {
+ setupDefaultMocks()
+ val intent: Intent = mockk()
+ val extras: Bundle = mockk()
+ every { intent.extras } returns extras
+ every { extras.getBoolean(Const.COURSE_FAVORITES) } returns false
+
+ viewModel = createViewModel()
+
+ val receiverSlot = slot()
+ verify { localBroadcastManager.registerReceiver(capture(receiverSlot), any()) }
+
+ receiverSlot.captured.onReceive(mockk(), intent)
+
+ coVerify(exactly = 1) { loadFavoriteCoursesUseCase(LoadFavoriteCoursesParams(forceRefresh = false)) }
+ coVerify(exactly = 1) { loadGroupsUseCase(LoadGroupsParams(forceRefresh = false)) }
+ }
+
+ @Test
+ fun `exception during load is recorded to crashlytics`() {
+ setupDefaultMocks()
+ val exception = Exception("Test exception")
+ coEvery { loadFavoriteCoursesUseCase(any()) } throws exception
+
+ viewModel = createViewModel()
+
+ verify { crashlytics.recordException(exception) }
+ assertTrue(viewModel.uiState.value.isError)
+ assertFalse(viewModel.uiState.value.isLoading)
+ }
+
+ private fun createViewModel(): CoursesWidgetViewModel {
+ return CoursesWidgetViewModel(
+ loadFavoriteCoursesUseCase = loadFavoriteCoursesUseCase,
+ loadGroupsUseCase = loadGroupsUseCase,
+ loadCourseUseCase = loadCourseUseCase,
+ sectionExpandedStateDataStore = sectionExpandedStateDataStore,
+ coursesWidgetBehavior = coursesWidgetBehavior,
+ courseSyncSettingsDao = courseSyncSettingsDao,
+ networkStateProvider = networkStateProvider,
+ featureFlagProvider = featureFlagProvider,
+ crashlytics = crashlytics,
+ localBroadcastManager = localBroadcastManager
+ )
+ }
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCaseTest.kt
index 8ba93514bc..e8cc8463f9 100644
--- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCaseTest.kt
+++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/usecase/EnsureDefaultWidgetsUseCaseTest.kt
@@ -70,6 +70,13 @@ class EnsureDefaultWidgetsUseCaseTest {
}
)
}
+ coVerify {
+ repository.saveMetadata(
+ match {
+ it.id == "courses" && it.position == 3 && it.isVisible && it.isFullWidth
+ }
+ )
+ }
}
@Test
@@ -77,7 +84,8 @@ class EnsureDefaultWidgetsUseCaseTest {
val existingMetadata = listOf(
WidgetMetadata("course_invitations", 0, true, false),
WidgetMetadata("institutional_announcements", 1, true, false),
- WidgetMetadata("welcome", 2, true)
+ WidgetMetadata("welcome", 2, true),
+ WidgetMetadata("courses", 3, true, isFullWidth = true)
)
coEvery { repository.observeAllMetadata() } returns flowOf(existingMetadata)
@@ -110,6 +118,11 @@ class EnsureDefaultWidgetsUseCaseTest {
match { it.id == "welcome" }
)
}
+ coVerify(exactly = 1) {
+ repository.saveMetadata(
+ match { it.id == "courses" }
+ )
+ }
coVerify(exactly = 0) {
repository.saveMetadata(
match { it.id == "other-widget" }