Skip to content

Commit ffba855

Browse files
authored
ADFA-3718 | Require scrolling to end before project creation (#1321)
* 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. * fix: possible `IllegalStateException` with `ViewTreeObserver`, clean the `blinkAnimator` object and accessibility * feat: improve accessibility
1 parent ada6cd5 commit ffba855

5 files changed

Lines changed: 258 additions & 68 deletions

File tree

app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt

Lines changed: 93 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,21 @@ import com.itsaky.androidide.idetooltips.TooltipManager
3232
import com.itsaky.androidide.idetooltips.TooltipTag.SETUP_CREATE_PROJECT
3333
import com.itsaky.androidide.idetooltips.TooltipTag.SETUP_OVERVIEW
3434
import com.itsaky.androidide.idetooltips.TooltipTag.SETUP_PREVIOUS
35-
import com.itsaky.androidide.roomData.recentproject.RecentProject
36-
import com.itsaky.androidide.tasks.executeAsyncProvideError
3735
import com.itsaky.androidide.templates.ParameterWidget
38-
import com.itsaky.androidide.templates.ProjectTemplateRecipeResult
39-
import com.itsaky.androidide.templates.StringParameter
4036
import com.itsaky.androidide.templates.Template
41-
import com.itsaky.androidide.templates.impl.ConstraintVerifier
42-
import com.itsaky.androidide.utils.TemplateRecipeExecutor
37+
import com.itsaky.androidide.utils.ProjectCreationManager
38+
import com.itsaky.androidide.utils.ui.TemplateScrollGateKeeper
4339
import com.itsaky.androidide.utils.flashError
4440
import com.itsaky.androidide.utils.flashSuccess
4541
import com.itsaky.androidide.viewmodel.MainViewModel
4642
import kotlinx.coroutines.Dispatchers
4743
import kotlinx.coroutines.Job
4844
import kotlinx.coroutines.launch
4945
import kotlinx.coroutines.withContext
46+
import android.animation.ObjectAnimator
47+
import android.view.animation.LinearInterpolator
48+
import androidx.core.view.ViewCompat
49+
import androidx.core.view.isVisible
5050

5151
/**
5252
* A fragment which shows a wizard-like interface for creating templates.
@@ -61,24 +61,65 @@ class TemplateDetailsFragment :
6161
private val viewModel by activityViewModel<MainViewModel>()
6262
private var widgetsBindJob: Job? = null
6363

64+
private var scrollGateKeeper: TemplateScrollGateKeeper? = null
65+
private val projectCreationManager by lazy { ProjectCreationManager(requireContext()) }
66+
private var blinkAnimator: ObjectAnimator? = null
67+
6468
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
6569
super.onViewCreated(view, savedInstanceState)
6670

71+
setupRecyclerView()
72+
setupTooltips()
73+
setupObservers()
74+
setupClickListeners()
75+
startBlinkingIndicator()
76+
}
77+
78+
override fun onDestroyView() {
79+
super.onDestroyView()
80+
81+
blinkAnimator?.cancel()
82+
blinkAnimator = null
83+
84+
scrollGateKeeper?.detach()
85+
scrollGateKeeper = null
86+
}
87+
88+
private fun setupRecyclerView() {
89+
binding.widgets.layoutManager = LinearLayoutManager(requireContext())
90+
91+
scrollGateKeeper = TemplateScrollGateKeeper(binding.widgets) {
92+
updateFinishEnabledState()
93+
}
94+
scrollGateKeeper?.attach()
95+
}
96+
97+
private fun setupObservers() {
6798
viewModel.template.observe(viewLifecycleOwner) {
6899
binding.widgets.adapter = null
100+
scrollGateKeeper?.reset()
101+
updateFinishEnabledState()
69102
viewModel.postTransition(viewLifecycleOwner) { bindWithTemplate(it) }
70103
}
71104

72-
viewModel.creatingProject.observe(viewLifecycleOwner) {
105+
viewModel.creatingProject.observe(viewLifecycleOwner) { isCreating ->
73106
TransitionManager.beginDelayedTransition(binding.root)
74-
binding.finish.isEnabled = !it
75-
binding.previous.isEnabled = !it
107+
updateFinishEnabledState()
108+
binding.previous.isEnabled = !isCreating
76109
}
110+
}
77111

112+
private fun setupClickListeners() {
78113
binding.previous.setOnClickListener {
79114
viewModel.setScreen(MainViewModel.SCREEN_TEMPLATE_LIST)
80115
}
81116

117+
binding.finish.setOnClickListener {
118+
handleProjectCreation()
119+
}
120+
}
121+
122+
private fun setupTooltips() {
82123
binding.previous.setOnLongClickListener {
83124
TooltipManager.showIdeCategoryTooltip(requireContext(), it, SETUP_PREVIOUS)
84125
true
@@ -89,59 +130,26 @@ class TemplateDetailsFragment :
89130
true
90131
}
91132

92-
binding.finish.setOnClickListener {
93-
viewModel.creatingProject.value = true
94-
val template = viewModel.template.value ?: run {
95-
viewModel.setScreen(MainViewModel.SCREEN_MAIN)
96-
return@setOnClickListener
97-
}
98-
99-
val isValid = template.parameters.fold(true) { isValid, param ->
100-
if (param is StringParameter) {
101-
return@fold isValid && ConstraintVerifier.isValid(
102-
param.value,
103-
param.constraints
104-
)
105-
} else isValid
106-
}
107-
108-
if (!isValid) {
109-
viewModel.creatingProject.value = false
110-
flashError(string.msg_invalid_project_details)
111-
return@setOnClickListener
112-
}
133+
binding.title.setOnLongClickListener {
134+
TooltipManager.showIdeCategoryTooltip(requireContext(), binding.root, SETUP_OVERVIEW)
135+
true
136+
}
137+
}
113138

114-
viewModel.creatingProject.value = true
115-
val appContext = requireContext().applicationContext
116-
executeAsyncProvideError({
117-
template.recipe.execute(TemplateRecipeExecutor(appContext))
118-
}) { result, err ->
139+
private fun handleProjectCreation() {
140+
val template = viewModel.template.value ?: run {
141+
viewModel.setScreen(MainViewModel.SCREEN_MAIN)
142+
return
143+
}
119144

145+
projectCreationManager.execute(
146+
template = template,
147+
onStart = { viewModel.creatingProject.value = true },
148+
onSuccess = { result, project ->
120149
viewModel.creatingProject.value = false
121-
if (result == null || err != null || result !is ProjectTemplateRecipeResult) {
122-
err?.printStackTrace()
123-
if (err != null) {
124-
flashError(err.cause?.message ?: err.message)
125-
} else {
126-
flashError(string.project_creation_failed)
127-
}
128-
return@executeAsyncProvideError
129-
}
130-
131150
viewModel.setScreen(MainViewModel.SCREEN_MAIN)
132151
flashSuccess(string.project_created_successfully)
133152

134-
val now = System.currentTimeMillis().toString()
135-
136-
val project = RecentProject(
137-
location = result.data.projectDir.path,
138-
name = result.data.name,
139-
createdAt = now,
140-
lastModified = now,
141-
templateName = template.templateNameStr,
142-
language = result.data.language?.name ?: "unknown"
143-
)
144-
145153
viewModel.postTransition(viewLifecycleOwner) {
146154
// open the project
147155
(requireActivity() as MainActivity).openProject(
@@ -150,18 +158,12 @@ class TemplateDetailsFragment :
150158
hasTemplateIssues = result.hasErrorsWarnings
151159
)
152160
}
161+
},
162+
onError = { errorMsg ->
163+
viewModel.creatingProject.value = false
164+
flashError(errorMsg)
153165
}
154-
}
155-
156-
binding.widgets.layoutManager = LinearLayoutManager(requireContext())
157-
158-
binding.title.setOnLongClickListener {
159-
TooltipManager.showIdeCategoryTooltip(
160-
requireContext(), binding.root,
161-
SETUP_OVERVIEW
162-
)
163-
true
164-
}
166+
)
165167
}
166168

167169
private fun bindWithTemplate(template: Template<*>?) {
@@ -183,6 +185,29 @@ class TemplateDetailsFragment :
183185
}
184186
_binding ?: return@launch
185187
binding.widgets.adapter = TemplateWidgetsListAdapter(template.widgets)
188+
binding.widgets.post {
189+
scrollGateKeeper?.checkIfReachedEnd()
190+
}
191+
}
192+
}
193+
194+
private fun updateFinishEnabledState() {
195+
val isCreating = viewModel.creatingProject.value ?: false
196+
val hasScrolledToBottom = scrollGateKeeper?.hasReachedEnd ?: false
197+
val canFinish = !isCreating && hasScrolledToBottom
198+
val stateDesc = if (canFinish) null else getString(string.msg_scroll_to_create_project)
199+
200+
binding.finish.isEnabled = !isCreating && hasScrolledToBottom
201+
binding.scrollIndicator.isVisible = !hasScrolledToBottom
202+
ViewCompat.setStateDescription(binding.finish, stateDesc)
203+
}
204+
205+
private fun startBlinkingIndicator() {
206+
blinkAnimator = ObjectAnimator.ofFloat(binding.scrollIndicator, View.ALPHA, 1f, 0.2f, 1f).apply {
207+
duration = 1200
208+
interpolator = LinearInterpolator()
209+
repeatCount = ObjectAnimator.INFINITE
210+
start()
186211
}
187212
}
188-
}
213+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.itsaky.androidide.utils
2+
3+
import android.content.Context
4+
import com.itsaky.androidide.R
5+
import com.itsaky.androidide.roomData.recentproject.RecentProject
6+
import com.itsaky.androidide.tasks.executeAsyncProvideError
7+
import com.itsaky.androidide.templates.ProjectTemplateRecipeResult
8+
import com.itsaky.androidide.templates.StringParameter
9+
import com.itsaky.androidide.templates.Template
10+
import com.itsaky.androidide.templates.impl.ConstraintVerifier
11+
12+
class ProjectCreationManager(private val context: Context) {
13+
14+
fun execute(
15+
template: Template<*>,
16+
onStart: () -> Unit,
17+
onSuccess: (ProjectTemplateRecipeResult, RecentProject) -> Unit,
18+
onError: (String) -> Unit
19+
) {
20+
val isValid = template.parameters.filterIsInstance<StringParameter>().all { param ->
21+
ConstraintVerifier.isValid(param.value, param.constraints)
22+
}
23+
24+
if (!isValid) {
25+
onError(context.getString(R.string.msg_invalid_project_details))
26+
return
27+
}
28+
29+
onStart()
30+
31+
executeAsyncProvideError({
32+
template.recipe.execute(TemplateRecipeExecutor(context.applicationContext))
33+
}) { result, err ->
34+
if (result == null || err != null || result !is ProjectTemplateRecipeResult) {
35+
err?.printStackTrace()
36+
val errorMsg = err?.cause?.message ?: err?.message ?: context.getString(R.string.project_creation_failed)
37+
onError(errorMsg)
38+
return@executeAsyncProvideError
39+
}
40+
41+
val now = System.currentTimeMillis().toString()
42+
val project = RecentProject(
43+
location = result.data.projectDir.path,
44+
name = result.data.name,
45+
createdAt = now,
46+
lastModified = now,
47+
templateName = template.templateNameStr,
48+
language = result.data.language?.name ?: "unknown"
49+
)
50+
51+
onSuccess(result, project)
52+
}
53+
}
54+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.itsaky.androidide.utils.ui
2+
3+
import android.view.View
4+
import android.view.ViewTreeObserver
5+
import androidx.recyclerview.widget.LinearLayoutManager
6+
import androidx.recyclerview.widget.RecyclerView
7+
8+
/**
9+
* Monitors a [RecyclerView] to detect when the user scrolls to the bottom.
10+
* Once the bottom is reached, the state is locked to `true` until manually reset or the layout width changes.
11+
*
12+
* @param recyclerView The list to monitor.
13+
* @param onScrollStateChanged Callback invoked when the [hasReachedEnd] state changes.
14+
*/
15+
class TemplateScrollGateKeeper(
16+
private val recyclerView: RecyclerView,
17+
private var onScrollStateChanged: (() -> Unit)?
18+
) {
19+
/**
20+
* `true` if the user has scrolled to the bottom of the list at least once.
21+
*/
22+
var hasReachedEnd = false
23+
private set
24+
25+
private var lastWidth = -1
26+
27+
private val scrollListener = object : RecyclerView.OnScrollListener() {
28+
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
29+
checkIfReachedEnd()
30+
}
31+
}
32+
33+
private val layoutChangeListener = View.OnLayoutChangeListener { _, left, _, right, _, _, _, _, _ ->
34+
val currentWidth = right - left
35+
36+
if (lastWidth != -1 && lastWidth != currentWidth) {
37+
hasReachedEnd = false
38+
onScrollStateChanged?.invoke()
39+
}
40+
lastWidth = currentWidth
41+
}
42+
43+
private val globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
44+
checkIfReachedEnd()
45+
}
46+
47+
/**
48+
* Attaches scroll and layout listeners to the [RecyclerView].
49+
*/
50+
fun attach() {
51+
recyclerView.addOnScrollListener(scrollListener)
52+
recyclerView.addOnLayoutChangeListener(layoutChangeListener)
53+
recyclerView.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener)
54+
}
55+
56+
/**
57+
* Detaches listeners from the [RecyclerView] to prevent memory leaks.
58+
*/
59+
fun detach() {
60+
recyclerView.removeOnScrollListener(scrollListener)
61+
recyclerView.removeOnLayoutChangeListener(layoutChangeListener)
62+
if (recyclerView.viewTreeObserver.isAlive) {
63+
recyclerView.viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener)
64+
}
65+
onScrollStateChanged = null
66+
}
67+
68+
/**
69+
* Resets the gatekeeper state and notifies the callback.
70+
*/
71+
fun reset() {
72+
hasReachedEnd = false
73+
lastWidth = -1
74+
onScrollStateChanged?.invoke()
75+
}
76+
77+
/**
78+
* Evaluates the scroll position and updates [hasReachedEnd] if the bottom is reached.
79+
*/
80+
fun checkIfReachedEnd() {
81+
if (hasReachedEnd) return
82+
83+
val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return
84+
val itemCount = layoutManager.itemCount
85+
if (itemCount == 0) return
86+
87+
val lastVisibleItem = layoutManager.findLastCompletelyVisibleItemPosition()
88+
89+
if (lastVisibleItem == RecyclerView.NO_POSITION) return
90+
91+
val isAtBottom = !recyclerView.canScrollVertically(1)
92+
93+
if (lastVisibleItem >= itemCount - 1 || isAtBottom) {
94+
hasReachedEnd = true
95+
onScrollStateChanged?.invoke()
96+
}
97+
}
98+
}

app/src/main/res/layout/fragment_template_details.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@
6767
app:layout_constraintBottom_toBottomOf="parent"
6868
app:layout_constraintStart_toStartOf="parent" />
6969

70+
<ImageView
71+
android:id="@+id/scrollIndicator"
72+
android:layout_width="wrap_content"
73+
android:layout_height="wrap_content"
74+
android:importantForAccessibility="no"
75+
android:layout_marginEnd="8dp"
76+
android:src="@drawable/ic_arrow_down"
77+
app:tint="?attr/colorPrimary"
78+
app:layout_constraintEnd_toStartOf="@id/finish"
79+
app:layout_constraintTop_toTopOf="@id/finish"
80+
app:layout_constraintBottom_toBottomOf="@id/finish" />
81+
7082
<com.google.android.material.button.MaterialButton
7183
android:id="@+id/finish"
7284
android:layout_width="wrap_content"

0 commit comments

Comments
 (0)