diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..ec00831
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/github/adriankuta/unbounddragdrop/MainActivity.kt b/app/src/main/java/com/github/adriankuta/unbounddragdrop/MainActivity.kt
new file mode 100644
index 0000000..62d1fc8
--- /dev/null
+++ b/app/src/main/java/com/github/adriankuta/unbounddragdrop/MainActivity.kt
@@ -0,0 +1,147 @@
+package com.github.adriankuta.unbounddragdrop
+
+import android.os.Bundle
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.github.adriankuta.unbounddragdrop.databinding.ActivityMainBinding
+import com.github.adriankuta.unbounddragdrop.model.TaskStatus
+import dev.adriankuta.unbounddragdrop.DragDropHelper
+import kotlinx.coroutines.launch
+
+class MainActivity : AppCompatActivity() {
+
+ private val viewModel: MainViewModel by viewModels()
+ private lateinit var binding: ActivityMainBinding
+ private lateinit var todoAdapter: SimpleGridPageAdapter
+ private lateinit var inProgressAdapter: SimpleGridPageAdapter
+ private val callback = object : DragDropHelper.Callback() {
+ override fun onMove(
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ targetRecyclerView: RecyclerView,
+ targetViewHolder: RecyclerView.ViewHolder?
+ ): Boolean {
+ return onMove(
+ recyclerView,
+ viewHolder.adapterPosition,
+ targetRecyclerView,
+ targetViewHolder?.adapterPosition
+ )
+ }
+
+ override fun onMoved(
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ targetRecyclerView: RecyclerView,
+ targetViewHolder: RecyclerView.ViewHolder?
+ ) = Unit
+
+ }
+
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ enableEdgeToEdge()
+ setContentView(binding.root)
+ ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets ->
+ val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
+ insets
+ }
+
+ setupRecyclerViews()
+ setObservers()
+ }
+
+ private fun setupRecyclerViews() {
+ todoAdapter = SimpleGridPageAdapter()
+ inProgressAdapter = SimpleGridPageAdapter()
+
+ with(binding) {
+ setupRecyclerView(toDoRecyclerView, todoAdapter)
+ setupRecyclerView(inProgressRecyclerView, inProgressAdapter)
+ }
+ }
+
+ private fun setObservers() {
+ lifecycleScope.launch {
+ viewModel.uiState.collect(::render)
+ }
+ }
+
+ private fun render(uiState: MainUiState) {
+ todoAdapter.submitList(uiState.todo)
+ inProgressAdapter.submitList(uiState.inProgress)
+ }
+
+ private fun setupRecyclerView(
+ recyclerView: RecyclerView,
+ adapter: RecyclerView.Adapter<*>
+ ) {
+ recyclerView.adapter = adapter
+ recyclerView.layoutManager =
+ GridLayoutManager(this, GRID_ROWS, GridLayoutManager.HORIZONTAL, false)
+ DragDropHelper(callback).attachToRecyclerView(recyclerView)
+ }
+
+ private fun onMove(
+ recyclerView: RecyclerView,
+ sourcePosition: Int,
+ targetRecyclerView: RecyclerView,
+ targetPosition: Int?
+ ): Boolean {
+ if (recyclerView == targetRecyclerView) {
+ onChangeOrder(recyclerView, sourcePosition, targetPosition)
+ } else {
+ onChangeStatus(recyclerView, sourcePosition, targetRecyclerView, targetPosition)
+ }
+ return true
+ }
+
+ private fun onChangeOrder(
+ recyclerView: RecyclerView,
+ sourcePosition: Int,
+ targetPosition: Int?
+ ) {
+ targetPosition ?: return
+ val taskStatus = when (recyclerView.id) {
+ R.id.to_do_recycler_view -> TaskStatus.TODO
+ R.id.in_progress_recycler_view -> TaskStatus.IN_PROGRESS
+ else -> null
+ } ?: return
+
+ viewModel.onChangeOrder(taskStatus, sourcePosition, targetPosition)
+ }
+
+ private fun onChangeStatus(
+ recyclerView: RecyclerView,
+ sourcePosition: Int,
+ targetRecyclerView: RecyclerView,
+ targetPosition: Int?
+ ) {
+ val fromStatus = when (recyclerView.id) {
+ R.id.to_do_recycler_view -> TaskStatus.TODO
+ R.id.in_progress_recycler_view -> TaskStatus.IN_PROGRESS
+ else -> null
+ }
+ val toStatus = when (targetRecyclerView.id) {
+ R.id.to_do_recycler_view -> TaskStatus.TODO
+ R.id.in_progress_recycler_view -> TaskStatus.IN_PROGRESS
+ else -> null
+ }
+ if (fromStatus != null && toStatus != null) {
+ viewModel.onChangeStatus(fromStatus, toStatus, sourcePosition, targetPosition)
+ }
+ }
+
+ companion object {
+ const val GRID_ROWS = 2
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/adriankuta/unbounddragdrop/MainViewModel.kt b/app/src/main/java/com/github/adriankuta/unbounddragdrop/MainViewModel.kt
new file mode 100644
index 0000000..6f93722
--- /dev/null
+++ b/app/src/main/java/com/github/adriankuta/unbounddragdrop/MainViewModel.kt
@@ -0,0 +1,94 @@
+package com.github.adriankuta.unbounddragdrop
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.github.adriankuta.unbounddragdrop.model.Task
+import com.github.adriankuta.unbounddragdrop.model.TaskStatus
+import com.github.adriankuta.unbounddragdrop.model.TaskStatus.IN_PROGRESS
+import com.github.adriankuta.unbounddragdrop.model.TaskStatus.TODO
+import com.github.adriankuta.unbounddragdrop.util.WhileUiSubscribed
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import java.util.Collections
+import java.util.UUID
+
+data class MainUiState(
+ val todo: List = emptyList(),
+ val inProgress: List = emptyList(),
+)
+
+class MainViewModel : ViewModel() {
+
+
+ private val _todoTasks = MutableStateFlow>(emptyList())
+ private val _inProgressTasks = MutableStateFlow>(emptyList())
+
+ init {
+ _todoTasks.value =
+ List(30) { Task(UUID.randomUUID().toString(), "Task $it", TODO) }
+ _inProgressTasks.value =
+ List(4) { Task(UUID.randomUUID().toString(), "Task $it", IN_PROGRESS) }
+ }
+
+
+ val uiState: StateFlow = combine(_todoTasks, _inProgressTasks) { listA, listB ->
+ MainUiState(listA, listB)
+ }.stateIn(
+ scope = viewModelScope,
+ started = WhileUiSubscribed,
+ initialValue = MainUiState()
+ )
+
+ fun onChangeOrder(status: TaskStatus, sourcePosition: Int, targetPosition: Int) {
+ when (status) {
+ TODO -> _todoTasks.swapElements(sourcePosition, targetPosition)
+ IN_PROGRESS -> _inProgressTasks.swapElements(sourcePosition, targetPosition)
+ }
+ }
+
+ fun onChangeStatus(
+ fromStatus: TaskStatus,
+ toStatus: TaskStatus,
+ fromPosition: Int,
+ toPosition: Int?
+ ) {
+ val itemToMove = when (fromStatus) {
+ TODO -> _todoTasks.removeAt(fromPosition)
+ IN_PROGRESS -> _inProgressTasks.removeAt(fromPosition)
+ }
+
+ when (toStatus) {
+ TODO -> _todoTasks.add(toPosition ?: _todoTasks.size(), itemToMove)
+ IN_PROGRESS -> _inProgressTasks.add(toPosition ?: _inProgressTasks.size(), itemToMove)
+ }
+ }
+
+ private fun MutableStateFlow>.swapElements(
+ sourcePosition: Int,
+ targetPosition: Int
+ ) {
+ val newList = value.toMutableList()
+ Collections.swap(newList, sourcePosition, targetPosition)
+ value = newList
+ }
+
+ private fun MutableStateFlow>.removeAt(index: Int): T {
+ val newList = value.toMutableList()
+ val removedItem = newList.removeAt(index)
+ value = newList
+ return removedItem
+ }
+
+ private fun MutableStateFlow>.add(index: Int, item: T) {
+ val newList = value.toMutableList()
+ newList.add(index, item)
+ value = newList
+ }
+
+ private fun MutableStateFlow>.size(): Int {
+ return value.size
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/adriankuta/unbounddragdrop/MyApplication.kt b/app/src/main/java/com/github/adriankuta/unbounddragdrop/MyApplication.kt
new file mode 100644
index 0000000..0e34d38
--- /dev/null
+++ b/app/src/main/java/com/github/adriankuta/unbounddragdrop/MyApplication.kt
@@ -0,0 +1,13 @@
+package com.github.adriankuta.unbounddragdrop
+
+import android.app.Application
+import timber.log.Timber
+import timber.log.Timber.DebugTree
+
+class MyApplication : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ Timber.plant(DebugTree())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/adriankuta/unbounddragdrop/SimpleGridPageAdapter.kt b/app/src/main/java/com/github/adriankuta/unbounddragdrop/SimpleGridPageAdapter.kt
new file mode 100644
index 0000000..b5ef6fd
--- /dev/null
+++ b/app/src/main/java/com/github/adriankuta/unbounddragdrop/SimpleGridPageAdapter.kt
@@ -0,0 +1,56 @@
+package com.github.adriankuta.unbounddragdrop
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.AsyncListDiffer
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import com.github.adriankuta.unbounddragdrop.databinding.TextItemBinding
+import com.github.adriankuta.unbounddragdrop.model.Task
+
+
+class SimpleGridPageAdapter :
+ RecyclerView.Adapter() {
+
+ private val diffUtil = object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: Task, newItem: Task): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(
+ oldItem: Task,
+ newItem: Task
+ ): Boolean {
+ return oldItem == newItem
+ }
+ }
+
+ private val asyncListDiffer = AsyncListDiffer(this, diffUtil)
+
+ fun submitList(list: List) {
+ asyncListDiffer.submitList(list)
+ }
+
+ override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {
+ return ViewHolder(viewGroup.inflate())
+ }
+
+ override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
+ val data = asyncListDiffer.currentList[position]
+ viewHolder.bind(data)
+ }
+
+ override fun getItemCount() = asyncListDiffer.currentList.size
+
+ class ViewHolder(private val binding: TextItemBinding) : RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(task: Task) {
+ binding.textView.text = task.title
+ binding.root.isLongClickable = true
+ }
+ }
+
+ private fun ViewGroup.inflate() =
+ TextItemBinding.inflate(LayoutInflater.from(context), this, false)
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/adriankuta/unbounddragdrop/model/Task.kt b/app/src/main/java/com/github/adriankuta/unbounddragdrop/model/Task.kt
new file mode 100644
index 0000000..0e5b92c
--- /dev/null
+++ b/app/src/main/java/com/github/adriankuta/unbounddragdrop/model/Task.kt
@@ -0,0 +1,7 @@
+package com.github.adriankuta.unbounddragdrop.model
+
+data class Task(
+ val id: String,
+ val title: String,
+ val taskStatus: TaskStatus
+)
diff --git a/app/src/main/java/com/github/adriankuta/unbounddragdrop/model/TaskStatus.kt b/app/src/main/java/com/github/adriankuta/unbounddragdrop/model/TaskStatus.kt
new file mode 100644
index 0000000..bc8bbca
--- /dev/null
+++ b/app/src/main/java/com/github/adriankuta/unbounddragdrop/model/TaskStatus.kt
@@ -0,0 +1,6 @@
+package com.github.adriankuta.unbounddragdrop.model
+
+enum class TaskStatus {
+ TODO,
+ IN_PROGRESS
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/github/adriankuta/unbounddragdrop/util/CoroutinesUtils.kt b/app/src/main/java/com/github/adriankuta/unbounddragdrop/util/CoroutinesUtils.kt
new file mode 100644
index 0000000..a611346
--- /dev/null
+++ b/app/src/main/java/com/github/adriankuta/unbounddragdrop/util/CoroutinesUtils.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * 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
+ *
+ * https://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.github.adriankuta.unbounddragdrop.util
+
+import kotlinx.coroutines.flow.SharingStarted
+
+private const val StopTimeoutMillis: Long = 5000
+
+/**
+ * A [SharingStarted] meant to be used with a [StateFlow] to expose data to the UI.
+ *
+ * When the UI stops observing, upstream flows stay active for some time to allow the system to
+ * come back from a short-lived configuration change (such as rotations). If the UI stops
+ * observing for longer, the cache is kept but the upstream flows are stopped. When the UI comes
+ * back, the latest value is replayed and the upstream flows are executed again. This is done to
+ * save resources when the app is in the background but let users switch between apps quickly.
+ */
+val WhileUiSubscribed: SharingStarted = SharingStarted.WhileSubscribed(StopTimeoutMillis)
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..179b504
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/text_item.xml b/app/src/main/res/layout/text_item.xml
new file mode 100644
index 0000000..2761188
--- /dev/null
+++ b/app/src/main/res/layout/text_item.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryPublishConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryPublishConventionPlugin.kt
new file mode 100644
index 0000000..f23b4d1
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/AndroidLibraryPublishConventionPlugin.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * 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
+ *
+ * https://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.
+ */
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+
+class AndroidLibraryPublishConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ with(pluginManager) {
+ apply("com.vanniktech.maven.publish")
+ apply("com.gradleup.nmcp")
+ }
+ }
+ }
+}