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(