From 62d911b305aceff91538d1dd1cf8a884a18d628f Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Tue, 25 Aug 2026 14:06:10 +0100 Subject: [PATCH 1/7] feat(ADFA-5125): Create new dialog extension function --- .../idetooltips}/DialogExtensions.kt | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) rename {app/src/main/java/com/itsaky/androidide/utils => idetooltips/src/main/java/com/itsaky/androidide/idetooltips}/DialogExtensions.kt (54%) diff --git a/app/src/main/java/com/itsaky/androidide/utils/DialogExtensions.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/DialogExtensions.kt similarity index 54% rename from app/src/main/java/com/itsaky/androidide/utils/DialogExtensions.kt rename to idetooltips/src/main/java/com/itsaky/androidide/idetooltips/DialogExtensions.kt index ede0a296bb..eced00f4e8 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/DialogExtensions.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/DialogExtensions.kt @@ -1,4 +1,4 @@ -package com.itsaky.androidide.utils +package com.itsaky.androidide.idetooltips import android.annotation.SuppressLint import android.app.Activity @@ -10,18 +10,27 @@ import android.view.inputmethod.InputMethodManager import android.widget.AdapterView import android.widget.EditText import androidx.appcompat.app.AlertDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.itsaky.androidide.idetooltips.TooltipManager +import com.itsaky.androidide.utils.forEachViewRecursively +import com.itsaky.androidide.utils.onLongPress +import androidx.appcompat.R as AndroidR +/** + * Attaches an IDE category tooltip listener to an [AlertDialog] when any part of the + * dialog surface is long-pressed. + * + * Also configures soft keyboard auto-show/hide if an [EditText] is present in the + * dialog's custom panel. + * + * @param tooltipTag The tag for the tooltip to display. + * @param context Optional context override (defaults to dialog's context). + * @return The [AlertDialog] instance for chaining. + */ @SuppressLint("ClickableViewAccessibility") -fun MaterialAlertDialogBuilder.showWithLongPressTooltip( - context: Context, +fun AlertDialog.attachTooltip( tooltipTag: String, + context: Context = this.context, ): AlertDialog { - val dialog = this.create() - dialog.show() - - fun longPressAction() { + fun showTooltip() { val anchor = (context as? Activity)?.window?.decorView ?: return TooltipManager.showIdeCategoryTooltip( context = context, @@ -30,37 +39,36 @@ fun MaterialAlertDialogBuilder.showWithLongPressTooltip( ) } - dialog.onLongPress { - longPressAction() + this.onLongPress { + showTooltip() true } - dialog.listView?.onItemLongClickListener = + this.listView?.onItemLongClickListener = AdapterView.OnItemLongClickListener { _, _, _, _ -> - longPressAction() + showTooltip() true } - val customPanel: ViewGroup? = dialog.findViewById(androidx.appcompat.R.id.customPanel) - + val customPanel: ViewGroup? = this.findViewById(AndroidR.id.customPanel) customPanel?.forEachViewRecursively { view -> if (view is EditText) { - dialog.setOnShowListener { + this.setOnShowListener { view.requestFocus() val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) } - dialog.window?.decorView?.setOnTouchListener { v, event -> + this.window?.decorView?.setOnTouchListener { _, event -> if (event.action == MotionEvent.ACTION_DOWN) { val outRect = Rect() view.getGlobalVisibleRect(outRect) if (!outRect.contains(event.rawX.toInt(), event.rawY.toInt())) { view.clearFocus() - val imm = + val inputMethodManager = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) + inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) } } false @@ -68,5 +76,5 @@ fun MaterialAlertDialogBuilder.showWithLongPressTooltip( } } - return dialog + return this } From 18dfa1ba259489983f462721b95532b472e59319 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Tue, 25 Aug 2026 14:08:53 +0100 Subject: [PATCH 2/7] refactor(ADFA-5125): Refactor file tree dialog actions --- .../actions/filetree/DeleteAction.kt | 9 +- .../actions/filetree/NewFileAction.kt | 23 ++-- .../actions/filetree/NewFolderAction.kt | 108 +++++++++--------- .../actions/filetree/RenameAction.kt | 10 +- 4 files changed, 75 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/actions/filetree/DeleteAction.kt b/app/src/main/java/com/itsaky/androidide/actions/filetree/DeleteAction.kt index 144f1de025..5d40f72972 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/filetree/DeleteAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/filetree/DeleteAction.kt @@ -23,6 +23,7 @@ import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.requireFile import com.itsaky.androidide.eventbus.events.file.FileDeletionEvent import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.idetooltips.attachTooltip import com.itsaky.androidide.projects.FileManager import com.itsaky.androidide.resources.R import com.itsaky.androidide.tasks.executeAsync @@ -30,7 +31,6 @@ import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.FileUtils import com.itsaky.androidide.utils.FlashType import com.itsaky.androidide.utils.flashMessage -import com.itsaky.androidide.utils.showWithLongPressTooltip import org.greenrobot.eventbus.EventBus import java.io.File @@ -95,10 +95,9 @@ class DeleteAction( String.format("%s [%s]", file.name, file.absolutePath), ), ).setCancelable(false) - .showWithLongPressTooltip( - context = context, - tooltipTag = TooltipTag.PROJECT_CONFIRM_DELETE, - ) + .create() + .attachTooltip(TooltipTag.PROJECT_CONFIRM_DELETE) + .show() } private fun notifyFileDeleted( diff --git a/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt b/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt index bd5c19e234..2b0a8398b6 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFileAction.kt @@ -27,6 +27,7 @@ import com.itsaky.androidide.actions.requireFile import com.itsaky.androidide.databinding.LayoutCreateFileJavaBinding import com.itsaky.androidide.eventbus.events.file.FileCreationEvent import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.idetooltips.attachTooltip import com.itsaky.androidide.preferences.databinding.LayoutDialogTextInputBinding import com.itsaky.androidide.projects.IProjectManager import com.itsaky.androidide.projects.ProjectManagerImpl @@ -40,7 +41,6 @@ import com.itsaky.androidide.utils.ProjectWriter import com.itsaky.androidide.utils.SingleTextWatcher import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess -import com.itsaky.androidide.utils.showWithLongPressTooltip import com.unnamed.b.atv.model.TreeNode import jdkx.lang.model.SourceVersion import kotlinx.coroutines.CancellationException @@ -204,10 +204,9 @@ class NewFileAction( builder.setNegativeButton(android.R.string.cancel, null) builder .setCancelable(false) - .showWithLongPressTooltip( - context = context, - tooltipTag = TooltipTag.PROJECT_FOLDER_NEWTYPE, - ) + .create() + .attachTooltip(TooltipTag.PROJECT_FOLDER_NEWTYPE) + .show() } private fun doCreateSourceFile( @@ -401,10 +400,9 @@ class NewFileAction( 2 -> createMenuRes(context, node, File(file, "menu")) 3 -> createNewFile(context, node, file, true) } - }.showWithLongPressTooltip( - context = context, - tooltipTag = TooltipTag.PROJECT_FOLDER_NEWXML, - ) + }.create() + .attachTooltip(TooltipTag.PROJECT_FOLDER_NEWXML) + .show() } private fun createNewEmptyFile( @@ -469,10 +467,9 @@ class NewFileAction( } builder .setNegativeButton(android.R.string.cancel, null) - .showWithLongPressTooltip( - context = context, - tooltipTag = TooltipTag.PROJECT_NEWFILE_DIALOG, - ) + .create() + .attachTooltip(TooltipTag.PROJECT_NEWFILE_DIALOG) + .show() } private fun createFile( diff --git a/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFolderAction.kt b/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFolderAction.kt index 2b4db645df..06c13abf82 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFolderAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/filetree/NewFolderAction.kt @@ -23,12 +23,12 @@ import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.requireFile import com.itsaky.androidide.adapters.viewholders.FileTreeViewHolder import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.idetooltips.attachTooltip import com.itsaky.androidide.preferences.databinding.LayoutDialogTextInputBinding import com.itsaky.androidide.resources.R import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess -import com.itsaky.androidide.utils.showWithLongPressTooltip import com.unnamed.b.atv.model.TreeNode import java.io.File @@ -37,60 +37,64 @@ import java.io.File * * @author Akash Yadav */ -class NewFolderAction(context: Context, override val order: Int) : - BaseDirNodeAction( - context = context, - labelRes = R.string.new_folder, - iconRes = R.drawable.ic_new_folder - ) { +class NewFolderAction( + context: Context, + override val order: Int, +) : BaseDirNodeAction( + context = context, + labelRes = R.string.new_folder, + iconRes = R.drawable.ic_new_folder, + ) { + override val id: String = "ide.editor.fileTree.newFolder" - override val id: String = "ide.editor.fileTree.newFolder" + override fun retrieveTooltipTag(isAlternateContext: Boolean): String = TooltipTag.PROJECT_FOLDER_NEW_FOLDER - override fun retrieveTooltipTag(isAlternateContext: Boolean): String = - TooltipTag.PROJECT_FOLDER_NEW_FOLDER + override suspend fun execAction(data: ActionData) { + val context = data.requireActivity() + val currentDir = data.requireFile() + val lastHeld = data.getTreeNode() + val binding = LayoutDialogTextInputBinding.inflate(LayoutInflater.from(context)) + val builder = DialogUtils.newMaterialDialogBuilder(context) + binding.name.editText!!.setHint(R.string.folder_name) + builder.setTitle(R.string.new_folder) + builder.setMessage(R.string.msg_can_contain_slashes) + builder.setView(binding.root) + builder.setCancelable(false) + builder.setPositiveButton(R.string.text_create) { dialogInterface, _ -> + dialogInterface.dismiss() + val name: String = + binding.name.editText!! + .text + .toString() + .trim() + if (name.length !in 1..40 || name.startsWith("/")) { + flashError(R.string.msg_invalid_name) + return@setPositiveButton + } - override suspend fun execAction(data: ActionData) { - val context = data.requireActivity() - val currentDir = data.requireFile() - val lastHeld = data.getTreeNode() - val binding = LayoutDialogTextInputBinding.inflate(LayoutInflater.from(context)) - val builder = DialogUtils.newMaterialDialogBuilder(context) - binding.name.editText!!.setHint(R.string.folder_name) - builder.setTitle(R.string.new_folder) - builder.setMessage(R.string.msg_can_contain_slashes) - builder.setView(binding.root) - builder.setCancelable(false) - builder.setPositiveButton(R.string.text_create) { dialogInterface, _ -> - dialogInterface.dismiss() - val name: String = binding.name.editText!!.text.toString().trim() - if (name.length !in 1..40 || name.startsWith("/")) { - flashError(R.string.msg_invalid_name) - return@setPositiveButton - } + val newDir = File(currentDir, name) + if (newDir.exists()) { + flashError(R.string.msg_folder_exists) + return@setPositiveButton + } - val newDir = File(currentDir, name) - if (newDir.exists()) { - flashError(R.string.msg_folder_exists) - return@setPositiveButton - } + if (!newDir.mkdirs()) { + flashError(R.string.msg_folder_creation_failed) + return@setPositiveButton + } - if (!newDir.mkdirs()) { - flashError(R.string.msg_folder_creation_failed) - return@setPositiveButton - } - - flashSuccess(R.string.msg_folder_created) - if (lastHeld != null) { - requestCollapseNode(lastHeld, false) - requestExpandNode(lastHeld) - } else { - requestFileListing() - } - } - builder.setNegativeButton(android.R.string.cancel, null) - builder.showWithLongPressTooltip( - context = context, - tooltipTag = TooltipTag.PROJECT_NEW_FOLDER_DIALOG - ) - } + flashSuccess(R.string.msg_folder_created) + if (lastHeld != null) { + requestCollapseNode(lastHeld, false) + requestExpandNode(lastHeld) + } else { + requestFileListing() + } + } + builder.setNegativeButton(android.R.string.cancel, null) + builder + .create() + .attachTooltip(TooltipTag.PROJECT_NEW_FOLDER_DIALOG) + .show() + } } diff --git a/app/src/main/java/com/itsaky/androidide/actions/filetree/RenameAction.kt b/app/src/main/java/com/itsaky/androidide/actions/filetree/RenameAction.kt index 71357eb3a5..801618aa28 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/filetree/RenameAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/filetree/RenameAction.kt @@ -24,10 +24,10 @@ import com.itsaky.androidide.R import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.requireFile import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.idetooltips.attachTooltip import com.itsaky.androidide.preferences.databinding.LayoutDialogTextInputBinding import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.flashError -import com.itsaky.androidide.utils.showWithLongPressTooltip import com.itsaky.androidide.viewmodel.FileManagerViewModel /** @@ -93,9 +93,9 @@ class RenameAction( } } - builder.showWithLongPressTooltip( - context = context, - tooltipTag = TooltipTag.PROJECT_RENAME_DIALOG, - ) + builder + .create() + .attachTooltip(TooltipTag.PROJECT_RENAME_DIALOG) + .show() } } From 764843af0163faf7d35c5045bd7ff73373e89488 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Tue, 25 Aug 2026 14:17:05 +0100 Subject: [PATCH 3/7] refactor(ADFA-5125): Refactor editor dialog --- .../androidide/editor/ui/ReplaceAction.kt | 77 ++++++++----------- 1 file changed, 34 insertions(+), 43 deletions(-) diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/ReplaceAction.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/ReplaceAction.kt index 6a82154e60..a877752496 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/ReplaceAction.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/ReplaceAction.kt @@ -19,61 +19,52 @@ package com.itsaky.androidide.editor.ui import android.view.LayoutInflater import com.itsaky.androidide.editor.databinding.LayoutEditorFindReplaceBinding -import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.idetooltips.attachTooltip import com.itsaky.androidide.resources.R import com.itsaky.androidide.utils.DialogUtils -import com.itsaky.androidide.utils.onLongPress import org.slf4j.LoggerFactory /** - * Handles the replace action while searching in file. + * Handles the replacement action while searching in file. * * @author Akash Yadav */ object ReplaceAction { + private val log = LoggerFactory.getLogger(ReplaceAction::class.java) - private val log = LoggerFactory.getLogger(ReplaceAction::class.java) + @JvmStatic + fun doReplace(editor: IDEEditor) { + val context = editor.context + val binding = LayoutEditorFindReplaceBinding.inflate(LayoutInflater.from(context)) + val builder = DialogUtils.newMaterialDialogBuilder(context) + builder.setTitle(R.string.replace) + builder.setView(binding.root) + builder.setNegativeButton(android.R.string.cancel, null) + builder.setPositiveButton(R.string.replace) { dialog, _ -> + dialog.dismiss() + val input = binding.replacementInput.editText + if (input == null) { + log.error("Unable to perform replace action. Input field is null") + return@setPositiveButton + } - @JvmStatic - fun doReplace(editor: IDEEditor) { - val context = editor.context - val binding = LayoutEditorFindReplaceBinding.inflate(LayoutInflater.from(context)) - val builder = DialogUtils.newMaterialDialogBuilder(context) - builder.setTitle(R.string.replace) - builder.setView(binding.root) - builder.setNegativeButton(android.R.string.cancel, null) - builder.setPositiveButton(R.string.replace) { dialog, _ -> - dialog.dismiss() - val input = binding.replacementInput.editText - if (input == null) { - log.error("Unable to perform replace action. Input field is null") - return@setPositiveButton - } + editor.searcher.replaceThis(input.text.toString()) + } + builder.setNeutralButton(R.string.replaceAll) { dialog, _ -> + dialog.dismiss() + val input = binding.replacementInput.editText + if (input == null) { + log.error("Unable to perform replace action. Input field is null") + return@setNeutralButton + } - editor.searcher.replaceThis(input.text.toString()) - } - builder.setNeutralButton(R.string.replaceAll) { dialog, _ -> - dialog.dismiss() - val input = binding.replacementInput.editText - if (input == null) { - log.error("Unable to perform replace action. Input field is null") - return@setNeutralButton - } + editor.searcher.replaceAll(input.text.toString()) + } - editor.searcher.replaceAll(input.text.toString()) - } - - val dialog = builder.create() - dialog.onLongPress { - TooltipManager.showIdeCategoryTooltip( - context = binding.root.context, - anchorView = binding.root, - tag = TooltipTag.DIALOG_REPLACE_IN_FILE - ) - true - } - dialog.show() - - } + builder + .create() + .attachTooltip(TooltipTag.DIALOG_REPLACE_IN_FILE) + .show() + } } From 12caff7480fdb3c1d3ee9a88eb8f972e0dae3afd Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Tue, 25 Aug 2026 15:52:36 +0100 Subject: [PATCH 4/7] refactor(ADFA-5125): Refactor git dialogs --- .../fragments/git/GitBottomSheetFragment.kt | 839 +++++++++--------- .../fragments/git/GitCommitHistoryDialog.kt | 373 ++++---- 2 files changed, 607 insertions(+), 605 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 51a0acda05..9976500a6e 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -24,6 +24,7 @@ import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.git.core.models.ChangeType import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.idetooltips.attachTooltip import com.itsaky.androidide.interfaces.IEditorHandler import com.itsaky.androidide.preferences.internal.GitPreferences import com.itsaky.androidide.utils.flashSuccess @@ -38,420 +39,426 @@ import org.koin.androidx.viewmodel.ext.android.activityViewModel import java.io.File class GitBottomSheetFragment : Fragment(R.layout.fragment_git_bottom_sheet) { - - private val viewModel: GitBottomSheetViewModel by activityViewModel() - private val bottomSheetViewModel: BottomSheetViewModel by activityViewModel() - private lateinit var fileChangeAdapter: GitFileChangeAdapter - private lateinit var credentialsManager: GitCredentialsManager - - private var _binding: FragmentGitBottomSheetBinding? = null - private val binding get() = _binding!! - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - _binding = FragmentGitBottomSheetBinding.bind(view) - credentialsManager = GitCredentialsManager(requireContext()) - - fileChangeAdapter = GitFileChangeAdapter( - onFileClicked = { change -> - when (change.type) { - ChangeType.CONFLICTED -> { - val activity = requireActivity() - if (activity is EditorHandlerActivity) { - viewLifecycleOwner.lifecycleScope.launch { - val repo = viewModel.currentRepository - repo?.let { - activity.checkForExternalFileChanges(force = true) - activity.openFile(File(repo.rootDir, change.path)) - bottomSheetViewModel.setSheetState(BottomSheetBehavior.STATE_COLLAPSED) - } - } - } - } - - else -> { - val dialog = GitDiffViewerDialog.newInstance(change.path) - dialog.show(childFragmentManager, "GitDiffViewerDialog") - } - } - }, - onSelectionChanged = { - validateCommitButton() - updateCheckAllButton() - }, - onResolveConflict = { change -> - viewModel.resolveConflict(change.path) - } - ) - - binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) - binding.recyclerView.adapter = fileChangeAdapter - binding.recyclerView.onLongPress { _ -> - TooltipManager.showIdeCategoryTooltip( - context = requireContext(), - anchorView = binding.recyclerView, - tag = TooltipTag.PROJECT_GIT_FILES, - ) - } - - viewLifecycleOwner.lifecycleScope.launch { - launch { - viewModel.currentBranch.collectLatest { branchName -> - if (branchName != null) { - binding.tvBranchName.visibility = View.VISIBLE - binding.tvBranchName.text = - getString(R.string.current_branch_name, branchName) - } else { - binding.tvBranchName.visibility = View.GONE - } - } - } - - combine( - viewModel.isGitRepository, - viewModel.gitStatus - ) { isRepo, status -> - val allChanges = - status.staged + status.unstaged + status.untracked + status.conflicted - - when { - !isRepo -> binding.apply { - emptyView.visibility = View.VISIBLE - emptyView.text = getString(R.string.not_a_git_repo) - recyclerView.visibility = View.GONE - btnCheckAll.visibility = View.GONE - commitSection.visibility = View.GONE - authorWarning.visibility = View.GONE - commitHistoryButton.visibility = View.GONE - btnAbortMerge.visibility = View.GONE - } - - allChanges.isEmpty() -> binding.apply { - emptyView.visibility = View.VISIBLE - emptyView.text = getString(R.string.no_uncommitted_changes) - recyclerView.visibility = View.GONE - btnCheckAll.visibility = View.GONE - commitSection.visibility = View.GONE - authorWarning.visibility = View.GONE - commitHistoryButton.visibility = View.VISIBLE - btnAbortMerge.visibility = View.GONE - } - - else -> { - // Only offer "Check All" when there is at least one - // non-conflicted file; conflicted files can't be staged. - val hasSelectable = allChanges.any { it.type != ChangeType.CONFLICTED } - binding.apply { - emptyView.visibility = View.GONE - recyclerView.visibility = View.VISIBLE - btnCheckAll.visibility = - if (hasSelectable) View.VISIBLE else View.GONE - commitSection.visibility = View.VISIBLE - authorWarning.visibility = - if (hasAuthorInfo()) View.GONE else View.VISIBLE - commitHistoryButton.visibility = View.VISIBLE - btnAbortMerge.visibility = - if (status.isMerging) View.VISIBLE else View.GONE - } - fileChangeAdapter.submitList(allChanges) { - updateCheckAllButton() - } - } - } - }.collectLatest { } - } - - setupCommitUI() - - binding.commitHistoryButton.apply { - setOnClickListener { - val dialog = GitCommitHistoryDialog() - dialog.show(childFragmentManager, "CommitHistoryDialog") - } - setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT_HISTORY) - } - - setupPullUI() - } - - override fun onResume() { - super.onResume() - updateAuthorUI() - } - - private fun updateAuthorUI() { - val hasAuthor = hasAuthorInfo() - val allChanges = - viewModel.gitStatus.value.staged + viewModel.gitStatus.value.unstaged + viewModel.gitStatus.value.untracked + viewModel.gitStatus.value.conflicted - binding.authorWarning.visibility = - if (!hasAuthor && allChanges.isNotEmpty()) View.VISIBLE else View.GONE - validateCommitButton() - } - - private fun hasAuthorInfo(): Boolean { - return !GitPreferences.userName.isNullOrBlank() && !GitPreferences.userEmail.isNullOrBlank() - } - - private fun setupCommitUI() { - binding.commitSummary.doAfterTextChanged { validateCommitButton() } - binding.commitDescription.doAfterTextChanged { validateCommitButton() } - - binding.btnCheckAll.setOnClickListener { - if (fileChangeAdapter.areAllSelected()) { - fileChangeAdapter.clearSelection() - } else { - fileChangeAdapter.selectAll() - } - } - - binding.btnAbortMerge.apply { - setOnClickListener { - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.abort_merge) - .setMessage(R.string.confirm_abort_merge) - .setPositiveButton(R.string.abort_merge) { _, _ -> - viewModel.abortMerge { - val activity = requireActivity() - if (activity is EditorHandlerActivity) { - activity.checkForExternalFileChanges(force = true) - } - } - } - .setNegativeButton(android.R.string.cancel, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_ABORT_MERGE) - dialog.show() - } - setTooltipOnView(TooltipTag.PROJECT_GIT_ABORT) - } - - binding.authorAvatar.apply { - setOnClickListener { showAuthorPopup() } - setTooltipOnView(TooltipTag.PROJECT_GIT_ID) - } - - binding.commitButton.apply { - setOnClickListener { - checkUnsavedChangesAndProceed { - val summary = binding.commitSummary.text?.toString()?.trim() ?: "" - val description = binding.commitDescription.text?.toString()?.trim() - - if (summary.isNotEmpty() && fileChangeAdapter.selectedFiles.isNotEmpty() && hasAuthorInfo()) { - viewModel.commitChanges( - summary = summary, - description = description, - selectedPaths = fileChangeAdapter.selectedFiles.toList() - ) { - // Clear the inputs on successful commit - binding.commitSummary.text?.clear() - binding.commitDescription.text?.clear() - fileChangeAdapter.selectedFiles.clear() - updateCheckAllButton() - } - } - } - } - setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT) - } - } - - private fun showAuthorPopup() { - val name = GitPreferences.userName.orEmpty().ifBlank { getString(R.string.author_not_set) } - val email = - GitPreferences.userEmail.orEmpty().ifBlank { getString(R.string.author_not_set) } - val message = getString(R.string.git_committing_as, name) + "\n" + - getString(R.string.git_committing_email, email) + "\n\n" + - getString(R.string.git_update_config_in_preferences) - - val spannable = SpannableString(message) - val preferencesText = getString(R.string.git_update_config_in_preferences) - val startIndex = message.indexOf(preferencesText) - - val builder = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.idepref_git_author_title) - .setMessage(spannable) - .setPositiveButton(android.R.string.ok, null) - - val dialog = builder.create() - - if (startIndex != -1) { - spannable.setSpan( - object : ClickableSpan() { - override fun onClick(widget: View) { - val intent = Intent( - requireContext(), - PreferencesActivity::class.java - ) - dialog.dismiss() - startActivity(intent) - } - }, - startIndex, - startIndex + preferencesText.length, - SPAN_EXCLUSIVE_EXCLUSIVE - ) - } - - dialog.show() - dialog.findViewById(android.R.id.message)?.movementMethod = - LinkMovementMethod.getInstance() - } - - private fun validateCommitButton() { - // May be invoked from async adapter callbacks; bail if the view is gone. - val binding = _binding ?: return - val hasSummary = !binding.commitSummary.text.isNullOrBlank() - val hasSelection = fileChangeAdapter.selectedFiles.isNotEmpty() - val hasAuthor = hasAuthorInfo() - binding.commitButton.isEnabled = hasSummary && hasSelection && hasAuthor - } - - private fun updateCheckAllButton() { - // May be invoked from the async submitList commit callback; bail if the view is gone. - val binding = _binding ?: return - binding.btnCheckAll.setText( - if (fileChangeAdapter.areAllSelected()) R.string.uncheck_all else R.string.check_all - ) - } - - private fun setupPullUI() { - viewLifecycleOwner.lifecycleScope.launch { - viewModel.isGitRepository.collectLatest { isRepo -> - binding.btnPull.visibility = if (isRepo) View.VISIBLE else View.GONE - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.pullState.collectLatest { state -> - when (state) { - is PullUiState.Idle -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - } - - is PullUiState.Pulling -> { - binding.btnPull.isEnabled = false - binding.pullProgress.visibility = View.VISIBLE - } - - is PullUiState.Success -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - flashSuccess(R.string.pull_successful) - viewModel.resetPullState() - refreshEditorContent() - } - - is PullUiState.Conflicts -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - val message = state.message ?: getString(R.string.info_merge_conflicts) - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.merge_conflicts)) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_MERGE_CONFLICTS) - dialog.show() - viewModel.resetPullState() - refreshEditorContent() - } - - is PullUiState.Error -> { - binding.btnPull.isEnabled = true - binding.pullProgress.visibility = View.GONE - val message = - state.message ?: state.errorResId?.let { resId -> - if (state.errorArgs != null) getString( - resId, - *state.errorArgs.toTypedArray() - ) else getString(resId) - } - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.pull_failed) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_PULL_FAIL) - dialog.show() - } - } - } - } - - binding.btnPull.apply { - setOnClickListener { - checkUnsavedChangesAndProceed { - val username = credentialsManager.getUsername() - val token = credentialsManager.getToken() - if (!username.isNullOrBlank() && !token.isNullOrBlank()) { - viewModel.pull(username, token) - } else { - showGitCredentialsDialog( - credentialsManager = credentialsManager, - positiveButtonTextResId = R.string.pull - ) { user, accessToken -> - viewModel.pull(user, accessToken) - } - } - } - } - setTooltipOnView(TooltipTag.GIT_PULL) - } - } - - private fun refreshEditorContent(force: Boolean = false) { - val activity = requireActivity() - if (activity is EditorHandlerActivity) { - activity.checkForExternalFileChanges(force) - } - } - - private fun checkUnsavedChangesAndProceed(action: () -> Unit) { - val handler = requireActivity() as? IEditorHandler - if (handler?.areFilesModified() == true) { - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.title_files_unsaved) - .setMessage(R.string.msg_save_before_git_action) - .setPositiveButton(R.string.save_before_git_action) { _, _ -> - handler.saveAllAsync { action() } - } - .setNegativeButton(R.string.no_save_before_git_action) { _, _ -> - action() - } - .setNeutralButton(android.R.string.cancel, null) - .create() - dialog.setTooltipOnDialog(TooltipTag.GIT_DIALOG_SAVE) - dialog.show() - } else { - action() - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun AlertDialog.setTooltipOnDialog(tag: String) { - onLongPress { view -> - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = tag - ) - true - } - } - - private fun View.setTooltipOnView(tag: String) { - setOnLongClickListener { view -> - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = tag - ) - true - } - } + private val viewModel: GitBottomSheetViewModel by activityViewModel() + private val bottomSheetViewModel: BottomSheetViewModel by activityViewModel() + private lateinit var fileChangeAdapter: GitFileChangeAdapter + private lateinit var credentialsManager: GitCredentialsManager + + @Suppress("ktlint:standard:backing-property-naming") + private var _binding: FragmentGitBottomSheetBinding? = null + private val binding get() = _binding!! + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentGitBottomSheetBinding.bind(view) + credentialsManager = GitCredentialsManager(requireContext()) + + fileChangeAdapter = + GitFileChangeAdapter( + onFileClicked = { change -> + when (change.type) { + ChangeType.CONFLICTED -> { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + viewLifecycleOwner.lifecycleScope.launch { + val repo = viewModel.currentRepository + repo?.let { + activity.checkForExternalFileChanges(force = true) + activity.openFile(File(repo.rootDir, change.path)) + bottomSheetViewModel.setSheetState(BottomSheetBehavior.STATE_COLLAPSED) + } + } + } + } + + else -> { + val dialog = GitDiffViewerDialog.newInstance(change.path) + dialog.show(childFragmentManager, "GitDiffViewerDialog") + } + } + }, + onSelectionChanged = { + validateCommitButton() + updateCheckAllButton() + }, + onResolveConflict = { change -> + viewModel.resolveConflict(change.path) + }, + ) + + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.adapter = fileChangeAdapter + binding.recyclerView.onLongPress { _ -> + TooltipManager.showIdeCategoryTooltip( + context = requireContext(), + anchorView = binding.recyclerView, + tag = TooltipTag.PROJECT_GIT_FILES, + ) + } + + viewLifecycleOwner.lifecycleScope.launch { + launch { + viewModel.currentBranch.collectLatest { branchName -> + if (branchName != null) { + binding.tvBranchName.visibility = View.VISIBLE + binding.tvBranchName.text = + getString(R.string.current_branch_name, branchName) + } else { + binding.tvBranchName.visibility = View.GONE + } + } + } + + combine( + viewModel.isGitRepository, + viewModel.gitStatus, + ) { isRepo, status -> + val allChanges = + status.staged + status.unstaged + status.untracked + status.conflicted + + when { + !isRepo -> { + binding.apply { + emptyView.visibility = View.VISIBLE + emptyView.text = getString(R.string.not_a_git_repo) + recyclerView.visibility = View.GONE + btnCheckAll.visibility = View.GONE + commitSection.visibility = View.GONE + authorWarning.visibility = View.GONE + commitHistoryButton.visibility = View.GONE + btnAbortMerge.visibility = View.GONE + } + } + + allChanges.isEmpty() -> { + binding.apply { + emptyView.visibility = View.VISIBLE + emptyView.text = getString(R.string.no_uncommitted_changes) + recyclerView.visibility = View.GONE + btnCheckAll.visibility = View.GONE + commitSection.visibility = View.GONE + authorWarning.visibility = View.GONE + commitHistoryButton.visibility = View.VISIBLE + btnAbortMerge.visibility = View.GONE + } + } + + else -> { + // Only offer "Check All" when there is at least one + // non-conflicted file; conflicted files can't be staged. + val hasSelectable = allChanges.any { it.type != ChangeType.CONFLICTED } + binding.apply { + emptyView.visibility = View.GONE + recyclerView.visibility = View.VISIBLE + btnCheckAll.visibility = + if (hasSelectable) View.VISIBLE else View.GONE + commitSection.visibility = View.VISIBLE + authorWarning.visibility = + if (hasAuthorInfo()) View.GONE else View.VISIBLE + commitHistoryButton.visibility = View.VISIBLE + btnAbortMerge.visibility = + if (status.isMerging) View.VISIBLE else View.GONE + } + fileChangeAdapter.submitList(allChanges) { + updateCheckAllButton() + } + } + } + }.collectLatest { } + } + + setupCommitUI() + + binding.commitHistoryButton.apply { + setOnClickListener { + val dialog = GitCommitHistoryDialog() + dialog.show(childFragmentManager, "CommitHistoryDialog") + } + setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT_HISTORY) + } + + setupPullUI() + } + + override fun onResume() { + super.onResume() + updateAuthorUI() + } + + private fun updateAuthorUI() { + val hasAuthor = hasAuthorInfo() + val allChanges = + viewModel.gitStatus.value.staged + viewModel.gitStatus.value.unstaged + viewModel.gitStatus.value.untracked + + viewModel.gitStatus.value.conflicted + binding.authorWarning.visibility = + if (!hasAuthor && allChanges.isNotEmpty()) View.VISIBLE else View.GONE + validateCommitButton() + } + + private fun hasAuthorInfo(): Boolean = !GitPreferences.userName.isNullOrBlank() && !GitPreferences.userEmail.isNullOrBlank() + + private fun setupCommitUI() { + binding.commitSummary.doAfterTextChanged { validateCommitButton() } + binding.commitDescription.doAfterTextChanged { validateCommitButton() } + + binding.btnCheckAll.setOnClickListener { + if (fileChangeAdapter.areAllSelected()) { + fileChangeAdapter.clearSelection() + } else { + fileChangeAdapter.selectAll() + } + } + + binding.btnAbortMerge.apply { + setOnClickListener { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.abort_merge) + .setMessage(R.string.confirm_abort_merge) + .setPositiveButton(R.string.abort_merge) { _, _ -> + viewModel.abortMerge { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + activity.checkForExternalFileChanges(force = true) + } + } + }.setNegativeButton(android.R.string.cancel, null) + .create() + .attachTooltip(TooltipTag.GIT_DIALOG_ABORT_MERGE) + .show() + } + setTooltipOnView(TooltipTag.PROJECT_GIT_ABORT) + } + + binding.authorAvatar.apply { + setOnClickListener { showAuthorPopup() } + setTooltipOnView(TooltipTag.PROJECT_GIT_ID) + } + + binding.commitButton.apply { + setOnClickListener { + checkUnsavedChangesAndProceed { + val summary = + binding.commitSummary.text + ?.toString() + ?.trim() ?: "" + val description = + binding.commitDescription.text + ?.toString() + ?.trim() + + if (summary.isNotEmpty() && fileChangeAdapter.selectedFiles.isNotEmpty() && hasAuthorInfo()) { + viewModel.commitChanges( + summary = summary, + description = description, + selectedPaths = fileChangeAdapter.selectedFiles.toList(), + ) { + // Clear the inputs on successful commit + binding.commitSummary.text?.clear() + binding.commitDescription.text?.clear() + fileChangeAdapter.selectedFiles.clear() + updateCheckAllButton() + } + } + } + } + setTooltipOnView(TooltipTag.PROJECT_GIT_COMMIT) + } + } + + private fun showAuthorPopup() { + val name = GitPreferences.userName.orEmpty().ifBlank { getString(R.string.author_not_set) } + val email = + GitPreferences.userEmail.orEmpty().ifBlank { getString(R.string.author_not_set) } + val message = + getString(R.string.git_committing_as, name) + "\n" + + getString(R.string.git_committing_email, email) + "\n\n" + + getString(R.string.git_update_config_in_preferences) + + val spannable = SpannableString(message) + val preferencesText = getString(R.string.git_update_config_in_preferences) + val startIndex = message.indexOf(preferencesText) + + val builder = + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.idepref_git_author_title) + .setMessage(spannable) + .setPositiveButton(android.R.string.ok, null) + + val dialog = builder.create() + + if (startIndex != -1) { + spannable.setSpan( + object : ClickableSpan() { + override fun onClick(widget: View) { + val intent = + Intent( + requireContext(), + PreferencesActivity::class.java, + ) + dialog.dismiss() + startActivity(intent) + } + }, + startIndex, + startIndex + preferencesText.length, + SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + + dialog.show() + dialog.findViewById(android.R.id.message)?.movementMethod = + LinkMovementMethod.getInstance() + } + + private fun validateCommitButton() { + // May be invoked from async adapter callbacks; bail if the view is gone. + val binding = _binding ?: return + val hasSummary = !binding.commitSummary.text.isNullOrBlank() + val hasSelection = fileChangeAdapter.selectedFiles.isNotEmpty() + val hasAuthor = hasAuthorInfo() + binding.commitButton.isEnabled = hasSummary && hasSelection && hasAuthor + } + + private fun updateCheckAllButton() { + // May be invoked from the async submitList commit callback; bail if the view is gone. + val binding = _binding ?: return + binding.btnCheckAll.setText( + if (fileChangeAdapter.areAllSelected()) R.string.uncheck_all else R.string.check_all, + ) + } + + private fun setupPullUI() { + viewLifecycleOwner.lifecycleScope.launch { + viewModel.isGitRepository.collectLatest { isRepo -> + binding.btnPull.visibility = if (isRepo) View.VISIBLE else View.GONE + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.pullState.collectLatest { state -> + when (state) { + is PullUiState.Idle -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + } + + is PullUiState.Pulling -> { + binding.btnPull.isEnabled = false + binding.pullProgress.visibility = View.VISIBLE + } + + is PullUiState.Success -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + flashSuccess(R.string.pull_successful) + viewModel.resetPullState() + refreshEditorContent() + } + + is PullUiState.Conflicts -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + val message = state.message ?: getString(R.string.info_merge_conflicts) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.merge_conflicts)) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .create() + .attachTooltip(TooltipTag.GIT_DIALOG_MERGE_CONFLICTS) + .show() + viewModel.resetPullState() + refreshEditorContent() + } + + is PullUiState.Error -> { + binding.btnPull.isEnabled = true + binding.pullProgress.visibility = View.GONE + val message = + state.message ?: state.errorResId?.let { resId -> + if (state.errorArgs != null) { + getString( + resId, + *state.errorArgs.toTypedArray(), + ) + } else { + getString(resId) + } + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.pull_failed) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .create() + .attachTooltip(TooltipTag.GIT_DIALOG_PULL_FAIL) + .show() + } + } + } + } + + binding.btnPull.apply { + setOnClickListener { + checkUnsavedChangesAndProceed { + val username = credentialsManager.getUsername() + val token = credentialsManager.getToken() + if (!username.isNullOrBlank() && !token.isNullOrBlank()) { + viewModel.pull(username, token) + } else { + showGitCredentialsDialog( + credentialsManager = credentialsManager, + positiveButtonTextResId = R.string.pull, + ) { user, accessToken -> + viewModel.pull(user, accessToken) + } + } + } + } + setTooltipOnView(TooltipTag.GIT_PULL) + } + } + + private fun refreshEditorContent(force: Boolean = false) { + val activity = requireActivity() + if (activity is EditorHandlerActivity) { + activity.checkForExternalFileChanges(force) + } + } + + private fun checkUnsavedChangesAndProceed(action: () -> Unit) { + val handler = requireActivity() as? IEditorHandler + if (handler?.areFilesModified() == true) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.title_files_unsaved) + .setMessage(R.string.msg_save_before_git_action) + .setPositiveButton(R.string.save_before_git_action) { _, _ -> + handler.saveAllAsync { action() } + }.setNegativeButton(R.string.no_save_before_git_action) { _, _ -> + action() + }.setNeutralButton(android.R.string.cancel, null) + .create() + .attachTooltip(TooltipTag.GIT_DIALOG_SAVE) + .show() + } else { + action() + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun View.setTooltipOnView(tag: String) { + setOnLongClickListener { view -> + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = tag, + ) + true + } + } } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitCommitHistoryDialog.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitCommitHistoryDialog.kt index e281485f01..fa541943fb 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitCommitHistoryDialog.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitCommitHistoryDialog.kt @@ -16,6 +16,7 @@ import com.itsaky.androidide.git.core.GitCredentialsManager import com.itsaky.androidide.git.core.models.CommitHistoryUiState import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.idetooltips.attachTooltip import com.itsaky.androidide.utils.applyLongPressRecursively import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.utils.onLongPress @@ -26,193 +27,187 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.activityViewModel class GitCommitHistoryDialog : DialogFragment() { - - private var _binding: DialogGitCommitHistoryBinding? = null - private val binding get() = _binding!! - private val viewModel: GitBottomSheetViewModel by activityViewModel() - private lateinit var commitHistoryAdapter: GitCommitHistoryAdapter - private val credentialsManager: GitCredentialsManager by inject() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setStyle(STYLE_NORMAL, R.style.Theme_AndroidIDE) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = DialogGitCommitHistoryBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - commitHistoryAdapter = GitCommitHistoryAdapter() - val linearLayoutManager = LinearLayoutManager(requireContext()) - val dividerItemDecoration = DividerItemDecoration( - binding.rvCommitHistory.context, - linearLayoutManager.orientation - ) - - binding.root.applyLongPressRecursively( - listOf( - binding.btnPush, - binding.rvCommitHistory - ) - ) { view -> - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = TooltipTag.GIT_COMMIT_HISTORY - ) - true - } - - binding.rvCommitHistory.apply { - layoutManager = linearLayoutManager - addItemDecoration(dividerItemDecoration) - adapter = commitHistoryAdapter - onLongPress { - TooltipManager.showIdeCategoryTooltip( - context = binding.root.context, - anchorView = binding.root, - tag = TooltipTag.GIT_COMMIT_HISTORY - ) - } - } - - binding.btnBack.setOnClickListener { - dismiss() - } - - viewModel.getCommitHistoryList() - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.commitHistory.collectLatest { state -> - when (state) { - is CommitHistoryUiState.Loading -> { - binding.progressBar.visibility = View.VISIBLE - binding.emptyView.visibility = View.GONE - binding.rvCommitHistory.visibility = View.GONE - } - - is CommitHistoryUiState.Empty -> { - binding.progressBar.visibility = View.GONE - binding.emptyView.visibility = View.VISIBLE - binding.emptyView.setText(R.string.no_commit_history) - binding.rvCommitHistory.visibility = View.GONE - } - - is CommitHistoryUiState.Error -> { - binding.progressBar.visibility = View.GONE - binding.emptyView.visibility = View.VISIBLE - binding.emptyView.text = state.message ?: getString(R.string.unknown_error) - binding.rvCommitHistory.visibility = View.GONE - } - - is CommitHistoryUiState.Success -> { - binding.progressBar.visibility = View.GONE - binding.emptyView.visibility = View.GONE - binding.rvCommitHistory.visibility = View.VISIBLE - commitHistoryAdapter.submitList(state.commits) - } - } - } - } - - setupPushUI() - } - - private fun setupPushUI() { - binding.btnPush.apply { - setOnClickListener { - val username = credentialsManager.getUsername() - val token = credentialsManager.getToken() - if (!username.isNullOrBlank() && !token.isNullOrBlank()) { - viewModel.push(username, token) - } else { - showGitCredentialsDialog( - credentialsManager = credentialsManager, - positiveButtonTextResId = R.string.push - ) { user, accessToken -> - viewModel.push(user, accessToken) - } - } - } - setOnLongClickListener { view -> - TooltipManager.showIdeCategoryTooltip( - context = view.context, - anchorView = view, - tag = TooltipTag.GIT_PUSH - ) - true - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.localCommitsCount.collectLatest { count -> - binding.btnPush.visibility = if (count > 0) View.VISIBLE else View.GONE - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.pushState.collectLatest { state -> - when (state) { - is GitBottomSheetViewModel.PushUiState.Idle -> { - binding.btnPush.isEnabled = true - binding.btnPush.text = getString(R.string.push) - binding.pushProgress.visibility = View.GONE - } - - is GitBottomSheetViewModel.PushUiState.Pushing -> { - binding.btnPush.isEnabled = false - binding.pushProgress.visibility = View.VISIBLE - } - - is GitBottomSheetViewModel.PushUiState.Success -> { - binding.btnPush.isEnabled = true - binding.pushProgress.visibility = View.GONE - flashSuccess(R.string.push_successful) - viewModel.resetPushState() - dismiss() - } - - is GitBottomSheetViewModel.PushUiState.Error -> { - binding.btnPush.isEnabled = true - binding.pushProgress.visibility = View.GONE - val message = - if (state.errorResId != null && state.errorResId != R.string.unknown_error) { - getString(state.errorResId) - } else { - state.message ?: getString(R.string.unknown_error) - } - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.push_failed) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .create() - dialog.onLongPress { - TooltipManager.showIdeCategoryTooltip( - context = binding.root.context, - anchorView = binding.root, - tag = TooltipTag.GIT_DIALOG_PUSH_FAIL - ) - true - } - dialog.show() - } - } - } - } - - - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - + @Suppress("ktlint:standard:backing-property-naming") + private var _binding: DialogGitCommitHistoryBinding? = null + private val binding get() = _binding!! + private val viewModel: GitBottomSheetViewModel by activityViewModel() + private lateinit var commitHistoryAdapter: GitCommitHistoryAdapter + private val credentialsManager: GitCredentialsManager by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.Theme_AndroidIDE) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = DialogGitCommitHistoryBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + commitHistoryAdapter = GitCommitHistoryAdapter() + val linearLayoutManager = LinearLayoutManager(requireContext()) + val dividerItemDecoration = + DividerItemDecoration( + binding.rvCommitHistory.context, + linearLayoutManager.orientation, + ) + + binding.root.applyLongPressRecursively( + listOf( + binding.btnPush, + binding.rvCommitHistory, + ), + ) { view -> + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = TooltipTag.GIT_COMMIT_HISTORY, + ) + true + } + + binding.rvCommitHistory.apply { + layoutManager = linearLayoutManager + addItemDecoration(dividerItemDecoration) + adapter = commitHistoryAdapter + onLongPress { + TooltipManager.showIdeCategoryTooltip( + context = binding.root.context, + anchorView = binding.root, + tag = TooltipTag.GIT_COMMIT_HISTORY, + ) + } + } + + binding.btnBack.setOnClickListener { + dismiss() + } + + viewModel.getCommitHistoryList() + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.commitHistory.collectLatest { state -> + when (state) { + is CommitHistoryUiState.Loading -> { + binding.progressBar.visibility = View.VISIBLE + binding.emptyView.visibility = View.GONE + binding.rvCommitHistory.visibility = View.GONE + } + + is CommitHistoryUiState.Empty -> { + binding.progressBar.visibility = View.GONE + binding.emptyView.visibility = View.VISIBLE + binding.emptyView.setText(R.string.no_commit_history) + binding.rvCommitHistory.visibility = View.GONE + } + + is CommitHistoryUiState.Error -> { + binding.progressBar.visibility = View.GONE + binding.emptyView.visibility = View.VISIBLE + binding.emptyView.text = state.message ?: getString(R.string.unknown_error) + binding.rvCommitHistory.visibility = View.GONE + } + + is CommitHistoryUiState.Success -> { + binding.progressBar.visibility = View.GONE + binding.emptyView.visibility = View.GONE + binding.rvCommitHistory.visibility = View.VISIBLE + commitHistoryAdapter.submitList(state.commits) + } + } + } + } + + setupPushUI() + } + + private fun setupPushUI() { + binding.btnPush.apply { + setOnClickListener { + val username = credentialsManager.getUsername() + val token = credentialsManager.getToken() + if (!username.isNullOrBlank() && !token.isNullOrBlank()) { + viewModel.push(username, token) + } else { + showGitCredentialsDialog( + credentialsManager = credentialsManager, + positiveButtonTextResId = R.string.push, + ) { user, accessToken -> + viewModel.push(user, accessToken) + } + } + } + setOnLongClickListener { view -> + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = TooltipTag.GIT_PUSH, + ) + true + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.localCommitsCount.collectLatest { count -> + binding.btnPush.visibility = if (count > 0) View.VISIBLE else View.GONE + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.pushState.collectLatest { state -> + when (state) { + is GitBottomSheetViewModel.PushUiState.Idle -> { + binding.btnPush.isEnabled = true + binding.btnPush.text = getString(R.string.push) + binding.pushProgress.visibility = View.GONE + } + + is GitBottomSheetViewModel.PushUiState.Pushing -> { + binding.btnPush.isEnabled = false + binding.pushProgress.visibility = View.VISIBLE + } + + is GitBottomSheetViewModel.PushUiState.Success -> { + binding.btnPush.isEnabled = true + binding.pushProgress.visibility = View.GONE + flashSuccess(R.string.push_successful) + viewModel.resetPushState() + dismiss() + } + + is GitBottomSheetViewModel.PushUiState.Error -> { + binding.btnPush.isEnabled = true + binding.pushProgress.visibility = View.GONE + val message = + if (state.errorResId != null && state.errorResId != R.string.unknown_error) { + getString(state.errorResId) + } else { + state.message ?: getString(R.string.unknown_error) + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.push_failed) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .create() + .attachTooltip(TooltipTag.GIT_DIALOG_PUSH_FAIL) + .show() + } + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } } From 1c2dfd0eef2f034c52cc1a4f6d437ddafbc39628 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Tue, 25 Aug 2026 17:22:52 +0100 Subject: [PATCH 5/7] fix(ADFA-5125): Prioritize dialog's decor view as the anchor --- .../idetooltips/DialogExtensions.kt | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/DialogExtensions.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/DialogExtensions.kt index eced00f4e8..5c99fbf899 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/DialogExtensions.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/DialogExtensions.kt @@ -3,6 +3,8 @@ package com.itsaky.androidide.idetooltips import android.annotation.SuppressLint import android.app.Activity import android.content.Context +import android.content.ContextWrapper +import android.content.DialogInterface import android.graphics.Rect import android.view.MotionEvent import android.view.ViewGroup @@ -10,10 +12,17 @@ import android.view.inputmethod.InputMethodManager import android.widget.AdapterView import android.widget.EditText import androidx.appcompat.app.AlertDialog +import com.itsaky.androidide.utils.applyLongPressRecursively import com.itsaky.androidide.utils.forEachViewRecursively -import com.itsaky.androidide.utils.onLongPress import androidx.appcompat.R as AndroidR +private tailrec fun Context.findActivity(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext?.findActivity() + else -> null + } + /** * Attaches an IDE category tooltip listener to an [AlertDialog] when any part of the * dialog surface is long-pressed. @@ -31,29 +40,28 @@ fun AlertDialog.attachTooltip( context: Context = this.context, ): AlertDialog { fun showTooltip() { - val anchor = (context as? Activity)?.window?.decorView ?: return + val activity = context.findActivity() + val anchor = this.window?.decorView ?: activity?.window?.decorView ?: return TooltipManager.showIdeCategoryTooltip( - context = context, + context = activity ?: context, anchorView = anchor, tag = tooltipTag, ) } - this.onLongPress { - showTooltip() - true - } + val onShowActions = mutableListOf<(DialogInterface) -> Unit>() - this.listView?.onItemLongClickListener = - AdapterView.OnItemLongClickListener { _, _, _, _ -> + onShowActions.add { + this.window?.decorView?.applyLongPressRecursively(emptyList(), includeEditTexts = false) { showTooltip() true } + } val customPanel: ViewGroup? = this.findViewById(AndroidR.id.customPanel) customPanel?.forEachViewRecursively { view -> if (view is EditText) { - this.setOnShowListener { + onShowActions.add { view.requestFocus() val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager @@ -76,5 +84,17 @@ fun AlertDialog.attachTooltip( } } + this.setOnShowListener { dialog -> + for (action in onShowActions) { + action(dialog) + } + } + + this.listView?.onItemLongClickListener = + AdapterView.OnItemLongClickListener { _, _, _, _ -> + showTooltip() + true + } + return this } From 9ec0a91beffdb0a0519f48a310f0833c1fe194e2 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 26 Aug 2026 14:56:34 +0100 Subject: [PATCH 6/7] refactor(ADFA-5125): Configure custom-panel views after showing dialog --- .../idetooltips/DialogExtensions.kt | 41 +++++++------------ 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/DialogExtensions.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/DialogExtensions.kt index 5c99fbf899..a27505c412 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/DialogExtensions.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/DialogExtensions.kt @@ -4,7 +4,6 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.ContextWrapper -import android.content.DialogInterface import android.graphics.Rect import android.view.MotionEvent import android.view.ViewGroup @@ -49,47 +48,37 @@ fun AlertDialog.attachTooltip( ) } - val onShowActions = mutableListOf<(DialogInterface) -> Unit>() - - onShowActions.add { + this.setOnShowListener { this.window?.decorView?.applyLongPressRecursively(emptyList(), includeEditTexts = false) { showTooltip() true } - } - val customPanel: ViewGroup? = this.findViewById(AndroidR.id.customPanel) - customPanel?.forEachViewRecursively { view -> - if (view is EditText) { - onShowActions.add { + val customPanel: ViewGroup? = this.findViewById(AndroidR.id.customPanel) + customPanel?.forEachViewRecursively { view -> + if (view is EditText) { view.requestFocus() val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) - } - this.window?.decorView?.setOnTouchListener { _, event -> - if (event.action == MotionEvent.ACTION_DOWN) { - val outRect = Rect() - view.getGlobalVisibleRect(outRect) - if (!outRect.contains(event.rawX.toInt(), event.rawY.toInt())) { - view.clearFocus() - val inputMethodManager = - view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) + this.window?.decorView?.setOnTouchListener { _, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + val outRect = Rect() + view.getGlobalVisibleRect(outRect) + if (!outRect.contains(event.rawX.toInt(), event.rawY.toInt())) { + view.clearFocus() + val inputMethodManager = + view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0) + } } + false } - false } } } - this.setOnShowListener { dialog -> - for (action in onShowActions) { - action(dialog) - } - } - this.listView?.onItemLongClickListener = AdapterView.OnItemLongClickListener { _, _, _, _ -> showTooltip() From 86c95939cc9d014f69ec6cdd19958dd11dc64611 Mon Sep 17 00:00:00 2001 From: Oluwadara Abijo Date: Wed, 26 Aug 2026 14:57:03 +0100 Subject: [PATCH 7/7] format(ADFA-5125): Remove unused import --- .../itsaky/androidide/fragments/git/GitBottomSheetFragment.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt index 9976500a6e..8a13b8103f 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/git/GitBottomSheetFragment.kt @@ -8,7 +8,6 @@ import android.text.method.LinkMovementMethod import android.text.style.ClickableSpan import android.view.View import android.widget.TextView -import androidx.appcompat.app.AlertDialog import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope