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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,21 @@ 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
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.ViewCompat
import androidx.core.view.isVisible

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

private var scrollGateKeeper: TemplateScrollGateKeeper? = null
private val projectCreationManager by lazy { ProjectCreationManager(requireContext()) }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
private var blinkAnimator: ObjectAnimator? = null

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

setupRecyclerView()
setupTooltips()
setupObservers()
setupClickListeners()
startBlinkingIndicator()
}

override fun onDestroyView() {
super.onDestroyView()

blinkAnimator?.cancel()
blinkAnimator = null

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
Expand All @@ -89,59 +130,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(
Expand All @@ -150,18 +158,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<*>?) {
Expand All @@ -183,6 +185,29 @@ class TemplateDetailsFragment :
}
_binding ?: return@launch
binding.widgets.adapter = TemplateWidgetsListAdapter(template.widgets)
binding.widgets.post {
scrollGateKeeper?.checkIfReachedEnd()
}
}
}

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() {
blinkAnimator = ObjectAnimator.ofFloat(binding.scrollIndicator, View.ALPHA, 1f, 0.2f, 1f).apply {
duration = 1200
interpolator = LinearInterpolator()
repeatCount = ObjectAnimator.INFINITE
start()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<StringParameter>().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)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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(
Comment thread
jatezzz marked this conversation as resolved.
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)
if (recyclerView.viewTreeObserver.isAlive) {
recyclerView.viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener)
}
onScrollStateChanged = null
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* 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()
}
}
}
12 changes: 12 additions & 0 deletions app/src/main/res/layout/fragment_template_details.xml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />

<ImageView
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"
app:layout_constraintEnd_toStartOf="@id/finish"
app:layout_constraintTop_toTopOf="@id/finish"
app:layout_constraintBottom_toBottomOf="@id/finish" />
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<com.google.android.material.button.MaterialButton
android:id="@+id/finish"
android:layout_width="wrap_content"
Expand Down
Loading
Loading