From 5c8ae916525cf1d5ba2b87e8be1bd654d596de83 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 19 May 2026 10:34:04 -0500 Subject: [PATCH 1/3] fix(ui): prevent project creation until all form fields are viewed Adds scroll detection to ensure users reach the end of the project setup before continuing. --- .../fragments/TemplateDetailsFragment.kt | 151 ++++++++++-------- .../utils/ProjectCreationManager.kt | 54 +++++++ .../utils/ui/TemplateScrollGateKeeper.kt | 96 +++++++++++ .../res/layout/fragment_template_details.xml | 11 ++ 4 files changed, 244 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/utils/ProjectCreationManager.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/ui/TemplateScrollGateKeeper.kt diff --git a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt index 8b8b1d7979..5221b4205f 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt @@ -32,14 +32,10 @@ import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag.SETUP_CREATE_PROJECT import com.itsaky.androidide.idetooltips.TooltipTag.SETUP_OVERVIEW import com.itsaky.androidide.idetooltips.TooltipTag.SETUP_PREVIOUS -import com.itsaky.androidide.roomData.recentproject.RecentProject -import com.itsaky.androidide.tasks.executeAsyncProvideError import com.itsaky.androidide.templates.ParameterWidget -import com.itsaky.androidide.templates.ProjectTemplateRecipeResult -import com.itsaky.androidide.templates.StringParameter import com.itsaky.androidide.templates.Template -import com.itsaky.androidide.templates.impl.ConstraintVerifier -import com.itsaky.androidide.utils.TemplateRecipeExecutor +import com.itsaky.androidide.utils.ProjectCreationManager +import com.itsaky.androidide.utils.ui.TemplateScrollGateKeeper import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.viewmodel.MainViewModel @@ -47,6 +43,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import android.animation.ObjectAnimator +import android.view.animation.LinearInterpolator +import androidx.core.view.isVisible /** * A fragment which shows a wizard-like interface for creating templates. @@ -61,24 +60,60 @@ class TemplateDetailsFragment : private val viewModel by activityViewModel() private var widgetsBindJob: Job? = null + private var scrollGateKeeper: TemplateScrollGateKeeper? = null + private val projectCreationManager by lazy { ProjectCreationManager(requireContext()) } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + setupRecyclerView() + setupTooltips() + setupObservers() + setupClickListeners() + startBlinkingIndicator() + } + + override fun onDestroyView() { + super.onDestroyView() + scrollGateKeeper?.detach() + scrollGateKeeper = null + } + + private fun setupRecyclerView() { + binding.widgets.layoutManager = LinearLayoutManager(requireContext()) + + scrollGateKeeper = TemplateScrollGateKeeper(binding.widgets) { + updateFinishEnabledState() + } + scrollGateKeeper?.attach() + } + + private fun setupObservers() { viewModel.template.observe(viewLifecycleOwner) { binding.widgets.adapter = null + scrollGateKeeper?.reset() + updateFinishEnabledState() viewModel.postTransition(viewLifecycleOwner) { bindWithTemplate(it) } } - viewModel.creatingProject.observe(viewLifecycleOwner) { + viewModel.creatingProject.observe(viewLifecycleOwner) { isCreating -> TransitionManager.beginDelayedTransition(binding.root) - binding.finish.isEnabled = !it - binding.previous.isEnabled = !it + updateFinishEnabledState() + binding.previous.isEnabled = !isCreating } + } + private fun setupClickListeners() { binding.previous.setOnClickListener { viewModel.setScreen(MainViewModel.SCREEN_TEMPLATE_LIST) } + binding.finish.setOnClickListener { + handleProjectCreation() + } + } + + private fun setupTooltips() { binding.previous.setOnLongClickListener { TooltipManager.showIdeCategoryTooltip(requireContext(), it, SETUP_PREVIOUS) true @@ -89,59 +124,26 @@ class TemplateDetailsFragment : true } - binding.finish.setOnClickListener { - viewModel.creatingProject.value = true - val template = viewModel.template.value ?: run { - viewModel.setScreen(MainViewModel.SCREEN_MAIN) - return@setOnClickListener - } - - val isValid = template.parameters.fold(true) { isValid, param -> - if (param is StringParameter) { - return@fold isValid && ConstraintVerifier.isValid( - param.value, - param.constraints - ) - } else isValid - } - - if (!isValid) { - viewModel.creatingProject.value = false - flashError(string.msg_invalid_project_details) - return@setOnClickListener - } + binding.title.setOnLongClickListener { + TooltipManager.showIdeCategoryTooltip(requireContext(), binding.root, SETUP_OVERVIEW) + true + } + } - viewModel.creatingProject.value = true - val appContext = requireContext().applicationContext - executeAsyncProvideError({ - template.recipe.execute(TemplateRecipeExecutor(appContext)) - }) { result, err -> + private fun handleProjectCreation() { + val template = viewModel.template.value ?: run { + viewModel.setScreen(MainViewModel.SCREEN_MAIN) + return + } + projectCreationManager.execute( + template = template, + onStart = { viewModel.creatingProject.value = true }, + onSuccess = { result, project -> viewModel.creatingProject.value = false - if (result == null || err != null || result !is ProjectTemplateRecipeResult) { - err?.printStackTrace() - if (err != null) { - flashError(err.cause?.message ?: err.message) - } else { - flashError(string.project_creation_failed) - } - return@executeAsyncProvideError - } - viewModel.setScreen(MainViewModel.SCREEN_MAIN) flashSuccess(string.project_created_successfully) - val now = System.currentTimeMillis().toString() - - val project = RecentProject( - location = result.data.projectDir.path, - name = result.data.name, - createdAt = now, - lastModified = now, - templateName = template.templateNameStr, - language = result.data.language?.name ?: "unknown" - ) - viewModel.postTransition(viewLifecycleOwner) { // open the project (requireActivity() as MainActivity).openProject( @@ -150,18 +152,12 @@ class TemplateDetailsFragment : hasTemplateIssues = result.hasErrorsWarnings ) } + }, + onError = { errorMsg -> + viewModel.creatingProject.value = false + flashError(errorMsg) } - } - - binding.widgets.layoutManager = LinearLayoutManager(requireContext()) - - binding.title.setOnLongClickListener { - TooltipManager.showIdeCategoryTooltip( - requireContext(), binding.root, - SETUP_OVERVIEW - ) - true - } + ) } private fun bindWithTemplate(template: Template<*>?) { @@ -183,6 +179,25 @@ class TemplateDetailsFragment : } _binding ?: return@launch binding.widgets.adapter = TemplateWidgetsListAdapter(template.widgets) + binding.widgets.post { + scrollGateKeeper?.checkIfReachedEnd() + } } } -} \ No newline at end of file + + private fun updateFinishEnabledState() { + val isCreating = viewModel.creatingProject.value ?: false + val hasScrolledToBottom = scrollGateKeeper?.hasReachedEnd ?: false + + binding.finish.isEnabled = !isCreating && hasScrolledToBottom + binding.scrollIndicator.isVisible = !hasScrolledToBottom + } + + private fun startBlinkingIndicator() { + val animator = ObjectAnimator.ofFloat(binding.scrollIndicator, View.ALPHA, 1f, 0.2f, 1f) + animator.duration = 1200 + animator.interpolator = LinearInterpolator() + animator.repeatCount = ObjectAnimator.INFINITE + animator.start() + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/ProjectCreationManager.kt b/app/src/main/java/com/itsaky/androidide/utils/ProjectCreationManager.kt new file mode 100644 index 0000000000..cdd034e2ef --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectCreationManager.kt @@ -0,0 +1,54 @@ +package com.itsaky.androidide.utils + +import android.content.Context +import com.itsaky.androidide.R +import com.itsaky.androidide.roomData.recentproject.RecentProject +import com.itsaky.androidide.tasks.executeAsyncProvideError +import com.itsaky.androidide.templates.ProjectTemplateRecipeResult +import com.itsaky.androidide.templates.StringParameter +import com.itsaky.androidide.templates.Template +import com.itsaky.androidide.templates.impl.ConstraintVerifier + +class ProjectCreationManager(private val context: Context) { + + fun execute( + template: Template<*>, + onStart: () -> Unit, + onSuccess: (ProjectTemplateRecipeResult, RecentProject) -> Unit, + onError: (String) -> Unit + ) { + val isValid = template.parameters.filterIsInstance().all { param -> + ConstraintVerifier.isValid(param.value, param.constraints) + } + + if (!isValid) { + onError(context.getString(R.string.msg_invalid_project_details)) + return + } + + onStart() + + executeAsyncProvideError({ + template.recipe.execute(TemplateRecipeExecutor(context.applicationContext)) + }) { result, err -> + if (result == null || err != null || result !is ProjectTemplateRecipeResult) { + err?.printStackTrace() + val errorMsg = err?.cause?.message ?: err?.message ?: context.getString(R.string.project_creation_failed) + onError(errorMsg) + return@executeAsyncProvideError + } + + val now = System.currentTimeMillis().toString() + val project = RecentProject( + location = result.data.projectDir.path, + name = result.data.name, + createdAt = now, + lastModified = now, + templateName = template.templateNameStr, + language = result.data.language?.name ?: "unknown" + ) + + onSuccess(result, project) + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/ui/TemplateScrollGateKeeper.kt b/app/src/main/java/com/itsaky/androidide/utils/ui/TemplateScrollGateKeeper.kt new file mode 100644 index 0000000000..2318e396e8 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/ui/TemplateScrollGateKeeper.kt @@ -0,0 +1,96 @@ +package com.itsaky.androidide.utils.ui + +import android.view.View +import android.view.ViewTreeObserver +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView + +/** + * Monitors a [RecyclerView] to detect when the user scrolls to the bottom. + * Once the bottom is reached, the state is locked to `true` until manually reset or the layout width changes. + * + * @param recyclerView The list to monitor. + * @param onScrollStateChanged Callback invoked when the [hasReachedEnd] state changes. + */ +class TemplateScrollGateKeeper( + private val recyclerView: RecyclerView, + private var onScrollStateChanged: (() -> Unit)? +) { + /** + * `true` if the user has scrolled to the bottom of the list at least once. + */ + var hasReachedEnd = false + private set + + private var lastWidth = -1 + + private val scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + checkIfReachedEnd() + } + } + + private val layoutChangeListener = View.OnLayoutChangeListener { _, left, _, right, _, _, _, _, _ -> + val currentWidth = right - left + + if (lastWidth != -1 && lastWidth != currentWidth) { + hasReachedEnd = false + onScrollStateChanged?.invoke() + } + lastWidth = currentWidth + } + + private val globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { + checkIfReachedEnd() + } + + /** + * Attaches scroll and layout listeners to the [RecyclerView]. + */ + fun attach() { + recyclerView.addOnScrollListener(scrollListener) + recyclerView.addOnLayoutChangeListener(layoutChangeListener) + recyclerView.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener) + } + + /** + * Detaches listeners from the [RecyclerView] to prevent memory leaks. + */ + fun detach() { + recyclerView.removeOnScrollListener(scrollListener) + recyclerView.removeOnLayoutChangeListener(layoutChangeListener) + recyclerView.viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener) + onScrollStateChanged = null + } + + /** + * Resets the gatekeeper state and notifies the callback. + */ + fun reset() { + hasReachedEnd = false + lastWidth = -1 + onScrollStateChanged?.invoke() + } + + /** + * Evaluates the scroll position and updates [hasReachedEnd] if the bottom is reached. + */ + fun checkIfReachedEnd() { + if (hasReachedEnd) return + + val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return + val itemCount = layoutManager.itemCount + if (itemCount == 0) return + + val lastVisibleItem = layoutManager.findLastCompletelyVisibleItemPosition() + + if (lastVisibleItem == RecyclerView.NO_POSITION) return + + val isAtBottom = !recyclerView.canScrollVertically(1) + + if (lastVisibleItem >= itemCount - 1 || isAtBottom) { + hasReachedEnd = true + onScrollStateChanged?.invoke() + } + } +} diff --git a/app/src/main/res/layout/fragment_template_details.xml b/app/src/main/res/layout/fragment_template_details.xml index a53fb88baa..7537ae01e5 100644 --- a/app/src/main/res/layout/fragment_template_details.xml +++ b/app/src/main/res/layout/fragment_template_details.xml @@ -67,6 +67,17 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" /> + + Date: Wed, 20 May 2026 08:44:00 -0500 Subject: [PATCH 2/3] fix: possible `IllegalStateException` with `ViewTreeObserver`, clean the `blinkAnimator` object and accessibility --- .../fragments/TemplateDetailsFragment.kt | 16 +++++++++++----- .../utils/ui/TemplateScrollGateKeeper.kt | 4 +++- .../res/layout/fragment_template_details.xml | 1 + 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt index 5221b4205f..5833b48ab4 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt @@ -62,6 +62,7 @@ class TemplateDetailsFragment : private var scrollGateKeeper: TemplateScrollGateKeeper? = null private val projectCreationManager by lazy { ProjectCreationManager(requireContext()) } + private var blinkAnimator: ObjectAnimator? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -75,6 +76,10 @@ class TemplateDetailsFragment : override fun onDestroyView() { super.onDestroyView() + + blinkAnimator?.cancel() + blinkAnimator = null + scrollGateKeeper?.detach() scrollGateKeeper = null } @@ -194,10 +199,11 @@ class TemplateDetailsFragment : } private fun startBlinkingIndicator() { - val animator = ObjectAnimator.ofFloat(binding.scrollIndicator, View.ALPHA, 1f, 0.2f, 1f) - animator.duration = 1200 - animator.interpolator = LinearInterpolator() - animator.repeatCount = ObjectAnimator.INFINITE - animator.start() + blinkAnimator = ObjectAnimator.ofFloat(binding.scrollIndicator, View.ALPHA, 1f, 0.2f, 1f).apply { + duration = 1200 + interpolator = LinearInterpolator() + repeatCount = ObjectAnimator.INFINITE + start() + } } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/ui/TemplateScrollGateKeeper.kt b/app/src/main/java/com/itsaky/androidide/utils/ui/TemplateScrollGateKeeper.kt index 2318e396e8..4eaa56324d 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ui/TemplateScrollGateKeeper.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ui/TemplateScrollGateKeeper.kt @@ -59,7 +59,9 @@ class TemplateScrollGateKeeper( fun detach() { recyclerView.removeOnScrollListener(scrollListener) recyclerView.removeOnLayoutChangeListener(layoutChangeListener) - recyclerView.viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener) + if (recyclerView.viewTreeObserver.isAlive) { + recyclerView.viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener) + } onScrollStateChanged = null } diff --git a/app/src/main/res/layout/fragment_template_details.xml b/app/src/main/res/layout/fragment_template_details.xml index 7537ae01e5..ab13c28567 100644 --- a/app/src/main/res/layout/fragment_template_details.xml +++ b/app/src/main/res/layout/fragment_template_details.xml @@ -71,6 +71,7 @@ android:id="@+id/scrollIndicator" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:importantForAccessibility="no" android:layout_marginEnd="8dp" android:src="@drawable/ic_arrow_down" app:tint="?attr/colorPrimary" From 2fbf11fbb0d0dc0ff3fc9ad14ee2bacdd3ec0634 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 20 May 2026 13:28:57 -0500 Subject: [PATCH 3/3] feat: improve accessibility --- .../itsaky/androidide/fragments/TemplateDetailsFragment.kt | 4 ++++ resources/src/main/res/values/strings.xml | 1 + 2 files changed, 5 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt index 5833b48ab4..b0923ed4ad 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt @@ -45,6 +45,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import android.animation.ObjectAnimator import android.view.animation.LinearInterpolator +import androidx.core.view.ViewCompat import androidx.core.view.isVisible /** @@ -193,9 +194,12 @@ class TemplateDetailsFragment : private fun updateFinishEnabledState() { val isCreating = viewModel.creatingProject.value ?: false val hasScrolledToBottom = scrollGateKeeper?.hasReachedEnd ?: false + val canFinish = !isCreating && hasScrolledToBottom + val stateDesc = if (canFinish) null else getString(string.msg_scroll_to_create_project) binding.finish.isEnabled = !isCreating && hasScrolledToBottom binding.scrollIndicator.isVisible = !hasScrolledToBottom + ViewCompat.setStateDescription(binding.finish, stateDesc) } private fun startBlinkingIndicator() { diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 68b921ff98..834fc761c1 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -229,6 +229,7 @@ Search Results Project created successfully! Failed to create project + Scroll to bottom to create the project Failed to create folder Failed to create file Failed to create layout file