diff --git a/apps/CLAUDE.md b/apps/CLAUDE.md index f7db45f50d..070ca31112 100644 --- a/apps/CLAUDE.md +++ b/apps/CLAUDE.md @@ -235,9 +235,10 @@ Apps support multiple languages. Translation tags are scanned at build time via When creating a pull request, use the template located at `/PULL_REQUEST_TEMPLATE` in the repository root. The template includes: - Test plan description - Issue references (refs:) -- Impact scope (affects:) +- Impact scope (affects: - only Student, Teacher, or Parent; can be multiple if affecting multiple apps) - Release note -- Screenshots table (Before/After) -- Checklist (E2E tests, dark/light mode, landscape/tablet, accessibility, product approval) +- Checklist (dark/light mode, landscape/tablet, accessibility, product approval) + +Note: Do not include E2E tests or screenshots sections in the PR description unless specifically needed. Use `gh pr create` with the template to create PRs from the command line. \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/DashboardScreenTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/DashboardScreenTest.kt new file mode 100644 index 0000000000..b1f87222a3 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/rendertests/DashboardScreenTest.kt @@ -0,0 +1,90 @@ +package com.instructure.student.ui.rendertests + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.student.features.dashboard.compose.DashboardScreenContent +import com.instructure.student.features.dashboard.compose.DashboardUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DashboardScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testDashboardScreenShowsLoadingState() { + val mockUiState = DashboardUiState( + loading = true, + error = null, + refreshing = false, + onRefresh = {}, + onRetry = {} + ) + + composeTestRule.setContent { + DashboardScreenContent(uiState = mockUiState) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("loading").assertIsDisplayed() + } + + @Test + fun testDashboardScreenShowsErrorState() { + val mockUiState = DashboardUiState( + loading = false, + error = "An error occurred", + refreshing = false, + onRefresh = {}, + onRetry = {} + ) + + composeTestRule.setContent { + DashboardScreenContent(uiState = mockUiState) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("errorContent").assertIsDisplayed() + } + + @Test + fun testDashboardScreenShowsEmptyState() { + val mockUiState = DashboardUiState( + loading = false, + error = null, + refreshing = false, + onRefresh = {}, + onRetry = {} + ) + + composeTestRule.setContent { + DashboardScreenContent(uiState = mockUiState) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("emptyContent").assertIsDisplayed() + } + + @Test + fun testDashboardScreenShowsRefreshIndicator() { + val mockUiState = DashboardUiState( + loading = false, + error = null, + refreshing = true, + onRefresh = {}, + onRetry = {} + ) + + composeTestRule.setContent { + DashboardScreenContent(uiState = mockUiState) + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag("dashboardPullRefreshIndicator").assertIsDisplayed() + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt index f39b6315ad..f9287b83ac 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt @@ -138,7 +138,7 @@ import com.instructure.student.features.modules.progression.CourseModuleProgress import com.instructure.student.features.navigation.NavigationRepository import com.instructure.student.features.todolist.ToDoListFragment import com.instructure.student.fragment.BookmarksFragment -import com.instructure.student.fragment.DashboardFragment +import com.instructure.student.fragment.OldDashboardFragment import com.instructure.student.fragment.NotificationListFragment import com.instructure.student.fragment.OldToDoListFragment import com.instructure.student.mobius.assignmentDetails.submission.picker.PickerSubmissionUploadEffectHandler @@ -1012,7 +1012,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. private fun selectBottomNavFragment(fragmentClass: Class) { val selectedFragment = supportFragmentManager.findFragmentByTag(fragmentClass.name) - (topFragment as? DashboardFragment)?.cancelCardDrag() + (topFragment as? OldDashboardFragment)?.cancelCardDrag() if (selectedFragment == null) { val fragment = createBottomNavFragment(fragmentClass.name) 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 new file mode 100644 index 0000000000..0aef31261c --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardFragment.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2024 - 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.compose + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.interactions.router.Route +import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.student.fragment.ParentFragment +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class DashboardFragment : ParentFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + applyTheme() + return ComposeView(requireContext()).apply { + setContent { + CanvasTheme { + DashboardScreen() + } + } + } + } + + override fun title(): String = "" + + override fun applyTheme() { + navigation?.attachNavigationDrawer(this, null) + } + + companion object { + fun makeRoute(canvasContext: CanvasContext?) = + Route(DashboardFragment::class.java, canvasContext) + + fun newInstance(route: Route): DashboardFragment { + val fragment = DashboardFragment() + fragment.arguments = route.arguments + return fragment + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..07083a791b --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardScreen.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2024 - 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.compose + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.instructure.pandautils.compose.composables.CanvasThemedAppBar +import com.instructure.pandautils.compose.composables.EmptyContent +import com.instructure.pandautils.compose.composables.ErrorContent +import com.instructure.pandautils.compose.composables.Loading +import com.instructure.student.R +import com.instructure.student.activity.NavigationActivity + +@Composable +fun DashboardScreen() { + val viewModel: DashboardViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsState() + + DashboardScreenContent(uiState = uiState) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun DashboardScreenContent(uiState: DashboardUiState) { + val activity = LocalActivity.current + val pullRefreshState = rememberPullRefreshState( + refreshing = uiState.refreshing, + onRefresh = uiState.onRefresh + ) + + Scaffold( + modifier = Modifier.background(colorResource(R.color.backgroundLightest)), + topBar = { + CanvasThemedAppBar( + title = stringResource(id = R.string.dashboard), + navIconRes = R.drawable.ic_hamburger, + navIconContentDescription = stringResource(id = R.string.navigation_drawer_open), + navigationActionClick = { (activity as? NavigationActivity)?.openNavigationDrawer() } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .background(colorResource(R.color.backgroundLightest)) + .padding(paddingValues) + .pullRefresh(pullRefreshState) + .fillMaxSize() + ) { + when { + uiState.error != null -> { + ErrorContent( + errorMessage = uiState.error, + retryClick = uiState.onRetry, + modifier = Modifier + .fillMaxSize() + .testTag("errorContent") + ) + } + + uiState.loading -> { + Loading(modifier = Modifier + .fillMaxSize() + .testTag("loading")) + } + + else -> { + EmptyContent( + emptyMessage = stringResource(id = R.string.noCoursesSubtext), + imageRes = R.drawable.ic_panda_nocourses, + modifier = Modifier + .fillMaxSize() + .testTag("emptyContent") + ) + } + } + + PullRefreshIndicator( + refreshing = uiState.refreshing, + state = pullRefreshState, + modifier = Modifier + .align(Alignment.TopCenter) + .testTag("dashboardPullRefreshIndicator") + ) + } + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardUiState.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardUiState.kt new file mode 100644 index 0000000000..b18f6c4929 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardUiState.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2024 - 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.compose + +data class DashboardUiState( + val loading: Boolean = true, + val error: String? = null, + val refreshing: Boolean = false, + val onRefresh: () -> Unit = {}, + val onRetry: () -> Unit = {} +) \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardViewModel.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardViewModel.kt new file mode 100644 index 0000000000..9aee850464 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/compose/DashboardViewModel.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2024 - 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.compose + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.pandautils.utils.NetworkStateProvider +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DashboardViewModel @Inject constructor( + private val networkStateProvider: NetworkStateProvider +) : ViewModel() { + + private val _uiState = MutableStateFlow( + DashboardUiState( + onRefresh = ::onRefresh, + onRetry = ::onRetry + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _refreshSignal = MutableSharedFlow() + val refreshSignal = _refreshSignal.asSharedFlow() + + init { + loadDashboard() + } + + private fun loadDashboard() { + viewModelScope.launch { + _uiState.update { it.copy(loading = true, error = null) } + try { + _uiState.update { it.copy(loading = false, error = null) } + } catch (e: Exception) { + _uiState.update { it.copy(loading = false, error = e.message) } + } + } + } + + private fun onRefresh() { + viewModelScope.launch { + _uiState.update { it.copy(refreshing = true, error = null) } + try { + _refreshSignal.emit(Unit) + _uiState.update { it.copy(refreshing = false, error = null) } + } catch (e: Exception) { + _uiState.update { it.copy(refreshing = false, error = e.message) } + } + } + } + + private fun onRetry() { + loadDashboard() + } +} diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/di/DashboardModule.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/di/DashboardModule.kt new file mode 100644 index 0000000000..ccd9aec59f --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/di/DashboardModule.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2024 - 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.di + +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +class DashboardModule \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/OldDashboardFragment.kt similarity index 98% rename from apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt rename to apps/student/src/main/java/com/instructure/student/fragment/OldDashboardFragment.kt index 392aed5002..2cd28ac3b2 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/OldDashboardFragment.kt @@ -104,7 +104,7 @@ private const val LIST_SPAN_COUNT = 1 @ScreenView(SCREEN_VIEW_DASHBOARD) @PageView @AndroidEntryPoint -class DashboardFragment : ParentFragment() { +class OldDashboardFragment : ParentFragment() { @Inject lateinit var repository: DashboardRepository @@ -261,7 +261,7 @@ class DashboardFragment : ParentFragment() { with (binding) { toolbar.title = title() // Styling done in attachNavigationDrawer - navigation?.attachNavigationDrawer(this@DashboardFragment, toolbar) + navigation?.attachNavigationDrawer(this@OldDashboardFragment, toolbar) recyclerAdapter?.notifyDataSetChanged() } @@ -517,10 +517,10 @@ class DashboardFragment : ParentFragment() { companion object { fun newInstance(route: Route) = - DashboardFragment().apply { + OldDashboardFragment().apply { arguments = route.canvasContext?.makeBundle(route.arguments) ?: route.arguments } - fun makeRoute(canvasContext: CanvasContext?) = Route(DashboardFragment::class.java, canvasContext) + fun makeRoute(canvasContext: CanvasContext?) = Route(OldDashboardFragment::class.java, canvasContext) } } diff --git a/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt b/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt index 6a84f493a2..d67a64675a 100644 --- a/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt +++ b/apps/student/src/main/java/com/instructure/student/navigation/DefaultNavigationBehavior.kt @@ -19,25 +19,37 @@ package com.instructure.student.navigation import androidx.fragment.app.Fragment import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.RemoteConfigParam +import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.interactions.router.Route import com.instructure.pandautils.features.calendar.CalendarFragment import com.instructure.pandautils.utils.CanvasFont import com.instructure.student.R -import com.instructure.student.fragment.DashboardFragment +import com.instructure.student.features.dashboard.compose.DashboardFragment +import com.instructure.student.fragment.OldDashboardFragment import com.instructure.student.fragment.NotificationListFragment import com.instructure.student.fragment.ParentFragment -class DefaultNavigationBehavior(private val apiPrefs: ApiPrefs) : NavigationBehavior { +class DefaultNavigationBehavior(apiPrefs: ApiPrefs) : NavigationBehavior { + + private val dashboardFragmentClass: Class + get() { + return if (RemoteConfigUtils.getBoolean(RemoteConfigParam.DASHBOARD_REDESIGN)) { + DashboardFragment::class.java + } else { + OldDashboardFragment::class.java + } + } override val bottomNavBarFragments: List> = listOf( - DashboardFragment::class.java, + dashboardFragmentClass, CalendarFragment::class.java, todoFragmentClass, NotificationListFragment::class.java, getInboxBottomBarFragment(apiPrefs) ) - override val homeFragmentClass: Class = DashboardFragment::class.java + override val homeFragmentClass: Class = dashboardFragmentClass override val visibleNavigationMenuItems: Set = setOf(NavigationMenuItem.FILES, NavigationMenuItem.BOOKMARKS, NavigationMenuItem.SETTINGS) @@ -51,10 +63,18 @@ class DefaultNavigationBehavior(private val apiPrefs: ApiPrefs) : NavigationBeha override val bottomBarMenu: Int = R.menu.bottom_bar_menu override fun createHomeFragmentRoute(canvasContext: CanvasContext?): Route { - return DashboardFragment.makeRoute(ApiPrefs.user) + return if (RemoteConfigUtils.getBoolean(RemoteConfigParam.DASHBOARD_REDESIGN)) { + DashboardFragment.makeRoute(ApiPrefs.user) + } else { + OldDashboardFragment.makeRoute(ApiPrefs.user) + } } override fun createHomeFragment(route: Route): ParentFragment { - return DashboardFragment.newInstance(route) + return if (RemoteConfigUtils.getBoolean(RemoteConfigParam.DASHBOARD_REDESIGN)) { + DashboardFragment.newInstance(route) + } else { + OldDashboardFragment.newInstance(route) + } } } \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt b/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt index 4f6a29c8c8..f6d8c16c13 100644 --- a/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt +++ b/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt @@ -35,7 +35,7 @@ interface NavigationBehavior { /** 'Root' fragments that should include the bottom nav bar */ val bottomNavBarFragments: List> - val homeFragmentClass: Class + val homeFragmentClass: Class val visibleNavigationMenuItems: Set diff --git a/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt b/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt index 12a77f8045..26d06d5a7d 100644 --- a/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt +++ b/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt @@ -86,7 +86,7 @@ import com.instructure.student.features.todolist.ToDoListFragment import com.instructure.student.fragment.AnnouncementListFragment import com.instructure.student.fragment.BasicQuizViewFragment import com.instructure.student.fragment.CourseSettingsFragment -import com.instructure.student.fragment.DashboardFragment +import com.instructure.student.fragment.OldDashboardFragment import com.instructure.student.fragment.InternalWebviewFragment import com.instructure.student.fragment.NotificationListFragment import com.instructure.student.fragment.OldToDoListFragment @@ -122,7 +122,7 @@ object RouteMatcher : BaseRouteMatcher() { // Be sensitive to the order of items. It really, really matters. @androidx.annotation.OptIn(com.google.android.material.badge.ExperimentalBadgeUtils::class) private fun initRoutes() { - routes.add(Route("/", DashboardFragment::class.java)) + routes.add(Route("/", OldDashboardFragment::class.java)) // region Conversations routes.add(Route("/conversations", InboxFragment::class.java)) routes.add(Route("/conversations/:${InboxDetailsFragment.CONVERSATION_ID}", InboxDetailsFragment::class.java)) @@ -132,7 +132,7 @@ object RouteMatcher : BaseRouteMatcher() { ////////////////////////// // Courses ////////////////////////// - routes.add(Route(courseOrGroup("/"), DashboardFragment::class.java)) + routes.add(Route(courseOrGroup("/"), OldDashboardFragment::class.java)) routes.add( Route( courseOrGroup("/:${RouterParams.COURSE_ID}"), diff --git a/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt b/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt index 8b665681fd..55d488a0fa 100644 --- a/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt +++ b/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt @@ -30,6 +30,7 @@ import com.instructure.pandautils.utils.Const import com.instructure.student.AnnotationComments.AnnotationCommentListFragment import com.instructure.student.activity.NothingToSeeHereFragment import com.instructure.student.features.coursebrowser.CourseBrowserFragment +import com.instructure.student.features.dashboard.compose.DashboardFragment import com.instructure.student.features.discussion.details.DiscussionDetailsFragment import com.instructure.student.features.discussion.list.DiscussionListFragment import com.instructure.student.features.elementary.course.ElementaryCourseFragment @@ -51,14 +52,14 @@ import com.instructure.student.fragment.AnnouncementListFragment import com.instructure.student.fragment.AssignmentBasicFragment import com.instructure.student.fragment.BasicQuizViewFragment import com.instructure.student.fragment.CourseSettingsFragment -import com.instructure.student.fragment.DashboardFragment +import com.instructure.student.fragment.OldDashboardFragment import com.instructure.student.fragment.EditPageDetailsFragment import com.instructure.student.fragment.FeatureFlagsFragment import com.instructure.student.fragment.InternalWebviewFragment import com.instructure.student.fragment.NotificationListFragment +import com.instructure.student.fragment.OldToDoListFragment import com.instructure.student.fragment.ProfileSettingsFragment import com.instructure.student.fragment.StudioWebViewFragment -import com.instructure.student.fragment.OldToDoListFragment import com.instructure.student.fragment.UnknownItemFragment import com.instructure.student.fragment.UnsupportedFeatureFragment import com.instructure.student.fragment.UnsupportedTabFragment @@ -113,6 +114,7 @@ object RouteResolver { // Divided up into two camps, those who need a valid CanvasContext and those who do not return when { + cls.isA() -> OldDashboardFragment.newInstance(route) cls.isA() -> DashboardFragment.newInstance(route) cls.isA() -> ElementaryDashboardFragment.newInstance(route) cls.isA() -> OldToDoListFragment.newInstance(route) diff --git a/apps/student/src/test/java/com/instructure/student/features/dashboard/compose/DashboardViewModelTest.kt b/apps/student/src/test/java/com/instructure/student/features/dashboard/compose/DashboardViewModelTest.kt new file mode 100644 index 0000000000..0f23ebe61f --- /dev/null +++ b/apps/student/src/test/java/com/instructure/student/features/dashboard/compose/DashboardViewModelTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2024 - 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.compose + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.instructure.pandautils.utils.NetworkStateProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import junit.framework.Assert.assertEquals +import junit.framework.Assert.assertFalse +import junit.framework.Assert.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class DashboardViewModelTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + private val lifecycleOwner: LifecycleOwner = mockk(relaxed = true) + private val lifecycleRegistry = LifecycleRegistry(lifecycleOwner) + + private val testDispatcher = UnconfinedTestDispatcher() + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + + private lateinit var viewModel: DashboardViewModel + + @Before + fun setUp() { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + Dispatchers.setMain(testDispatcher) + + every { networkStateProvider.isOnline() } returns true + + viewModel = DashboardViewModel(networkStateProvider) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun testInitialState() = runTest { + val state = viewModel.uiState.value + + assertFalse(state.loading) + assertEquals(null, state.error) + assertFalse(state.refreshing) + } + + @Test + fun testLoadDashboardSuccess() = runTest { + val state = viewModel.uiState.value + + assertEquals(false, state.loading) + assertEquals(null, state.error) + } + + @Test + fun testRefresh() = runTest { + viewModel.uiState.value.onRefresh() + + val state = viewModel.uiState.value + assertFalse(state.refreshing) + assertEquals(null, state.error) + } + + @Test + fun testRetry() = runTest { + viewModel.uiState.value.onRetry() + + val state = viewModel.uiState.value + assertFalse(state.loading) + assertEquals(null, state.error) + } + + @Test + fun testCallbacksExist() { + val state = viewModel.uiState.value + + assertTrue(state.onRefresh != null) + assertTrue(state.onRetry != null) + } +} \ No newline at end of file diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/RemoteConfigUtils.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/RemoteConfigUtils.kt index 621281f55f..751e9cc3e9 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/RemoteConfigUtils.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/RemoteConfigUtils.kt @@ -17,7 +17,8 @@ enum class RemoteConfigParam(val rc_name: String, val safeValueAsString: String) TEST_LONG("test_long", "42"), TEST_STRING("test_string", "hey there"), SPEEDGRADER_V2("speedgrader_v2", "true"), - TODO_REDESIGN("todo_redesign", "false") + TODO_REDESIGN("todo_redesign", "false"), + DASHBOARD_REDESIGN("dashboard_redesign", "false") } /** diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/BaseFlowUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/BaseFlowUseCase.kt new file mode 100644 index 0000000000..00861f4145 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/BaseFlowUseCase.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024 - 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 + +import kotlinx.coroutines.flow.Flow + +/** + * Base class for use cases that return a Flow of results. + * + * Use this for use cases that emit multiple values over time or need reactive updates. + * + * @param Params The type of parameters this use case accepts + * @param Result The type of result this use case emits + * + * Usage: + * ``` + * class ObserveUserUpdatesUseCase @Inject constructor( + * private val userRepository: UserRepository + * ) : BaseFlowUseCase>() { + * override fun execute(params: String): Flow> { + * return userRepository.observeUser(params) + * .map { UseCaseResult.Success(it) } + * .catch { emit(UseCaseResult.Error(it)) } + * } + * } + * + * // Collect results + * observeUserUpdatesUseCase("user123").collect { result -> + * when (result) { + * is UseCaseResult.Success -> // Handle success + * is UseCaseResult.Error -> // Handle error + * is UseCaseResult.Loading -> // Handle loading + * } + * } + * ``` + */ +abstract class BaseFlowUseCase { + abstract fun execute(params: Params): Flow + + operator fun invoke(params: Params): Flow = execute(params) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/BaseUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/BaseUseCase.kt new file mode 100644 index 0000000000..9b86b2228e --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/BaseUseCase.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2024 - 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 + +/** + * Base class for use cases that execute suspending operations and return a single result. + * + * @param Params The type of parameters this use case accepts + * @param Result The type of result this use case returns + * + * Usage: + * ``` + * class GetUserUseCase @Inject constructor( + * private val userRepository: UserRepository + * ) : BaseUseCase() { + * override suspend fun execute(params: String): User { + * return userRepository.getUser(params) + * } + * } + * + * // Call with invoke operator + * val user = getUserUseCase("user123") + * ``` + */ +abstract class BaseUseCase { + abstract suspend fun execute(params: Params): Result + + suspend operator fun invoke(params: Params): Result = execute(params) +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/UseCaseResult.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/UseCaseResult.kt new file mode 100644 index 0000000000..b1831eab10 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/UseCaseResult.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 - 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 + +/** + * Represents the result of a use case operation. + * + * Can be in one of three states: + * - Success: Operation completed successfully with data + * - Error: Operation failed with an exception + * - Loading: Operation is in progress + * + * Usage: + * ``` + * when (result) { + * is UseCaseResult.Success -> { + * val data = result.data + * // Handle success + * } + * is UseCaseResult.Error -> { + * val exception = result.exception + * // Handle error + * } + * is UseCaseResult.Loading -> { + * // Handle loading state + * } + * } + * ``` + */ +sealed class UseCaseResult { + data class Success(val data: T) : UseCaseResult() + data class Error(val exception: Throwable) : UseCaseResult() + object Loading : UseCaseResult() +} \ No newline at end of file