From 8fd8a80e1f25cf180ff522f919fbd5dd5b0867e6 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Tue, 28 Oct 2025 07:45:22 +0100 Subject: [PATCH 01/13] Feature skeleton and items UI. --- .../student/activity/NavigationActivity.kt | 27 +- .../student/di/feature/ToDoListModule.kt | 36 ++ .../todolist/StudentToDoListRouter.kt | 43 ++ .../features/todolist/ToDoListFragment.kt | 117 ++++++ ...ListFragment.kt => OldToDoListFragment.kt} | 8 +- .../navigation/DefaultNavigationBehavior.kt | 3 +- .../ElementaryNavigationBehavior.kt | 3 +- .../student/navigation/NavigationBehavior.kt | 12 + .../student/router/RouteMatcher.kt | 11 +- .../student/router/RouteResolver.kt | 4 +- .../student/util/FeatureFlagPrefs.kt | 3 + libs/pandares/src/main/res/values/strings.xml | 4 + .../features/todolist/ToDoListRepository.kt | 25 ++ .../features/todolist/ToDoListRouter.kt | 25 ++ .../features/todolist/ToDoListScreen.kt | 380 ++++++++++++++++++ .../features/todolist/ToDoListUiState.kt | 57 +++ .../features/todolist/ToDoListViewModel.kt | 57 +++ 17 files changed, 796 insertions(+), 19 deletions(-) create mode 100644 apps/student/src/main/java/com/instructure/student/di/feature/ToDoListModule.kt create mode 100644 apps/student/src/main/java/com/instructure/student/features/todolist/StudentToDoListRouter.kt create mode 100644 apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt rename apps/student/src/main/java/com/instructure/student/fragment/{ToDoListFragment.kt => OldToDoListFragment.kt} (97%) create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRouter.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt 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 94d384b33d..474bd7f875 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 @@ -134,10 +134,11 @@ import com.instructure.student.events.UserUpdatedEvent import com.instructure.student.features.files.list.FileListFragment import com.instructure.student.features.modules.progression.CourseModuleProgressionFragment 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.NotificationListFragment -import com.instructure.student.fragment.ToDoListFragment +import com.instructure.student.fragment.OldToDoListFragment import com.instructure.student.mobius.assignmentDetails.submission.picker.PickerSubmissionUploadEffectHandler import com.instructure.student.mobius.assignmentDetails.submissionDetails.content.emptySubmission.ui.SubmissionDetailsEmptyContentFragment import com.instructure.student.navigation.AccountMenuItem @@ -150,6 +151,7 @@ import com.instructure.student.router.RouteResolver import com.instructure.student.tasks.StudentLogoutTask import com.instructure.student.util.Analytics import com.instructure.student.util.AppShortcutManager +import com.instructure.student.util.FeatureFlagPrefs import com.instructure.student.util.StudentPrefs import com.instructure.student.widget.WidgetUpdater import dagger.hilt.android.AndroidEntryPoint @@ -537,7 +539,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. } AppShortcutManager.APP_SHORTCUT_CALENDAR -> selectBottomNavFragment( CalendarFragment::class.java) - AppShortcutManager.APP_SHORTCUT_TODO -> selectBottomNavFragment(ToDoListFragment::class.java) + AppShortcutManager.APP_SHORTCUT_TODO -> selectBottomNavFragment(navigationBehavior.todoFragmentClass) AppShortcutManager.APP_SHORTCUT_NOTIFICATIONS -> selectBottomNavFragment(NotificationListFragment::class.java) AppShortcutManager.APP_SHORTCUT_INBOX -> { if (ApiPrefs.isStudentView) { @@ -789,7 +791,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. when (item.itemId) { R.id.bottomNavigationHome -> selectBottomNavFragment(navigationBehavior.homeFragmentClass) R.id.bottomNavigationCalendar -> selectBottomNavFragment(CalendarFragment::class.java) - R.id.bottomNavigationToDo -> selectBottomNavFragment(ToDoListFragment::class.java) + R.id.bottomNavigationToDo -> selectBottomNavFragment(navigationBehavior.todoFragmentClass) R.id.bottomNavigationNotifications -> selectBottomNavFragment(NotificationListFragment::class.java) R.id.bottomNavigationInbox -> { if (ApiPrefs.isStudentView) { @@ -812,7 +814,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. R.id.bottomNavigationHome -> abortReselect = currentFragmentClass.isAssignableFrom(navigationBehavior.homeFragmentClass) R.id.bottomNavigationCalendar -> abortReselect = currentFragmentClass.isAssignableFrom( CalendarFragment::class.java) - R.id.bottomNavigationToDo -> abortReselect = currentFragmentClass.isAssignableFrom(ToDoListFragment::class.java) + R.id.bottomNavigationToDo -> abortReselect = currentFragmentClass.isAssignableFrom(navigationBehavior.todoFragmentClass) R.id.bottomNavigationNotifications -> abortReselect = currentFragmentClass.isAssignableFrom(NotificationListFragment::class.java) R.id.bottomNavigationInbox -> abortReselect = currentFragmentClass.isAssignableFrom(InboxFragment::class.java) } @@ -822,7 +824,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. when (item.itemId) { R.id.bottomNavigationHome -> selectBottomNavFragment(navigationBehavior.homeFragmentClass) R.id.bottomNavigationCalendar -> selectBottomNavFragment(CalendarFragment::class.java) - R.id.bottomNavigationToDo -> selectBottomNavFragment(ToDoListFragment::class.java) + R.id.bottomNavigationToDo -> selectBottomNavFragment(navigationBehavior.todoFragmentClass) R.id.bottomNavigationNotifications -> selectBottomNavFragment(NotificationListFragment::class.java) R.id.bottomNavigationInbox -> { if (ApiPrefs.isStudentView) { @@ -875,6 +877,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. is EventFragment -> setBottomBarItemSelected(R.id.bottomNavigationCalendar) //To-do is ToDoListFragment -> setBottomBarItemSelected(R.id.bottomNavigationToDo) + is OldToDoListFragment -> setBottomBarItemSelected(R.id.bottomNavigationToDo) //Notifications is NotificationListFragment-> { setBottomBarItemSelected(if(fragment.isCourseOrGroup()) R.id.bottomNavigationHome @@ -1306,9 +1309,17 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. val route = CalendarFragment.makeRoute() CalendarFragment.newInstance(route) } - ToDoListFragment::class.java.name -> { - val route = ToDoListFragment.makeRoute(ApiPrefs.user!!) - ToDoListFragment.newInstance(route) + navigationBehavior.todoFragmentClass.name -> { + val route = if (FeatureFlagPrefs.ENABLE_NEW_TODO_LIST_SCREEN) { + ToDoListFragment.makeRoute(ApiPrefs.user!!) + } else { + OldToDoListFragment.makeRoute(ApiPrefs.user!!) + } + if (FeatureFlagPrefs.ENABLE_NEW_TODO_LIST_SCREEN) { + ToDoListFragment.newInstance(route) + } else { + OldToDoListFragment.newInstance(route) + } } NotificationListFragment::class.java.name -> { val route = NotificationListFragment.makeRoute(ApiPrefs.user!!) diff --git a/apps/student/src/main/java/com/instructure/student/di/feature/ToDoListModule.kt b/apps/student/src/main/java/com/instructure/student/di/feature/ToDoListModule.kt new file mode 100644 index 0000000000..29cbb5ffb3 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/di/feature/ToDoListModule.kt @@ -0,0 +1,36 @@ +/* + * 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 androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import com.instructure.pandautils.features.todolist.ToDoListRouter +import com.instructure.student.features.todolist.StudentToDoListRouter +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.FragmentComponent + +@Module +@InstallIn(FragmentComponent::class) +class ToDoListModule { + + @Provides + fun provideToDoListRouter(activity: FragmentActivity, fragment: Fragment): ToDoListRouter { + return StudentToDoListRouter(activity, fragment) + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/todolist/StudentToDoListRouter.kt b/apps/student/src/main/java/com/instructure/student/features/todolist/StudentToDoListRouter.kt new file mode 100644 index 0000000000..8e1ec238a0 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/todolist/StudentToDoListRouter.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.features.todolist + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import com.instructure.pandautils.features.todolist.ToDoListRouter +import com.instructure.student.activity.NavigationActivity + +class StudentToDoListRouter( + private val activity: FragmentActivity, + private val fragment: Fragment +) : ToDoListRouter { + + override fun openNavigationDrawer() { + (activity as? NavigationActivity)?.openNavigationDrawer() + } + + override fun attachNavigationDrawer() { + val toDoListFragment = fragment as? ToDoListFragment + if (toDoListFragment != null) { + (activity as? NavigationActivity)?.attachNavigationDrawer(toDoListFragment, null) + } + } + + override fun openToDoItem(itemId: String) { + // TODO: Implement navigation to specific to-do item based on item type + } +} diff --git a/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt new file mode 100644 index 0000000000..9797b598d3 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt @@ -0,0 +1,117 @@ +/* + * 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.todolist + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.utils.pageview.PageView +import com.instructure.interactions.FragmentInteractions +import com.instructure.interactions.Navigation +import com.instructure.interactions.router.Route +import com.instructure.pandautils.analytics.SCREEN_VIEW_TO_DO_LIST +import com.instructure.pandautils.analytics.ScreenView +import com.instructure.pandautils.base.BaseCanvasFragment +import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.features.todolist.ToDoListRouter +import com.instructure.pandautils.features.todolist.ToDoListScreen +import com.instructure.pandautils.features.todolist.ToDoListViewModel +import com.instructure.pandautils.features.todolist.ToDoListViewModelAction +import com.instructure.pandautils.interfaces.NavigationCallbacks +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.collectOneOffEvents +import com.instructure.pandautils.utils.makeBundle +import com.instructure.pandautils.utils.withArgs +import com.instructure.student.R +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@PageView +@ScreenView(SCREEN_VIEW_TO_DO_LIST) +@AndroidEntryPoint +class ToDoListFragment : BaseCanvasFragment(), FragmentInteractions, NavigationCallbacks { + + private val viewModel: ToDoListViewModel by viewModels() + + @Inject + lateinit var toDoListRouter: ToDoListRouter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + applyTheme() + viewLifecycleOwner.lifecycleScope.collectOneOffEvents(viewModel.events, ::handleAction) + + return ComposeView(requireActivity()).apply { + setContent { + CanvasTheme { + val uiState by viewModel.uiState.collectAsState() + + ToDoListScreen( + uiState = uiState, + actionHandler = viewModel::handleAction, + navigationIconClick = { toDoListRouter.openNavigationDrawer() } + ) + } + } + } + } + + override val navigation: Navigation? + get() = activity as? Navigation + + override fun title(): String = getString(R.string.Todo) + + override fun applyTheme() { + ViewStyler.setStatusBarDark(requireActivity(), ThemePrefs.primaryColor) + toDoListRouter.attachNavigationDrawer() + } + + override fun getFragment(): Fragment = this + + private fun handleAction(action: ToDoListViewModelAction) { + when (action) { + is ToDoListViewModelAction.OpenToDoItem -> toDoListRouter.openToDoItem(action.itemId) + } + } + + override fun onHandleBackPressed(): Boolean { + return false + } + + companion object { + fun makeRoute(canvasContext: CanvasContext): Route = Route(ToDoListFragment::class.java, canvasContext, Bundle()) + + private fun validateRoute(route: Route) = route.canvasContext != null + + fun newInstance(route: Route): ToDoListFragment? { + if (!validateRoute(route)) return null + return ToDoListFragment().withArgs(route.canvasContext!!.makeBundle()) + } + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/fragment/ToDoListFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/OldToDoListFragment.kt similarity index 97% rename from apps/student/src/main/java/com/instructure/student/fragment/ToDoListFragment.kt rename to apps/student/src/main/java/com/instructure/student/fragment/OldToDoListFragment.kt index 983872fe1b..c87f39ba29 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/ToDoListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/OldToDoListFragment.kt @@ -59,7 +59,7 @@ import javax.inject.Inject @ScreenView(SCREEN_VIEW_TO_DO_LIST) @PageView @AndroidEntryPoint -class ToDoListFragment : ParentFragment() { +class OldToDoListFragment : ParentFragment() { private val binding by viewBinding(FragmentListTodoBinding::bind) private lateinit var recyclerViewBinding: PandaRecyclerRefreshLayoutBinding @@ -250,13 +250,13 @@ class ToDoListFragment : ParentFragment() { } companion object { - fun makeRoute(canvasContext: CanvasContext): Route = Route(ToDoListFragment::class.java, canvasContext, Bundle()) + fun makeRoute(canvasContext: CanvasContext): Route = Route(OldToDoListFragment::class.java, canvasContext, Bundle()) private fun validateRoute(route: Route) = route.canvasContext != null - fun newInstance(route: Route): ToDoListFragment? { + fun newInstance(route: Route): OldToDoListFragment? { if (!validateRoute(route)) return null - return ToDoListFragment().withArgs(route.canvasContext!!.makeBundle()) + return OldToDoListFragment().withArgs(route.canvasContext!!.makeBundle()) } } 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 124ee3612c..6a84f493a2 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 @@ -26,14 +26,13 @@ import com.instructure.student.R import com.instructure.student.fragment.DashboardFragment import com.instructure.student.fragment.NotificationListFragment import com.instructure.student.fragment.ParentFragment -import com.instructure.student.fragment.ToDoListFragment class DefaultNavigationBehavior(private val apiPrefs: ApiPrefs) : NavigationBehavior { override val bottomNavBarFragments: List> = listOf( DashboardFragment::class.java, CalendarFragment::class.java, - ToDoListFragment::class.java, + todoFragmentClass, NotificationListFragment::class.java, getInboxBottomBarFragment(apiPrefs) ) diff --git a/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt b/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt index 9492060f65..6f2e276459 100644 --- a/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt +++ b/apps/student/src/main/java/com/instructure/student/navigation/ElementaryNavigationBehavior.kt @@ -25,7 +25,6 @@ import com.instructure.pandautils.utils.CanvasFont import com.instructure.student.R import com.instructure.student.fragment.NotificationListFragment import com.instructure.student.fragment.ParentFragment -import com.instructure.student.fragment.ToDoListFragment import com.instructure.student.mobius.elementary.ElementaryDashboardFragment class ElementaryNavigationBehavior(private val apiPrefs: ApiPrefs) : NavigationBehavior { @@ -33,7 +32,7 @@ class ElementaryNavigationBehavior(private val apiPrefs: ApiPrefs) : NavigationB override val bottomNavBarFragments: List> = listOf( ElementaryDashboardFragment::class.java, CalendarFragment::class.java, - ToDoListFragment::class.java, + todoFragmentClass, NotificationListFragment::class.java, getInboxBottomBarFragment(apiPrefs) ) 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 7ac1999403..b1d95703d9 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 @@ -24,7 +24,10 @@ import com.instructure.interactions.router.Route import com.instructure.pandautils.features.inbox.list.InboxFragment import com.instructure.pandautils.utils.CanvasFont import com.instructure.student.activity.NothingToSeeHereFragment +import com.instructure.student.features.todolist.ToDoListFragment +import com.instructure.student.fragment.OldToDoListFragment import com.instructure.student.fragment.ParentFragment +import com.instructure.student.util.FeatureFlagPrefs interface NavigationBehavior { @@ -41,6 +44,15 @@ interface NavigationBehavior { val canvasFont: CanvasFont + val todoFragmentClass: Class + get() { + return if (FeatureFlagPrefs.ENABLE_NEW_TODO_LIST_SCREEN) { + ToDoListFragment::class.java + } else { + OldToDoListFragment::class.java + } + } + @get:MenuRes val bottomBarMenu: Int 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 36a68b4187..90df7d898c 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 @@ -80,15 +80,16 @@ import com.instructure.student.features.pages.list.PageListFragment import com.instructure.student.features.people.details.PeopleDetailsFragment import com.instructure.student.features.people.list.PeopleListFragment import com.instructure.student.features.quiz.list.QuizListFragment +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.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.ToDoListFragment import com.instructure.student.fragment.UnsupportedFeatureFragment import com.instructure.student.fragment.UnsupportedTabFragment import com.instructure.student.fragment.ViewHtmlFragment @@ -97,6 +98,7 @@ import com.instructure.student.fragment.ViewUnsupportedFileFragment import com.instructure.student.mobius.assignmentDetails.submissionDetails.ui.SubmissionDetailsFragment import com.instructure.student.mobius.conferences.conference_list.ui.ConferenceListRepositoryFragment import com.instructure.student.mobius.syllabus.ui.SyllabusRepositoryFragment +import com.instructure.student.util.FeatureFlagPrefs import com.instructure.student.util.FileUtils import com.instructure.student.util.onMainThread import java.util.Locale @@ -343,7 +345,12 @@ object RouteMatcher : BaseRouteMatcher() { routes.add(Route("/todos/:${ToDoFragment.PLANNABLE_ID}", ToDoFragment::class.java)) // To Do List - routes.add(Route("/todolist", ToDoListFragment::class.java).copy(canvasContext = ApiPrefs.user)) + val todoListFragmentClass = if (FeatureFlagPrefs.ENABLE_NEW_TODO_LIST_SCREEN) { + ToDoListFragment::class.java + } else { + OldToDoListFragment::class.java + } + routes.add(Route("/todolist", todoListFragmentClass).copy(canvasContext = ApiPrefs.user)) // Syllabus routes.add(Route(courseOrGroup("/:${RouterParams.COURSE_ID}/assignments/syllabus"), SyllabusRepositoryFragment::class.java)) 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 0052ea522e..8b665681fd 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 @@ -45,6 +45,7 @@ import com.instructure.student.features.pages.list.PageListFragment import com.instructure.student.features.people.details.PeopleDetailsFragment import com.instructure.student.features.people.list.PeopleListFragment import com.instructure.student.features.quiz.list.QuizListFragment +import com.instructure.student.features.todolist.ToDoListFragment import com.instructure.student.fragment.AccountPreferencesFragment import com.instructure.student.fragment.AnnouncementListFragment import com.instructure.student.fragment.AssignmentBasicFragment @@ -57,7 +58,7 @@ import com.instructure.student.fragment.InternalWebviewFragment import com.instructure.student.fragment.NotificationListFragment import com.instructure.student.fragment.ProfileSettingsFragment import com.instructure.student.fragment.StudioWebViewFragment -import com.instructure.student.fragment.ToDoListFragment +import com.instructure.student.fragment.OldToDoListFragment import com.instructure.student.fragment.UnknownItemFragment import com.instructure.student.fragment.UnsupportedFeatureFragment import com.instructure.student.fragment.UnsupportedTabFragment @@ -114,6 +115,7 @@ object RouteResolver { return when { cls.isA() -> DashboardFragment.newInstance(route) cls.isA() -> ElementaryDashboardFragment.newInstance(route) + cls.isA() -> OldToDoListFragment.newInstance(route) cls.isA() -> ToDoListFragment.newInstance(route) cls.isA() -> NotificationListFragment.newInstance(route) cls.isA() -> InboxFragment.newInstance(route) diff --git a/apps/student/src/main/java/com/instructure/student/util/FeatureFlagPrefs.kt b/apps/student/src/main/java/com/instructure/student/util/FeatureFlagPrefs.kt index 251ba56c6a..c7a3d8c307 100644 --- a/apps/student/src/main/java/com/instructure/student/util/FeatureFlagPrefs.kt +++ b/apps/student/src/main/java/com/instructure/student/util/FeatureFlagPrefs.kt @@ -19,4 +19,7 @@ import com.instructure.canvasapi2.utils.PrefManager object FeatureFlagPrefs : PrefManager("feature_flags") { + // Temporary feature flag to enable the new To-Do List screen. There is a ticket to implement feature flag handling + const val ENABLE_NEW_TODO_LIST_SCREEN = true + } diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index cb8b0a8079..312e269721 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -2161,4 +2161,8 @@ Discussion Checkpoints Multiple Due Dates Course concluded. Unable to send messages! + Filter + There was an error loading your to-do items. Please check your connection and try again. + No To Dos for now! + It looks like a great time to rest, relax, and recharge. diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt new file mode 100644 index 0000000000..6dee43868f --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt @@ -0,0 +1,25 @@ +/* + * 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.features.todolist + +import com.instructure.canvasapi2.apis.PlannerAPI +import javax.inject.Inject + +class ToDoListRepository @Inject constructor( + private val plannerApi: PlannerAPI.PlannerInterface +) { + // TODO: Implement methods to fetch planner items +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRouter.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRouter.kt new file mode 100644 index 0000000000..020b03a395 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRouter.kt @@ -0,0 +1,25 @@ +/* + * 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.features.todolist + +interface ToDoListRouter { + + fun openNavigationDrawer() + + fun attachNavigationDrawer() + + fun openToDoItem(itemId: String) +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt new file mode 100644 index 0000000000..1be7a1301b --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt @@ -0,0 +1,380 @@ +/* + * 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.features.todolist + +import androidx.compose.foundation.clickable +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.fillMaxSize +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.Checkbox +import androidx.compose.material.CheckboxDefaults +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +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.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.core.graphics.toColorInt +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.pandautils.R +import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.compose.composables.CanvasDivider +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.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.localisedFormat +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ToDoListScreen( + uiState: ToDoListUiState, + actionHandler: (ToDoListActionHandler) -> Unit, + modifier: Modifier = Modifier, + navigationIconClick: () -> Unit = {} +) { + val pullRefreshState = rememberPullRefreshState( + refreshing = uiState.isRefreshing, + onRefresh = { actionHandler(ToDoListActionHandler.Refresh) } + ) + + Scaffold( + backgroundColor = colorResource(R.color.backgroundLightest), + topBar = { + CanvasThemedAppBar( + title = stringResource(id = R.string.Todo), + navIconRes = R.drawable.ic_hamburger, + navIconContentDescription = stringResource(id = R.string.navigation_drawer_open), + navigationActionClick = navigationIconClick, + actions = { + IconButton(onClick = { actionHandler(ToDoListActionHandler.FilterClicked) }) { + Icon( + painter = painterResource(id = R.drawable.ic_filter_outline), + contentDescription = stringResource(id = R.string.a11y_contentDescriptionToDoFilter) + ) + } + } + ) + }, + modifier = modifier + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .pullRefresh(pullRefreshState) + ) { + when { + uiState.isLoading -> { + Loading() + } + + uiState.isError -> { + ErrorContent( + errorMessage = stringResource(id = R.string.errorLoadingToDos), + retryClick = { actionHandler(ToDoListActionHandler.Refresh) }, + modifier = Modifier.fillMaxSize() + ) + } + + uiState.itemsByDate.isEmpty() -> { + EmptyContent( + emptyTitle = stringResource(id = R.string.noToDosForNow), + emptyMessage = stringResource(id = R.string.noToDosForNowSubtext), + imageRes = R.drawable.ic_no_events, + modifier = Modifier.fillMaxSize() + ) + } + + else -> { + ToDoListContent( + itemsByDate = uiState.itemsByDate, + actionHandler = actionHandler + ) + } + } + + PullRefreshIndicator( + refreshing = uiState.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + backgroundColor = colorResource(id = R.color.backgroundLightest), + contentColor = Color(ThemePrefs.brandColor) + ) + } + } +} + +@Composable +private fun ToDoListContent( + itemsByDate: Map>, + actionHandler: (ToDoListActionHandler) -> Unit, + modifier: Modifier = Modifier +) { + val dateGroups = itemsByDate.entries.toList() + LazyColumn(modifier = modifier.fillMaxSize()) { + dateGroups.forEachIndexed { groupIndex, (date, items) -> + items.forEachIndexed { index, item -> + item(key = item.id) { + ToDoItem( + item = item, + showDateBadge = index == 0, + onCheckedChange = { actionHandler(ToDoListActionHandler.ToggleItemChecked(item.id)) }, + onClick = { actionHandler(ToDoListActionHandler.ItemClicked(item.id)) } + ) + } + } + + if (groupIndex < dateGroups.size - 1) { + item(key = "divider_$date") { + CanvasDivider( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .padding(horizontal = 16.dp) + ) + } + } + } + } +} + +@Composable +private fun ToDoItem( + item: ToDoItemUiState, + showDateBadge: Boolean, + onCheckedChange: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val calendar = Calendar.getInstance().apply { + time = item.dueDate + } + val dayOfWeek = SimpleDateFormat("EEE", Locale.getDefault()).format(item.dueDate) + val day = calendar.get(Calendar.DAY_OF_MONTH) + val month = SimpleDateFormat("MMM", Locale.getDefault()).format(item.dueDate) + + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.Top + ) { + Box( + modifier = Modifier + .width(44.dp) + .padding(end = 12.dp), + contentAlignment = Alignment.TopCenter + ) { + if (showDateBadge) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = dayOfWeek, + fontSize = 12.sp, + color = colorResource(id = R.color.textDark) + ) + Text( + text = day.toString(), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = colorResource(id = R.color.textDark) + ) + Text( + text = month, + fontSize = 10.sp, + color = colorResource(id = R.color.textDark) + ) + } + } + } + + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.Top) { + Icon( + painter = painterResource(id = item.itemType.iconRes), + contentDescription = null, + tint = Color(item.contextColor), + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + CanvasDivider( + modifier = Modifier + .width(0.5.dp) + .height(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = item.contextLabel, + fontSize = 14.sp, + color = Color(item.contextColor), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + + Text( + text = item.title, + fontSize = 16.sp, + color = colorResource(id = R.color.textDarkest), + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = item.dueDate.localisedFormat("h:mm a"), + fontSize = 14.sp, + color = colorResource(id = R.color.textDark), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Checkbox( + checked = item.isChecked, + onCheckedChange = { onCheckedChange() }, + colors = CheckboxDefaults.colors( + checkedColor = Color(ThemePrefs.brandColor), + uncheckedColor = colorResource(id = R.color.textDark) + ) + ) + } + } +} + +@Preview(name = "Light Mode", showBackground = true) +@Preview(name = "Dark Mode", showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ToDoListScreenPreview() { + ContextKeeper.appContext = LocalContext.current + val calendar = Calendar.getInstance() + CanvasTheme { + ToDoListScreen( + uiState = ToDoListUiState( + itemsByDate = mapOf( + "Sun Oct 22" to listOf( + ToDoItemUiState( + id = "1", + title = "Short title", + dueDate = calendar.apply { set(2024, 9, 22, 7, 59) }.time, + contextLabel = "COURSE", + contextColor = "#00AC18".toColorInt(), + itemType = ToDoItemType.ASSIGNMENT, + isChecked = false + ), + ToDoItemUiState( + id = "2", + title = "Levitate an object without crushing it, bonus points if you don't scratch the paint", + dueDate = calendar.apply { set(2024, 9, 22, 11, 59) }.time, + contextLabel = "Introduction to Advanced Galactic Force Manipulation and Control Techniques for Beginners", + contextColor = "#2196F3".toColorInt(), + itemType = ToDoItemType.QUIZ, + isChecked = false + ), + ToDoItemUiState( + id = "3", + title = "Identify which emotions lead to Jedi calmness vs. a full Darth Vader office meltdown situation", + dueDate = calendar.apply { set(2024, 9, 22, 14, 30) }.time, + contextLabel = "FORC 101", + contextColor = "#00AC18".toColorInt(), + itemType = ToDoItemType.ASSIGNMENT, + isChecked = true + ) + ), + "Mon 23" to listOf( + ToDoItemUiState( + id = "4", + title = "Essay - Why Force-choking co-workers is frowned upon in most galactic workplaces", + dueDate = calendar.apply { set(2024, 9, 23, 19, 0) }.time, + contextLabel = "Professional Jedi Ethics and Workplace Communication", + contextColor = "#FF5722".toColorInt(), + itemType = ToDoItemType.DISCUSSION, + isChecked = false + ), + ToDoItemUiState( + id = "5", + title = "Q", + dueDate = calendar.apply { set(2024, 9, 23, 23, 59) }.time, + contextLabel = "PHY", + contextColor = "#9C27B0".toColorInt(), + itemType = ToDoItemType.PLANNER_NOTE, + isChecked = false + ), + ToDoItemUiState( + id = "6", + title = "Write a comprehensive research paper analyzing the psychological and physiological effects of prolonged exposure to the Dark Side of the Force on Jedi Knights and their ability to maintain emotional equilibrium", + dueDate = calendar.apply { set(2024, 9, 23, 23, 59) }.time, + contextLabel = "Advanced Force Psychology", + contextColor = "#FF9800".toColorInt(), + itemType = ToDoItemType.ASSIGNMENT, + isChecked = false + ) + ) + ) + ), + actionHandler = {} + ) + } +} + +@Preview(name = "Empty Light Mode", showBackground = true) +@Preview(name = "Empty Dark Mode", showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ToDoListScreenEmptyPreview() { + ContextKeeper.appContext = LocalContext.current + CanvasTheme { + ToDoListScreen( + uiState = ToDoListUiState(), + actionHandler = {} + ) + } +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt new file mode 100644 index 0000000000..3a7915fd4c --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt @@ -0,0 +1,57 @@ +/* + * 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.features.todolist + +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import com.instructure.pandautils.R +import java.util.Date + +data class ToDoListUiState( + val isLoading: Boolean = false, + val isError: Boolean = false, + val isRefreshing: Boolean = false, + val itemsByDate: Map> = emptyMap() +) + +data class ToDoItemUiState( + val id: String, + val title: String, + val dueDate: Date, + val contextLabel: String, + @ColorInt val contextColor: Int, + val itemType: ToDoItemType, + val isChecked: Boolean = false +) + +enum class ToDoItemType(@DrawableRes val iconRes: Int) { + ASSIGNMENT(R.drawable.ic_assignment), + QUIZ(R.drawable.ic_quiz), + DISCUSSION(R.drawable.ic_discussion), + CALENDAR_EVENT(R.drawable.ic_calendar), + PLANNER_NOTE(R.drawable.ic_todo) +} + +sealed class ToDoListViewModelAction { + data class OpenToDoItem(val itemId: String) : ToDoListViewModelAction() +} + +sealed class ToDoListActionHandler { + data object Refresh : ToDoListActionHandler() + data class ToggleItemChecked(val itemId: String) : ToDoListActionHandler() + data class ItemClicked(val itemId: String) : ToDoListActionHandler() + data object FilterClicked : ToDoListActionHandler() +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt new file mode 100644 index 0000000000..04237af49c --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt @@ -0,0 +1,57 @@ +/* + * 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.features.todolist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ToDoListViewModel @Inject constructor( + private val repository: ToDoListRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(ToDoListUiState()) + val uiState = _uiState.asStateFlow() + + private val _events = Channel() + val events = _events.receiveAsFlow() + + fun handleAction(action: ToDoListActionHandler) { + when (action) { + is ToDoListActionHandler.ItemClicked -> { + viewModelScope.launch { + _events.send(ToDoListViewModelAction.OpenToDoItem(action.itemId)) + } + } + is ToDoListActionHandler.Refresh -> { + // TODO: Implement refresh + } + is ToDoListActionHandler.ToggleItemChecked -> { + // TODO: Implement toggle checked + } + is ToDoListActionHandler.FilterClicked -> { + // TODO: Implement filter + } + } + } +} From 7c3a37760b255d0bb8c6ed9dfd341d260f660477 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Tue, 28 Oct 2025 18:42:59 +0100 Subject: [PATCH 02/13] Fetching data, mapping UI items. --- .../student/widget/todo/ToDoWidgetUpdater.kt | 58 +--------- .../features/todolist/ToDoListRepository.kt | 62 +++++++++- .../features/todolist/ToDoListScreen.kt | 72 +++++++----- .../features/todolist/ToDoListUiState.kt | 26 +++-- .../features/todolist/ToDoListViewModel.kt | 108 +++++++++++++++++- .../pandautils/utils/PlannerItemExtensions.kt | 56 +++++++++ 6 files changed, 277 insertions(+), 105 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetUpdater.kt b/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetUpdater.kt index 272cd376f3..4f8d3f64de 100644 --- a/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetUpdater.kt +++ b/apps/student/src/main/java/com/instructure/student/widget/todo/ToDoWidgetUpdater.kt @@ -23,16 +23,15 @@ import com.instructure.canvasapi2.models.PlannableType import com.instructure.canvasapi2.models.PlannerItem import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult -import com.instructure.canvasapi2.utils.DateHelper import com.instructure.canvasapi2.utils.Failure import com.instructure.canvasapi2.utils.toApiString -import com.instructure.canvasapi2.utils.toDate import com.instructure.pandautils.utils.courseOrUserColor +import com.instructure.pandautils.utils.getContextNameForPlannerItem +import com.instructure.pandautils.utils.getDateTextForPlannerItem import com.instructure.pandautils.utils.getIconForPlannerItem import com.instructure.pandautils.utils.getTagForPlannerItem import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.toLocalDate -import com.instructure.student.R import com.instructure.student.widget.glance.WidgetState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -119,59 +118,6 @@ class ToDoWidgetUpdater( tag = getTagForPlannerItem(context) ) - private fun PlannerItem.getContextNameForPlannerItem(context: Context, courses: List): String { - val courseCode = courses.find { it.id == canvasContext.id }?.courseCode - return when (plannableType) { - PlannableType.PLANNER_NOTE -> { - if (contextName.isNullOrEmpty()) { - context.getString(R.string.userCalendarToDo) - } else { - context.getString(R.string.courseToDo, courseCode) - } - } - - else -> { - if (canvasContext is Course) { - courseCode.orEmpty() - } else { - contextName.orEmpty() - } - } - } - } - - private fun PlannerItem.getDateTextForPlannerItem(context: Context): String? { - return when (plannableType) { - PlannableType.PLANNER_NOTE -> { - plannable.todoDate.toDate()?.let { - DateHelper.getFormattedTime(context, it) - } - } - - PlannableType.CALENDAR_EVENT -> { - val startDate = plannable.startAt - val endDate = plannable.endAt - if (startDate != null && endDate != null) { - val startText = DateHelper.getFormattedTime(context, startDate).orEmpty() - val endText = DateHelper.getFormattedTime(context, endDate).orEmpty() - - when { - plannable.allDay == true -> context.getString(R.string.widgetAllDay) - startDate == endDate -> startText - else -> context.getString(R.string.widgetFromTo, startText, endText) - } - } else null - } - - else -> { - plannable.dueAt?.let { - val timeText = DateHelper.getFormattedTime(context, it).orEmpty() - context.getString(R.string.widgetDueDate, timeText) - } - } - } - } - private fun PlannerItem.getUrl(): String { val url = when (plannableType) { PlannableType.CALENDAR_EVENT -> { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt index 6dee43868f..3ef9e3904e 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListRepository.kt @@ -15,11 +15,67 @@ */ package com.instructure.pandautils.features.todolist +import com.instructure.canvasapi2.apis.CourseAPI import com.instructure.canvasapi2.apis.PlannerAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.PlannableType +import com.instructure.canvasapi2.models.PlannerItem +import com.instructure.canvasapi2.models.PlannerOverride +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.canvasapi2.utils.depaginate import javax.inject.Inject class ToDoListRepository @Inject constructor( - private val plannerApi: PlannerAPI.PlannerInterface + private val plannerApi: PlannerAPI.PlannerInterface, + private val courseApi: CourseAPI.CoursesInterface ) { - // TODO: Implement methods to fetch planner items -} + suspend fun getPlannerItems( + startDate: String, + endDate: String, + forceRefresh: Boolean + ): DataResult> { + val restParams = RestParams(isForceReadFromNetwork = forceRefresh, usePerPageQueryParam = true) + return plannerApi.getPlannerItems( + startDate = startDate, + endDate = endDate, + contextCodes = emptyList(), + restParams = restParams + ).depaginate { nextUrl -> + plannerApi.nextPagePlannerItems(nextUrl, restParams) + } + } + + suspend fun getCourses(forceRefresh: Boolean): DataResult> { + val restParams = RestParams(isForceReadFromNetwork = forceRefresh) + return courseApi.getFirstPageCourses(restParams).depaginate { nextUrl -> + courseApi.next(nextUrl, restParams) + } + } + + suspend fun updatePlannerOverride( + plannerOverrideId: Long, + markedComplete: Boolean + ): DataResult { + val restParams = RestParams(isForceReadFromNetwork = true) + return plannerApi.updatePlannerOverride( + plannerOverrideId = plannerOverrideId, + complete = markedComplete, + params = restParams + ) + } + + suspend fun createPlannerOverride( + plannableId: Long, + plannableType: PlannableType, + markedComplete: Boolean + ): DataResult { + val restParams = RestParams(isForceReadFromNetwork = true) + val override = PlannerOverride( + plannableId = plannableId, + plannableType = plannableType, + markedComplete = markedComplete + ) + return plannerApi.createPlannerOverride(override, restParams) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt index 1be7a1301b..6915507c6c 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt @@ -50,7 +50,7 @@ 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.core.graphics.toColorInt +import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.pandautils.R import com.instructure.pandautils.compose.CanvasTheme @@ -60,9 +60,10 @@ import com.instructure.pandautils.compose.composables.EmptyContent import com.instructure.pandautils.compose.composables.ErrorContent import com.instructure.pandautils.compose.composables.Loading import com.instructure.pandautils.utils.ThemePrefs -import com.instructure.pandautils.utils.localisedFormat +import com.instructure.pandautils.utils.courseOrUserColor import java.text.SimpleDateFormat import java.util.Calendar +import java.util.Date import java.util.Locale @OptIn(ExperimentalMaterialApi::class) @@ -106,7 +107,7 @@ fun ToDoListScreen( ) { when { uiState.isLoading -> { - Loading() + Loading(modifier = Modifier.align(Alignment.Center)) } uiState.isError -> { @@ -147,7 +148,7 @@ fun ToDoListScreen( @Composable private fun ToDoListContent( - itemsByDate: Map>, + itemsByDate: Map>, actionHandler: (ToDoListActionHandler) -> Unit, modifier: Modifier = Modifier ) { @@ -188,11 +189,11 @@ private fun ToDoItem( modifier: Modifier = Modifier ) { val calendar = Calendar.getInstance().apply { - time = item.dueDate + time = item.date } - val dayOfWeek = SimpleDateFormat("EEE", Locale.getDefault()).format(item.dueDate) + val dayOfWeek = SimpleDateFormat("EEE", Locale.getDefault()).format(item.date) val day = calendar.get(Calendar.DAY_OF_MONTH) - val month = SimpleDateFormat("MMM", Locale.getDefault()).format(item.dueDate) + val month = SimpleDateFormat("MMM", Locale.getDefault()).format(item.date) Row( modifier = modifier @@ -236,11 +237,12 @@ private fun ToDoItem( verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { + val contextColor = Color(item.canvasContext.courseOrUserColor) Row(verticalAlignment = Alignment.Top) { Icon( - painter = painterResource(id = item.itemType.iconRes), + painter = painterResource(id = item.iconRes), contentDescription = null, - tint = Color(item.contextColor), + tint = contextColor, modifier = Modifier.size(16.dp) ) Spacer(modifier = Modifier.width(4.dp)) @@ -253,7 +255,7 @@ private fun ToDoItem( Text( text = item.contextLabel, fontSize = 14.sp, - color = Color(item.contextColor), + color = contextColor, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) @@ -268,13 +270,15 @@ private fun ToDoItem( overflow = TextOverflow.Ellipsis ) - Text( - text = item.dueDate.localisedFormat("h:mm a"), - fontSize = 14.sp, - color = colorResource(id = R.color.textDark), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + item.dateLabel?.let { + Text( + text = it, + fontSize = 14.sp, + color = colorResource(id = R.color.textDark), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } Spacer(modifier = Modifier.width(16.dp)) @@ -301,60 +305,66 @@ fun ToDoListScreenPreview() { ToDoListScreen( uiState = ToDoListUiState( itemsByDate = mapOf( - "Sun Oct 22" to listOf( + Date(10) to listOf( ToDoItemUiState( id = "1", title = "Short title", - dueDate = calendar.apply { set(2024, 9, 22, 7, 59) }.time, + date = calendar.apply { set(2024, 9, 22, 7, 59) }.time, + dateLabel = "7:59 AM", contextLabel = "COURSE", - contextColor = "#00AC18".toColorInt(), + canvasContext = CanvasContext.emptyCourseContext(1), itemType = ToDoItemType.ASSIGNMENT, isChecked = false ), ToDoItemUiState( id = "2", title = "Levitate an object without crushing it, bonus points if you don't scratch the paint", - dueDate = calendar.apply { set(2024, 9, 22, 11, 59) }.time, + date = calendar.apply { set(2024, 9, 22, 11, 59) }.time, + dateLabel = "11:59 AM", contextLabel = "Introduction to Advanced Galactic Force Manipulation and Control Techniques for Beginners", - contextColor = "#2196F3".toColorInt(), + canvasContext = CanvasContext.emptyCourseContext(1), itemType = ToDoItemType.QUIZ, isChecked = false ), ToDoItemUiState( id = "3", title = "Identify which emotions lead to Jedi calmness vs. a full Darth Vader office meltdown situation", - dueDate = calendar.apply { set(2024, 9, 22, 14, 30) }.time, + date = calendar.apply { set(2024, 9, 22, 14, 30) }.time, + dateLabel = "2:30 PM", contextLabel = "FORC 101", - contextColor = "#00AC18".toColorInt(), + canvasContext = CanvasContext.emptyCourseContext(1), itemType = ToDoItemType.ASSIGNMENT, isChecked = true ) ), - "Mon 23" to listOf( + Date(1000) to listOf( ToDoItemUiState( id = "4", title = "Essay - Why Force-choking co-workers is frowned upon in most galactic workplaces", - dueDate = calendar.apply { set(2024, 9, 23, 19, 0) }.time, + date = calendar.apply { set(2024, 9, 23, 19, 0) }.time, + dateLabel = "7:00 PM", contextLabel = "Professional Jedi Ethics and Workplace Communication", - contextColor = "#FF5722".toColorInt(), + canvasContext = CanvasContext.emptyCourseContext(1), itemType = ToDoItemType.DISCUSSION, isChecked = false ), ToDoItemUiState( id = "5", title = "Q", - dueDate = calendar.apply { set(2024, 9, 23, 23, 59) }.time, + date = calendar.apply { set(2024, 9, 23, 23, 59) }.time, + dateLabel = "11:59 PM", contextLabel = "PHY", - contextColor = "#9C27B0".toColorInt(), + canvasContext = CanvasContext.emptyCourseContext(1), itemType = ToDoItemType.PLANNER_NOTE, isChecked = false ), ToDoItemUiState( id = "6", title = "Write a comprehensive research paper analyzing the psychological and physiological effects of prolonged exposure to the Dark Side of the Force on Jedi Knights and their ability to maintain emotional equilibrium", - dueDate = calendar.apply { set(2024, 9, 23, 23, 59) }.time, + date = calendar.apply { set(2024, 9, 23, 23, 59) }.time, + dateLabel = "11:59 PM", contextLabel = "Advanced Force Psychology", - contextColor = "#FF9800".toColorInt(), + canvasContext = CanvasContext.emptyCourseContext(1), itemType = ToDoItemType.ASSIGNMENT, isChecked = false ) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt index 3a7915fd4c..3a2f173736 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt @@ -15,8 +15,7 @@ */ package com.instructure.pandautils.features.todolist -import androidx.annotation.ColorInt -import androidx.annotation.DrawableRes +import com.instructure.canvasapi2.models.CanvasContext import com.instructure.pandautils.R import java.util.Date @@ -24,25 +23,28 @@ data class ToDoListUiState( val isLoading: Boolean = false, val isError: Boolean = false, val isRefreshing: Boolean = false, - val itemsByDate: Map> = emptyMap() + val itemsByDate: Map> = emptyMap() ) data class ToDoItemUiState( val id: String, val title: String, - val dueDate: Date, + val date: Date, + val dateLabel: String?, val contextLabel: String, - @ColorInt val contextColor: Int, + val canvasContext: CanvasContext, val itemType: ToDoItemType, - val isChecked: Boolean = false + val isChecked: Boolean = false, + val iconRes: Int = R.drawable.ic_calendar ) -enum class ToDoItemType(@DrawableRes val iconRes: Int) { - ASSIGNMENT(R.drawable.ic_assignment), - QUIZ(R.drawable.ic_quiz), - DISCUSSION(R.drawable.ic_discussion), - CALENDAR_EVENT(R.drawable.ic_calendar), - PLANNER_NOTE(R.drawable.ic_todo) +enum class ToDoItemType { + ASSIGNMENT, + SUB_ASSIGNMENT, + QUIZ, + DISCUSSION, + CALENDAR_EVENT, + PLANNER_NOTE } sealed class ToDoListViewModelAction { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt index 04237af49c..0ba0801ccd 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt @@ -15,18 +15,33 @@ */ package com.instructure.pandautils.features.todolist +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.PlannableType +import com.instructure.canvasapi2.models.PlannerItem +import com.instructure.canvasapi2.utils.DateHelper +import com.instructure.canvasapi2.utils.isInvited +import com.instructure.canvasapi2.utils.toApiString +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.getContextNameForPlannerItem +import com.instructure.pandautils.utils.getDateTextForPlannerItem +import com.instructure.pandautils.utils.getIconForPlannerItem import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.threeten.bp.LocalDate import javax.inject.Inject @HiltViewModel class ToDoListViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val repository: ToDoListRepository ) : ViewModel() { @@ -36,6 +51,90 @@ class ToDoListViewModel @Inject constructor( private val _events = Channel() val events = _events.receiveAsFlow() + init { + loadData() + } + + private fun loadData(forceRefresh: Boolean = false) { + viewModelScope.launch { + try { + _uiState.update { it.copy(isLoading = !forceRefresh, isRefreshing = forceRefresh, isError = false) } + + val now = LocalDate.now().atStartOfDay() + val startDate = now.minusDays(28).toApiString().orEmpty() + val endDate = now.plusDays(28).toApiString().orEmpty() + + val coursesResult = repository.getCourses(forceRefresh) + val plannerItemsResult = repository.getPlannerItems(startDate, endDate, forceRefresh) + + val courses = coursesResult.dataOrNull ?: emptyList() + val plannerItems = plannerItemsResult.dataOrNull ?: emptyList() + + // Filter courses - exclude access restricted, invited + val filteredCourses = courses.filter { + !it.accessRestrictedByDate && !it.isInvited() + } + val courseMap = filteredCourses.associateBy { it.id } + + // Filter planner items - exclude announcements, assessment requests + val filteredItems = plannerItems + .filter { it.plannableType != PlannableType.ANNOUNCEMENT && it.plannableType != PlannableType.ASSESSMENT_REQUEST } + .sortedBy { it.comparisonDate } + + // Group items by date + val itemsByDate = filteredItems + .groupBy { DateHelper.getCleanDate(it.comparisonDate.time) } + .mapValues { (_, items) -> + items.map { plannerItem -> + mapToUiState(plannerItem, courseMap) + } + } + + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + isError = false, + itemsByDate = itemsByDate + ) + } + } catch (e: Exception) { + e.printStackTrace() + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + isError = true + ) + } + } + } + } + + private fun mapToUiState(plannerItem: PlannerItem, courseMap: Map): ToDoItemUiState { + val itemType = when (plannerItem.plannableType) { + PlannableType.ASSIGNMENT -> ToDoItemType.ASSIGNMENT + PlannableType.SUB_ASSIGNMENT -> ToDoItemType.SUB_ASSIGNMENT + PlannableType.QUIZ -> ToDoItemType.QUIZ + PlannableType.DISCUSSION_TOPIC -> ToDoItemType.DISCUSSION + PlannableType.CALENDAR_EVENT -> ToDoItemType.CALENDAR_EVENT + PlannableType.PLANNER_NOTE -> ToDoItemType.PLANNER_NOTE + else -> ToDoItemType.CALENDAR_EVENT + } + + return ToDoItemUiState( + id = plannerItem.plannable.id.toString(), + title = plannerItem.plannable.title, + date = plannerItem.plannableDate, + dateLabel = plannerItem.getDateTextForPlannerItem(context), + contextLabel = plannerItem.getContextNameForPlannerItem(context, courseMap.values), + canvasContext = plannerItem.canvasContext, + itemType = itemType, + isChecked = false, + iconRes = plannerItem.getIconForPlannerItem() + ) + } + fun handleAction(action: ToDoListActionHandler) { when (action) { is ToDoListActionHandler.ItemClicked -> { @@ -43,14 +142,17 @@ class ToDoListViewModel @Inject constructor( _events.send(ToDoListViewModelAction.OpenToDoItem(action.itemId)) } } + is ToDoListActionHandler.Refresh -> { - // TODO: Implement refresh + loadData(forceRefresh = true) } + is ToDoListActionHandler.ToggleItemChecked -> { - // TODO: Implement toggle checked + // TODO: Implement toggle checked - will be implemented in future story } + is ToDoListActionHandler.FilterClicked -> { - // TODO: Implement filter + // TODO: Implement filter - will be implemented in future story } } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt index 8019185672..9533c5c261 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt @@ -18,9 +18,12 @@ package com.instructure.pandautils.utils import android.content.Context import androidx.annotation.DrawableRes +import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.PlannableType import com.instructure.canvasapi2.models.PlannerItem import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.DateHelper +import com.instructure.canvasapi2.utils.toDate import com.instructure.pandautils.R fun PlannerItem.todoHtmlUrl(apiPrefs: ApiPrefs): String { @@ -39,6 +42,59 @@ fun PlannerItem.getIconForPlannerItem(): Int { } } +fun PlannerItem.getDateTextForPlannerItem(context: Context): String? { + return when (plannableType) { + PlannableType.PLANNER_NOTE -> { + plannable.todoDate.toDate()?.let { + DateHelper.getFormattedTime(context, it) + } + } + + PlannableType.CALENDAR_EVENT -> { + val startDate = plannable.startAt + val endDate = plannable.endAt + if (startDate != null && endDate != null) { + val startText = DateHelper.getFormattedTime(context, startDate).orEmpty() + val endText = DateHelper.getFormattedTime(context, endDate).orEmpty() + + when { + plannable.allDay == true -> context.getString(R.string.widgetAllDay) + startDate == endDate -> startText + else -> context.getString(R.string.widgetFromTo, startText, endText) + } + } else null + } + + else -> { + plannable.dueAt?.let { + val timeText = DateHelper.getFormattedTime(context, it).orEmpty() + context.getString(R.string.widgetDueDate, timeText) + } + } + } +} + +fun PlannerItem.getContextNameForPlannerItem(context: Context, courses: Collection): String { + val courseCode = courses.find { it.id == canvasContext.id }?.courseCode + return when (plannableType) { + PlannableType.PLANNER_NOTE -> { + if (contextName.isNullOrEmpty()) { + context.getString(R.string.userCalendarToDo) + } else { + context.getString(R.string.courseToDo, courseCode) + } + } + + else -> { + if (canvasContext is Course) { + courseCode.orEmpty() + } else { + contextName.orEmpty() + } + } + } +} + fun PlannerItem.getTagForPlannerItem(context: Context): String? { return if (plannable.subAssignmentTag == Const.REPLY_TO_TOPIC) { context.getString(R.string.reply_to_topic) From 9f374ebc39427918be4a21931a216754869a3ad3 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Tue, 28 Oct 2025 20:30:13 +0100 Subject: [PATCH 03/13] DCP and today indicator. --- .../features/todolist/ToDoListScreen.kt | 110 ++++++++++++++---- .../features/todolist/ToDoListUiState.kt | 3 +- .../features/todolist/ToDoListViewModel.kt | 5 +- 3 files changed, 92 insertions(+), 26 deletions(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt index 6915507c6c..7495306f23 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt @@ -15,6 +15,7 @@ */ package com.instructure.pandautils.features.todolist +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Checkbox import androidx.compose.material.CheckboxDefaults import androidx.compose.material.ExperimentalMaterialApi @@ -195,6 +197,16 @@ private fun ToDoItem( val day = calendar.get(Calendar.DAY_OF_MONTH) val month = SimpleDateFormat("MMM", Locale.getDefault()).format(item.date) + val today = Calendar.getInstance() + val isToday = calendar.get(Calendar.YEAR) == today.get(Calendar.YEAR) && + calendar.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR) + + val dateTextColor = if (isToday) { + Color(ThemePrefs.brandColor) + } else { + colorResource(id = R.color.textDark) + } + Row( modifier = modifier .fillMaxWidth() @@ -215,18 +227,29 @@ private fun ToDoItem( Text( text = dayOfWeek, fontSize = 12.sp, - color = colorResource(id = R.color.textDark) - ) - Text( - text = day.toString(), - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - color = colorResource(id = R.color.textDark) + color = dateTextColor ) + Box( + contentAlignment = Alignment.Center, + modifier = if (isToday) { + Modifier + .size(32.dp) + .border(width = 1.dp, color = dateTextColor, shape = CircleShape) + } else { + Modifier + } + ) { + Text( + text = day.toString(), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = dateTextColor + ) + } Text( text = month, fontSize = 10.sp, - color = colorResource(id = R.color.textDark) + color = dateTextColor ) } } @@ -270,6 +293,16 @@ private fun ToDoItem( overflow = TextOverflow.Ellipsis ) + item.tag?.let { + Text( + text = it, + fontSize = 14.sp, + color = colorResource(id = R.color.textDark), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + item.dateLabel?.let { Text( text = it, @@ -312,8 +345,9 @@ fun ToDoListScreenPreview() { date = calendar.apply { set(2024, 9, 22, 7, 59) }.time, dateLabel = "7:59 AM", contextLabel = "COURSE", - canvasContext = CanvasContext.emptyCourseContext(1), + canvasContext = CanvasContext.defaultCanvasContext(), itemType = ToDoItemType.ASSIGNMENT, + iconRes = R.drawable.ic_assignment, isChecked = false ), ToDoItemUiState( @@ -322,8 +356,9 @@ fun ToDoListScreenPreview() { date = calendar.apply { set(2024, 9, 22, 11, 59) }.time, dateLabel = "11:59 AM", contextLabel = "Introduction to Advanced Galactic Force Manipulation and Control Techniques for Beginners", - canvasContext = CanvasContext.emptyCourseContext(1), + canvasContext = CanvasContext.defaultCanvasContext(), itemType = ToDoItemType.QUIZ, + iconRes = R.drawable.ic_quiz, isChecked = false ), ToDoItemUiState( @@ -332,40 +367,69 @@ fun ToDoListScreenPreview() { date = calendar.apply { set(2024, 9, 22, 14, 30) }.time, dateLabel = "2:30 PM", contextLabel = "FORC 101", - canvasContext = CanvasContext.emptyCourseContext(1), + canvasContext = CanvasContext.defaultCanvasContext(), itemType = ToDoItemType.ASSIGNMENT, + iconRes = R.drawable.ic_assignment, isChecked = true + ), + ToDoItemUiState( + id = "4", + title = "Peer review discussion post", + date = calendar.apply { set(2024, 9, 22, 16, 0) }.time, + dateLabel = "4:00 PM", + tag = "Peer Reviews for Exploring Emotional Mastery", + contextLabel = "Advanced Force Psychology", + canvasContext = CanvasContext.defaultCanvasContext(), + itemType = ToDoItemType.SUB_ASSIGNMENT, + iconRes = R.drawable.ic_discussion, + isChecked = false ) ), Date(1000) to listOf( ToDoItemUiState( - id = "4", + id = "5", title = "Essay - Why Force-choking co-workers is frowned upon in most galactic workplaces", date = calendar.apply { set(2024, 9, 23, 19, 0) }.time, dateLabel = "7:00 PM", contextLabel = "Professional Jedi Ethics and Workplace Communication", - canvasContext = CanvasContext.emptyCourseContext(1), + canvasContext = CanvasContext.defaultCanvasContext(), itemType = ToDoItemType.DISCUSSION, + iconRes = R.drawable.ic_discussion, isChecked = false ), ToDoItemUiState( - id = "5", + id = "6", + title = "Personal meditation practice", + date = calendar.apply { set(2024, 9, 23, 20, 0) }.time, + dateLabel = "8:00 PM", + contextLabel = "My Notes", + canvasContext = CanvasContext.defaultCanvasContext(), + itemType = ToDoItemType.PLANNER_NOTE, + iconRes = R.drawable.ic_todo, + isChecked = false + ), + ToDoItemUiState( + id = "7", title = "Q", date = calendar.apply { set(2024, 9, 23, 23, 59) }.time, dateLabel = "11:59 PM", contextLabel = "PHY", - canvasContext = CanvasContext.emptyCourseContext(1), + canvasContext = CanvasContext.defaultCanvasContext(), itemType = ToDoItemType.PLANNER_NOTE, + iconRes = R.drawable.ic_todo, isChecked = false - ), + ) + ), + Date(2000) to listOf( ToDoItemUiState( - id = "6", - title = "Write a comprehensive research paper analyzing the psychological and physiological effects of prolonged exposure to the Dark Side of the Force on Jedi Knights and their ability to maintain emotional equilibrium", - date = calendar.apply { set(2024, 9, 23, 23, 59) }.time, - dateLabel = "11:59 PM", - contextLabel = "Advanced Force Psychology", - canvasContext = CanvasContext.emptyCourseContext(1), - itemType = ToDoItemType.ASSIGNMENT, + id = "9", + title = "Lightsaber maintenance workshop", + date = calendar.apply { set(2024, 9, 24, 10, 0) }.time, + dateLabel = "10:00 AM", + contextLabel = "Equipment & Safety", + canvasContext = CanvasContext.defaultCanvasContext(), + itemType = ToDoItemType.CALENDAR_EVENT, + iconRes = R.drawable.ic_calendar, isChecked = false ) ) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt index 3a2f173736..03f7481d46 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListUiState.kt @@ -35,7 +35,8 @@ data class ToDoItemUiState( val canvasContext: CanvasContext, val itemType: ToDoItemType, val isChecked: Boolean = false, - val iconRes: Int = R.drawable.ic_calendar + val iconRes: Int = R.drawable.ic_calendar, + val tag: String? = null ) enum class ToDoItemType { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt index 0ba0801ccd..452d32f54d 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt @@ -24,10 +24,10 @@ import com.instructure.canvasapi2.models.PlannerItem import com.instructure.canvasapi2.utils.DateHelper import com.instructure.canvasapi2.utils.isInvited import com.instructure.canvasapi2.utils.toApiString -import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.getContextNameForPlannerItem import com.instructure.pandautils.utils.getDateTextForPlannerItem import com.instructure.pandautils.utils.getIconForPlannerItem +import com.instructure.pandautils.utils.getTagForPlannerItem import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.Channel @@ -131,7 +131,8 @@ class ToDoListViewModel @Inject constructor( canvasContext = plannerItem.canvasContext, itemType = itemType, isChecked = false, - iconRes = plannerItem.getIconForPlannerItem() + iconRes = plannerItem.getIconForPlannerItem(), + tag = plannerItem.getTagForPlannerItem(context) ) } From f28ebe5fad2bcc590efb99041fe569b20b1fc90a Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Wed, 29 Oct 2025 09:38:44 +0100 Subject: [PATCH 04/13] Sticky header. --- .../features/todolist/ToDoListScreen.kt | 318 ++++++++++++++---- 1 file changed, 250 insertions(+), 68 deletions(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt index 7495306f23..3d3cff2d46 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt @@ -15,6 +15,7 @@ */ package com.instructure.pandautils.features.todolist +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -24,10 +25,13 @@ import androidx.compose.foundation.layout.Spacer 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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Checkbox import androidx.compose.material.CheckboxDefaults @@ -40,18 +44,27 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.Density 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.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.pandautils.R @@ -68,6 +81,20 @@ import java.util.Calendar import java.util.Date import java.util.Locale +private data class StickyHeaderState( + val item: ToDoItemUiState?, + val yOffset: Float, + val isVisible: Boolean +) + +private data class DateBadgeData( + val dayOfWeek: String, + val day: Int, + val month: String, + val isToday: Boolean, + val dateTextColor: Color +) + @OptIn(ExperimentalMaterialApi::class) @Composable fun ToDoListScreen( @@ -155,30 +182,84 @@ private fun ToDoListContent( modifier: Modifier = Modifier ) { val dateGroups = itemsByDate.entries.toList() - LazyColumn(modifier = modifier.fillMaxSize()) { - dateGroups.forEachIndexed { groupIndex, (date, items) -> - items.forEachIndexed { index, item -> - item(key = item.id) { - ToDoItem( - item = item, - showDateBadge = index == 0, - onCheckedChange = { actionHandler(ToDoListActionHandler.ToggleItemChecked(item.id)) }, - onClick = { actionHandler(ToDoListActionHandler.ItemClicked(item.id)) } - ) + val listState = rememberLazyListState() + val itemPositions = remember { mutableStateMapOf() } + val density = LocalDensity.current + + val stickyHeaderState = rememberStickyHeaderState( + dateGroups = dateGroups, + listState = listState, + itemPositions = itemPositions, + density = density + ) + + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + dateGroups.forEachIndexed { groupIndex, (date, items) -> + items.forEachIndexed { index, item -> + item(key = item.id) { + ToDoItem( + item = item, + showDateBadge = index == 0, + hideDate = index == 0 && stickyHeaderState.isVisible && stickyHeaderState.item?.id == item.id, + onCheckedChange = { actionHandler(ToDoListActionHandler.ToggleItemChecked(item.id)) }, + onClick = { actionHandler(ToDoListActionHandler.ItemClicked(item.id)) }, + modifier = Modifier.onGloballyPositioned { coordinates -> + itemPositions[item.id] = coordinates.positionInParent().y + } + ) + } } - } - if (groupIndex < dateGroups.size - 1) { - item(key = "divider_$date") { - CanvasDivider( - modifier = Modifier - .fillMaxWidth() - .height(0.5.dp) - .padding(horizontal = 16.dp) - ) + if (groupIndex < dateGroups.size - 1) { + item(key = "divider_$date") { + CanvasDivider( + modifier = Modifier + .fillMaxWidth() + .height(0.5.dp) + .padding(horizontal = 16.dp) + ) + } } } } + + // Sticky header overlay + stickyHeaderState.item?.let { item -> + if (stickyHeaderState.isVisible) { + StickyDateBadge( + item = item, + yOffset = stickyHeaderState.yOffset + ) + } + } + } +} + +@Composable +private fun StickyDateBadge( + item: ToDoItemUiState, + yOffset: Float +) { + val dateBadgeData = rememberDateBadgeData(item.date) + + Box( + modifier = Modifier + .offset { IntOffset(0, yOffset.roundToInt()) } + .padding(start = 12.dp, top = 8.dp, bottom = 8.dp) + ) { + Box( + modifier = Modifier + .width(44.dp) + .padding(end = 12.dp) + .background(colorResource(id = R.color.backgroundLightest)), + contentAlignment = Alignment.TopCenter + ) { + DateBadge(dateBadgeData) + } } } @@ -188,24 +269,10 @@ private fun ToDoItem( showDateBadge: Boolean, onCheckedChange: () -> Unit, onClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + hideDate: Boolean = false ) { - val calendar = Calendar.getInstance().apply { - time = item.date - } - val dayOfWeek = SimpleDateFormat("EEE", Locale.getDefault()).format(item.date) - val day = calendar.get(Calendar.DAY_OF_MONTH) - val month = SimpleDateFormat("MMM", Locale.getDefault()).format(item.date) - - val today = Calendar.getInstance() - val isToday = calendar.get(Calendar.YEAR) == today.get(Calendar.YEAR) && - calendar.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR) - - val dateTextColor = if (isToday) { - Color(ThemePrefs.brandColor) - } else { - colorResource(id = R.color.textDark) - } + val dateBadgeData = rememberDateBadgeData(item.date) Row( modifier = modifier @@ -220,38 +287,8 @@ private fun ToDoItem( .padding(end = 12.dp), contentAlignment = Alignment.TopCenter ) { - if (showDateBadge) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = dayOfWeek, - fontSize = 12.sp, - color = dateTextColor - ) - Box( - contentAlignment = Alignment.Center, - modifier = if (isToday) { - Modifier - .size(32.dp) - .border(width = 1.dp, color = dateTextColor, shape = CircleShape) - } else { - Modifier - } - ) { - Text( - text = day.toString(), - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - color = dateTextColor - ) - } - Text( - text = month, - fontSize = 10.sp, - color = dateTextColor - ) - } + if (showDateBadge && !hideDate) { + DateBadge(dateBadgeData) } } @@ -328,6 +365,151 @@ private fun ToDoItem( } } +@Composable +private fun rememberDateBadgeData(date: Date): DateBadgeData { + val calendar = remember(date) { + Calendar.getInstance().apply { time = date } + } + + val dayOfWeek = remember(date) { + SimpleDateFormat("EEE", Locale.getDefault()).format(date) + } + + val day = remember(date) { + calendar.get(Calendar.DAY_OF_MONTH) + } + + val month = remember(date) { + SimpleDateFormat("MMM", Locale.getDefault()).format(date) + } + + val isToday = remember(date) { + val today = Calendar.getInstance() + calendar.get(Calendar.YEAR) == today.get(Calendar.YEAR) && + calendar.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR) + } + + val dateTextColor = if (isToday) { + Color(ThemePrefs.brandColor) + } else { + colorResource(id = R.color.textDark) + } + + return DateBadgeData(dayOfWeek, day, month, isToday, dateTextColor) +} + +@Composable +private fun DateBadge(dateBadgeData: DateBadgeData) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = dateBadgeData.dayOfWeek, + fontSize = 12.sp, + color = dateBadgeData.dateTextColor + ) + Box( + contentAlignment = Alignment.Center, + modifier = if (dateBadgeData.isToday) { + Modifier + .size(32.dp) + .border(width = 1.dp, color = dateBadgeData.dateTextColor, shape = CircleShape) + } else { + Modifier + } + ) { + Text( + text = dateBadgeData.day.toString(), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = dateBadgeData.dateTextColor + ) + } + Text( + text = dateBadgeData.month, + fontSize = 10.sp, + color = dateBadgeData.dateTextColor + ) + } +} + +@Composable +private fun rememberStickyHeaderState( + dateGroups: List>>, + listState: LazyListState, + itemPositions: Map, + density: Density +): StickyHeaderState { + return remember { + derivedStateOf { + calculateStickyHeaderState(dateGroups, listState, itemPositions, density) + } + }.value +} + +private fun calculateStickyHeaderState( + dateGroups: List>>, + listState: LazyListState, + itemPositions: Map, + density: Density +): StickyHeaderState { + val firstVisibleItemIndex = listState.firstVisibleItemIndex + val firstVisibleItemScrollOffset = listState.firstVisibleItemScrollOffset + + // Find which date group's first item has been scrolled past + var currentGroupIndex = -1 + var itemCount = 0 + + for ((groupIndex, group) in dateGroups.withIndex()) { + val groupItemCount = group.value.size + if (firstVisibleItemIndex < itemCount + groupItemCount) { + currentGroupIndex = groupIndex + break + } + itemCount += groupItemCount + if (groupIndex < dateGroups.size - 1) 1 else 0 // +1 for divider + } + + if (currentGroupIndex == -1 || currentGroupIndex >= dateGroups.size) { + return StickyHeaderState(null, 0f, false) + } + + val currentGroup = dateGroups[currentGroupIndex] + val firstItemOfCurrentGroup = currentGroup.value.first() + + // Check if the first item has scrolled up even slightly + val shouldShowSticky = if (firstVisibleItemIndex > 0) { + true + } else { + firstVisibleItemScrollOffset > 0 + } + + // Calculate offset for animation when next group approaches + var yOffset = 0f + if (currentGroupIndex < dateGroups.size - 1) { + val nextGroup = dateGroups[currentGroupIndex + 1] + val nextGroupFirstItem = nextGroup.value.first() + val nextItemPosition = itemPositions[nextGroupFirstItem.id] ?: Float.MAX_VALUE + + // When next group's first item date badge approaches, start pushing the sticky header up + // Components to consider: + // - Date badge height: 54dp (dayOfWeek 12sp + circle 32dp + month 10sp) + // Note: The circle (32dp) is only shown for today, but we calculate for the max height + // - Item bottom padding: 8dp (top padding doesn't count since sticky header has its own top padding) + // Total: 54dp + 8dp = 62dp + // Convert dp to pixels for comparison with positionInParent() which returns pixels + val stickyHeaderHeightPx = with(density) { 62.dp.toPx() } + if (nextItemPosition < stickyHeaderHeightPx && nextItemPosition > 0) { + yOffset = nextItemPosition - stickyHeaderHeightPx + } + } + + return StickyHeaderState( + item = if (shouldShowSticky) firstItemOfCurrentGroup else null, + yOffset = yOffset, + isVisible = shouldShowSticky + ) +} + @Preview(name = "Light Mode", showBackground = true) @Preview(name = "Dark Mode", showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) @Composable From 3bf5b7b51c3fc6eb7e26f1cc7fea8aacc93c1251 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Wed, 29 Oct 2025 10:37:45 +0100 Subject: [PATCH 05/13] Text size changes. --- .../features/todolist/ToDoListScreen.kt | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt index 3d3cff2d46..56e88adb9b 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt @@ -55,16 +55,15 @@ import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.colorResource -import androidx.compose.ui.unit.Density 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.Density import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlin.math.roundToInt import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.pandautils.R @@ -80,6 +79,7 @@ import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import kotlin.math.roundToInt private data class StickyHeaderState( val item: ToDoItemUiState?, @@ -406,7 +406,9 @@ private fun DateBadge(dateBadgeData: DateBadgeData) { Text( text = dateBadgeData.dayOfWeek, fontSize = 12.sp, - color = dateBadgeData.dateTextColor + color = dateBadgeData.dateTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) Box( contentAlignment = Alignment.Center, @@ -428,7 +430,9 @@ private fun DateBadge(dateBadgeData: DateBadgeData) { Text( text = dateBadgeData.month, fontSize = 10.sp, - color = dateBadgeData.dateTextColor + color = dateBadgeData.dateTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } @@ -490,14 +494,18 @@ private fun calculateStickyHeaderState( val nextGroupFirstItem = nextGroup.value.first() val nextItemPosition = itemPositions[nextGroupFirstItem.id] ?: Float.MAX_VALUE - // When next group's first item date badge approaches, start pushing the sticky header up - // Components to consider: - // - Date badge height: 54dp (dayOfWeek 12sp + circle 32dp + month 10sp) - // Note: The circle (32dp) is only shown for today, but we calculate for the max height - // - Item bottom padding: 8dp (top padding doesn't count since sticky header has its own top padding) - // Total: 54dp + 8dp = 62dp - // Convert dp to pixels for comparison with positionInParent() which returns pixels - val stickyHeaderHeightPx = with(density) { 62.dp.toPx() } + // Calculate date badge height by converting sp and dp values to pixels + // Date badge components: + // - dayOfWeek text: 12.sp + // - day text (in 32.dp box): 12.sp (bold) + // - month text: 10.sp + // - All text heights together: 22.sp + // - item bottom padding: 8.dp + val textHeightPx = with(density) { 22.sp.toPx() } + val circleHeightPx = with(density) { 32.dp.toPx() } + val paddingPx = with(density) { 8.dp.toPx() } + val stickyHeaderHeightPx = textHeightPx + circleHeightPx + paddingPx + if (nextItemPosition < stickyHeaderHeightPx && nextItemPosition > 0) { yOffset = nextItemPosition - stickyHeaderHeightPx } From f2cb3b72fa2fdef1b0ee33ffd914834f15bbbb43 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Wed, 29 Oct 2025 11:29:29 +0100 Subject: [PATCH 06/13] Added bottom bar badge. --- .../student/activity/CallbackActivity.kt | 30 +++++++++++++++++++ .../student/activity/NavigationActivity.kt | 4 +++ libs/pandares/src/main/res/values/strings.xml | 5 ++++ 3 files changed, 39 insertions(+) diff --git a/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt index fe20ece105..d2e64f4887 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt @@ -20,6 +20,7 @@ package com.instructure.student.activity import android.os.Bundle import com.google.firebase.crashlytics.FirebaseCrashlytics import com.instructure.canvasapi2.StatusCallback +import com.instructure.canvasapi2.apis.PlannerAPI import com.instructure.canvasapi2.apis.UserAPI import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.managers.FeaturesManager @@ -41,8 +42,10 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.ApiType import com.instructure.canvasapi2.utils.LinkHeaders import com.instructure.canvasapi2.utils.Logger +import com.instructure.canvasapi2.utils.depaginate import com.instructure.canvasapi2.utils.pageview.PandataInfo import com.instructure.canvasapi2.utils.pageview.PandataManager +import com.instructure.canvasapi2.utils.toApiString import com.instructure.canvasapi2.utils.weave.StatusCallbackError import com.instructure.canvasapi2.utils.weave.awaitApi import com.instructure.canvasapi2.utils.weave.catch @@ -65,6 +68,7 @@ import com.instructure.student.util.StudentPrefs import com.instructure.student.widget.WidgetLogger import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job +import org.threeten.bp.LocalDate import retrofit2.Call import retrofit2.Response import sdk.pendo.io.Pendo @@ -85,6 +89,9 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No @Inject lateinit var userApi: UserAPI.UsersInterface + @Inject + lateinit var plannerApi: PlannerAPI.PlannerInterface + @Inject lateinit var widgetLogger: WidgetLogger @@ -94,6 +101,7 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No abstract fun updateUnreadCount(unreadCount: Int) abstract fun increaseUnreadCount(increaseBy: Int) abstract fun updateNotificationCount(notificationCount: Int) + abstract fun updateToDoCount(toDoCount: Int) abstract fun initialCoreDataLoadingComplete() override fun onCreate(savedInstanceState: Bundle?) { @@ -178,6 +186,8 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No getUnreadNotificationCount() + getToDoCount() + initialCoreDataLoadingComplete() } catch { initialCoreDataLoadingComplete() @@ -206,6 +216,26 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No } } + private suspend fun getToDoCount() { + // TODO Implement correct filtering in MBL-19401 + val now = LocalDate.now().atStartOfDay() + val startDate = now.minusDays(28).toApiString().orEmpty() + val endDate = now.plusDays(28).toApiString().orEmpty() + + val restParams = RestParams(isForceReadFromNetwork = true, usePerPageQueryParam = true) + val plannerItems = plannerApi.getPlannerItems( + startDate = startDate, + endDate = endDate, + contextCodes = emptyList(), + restParams = restParams + ).depaginate { nextUrl -> + plannerApi.nextPagePlannerItems(nextUrl, restParams) + } + + val todoCount = plannerItems.dataOrNull?.count().orDefault() + updateToDoCount(todoCount) + } + private fun getUnreadNotificationCount() { UnreadCountManager.getUnreadNotificationCount(object : StatusCallback>() { override fun onResponse(data: Call>, response: Response>) { 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 474bd7f875..b012dc6425 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 @@ -1268,6 +1268,10 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. updateBottomBarBadge(R.id.bottomNavigationNotifications, notificationCount, R.plurals.a11y_notificationsUnreadCount) } + override fun updateToDoCount(toDoCount: Int) { + updateBottomBarBadge(R.id.bottomNavigationToDo, toDoCount, R.plurals.a11y_todoBadgeCount) + } + private fun updateBottomBarBadge(@IdRes menuItemId: Int, count: Int, @PluralsRes quantityContentDescription: Int? = null) = with(binding) { if (count > 0) { bottomBar.getOrCreateBadge(menuItemId).number = count diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index 312e269721..afc7c7e074 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -1375,6 +1375,11 @@ %s unread notifications + + %s to do item + %s to do items + + No annotation selected Email Notifications From 39f72a220de2863afdcc773b12bc1e69c1bcd56c Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Wed, 29 Oct 2025 14:10:20 +0100 Subject: [PATCH 07/13] Pandas --- .../features/todolist/ToDoListScreen.kt | 117 +++++++++++++++++- .../src/main/res/drawable/ic_panda_bottom.xml | 74 +++++++++++ .../src/main/res/drawable/ic_panda_top.xml | 45 +++++++ 3 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 libs/pandautils/src/main/res/drawable/ic_panda_bottom.xml create mode 100644 libs/pandautils/src/main/res/drawable/ic_panda_top.xml diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt index 56e88adb9b..ca2e86382f 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListScreen.kt @@ -45,8 +45,11 @@ import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -73,6 +76,7 @@ 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.pandautils.compose.modifiers.conditional import com.instructure.pandautils.utils.ThemePrefs import com.instructure.pandautils.utils.courseOrUserColor import java.text.SimpleDateFormat @@ -184,7 +188,9 @@ private fun ToDoListContent( val dateGroups = itemsByDate.entries.toList() val listState = rememberLazyListState() val itemPositions = remember { mutableStateMapOf() } + val itemSizes = remember { mutableStateMapOf() } val density = LocalDensity.current + var listHeight by remember { mutableIntStateOf(0) } val stickyHeaderState = rememberStickyHeaderState( dateGroups = dateGroups, @@ -193,10 +199,33 @@ private fun ToDoListContent( density = density ) + // Calculate content height from last item's position + size + val listContentHeight by remember { + derivedStateOf { + if (dateGroups.isEmpty()) return@derivedStateOf 0 + val lastGroup = dateGroups.last() + val lastItem = lastGroup.value.last() + val lastItemPosition = itemPositions[lastItem.id] ?: return@derivedStateOf 0 + val lastItemSize = itemSizes[lastItem.id] ?: return@derivedStateOf 0 + (lastItemPosition + lastItemSize).toInt() + } + } + + // Calculate if there's enough space for pandas (at least 140dp) + val availableSpacePx = listHeight - listContentHeight + val minSpaceForPandasPx = with(density) { 140.dp.toPx() } + val showPandas = listHeight > 0 && listContentHeight > 0 && + availableSpacePx >= minSpaceForPandasPx && + itemsByDate.isNotEmpty() + Box(modifier = modifier.fillMaxSize()) { LazyColumn( state = listState, - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + listHeight = coordinates.size.height + } ) { dateGroups.forEachIndexed { groupIndex, (date, items) -> items.forEachIndexed { index, item -> @@ -209,24 +238,34 @@ private fun ToDoListContent( onClick = { actionHandler(ToDoListActionHandler.ItemClicked(item.id)) }, modifier = Modifier.onGloballyPositioned { coordinates -> itemPositions[item.id] = coordinates.positionInParent().y + itemSizes[item.id] = coordinates.size.height } ) } } - if (groupIndex < dateGroups.size - 1) { + // Add divider between date groups, or after last group if pandas are showing + if (groupIndex < dateGroups.size - 1 || showPandas) { item(key = "divider_$date") { + val isLastDivider = groupIndex == dateGroups.size - 1 CanvasDivider( modifier = Modifier .fillMaxWidth() .height(0.5.dp) - .padding(horizontal = 16.dp) + .conditional(!isLastDivider) { + padding(horizontal = 16.dp) + } ) } } } } + // Panda illustrations + if (showPandas) { + PandaIllustrations(contentHeightPx = listContentHeight.toFloat()) + } + // Sticky header overlay stickyHeaderState.item?.let { item -> if (stickyHeaderState.isVisible) { @@ -518,6 +557,37 @@ private fun calculateStickyHeaderState( ) } +@Composable +private fun PandaIllustrations(contentHeightPx: Float) { + val density = LocalDensity.current + + Box(modifier = Modifier.fillMaxSize()) { + // Top-right panda - positioned at top-right, below content + Icon( + painter = painterResource(id = R.drawable.ic_panda_top), + contentDescription = null, + modifier = Modifier + .width(180.dp) + .height(137.dp) + .align(Alignment.TopEnd) + .offset(x = (-24).dp, y = with(density) { (contentHeightPx - 2.5.dp.toPx()).toDp() }), + tint = Color.Unspecified + ) + + // Bottom-left panda - positioned at bottom-left + Icon( + painter = painterResource(id = R.drawable.ic_panda_bottom), + contentDescription = null, + modifier = Modifier + .width(114.dp) + .height(137.dp) + .align(Alignment.BottomStart) + .offset(x = 24.dp, y = 30.5.dp), + tint = Color.Unspecified + ) + } +} + @Preview(name = "Light Mode", showBackground = true) @Preview(name = "Dark Mode", showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) @Composable @@ -630,6 +700,47 @@ fun ToDoListScreenPreview() { } } +@Preview(name = "With Pandas Light Mode", showBackground = true) +@Preview(name = "With Pandas Dark Mode", showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ToDoListScreenWithPandasPreview() { + ContextKeeper.appContext = LocalContext.current + val calendar = Calendar.getInstance() + CanvasTheme { + ToDoListScreen( + uiState = ToDoListUiState( + itemsByDate = mapOf( + Date(10) to listOf( + ToDoItemUiState( + id = "1", + title = "Complete Force training assignment", + date = calendar.apply { set(2024, 9, 22, 7, 59) }.time, + dateLabel = "7:59 AM", + contextLabel = "FORC 101", + canvasContext = CanvasContext.defaultCanvasContext(), + itemType = ToDoItemType.ASSIGNMENT, + iconRes = R.drawable.ic_assignment, + isChecked = false + ), + ToDoItemUiState( + id = "2", + title = "Read chapter on Jedi meditation techniques", + date = calendar.apply { set(2024, 9, 22, 11, 59) }.time, + dateLabel = "11:59 AM", + contextLabel = "Introduction to Advanced Force Manipulation", + canvasContext = CanvasContext.defaultCanvasContext(), + itemType = ToDoItemType.QUIZ, + iconRes = R.drawable.ic_quiz, + isChecked = false + ) + ) + ) + ), + actionHandler = {} + ) + } +} + @Preview(name = "Empty Light Mode", showBackground = true) @Preview(name = "Empty Dark Mode", showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/libs/pandautils/src/main/res/drawable/ic_panda_bottom.xml b/libs/pandautils/src/main/res/drawable/ic_panda_bottom.xml new file mode 100644 index 0000000000..e7a042bc9d --- /dev/null +++ b/libs/pandautils/src/main/res/drawable/ic_panda_bottom.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + diff --git a/libs/pandautils/src/main/res/drawable/ic_panda_top.xml b/libs/pandautils/src/main/res/drawable/ic_panda_top.xml new file mode 100644 index 0000000000..f3605adfc9 --- /dev/null +++ b/libs/pandautils/src/main/res/drawable/ic_panda_top.xml @@ -0,0 +1,45 @@ + + + + + + + + + + From 4746d29dd9c33112a1700c77323c80fb6bad32ca Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Wed, 29 Oct 2025 14:21:46 +0100 Subject: [PATCH 08/13] Complein status. --- .../features/todolist/ToDoListViewModel.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt index 452d32f54d..d52ff4b572 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt @@ -130,12 +130,23 @@ class ToDoListViewModel @Inject constructor( contextLabel = plannerItem.getContextNameForPlannerItem(context, courseMap.values), canvasContext = plannerItem.canvasContext, itemType = itemType, - isChecked = false, + isChecked = isComplete(plannerItem), iconRes = plannerItem.getIconForPlannerItem(), tag = plannerItem.getTagForPlannerItem(context) ) } + private fun isComplete(plannerItem: PlannerItem): Boolean { + return if (plannerItem.plannableType == PlannableType.ASSIGNMENT + || plannerItem.plannableType == PlannableType.DISCUSSION_TOPIC + || plannerItem.plannableType == PlannableType.SUB_ASSIGNMENT + ) { + plannerItem.submissionState?.submitted == true + } else { + plannerItem.plannerOverride?.markedComplete == true + } + } + fun handleAction(action: ToDoListActionHandler) { when (action) { is ToDoListActionHandler.ItemClicked -> { From d8b755ea3bd98cc84c918f5cb17c54677154e345 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Wed, 29 Oct 2025 18:29:06 +0100 Subject: [PATCH 09/13] Unit tests. --- .../features/todolist/ToDoListViewModel.kt | 7 +- .../todolist/ToDoListRepositoryTest.kt | 410 ++++++++++++++++ .../todolist/ToDoListViewModelTest.kt | 423 ++++++++++++++++ .../utils/PlannerItemExtensionsTest.kt | 459 ++++++++++++++++++ 4 files changed, 1294 insertions(+), 5 deletions(-) create mode 100644 libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListRepositoryTest.kt create mode 100644 libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListViewModelTest.kt create mode 100644 libs/pandautils/src/test/java/com/instructure/pandautils/utils/PlannerItemExtensionsTest.kt diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt index d52ff4b572..be24f55760 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt @@ -64,11 +64,8 @@ class ToDoListViewModel @Inject constructor( val startDate = now.minusDays(28).toApiString().orEmpty() val endDate = now.plusDays(28).toApiString().orEmpty() - val coursesResult = repository.getCourses(forceRefresh) - val plannerItemsResult = repository.getPlannerItems(startDate, endDate, forceRefresh) - - val courses = coursesResult.dataOrNull ?: emptyList() - val plannerItems = plannerItemsResult.dataOrNull ?: emptyList() + val courses = repository.getCourses(forceRefresh).dataOrThrow + val plannerItems = repository.getPlannerItems(startDate, endDate, forceRefresh).dataOrThrow // Filter courses - exclude access restricted, invited val filteredCourses = courses.filter { diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListRepositoryTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListRepositoryTest.kt new file mode 100644 index 0000000000..41aca37f2b --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListRepositoryTest.kt @@ -0,0 +1,410 @@ +/* + * 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.features.todolist + +import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.apis.PlannerAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.Plannable +import com.instructure.canvasapi2.models.PlannableType +import com.instructure.canvasapi2.models.PlannerItem +import com.instructure.canvasapi2.models.PlannerOverride +import com.instructure.canvasapi2.utils.DataResult +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Date + +class ToDoListRepositoryTest { + + private val plannerApi: PlannerAPI.PlannerInterface = mockk(relaxed = true) + private val courseApi: CourseAPI.CoursesInterface = mockk(relaxed = true) + + private lateinit var repository: ToDoListRepository + + @Before + fun setup() { + repository = ToDoListRepository(plannerApi, courseApi) + } + + // getPlannerItems tests + @Test + fun `getPlannerItems returns success with data`() = runTest { + val startDate = "2025-01-01" + val endDate = "2025-01-31" + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1"), + createPlannerItem(id = 2L, title = "Assignment 2") + ) + + coEvery { + plannerApi.getPlannerItems( + startDate = startDate, + endDate = endDate, + contextCodes = emptyList(), + restParams = any() + ) + } returns DataResult.Success(plannerItems) + + val result = repository.getPlannerItems(startDate, endDate, forceRefresh = false) + + assertTrue(result is DataResult.Success) + assertEquals(2, result.dataOrNull?.size) + assertEquals("Assignment 1", result.dataOrNull?.get(0)?.plannable?.title) + } + + @Test + fun `getPlannerItems returns failure when API call fails`() = runTest { + val startDate = "2025-01-01" + val endDate = "2025-01-31" + + coEvery { + plannerApi.getPlannerItems( + startDate = startDate, + endDate = endDate, + contextCodes = emptyList(), + restParams = any() + ) + } returns DataResult.Fail() + + val result = repository.getPlannerItems(startDate, endDate, forceRefresh = false) + + assertTrue(result is DataResult.Fail) + } + + @Test + fun `getPlannerItems uses correct RestParams when forceRefresh is true`() = runTest { + val startDate = "2025-01-01" + val endDate = "2025-01-31" + + coEvery { + plannerApi.getPlannerItems( + startDate = any(), + endDate = any(), + contextCodes = any(), + restParams = any() + ) + } returns DataResult.Success(emptyList()) + + repository.getPlannerItems(startDate, endDate, forceRefresh = true) + + coVerify { + plannerApi.getPlannerItems( + startDate = startDate, + endDate = endDate, + contextCodes = emptyList(), + restParams = match { it.isForceReadFromNetwork && it.usePerPageQueryParam } + ) + } + } + + @Test + fun `getPlannerItems uses correct RestParams when forceRefresh is false`() = runTest { + val startDate = "2025-01-01" + val endDate = "2025-01-31" + + coEvery { + plannerApi.getPlannerItems( + startDate = any(), + endDate = any(), + contextCodes = any(), + restParams = any() + ) + } returns DataResult.Success(emptyList()) + + repository.getPlannerItems(startDate, endDate, forceRefresh = false) + + coVerify { + plannerApi.getPlannerItems( + startDate = startDate, + endDate = endDate, + contextCodes = emptyList(), + restParams = match { !it.isForceReadFromNetwork && it.usePerPageQueryParam } + ) + } + } + + // getCourses tests + @Test + fun `getCourses returns success with data`() = runTest { + val courses = listOf( + Course(id = 1L, name = "Course 1", courseCode = "CS101"), + Course(id = 2L, name = "Course 2", courseCode = "MATH201") + ) + + coEvery { + courseApi.getFirstPageCourses(any()) + } returns DataResult.Success(courses) + + val result = repository.getCourses(forceRefresh = false) + + assertTrue(result is DataResult.Success) + assertEquals(2, result.dataOrNull?.size) + assertEquals("Course 1", result.dataOrNull?.get(0)?.name) + } + + @Test + fun `getCourses returns failure when API call fails`() = runTest { + coEvery { + courseApi.getFirstPageCourses(any()) + } returns DataResult.Fail() + + val result = repository.getCourses(forceRefresh = false) + + assertTrue(result is DataResult.Fail) + } + + @Test + fun `getCourses uses correct RestParams when forceRefresh is true`() = runTest { + coEvery { + courseApi.getFirstPageCourses(any()) + } returns DataResult.Success(emptyList()) + + repository.getCourses(forceRefresh = true) + + coVerify { + courseApi.getFirstPageCourses( + match { it.isForceReadFromNetwork } + ) + } + } + + @Test + fun `getCourses uses correct RestParams when forceRefresh is false`() = runTest { + coEvery { + courseApi.getFirstPageCourses(any()) + } returns DataResult.Success(emptyList()) + + repository.getCourses(forceRefresh = false) + + coVerify { + courseApi.getFirstPageCourses( + match { !it.isForceReadFromNetwork } + ) + } + } + + // updatePlannerOverride tests + @Test + fun `updatePlannerOverride returns success with updated override`() = runTest { + val overrideId = 123L + val override = PlannerOverride( + id = overrideId, + plannableId = 1L, + plannableType = PlannableType.ASSIGNMENT, + markedComplete = true + ) + + coEvery { + plannerApi.updatePlannerOverride( + plannerOverrideId = overrideId, + complete = true, + params = any() + ) + } returns DataResult.Success(override) + + val result = repository.updatePlannerOverride(overrideId, markedComplete = true) + + assertTrue(result is DataResult.Success) + assertEquals(overrideId, result.dataOrNull?.id) + assertEquals(true, result.dataOrNull?.markedComplete) + } + + @Test + fun `updatePlannerOverride returns failure when API call fails`() = runTest { + val overrideId = 123L + + coEvery { + plannerApi.updatePlannerOverride( + plannerOverrideId = overrideId, + complete = false, + params = any() + ) + } returns DataResult.Fail() + + val result = repository.updatePlannerOverride(overrideId, markedComplete = false) + + assertTrue(result is DataResult.Fail) + } + + @Test + fun `updatePlannerOverride always uses forceRefresh`() = runTest { + val overrideId = 123L + + coEvery { + plannerApi.updatePlannerOverride( + plannerOverrideId = any(), + complete = any(), + params = any() + ) + } returns DataResult.Success(mockk(relaxed = true)) + + repository.updatePlannerOverride(overrideId, markedComplete = true) + + coVerify { + plannerApi.updatePlannerOverride( + plannerOverrideId = overrideId, + complete = true, + params = match { it.isForceReadFromNetwork } + ) + } + } + + // createPlannerOverride tests + @Test + fun `createPlannerOverride returns success with created override`() = runTest { + val plannableId = 456L + val plannableType = PlannableType.ASSIGNMENT + val override = PlannerOverride( + id = 789L, + plannableId = plannableId, + plannableType = plannableType, + markedComplete = true + ) + + coEvery { + plannerApi.createPlannerOverride( + plannerOverride = any(), + params = any() + ) + } returns DataResult.Success(override) + + val result = repository.createPlannerOverride( + plannableId = plannableId, + plannableType = plannableType, + markedComplete = true + ) + + assertTrue(result is DataResult.Success) + assertEquals(plannableId, result.dataOrNull?.plannableId) + assertEquals(plannableType, result.dataOrNull?.plannableType) + assertEquals(true, result.dataOrNull?.markedComplete) + } + + @Test + fun `createPlannerOverride returns failure when API call fails`() = runTest { + coEvery { + plannerApi.createPlannerOverride( + plannerOverride = any(), + params = any() + ) + } returns DataResult.Fail() + + val result = repository.createPlannerOverride( + plannableId = 456L, + plannableType = PlannableType.QUIZ, + markedComplete = false + ) + + assertTrue(result is DataResult.Fail) + } + + @Test + fun `createPlannerOverride passes correct parameters to API`() = runTest { + val plannableId = 456L + val plannableType = PlannableType.DISCUSSION_TOPIC + + coEvery { + plannerApi.createPlannerOverride( + plannerOverride = any(), + params = any() + ) + } returns DataResult.Success(mockk(relaxed = true)) + + repository.createPlannerOverride( + plannableId = plannableId, + plannableType = plannableType, + markedComplete = false + ) + + coVerify { + plannerApi.createPlannerOverride( + plannerOverride = match { + it.plannableId == plannableId && + it.plannableType == plannableType && !it.markedComplete + }, + params = match { it.isForceReadFromNetwork } + ) + } + } + + @Test + fun `createPlannerOverride always uses forceRefresh`() = runTest { + coEvery { + plannerApi.createPlannerOverride( + plannerOverride = any(), + params = any() + ) + } returns DataResult.Success(mockk(relaxed = true)) + + repository.createPlannerOverride( + plannableId = 456L, + plannableType = PlannableType.PLANNER_NOTE, + markedComplete = true + ) + + coVerify { + plannerApi.createPlannerOverride( + plannerOverride = any(), + params = match { it.isForceReadFromNetwork } + ) + } + } + + // Helper function to create test PlannerItem + private fun createPlannerItem( + id: Long, + title: String, + plannableType: PlannableType = PlannableType.ASSIGNMENT + ): PlannerItem { + return PlannerItem( + courseId = 1L, + groupId = null, + userId = null, + contextType = "Course", + contextName = "Test Course", + plannableType = plannableType, + plannable = Plannable( + id = id, + title = title, + courseId = 1L, + groupId = null, + userId = null, + pointsPossible = null, + dueAt = Date(), + assignmentId = null, + todoDate = null, + startAt = null, + endAt = null, + details = null, + allDay = null, + subAssignmentTag = null + ), + plannableDate = Date(), + htmlUrl = null, + submissionState = null, + newActivity = null, + plannerOverride = null, + plannableItemDetails = null + ) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListViewModelTest.kt new file mode 100644 index 0000000000..e466f0fc25 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/todolist/ToDoListViewModelTest.kt @@ -0,0 +1,423 @@ +/* + * 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.features.todolist + +import android.content.Context +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.Plannable +import com.instructure.canvasapi2.models.PlannableType +import com.instructure.canvasapi2.models.PlannerItem +import com.instructure.canvasapi2.models.SubmissionState +import com.instructure.canvasapi2.utils.ContextKeeper +import com.instructure.canvasapi2.utils.DataResult +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +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.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class ToDoListViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val context: Context = mockk(relaxed = true) + private val repository: ToDoListRepository = mockk(relaxed = true) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + ContextKeeper.appContext = context + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `ViewModel init loads data successfully`() = runTest { + val courses = listOf( + Course(id = 1L, name = "Course 1", courseCode = "CS101"), + Course(id = 2L, name = "Course 2", courseCode = "MATH201") + ) + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1", courseId = 1L), + createPlannerItem(id = 2L, title = "Quiz 1", courseId = 2L, plannableType = PlannableType.QUIZ) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertFalse(uiState.isLoading) + assertFalse(uiState.isRefreshing) + assertFalse(uiState.isError) + assertEquals(2, uiState.itemsByDate.values.flatten().size) + } + + @Test + fun `ViewModel filters out announcements`() = runTest { + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1", plannableType = PlannableType.ASSIGNMENT), + createPlannerItem(id = 2L, title = "Announcement", plannableType = PlannableType.ANNOUNCEMENT) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val allItems = uiState.itemsByDate.values.flatten() + + assertEquals(1, allItems.size) + assertEquals("Assignment 1", allItems.first().title) + } + + @Test + fun `ViewModel filters out assessment requests`() = runTest { + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1", plannableType = PlannableType.ASSIGNMENT), + createPlannerItem(id = 2L, title = "Assessment Request", plannableType = PlannableType.ASSESSMENT_REQUEST) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val allItems = uiState.itemsByDate.values.flatten() + + assertEquals(1, allItems.size) + assertEquals("Assignment 1", allItems.first().title) + } + + @Test + fun `ViewModel filters out access restricted courses`() = runTest { + val courses = listOf( + Course(id = 1L, name = "Available Course", courseCode = "CS101", accessRestrictedByDate = false), + Course(id = 2L, name = "Restricted Course", courseCode = "CS102", accessRestrictedByDate = true) + ) + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1", courseId = 1L), + createPlannerItem(id = 2L, title = "Assignment 2", courseId = 2L) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + every { context.getString(any(), any()) } returns "CS101" + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val allItems = uiState.itemsByDate.values.flatten() + + // Only assignment from non-restricted course should be present with context label + assertEquals(2, allItems.size) + // First item should have context label from course map + assertTrue(allItems.any { it.title == "Assignment 1" }) + } + + @Test + fun `ViewModel filters out invited courses`() = runTest { + val courses = listOf( + Course(id = 1L, name = "Enrolled Course", courseCode = "CS101", enrollments = mutableListOf()), + Course(id = 2L, name = "Invited Course", courseCode = "CS102", enrollments = mutableListOf(mockk { + every { enrollmentState } returns "invited" + })) + ) + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1", courseId = 1L), + createPlannerItem(id = 2L, title = "Assignment 2", courseId = 2L) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + // Should have both assignments, but invited course won't be in course map + assertEquals(2, uiState.itemsByDate.values.flatten().size) + } + + @Test + fun `ViewModel handles error state`() = runTest { + coEvery { repository.getCourses(any()) } returns DataResult.Fail() + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Fail() + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertTrue(uiState.isError) + assertFalse(uiState.isLoading) + assertFalse(uiState.isRefreshing) + } + + @Test + fun `ViewModel handles exception during load`() = runTest { + coEvery { repository.getCourses(any()) } throws RuntimeException("Test error") + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertTrue(uiState.isError) + assertFalse(uiState.isLoading) + assertFalse(uiState.isRefreshing) + } + + @Test + fun `ViewModel groups items by date`() = runTest { + val date1 = Date(1704067200000L) // Jan 1, 2024 + val date2 = Date(1704153600000L) // Jan 2, 2024 + + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1", plannableDate = date1), + createPlannerItem(id = 2L, title = "Assignment 2", plannableDate = date1), + createPlannerItem(id = 3L, title = "Assignment 3", plannableDate = date2) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertEquals(2, uiState.itemsByDate.keys.size) + } + + @Test + fun `ViewModel maps item types correctly`() = runTest { + val plannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment", plannableType = PlannableType.ASSIGNMENT), + createPlannerItem(id = 2L, title = "Quiz", plannableType = PlannableType.QUIZ), + createPlannerItem(id = 3L, title = "Discussion", plannableType = PlannableType.DISCUSSION_TOPIC), + createPlannerItem(id = 4L, title = "Calendar Event", plannableType = PlannableType.CALENDAR_EVENT), + createPlannerItem(id = 5L, title = "Planner Note", plannableType = PlannableType.PLANNER_NOTE), + createPlannerItem(id = 6L, title = "Sub Assignment", plannableType = PlannableType.SUB_ASSIGNMENT) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val allItems = uiState.itemsByDate.values.flatten() + + assertEquals(6, allItems.size) + assertEquals(ToDoItemType.ASSIGNMENT, allItems.find { it.title == "Assignment" }?.itemType) + assertEquals(ToDoItemType.QUIZ, allItems.find { it.title == "Quiz" }?.itemType) + assertEquals(ToDoItemType.DISCUSSION, allItems.find { it.title == "Discussion" }?.itemType) + assertEquals(ToDoItemType.CALENDAR_EVENT, allItems.find { it.title == "Calendar Event" }?.itemType) + assertEquals(ToDoItemType.PLANNER_NOTE, allItems.find { it.title == "Planner Note" }?.itemType) + assertEquals(ToDoItemType.SUB_ASSIGNMENT, allItems.find { it.title == "Sub Assignment" }?.itemType) + } + + @Test + fun `ViewModel sets isChecked true for submitted assignments`() = runTest { + val plannerItem = createPlannerItem( + id = 1L, + title = "Submitted Assignment", + plannableType = PlannableType.ASSIGNMENT, + submitted = true + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val item = uiState.itemsByDate.values.flatten().first() + + assertTrue(item.isChecked) + } + + @Test + fun `ViewModel sets isChecked false for unsubmitted assignments`() = runTest { + val plannerItem = createPlannerItem( + id = 1L, + title = "Unsubmitted Assignment", + plannableType = PlannableType.ASSIGNMENT, + submitted = false + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem)) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val item = uiState.itemsByDate.values.flatten().first() + + assertFalse(item.isChecked) + } + + // handleAction tests + @Test + fun `handleAction ItemClicked sends OpenToDoItem event`() = runTest { + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + + val viewModel = getViewModel() + val events = mutableListOf() + + backgroundScope.launch(testDispatcher) { + viewModel.events.toList(events) + } + + viewModel.handleAction(ToDoListActionHandler.ItemClicked("123")) + + assertEquals(1, events.size) + assertTrue(events.first() is ToDoListViewModelAction.OpenToDoItem) + assertEquals("123", (events.first() as ToDoListViewModelAction.OpenToDoItem).itemId) + } + + @Test + fun `handleAction Refresh triggers data reload with forceRefresh`() = runTest { + val courses = listOf(Course(id = 1L, name = "Course 1", courseCode = "CS101")) + val initialPlannerItems = listOf(createPlannerItem(id = 1L, title = "Assignment 1")) + val refreshedPlannerItems = listOf( + createPlannerItem(id = 1L, title = "Assignment 1"), + createPlannerItem(id = 2L, title = "Assignment 2") + ) + + coEvery { repository.getCourses(false) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), false) } returns DataResult.Success(initialPlannerItems) + coEvery { repository.getCourses(true) } returns DataResult.Success(courses) + coEvery { repository.getPlannerItems(any(), any(), true) } returns DataResult.Success(refreshedPlannerItems) + + val viewModel = getViewModel() + + // Verify initial data + val initialUiState = viewModel.uiState.value + assertEquals(1, initialUiState.itemsByDate.values.flatten().size) + assertEquals("Assignment 1", initialUiState.itemsByDate.values.flatten().first().title) + + // Trigger refresh + viewModel.handleAction(ToDoListActionHandler.Refresh) + + // Verify refreshed data + val refreshedUiState = viewModel.uiState.value + assertEquals(2, refreshedUiState.itemsByDate.values.flatten().size) + assertTrue(refreshedUiState.itemsByDate.values.flatten().any { it.title == "Assignment 2" }) + } + + @Test + fun `Empty planner items returns empty state`() = runTest { + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(emptyList()) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + + assertFalse(uiState.isLoading) + assertFalse(uiState.isError) + assertTrue(uiState.itemsByDate.isEmpty()) + } + + @Test + fun `Items are sorted by comparison date`() = runTest { + val date1 = Date(1704067200000L) // Earlier date + val date2 = Date(1704153600000L) // Later date + + val plannerItems = listOf( + createPlannerItem(id = 2L, title = "Later Assignment", plannableDate = date2), + createPlannerItem(id = 1L, title = "Earlier Assignment", plannableDate = date1) + ) + + coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList()) + coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(plannerItems) + + val viewModel = getViewModel() + + val uiState = viewModel.uiState.value + val dates = uiState.itemsByDate.keys.toList() + + // Dates should be sorted (earlier date first) + assertTrue(dates.size == 2) + } + + // Helper functions + private fun getViewModel(): ToDoListViewModel { + return ToDoListViewModel(context, repository) + } + + private fun createPlannerItem( + id: Long, + title: String, + courseId: Long? = null, + plannableType: PlannableType = PlannableType.ASSIGNMENT, + plannableDate: Date = Date(), + submitted: Boolean = false + ): PlannerItem { + return PlannerItem( + courseId = courseId, + groupId = null, + userId = null, + contextType = if (courseId != null) "Course" else null, + contextName = null, + plannableType = plannableType, + plannable = Plannable( + id = id, + title = title, + courseId = courseId, + groupId = null, + userId = null, + pointsPossible = null, + dueAt = plannableDate, + assignmentId = null, + todoDate = null, + startAt = null, + endAt = null, + details = null, + allDay = null, + subAssignmentTag = null + ), + plannableDate = plannableDate, + htmlUrl = null, + submissionState = if (submitted) SubmissionState(submitted = true) else null, + newActivity = null, + plannerOverride = null, + plannableItemDetails = null + ) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/utils/PlannerItemExtensionsTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/PlannerItemExtensionsTest.kt new file mode 100644 index 0000000000..6536e2ecba --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/PlannerItemExtensionsTest.kt @@ -0,0 +1,459 @@ +/* + * 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.utils + +import android.content.Context +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.Plannable +import com.instructure.canvasapi2.models.PlannableType +import com.instructure.canvasapi2.models.PlannerItem +import com.instructure.canvasapi2.models.PlannerItemDetails +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.DateHelper +import com.instructure.canvasapi2.utils.toApiString +import com.instructure.pandautils.R +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.util.Calendar +import java.util.Date + +class PlannerItemExtensionsTest { + + private val context: Context = mockk(relaxed = true) + + @Before + fun setup() { + mockkObject(ApiPrefs) + every { ApiPrefs.fullDomain } returns "https://test.instructure.com" + + mockkObject(DateHelper) + } + + @After + fun tearDown() { + unmockkAll() + } + + companion object { + // Static dates for predictable testing + private val TEST_DATE = createDate(2025, Calendar.JANUARY, 15, 14, 30) // Jan 15, 2025 at 2:30 PM + private val TEST_DATE_2 = createDate(2025, Calendar.JANUARY, 15, 15, 30) // Jan 15, 2025 at 3:30 PM + + private fun createDate(year: Int, month: Int, day: Int, hour: Int = 0, minute: Int = 0): Date { + return Calendar.getInstance().apply { + set(year, month, day, hour, minute, 0) + set(Calendar.MILLISECOND, 0) + }.time + } + } + + // todoHtmlUrl tests + @Test + fun `todoHtmlUrl returns correct URL`() { + val plannerItem = createPlannerItem(plannableId = 12345L) + + val result = plannerItem.todoHtmlUrl(ApiPrefs) + + assertEquals("https://test.instructure.com/todos/12345", result) + } + + // getIconForPlannerItem tests + @Test + fun `getIconForPlannerItem returns assignment icon for ASSIGNMENT type`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.ASSIGNMENT) + + val result = plannerItem.getIconForPlannerItem() + + assertEquals(R.drawable.ic_assignment, result) + } + + @Test + fun `getIconForPlannerItem returns quiz icon for QUIZ type`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.QUIZ) + + val result = plannerItem.getIconForPlannerItem() + + assertEquals(R.drawable.ic_quiz, result) + } + + @Test + fun `getIconForPlannerItem returns calendar icon for CALENDAR_EVENT type`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.CALENDAR_EVENT) + + val result = plannerItem.getIconForPlannerItem() + + assertEquals(R.drawable.ic_calendar, result) + } + + @Test + fun `getIconForPlannerItem returns discussion icon for DISCUSSION_TOPIC type`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.DISCUSSION_TOPIC) + + val result = plannerItem.getIconForPlannerItem() + + assertEquals(R.drawable.ic_discussion, result) + } + + @Test + fun `getIconForPlannerItem returns discussion icon for SUB_ASSIGNMENT type`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.SUB_ASSIGNMENT) + + val result = plannerItem.getIconForPlannerItem() + + assertEquals(R.drawable.ic_discussion, result) + } + + @Test + fun `getIconForPlannerItem returns todo icon for PLANNER_NOTE type`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.PLANNER_NOTE) + + val result = plannerItem.getIconForPlannerItem() + + assertEquals(R.drawable.ic_todo, result) + } + + @Test + fun `getIconForPlannerItem returns calendar icon for unknown type`() { + val plannerItem = createPlannerItem(plannableType = PlannableType.ANNOUNCEMENT) + + val result = plannerItem.getIconForPlannerItem() + + assertEquals(R.drawable.ic_calendar, result) + } + + // getDateTextForPlannerItem tests + @Test + fun `getDateTextForPlannerItem returns formatted time for PLANNER_NOTE with todoDate`() { + val plannable = createPlannable(todoDate = TEST_DATE.toApiString()) + val plannerItem = createPlannerItem( + plannableType = PlannableType.PLANNER_NOTE, + plannable = plannable + ) + + every { DateHelper.getFormattedTime(context, TEST_DATE) } returns "2:30 PM" + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertEquals("2:30 PM", result) + } + + @Test + fun `getDateTextForPlannerItem returns null for PLANNER_NOTE without todoDate`() { + val plannable = createPlannable(todoDate = null) + val plannerItem = createPlannerItem( + plannableType = PlannableType.PLANNER_NOTE, + plannable = plannable + ) + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertNull(result) + } + + @Test + fun `getDateTextForPlannerItem returns all day text for all-day CALENDAR_EVENT`() { + val plannable = createPlannable( + startAt = TEST_DATE, + endAt = TEST_DATE, + allDay = true + ) + val plannerItem = createPlannerItem( + plannableType = PlannableType.CALENDAR_EVENT, + plannable = plannable + ) + + every { context.getString(R.string.widgetAllDay) } returns "All Day" + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertEquals("All Day", result) + } + + @Test + fun `getDateTextForPlannerItem returns single time for CALENDAR_EVENT with same start and end`() { + val plannable = createPlannable( + startAt = TEST_DATE, + endAt = TEST_DATE, + allDay = false + ) + val plannerItem = createPlannerItem( + plannableType = PlannableType.CALENDAR_EVENT, + plannable = plannable + ) + + every { DateHelper.getFormattedTime(context, TEST_DATE) } returns "2:30 PM" + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertEquals("2:30 PM", result) + } + + @Test + fun `getDateTextForPlannerItem returns time range for CALENDAR_EVENT with different times`() { + val plannable = createPlannable( + startAt = TEST_DATE, + endAt = TEST_DATE_2, + allDay = false + ) + val plannerItem = createPlannerItem( + plannableType = PlannableType.CALENDAR_EVENT, + plannable = plannable + ) + + every { DateHelper.getFormattedTime(context, TEST_DATE) } returns "2:30 PM" + every { DateHelper.getFormattedTime(context, TEST_DATE_2) } returns "3:30 PM" + every { context.getString(R.string.widgetFromTo, "2:30 PM", "3:30 PM") } returns "2:30 PM - 3:30 PM" + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertEquals("2:30 PM - 3:30 PM", result) + } + + @Test + fun `getDateTextForPlannerItem returns null for CALENDAR_EVENT without dates`() { + val plannable = createPlannable( + startAt = null, + endAt = null + ) + val plannerItem = createPlannerItem( + plannableType = PlannableType.CALENDAR_EVENT, + plannable = plannable + ) + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertNull(result) + } + + @Test + fun `getDateTextForPlannerItem returns due date text for ASSIGNMENT with dueAt`() { + val plannable = createPlannable(dueAt = TEST_DATE) + val plannerItem = createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + plannable = plannable + ) + + every { DateHelper.getFormattedTime(context, TEST_DATE) } returns "2:30 PM" + every { context.getString(R.string.widgetDueDate, "2:30 PM") } returns "Due: 2:30 PM" + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertEquals("Due: 2:30 PM", result) + } + + @Test + fun `getDateTextForPlannerItem returns null for ASSIGNMENT without dueAt`() { + val plannable = createPlannable(dueAt = null) + val plannerItem = createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + plannable = plannable + ) + + val result = plannerItem.getDateTextForPlannerItem(context) + + assertNull(result) + } + + // getContextNameForPlannerItem tests + @Test + fun `getContextNameForPlannerItem returns User To-Do for PLANNER_NOTE without contextName`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.PLANNER_NOTE, + contextName = null + ) + + every { context.getString(R.string.userCalendarToDo) } returns "User To-Do" + + val result = plannerItem.getContextNameForPlannerItem(context, emptyList()) + + assertEquals("User To-Do", result) + } + + @Test + fun `getContextNameForPlannerItem returns course todo for PLANNER_NOTE with contextName`() { + val course = Course(id = 123L, courseCode = "CS101") + val plannerItem = createPlannerItem( + plannableType = PlannableType.PLANNER_NOTE, + courseId = 123L, + contextName = "Computer Science" + ) + + every { context.getString(R.string.courseToDo, "CS101") } returns "CS101 To-Do" + + val result = plannerItem.getContextNameForPlannerItem(context, listOf(course)) + + assertEquals("CS101 To-Do", result) + } + + @Test + fun `getContextNameForPlannerItem returns course code for Course context`() { + val course = Course(id = 123L, courseCode = "CS101") + val plannerItem = createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + courseId = 123L + ) + + val result = plannerItem.getContextNameForPlannerItem(context, listOf(course)) + + assertEquals("CS101", result) + } + + @Test + fun `getContextNameForPlannerItem returns empty string for Course context without matching course`() { + val course = Course(id = 999L, courseCode = "CS101") + val plannerItem = createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + courseId = 123L + ) + + val result = plannerItem.getContextNameForPlannerItem(context, listOf(course)) + + assertEquals("", result) + } + + @Test + fun `getContextNameForPlannerItem returns contextName for non-Course context`() { + val plannerItem = createPlannerItem( + plannableType = PlannableType.ASSIGNMENT, + userId = 456L, + contextName = "Personal" + ) + + val result = plannerItem.getContextNameForPlannerItem(context, emptyList()) + + assertEquals("Personal", result) + } + + // getTagForPlannerItem tests + @Test + fun `getTagForPlannerItem returns reply to topic for REPLY_TO_TOPIC tag`() { + val plannable = createPlannable(subAssignmentTag = Const.REPLY_TO_TOPIC) + val plannerItem = createPlannerItem(plannable = plannable) + + every { context.getString(R.string.reply_to_topic) } returns "Reply to Topic" + + val result = plannerItem.getTagForPlannerItem(context) + + assertEquals("Reply to Topic", result) + } + + @Test + fun `getTagForPlannerItem returns additional replies for REPLY_TO_ENTRY with count`() { + val details = PlannerItemDetails(replyRequiredCount = 3) + val plannable = createPlannable(subAssignmentTag = Const.REPLY_TO_ENTRY) + val plannerItem = createPlannerItem( + plannable = plannable, + plannableItemDetails = details + ) + + every { context.getString(R.string.additional_replies, 3) } returns "3 Additional Replies" + + val result = plannerItem.getTagForPlannerItem(context) + + assertEquals("3 Additional Replies", result) + } + + @Test + fun `getTagForPlannerItem returns null for REPLY_TO_ENTRY without count`() { + val plannable = createPlannable(subAssignmentTag = Const.REPLY_TO_ENTRY) + val plannerItem = createPlannerItem( + plannable = plannable, + plannableItemDetails = null + ) + + val result = plannerItem.getTagForPlannerItem(context) + + assertNull(result) + } + + @Test + fun `getTagForPlannerItem returns null for no subAssignmentTag`() { + val plannable = createPlannable(subAssignmentTag = null) + val plannerItem = createPlannerItem(plannable = plannable) + + val result = plannerItem.getTagForPlannerItem(context) + + assertNull(result) + } + + // Helper functions to create test objects with default values + private fun createPlannable( + id: Long = 1L, + title: String = "Test", + courseId: Long? = null, + groupId: Long? = null, + userId: Long? = null, + pointsPossible: Double? = null, + dueAt: Date? = null, + assignmentId: Long? = null, + todoDate: String? = null, + startAt: Date? = null, + endAt: Date? = null, + details: String? = null, + allDay: Boolean? = null, + subAssignmentTag: String? = null + ): Plannable { + return Plannable( + id = id, + title = title, + courseId = courseId, + groupId = groupId, + userId = userId, + pointsPossible = pointsPossible, + dueAt = dueAt, + assignmentId = assignmentId, + todoDate = todoDate, + startAt = startAt, + endAt = endAt, + details = details, + allDay = allDay, + subAssignmentTag = subAssignmentTag + ) + } + + private fun createPlannerItem( + plannableId: Long = 1L, + plannableType: PlannableType = PlannableType.ASSIGNMENT, + plannable: Plannable = createPlannable(id = plannableId), + courseId: Long? = null, + userId: Long? = null, + contextName: String? = null, + plannableItemDetails: PlannerItemDetails? = null + ): PlannerItem { + return PlannerItem( + courseId = courseId, + groupId = null, + userId = userId, + contextType = if (courseId != null) "Course" else null, + contextName = contextName, + plannableType = plannableType, + plannable = plannable, + plannableDate = Date(), + htmlUrl = null, + submissionState = null, + newActivity = null, + plannerOverride = null, + plannableItemDetails = plannableItemDetails + ) + } +} \ No newline at end of file From 6a2559d66e20b41f684b3df44ab5843a79007c0b Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Wed, 29 Oct 2025 18:39:33 +0100 Subject: [PATCH 10/13] Changed dates to the correct default. --- .../java/com/instructure/student/activity/CallbackActivity.kt | 4 ++-- .../pandautils/features/todolist/ToDoListViewModel.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt index d2e64f4887..9cc8dec943 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt @@ -219,8 +219,8 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No private suspend fun getToDoCount() { // TODO Implement correct filtering in MBL-19401 val now = LocalDate.now().atStartOfDay() - val startDate = now.minusDays(28).toApiString().orEmpty() - val endDate = now.plusDays(28).toApiString().orEmpty() + val startDate = now.minusDays(7).toApiString().orEmpty() + val endDate = now.plusDays(7).toApiString().orEmpty() val restParams = RestParams(isForceReadFromNetwork = true, usePerPageQueryParam = true) val plannerItems = plannerApi.getPlannerItems( diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt index be24f55760..fe424743c0 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListViewModel.kt @@ -61,8 +61,8 @@ class ToDoListViewModel @Inject constructor( _uiState.update { it.copy(isLoading = !forceRefresh, isRefreshing = forceRefresh, isError = false) } val now = LocalDate.now().atStartOfDay() - val startDate = now.minusDays(28).toApiString().orEmpty() - val endDate = now.plusDays(28).toApiString().orEmpty() + val startDate = now.minusDays(7).toApiString().orEmpty() + val endDate = now.plusDays(7).toApiString().orEmpty() val courses = repository.getCourses(forceRefresh).dataOrThrow val plannerItems = repository.getPlannerItems(startDate, endDate, forceRefresh).dataOrThrow From 5e7de1e4d7fdf1f170ada460365bb78d0116b15c Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Wed, 29 Oct 2025 19:10:03 +0100 Subject: [PATCH 11/13] Added remote config flag. --- .../com/instructure/student/activity/CallbackActivity.kt | 6 +++++- .../com/instructure/student/activity/NavigationActivity.kt | 6 ++++-- .../instructure/student/navigation/NavigationBehavior.kt | 4 +++- .../java/com/instructure/student/router/RouteMatcher.kt | 4 +++- .../java/com/instructure/student/util/FeatureFlagPrefs.kt | 3 --- .../com/instructure/canvasapi2/utils/RemoteConfigUtils.kt | 1 + 6 files changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt index 9cc8dec943..d1715f8ae9 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt @@ -42,6 +42,8 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.ApiType import com.instructure.canvasapi2.utils.LinkHeaders import com.instructure.canvasapi2.utils.Logger +import com.instructure.canvasapi2.utils.RemoteConfigParam +import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.canvasapi2.utils.depaginate import com.instructure.canvasapi2.utils.pageview.PandataInfo import com.instructure.canvasapi2.utils.pageview.PandataManager @@ -186,7 +188,9 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No getUnreadNotificationCount() - getToDoCount() + if (RemoteConfigUtils.getBoolean(RemoteConfigParam.TODO_REDESIGN)) { + getToDoCount() + } initialCoreDataLoadingComplete() } catch { 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 b012dc6425..2f783c5c6f 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 @@ -62,6 +62,8 @@ import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.Logger import com.instructure.canvasapi2.utils.MasqueradeHelper import com.instructure.canvasapi2.utils.Pronouns +import com.instructure.canvasapi2.utils.RemoteConfigParam +import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.canvasapi2.utils.weave.WeaveJob import com.instructure.canvasapi2.utils.weave.awaitApi import com.instructure.canvasapi2.utils.weave.catch @@ -1314,12 +1316,12 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. CalendarFragment.newInstance(route) } navigationBehavior.todoFragmentClass.name -> { - val route = if (FeatureFlagPrefs.ENABLE_NEW_TODO_LIST_SCREEN) { + val route = if (RemoteConfigUtils.getBoolean(RemoteConfigParam.TODO_REDESIGN)) { ToDoListFragment.makeRoute(ApiPrefs.user!!) } else { OldToDoListFragment.makeRoute(ApiPrefs.user!!) } - if (FeatureFlagPrefs.ENABLE_NEW_TODO_LIST_SCREEN) { + if (RemoteConfigUtils.getBoolean(RemoteConfigParam.TODO_REDESIGN)) { ToDoListFragment.newInstance(route) } else { OldToDoListFragment.newInstance(route) 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 b1d95703d9..4143610768 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 @@ -20,6 +20,8 @@ import androidx.annotation.MenuRes 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.inbox.list.InboxFragment import com.instructure.pandautils.utils.CanvasFont @@ -46,7 +48,7 @@ interface NavigationBehavior { val todoFragmentClass: Class get() { - return if (FeatureFlagPrefs.ENABLE_NEW_TODO_LIST_SCREEN) { + return if (RemoteConfigUtils.getBoolean(RemoteConfigParam.TODO_REDESIGN)) { ToDoListFragment::class.java } else { OldToDoListFragment::class.java 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 90df7d898c..28a8d30074 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 @@ -33,6 +33,8 @@ import com.instructure.canvasapi2.models.FileFolder import com.instructure.canvasapi2.models.Tab import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.Logger +import com.instructure.canvasapi2.utils.RemoteConfigParam +import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.interactions.router.BaseRouteMatcher @@ -345,7 +347,7 @@ object RouteMatcher : BaseRouteMatcher() { routes.add(Route("/todos/:${ToDoFragment.PLANNABLE_ID}", ToDoFragment::class.java)) // To Do List - val todoListFragmentClass = if (FeatureFlagPrefs.ENABLE_NEW_TODO_LIST_SCREEN) { + val todoListFragmentClass = if (RemoteConfigUtils.getBoolean(RemoteConfigParam.TODO_REDESIGN)) { ToDoListFragment::class.java } else { OldToDoListFragment::class.java diff --git a/apps/student/src/main/java/com/instructure/student/util/FeatureFlagPrefs.kt b/apps/student/src/main/java/com/instructure/student/util/FeatureFlagPrefs.kt index c7a3d8c307..251ba56c6a 100644 --- a/apps/student/src/main/java/com/instructure/student/util/FeatureFlagPrefs.kt +++ b/apps/student/src/main/java/com/instructure/student/util/FeatureFlagPrefs.kt @@ -19,7 +19,4 @@ import com.instructure.canvasapi2.utils.PrefManager object FeatureFlagPrefs : PrefManager("feature_flags") { - // Temporary feature flag to enable the new To-Do List screen. There is a ticket to implement feature flag handling - const val ENABLE_NEW_TODO_LIST_SCREEN = true - } 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 76618fbdef..621281f55f 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,6 +17,7 @@ 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") } /** From 5344e0c3014c84574c0a120009a0adec04e96b3a Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Wed, 29 Oct 2025 19:53:39 +0100 Subject: [PATCH 12/13] Handle correct context name and removed unused imports. --- .../instructure/student/activity/NavigationActivity.kt | 1 - .../instructure/student/navigation/NavigationBehavior.kt | 1 - .../java/com/instructure/student/router/RouteMatcher.kt | 1 - .../instructure/pandautils/utils/PlannerItemExtensions.kt | 8 +++++--- 4 files changed, 5 insertions(+), 6 deletions(-) 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 2f783c5c6f..f39b6315ad 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 @@ -153,7 +153,6 @@ import com.instructure.student.router.RouteResolver import com.instructure.student.tasks.StudentLogoutTask import com.instructure.student.util.Analytics import com.instructure.student.util.AppShortcutManager -import com.instructure.student.util.FeatureFlagPrefs import com.instructure.student.util.StudentPrefs import com.instructure.student.widget.WidgetUpdater import dagger.hilt.android.AndroidEntryPoint 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 4143610768..4f6a29c8c8 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 @@ -29,7 +29,6 @@ import com.instructure.student.activity.NothingToSeeHereFragment import com.instructure.student.features.todolist.ToDoListFragment import com.instructure.student.fragment.OldToDoListFragment import com.instructure.student.fragment.ParentFragment -import com.instructure.student.util.FeatureFlagPrefs interface NavigationBehavior { 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 28a8d30074..648d5da8ba 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 @@ -100,7 +100,6 @@ import com.instructure.student.fragment.ViewUnsupportedFileFragment import com.instructure.student.mobius.assignmentDetails.submissionDetails.ui.SubmissionDetailsFragment import com.instructure.student.mobius.conferences.conference_list.ui.ConferenceListRepositoryFragment import com.instructure.student.mobius.syllabus.ui.SyllabusRepositoryFragment -import com.instructure.student.util.FeatureFlagPrefs import com.instructure.student.util.FileUtils import com.instructure.student.util.onMainThread import java.util.Locale diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt index 9533c5c261..3f5c7540d4 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/PlannerItemExtensions.kt @@ -75,19 +75,21 @@ fun PlannerItem.getDateTextForPlannerItem(context: Context): String? { } fun PlannerItem.getContextNameForPlannerItem(context: Context, courses: Collection): String { - val courseCode = courses.find { it.id == canvasContext.id }?.courseCode + val course = courses.find { it.id == canvasContext.id } + val hasNickname = course?.originalName != null + val courseTitle = if (hasNickname) course.name else course?.courseCode return when (plannableType) { PlannableType.PLANNER_NOTE -> { if (contextName.isNullOrEmpty()) { context.getString(R.string.userCalendarToDo) } else { - context.getString(R.string.courseToDo, courseCode) + context.getString(R.string.courseToDo, courseTitle ?: contextName) } } else -> { if (canvasContext is Course) { - courseCode.orEmpty() + courseTitle.orEmpty() } else { contextName.orEmpty() } From 7fb10d1b1f73cf3d22948f8cd24a41bfa21be76a Mon Sep 17 00:00:00 2001 From: Tamas Kozmer Date: Thu, 30 Oct 2025 08:46:23 +0100 Subject: [PATCH 13/13] Fixed RouterUtilsTest --- .../instructure/student/test/util/RouterUtilsTest.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/student/src/test/java/com/instructure/student/test/util/RouterUtilsTest.kt b/apps/student/src/test/java/com/instructure/student/test/util/RouterUtilsTest.kt index 32710d6e65..57f4ea4d9d 100644 --- a/apps/student/src/test/java/com/instructure/student/test/util/RouterUtilsTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/util/RouterUtilsTest.kt @@ -21,6 +21,7 @@ import androidx.fragment.app.FragmentActivity import androidx.test.ext.junit.runners.AndroidJUnit4 import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.RemoteConfigUtils import com.instructure.interactions.router.Route import com.instructure.interactions.router.RouteContext import com.instructure.interactions.router.RouterParams @@ -46,8 +47,11 @@ import com.instructure.student.mobius.assignmentDetails.submissionDetails.ui.Sub import com.instructure.student.mobius.conferences.conference_list.ui.ConferenceListRepositoryFragment import com.instructure.student.mobius.syllabus.ui.SyllabusRepositoryFragment import com.instructure.student.router.RouteMatcher +import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject import junit.framework.TestCase +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -56,6 +60,12 @@ class RouterUtilsTest : TestCase() { private val activity: FragmentActivity = mockk(relaxed = true) + @Before + fun setup() { + mockkObject(RemoteConfigUtils) + every { RemoteConfigUtils.getString(any()) } returns "false" + } + @Test fun testCanRouteInternally_misc() { // Home