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 b0923ed4ad..e1ff047d4c 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt @@ -17,10 +17,15 @@ package com.itsaky.androidide.fragments +import android.animation.ObjectAnimator import android.os.Bundle import android.view.View +import android.view.animation.LinearInterpolator +import androidx.core.view.ViewCompat +import androidx.core.view.isVisible +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope -import org.koin.androidx.viewmodel.ext.android.activityViewModel import androidx.recyclerview.widget.LinearLayoutManager import androidx.transition.TransitionManager import com.itsaky.androidide.R @@ -35,18 +40,15 @@ import com.itsaky.androidide.idetooltips.TooltipTag.SETUP_PREVIOUS import com.itsaky.androidide.templates.ParameterWidget import com.itsaky.androidide.templates.Template 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.utils.ui.TemplateScrollGateKeeper 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 +import org.koin.androidx.viewmodel.ext.android.activityViewModel /** * A fragment which shows a wizard-like interface for creating templates. @@ -54,160 +56,245 @@ import androidx.core.view.isVisible * @author Akash Yadav */ class TemplateDetailsFragment : - FragmentWithBinding( - R.layout.fragment_template_details, FragmentTemplateDetailsBinding::bind - ) { - - private val viewModel by activityViewModel() - private var widgetsBindJob: Job? = null - - 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) - - 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) { isCreating -> - TransitionManager.beginDelayedTransition(binding.root) - 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 - } - - binding.finish.setOnLongClickListener { - TooltipManager.showIdeCategoryTooltip(requireContext(), it, SETUP_CREATE_PROJECT) - true - } - - binding.title.setOnLongClickListener { - TooltipManager.showIdeCategoryTooltip(requireContext(), binding.root, SETUP_OVERVIEW) - true - } - } - - 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 - viewModel.setScreen(MainViewModel.SCREEN_MAIN) - flashSuccess(string.project_created_successfully) - - viewModel.postTransition(viewLifecycleOwner) { - // open the project - (requireActivity() as MainActivity).openProject( - result.data.projectDir, - project = project, - hasTemplateIssues = result.hasErrorsWarnings - ) - } - }, - onError = { errorMsg -> - viewModel.creatingProject.value = false - flashError(errorMsg) - } - ) - } - - private fun bindWithTemplate(template: Template<*>?) { - template ?: return - - binding.title.text = template.templateNameStr - - // Some parameters do disk work in their beforeCreateView hook (e.g. computing a - // non-colliding default project name). Run those hooks on Dispatchers.IO before - // attaching the adapter so that onBindViewHolder skips them (they are one-shot). - widgetsBindJob?.cancel() - widgetsBindJob = viewLifecycleOwner.lifecycleScope.launch { - withContext(Dispatchers.IO) { - template.widgets.forEach { widget -> - if (widget is ParameterWidget<*>) { - widget.parameter.beforeCreateView() - } - } - } - _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() - } - } + FragmentWithBinding( + R.layout.fragment_template_details, + FragmentTemplateDetailsBinding::bind, + ) { + private val viewModel by activityViewModel() + private var widgetsBindJob: Job? = null + + private var scrollGateKeeper: TemplateScrollGateKeeper? = null + private val projectCreationManager by lazy { ProjectCreationManager(requireContext()) } + private var blinkAnimator: ObjectAnimator? = null + + /** Whether this fragment's view is started, one of the three [shouldBlinkScrollIndicator] inputs. */ + private var isViewStarted = false + + /** Keeps [isViewStarted] current and re-evaluates the blink whenever the view starts or stops. */ + private val blinkWhileOnScreen = + object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + isViewStarted = true + updateBlinkState() + } + + override fun onStop(owner: LifecycleOwner) { + isViewStarted = false + updateBlinkState() + } + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + setupRecyclerView() + setupTooltips() + setupObservers() + setupClickListeners() + viewLifecycleOwner.lifecycle.addObserver(blinkWhileOnScreen) + } + + override fun onDestroyView() { + /* + * [blinkWhileOnScreen] is what guarantees the animator is gone: performDestroyView drives the + * view lifecycle to DESTROYED before calling here, and that backward pass dispatches ON_STOP. + * This call is a no-op belt-and-braces for a teardown that skipped the observer; it still has + * to precede super, since FragmentWithBinding.onDestroyView nulls _binding before calling up. + */ + stopBlinkingIndicator() + + scrollGateKeeper?.detach() + scrollGateKeeper = null + + super.onDestroyView() + } + + private fun setupRecyclerView() { + binding.widgets.layoutManager = LinearLayoutManager(requireContext()) + + scrollGateKeeper = + TemplateScrollGateKeeper(binding.widgets) { + updateFinishEnabledState() + } + scrollGateKeeper?.attach() + } + + private fun setupObservers() { + viewModel.currentScreen.observe(viewLifecycleOwner) { updateBlinkState() } + + viewModel.template.observe(viewLifecycleOwner) { + binding.widgets.adapter = null + scrollGateKeeper?.reset() + updateFinishEnabledState() + viewModel.postTransition(viewLifecycleOwner) { bindWithTemplate(it) } + } + + viewModel.creatingProject.observe(viewLifecycleOwner) { isCreating -> + TransitionManager.beginDelayedTransition(binding.root) + 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 + } + + binding.finish.setOnLongClickListener { + TooltipManager.showIdeCategoryTooltip(requireContext(), it, SETUP_CREATE_PROJECT) + true + } + + binding.title.setOnLongClickListener { + TooltipManager.showIdeCategoryTooltip(requireContext(), binding.root, SETUP_OVERVIEW) + true + } + } + + 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 + viewModel.setScreen(MainViewModel.SCREEN_MAIN) + flashSuccess(string.project_created_successfully) + + viewModel.postTransition(viewLifecycleOwner) { + // open the project + (requireActivity() as MainActivity).openProject( + result.data.projectDir, + project = project, + hasTemplateIssues = result.hasErrorsWarnings, + ) + } + }, + onError = { errorMsg -> + viewModel.creatingProject.value = false + flashError(errorMsg) + }, + ) + } + + private fun bindWithTemplate(template: Template<*>?) { + template ?: return + + binding.title.text = template.templateNameStr + + // Some parameters do disk work in their beforeCreateView hook (e.g. computing a + // non-colliding default project name). Run those hooks on Dispatchers.IO before + // attaching the adapter so that onBindViewHolder skips them (they are one-shot). + widgetsBindJob?.cancel() + widgetsBindJob = + viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { + template.widgets.forEach { widget -> + if (widget is ParameterWidget<*>) { + widget.parameter.beforeCreateView() + } + } + } + _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) + + updateBlinkState() + } + + /** + * Starts or stops the blink to match [shouldBlinkScrollIndicator]. + */ + private fun updateBlinkState() { + val shouldBlink = + shouldBlinkScrollIndicator( + isViewStarted = isViewStarted, + currentScreen = viewModel.currentScreen.value, + isIndicatorVisible = _binding?.scrollIndicator?.isVisible == true, + ) + + if (shouldBlink) { + startBlinkingIndicator() + } else { + stopBlinkingIndicator() + } + } + + private fun startBlinkingIndicator() { + if (blinkAnimator != null) { + return + } + + blinkAnimator = + ObjectAnimator.ofFloat(binding.scrollIndicator, View.ALPHA, 1f, 0.2f, 1f).apply { + duration = 1200 + interpolator = LinearInterpolator() + repeatCount = ObjectAnimator.INFINITE + start() + } + } + + private fun stopBlinkingIndicator() { + blinkAnimator?.cancel() + blinkAnimator = null + + // cancelling mid-repeat leaves the indicator at whatever alpha it had reached + _binding?.scrollIndicator?.alpha = 1f + } } + +/** + * Whether the scroll indicator's blink should be running. + * + * The blink repeats forever, and an endlessly repeating animator keeps the main thread's + * Choreographer loop alive process-wide: it re-posts a vsync callback every frame whether or not + * anything is drawn, and it does not pause when its activity merely stops. So it may run only + * while the indicator can actually be seen, which takes all three inputs here. + * + * A started view lifecycle is not on its own a proxy for that. `activity_main.xml` declares every + * one of MainActivity's screens as a sibling container in one `FrameLayout`, and switching screens + * only flips their `View` visibility, so this fragment's view reaches `STARTED` at cold start and + * stays there for as long as MainActivity is started -- including the whole time the user sits on + * the project list having never opened the new-project flow. The indicator also hides itself once + * the form has been scrolled to the bottom, which is a third way for it to be off screen while + * this screen is the current one. + */ +internal fun shouldBlinkScrollIndicator( + isViewStarted: Boolean, + currentScreen: Int?, + isIndicatorVisible: Boolean, +): Boolean = isViewStarted && currentScreen == MainViewModel.SCREEN_TEMPLATE_DETAILS && isIndicatorVisible diff --git a/app/src/test/java/com/itsaky/androidide/fragments/ScrollIndicatorBlinkTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/ScrollIndicatorBlinkTest.kt new file mode 100644 index 0000000000..2c5a1320e7 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/fragments/ScrollIndicatorBlinkTest.kt @@ -0,0 +1,114 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.fragments + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.viewmodel.MainViewModel +import org.junit.Test + +/** + * The three inputs that decide whether the template scroll indicator's endless blink may run, as + * documented on [shouldBlinkScrollIndicator]. + * + * Scope: this pins the decision only. Of the three call sites that re-evaluate it, + * [TemplateDetailsBlinkLifecycleTest] pins the view-lifecycle observer and the `currentScreen` + * observer; the `updateFinishEnabledState` one, which needs a real scroll to the form's end, is not + * pinned. + */ +class ScrollIndicatorBlinkTest { + @Test + fun `blinks only when started, on the details screen, with the indicator showing`() { + assertThat( + shouldBlinkScrollIndicator( + isViewStarted = true, + currentScreen = MainViewModel.SCREEN_TEMPLATE_DETAILS, + isIndicatorVisible = true, + ), + ).isTrue() + } + + @Test + fun `does not blink behind another screen sharing the container`() { + val offScreens = + listOf( + MainViewModel.SCREEN_MAIN, + MainViewModel.SCREEN_TEMPLATE_LIST, + MainViewModel.SCREEN_SAVED_PROJECTS, + MainViewModel.SCREEN_DELETE_PROJECTS, + MainViewModel.SCREEN_CLONE_REPO, + MainViewModel.TOOLTIPS_WEB_VIEW, + ) + + for (screen in offScreens) { + assertThat( + shouldBlinkScrollIndicator( + isViewStarted = true, + currentScreen = screen, + isIndicatorVisible = true, + ), + ).isFalse() + } + } + + @Test + fun `does not blink while the screen sentinel is unset`() { + /* + * -1, not null: MainViewModel backs currentScreen with MutableLiveData(-1) and documents -1 + * as the "no screen yet" sentinel, so the value is never null. It is live between + * MainActivity's setContentView, which creates this fragment, and its first setScreen. + */ + assertThat( + shouldBlinkScrollIndicator( + isViewStarted = true, + currentScreen = -1, + isIndicatorVisible = true, + ), + ).isFalse() + + // null only because LiveData.getValue() is platform-nullable at the call site + assertThat( + shouldBlinkScrollIndicator( + isViewStarted = true, + currentScreen = null, + isIndicatorVisible = true, + ), + ).isFalse() + } + + @Test + fun `does not blink once the form has been scrolled to the bottom`() { + assertThat( + shouldBlinkScrollIndicator( + isViewStarted = true, + currentScreen = MainViewModel.SCREEN_TEMPLATE_DETAILS, + isIndicatorVisible = false, + ), + ).isFalse() + } + + @Test + fun `does not blink while the view is stopped`() { + assertThat( + shouldBlinkScrollIndicator( + isViewStarted = false, + currentScreen = MainViewModel.SCREEN_TEMPLATE_DETAILS, + isIndicatorVisible = true, + ), + ).isFalse() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/fragments/TemplateDetailsBlinkLifecycleTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/TemplateDetailsBlinkLifecycleTest.kt new file mode 100644 index 0000000000..cac29c81d4 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/fragments/TemplateDetailsBlinkLifecycleTest.kt @@ -0,0 +1,130 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.fragments + +import android.animation.ObjectAnimator +import androidx.appcompat.app.AppCompatActivity +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.BaseApplication +import com.itsaky.androidide.viewmodel.MainViewModel +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.GlobalContext +import org.koin.core.context.GlobalContext.startKoin +import org.koin.core.context.GlobalContext.stopKoin +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Pins the wiring that feeds [shouldBlinkScrollIndicator]: the indicator's endlessly repeating + * animator must exist only while the fragment's view is started and this screen is current. + * + * The animator re-posts a vsync callback every frame for as long as it lives, so an animator that + * outlives the view being on screen burns frames for the rest of the process. Pre-fix the blink + * started once in `onViewCreated` and was only cancelled in `onDestroyView`, so both cases here go + * red: leaving the screen and stopping the activity each left it running. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = TemplateDetailsBlinkLifecycleTest.TestApp::class) +class TemplateDetailsBlinkLifecycleTest { + open class TestApp : BaseApplication() + + private var startedKoin = false + + /* + * activityViewModel resolves through Koin, so a context has to exist. Another + * test's application may already have started one, in which case startKoin would throw + * KoinApplicationAlreadyStartedException -- join that context and leave it to its owner. + */ + @Before + fun setUp() { + val binding = module { viewModel { MainViewModel() } } + val existing = GlobalContext.getOrNull() + if (existing == null) { + startedKoin = true + startKoin { modules(binding) } + } else { + existing.loadModules(listOf(binding)) + } + } + + @After + fun tearDown() { + if (startedKoin) { + stopKoin() + } + } + + @Test + fun `blink stops when the view lifecycle stops`() { + val controller = Robolectric.buildActivity(AppCompatActivity::class.java).setup() + val fragment = controller.get().showTemplateDetails() + + assertThat(fragment.blinkAnimator()?.isRunning).isTrue() + + controller.pause().stop() + + assertThat(fragment.blinkAnimator()).isNull() + } + + @Test + fun `blink stops when another screen becomes current`() { + val controller = Robolectric.buildActivity(AppCompatActivity::class.java).setup() + val fragment = controller.get().showTemplateDetails() + + assertThat(fragment.blinkAnimator()?.isRunning).isTrue() + + fragment.viewModel().setScreen(MainViewModel.SCREEN_MAIN) + + assertThat(fragment.blinkAnimator()).isNull() + } + + private fun AppCompatActivity.showTemplateDetails(): TemplateDetailsFragment { + val fragment = TemplateDetailsFragment() + supportFragmentManager + .beginTransaction() + .add(android.R.id.content, fragment) + .commitNow() + + fragment.viewModel().setScreen(MainViewModel.SCREEN_TEMPLATE_DETAILS) + return fragment + } + + private fun TemplateDetailsFragment.viewModel(): MainViewModel = + readPrivate("viewModel\$delegate").let { delegate -> + @Suppress("UNCHECKED_CAST") + (delegate as Lazy).value + } + + private fun TemplateDetailsFragment.blinkAnimator(): ObjectAnimator? = readPrivate("blinkAnimator") as ObjectAnimator? + + /* + * The animator field and the view-model delegate are private production state with no reason to + * be otherwise; reading them reflectively keeps the test-only access in the test. + */ + private fun TemplateDetailsFragment.readPrivate(name: String): Any? = + TemplateDetailsFragment::class.java + .getDeclaredField(name) + .apply { isAccessible = true } + .get(this) +}