diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt
index e665f3eaeb..ac1bfddfa6 100644
--- a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt
+++ b/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt
@@ -20,6 +20,8 @@ package com.instructure.parentapp.di.feature
import androidx.fragment.app.FragmentActivity
import com.instructure.pandautils.features.calendartodo.details.ToDoViewModelBehavior
import com.instructure.pandautils.features.calendartodo.details.ToDoRouter
+import com.instructure.pandautils.features.todolist.DefaultToDoListRouter
+import com.instructure.pandautils.features.todolist.ToDoListRouter
import com.instructure.parentapp.features.calendartodo.ParentToDoRouter
import com.instructure.parentapp.util.navigation.Navigation
import dagger.Module
@@ -36,6 +38,11 @@ class ToDoModule {
fun provideToDoRouter(activity: FragmentActivity, navigation: Navigation): ToDoRouter {
return ParentToDoRouter(activity, navigation)
}
+
+ @Provides
+ fun provideToDoListRouter(): ToDoListRouter {
+ return DefaultToDoListRouter()
+ }
}
@Module
diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/LoginInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/LoginInteractionTest.kt
index 4720757b45..beafbbc822 100644
--- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/LoginInteractionTest.kt
+++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/LoginInteractionTest.kt
@@ -38,7 +38,7 @@ class LoginInteractionTest : StudentTest() {
if(isTabletDevice()) loginFindSchoolPage.assertHintText(R.string.schoolInstructureCom)
else loginFindSchoolPage.assertHintText(R.string.loginHint)
- loginFindSchoolPage.enterDomain("harv")
+ loginFindSchoolPage.enterDomain("harvest")
loginFindSchoolPage.assertSchoolSearchResults("City Harvest Church (Singapore)")
}
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 d1715f8ae9..9dcbff4e6d 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
@@ -60,6 +60,7 @@ import com.instructure.pandautils.utils.FeatureFlagProvider
import com.instructure.pandautils.utils.LocaleUtils
import com.instructure.pandautils.utils.SHA256
import com.instructure.pandautils.utils.ThemePrefs
+import com.instructure.pandautils.utils.isComplete
import com.instructure.pandautils.utils.orDefault
import com.instructure.pandautils.utils.toast
import com.instructure.student.BuildConfig
@@ -236,7 +237,7 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No
plannerApi.nextPagePlannerItems(nextUrl, restParams)
}
- val todoCount = plannerItems.dataOrNull?.count().orDefault()
+ val todoCount = plannerItems.dataOrNull?.count { !it.isComplete() }.orDefault()
updateToDoCount(todoCount)
}
diff --git a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt
index f39b6315ad..f7ad3fa378 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
@@ -91,6 +91,8 @@ import com.instructure.pandautils.features.notification.preferences.PushNotifica
import com.instructure.pandautils.features.offline.sync.OfflineSyncHelper
import com.instructure.pandautils.features.reminder.AlarmScheduler
import com.instructure.pandautils.features.settings.SettingsFragment
+import com.instructure.pandautils.features.todolist.OnToDoCountChanged
+import com.instructure.pandautils.features.todolist.ToDoListFragment
import com.instructure.pandautils.interfaces.NavigationCallbacks
import com.instructure.pandautils.models.PushNotification
import com.instructure.pandautils.receivers.PushExternalReceiver
@@ -136,7 +138,6 @@ 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
@@ -174,7 +175,7 @@ private const val BOTTOM_SCREENS_BUNDLE_KEY = "bottomScreens"
@AndroidEntryPoint
@Suppress("DELEGATED_MEMBER_HIDES_SUPERTYPE_OVERRIDE")
class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog.OnMasqueradingSet,
- FullScreenInteractions, ActivityCompat.OnRequestPermissionsResultCallback by PermissionReceiver() {
+ FullScreenInteractions, ActivityCompat.OnRequestPermissionsResultCallback by PermissionReceiver(), OnToDoCountChanged {
private val binding by viewBinding(ActivityNavigationBinding::inflate)
private lateinit var navigationDrawerBinding: NavigationDrawerBinding
@@ -1273,6 +1274,10 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog.
updateBottomBarBadge(R.id.bottomNavigationToDo, toDoCount, R.plurals.a11y_todoBadgeCount)
}
+ override fun onToDoCountChanged(count: Int) {
+ updateToDoCount(count)
+ }
+
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/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt b/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt
index 3ef8fbc4d9..f735e4312f 100644
--- a/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt
+++ b/apps/student/src/main/java/com/instructure/student/features/settings/StudentSettingsBehaviour.kt
@@ -45,7 +45,7 @@ class StudentSettingsBehaviour(
if (apiPrefs.canvasForElementary) {
preferencesList.add(1, SettingsItem.HOMEROOM_VIEW)
}
- if (BuildConfig.DEBUG) {
+ if (BuildConfig.IS_DEBUG) {
preferencesList.add(SettingsItem.ACCOUNT_PREFERENCES)
preferencesList.add(SettingsItem.FEATURE_FLAGS)
preferencesList.add(SettingsItem.REMOTE_CONFIG)
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
index 8e1ec238a0..546b18af37 100644
--- 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
@@ -18,6 +18,7 @@ package com.instructure.student.features.todolist
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
+import com.instructure.pandautils.features.todolist.ToDoListFragment
import com.instructure.pandautils.features.todolist.ToDoListRouter
import com.instructure.student.activity.NavigationActivity
diff --git a/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt b/apps/student/src/main/java/com/instructure/student/navigation/NavigationBehavior.kt
index 4f6a29c8c8..1f5fa1b168 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
@@ -26,7 +26,7 @@ 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.pandautils.features.todolist.ToDoListFragment
import com.instructure.student.fragment.OldToDoListFragment
import com.instructure.student.fragment.ParentFragment
diff --git a/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt b/apps/student/src/main/java/com/instructure/student/router/RouteMatcher.kt
index 12a77f8045..be5807afc7 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
@@ -82,7 +82,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.pandautils.features.todolist.ToDoListFragment
import com.instructure.student.fragment.AnnouncementListFragment
import com.instructure.student.fragment.BasicQuizViewFragment
import com.instructure.student.fragment.CourseSettingsFragment
diff --git a/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt b/apps/student/src/main/java/com/instructure/student/router/RouteResolver.kt
index 8b665681fd..f1ffe01a5a 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,7 +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.pandautils.features.todolist.ToDoListFragment
import com.instructure.student.fragment.AccountPreferencesFragment
import com.instructure.student.fragment.AnnouncementListFragment
import com.instructure.student.fragment.AssignmentBasicFragment
diff --git a/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoModule.kt b/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoModule.kt
index 49984b9398..19ae28e1a9 100644
--- a/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoModule.kt
+++ b/apps/teacher/src/main/java/com/instructure/teacher/di/ToDoModule.kt
@@ -20,6 +20,8 @@ package com.instructure.teacher.di
import androidx.fragment.app.FragmentActivity
import com.instructure.pandautils.features.calendartodo.details.ToDoViewModelBehavior
import com.instructure.pandautils.features.calendartodo.details.ToDoRouter
+import com.instructure.pandautils.features.todolist.DefaultToDoListRouter
+import com.instructure.pandautils.features.todolist.ToDoListRouter
import com.instructure.teacher.features.calendartodo.TeacherToDoRouter
import dagger.Module
import dagger.Provides
@@ -35,6 +37,11 @@ class ToDoModule {
fun provideToDoRouter(activity: FragmentActivity): ToDoRouter {
return TeacherToDoRouter(activity)
}
+
+ @Provides
+ fun provideToDoListRouter(): ToDoListRouter {
+ return DefaultToDoListRouter()
+ }
}
@Module
diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/ApiEndpoint.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/ApiEndpoint.kt
index ef196dfbc5..5a2194d4c6 100644
--- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/ApiEndpoint.kt
+++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/ApiEndpoint.kt
@@ -18,7 +18,6 @@ package com.instructure.canvas.espresso.mockcanvas.endpoints
import android.util.Log
import com.google.gson.Gson
-import com.instructure.canvas.espresso.mockCanvas.endpoints.CareerEndpoint
import com.instructure.canvas.espresso.mockcanvas.Endpoint
import com.instructure.canvas.espresso.mockcanvas.addDiscussionTopicToCourse
import com.instructure.canvas.espresso.mockcanvas.addPlannable
diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/CareerEndpoints.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/CareerEndpoints.kt
index 28f6dd32a1..acfcfc0e4d 100644
--- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/CareerEndpoints.kt
+++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockcanvas/endpoints/CareerEndpoints.kt
@@ -14,7 +14,7 @@
* along with this program. If not, see .
*
*/
-package com.instructure.canvas.espresso.mockCanvas.endpoints
+package com.instructure.canvas.espresso.mockcanvas.endpoints
import com.instructure.canvas.espresso.mockcanvas.Endpoint
import com.instructure.canvas.espresso.mockcanvas.utils.Segment
diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml
index f20aadf279..20259688d5 100644
--- a/libs/pandares/src/main/res/values/strings.xml
+++ b/libs/pandares/src/main/res/values/strings.xml
@@ -2186,6 +2186,13 @@
Multiple filters
Filter
There was an error loading your to-do items. Please check your connection and try again.
+ There was an error updating the to-do item. Please check your connection and try again.
No To Dos for now!
It looks like a great time to rest, relax, and recharge.
+ Done
+ Undo
+ %s marked as done
+ %s marked as not done
+ Undo
+ This action cannot be performed offline
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SelectContextScreen.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SelectContextScreen.kt
index 502258936c..dcff8f4c1e 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SelectContextScreen.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/compose/composables/SelectContextScreen.kt
@@ -48,12 +48,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.instructure.canvasapi2.models.CanvasContext
import com.instructure.canvasapi2.models.Course
-import com.instructure.canvasapi2.models.User
import com.instructure.canvasapi2.utils.ContextKeeper
import com.instructure.pandautils.R
import com.instructure.pandautils.compose.CanvasTheme
-import com.instructure.pandautils.utils.ThemePrefs
-import com.instructure.pandautils.utils.color
+import com.instructure.pandautils.utils.courseOrUserColor
import com.instructure.pandautils.utils.isCourse
import com.instructure.pandautils.utils.isGroup
import com.instructure.pandautils.utils.isUser
@@ -188,13 +186,7 @@ private fun SelectContextItem(
modifier: Modifier = Modifier
) {
val context = LocalContext.current
- val color = Color(
- if (canvasContext is User) {
- ThemePrefs.brandColor
- } else {
- canvasContext.color
- }
- )
+ val color = Color(canvasContext.courseOrUserColor)
Row(
modifier = modifier
.defaultMinSize(minHeight = 54.dp)
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/composables/CalendarEvents.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/composables/CalendarEvents.kt
index 894a8b9697..447df04416 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/composables/CalendarEvents.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/composables/CalendarEvents.kt
@@ -65,7 +65,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.instructure.canvasapi2.models.CanvasContext
-import com.instructure.canvasapi2.models.User
import com.instructure.canvasapi2.utils.ContextKeeper
import com.instructure.pandautils.R
import com.instructure.pandautils.compose.composables.ErrorContent
@@ -75,7 +74,7 @@ import com.instructure.pandautils.features.calendar.CalendarEventsPageUiState
import com.instructure.pandautils.features.calendar.CalendarEventsUiState
import com.instructure.pandautils.features.calendar.EventUiState
import com.instructure.pandautils.utils.ThemePrefs
-import com.instructure.pandautils.utils.color
+import com.instructure.pandautils.utils.courseOrUserColor
import com.jakewharton.threetenabp.AndroidThreeTen
private const val PAGE_COUNT = 1000
@@ -184,11 +183,7 @@ fun CalendarEventsPage(
@Composable
fun CalendarEventItem(eventUiState: EventUiState, onEventClick: (Long) -> Unit, modifier: Modifier = Modifier) {
- val contextColor = if (eventUiState.canvasContext is User) {
- Color(ThemePrefs.brandColor)
- } else {
- Color(eventUiState.canvasContext.color)
- }
+ val contextColor = Color(eventUiState.canvasContext.courseOrUserColor)
Row(
modifier
.clickable { onEventClick(eventUiState.plannableId) }
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/filter/CalendarFilterViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/filter/CalendarFilterViewModel.kt
index 292d524db2..dcc4b1e145 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/filter/CalendarFilterViewModel.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/calendar/filter/CalendarFilterViewModel.kt
@@ -23,8 +23,7 @@ import com.instructure.canvasapi2.utils.DataResult
import com.instructure.pandautils.R
import com.instructure.pandautils.features.calendar.CalendarRepository
import com.instructure.pandautils.room.calendar.entities.CalendarFilterEntity
-import com.instructure.pandautils.utils.ThemePrefs
-import com.instructure.pandautils.utils.color
+import com.instructure.pandautils.utils.courseOrUserColor
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -89,7 +88,7 @@ class CalendarFilterViewModel @Inject constructor(
}
private fun createFilterItemsUiState(type: CanvasContext.Type) = canvasContexts[type]?.map {
- val color = if (type == CanvasContext.Type.USER) ThemePrefs.brandColor else it.color
+ val color = it.courseOrUserColor
CalendarFilterItemUiState(it.contextId, it.name.orEmpty(), contextIdFilters.contains(it.contextId), color)
} ?: emptyList()
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/DefaultToDoListRouter.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/DefaultToDoListRouter.kt
new file mode 100644
index 0000000000..01e8550b48
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/DefaultToDoListRouter.kt
@@ -0,0 +1,35 @@
+/*
+ * 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
+
+/**
+ * Default implementation of ToDoListRouter with no-op implementations.
+ * Used when the app doesn't need custom routing behavior.
+ */
+class DefaultToDoListRouter : ToDoListRouter {
+
+ override fun openNavigationDrawer() {
+ // No-op implementation
+ }
+
+ override fun attachNavigationDrawer() {
+ // No-op implementation
+ }
+
+ override fun openToDoItem(itemId: String) {
+ // No-op implementation
+ }
+}
\ No newline at end of file
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/OnToDoCountChanged.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/OnToDoCountChanged.kt
new file mode 100644
index 0000000000..87d20371cf
--- /dev/null
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/OnToDoCountChanged.kt
@@ -0,0 +1,20 @@
+/*
+ * 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 OnToDoCountChanged {
+ fun onToDoCountChanged(count: Int)
+}
\ No newline at end of file
diff --git a/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListFragment.kt
similarity index 58%
rename from apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt
rename to libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListFragment.kt
index 9797b598d3..0d291327b1 100644
--- a/apps/student/src/main/java/com/instructure/student/features/todolist/ToDoListFragment.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/todolist/ToDoListFragment.kt
@@ -1,51 +1,27 @@
-/*
- * 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
+package com.instructure.pandautils.features.todolist
+import android.content.Context
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.R
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
@@ -54,28 +30,30 @@ import javax.inject.Inject
@AndroidEntryPoint
class ToDoListFragment : BaseCanvasFragment(), FragmentInteractions, NavigationCallbacks {
- private val viewModel: ToDoListViewModel by viewModels()
-
@Inject
lateinit var toDoListRouter: ToDoListRouter
+ private var onToDoCountChanged: OnToDoCountChanged? = null
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ onToDoCountChanged = context as? OnToDoCountChanged
+ }
+
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() }
+ navigationIconClick = { toDoListRouter.openNavigationDrawer() },
+ openToDoItem = { itemId -> toDoListRouter.openToDoItem(itemId) },
+ onToDoCountChanged = { count -> onToDoCountChanged?.onToDoCountChanged(count) }
)
}
}
@@ -94,12 +72,6 @@ class ToDoListFragment : BaseCanvasFragment(), FragmentInteractions, NavigationC
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
}
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 3ef9e3904e..a64ee2b5fd 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,6 +15,7 @@
*/
package com.instructure.pandautils.features.todolist
+import com.instructure.canvasapi2.CanvasRestAdapter
import com.instructure.canvasapi2.apis.CourseAPI
import com.instructure.canvasapi2.apis.PlannerAPI
import com.instructure.canvasapi2.builders.RestParams
@@ -78,4 +79,8 @@ class ToDoListRepository @Inject constructor(
)
return plannerApi.createPlannerOverride(override, restParams)
}
+
+ fun invalidateCachedResponses() {
+ CanvasRestAdapter.clearCacheUrls("planner")
+ }
}
\ 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 ca2e86382f..b2d54c9798 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,10 +15,15 @@
*/
package com.instructure.pandautils.features.todolist
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -39,24 +44,36 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
+import androidx.compose.material.Snackbar
+import androidx.compose.material.SnackbarDuration
+import androidx.compose.material.SnackbarHost
+import androidx.compose.material.SnackbarHostState
+import androidx.compose.material.SnackbarResult
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.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
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.platform.LocalView
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -67,6 +84,7 @@ 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 androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.instructure.canvasapi2.models.CanvasContext
import com.instructure.canvasapi2.utils.ContextKeeper
import com.instructure.pandautils.R
@@ -79,12 +97,19 @@ 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 com.instructure.pandautils.utils.performGestureHapticFeedback
+import com.instructure.pandautils.utils.performToggleHapticFeedback
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
+import kotlin.math.abs
import kotlin.math.roundToInt
+private const val SWIPE_THRESHOLD_DP = 150
+
private data class StickyHeaderState(
val item: ToDoItemUiState?,
val yOffset: Float,
@@ -102,14 +127,53 @@ private data class DateBadgeData(
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ToDoListScreen(
- uiState: ToDoListUiState,
- actionHandler: (ToDoListActionHandler) -> Unit,
- modifier: Modifier = Modifier,
- navigationIconClick: () -> Unit = {}
+ navigationIconClick: () -> Unit,
+ openToDoItem: (String) -> Unit,
+ onToDoCountChanged: (Int) -> Unit,
+ modifier: Modifier = Modifier
) {
+ val viewModel = hiltViewModel()
+ val uiState by viewModel.uiState.collectAsState()
+ val snackbarHostState = remember { SnackbarHostState() }
+ val context = LocalContext.current
+
+ LaunchedEffect(uiState.snackbarMessage) {
+ uiState.snackbarMessage?.let { message ->
+ snackbarHostState.showSnackbar(message)
+ uiState.onSnackbarDismissed()
+ }
+ }
+
+ LaunchedEffect(uiState.confirmationSnackbarData) {
+ uiState.confirmationSnackbarData?.let { item ->
+ val messageRes = if (item.markedAsDone) {
+ R.string.todoMarkedAsDone
+ } else {
+ R.string.todoMarkedAsNotDone
+ }
+ val message = context.getString(messageRes, item.title)
+ val result = snackbarHostState.showSnackbar(
+ message = message,
+ actionLabel = context.getString(R.string.todoMarkedAsDoneSnackbarUndo),
+ duration = SnackbarDuration.Long
+ )
+ if (result == SnackbarResult.ActionPerformed) {
+ uiState.onUndoMarkAsDoneUndoneAction()
+ } else {
+ uiState.onMarkedAsDoneSnackbarDismissed()
+ }
+ }
+ }
+
+ LaunchedEffect(uiState.toDoCount) {
+ uiState.toDoCount?.let { count ->
+ onToDoCountChanged(count)
+ }
+ }
+
val pullRefreshState = rememberPullRefreshState(
refreshing = uiState.isRefreshing,
- onRefresh = { actionHandler(ToDoListActionHandler.Refresh) }
+ onRefresh = uiState.onRefresh
)
Scaffold(
@@ -121,7 +185,7 @@ fun ToDoListScreen(
navIconContentDescription = stringResource(id = R.string.navigation_drawer_open),
navigationActionClick = navigationIconClick,
actions = {
- IconButton(onClick = { actionHandler(ToDoListActionHandler.FilterClicked) }) {
+ IconButton(onClick = { /* TODO: Implement filter - will be implemented in future story */ }) {
Icon(
painter = painterResource(id = R.drawable.ic_filter_outline),
contentDescription = stringResource(id = R.string.a11y_contentDescriptionToDoFilter)
@@ -130,6 +194,14 @@ fun ToDoListScreen(
}
)
},
+ snackbarHost = {
+ SnackbarHost(snackbarHostState) { data ->
+ Snackbar(
+ snackbarData = data,
+ actionColor = Color(ThemePrefs.textButtonColor)
+ )
+ }
+ },
modifier = modifier
) { padding ->
Box(
@@ -138,35 +210,10 @@ fun ToDoListScreen(
.padding(padding)
.pullRefresh(pullRefreshState)
) {
- when {
- uiState.isLoading -> {
- Loading(modifier = Modifier.align(Alignment.Center))
- }
-
- 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
- )
- }
- }
+ ToDoListContent(
+ uiState = uiState,
+ onOpenToDoItem = openToDoItem
+ )
PullRefreshIndicator(
refreshing = uiState.isRefreshing,
@@ -181,8 +228,46 @@ fun ToDoListScreen(
@Composable
private fun ToDoListContent(
+ uiState: ToDoListUiState,
+ onOpenToDoItem: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ when {
+ uiState.isLoading -> {
+ Loading(modifier = modifier.fillMaxSize())
+ }
+
+ uiState.isError -> {
+ ErrorContent(
+ errorMessage = stringResource(id = R.string.errorLoadingToDos),
+ retryClick = uiState.onRefresh,
+ 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 -> {
+ ToDoItemsList(
+ itemsByDate = uiState.itemsByDate,
+ onItemClicked = onOpenToDoItem,
+ modifier = modifier
+ )
+ }
+ }
+}
+
+@Composable
+private fun ToDoItemsList(
itemsByDate: Map>,
- actionHandler: (ToDoListActionHandler) -> Unit,
+ onItemClicked: (String) -> Unit,
modifier: Modifier = Modifier
) {
val dateGroups = itemsByDate.entries.toList()
@@ -234,8 +319,8 @@ private fun ToDoListContent(
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)) },
+ onCheckedChange = { item.onCheckboxToggle(!item.isChecked) },
+ onClick = { onItemClicked(item.id) },
modifier = Modifier.onGloballyPositioned { coordinates ->
itemPositions[item.id] = coordinates.positionInParent().y
itemSizes[item.id] = coordinates.size.height
@@ -310,12 +395,187 @@ private fun ToDoItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
hideDate: Boolean = false
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val animatedOffsetX = remember { Animatable(0f) }
+ var itemWidth by remember { mutableFloatStateOf(0f) }
+ val density = LocalDensity.current
+ val view = LocalView.current
+
+ val swipeThreshold = with(density) { SWIPE_THRESHOLD_DP.dp.toPx() }
+
+ fun animateToCenter() {
+ coroutineScope.launch {
+ animatedOffsetX.animateTo(
+ targetValue = 0f,
+ animationSpec = tween(durationMillis = 300)
+ )
+ }
+ }
+
+ fun handleSwipeEnd() {
+ coroutineScope.launch {
+ val currentOffset = animatedOffsetX.value
+ val absOffset = abs(currentOffset)
+ if (absOffset >= swipeThreshold) {
+ val targetOffset = if (currentOffset > 0) itemWidth else -itemWidth
+ animatedOffsetX.animateTo(
+ targetValue = targetOffset,
+ animationSpec = tween(durationMillis = 200)
+ )
+
+ // Gesture end haptic feedback
+ view.performGestureHapticFeedback(isStart = false)
+ delay(300)
+ animateToCenter()
+ item.onSwipeToDone()
+ } else {
+ animateToCenter()
+ }
+ }
+ }
+
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .onGloballyPositioned { coordinates ->
+ itemWidth = coordinates.size.width.toFloat()
+ }
+ .pointerInput(Unit) {
+ detectHorizontalDragGestures(
+ onDragStart = {
+ // Gesture start haptic feedback when user begins dragging
+ view.performGestureHapticFeedback(isStart = true)
+ },
+ onDragEnd = { handleSwipeEnd() },
+ onDragCancel = {
+ animateToCenter()
+ },
+ onHorizontalDrag = { _, dragAmount ->
+ coroutineScope.launch {
+ val newOffset = (animatedOffsetX.value + dragAmount).coerceIn(-itemWidth, itemWidth)
+ animatedOffsetX.snapTo(newOffset)
+ }
+ }
+ )
+ }
+ ) {
+ SwipeBackground(
+ isChecked = item.isChecked,
+ offsetX = animatedOffsetX.value
+ )
+
+ ToDoItemContent(
+ item = item,
+ showDateBadge = showDateBadge,
+ hideDate = hideDate,
+ onCheckedChange = onCheckedChange,
+ onClick = onClick,
+ modifier = Modifier.offset { IntOffset(animatedOffsetX.value.roundToInt(), 0) }
+ )
+ }
+}
+
+@Composable
+private fun BoxScope.SwipeBackground(isChecked: Boolean, offsetX: Float) {
+ val backgroundColor = if (isChecked) {
+ colorResource(R.color.backgroundDark)
+ } else {
+ colorResource(R.color.backgroundSuccess)
+ }
+
+ val text = if (isChecked) {
+ stringResource(id = R.string.todoSwipeUndo)
+ } else {
+ stringResource(id = R.string.todoSwipeDone)
+ }
+
+ val icon = if (isChecked) {
+ R.drawable.ic_reply
+ } else {
+ R.drawable.ic_checkmark_lined
+ }
+
+ // Calculate alpha based on swipe progress with ease-in curve
+ val density = LocalDensity.current
+ val swipeThreshold = with(density) { SWIPE_THRESHOLD_DP.dp.toPx() }
+ val progress = (abs(offsetX) / swipeThreshold).coerceIn(0f, 1f)
+ // Apply ease-in cubic easing for gradual fade-in that accelerates near threshold
+ val alpha = progress * progress * progress
+
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(backgroundColor)
+ ) {
+ if (offsetX > 0) {
+ Row(
+ modifier = Modifier
+ .align(Alignment.CenterStart)
+ .padding(start = 16.dp)
+ .alpha(alpha),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start
+ ) {
+ Icon(
+ painter = painterResource(id = icon),
+ contentDescription = null,
+ tint = colorResource(R.color.textLightest),
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = text,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = colorResource(R.color.textLightest)
+ )
+ }
+ }
+
+ if (offsetX < 0) {
+ Row(
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .padding(end = 16.dp)
+ .alpha(alpha),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.End
+ ) {
+ Text(
+ text = text,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = colorResource(R.color.textLightest)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Icon(
+ painter = painterResource(id = icon),
+ contentDescription = null,
+ tint = colorResource(R.color.textLightest),
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ToDoItemContent(
+ item: ToDoItemUiState,
+ showDateBadge: Boolean,
+ hideDate: Boolean,
+ onCheckedChange: () -> Unit,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
) {
val dateBadgeData = rememberDateBadgeData(item.date)
+ val view = LocalView.current
Row(
modifier = modifier
.fillMaxWidth()
+ .background(colorResource(id = R.color.backgroundLightest))
.clickable(onClick = onClick)
.padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.Top
@@ -394,7 +654,11 @@ private fun ToDoItem(
Checkbox(
checked = item.isChecked,
- onCheckedChange = { onCheckedChange() },
+ onCheckedChange = {
+ // Determine if marking as done or undone based on the new checked state
+ view.performToggleHapticFeedback(it)
+ onCheckedChange()
+ },
colors = CheckboxDefaults.colors(
checkedColor = Color(ThemePrefs.brandColor),
uncheckedColor = colorResource(id = R.color.textDark)
@@ -595,7 +859,7 @@ fun ToDoListScreenPreview() {
ContextKeeper.appContext = LocalContext.current
val calendar = Calendar.getInstance()
CanvasTheme {
- ToDoListScreen(
+ ToDoListContent(
uiState = ToDoListUiState(
itemsByDate = mapOf(
Date(10) to listOf(
@@ -695,7 +959,7 @@ fun ToDoListScreenPreview() {
)
)
),
- actionHandler = {}
+ onOpenToDoItem = {}
)
}
}
@@ -707,7 +971,7 @@ fun ToDoListScreenWithPandasPreview() {
ContextKeeper.appContext = LocalContext.current
val calendar = Calendar.getInstance()
CanvasTheme {
- ToDoListScreen(
+ ToDoListContent(
uiState = ToDoListUiState(
itemsByDate = mapOf(
Date(10) to listOf(
@@ -736,7 +1000,7 @@ fun ToDoListScreenWithPandasPreview() {
)
)
),
- actionHandler = {}
+ onOpenToDoItem = {}
)
}
}
@@ -747,9 +1011,9 @@ fun ToDoListScreenWithPandasPreview() {
fun ToDoListScreenEmptyPreview() {
ContextKeeper.appContext = LocalContext.current
CanvasTheme {
- ToDoListScreen(
+ ToDoListContent(
uiState = ToDoListUiState(),
- actionHandler = {}
+ onOpenToDoItem = {}
)
}
}
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 03f7481d46..4b10f3855a 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
@@ -23,7 +23,20 @@ data class ToDoListUiState(
val isLoading: Boolean = false,
val isError: Boolean = false,
val isRefreshing: Boolean = false,
- val itemsByDate: Map> = emptyMap()
+ val itemsByDate: Map> = emptyMap(),
+ val snackbarMessage: String? = null,
+ val onSnackbarDismissed: () -> Unit = {},
+ val confirmationSnackbarData: ConfirmationSnackbarData? = null,
+ val onUndoMarkAsDoneUndoneAction: () -> Unit = {},
+ val onMarkedAsDoneSnackbarDismissed: () -> Unit = {},
+ val onRefresh: () -> Unit = {},
+ val toDoCount: Int? = null
+)
+
+data class ConfirmationSnackbarData(
+ val itemId: String,
+ val title: String,
+ val markedAsDone: Boolean
)
data class ToDoItemUiState(
@@ -36,7 +49,9 @@ data class ToDoItemUiState(
val itemType: ToDoItemType,
val isChecked: Boolean = false,
val iconRes: Int = R.drawable.ic_calendar,
- val tag: String? = null
+ val tag: String? = null,
+ val onSwipeToDone: () -> Unit = {},
+ val onCheckboxToggle: (Boolean) -> Unit = {}
)
enum class ToDoItemType {
@@ -47,14 +62,3 @@ enum class ToDoItemType {
CALENDAR_EVENT,
PLANNER_NOTE
}
-
-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
index fe424743c0..bbd40ad710 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
@@ -18,38 +18,49 @@ package com.instructure.pandautils.features.todolist
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.google.firebase.crashlytics.FirebaseCrashlytics
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.R
+import com.instructure.pandautils.utils.NetworkStateProvider
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.isComplete
+import com.instructure.pandautils.utils.orDefault
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 java.util.Date
import javax.inject.Inject
@HiltViewModel
class ToDoListViewModel @Inject constructor(
@ApplicationContext private val context: Context,
- private val repository: ToDoListRepository
+ private val repository: ToDoListRepository,
+ private val networkStateProvider: NetworkStateProvider,
+ private val firebaseCrashlytics: FirebaseCrashlytics
) : ViewModel() {
- private val _uiState = MutableStateFlow(ToDoListUiState())
+ private val _uiState = MutableStateFlow(
+ ToDoListUiState(
+ onSnackbarDismissed = { clearSnackbarMessage() },
+ onUndoMarkAsDoneUndoneAction = { handleUndoMarkAsDoneUndone() },
+ onMarkedAsDoneSnackbarDismissed = { clearMarkedAsDoneItem() },
+ onRefresh = { handleRefresh() }
+ ))
val uiState = _uiState.asStateFlow()
- private val _events = Channel()
- val events = _events.receiveAsFlow()
+ private val plannerItemsMap = mutableMapOf()
init {
loadData()
@@ -78,6 +89,10 @@ class ToDoListViewModel @Inject constructor(
.filter { it.plannableType != PlannableType.ANNOUNCEMENT && it.plannableType != PlannableType.ASSESSMENT_REQUEST }
.sortedBy { it.comparisonDate }
+ // Store planner items for later reference
+ plannerItemsMap.clear()
+ filteredItems.forEach { plannerItemsMap[it.plannable.id.toString()] = it }
+
// Group items by date
val itemsByDate = filteredItems
.groupBy { DateHelper.getCleanDate(it.comparisonDate.time) }
@@ -87,16 +102,20 @@ class ToDoListViewModel @Inject constructor(
}
}
+ val toDoCount = calculateToDoCount(itemsByDate)
+
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
isError = false,
- itemsByDate = itemsByDate
+ itemsByDate = itemsByDate,
+ toDoCount = toDoCount
)
}
} catch (e: Exception) {
e.printStackTrace()
+ firebaseCrashlytics.recordException(e)
_uiState.update {
it.copy(
isLoading = false,
@@ -119,50 +138,166 @@ class ToDoListViewModel @Inject constructor(
else -> ToDoItemType.CALENDAR_EVENT
}
+ val itemId = plannerItem.plannable.id.toString()
+
return ToDoItemUiState(
- id = plannerItem.plannable.id.toString(),
+ id = itemId,
title = plannerItem.plannable.title,
date = plannerItem.plannableDate,
dateLabel = plannerItem.getDateTextForPlannerItem(context),
contextLabel = plannerItem.getContextNameForPlannerItem(context, courseMap.values),
canvasContext = plannerItem.canvasContext,
itemType = itemType,
- isChecked = isComplete(plannerItem),
+ isChecked = plannerItem.isComplete(),
iconRes = plannerItem.getIconForPlannerItem(),
- tag = plannerItem.getTagForPlannerItem(context)
+ tag = plannerItem.getTagForPlannerItem(context),
+ onSwipeToDone = { handleSwipeToDone(itemId) },
+ onCheckboxToggle = { isChecked -> handleCheckboxToggle(itemId, isChecked) }
)
}
- 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
+ private fun handleSwipeToDone(itemId: String) {
+ viewModelScope.launch {
+ if (!networkStateProvider.isOnline()) {
+ _uiState.update {
+ it.copy(snackbarMessage = context.getString(R.string.todoActionOffline))
+ }
+ return@launch
+ }
+
+ val plannerItem = plannerItemsMap[itemId] ?: return@launch
+ val currentIsChecked = plannerItem.isComplete()
+ val newIsChecked = !currentIsChecked
+
+ val success = updateItemCompleteState(itemId, newIsChecked)
+
+ // Show marked-as-done snackbar only when marking as done (not when undoing)
+ if (success) {
+ _uiState.update {
+ it.copy(
+ confirmationSnackbarData = ConfirmationSnackbarData(
+ itemId = itemId,
+ title = plannerItem.plannable.title,
+ markedAsDone = newIsChecked
+ )
+ )
+ }
+ }
+ }
+ }
+
+ private fun handleUndoMarkAsDoneUndone() {
+ viewModelScope.launch {
+ val markedAsDoneItem = _uiState.value.confirmationSnackbarData ?: return@launch
+ val itemId = markedAsDoneItem.itemId
+
+ // Clear the snackbar immediately
+ _uiState.update { it.copy(confirmationSnackbarData = null) }
+
+ updateItemCompleteState(itemId, !markedAsDoneItem.markedAsDone)
}
}
- fun handleAction(action: ToDoListActionHandler) {
- when (action) {
- is ToDoListActionHandler.ItemClicked -> {
- viewModelScope.launch {
- _events.send(ToDoListViewModelAction.OpenToDoItem(action.itemId))
+ private fun handleCheckboxToggle(itemId: String, isChecked: Boolean) {
+ viewModelScope.launch {
+ if (!networkStateProvider.isOnline()) {
+ _uiState.update {
+ it.copy(snackbarMessage = context.getString(R.string.todoActionOffline))
}
+ return@launch
}
- is ToDoListActionHandler.Refresh -> {
- loadData(forceRefresh = true)
+ val plannerItem = plannerItemsMap[itemId] ?: return@launch
+
+ val success = updateItemCompleteState(itemId, isChecked)
+
+ // Show marked-as-done snackbar only when checking the box
+ if (success) {
+ _uiState.update {
+ it.copy(
+ confirmationSnackbarData = ConfirmationSnackbarData(
+ itemId = itemId,
+ title = plannerItem.plannable.title,
+ markedAsDone = isChecked
+ )
+ )
+ }
+ }
+ }
+ }
+
+ private suspend fun updateItemCompleteState(itemId: String, newIsChecked: Boolean): Boolean {
+ val plannerItem = plannerItemsMap[itemId] ?: return false
+ val currentIsChecked = plannerItem.isComplete()
+
+ // Optimistically update UI
+ updateItemCheckedState(itemId, newIsChecked)
+
+ return try {
+ // Update or create planner override
+ val plannerOverrideResult = if (plannerItem.plannerOverride?.id != null) {
+ repository.updatePlannerOverride(
+ plannerOverrideId = plannerItem.plannerOverride?.id.orDefault(),
+ markedComplete = newIsChecked
+ ).dataOrThrow
+ } else {
+ repository.createPlannerOverride(
+ plannableId = plannerItem.plannable.id,
+ plannableType = plannerItem.plannableType,
+ markedComplete = newIsChecked
+ ).dataOrThrow
}
- is ToDoListActionHandler.ToggleItemChecked -> {
- // TODO: Implement toggle checked - will be implemented in future story
+ // Update the stored planner item with new override state
+ val updatedPlannerItem = plannerItem.copy(plannerOverride = plannerOverrideResult)
+ plannerItemsMap[itemId] = updatedPlannerItem
+
+ // Invalidate planner cache
+ repository.invalidateCachedResponses()
+
+ true
+ } catch (e: Exception) {
+ e.printStackTrace()
+ firebaseCrashlytics.recordException(e)
+ // Revert the optimistic update
+ updateItemCheckedState(itemId, currentIsChecked)
+ // Show error snackbar
+ _uiState.update {
+ it.copy(snackbarMessage = context.getString(R.string.errorUpdatingToDo))
}
+ false
+ }
+ }
- is ToDoListActionHandler.FilterClicked -> {
- // TODO: Implement filter - will be implemented in future story
+ private fun updateItemCheckedState(itemId: String, isChecked: Boolean) {
+ _uiState.update { state ->
+ val updatedItemsByDate = state.itemsByDate.mapValues { (_, items) ->
+ items.map { item ->
+ if (item.id == itemId) {
+ item.copy(isChecked = isChecked)
+ } else {
+ item
+ }
+ }
}
+ val toDoCount = calculateToDoCount(updatedItemsByDate)
+ state.copy(itemsByDate = updatedItemsByDate, toDoCount = toDoCount)
}
}
+
+ private fun calculateToDoCount(itemsByDate: Map>): Int {
+ return itemsByDate.values.flatten().count { !it.isChecked }
+ }
+
+ private fun handleRefresh() {
+ loadData(forceRefresh = true)
+ }
+
+ private fun clearSnackbarMessage() {
+ _uiState.update { it.copy(snackbarMessage = null) }
+ }
+
+ private fun clearMarkedAsDoneItem() {
+ _uiState.update { it.copy(confirmationSnackbarData = null) }
+ }
}
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 3f5c7540d4..b9d132142e 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
@@ -109,3 +109,14 @@ fun PlannerItem.getTagForPlannerItem(context: Context): String? {
null
}
}
+
+fun PlannerItem.isComplete(): Boolean {
+ return plannerOverride?.markedComplete ?: if (plannableType == PlannableType.ASSIGNMENT
+ || plannableType == PlannableType.DISCUSSION_TOPIC
+ || plannableType == PlannableType.SUB_ASSIGNMENT
+ ) {
+ submissionState?.submitted == true
+ } else {
+ false
+ }
+}
diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt
index c764887a1e..a651bad475 100644
--- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt
+++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt
@@ -35,10 +35,12 @@ import android.graphics.Rect
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
+import android.os.Build
import android.text.Editable
import android.text.TextWatcher
import android.util.AttributeSet
import android.util.TypedValue
+import android.view.HapticFeedbackConstants
import android.view.Menu
import android.view.MenuItem
import android.view.TouchDelegate
@@ -990,3 +992,29 @@ fun View.showSnackbar(
snackbar.show()
snackbar.view.requestAccessibilityFocus(1000)
}
+
+/**
+ * Performs haptic feedback with appropriate constants based on API level.
+ * Uses TOGGLE_ON/TOGGLE_OFF on API 34+ for marking done/undone, falls back to CONTEXT_CLICK on older versions.
+ */
+fun View.performToggleHapticFeedback(toggleOn: Boolean) {
+ val hapticConstant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ if (toggleOn) HapticFeedbackConstants.TOGGLE_ON else HapticFeedbackConstants.TOGGLE_OFF
+ } else {
+ HapticFeedbackConstants.CONTEXT_CLICK
+ }
+ performHapticFeedback(hapticConstant)
+}
+
+/**
+ * Performs haptic feedback for gesture start/end with appropriate constants based on API level.
+ * Uses GESTURE_START/GESTURE_END on API 34+, falls back to CONTEXT_CLICK on older versions.
+ */
+fun View.performGestureHapticFeedback(isStart: Boolean) {
+ val hapticConstant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ if (isStart) HapticFeedbackConstants.GESTURE_START else HapticFeedbackConstants.GESTURE_END
+ } else {
+ HapticFeedbackConstants.CONTEXT_CLICK
+ }
+ performHapticFeedback(hapticConstant)
+}
\ 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
index e466f0fc25..f9753e7abe 100644
--- 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
@@ -16,21 +16,25 @@
package com.instructure.pandautils.features.todolist
import android.content.Context
+import com.google.firebase.crashlytics.FirebaseCrashlytics
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.models.SubmissionState
import com.instructure.canvasapi2.utils.ContextKeeper
import com.instructure.canvasapi2.utils.DataResult
+import com.instructure.pandautils.R
+import com.instructure.pandautils.utils.NetworkStateProvider
import io.mockk.coEvery
+import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
+import io.mockk.verify
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
@@ -49,6 +53,8 @@ class ToDoListViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val context: Context = mockk(relaxed = true)
private val repository: ToDoListRepository = mockk(relaxed = true)
+ private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true)
+ private val firebaseCrashlytics: FirebaseCrashlytics = mockk(relaxed = true)
@Before
fun setUp() {
@@ -290,28 +296,9 @@ class ToDoListViewModelTest {
assertFalse(item.isChecked)
}
- // handleAction tests
+ // Callback 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 {
+ fun `onRefresh callback 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(
@@ -332,7 +319,7 @@ class ToDoListViewModelTest {
assertEquals("Assignment 1", initialUiState.itemsByDate.values.flatten().first().title)
// Trigger refresh
- viewModel.handleAction(ToDoListActionHandler.Refresh)
+ viewModel.uiState.value.onRefresh()
// Verify refreshed data
val refreshedUiState = viewModel.uiState.value
@@ -376,9 +363,350 @@ class ToDoListViewModelTest {
assertTrue(dates.size == 2)
}
+ // Todo count tests
+ @Test
+ fun `ViewModel calculates todo count correctly on initial load`() = runTest {
+ val plannerItems = listOf(
+ createPlannerItem(id = 1L, title = "Unchecked 1", submitted = false),
+ createPlannerItem(id = 2L, title = "Checked", submitted = true),
+ createPlannerItem(id = 3L, title = "Unchecked 2", submitted = false)
+ )
+
+ 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.toDoCount)
+ }
+
+ @Test
+ fun `ViewModel emits zero todo count when all items are checked`() = runTest {
+ val plannerItems = listOf(
+ createPlannerItem(id = 1L, title = "Checked 1", submitted = true),
+ createPlannerItem(id = 2L, title = "Checked 2", submitted = true)
+ )
+
+ 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(0, uiState.toDoCount)
+ }
+
+ @Test
+ fun `ViewModel emits todo count when all items are unchecked`() = runTest {
+ val plannerItems = listOf(
+ createPlannerItem(id = 1L, title = "Unchecked 1", submitted = false),
+ createPlannerItem(id = 2L, title = "Unchecked 2", submitted = false),
+ createPlannerItem(id = 3L, title = "Unchecked 3", submitted = false)
+ )
+
+ 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(3, uiState.toDoCount)
+ }
+
+ // Checkbox toggle tests
+ @Test
+ fun `Checkbox toggle successfully marks item as done`() = runTest {
+ val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false)
+ val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true)
+
+ coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem))
+ coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride)
+ every { networkStateProvider.isOnline() } returns true
+
+ val viewModel = getViewModel()
+
+ val item = viewModel.uiState.value.itemsByDate.values.flatten().first()
+ item.onCheckboxToggle(true)
+
+ val uiState = viewModel.uiState.value
+
+ assertTrue(uiState.itemsByDate.values.flatten().first().isChecked)
+ assertEquals("Assignment", uiState.confirmationSnackbarData?.title)
+ assertTrue(uiState.confirmationSnackbarData?.markedAsDone == true)
+ coVerify { repository.createPlannerOverride(1L, PlannableType.ASSIGNMENT, true) }
+ }
+
+ @Test
+ fun `Checkbox toggle successfully marks item as undone`() = runTest {
+ val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true)
+ val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false).copy(
+ plannerOverride = plannerOverride
+ )
+ val updatedOverride = plannerOverride.copy(markedComplete = false)
+
+ coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem))
+ coEvery { repository.updatePlannerOverride(any(), any()) } returns DataResult.Success(updatedOverride)
+ every { networkStateProvider.isOnline() } returns true
+
+ val viewModel = getViewModel()
+
+ val item = viewModel.uiState.value.itemsByDate.values.flatten().first()
+ item.onCheckboxToggle(false)
+
+ val uiState = viewModel.uiState.value
+
+ assertFalse(uiState.itemsByDate.values.flatten().first().isChecked)
+ coVerify { repository.updatePlannerOverride(100L, false) }
+ }
+
+ @Test
+ fun `Checkbox toggle shows offline snackbar when device is offline`() = runTest {
+ val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false)
+
+ coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem))
+ every { networkStateProvider.isOnline() } returns false
+ every { context.getString(R.string.todoActionOffline) } returns "This action cannot be performed offline"
+
+ val viewModel = getViewModel()
+
+ val item = viewModel.uiState.value.itemsByDate.values.flatten().first()
+ item.onCheckboxToggle(true)
+
+ val uiState = viewModel.uiState.value
+
+ assertFalse(uiState.itemsByDate.values.flatten().first().isChecked)
+ assertEquals("This action cannot be performed offline", uiState.snackbarMessage)
+ }
+
+ @Test
+ fun `Checkbox toggle reverts on failure`() = runTest {
+ val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false)
+
+ coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem))
+ coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Fail()
+ every { networkStateProvider.isOnline() } returns true
+ every { context.getString(R.string.errorUpdatingToDo) } returns "Error updating to-do"
+
+ val viewModel = getViewModel()
+
+ val item = viewModel.uiState.value.itemsByDate.values.flatten().first()
+ item.onCheckboxToggle(true)
+
+ val uiState = viewModel.uiState.value
+
+ assertFalse(uiState.itemsByDate.values.flatten().first().isChecked)
+ assertEquals("Error updating to-do", uiState.snackbarMessage)
+ }
+
+ // Swipe to done tests
+ @Test
+ fun `Swipe to done successfully marks item as done when unchecked`() = runTest {
+ val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false)
+ val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true)
+
+ coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem))
+ coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride)
+ every { networkStateProvider.isOnline() } returns true
+
+ val viewModel = getViewModel()
+
+ val item = viewModel.uiState.value.itemsByDate.values.flatten().first()
+ item.onSwipeToDone()
+
+ val uiState = viewModel.uiState.value
+
+ assertTrue(uiState.itemsByDate.values.flatten().first().isChecked)
+ assertEquals("Assignment", uiState.confirmationSnackbarData?.title)
+ assertTrue(uiState.confirmationSnackbarData?.markedAsDone == true)
+ }
+
+ @Test
+ fun `Swipe to done successfully marks item as undone when checked`() = runTest {
+ val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true)
+ val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false).copy(
+ plannerOverride = plannerOverride
+ )
+ val updatedOverride = plannerOverride.copy(markedComplete = false)
+
+ coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem))
+ coEvery { repository.updatePlannerOverride(any(), any()) } returns DataResult.Success(updatedOverride)
+ every { networkStateProvider.isOnline() } returns true
+
+ val viewModel = getViewModel()
+
+ val item = viewModel.uiState.value.itemsByDate.values.flatten().first()
+ item.onSwipeToDone()
+
+ val uiState = viewModel.uiState.value
+
+ assertFalse(uiState.itemsByDate.values.flatten().first().isChecked)
+ }
+
+ @Test
+ fun `Swipe to done shows offline snackbar when device is offline`() = runTest {
+ val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false)
+
+ coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem))
+ every { networkStateProvider.isOnline() } returns false
+ every { context.getString(R.string.todoActionOffline) } returns "This action cannot be performed offline"
+
+ val viewModel = getViewModel()
+
+ val item = viewModel.uiState.value.itemsByDate.values.flatten().first()
+ item.onSwipeToDone()
+
+ val uiState = viewModel.uiState.value
+
+ assertFalse(uiState.itemsByDate.values.flatten().first().isChecked)
+ assertEquals("This action cannot be performed offline", uiState.snackbarMessage)
+ }
+
+ // Cache invalidation tests
+ @Test
+ fun `Cache is invalidated after successfully creating planner override`() = runTest {
+ val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false)
+ val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true)
+
+ coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem))
+ coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride)
+ every { networkStateProvider.isOnline() } returns true
+
+ val viewModel = getViewModel()
+
+ val item = viewModel.uiState.value.itemsByDate.values.flatten().first()
+ item.onCheckboxToggle(true)
+
+ verify { repository.invalidateCachedResponses() }
+ }
+
+ @Test
+ fun `Cache is invalidated after successfully updating planner override`() = runTest {
+ val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true)
+ val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false).copy(
+ plannerOverride = plannerOverride
+ )
+ val updatedOverride = plannerOverride.copy(markedComplete = false)
+
+ coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem))
+ coEvery { repository.updatePlannerOverride(any(), any()) } returns DataResult.Success(updatedOverride)
+ every { networkStateProvider.isOnline() } returns true
+
+ val viewModel = getViewModel()
+
+ val item = viewModel.uiState.value.itemsByDate.values.flatten().first()
+ item.onCheckboxToggle(false)
+
+ verify { repository.invalidateCachedResponses() }
+ }
+
+ @Test
+ fun `Cache is not invalidated when planner override update fails`() = runTest {
+ val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false)
+
+ coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem))
+ coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Fail()
+ every { networkStateProvider.isOnline() } returns true
+ every { context.getString(R.string.errorUpdatingToDo) } returns "Error updating to-do"
+
+ val viewModel = getViewModel()
+
+ val item = viewModel.uiState.value.itemsByDate.values.flatten().first()
+ item.onCheckboxToggle(true)
+
+ verify(exactly = 0) { repository.invalidateCachedResponses() }
+ }
+
+ // Undo tests
+ @Test
+ fun `Undo mark as done successfully reverts item to unchecked`() = runTest {
+ val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false)
+ val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true)
+ val revertedOverride = plannerOverride.copy(markedComplete = false)
+
+ coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem))
+ coEvery { repository.createPlannerOverride(1L, PlannableType.ASSIGNMENT, true) } returns DataResult.Success(plannerOverride)
+ coEvery { repository.updatePlannerOverride(100L, false) } returns DataResult.Success(revertedOverride)
+ every { networkStateProvider.isOnline() } returns true
+
+ val viewModel = getViewModel()
+
+ // First mark as done
+ val item = viewModel.uiState.value.itemsByDate.values.flatten().first()
+ item.onCheckboxToggle(true)
+
+ // Verify marked as done
+ assertTrue(viewModel.uiState.value.itemsByDate.values.flatten().first().isChecked)
+
+ // Now undo
+ viewModel.uiState.value.onUndoMarkAsDoneUndoneAction()
+
+ val uiState = viewModel.uiState.value
+
+ assertFalse(uiState.itemsByDate.values.flatten().first().isChecked)
+ assertEquals(null, uiState.confirmationSnackbarData)
+ }
+
+ @Test
+ fun `Todo count updates when item is marked as done`() = runTest {
+ val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false)
+ val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true)
+
+ coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem))
+ coEvery { repository.createPlannerOverride(any(), any(), any()) } returns DataResult.Success(plannerOverride)
+ every { networkStateProvider.isOnline() } returns true
+
+ val viewModel = getViewModel()
+
+ assertEquals(1, viewModel.uiState.value.toDoCount)
+
+ val item = viewModel.uiState.value.itemsByDate.values.flatten().first()
+ item.onCheckboxToggle(true)
+
+ assertEquals(0, viewModel.uiState.value.toDoCount)
+ }
+
+ @Test
+ fun `Todo count updates when item is marked as undone`() = runTest {
+ val plannerOverride = PlannerOverride(id = 100L, plannableId = 1L, plannableType = PlannableType.ASSIGNMENT, markedComplete = true)
+ val plannerItem = createPlannerItem(id = 1L, title = "Assignment", submitted = false).copy(
+ plannerOverride = plannerOverride
+ )
+ val updatedOverride = plannerOverride.copy(markedComplete = false)
+
+ coEvery { repository.getCourses(any()) } returns DataResult.Success(emptyList())
+ coEvery { repository.getPlannerItems(any(), any(), any()) } returns DataResult.Success(listOf(plannerItem))
+ coEvery { repository.updatePlannerOverride(any(), any()) } returns DataResult.Success(updatedOverride)
+ every { networkStateProvider.isOnline() } returns true
+
+ val viewModel = getViewModel()
+
+ assertEquals(0, viewModel.uiState.value.toDoCount)
+
+ val item = viewModel.uiState.value.itemsByDate.values.flatten().first()
+ item.onCheckboxToggle(false)
+
+ assertEquals(1, viewModel.uiState.value.toDoCount)
+ }
+
// Helper functions
private fun getViewModel(): ToDoListViewModel {
- return ToDoListViewModel(context, repository)
+ return ToDoListViewModel(context, repository, networkStateProvider, firebaseCrashlytics)
}
private fun createPlannerItem(