Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions app/src/main/java/com/itsaky/androidide/dnd/DragEventRouter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.itsaky.androidide.dnd

import android.view.DragEvent
import android.view.View

/**
* A [View.OnDragListener] implementation that delegates the complex Android drag state machine
* into a clean [DropTargetCallback].
*/
class DragEventRouter(
private val callback: DropTargetCallback
) : View.OnDragListener {
override fun onDrag(view: View, event: DragEvent): Boolean {
val canHandle = callback.canAcceptDrop(event)

return when (event.action) {
DragEvent.ACTION_DRAG_STARTED -> {
if (canHandle) callback.onDragStarted(view)
canHandle
}

DragEvent.ACTION_DRAG_ENTERED -> {
if (canHandle) callback.onDragEntered(view)
true
}

DragEvent.ACTION_DRAG_LOCATION -> {
canHandle
}

DragEvent.ACTION_DRAG_EXITED -> {
callback.onDragExited(view)
true
}

DragEvent.ACTION_DROP -> {
callback.onDragExited(view)
if (canHandle) {
callback.onDrop(event)
} else {
false
}
}

DragEvent.ACTION_DRAG_ENDED -> {
callback.onDragExited(view)
canHandle
}

else -> false
}
}
}
35 changes: 35 additions & 0 deletions app/src/main/java/com/itsaky/androidide/dnd/DropTargetCallback.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.itsaky.androidide.dnd

import android.view.DragEvent
import android.view.View

/**
* Callback interface for handling routed drag-and-drop events on a specific target view.
*/
interface DropTargetCallback {
/**
* Determines whether the current [event] contains data that this target can handle.
*/
fun canAcceptDrop(event: DragEvent): Boolean

/**
* Called when a drag operation begins. Useful for applying initial visual cues.
*/
fun onDragStarted(view: View) {}

/**
* Called when a valid dragged item enters the bounds of the target [view].
*/
fun onDragEntered(view: View)

/**
* Called when a dragged item exits the target [view], or when the drag operation ends/is canceled.
*/
fun onDragExited(view: View)

/**
* Called when the user successfully drops a valid item on the target.
* * @return True if the drop was successfully consumed and handled.
*/
fun onDrop(event: DragEvent): Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.itsaky.androidide.dnd

import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner


fun Fragment.handleGitUrlDrop(
targetView: View = requireView(),
shouldAcceptDrop: () -> Boolean = { isVisible },
onDropped: (String) -> Unit
) {
val dropTarget = GitUrlDropTarget(
context = requireContext(),
rootView = targetView,
shouldAcceptDrop = shouldAcceptDrop,
onRepositoryDropped = onDropped
)

dropTarget.attach()

viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
dropTarget.detach()
}
})
}
107 changes: 107 additions & 0 deletions app/src/main/java/com/itsaky/androidide/dnd/GitUrlDropTarget.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.itsaky.androidide.dnd

import android.content.ClipDescription
import android.content.Context
import android.view.DragEvent
import android.view.View
import androidx.core.view.ContentInfoCompat
import androidx.core.view.ViewCompat
import com.itsaky.androidide.git.core.parseGitRepositoryUrl
import com.itsaky.androidide.utils.DropHighlighter

internal class GitUrlDropTarget(
private val context: Context,
private val rootView: View,
private val shouldAcceptDrop: () -> Boolean = { true },
private val onRepositoryDropped: (String) -> Unit,
) {

fun attach() {
ViewCompat.setOnReceiveContentListener(
rootView,
supportedDropMimeTypes,
) { _, payload -> tryConsumeRepositoryPayload(payload) }

bindRepositoryDropTarget(rootView)
}

fun detach() {
ViewCompat.setOnReceiveContentListener(rootView, null, null)
rootView.setOnDragListener(null)
}

/**
* Attempts to consume a dropped repository URL from the given [payload].
* Returns `null` on success to indicate consumption, or the original [payload] otherwise.
*/
private fun tryConsumeRepositoryPayload(payload: ContentInfoCompat): ContentInfoCompat? {
if (!shouldAcceptDrop()) {
return payload
}

val repositoryUrl = extractRepositoryUrl(payload) ?: return payload

onRepositoryDropped(repositoryUrl)
return null
}

private fun bindRepositoryDropTarget(view: View) {
val dropCallback = object : DropTargetCallback {
override fun canAcceptDrop(event: DragEvent): Boolean {
return shouldAcceptDrop() && event.clipDescription.isSupportedDropPayload()
}

override fun onDragStarted(view: View) {
DropHighlighter.highlight(view, context)
}

override fun onDragEntered(view: View) {
DropHighlighter.highlight(view, context)
}

override fun onDragExited(view: View) {
DropHighlighter.clear(view)
}

override fun onDrop(event: DragEvent): Boolean {
val contentInfo = ContentInfoCompat.Builder(
event.clipData,
ContentInfoCompat.SOURCE_DRAG_AND_DROP,
).build()

return ViewCompat.performReceiveContent(view, contentInfo) == null
}
}

view.setOnDragListener(DragEventRouter(dropCallback))
}

private fun extractRepositoryUrl(payload: ContentInfoCompat): String? {
val clip = payload.clip
for (index in 0 until clip.itemCount) {
val item = clip.getItemAt(index)
val text = item.uri?.toString() ?: item.coerceToText(context)?.toString()

if (text.isNullOrBlank()) continue

parseGitRepositoryUrl(text)?.let { return it }
}

return null
}

private fun ClipDescription?.isSupportedDropPayload(): Boolean {
if (this == null) {
return false
}

return supportedDropMimeTypes.any(::hasMimeType)
}

private companion object {
val supportedDropMimeTypes = arrayOf(
ClipDescription.MIMETYPE_TEXT_PLAIN,
ClipDescription.MIMETYPE_TEXT_URILIST,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import com.itsaky.androidide.viewmodel.CloneRepositoryViewModel
import com.itsaky.androidide.viewmodel.MainViewModel
import com.itsaky.androidide.git.core.models.CloneRepoUiState
import com.itsaky.androidide.R
import com.itsaky.androidide.dnd.handleGitUrlDrop
import com.itsaky.androidide.idetooltips.TooltipManager
import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.utils.forEachViewRecursively
Expand All @@ -44,7 +45,12 @@ class CloneRepositoryFragment : BaseFragment() {
super.onViewCreated(view, savedInstanceState)

setupUI()
observePendingCloneUrl()
observeViewModel()
handleGitUrlDrop { url ->
binding?.repoUrl?.setText(url)
viewModel.onInputChanged(url, binding?.localPath?.text?.toString().orEmpty())
}
}

private fun setupUI() {
Expand Down Expand Up @@ -201,6 +207,20 @@ class CloneRepositoryFragment : BaseFragment() {
}
}

private fun observePendingCloneUrl() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
mainViewModel.cloneRepositoryEvent.collect { url ->
val trimmedUrl = url.trim()
if (trimmedUrl.isNotBlank()) {
binding?.repoUrl?.setText(trimmedUrl)
viewModel.onInputChanged(trimmedUrl, binding?.localPath?.text?.toString().orEmpty())
}
}
}
}
}

private fun MaterialButton.refreshStatus(isForRetry: Boolean) {
setIconResource(if (isForRetry) R.drawable.ic_refresh else 0)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import org.koin.androidx.viewmodel.ext.android.activityViewModel
import com.itsaky.androidide.R
import com.itsaky.androidide.activities.editor.HelpActivity
import com.itsaky.androidide.adapters.MainActionsListAdapter
Expand All @@ -14,11 +13,14 @@ import com.itsaky.androidide.actions.ActionItem
import com.itsaky.androidide.actions.ActionsRegistry
import com.itsaky.androidide.actions.internal.DefaultActionsRegistry
import com.itsaky.androidide.databinding.FragmentMainBinding
import com.itsaky.androidide.dnd.handleGitUrlDrop
import com.itsaky.androidide.idetooltips.TooltipManager
import com.itsaky.androidide.idetooltips.TooltipTag.MAIN_GET_STARTED
import com.itsaky.androidide.viewmodel.MainViewModel
import org.adfa.constants.CONTENT_KEY
import org.adfa.constants.CONTENT_TITLE_KEY
import org.appdevforall.codeonthego.layouteditor.managers.ProjectManager
import org.koin.androidx.viewmodel.ext.android.activityViewModel

class MainFragment : BaseFragment() {
private val viewModel by activityViewModel<MainViewModel>()
Expand Down Expand Up @@ -81,6 +83,15 @@ class MainFragment : BaseFragment() {
true
}
binding!!.greetingText.setOnClickListener { ifAttached { openQuickstartPageAction() } }

handleGitUrlDrop(
shouldAcceptDrop = {
isVisible &&
viewModel.currentScreen.value == MainViewModel.SCREEN_MAIN &&
ProjectManager.instance.openedProject == null
},
onDropped = viewModel::requestCloneRepository
)
}

private fun openQuickstartPageAction() {
Expand Down
35 changes: 35 additions & 0 deletions app/src/main/java/com/itsaky/androidide/utils/DropHighlighter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.itsaky.androidide.utils

import android.content.Context
import android.graphics.drawable.Drawable
import android.view.View
import androidx.core.content.ContextCompat
import com.itsaky.androidide.R
import androidx.core.graphics.drawable.toDrawable

object DropHighlighter {
/**
* Applies a highlight foreground to the [view] to indicate an active drop target,
* saving its original foreground state safely.
*/
fun highlight(view: View, context: Context) {
if (view.getTag(R.id.filetree_drop_target_tag) == null) {
view.setTag(R.id.filetree_drop_target_tag, view.foreground ?: "NULL_FG")
}

val baseColor = ContextCompat.getColor(context, R.color.teal_200)
val highlightColor = (baseColor and 0x00FFFFFF) or (64 shl 24)

view.foreground = highlightColor.toDrawable()
}

/**
* Restores the original foreground of the [view] and clears the drop target highlight.
*/
fun clear(view: View) {
val savedFg = view.getTag(R.id.filetree_drop_target_tag) ?: return

view.foreground = if (savedFg == "NULL_FG") null else savedFg as? Drawable
view.setTag(R.id.filetree_drop_target_tag, null)
}
}
Loading
Loading