Skip to content

Commit b192b92

Browse files
kristofnemereclaude
andcommitted
[MBL-17287][Teacher] Add dashboard card reordering functionality
Implemented drag-and-drop reordering for dashboard cards in Teacher app, matching the Student app functionality. Cards can be reordered by dragging, and positions persist across app restarts via API persistence and cache invalidation. Changes: - DashboardFragment: Added ItemTouchHelper for drag-and-drop, cancelCardDrag() safety method to handle tab switching during drag - DashboardPresenter: Added moveCourse() for local reordering, saveDashboardPositions() for API persistence with cache invalidation, isOnline() check to disable dragging when offline - DashboardPresenterFactory: Updated to inject UserAPI and NetworkStateProvider - InitActivity: Added call to cancelCardDrag() when switching bottom nav tabs to prevent UI glitches Key features: - Drag-and-drop only enabled when online - Position persistence via API with cache invalidation strategy - Cancel drag gesture when switching tabs for clean UX - Error handling with toast notification on save failure Test plan: - Verify cards can be dragged and reordered in grid/list views - Verify positions persist after app restart - Verify dragging is disabled when offline - Verify switching tabs while dragging cancels the gesture cleanly - Verify error toast appears if position save fails 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 74f351c commit b192b92

File tree

4 files changed

+157
-5
lines changed

4 files changed

+157
-5
lines changed

apps/teacher/src/main/java/com/instructure/teacher/activities/InitActivity.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,9 @@ class InitActivity : BasePresenterActivity<InitActivityPresenter, InitActivityVi
158158
private var drawerItemSelectedJob: Job? = null
159159

160160
private val mTabSelectedListener = BottomNavigationView.OnNavigationItemSelectedListener { item ->
161+
// Cancel any active drag on dashboard before switching tabs
162+
(supportFragmentManager.findFragmentByTag(DashboardFragment::class.java.simpleName) as? DashboardFragment)?.cancelCardDrag()
163+
161164
selectedTab = when (item.itemId) {
162165
R.id.tab_courses -> {
163166
addCoursesFragment()

apps/teacher/src/main/java/com/instructure/teacher/factory/DashboardPresenterFactory.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,15 @@
1717
package com.instructure.teacher.factory
1818

1919

20+
import com.instructure.canvasapi2.apis.UserAPI
21+
import com.instructure.pandautils.utils.NetworkStateProvider
2022
import com.instructure.teacher.presenters.DashboardPresenter
2123
import com.instructure.teacher.viewinterface.CoursesView
2224
import com.instructure.pandautils.blueprint.PresenterFactory
2325

24-
class DashboardPresenterFactory : PresenterFactory<CoursesView, DashboardPresenter> {
25-
override fun create() = DashboardPresenter()
26+
class DashboardPresenterFactory(
27+
private val userApi: UserAPI.UsersInterface,
28+
private val networkStateProvider: NetworkStateProvider
29+
) : PresenterFactory<CoursesView, DashboardPresenter> {
30+
override fun create() = DashboardPresenter(userApi, networkStateProvider)
2631
}

apps/teacher/src/main/java/com/instructure/teacher/fragments/DashboardFragment.kt

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,15 @@ import android.content.Context
2020
import android.content.Intent
2121
import android.content.IntentFilter
2222
import android.view.MenuItem
23+
import android.view.MotionEvent
24+
import android.view.MotionEvent.ACTION_CANCEL
2325
import android.view.View
26+
import androidx.lifecycle.lifecycleScope
2427
import androidx.localbroadcastmanager.content.LocalBroadcastManager
2528
import androidx.recyclerview.widget.GridLayoutManager
29+
import androidx.recyclerview.widget.ItemTouchHelper
2630
import androidx.recyclerview.widget.RecyclerView
31+
import com.instructure.canvasapi2.apis.UserAPI
2732
import com.instructure.canvasapi2.models.Course
2833
import com.instructure.canvasapi2.utils.ApiPrefs
2934
import com.instructure.canvasapi2.utils.pageview.PageView
@@ -33,7 +38,18 @@ import com.instructure.pandautils.binding.viewBinding
3338
import com.instructure.pandautils.features.dashboard.edit.EditDashboardFragment
3439
import com.instructure.pandautils.features.dashboard.notifications.DashboardNotificationsFragment
3540
import com.instructure.pandautils.fragments.BaseSyncFragment
36-
import com.instructure.pandautils.utils.*
41+
import com.instructure.pandautils.utils.Const
42+
import com.instructure.pandautils.utils.NetworkStateProvider
43+
import com.instructure.pandautils.utils.ThemePrefs
44+
import com.instructure.pandautils.utils.Utils
45+
import com.instructure.pandautils.utils.ViewStyler
46+
import com.instructure.pandautils.utils.fadeAnimationWithAction
47+
import com.instructure.pandautils.utils.getDrawableCompat
48+
import com.instructure.pandautils.utils.requestAccessibilityFocus
49+
import com.instructure.pandautils.utils.setGone
50+
import com.instructure.pandautils.utils.setVisible
51+
import com.instructure.pandautils.utils.setupAsBackButton
52+
import com.instructure.pandautils.utils.toast
3753
import com.instructure.teacher.R
3854
import com.instructure.teacher.activities.InitActivity
3955
import com.instructure.teacher.adapters.CoursesAdapter
@@ -49,16 +65,26 @@ import com.instructure.teacher.utils.RecyclerViewUtils
4965
import com.instructure.teacher.utils.TeacherPrefs
5066
import com.instructure.teacher.utils.setupMenu
5167
import com.instructure.teacher.viewinterface.CoursesView
68+
import dagger.hilt.android.AndroidEntryPoint
69+
import kotlinx.coroutines.launch
5270
import org.greenrobot.eventbus.EventBus
5371
import org.greenrobot.eventbus.Subscribe
5472
import org.greenrobot.eventbus.ThreadMode
73+
import javax.inject.Inject
5574

5675
private const val LIST_SPAN_COUNT = 1
5776

5877
@PageView
5978
@ScreenView(SCREEN_VIEW_DASHBOARD)
79+
@AndroidEntryPoint
6080
class DashboardFragment : BaseSyncFragment<Course, DashboardPresenter, CoursesView, CoursesViewHolder, CoursesAdapter>(), CoursesView {
6181

82+
@Inject
83+
lateinit var userApi: UserAPI.UsersInterface
84+
85+
@Inject
86+
lateinit var networkStateProvider: NetworkStateProvider
87+
6288
private val binding by viewBinding(FragmentDashboardBinding::bind)
6389

6490
private lateinit var mGridLayoutManager: GridLayoutManager
@@ -82,7 +108,7 @@ class DashboardFragment : BaseSyncFragment<Course, DashboardPresenter, CoursesVi
82108
override fun perPageCount() = ApiPrefs.perPageCount
83109
override fun withPagination() = false
84110

85-
override fun getPresenterFactory() = DashboardPresenterFactory()
111+
override fun getPresenterFactory() = DashboardPresenterFactory(userApi, networkStateProvider)
86112

87113
override fun onAttach(context: Context) {
88114
super.onAttach(context)
@@ -141,6 +167,7 @@ class DashboardFragment : BaseSyncFragment<Course, DashboardPresenter, CoursesVi
141167
if(courseRecyclerView.adapter == null) {
142168
courseRecyclerView.adapter = adapter
143169
}
170+
addItemTouchHelperForCardReorder()
144171
presenter.loadData(mNeedToForceNetwork)
145172
mNeedToForceNetwork = false
146173
}
@@ -234,6 +261,84 @@ class DashboardFragment : BaseSyncFragment<Course, DashboardPresenter, CoursesVi
234261
RecyclerViewUtils.checkIfEmpty(emptyCoursesView, courseRecyclerView, swipeRefreshLayout, adapter, presenter.isEmpty)
235262
}
236263

264+
private fun addItemTouchHelperForCardReorder() {
265+
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
266+
ItemTouchHelper.START or ItemTouchHelper.END or ItemTouchHelper.DOWN or ItemTouchHelper.UP,
267+
0
268+
) {
269+
private var draggedFromPosition: Int? = null
270+
271+
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
272+
super.onSelectedChanged(viewHolder, actionState)
273+
274+
if (viewHolder != null && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
275+
draggedFromPosition = viewHolder.bindingAdapterPosition
276+
}
277+
}
278+
279+
override fun onMove(
280+
recyclerView: RecyclerView,
281+
viewHolder: RecyclerView.ViewHolder,
282+
target: RecyclerView.ViewHolder
283+
): Boolean {
284+
val fromPosition = viewHolder.bindingAdapterPosition
285+
val toPosition = target.bindingAdapterPosition
286+
287+
if (fromPosition in 0 until adapter.size() && toPosition in 0 until adapter.size()) {
288+
adapter.notifyItemMoved(fromPosition, toPosition)
289+
}
290+
291+
return true
292+
}
293+
294+
override fun getDragDirs(
295+
recyclerView: RecyclerView,
296+
viewHolder: RecyclerView.ViewHolder
297+
): Int {
298+
return if (viewHolder is CoursesViewHolder && presenter.isOnline()) {
299+
ItemTouchHelper.START or ItemTouchHelper.END or ItemTouchHelper.DOWN or ItemTouchHelper.UP
300+
} else 0
301+
}
302+
303+
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
304+
305+
override fun clearView(
306+
recyclerView: RecyclerView,
307+
viewHolder: RecyclerView.ViewHolder
308+
) {
309+
super.clearView(recyclerView, viewHolder)
310+
311+
val finishingPosition = viewHolder.bindingAdapterPosition
312+
val startPosition = draggedFromPosition
313+
314+
if (finishingPosition == RecyclerView.NO_POSITION || startPosition == null) {
315+
draggedFromPosition = null
316+
return
317+
}
318+
319+
if (startPosition != finishingPosition) {
320+
presenter.moveCourse(startPosition, finishingPosition)
321+
adapter.notifyDataSetChanged()
322+
323+
lifecycleScope.launch {
324+
val result = presenter.saveDashboardPositions()
325+
if (result.isFail) {
326+
toast(R.string.failedToUpdateDashboardOrder)
327+
}
328+
}
329+
}
330+
331+
draggedFromPosition = null
332+
}
333+
})
334+
335+
itemTouchHelper.attachToRecyclerView(binding.courseRecyclerView)
336+
}
337+
338+
fun cancelCardDrag() {
339+
binding.courseRecyclerView.onTouchEvent(MotionEvent.obtain(0L, 0L, ACTION_CANCEL, 0f, 0f, 0))
340+
}
341+
237342
companion object {
238343
fun getInstance() = DashboardFragment()
239344
}

apps/teacher/src/main/java/com/instructure/teacher/presenters/DashboardPresenter.kt

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,31 @@
1515
*/
1616
package com.instructure.teacher.presenters
1717

18+
import com.instructure.canvasapi2.CanvasRestAdapter
19+
import com.instructure.canvasapi2.apis.UserAPI
20+
import com.instructure.canvasapi2.builders.RestParams
1821
import com.instructure.canvasapi2.managers.CourseManager
1922
import com.instructure.canvasapi2.models.Course
2023
import com.instructure.canvasapi2.models.DashboardCard
24+
import com.instructure.canvasapi2.models.DashboardPositions
2125
import com.instructure.canvasapi2.models.Tab
2226
import com.instructure.canvasapi2.utils.ApiPrefs
27+
import com.instructure.canvasapi2.utils.DataResult
2328
import com.instructure.canvasapi2.utils.weave.apiAsync
29+
import com.instructure.pandarecycler.util.toList
2430
import com.instructure.pandautils.blueprint.SyncPresenter
2531
import com.instructure.pandautils.utils.ColorApiHelper
32+
import com.instructure.pandautils.utils.NetworkStateProvider
2633
import com.instructure.teacher.viewinterface.CoursesView
2734
import kotlinx.coroutines.Dispatchers
2835
import kotlinx.coroutines.GlobalScope
2936
import kotlinx.coroutines.Job
3037
import kotlinx.coroutines.launch
3138

32-
class DashboardPresenter : SyncPresenter<Course, CoursesView>(Course::class.java) {
39+
class DashboardPresenter(
40+
private val userApi: UserAPI.UsersInterface,
41+
private val networkStateProvider: NetworkStateProvider
42+
) : SyncPresenter<Course, CoursesView>(Course::class.java) {
3343

3444
private var dashboardJob: Job? = null
3545

@@ -113,4 +123,33 @@ class DashboardPresenter : SyncPresenter<Course, CoursesView>(Course::class.java
113123
override fun areContentsTheSame(item1: Course, item2: Course): Boolean {
114124
return item1.contextId.hashCode() == item2.contextId.hashCode()
115125
}
126+
127+
fun moveCourse(fromPosition: Int, toPosition: Int) {
128+
if (fromPosition < 0 || toPosition < 0 || fromPosition >= data.size() || toPosition >= data.size()) {
129+
return
130+
}
131+
val courses = data.toList().toMutableList()
132+
val movedCourse = courses.removeAt(fromPosition)
133+
courses.add(toPosition, movedCourse)
134+
data.clear()
135+
data.addOrUpdate(courses)
136+
}
137+
138+
suspend fun saveDashboardPositions(): DataResult<DashboardPositions> {
139+
val courses = data.toList()
140+
val positions = courses
141+
.mapIndexed { index, course -> Pair(course.contextId, index) }
142+
.toMap()
143+
val dashboardPositions = DashboardPositions(positions)
144+
145+
val result = userApi.updateDashboardPositions(dashboardPositions, RestParams(isForceReadFromNetwork = true))
146+
if (result is DataResult.Success) {
147+
CanvasRestAdapter.clearCacheUrls("dashboard/dashboard_cards")
148+
}
149+
return result
150+
}
151+
152+
fun isOnline(): Boolean {
153+
return networkStateProvider.isOnline()
154+
}
116155
}

0 commit comments

Comments
 (0)