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() } } 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..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 @@ -24,6 +23,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 +38,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 + } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/DialogExtensions.kt b/app/src/main/java/com/itsaky/androidide/utils/DialogExtensions.kt deleted file mode 100644 index ede0a296bb..0000000000 --- a/app/src/main/java/com/itsaky/androidide/utils/DialogExtensions.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.itsaky.androidide.utils - -import android.annotation.SuppressLint -import android.app.Activity -import android.content.Context -import android.graphics.Rect -import android.view.MotionEvent -import android.view.ViewGroup -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 - -@SuppressLint("ClickableViewAccessibility") -fun MaterialAlertDialogBuilder.showWithLongPressTooltip( - context: Context, - tooltipTag: String, -): AlertDialog { - val dialog = this.create() - dialog.show() - - fun longPressAction() { - val anchor = (context as? Activity)?.window?.decorView ?: return - TooltipManager.showIdeCategoryTooltip( - context = context, - anchorView = anchor, - tag = tooltipTag, - ) - } - - dialog.onLongPress { - longPressAction() - true - } - - dialog.listView?.onItemLongClickListener = - AdapterView.OnItemLongClickListener { _, _, _, _ -> - longPressAction() - true - } - - val customPanel: ViewGroup? = dialog.findViewById(androidx.appcompat.R.id.customPanel) - - customPanel?.forEachViewRecursively { view -> - if (view is EditText) { - dialog.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 -> - 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 = - view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - } - } - false - } - } - } - - return dialog -} 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() + } } diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/DialogExtensions.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/DialogExtensions.kt new file mode 100644 index 0000000000..a27505c412 --- /dev/null +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/DialogExtensions.kt @@ -0,0 +1,89 @@ +package com.itsaky.androidide.idetooltips + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.graphics.Rect +import android.view.MotionEvent +import android.view.ViewGroup +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 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. + * + * 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 AlertDialog.attachTooltip( + tooltipTag: String, + context: Context = this.context, +): AlertDialog { + fun showTooltip() { + val activity = context.findActivity() + val anchor = this.window?.decorView ?: activity?.window?.decorView ?: return + TooltipManager.showIdeCategoryTooltip( + context = activity ?: context, + anchorView = anchor, + tag = tooltipTag, + ) + } + + 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) { + 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) + } + } + false + } + } + } + } + + this.listView?.onItemLongClickListener = + AdapterView.OnItemLongClickListener { _, _, _, _ -> + showTooltip() + true + } + + return this +}