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" }