From dca90cc2e7633b80e377394e89f88e4419a09101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Wed, 29 Jul 2026 17:50:43 +0200 Subject: [PATCH 01/28] add config-driven onboarding dialog data model --- .../ui/page/configdriven/ContentConfig.kt | 45 ++++++++++++++++ .../ui/page/configdriven/DialogConfig.kt | 54 +++++++++++++++++++ .../ui/page/configdriven/TextConfig.kt | 31 +++++++++++ .../ui/page/configdriven/ContentConfigTest.kt | 45 ++++++++++++++++ .../ui/page/configdriven/TextConfigTest.kt | 40 ++++++++++++++ 5 files changed, 215 insertions(+) create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentConfig.kt create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfig.kt create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/TextConfig.kt create mode 100644 app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentConfigTest.kt create mode 100644 app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/TextConfigTest.kt diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentConfig.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentConfig.kt new file mode 100644 index 000000000000..6a12cc6ed3c6 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentConfig.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven + +import com.duckduckgo.app.browser.omnibar.OmnibarType +import com.duckduckgo.app.onboarding.ui.page.ComparisonChartConfig + +/** A screen with working state the user edits before submitting. */ +interface Stateful { + fun initialState(): S +} + +sealed interface ContentConfig { + + val title: TextConfig + + data class ComparisonChart( + override val title: TextConfig, + val config: ComparisonChartConfig, + ) : ContentConfig + + data class AddressBar( + override val title: TextConfig, + val initialPosition: OmnibarType, + val showSplitOption: Boolean, + ) : ContentConfig, Stateful { + override fun initialState() = AddressBarContentState(position = initialPosition) + } +} + +data class AddressBarContentState(val position: OmnibarType) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfig.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfig.kt new file mode 100644 index 000000000000..2a498c22ec62 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfig.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven + +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingEvent +import com.duckduckgo.app.onboarding.orchestrator.StepProgress +import com.duckduckgo.app.onboarding.ui.page.OnboardingBackgroundStep + +/** + * Everything that makes one onboarding dialog different from another. Plain, value-comparable data: equality + * drives the render engine's diff, so it must never hold views or lambdas over view state. + */ +data class DialogConfig( + val background: OnboardingBackgroundStep, + val embellishment: Embellishment = Embellishment.None, + val cardArrow: CardArrowConfig = CardArrowConfig.Hidden, + val content: ContentConfig, + val primaryCta: CtaConfig? = null, + val secondaryCta: CtaConfig? = null, + val stepIndicator: StepProgress? = null, +) + +/** The animated stage decoration accompanying a dialog. A runtime fit check may still hide it. */ +enum class Embellishment { WalkingDax, BobbingDax, BottomWing, LeftWing, None } + +enum class CardArrowConfig { Hidden, AtStart, AtEnd } + +data class CtaConfig( + val text: TextConfig, + val action: CtaAction, +) + +sealed interface CtaAction { + + /** Forwards [event] to the orchestrator as-is. */ + data class Emit(val event: NewUserOnboardingEvent) : CtaAction + + /** Asks the bound screen to build the event from its live state, via [ContentHandle.result]. */ + data object Submit : CtaAction +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/TextConfig.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/TextConfig.kt new file mode 100644 index 000000000000..04f4210ba1eb --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/TextConfig.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven + +import android.content.Context +import androidx.annotation.StringRes + +sealed interface TextConfig { + + data class Resource(@StringRes val resId: Int) : TextConfig + data class Literal(val text: String) : TextConfig + + fun resolve(context: Context): String = when (this) { + is Resource -> context.getString(resId) + is Literal -> text + } +} diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentConfigTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentConfigTest.kt new file mode 100644 index 000000000000..55ddfa21a7c5 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentConfigTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven + +import com.duckduckgo.app.browser.omnibar.OmnibarType +import com.duckduckgo.app.onboarding.ui.page.ComparisonChartConfig +import org.junit.Assert.assertEquals +import org.junit.Test + +class ContentConfigTest { + + @Test + fun `address bar seeds its state from the configured initial position`() { + val content = ContentConfig.AddressBar( + title = TextConfig.Literal("title"), + initialPosition = OmnibarType.SINGLE_BOTTOM, + showSplitOption = true, + ) + + assertEquals(AddressBarContentState(position = OmnibarType.SINGLE_BOTTOM), content.initialState()) + } + + @Test + fun `configs with the same values are equal`() { + val config = ComparisonChartConfig.Browser(isCustomAiCopy = false) + val first = ContentConfig.ComparisonChart(title = TextConfig.Resource(1), config = config) + val second = ContentConfig.ComparisonChart(title = TextConfig.Resource(1), config = config) + + assertEquals(first, second) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/TextConfigTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/TextConfigTest.kt new file mode 100644 index 000000000000..5b85f394c28b --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/TextConfigTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class TextConfigTest { + + private val context: Context = mock() + + @Test + fun `resolves a string resource through the context`() { + whenever(context.getString(42)).thenReturn("resolved") + + assertEquals("resolved", TextConfig.Resource(42).resolve(context)) + } + + @Test + fun `resolves a literal without touching the context`() { + assertEquals("literal", TextConfig.Literal("literal").resolve(context)) + } +} From 6f618e18f2f2a18938d7293d54369046877ea153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Wed, 29 Jul 2026 17:52:52 +0200 Subject: [PATCH 02/28] add config-driven dialog binder contracts and content value store --- .../ui/page/configdriven/ContentHandle.kt | 37 +++++++++++ .../ui/page/configdriven/ContentValueStore.kt | 35 +++++++++++ .../ui/page/configdriven/DialogBinder.kt | 49 +++++++++++++++ .../configdriven/ContentValueStoreTest.kt | 62 +++++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentHandle.kt create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentValueStore.kt create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogBinder.kt create mode 100644 app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentValueStoreTest.kt diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentHandle.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentHandle.kt new file mode 100644 index 000000000000..bce26583a626 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentHandle.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven + +import android.animation.Animator +import android.view.View +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingEvent +import com.duckduckgo.app.onboarding.ui.view.OnboardingDialogTitleView + +/** + * What a binder hands back to the render engine after binding a screen. + * + * [afterFade] is a factory, not a running animator: the engine decides when to start it, ends it when the + * render is snapped, and cancels it on teardown. An animator it returns must leave its views in their final + * visible state even if `end()` arrives before it ever ran. + */ +class ContentHandle( + val title: OnboardingDialogTitleView?, + val fadeTargets: List, + val afterFade: (() -> Animator)? = null, + val result: (() -> NewUserOnboardingEvent)? = null, + val unbind: () -> Unit = {}, +) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentValueStore.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentValueStore.kt new file mode 100644 index 000000000000..b06d4bface40 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentValueStore.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven + +import com.duckduckgo.onboarding.api.LinearOnboardingStepId +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Live working state for stateful screens: one flow per step, seeded on first use. Owned by the view model, so + * an in-progress selection survives rotation while the engine's observation of it is only bind-scoped. + */ +class ContentValueStore { + + private val states = mutableMapOf>() + + @Suppress("UNCHECKED_CAST") + fun contentState( + stepId: LinearOnboardingStepId, + content: Stateful, + ): MutableStateFlow = states.getOrPut(stepId) { MutableStateFlow(content.initialState()) } as MutableStateFlow +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogBinder.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogBinder.kt new file mode 100644 index 000000000000..679c7080fbd3 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogBinder.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven + +import android.view.View +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow + +/** What the engine gives a binder at bind time. */ +class BindScope( + /** Cancelled by the engine at unbind, so state observation dies with the binding. */ + val coroutineScope: CoroutineScope, + val execute: (ContentInteraction) -> Unit, +) + +/** Interactions a bound screen raises outside the shared CTA flow. */ +sealed interface ContentInteraction + +/** Binds a stateless [ContentConfig] to its include layout. */ +interface DialogBinder { + + /** The include root, shown and hidden by the engine. */ + val view: View + + fun bind(content: C, scope: BindScope): ContentHandle +} + +/** Binds a [Stateful] [ContentConfig], observing and mutating the store-owned [MutableStateFlow]. */ +interface StatefulDialogBinder where C : ContentConfig, C : Stateful { + + /** The include root, shown and hidden by the engine. */ + val view: View + + fun bind(content: C, state: MutableStateFlow, scope: BindScope): ContentHandle +} diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentValueStoreTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentValueStoreTest.kt new file mode 100644 index 000000000000..2b9f3403adc2 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentValueStoreTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven + +import com.duckduckgo.app.browser.omnibar.OmnibarType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Test + +class ContentValueStoreTest { + + private val testee = ContentValueStore() + + private fun addressBar(initialPosition: OmnibarType = OmnibarType.SINGLE_TOP) = ContentConfig.AddressBar( + title = TextConfig.Literal("title"), + initialPosition = initialPosition, + showSplitOption = false, + ) + + @Test + fun `seeds the state from the content's initial state`() { + val state = testee.contentState("address_bar_position", addressBar(OmnibarType.SINGLE_BOTTOM)) + + assertEquals(AddressBarContentState(position = OmnibarType.SINGLE_BOTTOM), state.value) + } + + @Test + fun `returns the same flow for the same step so live edits survive a rebind`() { + val first = testee.contentState("address_bar_position", addressBar()) + first.value = AddressBarContentState(position = OmnibarType.SPLIT) + + val second = testee.contentState("address_bar_position", addressBar()) + + assertSame(first, second) + assertEquals(AddressBarContentState(position = OmnibarType.SPLIT), second.value) + } + + @Test + fun `keeps independent state per step`() { + val first = testee.contentState("address_bar_position", addressBar()) + val second = testee.contentState("quick_setup_address_bar", addressBar(OmnibarType.SINGLE_BOTTOM)) + + assertNotSame(first, second) + assertEquals(AddressBarContentState(position = OmnibarType.SINGLE_TOP), first.value) + assertEquals(AddressBarContentState(position = OmnibarType.SINGLE_BOTTOM), second.value) + } +} From b22887bd566c7e32eb512bad879735e7406b17ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Wed, 29 Jul 2026 17:56:37 +0200 Subject: [PATCH 03/28] add config-driven dialog render engine and controller contracts --- .../configdriven/engine/DialogRenderEngine.kt | 210 +++++++++ .../configdriven/engine/RenderControllers.kt | 106 +++++ .../engine/DialogRenderEngineTest.kt | 419 ++++++++++++++++++ 3 files changed, 735 insertions(+) create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt create mode 100644 app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt new file mode 100644 index 000000000000..c154fb24687c --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.engine + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingEvent +import com.duckduckgo.app.onboarding.ui.page.configdriven.BindScope +import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentHandle +import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentInteraction +import com.duckduckgo.app.onboarding.ui.page.configdriven.CtaAction +import com.duckduckgo.app.onboarding.ui.page.configdriven.DialogConfig +import com.duckduckgo.onboarding.api.LinearOnboardingStepId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +class DialogRenderEngine( + private val content: ContentController, + private val cardStage: CardStage, + private val background: BackgroundController, + private val embellishments: EmbellishmentController, + private val cardAnchor: CardAnchorController, + private val cardArrow: CardArrowController, + private val stepIndicator: StepIndicatorController, + private val emit: (NewUserOnboardingEvent) -> Unit, + private val execute: (ContentInteraction) -> Unit, + private val onAnimatingChanged: (Boolean) -> Unit = {}, +) { + + private var previousStepId: LinearOnboardingStepId? = null + private var previous: DialogConfig? = null + private var bound: ContentHandle? = null + private var bindScope: CoroutineScope? = null + private var afterFadeAnimator: Animator? = null + + /** + * While set, every remaining stage runs snapped. Settling an in-flight entrance unwinds the rest of the + * chain synchronously, and those stages must land on their end state rather than start fresh animations. + */ + private var settling = false + + private var isAnimating = false + set(value) { + if (field == value) return + field = value + onAnimatingChanged(value) + } + + /** + * Renders [config] for [stepId], animated when [animate]. + */ + fun render( + stepId: LinearOnboardingStepId, + config: DialogConfig, + animate: Boolean, + ) { + if (stepId == previousStepId && config == previous && bound != null) return + + val freshStage = previous == null + skipRunningAnimations() + unbindCurrent() + if (freshStage) content.resetStage() + + background.apply(previous?.background, config.background, animate) + stepIndicator.apply(previous?.stepIndicator, config.stepIndicator, animate) + cardArrow.apply(previous?.cardArrow, config.cardArrow, animate) + + if (animate) isAnimating = true + + val scope = createBindScope() + bindScope = scope + val handle = content.bind(stepId, config.content, BindScope(coroutineScope = scope, execute = execute)) + bound = handle + + cardStage.showCtas(config.primaryCta, config.secondaryCta) { cta -> performCta(cta.action, handle) } + if (animate) cardStage.prepareEntrance(handle.fadeTargets) + + embellishments.transition(previous?.embellishment, config.embellishment, animate) { settled -> + cardAnchor.apply(settled) + } + + // Every deferred stage below bails unless this render's binding is still the current one. Settling the + // card stage runs pending continuations rather than dropping them, so a render that has been superseded + // would otherwise drive the views its successor has already rebound. + cardStage.reveal(animate) { + if (bound !== handle) return@reveal + cardStage.morph(animating(animate)) { + if (bound !== handle) return@morph + showTitle(handle, animating(animate)) { + if (bound !== handle) return@showTitle + cardStage.fadeInContent(handle.fadeTargets, animating(animate)) { + if (bound !== handle) return@fadeInContent + playAfterFade(handle, animating(animate)) + isAnimating = false + } + } + } + } + + previousStepId = stepId + previous = config + } + + /** Tap-to-skip and reduced motion: settles the whole stage in one call. */ + fun skipRunningAnimations() { + if (settling) return + settling = true + try { + bound?.title?.finishTyping() + cardStage.settle() + afterFadeAnimator?.end() + embellishments.skipRunning() + background.skipRunning() + stepIndicator.skipRunning() + cardArrow.skipRunning() + isAnimating = false + } finally { + settling = false + } + } + + /** Teardown: suppresses everything still pending instead of settling it. */ + fun release() { + isAnimating = false + afterFadeAnimator?.cancel() + afterFadeAnimator = null + unbindCurrent() + cardStage.release() + embellishments.release() + stepIndicator.release() + } + + private fun animating(animate: Boolean) = animate && !settling + + private fun unbindCurrent() { + val handle = bound ?: return + handle.title?.cancelAnimation() + handle.unbind() + bindScope?.cancel() + bindScope = null + content.hideBound() + bound = null + } + + private fun showTitle( + handle: ContentHandle, + animate: Boolean, + onEnd: () -> Unit, + ) { + val title = handle.title + if (title == null) { + onEnd() + } else if (animate) { + title.typeTitle(onEnd) + } else { + title.snapTitle() + onEnd() + } + } + + /** + * A snapped render starts the animator before ending it: `start()` fires the listeners a screen relies on to + * put its views in their final state, and an `AnimatorSet`'s `end()` is a no-op while it is unstarted. + */ + private fun playAfterFade(handle: ContentHandle, animate: Boolean) { + val animator = handle.afterFade?.invoke() ?: return + afterFadeAnimator = animator + // Ending an animator that already finished restarts it, re-firing its start listeners, so drop the + // reference the moment it completes on its own. + animator.addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + if (afterFadeAnimator === animation) afterFadeAnimator = null + } + }, + ) + animator.start() + if (!animate) animator.end() + } + + private fun performCta(action: CtaAction, handle: ContentHandle) { + when (action) { + is CtaAction.Emit -> emit(action.event) + CtaAction.Submit -> handle.result?.invoke()?.let(emit) + } + } + + /** + * One bind-scoped scope per render, cancelled at unbind. This is a view-layer collaborator built directly by + * the fragment rather than an injected class, so there is no `DispatcherProvider` to take. + */ + @Suppress("NoHardcodedCoroutineDispatcher") + private fun createBindScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt new file mode 100644 index 000000000000..1abab3aa714e --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.engine + +import android.view.View +import com.duckduckgo.app.onboarding.orchestrator.StepProgress +import com.duckduckgo.app.onboarding.ui.page.OnboardingBackgroundStep +import com.duckduckgo.app.onboarding.ui.page.configdriven.BindScope +import com.duckduckgo.app.onboarding.ui.page.configdriven.CardArrowConfig +import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentConfig +import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentHandle +import com.duckduckgo.app.onboarding.ui.page.configdriven.CtaConfig +import com.duckduckgo.app.onboarding.ui.page.configdriven.Embellishment +import com.duckduckgo.onboarding.api.LinearOnboardingStepId + +/** + * The decoration the embellishment controller settled on, after its fit check. + */ +data class SettledDecoration( + val view: View, + val anchorsCardOnPhone: Boolean, + val anchoredCardBiasPhone: Float, + val anchoredCardBiasTablet: Float, +) + +interface BackgroundController { + fun apply(previous: OnboardingBackgroundStep?, next: OnboardingBackgroundStep, animate: Boolean) + fun skipRunning() +} + +interface StepIndicatorController { + fun apply(previous: StepProgress?, next: StepProgress?, animate: Boolean) + fun skipRunning() + fun release() +} + +interface CardArrowController { + fun apply(previous: CardArrowConfig?, next: CardArrowConfig, animate: Boolean) + fun skipRunning() +} + +interface CardAnchorController { + fun apply(settled: SettledDecoration?) +} + +interface EmbellishmentController { + /** + * [onSettled] reports what the fit check settled on. When a decoration is leaving, it fires only once that + * exit has finished: the card must keep its anchor until the outgoing decoration is gone. + */ + fun transition( + previous: Embellishment?, + next: Embellishment, + animate: Boolean, + onSettled: (SettledDecoration?) -> Unit, + ) + + fun skipRunning() + fun release() +} + +interface ContentController { + /** Hides every content include. Used before the first bind, when includes still sit at their XML defaults. */ + fun resetStage() + + fun bind(stepId: LinearOnboardingStepId, content: ContentConfig, scope: BindScope): ContentHandle + + fun hideBound() +} + +/** The card choreography shared by every screen. Each call runs synchronously to its end state when not animating. */ +interface CardStage { + fun reveal(animate: Boolean, onEnd: () -> Unit) + + fun morph(animate: Boolean, onEnd: () -> Unit) + + /** + * CTA text, visibility and click handling. A CTA fading in from alpha 0 cannot be clicked early even though + * it is bound here: the card container swallows its children's touches for as long as an entrance runs. + */ + fun showCtas(primary: CtaConfig?, secondary: CtaConfig?, onClick: (CtaConfig) -> Unit) + + /** Hides [contentTargets] and the visible CTAs so an entrance can fade them in. */ + fun prepareEntrance(contentTargets: List) + + fun fadeInContent(contentTargets: List, animate: Boolean, onEnd: () -> Unit) + + /** Ends whatever is in flight, running its continuation now rather than at its natural completion. */ + fun settle() + + fun release() +} diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt new file mode 100644 index 000000000000..09d2118621a4 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt @@ -0,0 +1,419 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.engine + +import android.animation.Animator +import android.view.View +import com.duckduckgo.app.browser.omnibar.OmnibarType +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingEvent +import com.duckduckgo.app.onboarding.orchestrator.StepProgress +import com.duckduckgo.app.onboarding.ui.page.ComparisonChartConfig +import com.duckduckgo.app.onboarding.ui.page.OnboardingBackgroundStep +import com.duckduckgo.app.onboarding.ui.page.configdriven.BindScope +import com.duckduckgo.app.onboarding.ui.page.configdriven.CardArrowConfig +import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentConfig +import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentHandle +import com.duckduckgo.app.onboarding.ui.page.configdriven.CtaAction +import com.duckduckgo.app.onboarding.ui.page.configdriven.CtaConfig +import com.duckduckgo.app.onboarding.ui.page.configdriven.DialogConfig +import com.duckduckgo.app.onboarding.ui.page.configdriven.Embellishment +import com.duckduckgo.app.onboarding.ui.page.configdriven.TextConfig +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.onboarding.api.LinearOnboardingStepId +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +class DialogRenderEngineTest { + + @get:Rule + @Suppress("unused") + val coroutineRule = CoroutineTestRule() + + private val content = FakeContentController() + private val cardStage = FakeCardStage() + private val background = FakeBackgroundController() + private val embellishments = FakeEmbellishmentController() + private val cardAnchor = FakeCardAnchorController() + private val cardArrow = FakeCardArrowController() + private val stepIndicator = FakeStepIndicatorController() + + private val emitted = mutableListOf() + private val animatingChanges = mutableListOf() + + private val testee = DialogRenderEngine( + content = content, + cardStage = cardStage, + background = background, + embellishments = embellishments, + cardAnchor = cardAnchor, + cardArrow = cardArrow, + stepIndicator = stepIndicator, + emit = { emitted += it }, + execute = {}, + onAnimatingChanged = { animatingChanges += it }, + ) + + @Test + fun `first render resets the stage and applies every axis`() = runTest { + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + + assertTrue(content.stageReset) + assertEquals(null to OnboardingBackgroundStep.ComparisonChart, background.applied) + assertEquals(null to Embellishment.BottomWing, embellishments.applied) + assertEquals(null to CardArrowConfig.AtEnd, cardArrow.applied) + assertEquals(null to StepProgress(current = 1, total = 2), stepIndicator.applied) + } + + @Test + fun `second render diffs each axis against the previous config`() = runTest { + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + content.stageReset = false + + testee.render(ADDRESS_BAR_STEP, addressBarConfig(), animate = true) + + assertFalse(content.stageReset) + assertEquals(OnboardingBackgroundStep.ComparisonChart to OnboardingBackgroundStep.AddressBar, background.applied) + assertEquals(Embellishment.BottomWing to Embellishment.BobbingDax, embellishments.applied) + assertEquals( + StepProgress(current = 1, total = 2) to StepProgress(current = 2, total = 2), + stepIndicator.applied, + ) + } + + @Test + fun `re-emitting the same step and config does not re-render`() = runTest { + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + val bindCount = content.bindCount + + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + + assertEquals(bindCount, content.bindCount) + } + + @Test + fun `the same config on a different step re-renders`() = runTest { + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + + testee.render("another_step", comparisonConfig(), animate = true) + + assertEquals(2, content.bindCount) + } + + @Test + fun `a snapped render runs the whole pipeline without animating`() = runTest { + testee.render(COMPARISON_STEP, comparisonConfig(), animate = false) + + assertEquals(listOf(false, false, false), cardStage.animateFlags) + assertFalse(background.animated) + assertFalse(embellishments.animated) + assertEquals(1, cardStage.fadeCount) + } + + @Test + fun `an emit cta forwards its event as-is`() = runTest { + testee.render(COMPARISON_STEP, comparisonConfig(), animate = false) + + cardStage.clickPrimary() + + assertEquals(listOf(NewUserOnboardingEvent.ContinueClicked), emitted) + } + + @Test + fun `a submit cta emits the event the bound screen builds`() = runTest { + content.handleResult = { NewUserOnboardingEvent.AddressBarConfirmed(OmnibarType.SPLIT) } + + testee.render(ADDRESS_BAR_STEP, addressBarConfig(), animate = false) + cardStage.clickPrimary() + + assertEquals(listOf(NewUserOnboardingEvent.AddressBarConfirmed(OmnibarType.SPLIT)), emitted) + } + + @Test + fun `an animated render reports animating until the entrance settles`() = runTest { + cardStage.autoComplete = false + + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + assertEquals(listOf(true), animatingChanges) + + cardStage.completePendingStages() + + assertEquals(listOf(true, false), animatingChanges) + } + + @Test + fun `skip settles every axis`() = runTest { + cardStage.autoComplete = false + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + + testee.skipRunningAnimations() + + assertTrue(cardStage.settled) + assertTrue(background.skipped) + assertTrue(embellishments.skipped) + assertTrue(stepIndicator.skipped) + assertTrue(cardArrow.skipped) + assertEquals(listOf(true, false), animatingChanges) + } + + @Test + fun `an after-fade animator is started and ended on a snapped render`() = runTest { + val animator: Animator = mock() + content.afterFade = { animator } + + testee.render(COMPARISON_STEP, comparisonConfig(), animate = false) + + verify(animator).start() + verify(animator).end() + } + + @Test + fun `release unbinds the content and cancels the after-fade animator`() = runTest { + val animator: Animator = mock() + content.afterFade = { animator } + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + + testee.release() + + verify(animator).cancel() + assertTrue(content.hidden) + assertTrue(cardStage.released) + assertTrue(embellishments.released) + } + + @Test + fun `a superseded render does not continue its pipeline`() = runTest { + cardStage.autoComplete = false + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + val supersededStages = cardStage.takePendingStages() + + testee.render(ADDRESS_BAR_STEP, addressBarConfig(), animate = true) + val fadeCountAfterSupersede = cardStage.fadeCount + supersededStages.forEach { it() } + + assertEquals(fadeCountAfterSupersede, cardStage.fadeCount) + } + + private companion object { + const val COMPARISON_STEP: LinearOnboardingStepId = "comparison_chart" + const val ADDRESS_BAR_STEP: LinearOnboardingStepId = "address_bar_position" + + fun comparisonConfig() = DialogConfig( + background = OnboardingBackgroundStep.ComparisonChart, + embellishment = Embellishment.BottomWing, + cardArrow = CardArrowConfig.AtEnd, + content = ContentConfig.ComparisonChart( + title = TextConfig.Literal("comparison"), + config = ComparisonChartConfig.Browser(isCustomAiCopy = false), + ), + primaryCta = CtaConfig( + text = TextConfig.Literal("next"), + action = CtaAction.Emit(NewUserOnboardingEvent.ContinueClicked), + ), + stepIndicator = StepProgress(current = 1, total = 2), + ) + + fun addressBarConfig() = DialogConfig( + background = OnboardingBackgroundStep.AddressBar, + embellishment = Embellishment.BobbingDax, + cardArrow = CardArrowConfig.AtEnd, + content = ContentConfig.AddressBar( + title = TextConfig.Literal("address bar"), + initialPosition = OmnibarType.SINGLE_TOP, + showSplitOption = false, + ), + primaryCta = CtaConfig(text = TextConfig.Literal("next"), action = CtaAction.Submit), + stepIndicator = StepProgress(current = 2, total = 2), + ) + } +} + +private class FakeContentController : ContentController { + + var stageReset = false + var bindCount = 0 + var hidden = false + var afterFade: (() -> Animator)? = null + var handleResult: (() -> NewUserOnboardingEvent)? = null + var unbindCount = 0 + + override fun resetStage() { + stageReset = true + } + + override fun bind(stepId: LinearOnboardingStepId, content: ContentConfig, scope: BindScope): ContentHandle { + bindCount++ + return ContentHandle( + title = null, + fadeTargets = emptyList(), + afterFade = afterFade, + result = handleResult, + unbind = { unbindCount++ }, + ) + } + + override fun hideBound() { + hidden = true + } +} + +private class FakeCardStage : CardStage { + + /** When false, stage continuations queue up in [pending] so a test can settle them explicitly. */ + var autoComplete = true + var settled = false + var released = false + var fadeCount = 0 + val animateFlags = mutableListOf() + + private val pending = mutableListOf<() -> Unit>() + private var primary: CtaConfig? = null + private var onCtaClick: ((CtaConfig) -> Unit)? = null + + override fun reveal(animate: Boolean, onEnd: () -> Unit) = stage(animate, onEnd) + + override fun morph(animate: Boolean, onEnd: () -> Unit) = stage(animate, onEnd) + + override fun fadeInContent(contentTargets: List, animate: Boolean, onEnd: () -> Unit) { + fadeCount++ + stage(animate, onEnd) + } + + override fun showCtas(primary: CtaConfig?, secondary: CtaConfig?, onClick: (CtaConfig) -> Unit) { + this.primary = primary + onCtaClick = onClick + } + + override fun prepareEntrance(contentTargets: List) = Unit + + override fun settle() { + settled = true + completePendingStages() + } + + override fun release() { + released = true + pending.clear() + } + + fun clickPrimary() { + primary?.let { cta -> onCtaClick?.invoke(cta) } + } + + fun completePendingStages() { + while (pending.isNotEmpty()) { + pending.removeAt(0).invoke() + } + } + + fun takePendingStages(): List<() -> Unit> = pending.toList().also { pending.clear() } + + private fun stage(animate: Boolean, onEnd: () -> Unit) { + animateFlags += animate + if (autoComplete || !animate) onEnd() else pending += onEnd + } +} + +private class FakeBackgroundController : BackgroundController { + + var applied: Pair? = null + var animated = false + var skipped = false + + override fun apply(previous: OnboardingBackgroundStep?, next: OnboardingBackgroundStep, animate: Boolean) { + applied = previous to next + animated = animate + } + + override fun skipRunning() { + skipped = true + } +} + +private class FakeStepIndicatorController : StepIndicatorController { + + var applied: Pair? = null + var skipped = false + var released = false + + override fun apply(previous: StepProgress?, next: StepProgress?, animate: Boolean) { + applied = previous to next + } + + override fun skipRunning() { + skipped = true + } + + override fun release() { + released = true + } +} + +private class FakeCardArrowController : CardArrowController { + + var applied: Pair? = null + var skipped = false + + override fun apply(previous: CardArrowConfig?, next: CardArrowConfig, animate: Boolean) { + applied = previous to next + } + + override fun skipRunning() { + skipped = true + } +} + +private class FakeCardAnchorController : CardAnchorController { + + var applied = false + + override fun apply(settled: SettledDecoration?) { + applied = true + } +} + +private class FakeEmbellishmentController : EmbellishmentController { + + var applied: Pair? = null + var animated = false + var skipped = false + var released = false + + override fun transition( + previous: Embellishment?, + next: Embellishment, + animate: Boolean, + onSettled: (SettledDecoration?) -> Unit, + ) { + applied = previous to next + animated = animate + onSettled(null) + } + + override fun skipRunning() { + skipped = true + } + + override fun release() { + released = true + } +} From 14c2975198579eb43802c373d13145df7d72a392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Wed, 29 Jul 2026 17:59:20 +0200 Subject: [PATCH 04/28] add background, step indicator, card anchor and card arrow controllers --- .../engine/BackgroundController.kt | 52 ++++++++++ .../engine/CardAnchorController.kt | 62 ++++++++++++ .../engine/CardArrowController.kt | 75 +++++++++++++++ .../configdriven/engine/RenderControllers.kt | 23 ----- .../engine/StepIndicatorController.kt | 96 +++++++++++++++++++ 5 files changed, 285 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/BackgroundController.kt create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorController.kt create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardArrowController.kt create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/StepIndicatorController.kt diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/BackgroundController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/BackgroundController.kt new file mode 100644 index 000000000000..685c9b39552a --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/BackgroundController.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.engine + +import com.duckduckgo.app.onboarding.ui.page.OnboardingBackgroundAnimator +import com.duckduckgo.app.onboarding.ui.page.OnboardingBackgroundStep + +/** Owns the background axis: which [OnboardingBackgroundStep] image is showing behind the dialog. */ +interface BackgroundController { + fun apply(previous: OnboardingBackgroundStep?, next: OnboardingBackgroundStep, animate: Boolean) + fun skipRunning() +} + +class BackgroundControllerImpl(private val animator: OnboardingBackgroundAnimator) : BackgroundController { + + /** Set while a transitionTo may still be animating, so [skipRunning] knows there is something to settle. */ + private var transitioningTo: OnboardingBackgroundStep? = null + + override fun apply( + previous: OnboardingBackgroundStep?, + next: OnboardingBackgroundStep, + animate: Boolean, + ) { + if (previous == next) return + if (animate) { + transitioningTo = next + animator.transitionTo(next) + } else { + transitioningTo = null + animator.snapTo(next) + } + } + + override fun skipRunning() { + transitioningTo?.let { animator.snapTo(it) } + transitioningTo = null + } +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorController.kt new file mode 100644 index 000000000000..fd929dbf6d5f --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorController.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.engine + +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.updateLayoutParams +import com.duckduckgo.app.browser.databinding.ContentOnboardingWelcomePageUpdateBinding + +/** + * Owns the card-anchor axis: whether the dax card sits above the settled decoration or pinned to the parent + * bottom, the card's vertical bias in either case, and the bubble arrow's depth. + */ +interface CardAnchorController { + fun apply(settled: SettledDecoration?) +} + +class CardAnchorControllerImpl( + private val binding: ContentOnboardingWelcomePageUpdateBinding, + private val isTablet: Boolean, +) : CardAnchorController { + + /** + * @param settled The decoration the embellishment axis settled on, or null when there is none or the fit + * check vetoed it. The card anchors above [SettledDecoration.view] when non-null and either [isTablet] or + * [SettledDecoration.anchorsCardOnPhone] is true; otherwise it pins to the parent bottom. + * + * Arrow visibility is deliberately not handled here: it is screen data the engine applies synchronously at + * render time, whereas this fires from the embellishment axis's settle, which waits out a previous + * decoration's exit. + */ + override fun apply(settled: SettledDecoration?) { + val card = binding.daxDialogCta.root + + card.updateLayoutParams { + if (settled != null && (isTablet || settled.anchorsCardOnPhone)) { + bottomToTop = settled.view.id + bottomToBottom = ConstraintLayout.LayoutParams.UNSET + verticalBias = if (isTablet) settled.anchoredCardBiasTablet else settled.anchoredCardBiasPhone + } else { + bottomToTop = ConstraintLayout.LayoutParams.UNSET + bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID + verticalBias = if (isTablet) 0.5f else 0f + } + } + + binding.daxDialogCta.cardView.setArrowDepthFraction(if (settled != null) 1f else 0f) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardArrowController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardArrowController.kt new file mode 100644 index 000000000000..7fd8048a78f6 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardArrowController.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.engine + +import android.animation.ValueAnimator +import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import com.duckduckgo.app.onboarding.ui.page.configdriven.CardArrowConfig +import com.duckduckgo.common.ui.view.shape.DaxOnboardingBubbleBrandDesignUpdateCardView +import com.duckduckgo.common.ui.view.toPx + +interface CardArrowController { + fun apply(previous: CardArrowConfig?, next: CardArrowConfig, animate: Boolean) + fun skipRunning() +} + +/** + * Owns the card's bubble arrow: whether it shows, and where along the card's edge it sits. The position only + * animates across a transition that actually moves it; every other render snaps it. + */ +class CardArrowControllerImpl( + private val cardView: DaxOnboardingBubbleBrandDesignUpdateCardView, +) : CardArrowController { + + private var slide: ValueAnimator? = null + + override fun apply( + previous: CardArrowConfig?, + next: CardArrowConfig, + animate: Boolean, + ) { + slide?.cancel() + slide = null + + cardView.setShowArrow(next != CardArrowConfig.Hidden) + cardView.setArrowAnimationTarget(ARROW_TARGET_OFFSET_END_DP.toPx().toFloat()) + + val target = if (next == CardArrowConfig.AtEnd) 1f else 0f + val moves = previous != null && previous != next && + previous != CardArrowConfig.Hidden && next != CardArrowConfig.Hidden + if (animate && moves) { + slide = ValueAnimator.ofFloat(1f - target, target).apply { + duration = SLIDE_DURATION_MS + interpolator = FastOutSlowInInterpolator() + addUpdateListener { cardView.setArrowAnimationFraction(it.animatedValue as Float) } + start() + } + } else { + cardView.setArrowAnimationFraction(target) + } + } + + override fun skipRunning() { + slide?.end() + slide = null + } + + private companion object { + const val ARROW_TARGET_OFFSET_END_DP = 80 + const val SLIDE_DURATION_MS = 400L + } +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt index 1abab3aa714e..8d87cf1f854b 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt @@ -17,10 +17,7 @@ package com.duckduckgo.app.onboarding.ui.page.configdriven.engine import android.view.View -import com.duckduckgo.app.onboarding.orchestrator.StepProgress -import com.duckduckgo.app.onboarding.ui.page.OnboardingBackgroundStep import com.duckduckgo.app.onboarding.ui.page.configdriven.BindScope -import com.duckduckgo.app.onboarding.ui.page.configdriven.CardArrowConfig import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentConfig import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentHandle import com.duckduckgo.app.onboarding.ui.page.configdriven.CtaConfig @@ -37,26 +34,6 @@ data class SettledDecoration( val anchoredCardBiasTablet: Float, ) -interface BackgroundController { - fun apply(previous: OnboardingBackgroundStep?, next: OnboardingBackgroundStep, animate: Boolean) - fun skipRunning() -} - -interface StepIndicatorController { - fun apply(previous: StepProgress?, next: StepProgress?, animate: Boolean) - fun skipRunning() - fun release() -} - -interface CardArrowController { - fun apply(previous: CardArrowConfig?, next: CardArrowConfig, animate: Boolean) - fun skipRunning() -} - -interface CardAnchorController { - fun apply(settled: SettledDecoration?) -} - interface EmbellishmentController { /** * [onSettled] reports what the fit check settled on. When a decoration is leaving, it fires only once that diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/StepIndicatorController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/StepIndicatorController.kt new file mode 100644 index 000000000000..45c4b43f53db --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/StepIndicatorController.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.engine + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ObjectAnimator +import android.view.View +import androidx.core.view.isVisible +import com.duckduckgo.app.onboarding.orchestrator.StepProgress +import com.duckduckgo.app.onboarding.ui.view.OnboardingStepIndicatorView + +/** Owns the step-indicator axis: the "X of Y" pill's visibility and its snap-vs-advance-one-step choreography. */ +interface StepIndicatorController { + fun apply(previous: StepProgress?, next: StepProgress?, animate: Boolean) + fun skipRunning() + fun release() +} + +class StepIndicatorControllerImpl(private val indicator: OnboardingStepIndicatorView) : StepIndicatorController { + + private var fadeOut: ObjectAnimator? = null + + /** + * @param previous The step shown before this call, or null if none was showing. + * @param next The step to show now, or null to hide the indicator entirely. + * @param animate Whether to animate the transition (fade-out when hiding; advance-one-step when [previous] + * was already showing) or snap directly to the end state. + */ + override fun apply( + previous: StepProgress?, + next: StepProgress?, + animate: Boolean, + ) { + fadeOut?.cancel() + fadeOut = null + + when { + next == null && previous != null && animate -> { + fadeOut = ObjectAnimator.ofFloat(indicator, View.ALPHA, 1f, 0f).apply { + duration = OUTRO_FADE_DURATION + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + indicator.isVisible = false + } + }, + ) + start() + } + } + next == null -> { + indicator.alpha = 1f + indicator.isVisible = false + } + !animate || previous == null -> { + indicator.alpha = 1f + indicator.isVisible = true + indicator.setSteps(totalSteps = next.total, currentStep = next.current) + } + else -> { + indicator.alpha = 1f + indicator.isVisible = true + indicator.setSteps(totalSteps = next.total, currentStep = next.current - 1) + indicator.animateToNextStep() + } + } + } + + override fun skipRunning() { + fadeOut?.end() + } + + override fun release() { + fadeOut?.cancel() + fadeOut = null + } + + private companion object { + const val OUTRO_FADE_DURATION = 300L + } +} From 36654f279f328b8f5d845892450d6fcab9f889c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Wed, 29 Jul 2026 18:06:43 +0200 Subject: [PATCH 05/28] add embellishment controller with decoration fit and choreography --- .../engine/EmbellishmentController.kt | 565 ++++++++++++++++++ .../configdriven/engine/RenderControllers.kt | 17 - 2 files changed, 565 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt new file mode 100644 index 000000000000..df663dd884a3 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt @@ -0,0 +1,565 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.engine + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.view.View +import android.view.ViewGroup +import android.view.animation.PathInterpolator +import androidx.core.view.isGone +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.airbnb.lottie.LottieAnimationView +import com.duckduckgo.app.browser.databinding.ContentOnboardingWelcomePageUpdateBinding +import com.duckduckgo.app.onboarding.ui.page.BrandDesignUpdateOnboardingLayoutHelper +import com.duckduckgo.app.onboarding.ui.page.OnboardingBackgroundAnimator +import com.duckduckgo.app.onboarding.ui.page.OnboardingDecorationFitCorrector +import com.duckduckgo.app.onboarding.ui.page.configdriven.Embellishment +import com.duckduckgo.common.ui.view.toPx + +interface EmbellishmentController { + /** + * [onSettled] reports what the fit check settled on. When a decoration is leaving, it fires only once that + * exit has finished: the card must keep its anchor until the outgoing decoration is gone. + */ + fun transition( + previous: Embellishment?, + next: Embellishment, + animate: Boolean, + onSettled: (SettledDecoration?) -> Unit, + ) + + fun skipRunning() + fun release() +} + +/** + * Owns the embellishment axis: which Lottie decoration (walking dax, bobbing dax, either wing) accompanies the + * current dialog, its enter and exit choreography, and the fit check that hides a declared decoration when the + * dialog content leaves it no room. + * + * [onDecorationHidden] fires asynchronously, from the fit corrector's pre-draw pass rather than from + * [transition], when a decoration that used to fit stops fitting — the keyboard opening, say. No [transition] is + * in flight at that point, so the card has to be re-anchored straight from that callback. + */ +class EmbellishmentControllerImpl( + private val binding: ContentOnboardingWelcomePageUpdateBinding, + private val onDecorationHidden: () -> Unit, + private val cardBottomInsetPx: () -> Int, +) : EmbellishmentController { + + /** + * Every animator started here that has not finished on its own, so a superseding [transition] can end() them + * and [release] can cancel() them. Ending an animator that already completed restarts it, re-firing the start + * listeners that call [LottieAnimationView.playAnimation], so entries drop themselves as they complete. + */ + private val trackedAnimators = mutableListOf() + + /** + * The wings leave by running their Lottie to its end, whose duration is not known up front, so completion + * arrives through a [LottieAnimationView] listener rather than an [Animator] this controller can end() or + * cancel() itself. Only one decoration is ever leaving at a time, so a single slot covers it. + */ + private var pendingExit: LottieExit? = null + + /** + * Bumped by every [transition]; each call captures the value, and every deferred continuation of that call + * re-checks it before acting. A transition superseded before it settled sees a mismatch and no-ops, so it + * never reports a stale [SettledDecoration]. + */ + private var generation = 0 + + /** The fit-approved decoration on stage, or null when the current screen shows none. What [skipRunning] snaps. */ + private var currentDecoration: Decoration? = null + + private val fitCorrector = OnboardingDecorationFitCorrector( + root = binding.root, + dialog = binding.daxDialogCta.root, + cardContainer = binding.daxDialogCta.cardContainer, + onDecorationHidden = onDecorationHidden, + cardBottomInsetPx = cardBottomInsetPx, + ) + + private val decorations: Map = mapOf( + Embellishment.WalkingDax to buildWalkingDax(), + Embellishment.BottomWing to buildBottomWing(), + Embellishment.LeftWing to buildLeftWing(), + Embellishment.BobbingDax to buildBobbingDax(), + ) + + init { + fitCorrector.enabled = true + fitCorrector.attach() + } + + override fun transition( + previous: Embellishment?, + next: Embellishment, + animate: Boolean, + onSettled: (SettledDecoration?) -> Unit, + ) { + generation++ + val gen = generation + + // An earlier transition's exit may still be running, with a continuation that belongs to that older + // generation. Draining it now keeps two exits off the stage at once, and the generation check turns the + // drained continuation into a no-op. + drainInFlight() + + if (previous == next) { + // The drain may have cut this decoration's own entrance short, so snap it to where that entrance was + // heading before reporting the fit. + decorations[next]?.snap() + onSettled(applyFit(next)) + return + } + + val exiting = previous?.let { decorations[it] } + + fun applyNext(): SettledDecoration? { + val settled = applyFit(next) + if (settled != null) { + val entering = decorations.getValue(next) + if (animate) { + track(entering.enter()) + } else { + entering.snap() + } + } else { + decorations[next]?.let { instantHide(it.view) } + } + return settled + } + + when { + exiting == null -> onSettled(applyNext()) + animate && exiting.view.isVisible -> { + // The incoming decoration enters in the same frame the outgoing one starts leaving. A wing's exit + // plays its Lottie out over several seconds, and serializing the entrance behind that leaves the + // new decoration visibly late and out of step with the background transition. Only the card anchor + // waits, and it settles from the exit's own completion. + val settled = applyNext() + track( + exiting.exit { + if (gen == generation) onSettled(settled) + }, + ) + } + else -> { + instantHide(exiting.view) + onSettled(applyNext()) + } + } + } + + /** + * [drainInFlight] ends whatever is running, so an exit's completion happens now rather than at its natural + * end. Snapping on top of that matters for an entrance that had not started yet: ending it fires its start + * listener, which plays the decoration's Lottie from frame 0, and the snap freezes it at its end state. + */ + override fun skipRunning() { + drainInFlight() + currentDecoration?.snap() + } + + override fun release() { + trackedAnimators.forEach { it.cancel() } + trackedAnimators.clear() + pendingExit?.let { + it.view.removeAnimatorListener(it.listener) + it.view.cancelAnimation() + } + pendingExit = null + fitCorrector.clear() + fitCorrector.detach() + } + + /** + * Ends every tracked animator and finishes any pending Lottie exit. The snapshot comes first because ending an + * animator removes it from [trackedAnimators] from inside the iteration. + */ + private fun drainInFlight() { + val animators = trackedAnimators.toList() + trackedAnimators.removeAll(animators) + animators.forEach { it.end() } + pendingExit?.finish?.invoke() + } + + /** Tracks [animators] and removes each again the moment it ends on its own. */ + private fun track(animators: List) { + trackedAnimators += animators + animators.forEach { animator -> + animator.addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + trackedAnimators.remove(animation) + } + }, + ) + } + } + + /** + * Sizes [embellishment]'s view to the room the dialog leaves it and hands it to the fit corrector, or returns + * null when it does not fit at all and the card should anchor to the parent bottom instead. + */ + private fun applyFit(embellishment: Embellishment): SettledDecoration? { + val decoration = decorations[embellishment] + currentDecoration = decoration + if (decoration == null) return null + + releaseCardBottomInset() + val fitHeightPx = BrandDesignUpdateOnboardingLayoutHelper.calculateDecorationHeight( + rootView = binding.root, + dialogView = binding.daxDialogCta.root, + decorationView = decoration.view, + maxHeightPx = decoration.maxHeightDp.toPx(), + minHeightPx = decoration.minHeightDp.toPx(), + bottomOverlapPx = decoration.bottomOverlapPx(), + ) + if (fitHeightPx == null) { + fitCorrector.clear() + currentDecoration = null + return null + } + + decoration.view.updateLayoutParams { height = fitHeightPx } + fitCorrector.track( + decoration.view, + minHeightPx = decoration.minHeightDp.toPx(), + maxHeightPx = decoration.maxHeightDp.toPx(), + bottomOverlapPx = decoration.bottomOverlapPx(), + ) + return SettledDecoration( + view = decoration.view, + anchorsCardOnPhone = decoration.anchorsCardOnPhone, + anchoredCardBiasPhone = decoration.anchoredCardBiasPhone, + anchoredCardBiasTablet = decoration.anchoredCardBiasTablet, + ) + } + + // A bottom-anchored predecessor can leave a bottom inset on the card. Clear it before measuring so it does not + // count against the decoration's room; the fit corrector re-applies it if this dialog is bottom-anchored too. + private fun releaseCardBottomInset() { + val params = binding.daxDialogCta.root.layoutParams as? ViewGroup.MarginLayoutParams ?: return + if (params.bottomMargin != 0) { + params.bottomMargin = 0 + binding.daxDialogCta.root.layoutParams = params + } + } + + private fun leftWingBottomOverlapPx(): Int { + val cardBottomMargin = (binding.daxDialogCta.cardView.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin ?: return 0 + return (cardBottomMargin - LEFT_WING_CARD_GAP_DP.toPx()).coerceAtLeast(0) + } + + private fun instantHide(view: LottieAnimationView) { + view.cancelAnimation() + view.isVisible = false + } + + private fun buildWalkingDax(): Decoration { + val view = binding.welcomeScreenWalkingDax + return Decoration( + view = view, + anchorsCardOnPhone = true, + // Bias 1 keeps the card pressed down against the dax, on phone and tablet alike. + anchoredCardBiasPhone = 1f, + anchoredCardBiasTablet = 1f, + maxHeightDp = WALKING_DAX_MAX_HEIGHT_DP, + minHeightDp = WALKING_DAX_MIN_HEIGHT_DP, + enter = { + val fade = ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f) + .setDuration(WALKING_DAX_FADE_DURATION) + val slide = ObjectAnimator.ofFloat( + view, + View.TRANSLATION_X, + -WALKING_DAX_START_X_DP.toPx().toFloat(), + -WALKING_DAX_FINAL_X_DP.toPx().toFloat(), + ).setDuration(WALKING_DAX_SLIDE_DURATION) + val set = AnimatorSet().apply { + interpolator = WALKING_DAX_INTERPOLATOR + startDelay = WALKING_DAX_DELAY + playTogether(fade, slide) + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + view.playAnimation() + } + }, + ) + } + set.start() + listOf(set) + }, + exit = { onEnd -> + instantHide(view) + onEnd() + emptyList() + }, + snap = { + view.cancelAnimation() + view.isVisible = true + view.progress = 1f + view.alpha = 1f + view.translationX = -WALKING_DAX_FINAL_X_DP.toPx().toFloat() + }, + ) + } + + private fun buildBottomWing(): Decoration { + val view = binding.bottomWingAnimation + return Decoration( + view = view, + anchorsCardOnPhone = true, + anchoredCardBiasPhone = 0f, + anchoredCardBiasTablet = 0.5f, + maxHeightDp = BOTTOM_WING_MAX_HEIGHT_DP, + minHeightDp = BOTTOM_WING_MIN_HEIGHT_DP, + enter = { + view.isVisible = true + view.alpha = 0f + view.setMaxProgress(WING_STOP_PROGRESS) + val fadeIn = ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f).apply { + startDelay = WING_START_DELAY + duration = WING_FADE_IN_DURATION + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + view.playAnimation() + } + }, + ) + } + fadeIn.start() + listOf(fadeIn) + }, + exit = { onEnd -> + view.setMinProgress(WING_STOP_PROGRESS) + view.setMaxProgress(1f) + view.speed = 1f + exitViaLottie(view, onEnd = onEnd, applyFinalState = { view.isInvisible = true }) + emptyList() + }, + snap = { + view.cancelAnimation() + view.isVisible = true + view.alpha = 1f + view.progress = WING_STOP_PROGRESS + }, + ) + } + + private fun buildLeftWing(): Decoration { + val view = binding.leftWingAnimation + return Decoration( + view = view, + anchorsCardOnPhone = false, + // Anchors the card on tablet only, so the phone bias is never read. + anchoredCardBiasPhone = 0f, + anchoredCardBiasTablet = 0.5f, + maxHeightDp = LEFT_WING_MAX_HEIGHT_DP, + minHeightDp = LEFT_WING_MIN_HEIGHT_DP, + bottomOverlapPx = { leftWingBottomOverlapPx() }, + enter = { + view.isVisible = true + view.alpha = 0f + view.setMinAndMaxProgress(0f, WING_STOP_PROGRESS) + val fadeIn = ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f).apply { + startDelay = WING_START_DELAY + duration = WING_FADE_IN_DURATION + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + view.playAnimation() + } + }, + ) + } + fadeIn.start() + listOf(fadeIn) + }, + exit = { onEnd -> + view.setMinProgress(WING_STOP_PROGRESS) + view.setMaxProgress(1f) + view.speed = 1f + exitViaLottie(view, onEnd = onEnd, applyFinalState = { view.isGone = true }) + emptyList() + }, + snap = { + view.cancelAnimation() + view.isVisible = true + view.alpha = 1f + view.setMinAndMaxProgress(0f, WING_STOP_PROGRESS) + view.progress = WING_STOP_PROGRESS + }, + ) + } + + private fun buildBobbingDax(): Decoration { + val view = binding.bobbingDaxAnimation + return Decoration( + view = view, + anchorsCardOnPhone = false, + // Anchors the card on tablet only, so the phone bias is never read. + anchoredCardBiasPhone = 0f, + anchoredCardBiasTablet = 0.5f, + maxHeightDp = BOBBING_DAX_MAX_HEIGHT_DP, + minHeightDp = BOBBING_DAX_MIN_HEIGHT_DP, + enter = { + val screenWidth = binding.root.rootView.width.toFloat() + view.isVisible = true + view.alpha = 0f + view.translationX = screenWidth + var cancelled = false + val animator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = OnboardingBackgroundAnimator.ENTER_DURATION + interpolator = OnboardingBackgroundAnimator.EASE_IN_OUT + addUpdateListener { + val progress = it.animatedValue as Float + view.translationX = screenWidth * (1f - progress) + view.alpha = OnboardingBackgroundAnimator.enterAlpha(progress) + } + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationCancel(animation: Animator) { + cancelled = true + } + + override fun onAnimationEnd(animation: Animator) { + if (!cancelled) view.playAnimation() + } + }, + ) + } + animator.start() + listOf(animator) + }, + exit = { onEnd -> + val screenWidth = binding.root.rootView.width.toFloat() + var cancelled = false + val animator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = OnboardingBackgroundAnimator.EXIT_DURATION + interpolator = OnboardingBackgroundAnimator.EASE_IN_OUT + addUpdateListener { + val progress = it.animatedValue as Float + view.translationX = -screenWidth * progress + view.alpha = OnboardingBackgroundAnimator.exitAlpha(progress) + } + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationCancel(animation: Animator) { + cancelled = true + } + + override fun onAnimationEnd(animation: Animator) { + if (cancelled) return + view.isVisible = false + view.cancelAnimation() + view.translationX = 0f + onEnd() + } + }, + ) + } + animator.start() + listOf(animator) + }, + snap = { + view.isVisible = true + view.alpha = 1f + view.translationX = 0f + if (!view.isAnimating) view.playAnimation() + }, + ) + } + + /** Plays [view]'s Lottie to its end and reports through [onEnd]. Both the listener and a drain reach the same finish, which runs once. */ + private fun exitViaLottie( + view: LottieAnimationView, + onEnd: () -> Unit, + applyFinalState: () -> Unit, + ) { + var finished = false + lateinit var listener: Animator.AnimatorListener + val finish = { + if (!finished) { + finished = true + view.removeAnimatorListener(listener) + applyFinalState() + pendingExit = null + onEnd() + } + } + listener = object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + finish() + } + } + view.addAnimatorListener(listener) + pendingExit = LottieExit(view, listener, finish) + view.playAnimation() + } + + /** One decoration: its view, its anchoring and fit policy, and its choreography. */ + private class Decoration( + val view: LottieAnimationView, + val anchorsCardOnPhone: Boolean, + val anchoredCardBiasPhone: Float, + val anchoredCardBiasTablet: Float, + val maxHeightDp: Int, + val minHeightDp: Int, + val bottomOverlapPx: () -> Int = { 0 }, + val enter: () -> List, + val exit: (onEnd: () -> Unit) -> List, + val snap: () -> Unit, + ) + + private class LottieExit( + val view: LottieAnimationView, + val listener: Animator.AnimatorListener, + val finish: () -> Unit, + ) + + private companion object { + const val WING_START_DELAY = 300L + const val WING_FADE_IN_DURATION = 150L + const val WING_STOP_PROGRESS = 0.5f + + const val WALKING_DAX_DELAY = 400L + const val WALKING_DAX_FADE_DURATION = 100L + const val WALKING_DAX_SLIDE_DURATION = 600L + const val WALKING_DAX_START_X_DP = 48 + const val WALKING_DAX_FINAL_X_DP = 22 + const val WALKING_DAX_MAX_HEIGHT_DP = 274 + const val WALKING_DAX_MIN_HEIGHT_DP = 174 + const val BOTTOM_WING_MAX_HEIGHT_DP = 199 + const val BOTTOM_WING_MIN_HEIGHT_DP = 130 + const val LEFT_WING_MAX_HEIGHT_DP = 196 + const val LEFT_WING_MIN_HEIGHT_DP = 130 + const val LEFT_WING_CARD_GAP_DP = 8 + const val BOBBING_DAX_MAX_HEIGHT_DP = 156 + const val BOBBING_DAX_MIN_HEIGHT_DP = 130 + + val WALKING_DAX_INTERPOLATOR = PathInterpolator(0.33f, 0f, 0.67f, 1f) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt index 8d87cf1f854b..572827fd9fe4 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt @@ -21,7 +21,6 @@ import com.duckduckgo.app.onboarding.ui.page.configdriven.BindScope import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentConfig import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentHandle import com.duckduckgo.app.onboarding.ui.page.configdriven.CtaConfig -import com.duckduckgo.app.onboarding.ui.page.configdriven.Embellishment import com.duckduckgo.onboarding.api.LinearOnboardingStepId /** @@ -34,22 +33,6 @@ data class SettledDecoration( val anchoredCardBiasTablet: Float, ) -interface EmbellishmentController { - /** - * [onSettled] reports what the fit check settled on. When a decoration is leaving, it fires only once that - * exit has finished: the card must keep its anchor until the outgoing decoration is gone. - */ - fun transition( - previous: Embellishment?, - next: Embellishment, - animate: Boolean, - onSettled: (SettledDecoration?) -> Unit, - ) - - fun skipRunning() - fun release() -} - interface ContentController { /** Hides every content include. Used before the first bind, when includes still sit at their XML defaults. */ fun resetStage() From 401cd08efd79dd60185fd9c474489b6136ff882a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Wed, 29 Jul 2026 18:10:12 +0200 Subject: [PATCH 06/28] add card stage owning reveal, morph, fade and cta choreography --- .../ui/page/configdriven/engine/CardStage.kt | 223 ++++++++++++++++++ .../configdriven/engine/RenderControllers.kt | 24 -- 2 files changed, 223 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt new file mode 100644 index 000000000000..9962da26e198 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.engine + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.transition.ChangeBounds +import androidx.transition.Transition +import androidx.transition.TransitionListenerAdapter +import androidx.transition.TransitionManager +import com.duckduckgo.app.browser.databinding.ContentOnboardingWelcomePageUpdateBinding +import com.duckduckgo.app.onboarding.ui.page.configdriven.CtaConfig +import com.duckduckgo.common.ui.view.button.DaxButton + +/** The card choreography shared by every screen. Each call runs synchronously to its end state when not animating. */ +interface CardStage { + fun reveal(animate: Boolean, onEnd: () -> Unit) + + fun morph(animate: Boolean, onEnd: () -> Unit) + + /** + * CTA text, visibility and click handling. A CTA fading in from alpha 0 cannot be clicked early even though + * it is bound here: the card container swallows its children's touches for as long as an entrance runs. + */ + fun showCtas(primary: CtaConfig?, secondary: CtaConfig?, onClick: (CtaConfig) -> Unit) + + /** Hides [contentTargets] and the visible CTAs so an entrance can fade them in. */ + fun prepareEntrance(contentTargets: List) + + fun fadeInContent(contentTargets: List, animate: Boolean, onEnd: () -> Unit) + + /** Ends whatever is in flight, running its continuation now rather than at its natural completion. */ + fun settle() + + fun release() +} + +class CardStageImpl(private val binding: ContentOnboardingWelcomePageUpdateBinding) : CardStage { + + private val runningAnimators = mutableListOf() + + /** + * The morph continuation waiting on a `ChangeBounds` that has not ended yet. Held so [settle] can run it + * early, and nulled first so the transition's own `onTransitionEnd` does not run it a second time. + */ + private var pendingMorph: (() -> Unit)? = null + + private var ctaViews = emptyList() + + override fun reveal(animate: Boolean, onEnd: () -> Unit) { + val card = binding.daxDialogCta.root + card.isVisible = true + // Alpha already 1 means the card is on stage from an earlier render, so there is nothing to fade. + if (card.alpha == 1f) { + onEnd() + return + } + if (!animate) { + card.alpha = 1f + onEnd() + return + } + val reveal = ObjectAnimator.ofFloat(card, View.ALPHA, 1f).apply { + startDelay = CARD_FADE_IN_START_DELAY_MS + duration = CARD_FADE_IN_DURATION_MS + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) = onEnd() + }, + ) + } + track(reveal) + reveal.start() + } + + override fun morph(animate: Boolean, onEnd: () -> Unit) { + // A delayed transition no-ops on a root that has not been laid out and its end callback never fires, so + // the continuation has to run directly; the first layout pass places everything anyway. + if (!animate || !binding.root.isLaidOut) { + onEnd() + return + } + val transition: Transition = ChangeBounds().setDuration(CARD_MORPH_DURATION_MS) + transition.addListener( + object : TransitionListenerAdapter() { + override fun onTransitionEnd(transition: Transition) { + val continuation = pendingMorph ?: return + pendingMorph = null + continuation() + } + }, + ) + pendingMorph = onEnd + // ViewBinding types the root as View because the layout has multiple variants; the page root is always + // a ViewGroup. + TransitionManager.beginDelayedTransition(binding.root as ViewGroup, transition) + // Guarantees a layout pass is observed even if none of this render's view mutations triggered one. + binding.root.requestLayout() + } + + override fun showCtas( + primary: CtaConfig?, + secondary: CtaConfig?, + onClick: (CtaConfig) -> Unit, + ) { + bindCta(binding.daxDialogCta.primaryCta, primary, onClick) + bindCta(binding.daxDialogCta.secondaryCta, secondary, onClick) + ctaViews = listOfNotNull( + binding.daxDialogCta.primaryCta.takeIf { primary != null }, + binding.daxDialogCta.secondaryCta.takeIf { secondary != null }, + ) + } + + override fun prepareEntrance(contentTargets: List) { + (contentTargets + ctaViews).forEach { it.alpha = 0f } + } + + override fun fadeInContent( + contentTargets: List, + animate: Boolean, + onEnd: () -> Unit, + ) { + val targets = contentTargets + ctaViews + if (!animate) { + targets.forEach { it.alpha = 1f } + onEnd() + return + } + if (targets.isEmpty()) { + onEnd() + return + } + val fade = AnimatorSet().apply { + playTogether(targets.map { view -> ObjectAnimator.ofFloat(view, View.ALPHA, 1f).setDuration(CONTENT_FADE_DURATION_MS) }) + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) = onEnd() + }, + ) + } + track(fade) + fade.start() + } + + override fun settle() { + pendingMorph?.let { continuation -> + pendingMorph = null + continuation() + } + drain { it.end() } + } + + override fun release() { + pendingMorph = null + drain { it.cancel() } + } + + private fun bindCta( + view: DaxButton, + cta: CtaConfig?, + onClick: (CtaConfig) -> Unit, + ) { + // Cleared first so a listener closing over an already-unbound handle is never retained. + view.setOnClickListener(null) + if (cta == null) { + view.isGone = true + return + } + view.text = cta.text.resolve(view.context) + view.isVisible = true + view.setOnClickListener { onClick(cta) } + } + + private fun track(animator: Animator) { + runningAnimators += animator + animator.addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + runningAnimators -= animation + } + }, + ) + } + + /** + * Snapshot-and-remove before running [operation]: ending an animator can synchronously create the next one + * in the chain, and those must be drained by the next pass rather than dropped. + */ + private fun drain(operation: (Animator) -> Unit) { + while (runningAnimators.isNotEmpty()) { + val animators = runningAnimators.toList() + runningAnimators.removeAll(animators) + animators.forEach(operation) + } + } + + private companion object { + const val CARD_FADE_IN_START_DELAY_MS = 200L + const val CARD_FADE_IN_DURATION_MS = 400L + const val CARD_MORPH_DURATION_MS = 400L + const val CONTENT_FADE_DURATION_MS = 200L + } +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt index 572827fd9fe4..6a8e12e68fd3 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt @@ -20,7 +20,6 @@ import android.view.View import com.duckduckgo.app.onboarding.ui.page.configdriven.BindScope import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentConfig import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentHandle -import com.duckduckgo.app.onboarding.ui.page.configdriven.CtaConfig import com.duckduckgo.onboarding.api.LinearOnboardingStepId /** @@ -41,26 +40,3 @@ interface ContentController { fun hideBound() } - -/** The card choreography shared by every screen. Each call runs synchronously to its end state when not animating. */ -interface CardStage { - fun reveal(animate: Boolean, onEnd: () -> Unit) - - fun morph(animate: Boolean, onEnd: () -> Unit) - - /** - * CTA text, visibility and click handling. A CTA fading in from alpha 0 cannot be clicked early even though - * it is bound here: the card container swallows its children's touches for as long as an entrance runs. - */ - fun showCtas(primary: CtaConfig?, secondary: CtaConfig?, onClick: (CtaConfig) -> Unit) - - /** Hides [contentTargets] and the visible CTAs so an entrance can fade them in. */ - fun prepareEntrance(contentTargets: List) - - fun fadeInContent(contentTargets: List, animate: Boolean, onEnd: () -> Unit) - - /** Ends whatever is in flight, running its continuation now rather than at its natural completion. */ - fun settle() - - fun release() -} From 186c762dfefda96b5040c702a9ffbef6736be4e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Wed, 29 Jul 2026 18:11:45 +0200 Subject: [PATCH 07/28] add comparison chart and address bar binders with the content controller --- .../configdriven/binders/AddressBarBinder.kt | 59 ++++++ .../binders/ComparisonChartBinder.kt | 183 ++++++++++++++++++ .../configdriven/engine/ContentController.kt | 94 +++++++++ .../configdriven/engine/RenderControllers.kt | 13 -- 4 files changed, 336 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/AddressBarBinder.kt create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ComparisonChartBinder.kt create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/ContentController.kt diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/AddressBarBinder.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/AddressBarBinder.kt new file mode 100644 index 000000000000..43a5e79f36ad --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/AddressBarBinder.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.binders + +import android.view.View +import com.duckduckgo.app.browser.databinding.IncludeBrandDesignAddressBarPositionBinding +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingEvent +import com.duckduckgo.app.onboarding.ui.page.configdriven.AddressBarContentState +import com.duckduckgo.app.onboarding.ui.page.configdriven.BindScope +import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentConfig +import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentHandle +import com.duckduckgo.app.onboarding.ui.page.configdriven.StatefulDialogBinder +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class AddressBarBinder( + private val binding: IncludeBrandDesignAddressBarPositionBinding, + private val isLightMode: () -> Boolean, +) : StatefulDialogBinder { + + override val view: View = binding.root + + override fun bind( + content: ContentConfig.AddressBar, + state: MutableStateFlow, + scope: BindScope, + ): ContentHandle = with(binding) { + addressBarPicker.setLightMode(isLightMode()) + addressBarPicker.isSplitOptionVisible = content.showSplitOption + addressBarPicker.setSelection(state.value.position, animate = false) + addressBarPicker.setOnSelectionChangedListener { position -> state.update { it.copy(position = position) } } + scope.coroutineScope.launch { + state.collect { addressBarPicker.setSelection(it.position, animate = true) } + } + + addressBarTitle.setTitle(content.title.resolve(root.context)) + + ContentHandle( + title = addressBarTitle, + fadeTargets = listOf(addressBarPicker), + result = { NewUserOnboardingEvent.AddressBarConfirmed(state.value.position) }, + ) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ComparisonChartBinder.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ComparisonChartBinder.kt new file mode 100644 index 000000000000..e32dfc888e8a --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ComparisonChartBinder.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.binders + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.graphics.drawable.AnimatedVectorDrawable +import android.os.Build +import android.view.LayoutInflater +import android.view.View +import android.view.animation.OvershootInterpolator +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.databinding.IncludeBrandDesignComparisonChartBinding +import com.duckduckgo.app.onboarding.ui.page.ComparisonChartConfig +import com.duckduckgo.app.onboarding.ui.page.configdriven.BindScope +import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentConfig +import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentHandle +import com.duckduckgo.app.onboarding.ui.page.configdriven.DialogBinder +import com.duckduckgo.common.ui.view.addBottomShadow +import com.duckduckgo.common.ui.view.text.DaxTextView +import com.duckduckgo.common.ui.view.toPx +import com.duckduckgo.common.utils.extensions.preventWidows +import com.duckduckgo.mobile.android.R as CommonR + +class ComparisonChartBinder( + private val binding: IncludeBrandDesignComparisonChartBinding, +) : DialogBinder { + + override val view: View = binding.root + + override fun bind(content: ContentConfig.ComparisonChart, scope: BindScope): ContentHandle { + val context = binding.root.context + val config = content.config + + binding.comparisonChartHeaderLeftIcon.setImageResource(config.headerLeftIconRes) + binding.comparisonChartHeaderLeftIcon.updateLayoutParams { + width = config.headerLeftIconSizeDp.toPx(context).toInt() + height = config.headerLeftIconSizeDp.toPx(context).toInt() + } + if (Build.VERSION.SDK_INT >= 28) { + binding.comparisonChartHeaderLeftIconCard.addBottomShadow() + binding.comparisonChartHeaderRightIconCard.addBottomShadow() + } + if (config.headerLeftLabelRes != null) { + binding.comparisonChartHeaderLabel.text = context.getString(config.headerLeftLabelRes).preventWidows() + binding.comparisonChartHeaderLabel.isVisible = true + } else { + binding.comparisonChartHeaderLabel.isVisible = false + } + populateRows(config) + + binding.comparisonChartTitle.setTitle(content.title.resolve(context)) + + return ContentHandle( + title = binding.comparisonChartTitle, + fadeTargets = listOf(binding.comparisonTable), + afterFade = { checkIconStaggerAnimator() }, + ) + } + + private fun populateRows(config: ComparisonChartConfig) { + val context = binding.root.context + binding.comparisonRows.removeAllViews() + val inflater = LayoutInflater.from(binding.comparisonRows.context) + config.rows.forEachIndexed { index, row -> + val rowView = inflater.inflate( + R.layout.include_brand_design_comparison_chart_row, + binding.comparisonRows, + false, + ) as LinearLayout + rowView.findViewById(R.id.rowIcon).setImageResource(row.iconRes) + rowView.findViewById(R.id.rowText).text = context.getString(row.textRes) + if (index % 2 == 0) { + rowView.setBackgroundResource(R.drawable.background_comparison_chart_row_highlighted) + } + binding.comparisonRows.addView(rowView) + } + } + + private fun comparisonCheckViews(): List = + binding.comparisonRows.children + .map { it.findViewById(R.id.rowCheck) } + .toList() + + /** + * One AnimatorSet the engine owns end to end: per row, fades and scales the check icon in with an overshoot + * interpolator, plus a zero-duration trigger animator starting the icon's AnimatedVectorDrawable at its own + * relative delay. Each row's listener forces the final state in `onAnimationEnd`, which Android invokes on + * natural completion and on cancel alike, so the engine's `end()`/`cancel()` both land the views visible even + * when a row was still pending on its start delay. + */ + private fun checkIconStaggerAnimator(): Animator { + val overshoot = OvershootInterpolator(CHECK_ICON_OVERSHOOT_TENSION) + val checkViews = comparisonCheckViews() + + // The alpha fade completes before the drawable starts, so a stale trimPathEnd from a previous run would + // render the tick fully drawn during the gap. + checkViews.forEach { checkView -> + (checkView.drawable?.mutate() as? AnimatedVectorDrawable)?.reset() + } + + val rowAnimators: List = checkViews.mapIndexed { index, checkView -> + AnimatorSet().apply { + playTogether( + ObjectAnimator.ofFloat(checkView, View.ALPHA, 0f, 1f).apply { + duration = CHECK_ICON_FADE_DURATION + }, + ObjectAnimator.ofFloat(checkView, View.SCALE_X, 0f, 1f).apply { + duration = CHECK_ICON_ANIMATION_DURATION + interpolator = overshoot + }, + ObjectAnimator.ofFloat(checkView, View.SCALE_Y, 0f, 1f).apply { + duration = CHECK_ICON_ANIMATION_DURATION + interpolator = overshoot + }, + avdStartTrigger(checkView), + ) + startDelay = index * CHECK_ICON_STAGGER_DELAY + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + checkView.alpha = 1f + checkView.scaleX = 1f + checkView.scaleY = 1f + checkView.setImageResource(CommonR.drawable.ic_check_green_24) + } + }, + ) + } + } + + return AnimatorSet().apply { playTogether(rowAnimators) } + } + + /** + * Zero-duration animator used purely as a delayed trigger for `AnimatedVectorDrawable.start()`. + * + * `ValueAnimator.end()` on a never-started animator fires `onAnimationStart` synchronously first, so only + * `cancel()` suppresses this trigger. + */ + private fun avdStartTrigger(checkView: ImageView): Animator = + ValueAnimator.ofInt(0, 1).apply { + startDelay = CHECK_ICON_AVD_START_DELAY + duration = 0L + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + (checkView.drawable as? AnimatedVectorDrawable)?.start() + } + }, + ) + } + + private companion object { + const val CHECK_ICON_ANIMATION_DURATION = 400L + const val CHECK_ICON_FADE_DURATION = 130L + const val CHECK_ICON_STAGGER_DELAY = 130L + const val CHECK_ICON_OVERSHOOT_TENSION = 2.4f + const val CHECK_ICON_AVD_START_DELAY = 180L + } +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/ContentController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/ContentController.kt new file mode 100644 index 000000000000..f5ff91ff541b --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/ContentController.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.engine + +import android.view.View +import androidx.core.view.isVisible +import com.duckduckgo.app.browser.databinding.PreOnboardingDaxDialogCtaBrandDesignUpdateBinding +import com.duckduckgo.app.onboarding.ui.page.configdriven.BindScope +import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentConfig +import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentHandle +import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentValueStore +import com.duckduckgo.app.onboarding.ui.page.configdriven.binders.AddressBarBinder +import com.duckduckgo.app.onboarding.ui.page.configdriven.binders.ComparisonChartBinder +import com.duckduckgo.onboarding.api.LinearOnboardingStepId + +interface ContentController { + /** Hides every content include. Used before the first bind, when includes still sit at their XML defaults. */ + fun resetStage() + + fun bind(stepId: LinearOnboardingStepId, content: ContentConfig, scope: BindScope): ContentHandle + + fun hideBound() +} + +/** + * Routes a [ContentConfig] to the one binder that renders it, and owns which content include is on show. + */ +class ContentControllerImpl( + private val binding: PreOnboardingDaxDialogCtaBrandDesignUpdateBinding, + private val contentValues: ContentValueStore, + isLightMode: () -> Boolean, +) : ContentController { + + private val comparisonChart = ComparisonChartBinder(binding.comparisonChartContent) + private val addressBar = AddressBarBinder(binding.addressBarContent, isLightMode) + + private var boundView: View? = null + + /** + * Covers every content include, not only the ones with a binder: some default to visible in the card + * layout, so a first render of any other screen would otherwise leave one stacked above it, reserving + * blank height inside the card. + */ + override fun resetStage() { + listOf( + binding.welcomeContent.root, + binding.comparisonChartContent.root, + binding.addressBarContent.root, + binding.inputScreenContent.root, + binding.inputScreenPreviewContent.root, + binding.reinstallerQuickSetupContent.root, + binding.addToDockContent.root, + binding.widgetPromptContent.root, + ).forEach { it.isVisible = false } + } + + override fun bind( + stepId: LinearOnboardingStepId, + content: ContentConfig, + scope: BindScope, + ): ContentHandle { + val handle = when (content) { + is ContentConfig.ComparisonChart -> { + boundView = comparisonChart.view + comparisonChart.bind(content, scope) + } + is ContentConfig.AddressBar -> { + boundView = addressBar.view + addressBar.bind(content, contentValues.contentState(stepId, content), scope) + } + } + boundView?.isVisible = true + return handle + } + + override fun hideBound() { + boundView?.isVisible = false + boundView = null + } +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt index 6a8e12e68fd3..66a4fc124262 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt @@ -17,10 +17,6 @@ package com.duckduckgo.app.onboarding.ui.page.configdriven.engine import android.view.View -import com.duckduckgo.app.onboarding.ui.page.configdriven.BindScope -import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentConfig -import com.duckduckgo.app.onboarding.ui.page.configdriven.ContentHandle -import com.duckduckgo.onboarding.api.LinearOnboardingStepId /** * The decoration the embellishment controller settled on, after its fit check. @@ -31,12 +27,3 @@ data class SettledDecoration( val anchoredCardBiasPhone: Float, val anchoredCardBiasTablet: Float, ) - -interface ContentController { - /** Hides every content include. Used before the first bind, when includes still sit at their XML defaults. */ - fun resetStage() - - fun bind(stepId: LinearOnboardingStepId, content: ContentConfig, scope: BindScope): ContentHandle - - fun hideBound() -} From b026518bc94c125691046417c318932546c90fb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Wed, 29 Jul 2026 18:13:48 +0200 Subject: [PATCH 08/28] add dialog config resolver for the comparison chart and address bar screens --- .../page/configdriven/DialogConfigResolver.kt | 77 ++++++++++++++++ .../configdriven/DialogConfigResolverTest.kt | 90 +++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolver.kt create mode 100644 app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolverTest.kt diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolver.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolver.kt new file mode 100644 index 000000000000..0685e14a4db8 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolver.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven + +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.omnibar.OmnibarType +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingActivityDialog +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingEvent +import com.duckduckgo.app.onboarding.ui.page.ComparisonChartConfig +import com.duckduckgo.app.onboarding.ui.page.OnboardingBackgroundStep +import javax.inject.Inject + +class DialogConfigResolver @Inject constructor() { + + fun resolve( + dialog: NewUserOnboardingActivityDialog, + isCustomAiFlow: Boolean, + ): DialogConfig? = when (dialog) { + NewUserOnboardingActivityDialog.ComparisonChart -> comparisonChart(ComparisonChartConfig.Browser(isCustomAiCopy = isCustomAiFlow)) + + NewUserOnboardingActivityDialog.AiComparisonChart -> comparisonChart(ComparisonChartConfig.Ai) + + is NewUserOnboardingActivityDialog.AddressBarPosition -> DialogConfig( + background = OnboardingBackgroundStep.AddressBar, + embellishment = Embellishment.BobbingDax, + cardArrow = CardArrowConfig.AtEnd, + content = ContentConfig.AddressBar( + title = TextConfig.Resource(R.string.preOnboardingAddressBarTitle), + initialPosition = OmnibarType.SINGLE_TOP, + showSplitOption = dialog.showSplitOption, + ), + primaryCta = CtaConfig( + text = TextConfig.Resource(R.string.preOnboardingAddressBarOkButton), + action = CtaAction.Submit, + ), + ) + + is NewUserOnboardingActivityDialog.IntroAnimation, + NewUserOnboardingActivityDialog.NotificationPermission, + NewUserOnboardingActivityDialog.DefaultBrowserPrompt, + NewUserOnboardingActivityDialog.AddWidget, + NewUserOnboardingActivityDialog.SyncRestore, + NewUserOnboardingActivityDialog.InitialReinstallUser, + NewUserOnboardingActivityDialog.Initial, + NewUserOnboardingActivityDialog.AddToDock, + NewUserOnboardingActivityDialog.WidgetPrompt, + NewUserOnboardingActivityDialog.InputScreen, + is NewUserOnboardingActivityDialog.InputScreenPreview, + is NewUserOnboardingActivityDialog.QuickSetup, + -> null // to be implemented in following tasks + } + + private fun comparisonChart(chart: ComparisonChartConfig) = DialogConfig( + background = OnboardingBackgroundStep.ComparisonChart, + embellishment = Embellishment.BottomWing, + cardArrow = CardArrowConfig.AtEnd, + content = ContentConfig.ComparisonChart(title = TextConfig.Resource(chart.titleRes), config = chart), + primaryCta = CtaConfig( + text = TextConfig.Resource(chart.primaryCtaTextRes), + action = CtaAction.Emit(NewUserOnboardingEvent.ContinueClicked), + ), + ) +} diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolverTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolverTest.kt new file mode 100644 index 000000000000..16180a537cae --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogConfigResolverTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven + +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.omnibar.OmnibarType +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingActivityDialog +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingEvent +import com.duckduckgo.app.onboarding.ui.page.ComparisonChartConfig +import com.duckduckgo.app.onboarding.ui.page.OnboardingBackgroundStep +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DialogConfigResolverTest { + + private val testee = DialogConfigResolver() + + @Test + fun `resolves the comparison chart with the browser chart config`() { + val config = testee.resolve(NewUserOnboardingActivityDialog.ComparisonChart, isCustomAiFlow = false)!! + + assertEquals(OnboardingBackgroundStep.ComparisonChart, config.background) + assertEquals(Embellishment.BottomWing, config.embellishment) + assertEquals(CardArrowConfig.AtEnd, config.cardArrow) + val expectedChart = ComparisonChartConfig.Browser(isCustomAiCopy = false) + assertEquals(ContentConfig.ComparisonChart(TextConfig.Resource(expectedChart.titleRes), expectedChart), config.content) + assertEquals( + CtaConfig(TextConfig.Resource(expectedChart.primaryCtaTextRes), CtaAction.Emit(NewUserOnboardingEvent.ContinueClicked)), + config.primaryCta, + ) + assertNull(config.secondaryCta) + } + + @Test + fun `resolves the comparison chart with custom ai copy in the custom ai flow`() { + val config = testee.resolve(NewUserOnboardingActivityDialog.ComparisonChart, isCustomAiFlow = true)!! + + val expectedChart = ComparisonChartConfig.Browser(isCustomAiCopy = true) + assertEquals(ContentConfig.ComparisonChart(TextConfig.Resource(expectedChart.titleRes), expectedChart), config.content) + } + + @Test + fun `resolves the ai comparison chart with the ai chart config`() { + val config = testee.resolve(NewUserOnboardingActivityDialog.AiComparisonChart, isCustomAiFlow = true)!! + + assertEquals( + ContentConfig.ComparisonChart(TextConfig.Resource(ComparisonChartConfig.Ai.titleRes), ComparisonChartConfig.Ai), + config.content, + ) + } + + @Test + fun `resolves the address bar position with a submitting cta`() { + val config = testee.resolve(NewUserOnboardingActivityDialog.AddressBarPosition(showSplitOption = true), isCustomAiFlow = false)!! + + assertEquals(OnboardingBackgroundStep.AddressBar, config.background) + assertEquals(Embellishment.BobbingDax, config.embellishment) + assertEquals( + ContentConfig.AddressBar( + title = TextConfig.Resource(R.string.preOnboardingAddressBarTitle), + initialPosition = OmnibarType.SINGLE_TOP, + showSplitOption = true, + ), + config.content, + ) + assertEquals(CtaAction.Submit, config.primaryCta!!.action) + } + + @Test + fun `resolves no config for a dialog that has no config-driven screen yet`() { + assertNull(testee.resolve(NewUserOnboardingActivityDialog.Initial, isCustomAiFlow = false)) + assertNull(testee.resolve(NewUserOnboardingActivityDialog.NotificationPermission, isCustomAiFlow = false)) + assertNull(testee.resolve(NewUserOnboardingActivityDialog.AddToDock, isCustomAiFlow = false)) + } +} From b2b21baf1cb2896e80ec554fab381b5b02816414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Wed, 29 Jul 2026 18:17:32 +0200 Subject: [PATCH 09/28] add config-driven onboarding page view model --- .../ConfigDrivenOnboardingPageViewModel.kt | 313 ++++++++++++++++++ ...ConfigDrivenOnboardingPageViewModelTest.kt | 239 +++++++++++++ 2 files changed, 552 insertions(+) create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt create mode 100644 app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModelTest.kt diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt new file mode 100644 index 000000000000..f863aa038046 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector +import com.duckduckgo.app.browser.omnibar.OmnibarType +import com.duckduckgo.app.global.DefaultRoleBrowserDialog +import com.duckduckgo.app.global.install.AppInstallStore +import com.duckduckgo.app.onboarding.CustomAiOnboardingStore +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingActivityDialog +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingActivityStep +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingEvent +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingPlanBootstrapper +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingPlanProvider +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingResult +import com.duckduckgo.app.onboarding.orchestrator.stepIndicatorProgress +import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter +import com.duckduckgo.app.widget.ui.WidgetCapabilities +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.onboarding.api.LinearOnboardingHost +import com.duckduckgo.onboarding.api.LinearOnboardingOrchestrator +import com.duckduckgo.onboarding.api.LinearOnboardingState +import com.duckduckgo.onboarding.api.LinearOnboardingStepId +import com.duckduckgo.onboarding.api.forPlan +import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +/** + * Resolves the orchestrator's current step into one [DialogConfig] and publishes it, so the fragment's render + * engine has a single value-comparable description of the screen to diff against. Live working state for + * stateful screens lives in the [ContentValueStore] this view model owns, not in [ViewState]. + */ +@SuppressLint("StaticFieldLeak") +@ContributesViewModel(FragmentScope::class) +class ConfigDrivenOnboardingPageViewModel @Inject constructor( + private val orchestrator: LinearOnboardingOrchestrator, + private val newUserOnboardingPlanBootstrapper: NewUserOnboardingPlanBootstrapper, + private val dialogConfigResolver: DialogConfigResolver, + private val dispatchers: DispatcherProvider, + private val defaultBrowserDetector: DefaultBrowserDetector, + private val widgetCapabilities: WidgetCapabilities, + private val defaultRoleBrowserDialog: DefaultRoleBrowserDialog, + private val context: Context, + private val pixel: Pixel, + private val appInstallStore: AppInstallStore, + private val customAiOnboardingStore: CustomAiOnboardingStore, +) : ViewModel() { + + data class ViewState( + val stepId: LinearOnboardingStepId? = null, + val config: DialogConfig? = null, + val animateEntry: Boolean = true, + ) + + sealed interface Command { + data object RequestNotificationPermissions : Command + data class ShowDefaultBrowserDialog(val intent: Intent) : Command + data object LaunchAddWidgetPrompt : Command + data object Finish : Command + data class FinishAndSubmitSearchQuery(val query: String) : Command + data class FinishAndSubmitChatPrompt(val prompt: String) : Command + data object OnboardingSkipped : Command + data object HandOffToBrowserActivity : Command + } + + private val _viewState = MutableStateFlow(ViewState()) + val viewState = _viewState.asStateFlow() + + private val _commands = Channel(1, DROP_OLDEST) + val commands: Flow = _commands.receiveAsFlow() + + /** Live state for stateful screens; survives rotation with this view model, exposed for the fragment's binders. */ + val contentValues = ContentValueStore() + + /** Last step id a [DialogConfig] was published for; drives the [ViewState.animateEntry] policy. */ + private var lastPresentedStepId: LinearOnboardingStepId? = null + + private var notificationPermissionFlowStarted = false + + private var addWidgetPromptFlowStarted = false + + init { + start() + } + + /** Blind forward: the engine, through a CTA click or the bound screen's own result, already resolved the event. */ + fun onEvent(event: NewUserOnboardingEvent) = emit(event) + + /** No [ContentInteraction] variants exist yet, so a bound screen has nothing to raise outside the CTA flow. */ + fun onContentInteraction(interaction: ContentInteraction) = Unit + + /** + * Flips the one-shot entry animation flag once the fragment has rendered [stepId], so a later rotation + * re-collection of [viewState], which replays its last value rather than recomputing it, snaps instead of + * replaying the entrance. No-op if the current step has since moved on. + */ + fun onDialogRendered(stepId: LinearOnboardingStepId) { + _viewState.update { if (it.stepId == stepId) it.copy(animateEntry = false) else it } + } + + fun onResume() { + checkAddWidgetPromptResult() + } + + fun notificationPermissionFlowFinished(granted: Boolean?) { + if (granted == true) { + pixel.fire(AppPixelName.NOTIFICATIONS_ENABLED, mapOf(PixelParameter.FROM_ONBOARDING to true.toString())) + } + emit(NewUserOnboardingEvent.NotificationPermissionFinished(granted = granted)) + } + + /** + * Fires as the runtime permission dialog is about to be requested. That dialog is the screen for this step, + * so this doubles as its shown signal. + */ + fun notificationRuntimePermissionRequested() { + pixel.fire(AppPixelName.NOTIFICATION_RUNTIME_PERMISSION_SHOWN) + emit(NewUserOnboardingEvent.Presented) + } + + fun onDefaultBrowserSet() { + recordDefaultBrowserDialogResult(isSet = true) + emit(NewUserOnboardingEvent.DefaultBrowserPromptFinished(isDefaultBrowser = true)) + } + + fun onDefaultBrowserNotSet() { + recordDefaultBrowserDialogResult(isSet = false) + emit(NewUserOnboardingEvent.DefaultBrowserPromptFinished(isDefaultBrowser = false)) + } + + fun checkAddWidgetPromptResult() { + if (addWidgetPromptFlowStarted) { + viewModelScope.launch { + val hasWidget = withContext(dispatchers.io()) { widgetCapabilities.hasInstalledWidgets } + addWidgetPromptFlowStarted = false + orchestrator.onEvent(NewUserOnboardingEvent.AddWidgetFinished(widgetAdded = hasWidget)) + } + } + } + + private fun recordDefaultBrowserDialogResult(isSet: Boolean) { + defaultRoleBrowserDialog.dialogShown() + appInstallStore.defaultBrowser = isSet + val pixelName = if (isSet) AppPixelName.DEFAULT_BROWSER_SET else AppPixelName.DEFAULT_BROWSER_NOT_SET + pixel.fire(pixelName, mapOf(PixelParameter.DEFAULT_BROWSER_SET_FROM_ONBOARDING to true.toString())) + } + + private fun start() { + viewModelScope.launch { + if (orchestrator.state.value is LinearOnboardingState.NotStarted) { + // Safeguard in case OnboardingActivity is restored after process death and does not route + // through LaunchViewModel; restart the plan so onboarding resumes from the top. + newUserOnboardingPlanBootstrapper.startNewUserOnboardingPlan() + } + observeOrchestratorState() + } + } + + private fun observeOrchestratorState() { + orchestrator.state + .forPlan(NewUserOnboardingPlanProvider.ROOT_PLAN_ID) + .onEach { state -> + when (state) { + is LinearOnboardingState.InProgress -> { + val step = state.currentStep + when (step.host) { + LinearOnboardingHost.OnboardingActivity -> { + // stay + } + LinearOnboardingHost.BrowserActivity -> { + _commands.send(Command.HandOffToBrowserActivity) + return@onEach + } + else -> { + // This view model only drives the new-user onboarding plan; any other host is + // not its screen. + return@onEach + } + } + if (step is NewUserOnboardingActivityStep) { + applyStep(step, state) + } + } + is LinearOnboardingState.Completed -> { + when (val result = state.result as? NewUserOnboardingResult) { + is NewUserOnboardingResult.LaunchChat -> _commands.send(Command.FinishAndSubmitChatPrompt(prompt = result.prompt)) + is NewUserOnboardingResult.LaunchSearch -> _commands.send(Command.FinishAndSubmitSearchQuery(query = result.query)) + null -> _commands.send(Command.Finish) + } + } + is LinearOnboardingState.Skipped -> _commands.send(Command.OnboardingSkipped) + } + } + .launchIn(viewModelScope) + } + + private suspend fun applyStep( + step: NewUserOnboardingActivityStep, + state: LinearOnboardingState.InProgress, + ) { + val dialog = step.resolveDialog() + val config = dialogConfigResolver.resolve(dialog, customAiOnboardingStore.isEnabled()) + if (config != null) { + _viewState.update { + it.copy( + stepId = step.id, + config = config.copy(stepIndicator = state.stepIndicatorProgress()), + animateEntry = step.id != lastPresentedStepId, + ) + } + lastPresentedStepId = step.id + emit(NewUserOnboardingEvent.Presented) + } else { + advancePastUnrenderedDialog(dialog) + } + } + + /** + * Handle commands and dialogs the renderer doesn't support yet by advancing past them, without reporting + * them as presented. + */ + private suspend fun advancePastUnrenderedDialog(dialog: NewUserOnboardingActivityDialog) { + when (dialog) { + is NewUserOnboardingActivityDialog.IntroAnimation -> emit(NewUserOnboardingEvent.IntroAnimationFinished) + + NewUserOnboardingActivityDialog.NotificationPermission -> { + if (!notificationPermissionFlowStarted) { + notificationPermissionFlowStarted = true + viewModelScope.launch { + delay(2.seconds) + _commands.send(Command.RequestNotificationPermissions) + } + } + } + + NewUserOnboardingActivityDialog.DefaultBrowserPrompt -> { + val intent = defaultRoleBrowserDialog.createIntent(context) + if (intent != null) { + _commands.send(Command.ShowDefaultBrowserDialog(intent)) + } else { + pixel.fire(AppPixelName.DEFAULT_BROWSER_DIALOG_NOT_SHOWN) + emit(NewUserOnboardingEvent.DefaultBrowserPromptFinished(isDefaultBrowser = false)) + } + } + + NewUserOnboardingActivityDialog.AddWidget -> { + addWidgetPromptFlowStarted = true + _commands.send(Command.LaunchAddWidgetPrompt) + } + + NewUserOnboardingActivityDialog.SyncRestore -> emit(NewUserOnboardingEvent.SkipRequested) + + NewUserOnboardingActivityDialog.InitialReinstallUser, + NewUserOnboardingActivityDialog.Initial, + NewUserOnboardingActivityDialog.AddToDock, + -> emit(NewUserOnboardingEvent.ContinueClicked) + + NewUserOnboardingActivityDialog.WidgetPrompt -> emit(NewUserOnboardingEvent.WidgetPromptSkipped) + + NewUserOnboardingActivityDialog.InputScreen -> emit(NewUserOnboardingEvent.InputModeConfirmed(withAi = true)) + + is NewUserOnboardingActivityDialog.InputScreenPreview -> emit(NewUserOnboardingEvent.ContinueClicked) + + is NewUserOnboardingActivityDialog.QuickSetup -> emit( + NewUserOnboardingEvent.QuickSetupConfirmed(type = OmnibarType.SINGLE_TOP, withAi = true), + ) + + NewUserOnboardingActivityDialog.ComparisonChart, + NewUserOnboardingActivityDialog.AiComparisonChart, + is NewUserOnboardingActivityDialog.AddressBarPosition, + -> Unit + } + } + + private fun emit(event: NewUserOnboardingEvent) { + viewModelScope.launch { orchestrator.onEvent(event) } + } +} diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModelTest.kt new file mode 100644 index 000000000000..82bf51f6615e --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModelTest.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import app.cash.turbine.test +import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector +import com.duckduckgo.app.global.DefaultRoleBrowserDialog +import com.duckduckgo.app.global.install.AppInstallStore +import com.duckduckgo.app.onboarding.CustomAiOnboardingStore +import com.duckduckgo.app.onboarding.orchestrator.NewUserBrowserActivityAction +import com.duckduckgo.app.onboarding.orchestrator.NewUserBrowserActivityStep +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingActivityDialog +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingActivityStep +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingEvent +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingPlanBootstrapper +import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingPlanProvider +import com.duckduckgo.app.onboarding.ui.page.OnboardingBackgroundStep +import com.duckduckgo.app.onboarding.ui.page.configdriven.ConfigDrivenOnboardingPageViewModel.Command +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.widget.ui.WidgetCapabilities +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.onboarding.api.LinearOnboardingEvent +import com.duckduckgo.onboarding.api.LinearOnboardingOrchestrator +import com.duckduckgo.onboarding.api.LinearOnboardingPlan +import com.duckduckgo.onboarding.api.LinearOnboardingResult +import com.duckduckgo.onboarding.api.LinearOnboardingState +import com.duckduckgo.onboarding.api.LinearOnboardingTransition +import com.duckduckgo.onboarding.impl.LinearOnboardingOrchestratorImpl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@SuppressLint("DenyListedApi") +class ConfigDrivenOnboardingPageViewModelTest { + + @get:Rule + @Suppress("unused") + val coroutineRule = CoroutineTestRule() + + private val mockDefaultRoleBrowserDialog: DefaultRoleBrowserDialog = mock() + private val mockContext: Context = mock() + private val pixel: Pixel = mock() + private val mockAppInstallStore: AppInstallStore = mock() + private val mockDefaultBrowserDetector: DefaultBrowserDetector = mock() + private val mockWidgetCapabilities: WidgetCapabilities = mock() + private val customAiOnboardingStore: CustomAiOnboardingStore = mock() + private val newUserOnboardingPlanBootstrapper: NewUserOnboardingPlanBootstrapper = mock() + + // Default harness: mock orchestrator left NotStarted, so the view model renders no dialog and emits no + // commands on its own — the interaction tests drive a single method and assert exactly what it emits. + private val orchestratorState = MutableStateFlow(LinearOnboardingState.NotStarted) + private val mockOrchestrator: LinearOnboardingOrchestrator = mock { + on { state } doReturn orchestratorState + } + + // Real orchestrator, used by the flow tests that need an actual plan and step rendered. + private val realOrchestrator = LinearOnboardingOrchestratorImpl() + + private val recordedEvents = mutableListOf() + + // Records the event the view model emitted and stays on the same dialog. Presented is the view model's + // "step rendered" signal, so it is filtered out as noise. + private val recordAndStay: suspend (LinearOnboardingEvent) -> LinearOnboardingTransition = { event -> + if (event !is NewUserOnboardingEvent.Presented) { + recordedEvents.add(event) + } + LinearOnboardingTransition.Stay + } + + @Before + fun setUp() { + runBlocking { whenever(customAiOnboardingStore.isEnabled()).thenReturn(false) } + } + + private fun createViewModel( + orchestrator: LinearOnboardingOrchestrator = mockOrchestrator, + ): ConfigDrivenOnboardingPageViewModel = ConfigDrivenOnboardingPageViewModel( + orchestrator = orchestrator, + newUserOnboardingPlanBootstrapper = newUserOnboardingPlanBootstrapper, + dialogConfigResolver = DialogConfigResolver(), + dispatchers = coroutineRule.testDispatcherProvider, + defaultBrowserDetector = mockDefaultBrowserDetector, + widgetCapabilities = mockWidgetCapabilities, + defaultRoleBrowserDialog = mockDefaultRoleBrowserDialog, + context = mockContext, + pixel = pixel, + appInstallStore = mockAppInstallStore, + customAiOnboardingStore = customAiOnboardingStore, + ) + + // A one-step plan that renders [dialog]. By default the step records every event it is handed and stays put, + // so the view model keeps showing [dialog] and the test can assert what it emitted. + private fun planAt( + dialog: NewUserOnboardingActivityDialog, + id: String = "step", + transition: suspend (LinearOnboardingEvent) -> LinearOnboardingTransition = recordAndStay, + result: suspend () -> LinearOnboardingResult? = { null }, + ): LinearOnboardingPlan = LinearOnboardingPlan( + id = NewUserOnboardingPlanProvider.ROOT_PLAN_ID, + steps = listOf(NewUserOnboardingActivityStep(id = id, pixelName = null, transition = transition, resolveDialog = { dialog })), + result = result, + ) + + private suspend fun startAt( + dialog: NewUserOnboardingActivityDialog, + id: String = "step", + transition: suspend (LinearOnboardingEvent) -> LinearOnboardingTransition = recordAndStay, + result: suspend () -> LinearOnboardingResult? = { null }, + ): ConfigDrivenOnboardingPageViewModel { + realOrchestrator.startPlan(planAt(dialog, id, transition, result)) + return createViewModel(realOrchestrator) + } + + private suspend fun startAtBrowserStep(): ConfigDrivenOnboardingPageViewModel { + val browserStep = NewUserBrowserActivityStep( + id = "duck_ai_demo", + pixelName = null, + transition = { LinearOnboardingTransition.Stay }, + resolveAction = { NewUserBrowserActivityAction.RunDuckAiOnboardingDemo("x") }, + ) + realOrchestrator.startPlan(LinearOnboardingPlan(id = NewUserOnboardingPlanProvider.ROOT_PLAN_ID, steps = listOf(browserStep))) + return createViewModel(realOrchestrator) + } + + @Test + fun `publishes the resolved config and reports the step as presented`() = runTest { + val testee = startAt(NewUserOnboardingActivityDialog.ComparisonChart) + advanceUntilIdle() + + val state = testee.viewState.value + assertEquals("step", state.stepId) + assertEquals(OnboardingBackgroundStep.ComparisonChart, state.config!!.background) + assertTrue(state.animateEntry) + } + + @Test + fun `stops animating a step's entry once it has been rendered`() = runTest { + val testee = startAt(NewUserOnboardingActivityDialog.ComparisonChart) + advanceUntilIdle() + + testee.onDialogRendered("step") + + assertFalse(testee.viewState.value.animateEntry) + } + + @Test + fun `keeps animating when a later step is reported as rendered`() = runTest { + val testee = startAt(NewUserOnboardingActivityDialog.ComparisonChart) + advanceUntilIdle() + + testee.onDialogRendered("a_different_step") + + assertTrue(testee.viewState.value.animateEntry) + } + + @Test + fun `forwards a cta event to the orchestrator untouched`() = runTest { + val testee = startAt(NewUserOnboardingActivityDialog.ComparisonChart) + advanceUntilIdle() + + testee.onEvent(NewUserOnboardingEvent.ContinueClicked) + advanceUntilIdle() + + assertEquals(listOf(NewUserOnboardingEvent.ContinueClicked), recordedEvents) + } + + @Test + fun `requests the notification permission for the notification dialog`() = runTest { + val testee = startAt(NewUserOnboardingActivityDialog.NotificationPermission) + + testee.commands.test { + advanceUntilIdle() + assertEquals(Command.RequestNotificationPermissions, awaitItem()) + } + } + + @Test + fun `shows the default browser dialog when the system offers one`() = runTest { + whenever(mockDefaultRoleBrowserDialog.createIntent(any())).thenReturn(Intent()) + val testee = startAt(NewUserOnboardingActivityDialog.DefaultBrowserPrompt) + + testee.commands.test { + advanceUntilIdle() + assertTrue(awaitItem() is Command.ShowDefaultBrowserDialog) + } + } + + @Test + fun `finishes onboarding when the plan completes with no result`() = runTest { + val testee = startAt( + dialog = NewUserOnboardingActivityDialog.ComparisonChart, + transition = { LinearOnboardingTransition.Advance }, + ) + + testee.commands.test { + testee.onEvent(NewUserOnboardingEvent.ContinueClicked) + advanceUntilIdle() + assertEquals(Command.Finish, awaitItem()) + } + } + + @Test + fun `hands off to the browser when the current step is browser hosted`() = runTest { + val testee = startAtBrowserStep() + + testee.commands.test { + advanceUntilIdle() + assertEquals(Command.HandOffToBrowserActivity, awaitItem()) + } + } +} From 010944f2554f2443c404f0fe3267e9611d977aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Wed, 29 Jul 2026 18:19:49 +0200 Subject: [PATCH 10/28] add config-driven onboarding page fragment --- .../configdriven/ConfigDrivenWelcomePage.kt | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt new file mode 100644 index 000000000000..190de20c96f8 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.os.Bundle +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.ViewCompat +import androidx.core.view.ViewGroupCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnLayout +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.databinding.ContentOnboardingWelcomePageUpdateBinding +import com.duckduckgo.app.onboarding.ui.OnboardingActivity +import com.duckduckgo.app.onboarding.ui.page.OnboardingBackgroundAnimator +import com.duckduckgo.app.onboarding.ui.page.OnboardingPageFragment +import com.duckduckgo.app.onboarding.ui.page.configdriven.engine.BackgroundControllerImpl +import com.duckduckgo.app.onboarding.ui.page.configdriven.engine.CardAnchorControllerImpl +import com.duckduckgo.app.onboarding.ui.page.configdriven.engine.CardArrowControllerImpl +import com.duckduckgo.app.onboarding.ui.page.configdriven.engine.CardStageImpl +import com.duckduckgo.app.onboarding.ui.page.configdriven.engine.ContentControllerImpl +import com.duckduckgo.app.onboarding.ui.page.configdriven.engine.DialogRenderEngine +import com.duckduckgo.app.onboarding.ui.page.configdriven.engine.EmbellishmentControllerImpl +import com.duckduckgo.app.onboarding.ui.page.configdriven.engine.StepIndicatorControllerImpl +import com.duckduckgo.app.widget.AddWidgetLauncher +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.common.ui.store.AppTheme +import com.duckduckgo.common.ui.view.toPx +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.common.utils.FragmentViewModelFactory +import com.duckduckgo.common.utils.device.DeviceInfo +import com.duckduckgo.common.utils.device.isTablet +import com.duckduckgo.di.scopes.FragmentScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject +import com.duckduckgo.mobile.android.R as CommonR + +/** + * Wires [ConfigDrivenOnboardingPageViewModel] to [DialogRenderEngine]: it owns the views, builds the engine's + * collaborators from the inflated binding, and routes commands. Every rendering decision belongs to the engine. + */ +@InjectWith(FragmentScope::class) +class ConfigDrivenWelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_page_update) { + + @Inject + lateinit var viewModelFactory: FragmentViewModelFactory + + @Inject + lateinit var appBuildConfig: AppBuildConfig + + @Inject + lateinit var deviceInfo: DeviceInfo + + @Inject + lateinit var appTheme: AppTheme + + @Inject + lateinit var addWidgetLauncher: AddWidgetLauncher + + private val binding: ContentOnboardingWelcomePageUpdateBinding by viewBinding() + private val viewModel by lazy { + ViewModelProvider(this, viewModelFactory)[ConfigDrivenOnboardingPageViewModel::class.java] + } + + private var engine: DialogRenderEngine? = null + private var backgroundAnimator: OnboardingBackgroundAnimator? = null + + /** Fed to the embellishment controller's fit corrector; kept in sync by the window-insets listener below. */ + private var cardBottomInsetPx = 0 + + private var hasRenderedOnce = false + + private val requestNotificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (view?.windowVisibility == View.VISIBLE) { + viewModel.notificationPermissionFlowFinished(granted) + } + } + + private val defaultBrowserRoleManagerDialog = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + viewModel.onDefaultBrowserSet() + } else { + viewModel.onDefaultBrowserNotSet() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requireActivity().enableEdgeToEdge() + } + + override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { + val inflater = super.onGetLayoutInflater(savedInstanceState) + val themeRes = if (appTheme.isLightModeEnabled()) { + CommonR.style.Theme_DuckDuckGo_Light_Onboarding + } else { + CommonR.style.Theme_DuckDuckGo_Dark_Onboarding + } + return inflater.cloneInContext(ContextThemeWrapper(inflater.context, themeRes)) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + ViewGroupCompat.installCompatInsetsDispatch(binding.root) + ViewCompat.setOnApplyWindowInsetsListener(binding.daxDialogCta.root) { v, windowInsets -> + val insets = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), + ) + v.updateLayoutParams { + topMargin = insets.top + } + // Under adjustResize, systemBars().bottom already includes the keyboard height while the IME shows, + // which would leave the card measuring against a gap that is about to disappear. + if (!windowInsets.isVisible(WindowInsetsCompat.Type.ime())) { + cardBottomInsetPx = insets.bottom + DIALOG_BOTTOM_INSET_GAP_DP.toPx() + } + windowInsets + } + + val newBackgroundAnimator = OnboardingBackgroundAnimator( + backgroundPrimary = binding.backgroundPrimary, + backgroundSecondary = binding.backgroundSecondary, + ) + backgroundAnimator = newBackgroundAnimator + + engine = DialogRenderEngine( + content = ContentControllerImpl( + binding = binding.daxDialogCta, + contentValues = viewModel.contentValues, + isLightMode = { appTheme.isLightModeEnabled() }, + ), + cardStage = CardStageImpl(binding), + background = BackgroundControllerImpl(newBackgroundAnimator), + embellishments = EmbellishmentControllerImpl( + binding = binding, + onDecorationHidden = { binding.daxDialogCta.cardView.setArrowDepthFraction(0f) }, + cardBottomInsetPx = { cardBottomInsetPx }, + ), + cardAnchor = CardAnchorControllerImpl(binding, deviceInfo.isTablet()), + cardArrow = CardArrowControllerImpl(binding.daxDialogCta.cardView), + stepIndicator = StepIndicatorControllerImpl(binding.daxDialogCta.stepIndicator), + emit = viewModel::onEvent, + execute = viewModel::onContentInteraction, + // While an entrance runs the card container swallows its children's touches, so a tap anywhere on the + // card lands on the tap-to-skip listener below instead of a picker consuming it. + onAnimatingChanged = { animating -> binding.daxDialogCta.cardContainer.interceptChildTouches = animating }, + ) + + binding.root.setOnClickListener { engine?.skipRunningAnimations() } + binding.daxDialogCta.cardContainer.setOnClickListener { engine?.skipRunningAnimations() } + + viewModel.viewState + .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED) + .onEach { state -> if (state.config != null) renderConfig(state) } + .launchIn(viewLifecycleOwner.lifecycleScope) + + viewModel.commands + .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED) + .onEach { command -> handleCommand(command) } + .launchIn(viewLifecycleOwner.lifecycleScope) + } + + /** + * The intro is not part of this renderer, and the views it animates do not all default to hidden, so they are + * settled once before the first dialog reaches the stage. + */ + private fun settleIntroViews() { + binding.logoAnimation.isVisible = false + binding.welcomeTitle.alpha = 0f + binding.duckAiIntroAnimation.isVisible = false + } + + private fun renderConfig(state: ConfigDrivenOnboardingPageViewModel.ViewState) { + val engine = engine ?: return + val stepId = state.stepId ?: return + val config = state.config ?: return + + if (!hasRenderedOnce) { + hasRenderedOnce = true + settleIntroViews() + // A retained view model emits before a recreated view has been laid out, and the decoration fit + // check measures the root's height — measuring at 0 would hide the decoration for good. + binding.root.doOnLayout { + val live = viewModel.viewState.value + val liveStepId = live.stepId ?: return@doOnLayout + val liveConfig = live.config ?: return@doOnLayout + engine.render(liveStepId, liveConfig, live.animateEntry) + viewModel.onDialogRendered(liveStepId) + } + } else { + engine.render(stepId, config, state.animateEntry) + viewModel.onDialogRendered(stepId) + } + } + + private fun handleCommand(command: ConfigDrivenOnboardingPageViewModel.Command) { + when (command) { + ConfigDrivenOnboardingPageViewModel.Command.RequestNotificationPermissions -> requestNotificationsPermissions() + is ConfigDrivenOnboardingPageViewModel.Command.ShowDefaultBrowserDialog -> + defaultBrowserRoleManagerDialog.launch(command.intent) + ConfigDrivenOnboardingPageViewModel.Command.LaunchAddWidgetPrompt -> + addWidgetLauncher.launchAddWidget(activity, simpleWidgetPrompt = true) + ConfigDrivenOnboardingPageViewModel.Command.Finish -> onContinuePressed() + is ConfigDrivenOnboardingPageViewModel.Command.FinishAndSubmitSearchQuery -> + (activity as? OnboardingActivity)?.finishAndSubmitSearchQuery(command.query) + is ConfigDrivenOnboardingPageViewModel.Command.FinishAndSubmitChatPrompt -> + (activity as? OnboardingActivity)?.finishAndSubmitChatPrompt(command.prompt) + ConfigDrivenOnboardingPageViewModel.Command.OnboardingSkipped -> onSkipPressed() + ConfigDrivenOnboardingPageViewModel.Command.HandOffToBrowserActivity -> + (activity as? OnboardingActivity)?.handOffToBrowserActivity() + } + } + + @SuppressLint("InlinedApi") + private fun requestNotificationsPermissions() { + if (appBuildConfig.sdkInt >= 33) { + viewModel.notificationRuntimePermissionRequested() + requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + viewModel.notificationPermissionFlowFinished(granted = null) + } + } + + override fun onResume() { + super.onResume() + viewModel.onResume() + } + + override fun onDestroyView() { + super.onDestroyView() + engine?.release() + engine = null + backgroundAnimator?.cancel() + backgroundAnimator = null + } + + private companion object { + const val DIALOG_BOTTOM_INSET_GAP_DP = 16 + } +} From 543bd1ab0b5eaccbbe3c2a811c1a2b14484d3e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Wed, 29 Jul 2026 18:23:00 +0200 Subject: [PATCH 11/28] select the config-driven onboarding renderer behind a feature flag --- .../onboarding/ui/OnboardingPageBuilder.kt | 4 ++++ .../onboarding/ui/OnboardingPageManager.kt | 16 +++++++++++++ .../app/onboarding/ui/OnboardingViewModel.kt | 10 ++++---- .../OnboardingBrandDesignUpdateToggles.kt | 6 +++++ .../onboarding/ui/OnboardingViewModelTest.kt | 23 +++++++++++++++++++ 5 files changed, 55 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageBuilder.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageBuilder.kt index 4055a493fe34..7c83fe1d4775 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageBuilder.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageBuilder.kt @@ -20,10 +20,12 @@ import com.duckduckgo.app.onboarding.ui.page.BrandDesignUpdateDefaultBrowserPage import com.duckduckgo.app.onboarding.ui.page.BrandDesignUpdateWelcomePage import com.duckduckgo.app.onboarding.ui.page.DefaultBrowserPage import com.duckduckgo.app.onboarding.ui.page.WelcomePage +import com.duckduckgo.app.onboarding.ui.page.configdriven.ConfigDrivenWelcomePage interface OnboardingPageBuilder { fun buildWelcomePage(): WelcomePage fun buildBrandDesignUpdateWelcomePage(): BrandDesignUpdateWelcomePage + fun buildConfigDrivenWelcomePage(): ConfigDrivenWelcomePage fun buildDefaultBrowserPage(): DefaultBrowserPage fun buildBrandDesignUpdateDefaultBrowserPage(): BrandDesignUpdateDefaultBrowserPage @@ -31,6 +33,7 @@ interface OnboardingPageBuilder { data object DefaultBrowserBlueprint : OnboardingPageBlueprint() data object WelcomePageBlueprint : OnboardingPageBlueprint() data object BrandDesignUpdateWelcomePageBlueprint : OnboardingPageBlueprint() + data object ConfigDrivenWelcomePageBlueprint : OnboardingPageBlueprint() data object BrandDesignUpdateDefaultBrowserPageBlueprint : OnboardingPageBlueprint() } } @@ -39,6 +42,7 @@ class OnboardingFragmentPageBuilder : OnboardingPageBuilder { override fun buildWelcomePage() = WelcomePage() override fun buildBrandDesignUpdateWelcomePage() = BrandDesignUpdateWelcomePage() + override fun buildConfigDrivenWelcomePage() = ConfigDrivenWelcomePage() override fun buildDefaultBrowserPage() = DefaultBrowserPage() override fun buildBrandDesignUpdateDefaultBrowserPage() = BrandDesignUpdateDefaultBrowserPage() } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageManager.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageManager.kt index 8c2f4f1fe4aa..1bf335e7ed2b 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageManager.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageManager.kt @@ -21,6 +21,7 @@ import com.duckduckgo.app.global.DefaultRoleBrowserDialog import com.duckduckgo.app.onboarding.ui.OnboardingPageBuilder.OnboardingPageBlueprint import com.duckduckgo.app.onboarding.ui.OnboardingPageBuilder.OnboardingPageBlueprint.BrandDesignUpdateDefaultBrowserPageBlueprint import com.duckduckgo.app.onboarding.ui.OnboardingPageBuilder.OnboardingPageBlueprint.BrandDesignUpdateWelcomePageBlueprint +import com.duckduckgo.app.onboarding.ui.OnboardingPageBuilder.OnboardingPageBlueprint.ConfigDrivenWelcomePageBlueprint import com.duckduckgo.app.onboarding.ui.OnboardingPageBuilder.OnboardingPageBlueprint.DefaultBrowserBlueprint import com.duckduckgo.app.onboarding.ui.OnboardingPageBuilder.OnboardingPageBlueprint.WelcomePageBlueprint import com.duckduckgo.app.onboarding.ui.page.BrandDesignUpdateDefaultBrowserPage @@ -28,11 +29,13 @@ import com.duckduckgo.app.onboarding.ui.page.BrandDesignUpdateWelcomePage import com.duckduckgo.app.onboarding.ui.page.DefaultBrowserPage import com.duckduckgo.app.onboarding.ui.page.OnboardingPageFragment import com.duckduckgo.app.onboarding.ui.page.WelcomePage +import com.duckduckgo.app.onboarding.ui.page.configdriven.ConfigDrivenWelcomePage interface OnboardingPageManager { fun pageCount(): Int fun buildPageBlueprints() fun buildBrandDesignUpdatePageBlueprints() + fun buildConfigDrivenPageBlueprints() fun buildPage(position: Int): OnboardingPageFragment? } @@ -64,11 +67,20 @@ class OnboardingPageManagerWithTrackerBlocking( } } + override fun buildConfigDrivenPageBlueprints() { + pages.clear() + pages += ConfigDrivenWelcomePageBlueprint + if (shouldShowDefaultBrowserPage()) { + pages += BrandDesignUpdateDefaultBrowserPageBlueprint + } + } + override fun buildPage(position: Int): OnboardingPageFragment? { return when (pages.getOrNull(position)) { is WelcomePageBlueprint -> buildWelcomePage() is DefaultBrowserBlueprint -> buildDefaultBrowserPage() is BrandDesignUpdateWelcomePageBlueprint -> buildBrandDesignUpdateWelcomePage() + is ConfigDrivenWelcomePageBlueprint -> buildConfigDrivenWelcomePage() is BrandDesignUpdateDefaultBrowserPageBlueprint -> buildBrandDesignUpdateDefaultBrowserPage() else -> null } @@ -92,6 +104,10 @@ class OnboardingPageManagerWithTrackerBlocking( return onboardingPageBuilder.buildBrandDesignUpdateWelcomePage() } + private fun buildConfigDrivenWelcomePage(): ConfigDrivenWelcomePage { + return onboardingPageBuilder.buildConfigDrivenWelcomePage() + } + private fun buildBrandDesignUpdateDefaultBrowserPage(): BrandDesignUpdateDefaultBrowserPage { return onboardingPageBuilder.buildBrandDesignUpdateDefaultBrowserPage() } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModel.kt index 9c185845ceb6..fe90cf5ac301 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModel.kt @@ -60,10 +60,12 @@ class OnboardingViewModel @Inject constructor( val isBrandDesignUpdateEnabled = withContext(dispatchers.io()) { onboardingBrandDesignUpdateToggles.brandDesignUpdate().isEnabled() } - if (isBrandDesignUpdateEnabled) { - pageLayoutManager.buildBrandDesignUpdatePageBlueprints() - } else { - pageLayoutManager.buildPageBlueprints() + val isConfigDrivenDialogsEnabled = isBrandDesignUpdateEnabled && + withContext(dispatchers.io()) { onboardingBrandDesignUpdateToggles.configDrivenDialogs().isEnabled() } + when { + isConfigDrivenDialogsEnabled -> pageLayoutManager.buildConfigDrivenPageBlueprints() + isBrandDesignUpdateEnabled -> pageLayoutManager.buildBrandDesignUpdatePageBlueprints() + else -> pageLayoutManager.buildPageBlueprints() } } diff --git a/app/src/main/java/com/duckduckgo/app/onboardingbranddesignupdate/OnboardingBrandDesignUpdateToggles.kt b/app/src/main/java/com/duckduckgo/app/onboardingbranddesignupdate/OnboardingBrandDesignUpdateToggles.kt index e62795c52823..b6a1e4d3529b 100644 --- a/app/src/main/java/com/duckduckgo/app/onboardingbranddesignupdate/OnboardingBrandDesignUpdateToggles.kt +++ b/app/src/main/java/com/duckduckgo/app/onboardingbranddesignupdate/OnboardingBrandDesignUpdateToggles.kt @@ -54,4 +54,10 @@ interface OnboardingBrandDesignUpdateToggles { */ @Toggle.DefaultValue(DefaultFeatureValue.TRUE) fun onboardingImprovementsV2(): Toggle + + /** + * Selects the config-driven renderer for the brand-design onboarding dialogs. + */ + @Toggle.DefaultValue(DefaultFeatureValue.FALSE) + fun configDrivenDialogs(): Toggle } diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModelTest.kt index 9546c68815ce..c52d4a33b10e 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModelTest.kt @@ -75,6 +75,7 @@ class OnboardingViewModelTest { // Brand design update off by default -> legacy WelcomePage path (orchestrator does not drive the run). private val onboardingBrandDesignUpdateToggles: OnboardingBrandDesignUpdateToggles = mock { on { brandDesignUpdate() } doReturn disabledToggle + on { configDrivenDialogs() } doReturn disabledToggle } private val linearOnboardingOrchestrator: LinearOnboardingOrchestrator = mock() @@ -213,6 +214,28 @@ class OnboardingViewModelTest { verify(pageLayout).buildBrandDesignUpdatePageBlueprints() } + @Test + fun whenInitializePagesCalledAndConfigDrivenDialogsEnabledThenBuildConfigDrivenPageBlueprints() = runTest { + whenever(onboardingBrandDesignUpdateToggles.brandDesignUpdate()).thenReturn(enabledToggle) + whenever(onboardingBrandDesignUpdateToggles.configDrivenDialogs()).thenReturn(enabledToggle) + + testee.initializePages() + + verify(pageLayout).buildConfigDrivenPageBlueprints() + verify(pageLayout, never()).buildBrandDesignUpdatePageBlueprints() + } + + @Test + fun whenInitializePagesCalledAndConfigDrivenDialogsEnabledButBrandDesignUpdateDisabledThenBuildPageBlueprints() = runTest { + whenever(onboardingBrandDesignUpdateToggles.brandDesignUpdate()).thenReturn(disabledToggle) + whenever(onboardingBrandDesignUpdateToggles.configDrivenDialogs()).thenReturn(enabledToggle) + + testee.initializePages() + + verify(pageLayout).buildPageBlueprints() + verify(pageLayout, never()).buildConfigDrivenPageBlueprints() + } + private fun configureSkipperFlow() = runTest { val flow = MutableSharedFlow() flow.emit(ViewState(skipOnboardingPossible = true)) From 8e2b85ad54facc4efdbef2f0019894d573e93fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Wed, 29 Jul 2026 19:23:57 +0200 Subject: [PATCH 12/28] baseline the impl module import in the config-driven view model tests --- app/lint-baseline.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 6ef5d9d0683f..97e3419ac3e2 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -287,6 +287,17 @@ column="1"/> + + + + Date: Wed, 29 Jul 2026 19:23:58 +0200 Subject: [PATCH 13/28] drop the unused default browser detector from the config-driven view model --- .../page/configdriven/ConfigDrivenOnboardingPageViewModel.kt | 2 -- .../configdriven/ConfigDrivenOnboardingPageViewModelTest.kt | 3 --- 2 files changed, 5 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt index f863aa038046..69cd8c53f497 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt @@ -22,7 +22,6 @@ import android.content.Intent import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel -import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.browser.omnibar.OmnibarType import com.duckduckgo.app.global.DefaultRoleBrowserDialog import com.duckduckgo.app.global.install.AppInstallStore @@ -72,7 +71,6 @@ class ConfigDrivenOnboardingPageViewModel @Inject constructor( private val newUserOnboardingPlanBootstrapper: NewUserOnboardingPlanBootstrapper, private val dialogConfigResolver: DialogConfigResolver, private val dispatchers: DispatcherProvider, - private val defaultBrowserDetector: DefaultBrowserDetector, private val widgetCapabilities: WidgetCapabilities, private val defaultRoleBrowserDialog: DefaultRoleBrowserDialog, private val context: Context, diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModelTest.kt index 82bf51f6615e..551adf5779f9 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModelTest.kt @@ -20,7 +20,6 @@ import android.annotation.SuppressLint import android.content.Context import android.content.Intent import app.cash.turbine.test -import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.global.DefaultRoleBrowserDialog import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.onboarding.CustomAiOnboardingStore @@ -69,7 +68,6 @@ class ConfigDrivenOnboardingPageViewModelTest { private val mockContext: Context = mock() private val pixel: Pixel = mock() private val mockAppInstallStore: AppInstallStore = mock() - private val mockDefaultBrowserDetector: DefaultBrowserDetector = mock() private val mockWidgetCapabilities: WidgetCapabilities = mock() private val customAiOnboardingStore: CustomAiOnboardingStore = mock() private val newUserOnboardingPlanBootstrapper: NewUserOnboardingPlanBootstrapper = mock() @@ -107,7 +105,6 @@ class ConfigDrivenOnboardingPageViewModelTest { newUserOnboardingPlanBootstrapper = newUserOnboardingPlanBootstrapper, dialogConfigResolver = DialogConfigResolver(), dispatchers = coroutineRule.testDispatcherProvider, - defaultBrowserDetector = mockDefaultBrowserDetector, widgetCapabilities = mockWidgetCapabilities, defaultRoleBrowserDialog = mockDefaultRoleBrowserDialog, context = mockContext, From e7e82cf6b0b0b09255a1eeb04bdfafefb522a431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Wed, 29 Jul 2026 19:23:58 +0200 Subject: [PATCH 14/28] drop the cta clickability note from the card stage contract --- .../app/onboarding/ui/page/configdriven/engine/CardStage.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt index 9962da26e198..e654aab2d334 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt @@ -38,10 +38,6 @@ interface CardStage { fun morph(animate: Boolean, onEnd: () -> Unit) - /** - * CTA text, visibility and click handling. A CTA fading in from alpha 0 cannot be clicked early even though - * it is bound here: the card container swallows its children's touches for as long as an entrance runs. - */ fun showCtas(primary: CtaConfig?, secondary: CtaConfig?, onClick: (CtaConfig) -> Unit) /** Hides [contentTargets] and the visible CTAs so an entrance can fade them in. */ From 7694942d07e22b88660292a794731bd1f0cd9241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Thu, 30 Jul 2026 17:25:29 +0200 Subject: [PATCH 15/28] clean ups and comment adjustments --- .../ConfigDrivenOnboardingPageViewModel.kt | 22 +++++++----------- .../configdriven/ConfigDrivenWelcomePage.kt | 23 ++++++------------- .../ui/page/configdriven/DialogBinder.kt | 2 +- .../binders/ComparisonChartBinder.kt | 16 +++++-------- .../engine/BackgroundController.kt | 6 +++++ .../ui/page/configdriven/engine/CardStage.kt | 7 +++--- .../configdriven/engine/DialogRenderEngine.kt | 6 ++--- .../engine/DialogRenderEngineTest.kt | 2 +- 8 files changed, 35 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt index 69cd8c53f497..6f83e261ef61 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenOnboardingPageViewModel.kt @@ -59,11 +59,6 @@ import kotlinx.coroutines.withContext import javax.inject.Inject import kotlin.time.Duration.Companion.seconds -/** - * Resolves the orchestrator's current step into one [DialogConfig] and publishes it, so the fragment's render - * engine has a single value-comparable description of the screen to diff against. Live working state for - * stateful screens lives in the [ContentValueStore] this view model owns, not in [ViewState]. - */ @SuppressLint("StaticFieldLeak") @ContributesViewModel(FragmentScope::class) class ConfigDrivenOnboardingPageViewModel @Inject constructor( @@ -116,19 +111,16 @@ class ConfigDrivenOnboardingPageViewModel @Inject constructor( start() } - /** Blind forward: the engine, through a CTA click or the bound screen's own result, already resolved the event. */ fun onEvent(event: NewUserOnboardingEvent) = emit(event) - /** No [ContentInteraction] variants exist yet, so a bound screen has nothing to raise outside the CTA flow. */ - fun onContentInteraction(interaction: ContentInteraction) = Unit + fun onContentInteraction(interaction: ContentInteraction) = Unit // No-op until dialogs with local state are implemented in follow-ups. - /** - * Flips the one-shot entry animation flag once the fragment has rendered [stepId], so a later rotation - * re-collection of [viewState], which replays its last value rather than recomputing it, snaps instead of - * replaying the entrance. No-op if the current step has since moved on. - */ fun onDialogRendered(stepId: LinearOnboardingStepId) { - _viewState.update { if (it.stepId == stepId) it.copy(animateEntry = false) else it } + _viewState.update { + // The dialog for this step has been rendered. + // Disable entry animation for potential re-draws (like config change/rotation). + if (it.stepId == stepId) it.copy(animateEntry = false) else it + } } fun onResume() { @@ -251,6 +243,8 @@ class ConfigDrivenOnboardingPageViewModel @Inject constructor( /** * Handle commands and dialogs the renderer doesn't support yet by advancing past them, without reporting * them as presented. + * + * Temporary until all dialogs are implemented in the renderer. */ private suspend fun advancePastUnrenderedDialog(dialog: NewUserOnboardingActivityDialog) { when (dialog) { diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt index 190de20c96f8..a0ffbeba59b7 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt @@ -64,10 +64,6 @@ import kotlinx.coroutines.flow.onEach import javax.inject.Inject import com.duckduckgo.mobile.android.R as CommonR -/** - * Wires [ConfigDrivenOnboardingPageViewModel] to [DialogRenderEngine]: it owns the views, builds the engine's - * collaborators from the inflated binding, and routes commands. Every rendering decision belongs to the engine. - */ @InjectWith(FragmentScope::class) class ConfigDrivenWelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_page_update) { @@ -92,7 +88,6 @@ class ConfigDrivenWelcomePage : OnboardingPageFragment(R.layout.content_onboardi } private var engine: DialogRenderEngine? = null - private var backgroundAnimator: OnboardingBackgroundAnimator? = null /** Fed to the embellishment controller's fit corrector; kept in sync by the window-insets listener below. */ private var cardBottomInsetPx = 0 @@ -147,12 +142,6 @@ class ConfigDrivenWelcomePage : OnboardingPageFragment(R.layout.content_onboardi windowInsets } - val newBackgroundAnimator = OnboardingBackgroundAnimator( - backgroundPrimary = binding.backgroundPrimary, - backgroundSecondary = binding.backgroundSecondary, - ) - backgroundAnimator = newBackgroundAnimator - engine = DialogRenderEngine( content = ContentControllerImpl( binding = binding.daxDialogCta, @@ -160,7 +149,12 @@ class ConfigDrivenWelcomePage : OnboardingPageFragment(R.layout.content_onboardi isLightMode = { appTheme.isLightModeEnabled() }, ), cardStage = CardStageImpl(binding), - background = BackgroundControllerImpl(newBackgroundAnimator), + background = BackgroundControllerImpl( + OnboardingBackgroundAnimator( + backgroundPrimary = binding.backgroundPrimary, + backgroundSecondary = binding.backgroundSecondary, + ), + ), embellishments = EmbellishmentControllerImpl( binding = binding, onDecorationHidden = { binding.daxDialogCta.cardView.setArrowDepthFraction(0f) }, @@ -191,8 +185,7 @@ class ConfigDrivenWelcomePage : OnboardingPageFragment(R.layout.content_onboardi } /** - * The intro is not part of this renderer, and the views it animates do not all default to hidden, so they are - * settled once before the first dialog reaches the stage. + * Temporary until the intro animators are implemented in the follow-up. */ private fun settleIntroViews() { binding.logoAnimation.isVisible = false @@ -260,8 +253,6 @@ class ConfigDrivenWelcomePage : OnboardingPageFragment(R.layout.content_onboardi super.onDestroyView() engine?.release() engine = null - backgroundAnimator?.cancel() - backgroundAnimator = null } private companion object { diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogBinder.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogBinder.kt index 679c7080fbd3..083a7f3fc83b 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogBinder.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/DialogBinder.kt @@ -27,7 +27,7 @@ class BindScope( val execute: (ContentInteraction) -> Unit, ) -/** Interactions a bound screen raises outside the shared CTA flow. */ +/** Interactions a bound screen raises outside the shared CTA buttons interactions. */ sealed interface ContentInteraction /** Binds a stateless [ContentConfig] to its include layout. */ diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ComparisonChartBinder.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ComparisonChartBinder.kt index e32dfc888e8a..163525ae5ecf 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ComparisonChartBinder.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ComparisonChartBinder.kt @@ -104,13 +104,6 @@ class ComparisonChartBinder( .map { it.findViewById(R.id.rowCheck) } .toList() - /** - * One AnimatorSet the engine owns end to end: per row, fades and scales the check icon in with an overshoot - * interpolator, plus a zero-duration trigger animator starting the icon's AnimatedVectorDrawable at its own - * relative delay. Each row's listener forces the final state in `onAnimationEnd`, which Android invokes on - * natural completion and on cancel alike, so the engine's `end()`/`cancel()` both land the views visible even - * when a row was still pending on its start delay. - */ private fun checkIconStaggerAnimator(): Animator { val overshoot = OvershootInterpolator(CHECK_ICON_OVERSHOOT_TENSION) val checkViews = comparisonCheckViews() @@ -155,10 +148,13 @@ class ComparisonChartBinder( } /** - * Zero-duration animator used purely as a delayed trigger for `AnimatedVectorDrawable.start()`. + * Starts the check icon's [AnimatedVectorDrawable], which is not a property animation and so cannot sit on the + * row's timeline directly. A zero-duration animator carries it rather than a delayed post, keeping it under the + * engine's hold on the returned animator: a posted callback would outlive a skip or a teardown and draw the tick + * onto a card that has already snapped or been unbound. * - * `ValueAnimator.end()` on a never-started animator fires `onAnimationStart` synchronously first, so only - * `cancel()` suppresses this trigger. + * `ValueAnimator.end()` on a never-started animator fires `onAnimationStart` synchronously first, so a snapped + * render lands the tick drawn while only `cancel()` suppresses it. */ private fun avdStartTrigger(checkView: ImageView): Animator = ValueAnimator.ofInt(0, 1).apply { diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/BackgroundController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/BackgroundController.kt index 685c9b39552a..c67a266cd837 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/BackgroundController.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/BackgroundController.kt @@ -23,6 +23,8 @@ import com.duckduckgo.app.onboarding.ui.page.OnboardingBackgroundStep interface BackgroundController { fun apply(previous: OnboardingBackgroundStep?, next: OnboardingBackgroundStep, animate: Boolean) fun skipRunning() + + fun release() } class BackgroundControllerImpl(private val animator: OnboardingBackgroundAnimator) : BackgroundController { @@ -49,4 +51,8 @@ class BackgroundControllerImpl(private val animator: OnboardingBackgroundAnimato transitioningTo?.let { animator.snapTo(it) } transitioningTo = null } + + override fun release() { + animator.cancel() + } } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt index e654aab2d334..5765e4e83de2 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt @@ -32,13 +32,14 @@ import com.duckduckgo.app.browser.databinding.ContentOnboardingWelcomePageUpdate import com.duckduckgo.app.onboarding.ui.page.configdriven.CtaConfig import com.duckduckgo.common.ui.view.button.DaxButton -/** The card choreography shared by every screen. Each call runs synchronously to its end state when not animating. */ interface CardStage { + /** Fades the card root in. Nothing to do once the card is on stage, so only the first render of a run fades. */ fun reveal(animate: Boolean, onEnd: () -> Unit) + /** Tweens the card's bounds from the outgoing screen's size to the newly bound one's. */ fun morph(animate: Boolean, onEnd: () -> Unit) - fun showCtas(primary: CtaConfig?, secondary: CtaConfig?, onClick: (CtaConfig) -> Unit) + fun showCtaButtons(primary: CtaConfig?, secondary: CtaConfig?, onClick: (CtaConfig) -> Unit) /** Hides [contentTargets] and the visible CTAs so an entrance can fade them in. */ fun prepareEntrance(contentTargets: List) @@ -114,7 +115,7 @@ class CardStageImpl(private val binding: ContentOnboardingWelcomePageUpdateBindi binding.root.requestLayout() } - override fun showCtas( + override fun showCtaButtons( primary: CtaConfig?, secondary: CtaConfig?, onClick: (CtaConfig) -> Unit, diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt index c154fb24687c..ebf58b91f547 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt @@ -88,16 +88,14 @@ class DialogRenderEngine( val handle = content.bind(stepId, config.content, BindScope(coroutineScope = scope, execute = execute)) bound = handle - cardStage.showCtas(config.primaryCta, config.secondaryCta) { cta -> performCta(cta.action, handle) } + cardStage.showCtaButtons(config.primaryCta, config.secondaryCta) { cta -> performCta(cta.action, handle) } if (animate) cardStage.prepareEntrance(handle.fadeTargets) embellishments.transition(previous?.embellishment, config.embellishment, animate) { settled -> cardAnchor.apply(settled) } - // Every deferred stage below bails unless this render's binding is still the current one. Settling the - // card stage runs pending continuations rather than dropping them, so a render that has been superseded - // would otherwise drive the views its successor has already rebound. + // Every deferred stage below bails if the bound view has changed since dispatch cardStage.reveal(animate) { if (bound !== handle) return@reveal cardStage.morph(animating(animate)) { diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt index 09d2118621a4..1f999c184df0 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt @@ -298,7 +298,7 @@ private class FakeCardStage : CardStage { stage(animate, onEnd) } - override fun showCtas(primary: CtaConfig?, secondary: CtaConfig?, onClick: (CtaConfig) -> Unit) { + override fun showCtaButtons(primary: CtaConfig?, secondary: CtaConfig?, onClick: (CtaConfig) -> Unit) { this.primary = primary onCtaClick = onClick } From 6c3a7fa4ba76782f329809c1d631706b9bd6ca14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Thu, 30 Jul 2026 18:26:26 +0200 Subject: [PATCH 16/28] Add a content-ready hook for non-Animator entrance work afterFade is typed as an Animator factory, so entrance work that is not an Animator has to be wrapped in a zero-duration ValueAnimator purely to receive a start callback. That wrapper is only correct by way of a platform detail: end() on a never-started ValueAnimator fires onAnimationStart while cancel() does not, so a skip triggers the work and teardown suppresses it. Right semantics, but inherited rather than written, and unpinnable by a unit test that cannot inflate views. onContentReady states them as engine behaviour instead. It runs at the same point as afterFade, exactly once per render, on the animated, snapped and skipped paths alike, and never once the handle is unbound. The two slots are two ownership models at one moment: afterFade is bounded and engine-owned, onContentReady is unbounded and stopped by the binder in unbind. afterFade's KDoc also now records that the card stops intercepting touches as it starts, so a screen revealing interactive content there has to gate its own clickability until the animator ends. FakeBackgroundController gains the release() override its interface has required since that member was added. The test class did not compile without it. --- .../ui/page/configdriven/ContentHandle.kt | 26 ++++++--- .../configdriven/engine/DialogRenderEngine.kt | 1 + .../engine/DialogRenderEngineTest.kt | 55 +++++++++++++++++++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentHandle.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentHandle.kt index bce26583a626..54f51adb9dc3 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentHandle.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ContentHandle.kt @@ -21,17 +21,29 @@ import android.view.View import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingEvent import com.duckduckgo.app.onboarding.ui.view.OnboardingDialogTitleView -/** - * What a binder hands back to the render engine after binding a screen. - * - * [afterFade] is a factory, not a running animator: the engine decides when to start it, ends it when the - * render is snapped, and cancels it on teardown. An animator it returns must leave its views in their final - * visible state even if `end()` arrives before it ever ran. - */ +/** What a binder hands back to the render engine after binding a screen. */ class ContentHandle( val title: OnboardingDialogTitleView?, val fadeTargets: List, + /** + * Bounded entrance animation, played once [fadeTargets] have faded in. A factory, not a running animator: + * the engine decides when to start it, ends it when the render is snapped, and cancels it on teardown. An + * animator it returns must leave its views in their final visible state even if `end()` arrives before it + * ever ran. + * + * The card stops intercepting touches as this starts, so anything interactive revealed here is tappable + * while still invisible. Gate it: `isClickable = false` at bind, restored from the animator's end listener. + */ val afterFade: (() -> Animator)? = null, + /** + * Side effect run at the same point as [afterFade], for entrance work the engine cannot own as an + * [Animator]: an unbounded loop, or an animation driven outside the animator framework. Runs exactly once + * per render, whether the entrance animated, snapped or was skipped, and never once the handle is unbound. + * + * The engine keeps no reference to whatever this starts, so [unbind] has to stop it. The input caveat on + * [afterFade] applies here too. + */ + val onContentReady: (() -> Unit)? = null, val result: (() -> NewUserOnboardingEvent)? = null, val unbind: () -> Unit = {}, ) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt index ebf58b91f547..d1fad1039e65 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt @@ -105,6 +105,7 @@ class DialogRenderEngine( cardStage.fadeInContent(handle.fadeTargets, animating(animate)) { if (bound !== handle) return@fadeInContent playAfterFade(handle, animating(animate)) + handle.onContentReady?.invoke() isAnimating = false } } diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt index 1f999c184df0..682cc7a193b9 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt @@ -186,6 +186,54 @@ class DialogRenderEngineTest { verify(animator).end() } + @Test + fun `content ready runs once the entrance settles, not before`() = runTest { + var readyCount = 0 + content.onContentReady = { readyCount++ } + cardStage.autoComplete = false + + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + assertEquals(0, readyCount) + + cardStage.completePendingStages() + + assertEquals(1, readyCount) + } + + @Test + fun `content ready runs once on a snapped render`() = runTest { + var readyCount = 0 + content.onContentReady = { readyCount++ } + + testee.render(COMPARISON_STEP, comparisonConfig(), animate = false) + + assertEquals(1, readyCount) + } + + @Test + fun `content ready runs once when the entrance is skipped`() = runTest { + var readyCount = 0 + content.onContentReady = { readyCount++ } + cardStage.autoComplete = false + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + + testee.skipRunningAnimations() + + assertEquals(1, readyCount) + } + + @Test + fun `content ready does not run for an entrance abandoned by release`() = runTest { + var readyCount = 0 + content.onContentReady = { readyCount++ } + cardStage.autoComplete = false + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + + testee.release() + + assertEquals(0, readyCount) + } + @Test fun `release unbinds the content and cancels the after-fade animator`() = runTest { val animator: Animator = mock() @@ -253,6 +301,7 @@ private class FakeContentController : ContentController { var bindCount = 0 var hidden = false var afterFade: (() -> Animator)? = null + var onContentReady: (() -> Unit)? = null var handleResult: (() -> NewUserOnboardingEvent)? = null var unbindCount = 0 @@ -266,6 +315,7 @@ private class FakeContentController : ContentController { title = null, fadeTargets = emptyList(), afterFade = afterFade, + onContentReady = onContentReady, result = handleResult, unbind = { unbindCount++ }, ) @@ -338,6 +388,7 @@ private class FakeBackgroundController : BackgroundController { var applied: Pair? = null var animated = false var skipped = false + var released = false override fun apply(previous: OnboardingBackgroundStep?, next: OnboardingBackgroundStep, animate: Boolean) { applied = previous to next @@ -347,6 +398,10 @@ private class FakeBackgroundController : BackgroundController { override fun skipRunning() { skipped = true } + + override fun release() { + released = true + } } private class FakeStepIndicatorController : StepIndicatorController { From 7a733acc691bc5cf3e26c7a1a9a1f5f8de8ed821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Thu, 30 Jul 2026 18:44:19 +0200 Subject: [PATCH 17/28] comment adjustments --- .../binders/ComparisonChartBinder.kt | 4 +-- .../engine/EmbellishmentController.kt | 27 +++---------------- .../engine/StepIndicatorController.kt | 7 ----- 3 files changed, 4 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ComparisonChartBinder.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ComparisonChartBinder.kt index 163525ae5ecf..9c4918a9e0a7 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ComparisonChartBinder.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ComparisonChartBinder.kt @@ -149,9 +149,7 @@ class ComparisonChartBinder( /** * Starts the check icon's [AnimatedVectorDrawable], which is not a property animation and so cannot sit on the - * row's timeline directly. A zero-duration animator carries it rather than a delayed post, keeping it under the - * engine's hold on the returned animator: a posted callback would outlive a skip or a teardown and draw the tick - * onto a card that has already snapped or been unbound. + * row's timeline directly. * * `ValueAnimator.end()` on a never-started animator fires `onAnimationStart` synchronously first, so a snapped * render lands the tick drawn while only `cancel()` suppresses it. diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt index df663dd884a3..04fed81b8ae4 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt @@ -53,13 +53,7 @@ interface EmbellishmentController { } /** - * Owns the embellishment axis: which Lottie decoration (walking dax, bobbing dax, either wing) accompanies the - * current dialog, its enter and exit choreography, and the fit check that hides a declared decoration when the - * dialog content leaves it no room. - * - * [onDecorationHidden] fires asynchronously, from the fit corrector's pre-draw pass rather than from - * [transition], when a decoration that used to fit stops fitting — the keyboard opening, say. No [transition] is - * in flight at that point, so the card has to be re-anchored straight from that callback. + * @param onDecorationHidden callback to the host that a decoration that used to fit no longer does */ class EmbellishmentControllerImpl( private val binding: ContentOnboardingWelcomePageUpdateBinding, @@ -69,8 +63,7 @@ class EmbellishmentControllerImpl( /** * Every animator started here that has not finished on its own, so a superseding [transition] can end() them - * and [release] can cancel() them. Ending an animator that already completed restarts it, re-firing the start - * listeners that call [LottieAnimationView.playAnimation], so entries drop themselves as they complete. + * and [release] can cancel() them. */ private val trackedAnimators = mutableListOf() @@ -153,10 +146,7 @@ class EmbellishmentControllerImpl( when { exiting == null -> onSettled(applyNext()) animate && exiting.view.isVisible -> { - // The incoming decoration enters in the same frame the outgoing one starts leaving. A wing's exit - // plays its Lottie out over several seconds, and serializing the entrance behind that leaves the - // new decoration visibly late and out of step with the background transition. Only the card anchor - // waits, and it settles from the exit's own completion. + // The incoming decoration needs to enter in the same frame the outgoing one starts leaving to keep in sync with background. val settled = applyNext() track( exiting.exit { @@ -171,11 +161,6 @@ class EmbellishmentControllerImpl( } } - /** - * [drainInFlight] ends whatever is running, so an exit's completion happens now rather than at its natural - * end. Snapping on top of that matters for an entrance that had not started yet: ending it fires its start - * listener, which plays the decoration's Lottie from frame 0, and the snap freezes it at its end state. - */ override fun skipRunning() { drainInFlight() currentDecoration?.snap() @@ -193,10 +178,6 @@ class EmbellishmentControllerImpl( fitCorrector.detach() } - /** - * Ends every tracked animator and finishes any pending Lottie exit. The snapshot comes first because ending an - * animator removes it from [trackedAnimators] from inside the iteration. - */ private fun drainInFlight() { val animators = trackedAnimators.toList() trackedAnimators.removeAll(animators) @@ -204,7 +185,6 @@ class EmbellishmentControllerImpl( pendingExit?.finish?.invoke() } - /** Tracks [animators] and removes each again the moment it ends on its own. */ private fun track(animators: List) { trackedAnimators += animators animators.forEach { animator -> @@ -520,7 +500,6 @@ class EmbellishmentControllerImpl( view.playAnimation() } - /** One decoration: its view, its anchoring and fit policy, and its choreography. */ private class Decoration( val view: LottieAnimationView, val anchorsCardOnPhone: Boolean, diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/StepIndicatorController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/StepIndicatorController.kt index 45c4b43f53db..b781c7a66939 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/StepIndicatorController.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/StepIndicatorController.kt @@ -24,7 +24,6 @@ import androidx.core.view.isVisible import com.duckduckgo.app.onboarding.orchestrator.StepProgress import com.duckduckgo.app.onboarding.ui.view.OnboardingStepIndicatorView -/** Owns the step-indicator axis: the "X of Y" pill's visibility and its snap-vs-advance-one-step choreography. */ interface StepIndicatorController { fun apply(previous: StepProgress?, next: StepProgress?, animate: Boolean) fun skipRunning() @@ -35,12 +34,6 @@ class StepIndicatorControllerImpl(private val indicator: OnboardingStepIndicator private var fadeOut: ObjectAnimator? = null - /** - * @param previous The step shown before this call, or null if none was showing. - * @param next The step to show now, or null to hide the indicator entirely. - * @param animate Whether to animate the transition (fade-out when hiding; advance-one-step when [previous] - * was already showing) or snap directly to the end state. - */ override fun apply( previous: StepProgress?, next: StepProgress?, From 25ddb95fbdfbdcdb89add592b441762ae8f8451d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Fri, 31 Jul 2026 12:04:58 +0200 Subject: [PATCH 18/28] apply settled card anchor before bounds transition --- .../engine/CardAnchorController.kt | 5 +- .../configdriven/engine/DialogRenderEngine.kt | 6 +- .../engine/EmbellishmentController.kt | 90 ++++++------------- .../engine/DialogRenderEngineTest.kt | 28 ++++-- 4 files changed, 54 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorController.kt index fd929dbf6d5f..acb172677434 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorController.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorController.kt @@ -38,9 +38,8 @@ class CardAnchorControllerImpl( * check vetoed it. The card anchors above [SettledDecoration.view] when non-null and either [isTablet] or * [SettledDecoration.anchorsCardOnPhone] is true; otherwise it pins to the parent bottom. * - * Arrow visibility is deliberately not handled here: it is screen data the engine applies synchronously at - * render time, whereas this fires from the embellishment axis's settle, which waits out a previous - * decoration's exit. + * Arrow visibility is deliberately not handled here: it is screen data off the config, whereas the depth + * below follows from what the embellishment axis settled on. */ override fun apply(settled: SettledDecoration?) { val card = binding.daxDialogCta.root diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt index d1fad1039e65..b8b71a7cd6a0 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt @@ -91,9 +91,9 @@ class DialogRenderEngine( cardStage.showCtaButtons(config.primaryCta, config.secondaryCta) { cta -> performCta(cta.action, handle) } if (animate) cardStage.prepareEntrance(handle.fadeTargets) - embellishments.transition(previous?.embellishment, config.embellishment, animate) { settled -> - cardAnchor.apply(settled) - } + // Anchored before the morph below starts its transition, so the card's move to its new anchor is smooth + val settledDecoration = embellishments.transition(previous?.embellishment, config.embellishment, animate) + cardAnchor.apply(settledDecoration) // Every deferred stage below bails if the bound view has changed since dispatch cardStage.reveal(animate) { diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt index 04fed81b8ae4..c5b0f186b2b6 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt @@ -38,15 +38,14 @@ import com.duckduckgo.common.ui.view.toPx interface EmbellishmentController { /** - * [onSettled] reports what the fit check settled on. When a decoration is leaving, it fires only once that - * exit has finished: the card must keep its anchor until the outgoing decoration is gone. + * Returns what the fit check settled on, synchronously: the caller anchors the card to it in the same frame, + * so the card's reposition is picked up by the render's card morph instead of snapping into place later. */ fun transition( previous: Embellishment?, next: Embellishment, animate: Boolean, - onSettled: (SettledDecoration?) -> Unit, - ) + ): SettledDecoration? fun skipRunning() fun release() @@ -74,13 +73,6 @@ class EmbellishmentControllerImpl( */ private var pendingExit: LottieExit? = null - /** - * Bumped by every [transition]; each call captures the value, and every deferred continuation of that call - * re-checks it before acting. A transition superseded before it settled sees a mismatch and no-ops, so it - * never reports a stale [SettledDecoration]. - */ - private var generation = 0 - /** The fit-approved decoration on stage, or null when the current screen shows none. What [skipRunning] snaps. */ private var currentDecoration: Decoration? = null @@ -108,57 +100,37 @@ class EmbellishmentControllerImpl( previous: Embellishment?, next: Embellishment, animate: Boolean, - onSettled: (SettledDecoration?) -> Unit, - ) { - generation++ - val gen = generation - - // An earlier transition's exit may still be running, with a continuation that belongs to that older - // generation. Draining it now keeps two exits off the stage at once, and the generation check turns the - // drained continuation into a no-op. + ): SettledDecoration? { + // An earlier transition's exit may still be running. Draining it now keeps two exits off the stage at once. drainInFlight() if (previous == next) { // The drain may have cut this decoration's own entrance short, so snap it to where that entrance was // heading before reporting the fit. decorations[next]?.snap() - onSettled(applyFit(next)) - return + return applyFit(next) } val exiting = previous?.let { decorations[it] } - - fun applyNext(): SettledDecoration? { - val settled = applyFit(next) - if (settled != null) { - val entering = decorations.getValue(next) - if (animate) { - track(entering.enter()) - } else { - entering.snap() - } + val animatedExit = exiting?.takeIf { animate && it.view.isVisible } + if (exiting != null && animatedExit == null) instantHide(exiting.view) + + val settled = applyFit(next) + if (settled != null) { + val entering = decorations.getValue(next) + if (animate) { + track(entering.enter()) } else { - decorations[next]?.let { instantHide(it.view) } + entering.snap() } - return settled + } else { + decorations[next]?.let { instantHide(it.view) } } - when { - exiting == null -> onSettled(applyNext()) - animate && exiting.view.isVisible -> { - // The incoming decoration needs to enter in the same frame the outgoing one starts leaving to keep in sync with background. - val settled = applyNext() - track( - exiting.exit { - if (gen == generation) onSettled(settled) - }, - ) - } - else -> { - instantHide(exiting.view) - onSettled(applyNext()) - } - } + // Started last so the outgoing decoration begins leaving in the same frame the incoming one enters, which + // keeps both in sync with the background. + animatedExit?.let { track(it.exit()) } + return settled } override fun skipRunning() { @@ -291,9 +263,8 @@ class EmbellishmentControllerImpl( set.start() listOf(set) }, - exit = { onEnd -> + exit = { instantHide(view) - onEnd() emptyList() }, snap = { @@ -333,11 +304,11 @@ class EmbellishmentControllerImpl( fadeIn.start() listOf(fadeIn) }, - exit = { onEnd -> + exit = { view.setMinProgress(WING_STOP_PROGRESS) view.setMaxProgress(1f) view.speed = 1f - exitViaLottie(view, onEnd = onEnd, applyFinalState = { view.isInvisible = true }) + exitViaLottie(view, applyFinalState = { view.isInvisible = true }) emptyList() }, snap = { @@ -378,11 +349,11 @@ class EmbellishmentControllerImpl( fadeIn.start() listOf(fadeIn) }, - exit = { onEnd -> + exit = { view.setMinProgress(WING_STOP_PROGRESS) view.setMaxProgress(1f) view.speed = 1f - exitViaLottie(view, onEnd = onEnd, applyFinalState = { view.isGone = true }) + exitViaLottie(view, applyFinalState = { view.isGone = true }) emptyList() }, snap = { @@ -434,7 +405,7 @@ class EmbellishmentControllerImpl( animator.start() listOf(animator) }, - exit = { onEnd -> + exit = { val screenWidth = binding.root.rootView.width.toFloat() var cancelled = false val animator = ValueAnimator.ofFloat(0f, 1f).apply { @@ -456,7 +427,6 @@ class EmbellishmentControllerImpl( view.isVisible = false view.cancelAnimation() view.translationX = 0f - onEnd() } }, ) @@ -473,10 +443,9 @@ class EmbellishmentControllerImpl( ) } - /** Plays [view]'s Lottie to its end and reports through [onEnd]. Both the listener and a drain reach the same finish, which runs once. */ + /** Plays [view]'s Lottie to its end. Both the listener and a drain reach the same finish, which runs once. */ private fun exitViaLottie( view: LottieAnimationView, - onEnd: () -> Unit, applyFinalState: () -> Unit, ) { var finished = false @@ -487,7 +456,6 @@ class EmbellishmentControllerImpl( view.removeAnimatorListener(listener) applyFinalState() pendingExit = null - onEnd() } } listener = object : AnimatorListenerAdapter() { @@ -509,7 +477,7 @@ class EmbellishmentControllerImpl( val minHeightDp: Int, val bottomOverlapPx: () -> Int = { 0 }, val enter: () -> List, - val exit: (onEnd: () -> Unit) -> List, + val exit: () -> List, val snap: () -> Unit, ) diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt index 682cc7a193b9..0f77033c5e10 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt @@ -49,11 +49,13 @@ class DialogRenderEngineTest { @Suppress("unused") val coroutineRule = CoroutineTestRule() + private val callOrder = mutableListOf() + private val content = FakeContentController() - private val cardStage = FakeCardStage() + private val cardStage = FakeCardStage(record = { callOrder += it }) private val background = FakeBackgroundController() private val embellishments = FakeEmbellishmentController() - private val cardAnchor = FakeCardAnchorController() + private val cardAnchor = FakeCardAnchorController(record = { callOrder += it }) private val cardArrow = FakeCardArrowController() private val stepIndicator = FakeStepIndicatorController() @@ -100,6 +102,13 @@ class DialogRenderEngineTest { ) } + @Test + fun `the card anchor is applied before the morph so the card's reposition rides the transition`() = runTest { + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + + assertEquals(listOf("anchor", "morph"), callOrder) + } + @Test fun `re-emitting the same step and config does not re-render`() = runTest { testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) @@ -326,7 +335,7 @@ private class FakeContentController : ContentController { } } -private class FakeCardStage : CardStage { +private class FakeCardStage(private val record: (String) -> Unit = {}) : CardStage { /** When false, stage continuations queue up in [pending] so a test can settle them explicitly. */ var autoComplete = true @@ -341,7 +350,10 @@ private class FakeCardStage : CardStage { override fun reveal(animate: Boolean, onEnd: () -> Unit) = stage(animate, onEnd) - override fun morph(animate: Boolean, onEnd: () -> Unit) = stage(animate, onEnd) + override fun morph(animate: Boolean, onEnd: () -> Unit) { + record("morph") + stage(animate, onEnd) + } override fun fadeInContent(contentTargets: List, animate: Boolean, onEnd: () -> Unit) { fadeCount++ @@ -437,12 +449,13 @@ private class FakeCardArrowController : CardArrowController { } } -private class FakeCardAnchorController : CardAnchorController { +private class FakeCardAnchorController(private val record: (String) -> Unit = {}) : CardAnchorController { var applied = false override fun apply(settled: SettledDecoration?) { applied = true + record("anchor") } } @@ -457,11 +470,10 @@ private class FakeEmbellishmentController : EmbellishmentController { previous: Embellishment?, next: Embellishment, animate: Boolean, - onSettled: (SettledDecoration?) -> Unit, - ) { + ): SettledDecoration? { applied = previous to next animated = animate - onSettled(null) + return null } override fun skipRunning() { From 0400a8187ece224128792a58b5ec1f3f3180182b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Fri, 31 Jul 2026 12:49:19 +0200 Subject: [PATCH 19/28] give the card's anchor a single owner The fit corrector re-anchored the card itself on its veto path, hardcoding verticalBias to 0f. CardAnchorController applies 0.5f on tablet for the same unanchored case, so the two disagreed and whichever ran last won. The corrector now reports through onDecorationHidden and leaves the card's constraints alone. Config-driven re-runs cardAnchor.apply(null), which also drops the arrow depth the callback used to set on its own. Legacy keeps the old write verbatim in its callback, so its behaviour is unchanged. --- .../ui/page/BrandDesignUpdateWelcomePage.kt | 9 ++++++++- .../ui/page/OnboardingDecorationFitCorrector.kt | 10 +++++----- .../configdriven/ConfigDrivenWelcomePage.kt | 8 ++++++-- .../OnboardingDecorationFitCorrectorTest.kt | 17 +++++++++++++---- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BrandDesignUpdateWelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BrandDesignUpdateWelcomePage.kt index e7d0988ad028..8fb4634c7de2 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BrandDesignUpdateWelcomePage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BrandDesignUpdateWelcomePage.kt @@ -546,7 +546,14 @@ class BrandDesignUpdateWelcomePage : OnboardingPageFragment(R.layout.content_onb root = binding.root, dialog = binding.daxDialogCta.root, cardContainer = binding.daxDialogCta.cardContainer, - onDecorationHidden = { binding.daxDialogCta.cardView.setArrowDepthFraction(0f) }, + onDecorationHidden = { + binding.daxDialogCta.cardView.setArrowDepthFraction(0f) + binding.daxDialogCta.root.updateLayoutParams { + verticalBias = 0f + bottomToTop = ConstraintLayout.LayoutParams.UNSET + bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID + } + }, cardBottomInsetPx = { cardBottomInsetPx }, ).also { it.attach() } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingDecorationFitCorrector.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingDecorationFitCorrector.kt index c267e3259f71..3c549d1c36d4 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingDecorationFitCorrector.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingDecorationFitCorrector.kt @@ -24,6 +24,11 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.isGone import androidx.core.view.updateLayoutParams +/** + * @param onDecorationHidden the tracked decoration no longer fits and has been hidden. The card's own constraints + * are the host's to write, including re-anchoring it to the parent bottom now the decoration is gone: a second + * writer here would compete with whatever rule the host applies at render time. + */ class OnboardingDecorationFitCorrector( private val root: View, private val dialog: View, @@ -116,11 +121,6 @@ class OnboardingDecorationFitCorrector( if (target == null) { deco.isGone = true - dialog.updateLayoutParams { - verticalBias = 0f - bottomToTop = ConstraintLayout.LayoutParams.UNSET - bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID - } onDecorationHidden() return false } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt index a0ffbeba59b7..5754d48b4af3 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt @@ -142,6 +142,8 @@ class ConfigDrivenWelcomePage : OnboardingPageFragment(R.layout.content_onboardi windowInsets } + val cardAnchor = CardAnchorControllerImpl(binding, deviceInfo.isTablet()) + engine = DialogRenderEngine( content = ContentControllerImpl( binding = binding.daxDialogCta, @@ -157,10 +159,12 @@ class ConfigDrivenWelcomePage : OnboardingPageFragment(R.layout.content_onboardi ), embellishments = EmbellishmentControllerImpl( binding = binding, - onDecorationHidden = { binding.daxDialogCta.cardView.setArrowDepthFraction(0f) }, + // A decoration that stops fitting leaves the card anchored to a hidden view, so re-run the same + // anchor rule the render applies, which drops the arrow's depth along with it. + onDecorationHidden = { cardAnchor.apply(null) }, cardBottomInsetPx = { cardBottomInsetPx }, ), - cardAnchor = CardAnchorControllerImpl(binding, deviceInfo.isTablet()), + cardAnchor = cardAnchor, cardArrow = CardArrowControllerImpl(binding.daxDialogCta.cardView), stepIndicator = StepIndicatorControllerImpl(binding.daxDialogCta.stepIndicator), emit = viewModel::onEvent, diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/OnboardingDecorationFitCorrectorTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/OnboardingDecorationFitCorrectorTest.kt index 7c836b852102..ba44eccc27d1 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/OnboardingDecorationFitCorrectorTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/OnboardingDecorationFitCorrectorTest.kt @@ -169,8 +169,9 @@ class OnboardingDecorationFitCorrectorTest { } @Test - fun whenSlackBelowMinThenDecorationHiddenAndCardFillsParent() { + fun whenSlackBelowMinThenDecorationHiddenAndCardConstraintsLeftToTheHost() { // overflow = 1100 - 1080 = 20. dialogSpace = 1080 + 20 = 1100. slack = 1116 - 1100 = 16 < min 247 -> null. + var hidden = false val h = harness( rootHeight = 1200, rootPaddingBottom = 84, @@ -180,14 +181,22 @@ class OnboardingDecorationFitCorrectorTest { decorationHeight = 299, minHeightPx = 247, maxHeightPx = 299, + onDecorationHidden = { hidden = true }, ) + (h.dialog.layoutParams as ConstraintLayout.LayoutParams).apply { + verticalBias = 1f + bottomToTop = h.decoration.id + bottomToBottom = ConstraintLayout.LayoutParams.UNSET + } assertFalse(h.corrector.correctOnce()) + assertTrue(h.decoration.isGone) + assertTrue(hidden) val lp = h.dialog.layoutParams as ConstraintLayout.LayoutParams - assertEquals(0f, lp.verticalBias) - assertEquals(ConstraintLayout.LayoutParams.UNSET, lp.bottomToTop) - assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, lp.bottomToBottom) + assertEquals(1f, lp.verticalBias) + assertEquals(h.decoration.id, lp.bottomToTop) + assertEquals(ConstraintLayout.LayoutParams.UNSET, lp.bottomToBottom) } @Test From 91c180fd7266b1de73e273556aacbdb5b571991e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Fri, 31 Jul 2026 14:21:16 +0200 Subject: [PATCH 20/28] give screens with no decoration a reserved band Embellishment.None had no placement data of its own, so it fell through to a hardcoded bias in CardAnchorController that also served the fit-veto case and was wrong for it: legacy pins the card high when a decoration does not fit, but centres it on a tablet when the screen has none. None now maps to an undrawn Space that reserves the room a decoration would have taken, floored at the card's bottom inset since the card anchors above the band and so never reserves that inset itself. A decoration-less card therefore lands near a decorated one without consulting whichever screen ran before it, which is what the dock step reached for by keeping the outgoing wing INVISIBLE. That trick made placement depend on step order, so the bottom wing's exit goes back to GONE. Placement moves into an exhaustive table and bias selection into a pure resolver, both now unit-testable. The table also means a new embellishment cannot compile until its placement is stated, and the arrow depth reads the band's drawsArtwork rather than inferring artwork from a non-null decoration. --- .../configdriven/ConfigDrivenWelcomePage.kt | 3 +- .../engine/CardAnchorController.kt | 20 ++- .../configdriven/engine/CardAnchorResolver.kt | 61 +++++++++ .../engine/EmbellishmentController.kt | 118 ++++++++++-------- .../engine/EmbellishmentPlacement.kt | 52 ++++++++ .../configdriven/engine/RenderControllers.kt | 4 +- ...content_onboarding_welcome_page_update.xml | 12 ++ ...content_onboarding_welcome_page_update.xml | 12 ++ ...content_onboarding_welcome_page_update.xml | 12 ++ .../engine/CardAnchorResolverTest.kt | 115 +++++++++++++++++ .../engine/DialogRenderEngineTest.kt | 17 ++- .../engine/EmbellishmentPlacementTest.kt | 74 +++++++++++ 12 files changed, 434 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorResolver.kt create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentPlacement.kt create mode 100644 app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorResolverTest.kt create mode 100644 app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentPlacementTest.kt diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt index 5754d48b4af3..3ed30bdb757d 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt @@ -44,6 +44,7 @@ import com.duckduckgo.app.onboarding.ui.page.OnboardingBackgroundAnimator import com.duckduckgo.app.onboarding.ui.page.OnboardingPageFragment import com.duckduckgo.app.onboarding.ui.page.configdriven.engine.BackgroundControllerImpl import com.duckduckgo.app.onboarding.ui.page.configdriven.engine.CardAnchorControllerImpl +import com.duckduckgo.app.onboarding.ui.page.configdriven.engine.CardAnchorResolver import com.duckduckgo.app.onboarding.ui.page.configdriven.engine.CardArrowControllerImpl import com.duckduckgo.app.onboarding.ui.page.configdriven.engine.CardStageImpl import com.duckduckgo.app.onboarding.ui.page.configdriven.engine.ContentControllerImpl @@ -142,7 +143,7 @@ class ConfigDrivenWelcomePage : OnboardingPageFragment(R.layout.content_onboardi windowInsets } - val cardAnchor = CardAnchorControllerImpl(binding, deviceInfo.isTablet()) + val cardAnchor = CardAnchorControllerImpl(binding, CardAnchorResolver(deviceInfo.isTablet())) engine = DialogRenderEngine( content = ContentControllerImpl( diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorController.kt index acb172677434..8844dd036906 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorController.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorController.kt @@ -30,32 +30,28 @@ interface CardAnchorController { class CardAnchorControllerImpl( private val binding: ContentOnboardingWelcomePageUpdateBinding, - private val isTablet: Boolean, + private val resolver: CardAnchorResolver, ) : CardAnchorController { /** - * @param settled The decoration the embellishment axis settled on, or null when there is none or the fit - * check vetoed it. The card anchors above [SettledDecoration.view] when non-null and either [isTablet] or - * [SettledDecoration.anchorsCardOnPhone] is true; otherwise it pins to the parent bottom. - * * Arrow visibility is deliberately not handled here: it is screen data off the config, whereas the depth * below follows from what the embellishment axis settled on. */ override fun apply(settled: SettledDecoration?) { - val card = binding.daxDialogCta.root + val resolution = resolver.resolve(settled) - card.updateLayoutParams { - if (settled != null && (isTablet || settled.anchorsCardOnPhone)) { - bottomToTop = settled.view.id + binding.daxDialogCta.root.updateLayoutParams { + val anchorTo = resolution.anchorTo + if (anchorTo != null) { + bottomToTop = anchorTo.id bottomToBottom = ConstraintLayout.LayoutParams.UNSET - verticalBias = if (isTablet) settled.anchoredCardBiasTablet else settled.anchoredCardBiasPhone } else { bottomToTop = ConstraintLayout.LayoutParams.UNSET bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID - verticalBias = if (isTablet) 0.5f else 0f } + verticalBias = resolution.verticalBias } - binding.daxDialogCta.cardView.setArrowDepthFraction(if (settled != null) 1f else 0f) + binding.daxDialogCta.cardView.setArrowDepthFraction(resolution.arrowDepthFraction) } } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorResolver.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorResolver.kt new file mode 100644 index 000000000000..6d208c186a92 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorResolver.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.engine + +import android.view.View + +/** + * Resolves where the card's bottom edge goes from the placement the settled decoration declares. Free of views + * beyond the one it passes through, so the placement table is unit-testable. + * + * The card anchors above the decoration when it is a tablet or the decoration reserves room on a phone, at the + * bias that decoration declares for the form factor. Otherwise it pins to the parent bottom. + */ +class CardAnchorResolver(private val isTablet: Boolean) { + + /** @param settled null when the screen's decoration did not fit the room the card left it. */ + fun resolve(settled: SettledDecoration?): Resolution { + if (settled == null) { + return Resolution(anchorTo = null, verticalBias = UNANCHORED_CARD_BIAS, arrowDepthFraction = 0f) + } + val placement = settled.placement + val arrowDepthFraction = if (placement.drawsArtwork) 1f else 0f + if (!isTablet && !placement.anchorsCardOnPhone) { + return Resolution(anchorTo = null, verticalBias = UNANCHORED_CARD_BIAS, arrowDepthFraction = arrowDepthFraction) + } + return Resolution( + anchorTo = settled.view, + verticalBias = if (isTablet) placement.biasTablet else placement.biasPhone, + arrowDepthFraction = arrowDepthFraction, + ) + } + + /** @param anchorTo the view the card's bottom constrains to, or null to pin it to the parent bottom. */ + data class Resolution( + val anchorTo: View?, + val verticalBias: Float, + val arrowDepthFraction: Float, + ) + + private companion object { + /** + * The card pins to the parent bottom either because its decoration did not fit, in which case the card + * is already near-filling its space, or because a side decoration reserves no room on a phone. + */ + const val UNANCHORED_CARD_BIAS = 0f + } +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt index c5b0f186b2b6..07158e8758d7 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt @@ -25,7 +25,6 @@ import android.view.View import android.view.ViewGroup import android.view.animation.PathInterpolator import androidx.core.view.isGone -import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import com.airbnb.lottie.LottieAnimationView @@ -89,6 +88,7 @@ class EmbellishmentControllerImpl( Embellishment.BottomWing to buildBottomWing(), Embellishment.LeftWing to buildLeftWing(), Embellishment.BobbingDax to buildBobbingDax(), + Embellishment.None to buildUndecoratedBand(), ) init { @@ -113,7 +113,7 @@ class EmbellishmentControllerImpl( val exiting = previous?.let { decorations[it] } val animatedExit = exiting?.takeIf { animate && it.view.isVisible } - if (exiting != null && animatedExit == null) instantHide(exiting.view) + if (exiting != null && animatedExit == null) exiting.hide() val settled = applyFit(next) if (settled != null) { @@ -124,7 +124,7 @@ class EmbellishmentControllerImpl( entering.snap() } } else { - decorations[next]?.let { instantHide(it.view) } + decorations[next]?.hide() } // Started last so the outgoing decoration begins leaving in the same frame the incoming one enters, which @@ -184,8 +184,8 @@ class EmbellishmentControllerImpl( rootView = binding.root, dialogView = binding.daxDialogCta.root, decorationView = decoration.view, - maxHeightPx = decoration.maxHeightDp.toPx(), - minHeightPx = decoration.minHeightDp.toPx(), + maxHeightPx = decoration.maxHeightPx(), + minHeightPx = decoration.minHeightPx(), bottomOverlapPx = decoration.bottomOverlapPx(), ) if (fitHeightPx == null) { @@ -197,16 +197,11 @@ class EmbellishmentControllerImpl( decoration.view.updateLayoutParams { height = fitHeightPx } fitCorrector.track( decoration.view, - minHeightPx = decoration.minHeightDp.toPx(), - maxHeightPx = decoration.maxHeightDp.toPx(), + minHeightPx = decoration.minHeightPx(), + maxHeightPx = decoration.maxHeightPx(), bottomOverlapPx = decoration.bottomOverlapPx(), ) - return SettledDecoration( - view = decoration.view, - anchorsCardOnPhone = decoration.anchorsCardOnPhone, - anchoredCardBiasPhone = decoration.anchoredCardBiasPhone, - anchoredCardBiasTablet = decoration.anchoredCardBiasTablet, - ) + return SettledDecoration(view = decoration.view, placement = decoration.placement) } // A bottom-anchored predecessor can leave a bottom inset on the card. Clear it before measuring so it does not @@ -224,21 +219,14 @@ class EmbellishmentControllerImpl( return (cardBottomMargin - LEFT_WING_CARD_GAP_DP.toPx()).coerceAtLeast(0) } - private fun instantHide(view: LottieAnimationView) { - view.cancelAnimation() - view.isVisible = false - } - private fun buildWalkingDax(): Decoration { val view = binding.welcomeScreenWalkingDax + val hide = instantHideOf(view) return Decoration( view = view, - anchorsCardOnPhone = true, - // Bias 1 keeps the card pressed down against the dax, on phone and tablet alike. - anchoredCardBiasPhone = 1f, - anchoredCardBiasTablet = 1f, - maxHeightDp = WALKING_DAX_MAX_HEIGHT_DP, - minHeightDp = WALKING_DAX_MIN_HEIGHT_DP, + placement = EmbellishmentPlacement.of(Embellishment.WalkingDax), + maxHeightPx = { WALKING_DAX_MAX_HEIGHT_DP.toPx() }, + minHeightPx = { WALKING_DAX_MIN_HEIGHT_DP.toPx() }, enter = { val fade = ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f) .setDuration(WALKING_DAX_FADE_DURATION) @@ -264,9 +252,10 @@ class EmbellishmentControllerImpl( listOf(set) }, exit = { - instantHide(view) + hide() emptyList() }, + hide = hide, snap = { view.cancelAnimation() view.isVisible = true @@ -277,15 +266,46 @@ class EmbellishmentControllerImpl( ) } + /** Cancels [view]'s animation and drops its layout footprint, for a snapped render or a fit veto. */ + private fun instantHideOf(view: LottieAnimationView): () -> Unit = { + view.cancelAnimation() + view.isVisible = false + } + + /** + * A screen with no decoration still reserves the room one would have taken, so its card sits at a + * comparable height rather than dropping to the parent bottom. The floor is the card's bottom inset, + * because the card anchors above the band and so never reserves that inset itself. + */ + private fun buildUndecoratedBand(): Decoration { + val view = binding.undecoratedBand + val show = { view.isVisible = true } + val hide = { view.isVisible = false } + return Decoration( + view = view, + placement = EmbellishmentPlacement.of(Embellishment.None), + maxHeightPx = { UNDECORATED_BAND_MAX_HEIGHT_DP.toPx() }, + minHeightPx = cardBottomInsetPx, + enter = { + show() + emptyList() + }, + exit = { + hide() + emptyList() + }, + hide = hide, + snap = show, + ) + } + private fun buildBottomWing(): Decoration { val view = binding.bottomWingAnimation return Decoration( view = view, - anchorsCardOnPhone = true, - anchoredCardBiasPhone = 0f, - anchoredCardBiasTablet = 0.5f, - maxHeightDp = BOTTOM_WING_MAX_HEIGHT_DP, - minHeightDp = BOTTOM_WING_MIN_HEIGHT_DP, + placement = EmbellishmentPlacement.of(Embellishment.BottomWing), + maxHeightPx = { BOTTOM_WING_MAX_HEIGHT_DP.toPx() }, + minHeightPx = { BOTTOM_WING_MIN_HEIGHT_DP.toPx() }, enter = { view.isVisible = true view.alpha = 0f @@ -308,9 +328,10 @@ class EmbellishmentControllerImpl( view.setMinProgress(WING_STOP_PROGRESS) view.setMaxProgress(1f) view.speed = 1f - exitViaLottie(view, applyFinalState = { view.isInvisible = true }) + exitViaLottie(view, applyFinalState = { view.isGone = true }) emptyList() }, + hide = instantHideOf(view), snap = { view.cancelAnimation() view.isVisible = true @@ -324,12 +345,9 @@ class EmbellishmentControllerImpl( val view = binding.leftWingAnimation return Decoration( view = view, - anchorsCardOnPhone = false, - // Anchors the card on tablet only, so the phone bias is never read. - anchoredCardBiasPhone = 0f, - anchoredCardBiasTablet = 0.5f, - maxHeightDp = LEFT_WING_MAX_HEIGHT_DP, - minHeightDp = LEFT_WING_MIN_HEIGHT_DP, + placement = EmbellishmentPlacement.of(Embellishment.LeftWing), + maxHeightPx = { LEFT_WING_MAX_HEIGHT_DP.toPx() }, + minHeightPx = { LEFT_WING_MIN_HEIGHT_DP.toPx() }, bottomOverlapPx = { leftWingBottomOverlapPx() }, enter = { view.isVisible = true @@ -356,6 +374,7 @@ class EmbellishmentControllerImpl( exitViaLottie(view, applyFinalState = { view.isGone = true }) emptyList() }, + hide = instantHideOf(view), snap = { view.cancelAnimation() view.isVisible = true @@ -370,12 +389,9 @@ class EmbellishmentControllerImpl( val view = binding.bobbingDaxAnimation return Decoration( view = view, - anchorsCardOnPhone = false, - // Anchors the card on tablet only, so the phone bias is never read. - anchoredCardBiasPhone = 0f, - anchoredCardBiasTablet = 0.5f, - maxHeightDp = BOBBING_DAX_MAX_HEIGHT_DP, - minHeightDp = BOBBING_DAX_MIN_HEIGHT_DP, + placement = EmbellishmentPlacement.of(Embellishment.BobbingDax), + maxHeightPx = { BOBBING_DAX_MAX_HEIGHT_DP.toPx() }, + minHeightPx = { BOBBING_DAX_MIN_HEIGHT_DP.toPx() }, enter = { val screenWidth = binding.root.rootView.width.toFloat() view.isVisible = true @@ -434,6 +450,7 @@ class EmbellishmentControllerImpl( animator.start() listOf(animator) }, + hide = instantHideOf(view), snap = { view.isVisible = true view.alpha = 1f @@ -469,15 +486,15 @@ class EmbellishmentControllerImpl( } private class Decoration( - val view: LottieAnimationView, - val anchorsCardOnPhone: Boolean, - val anchoredCardBiasPhone: Float, - val anchoredCardBiasTablet: Float, - val maxHeightDp: Int, - val minHeightDp: Int, + val view: View, + val placement: EmbellishmentPlacement.Placement, + val maxHeightPx: () -> Int, + val minHeightPx: () -> Int, val bottomOverlapPx: () -> Int = { 0 }, val enter: () -> List, val exit: () -> List, + /** Removes the view and its layout footprint at once, for a snapped render or a fit veto. */ + val hide: () -> Unit, val snap: () -> Unit, ) @@ -507,6 +524,9 @@ class EmbellishmentControllerImpl( const val BOBBING_DAX_MAX_HEIGHT_DP = 156 const val BOBBING_DAX_MIN_HEIGHT_DP = 130 + // Matches the two wings, so a screen with no decoration lands within a few dp of a wing screen. + const val UNDECORATED_BAND_MAX_HEIGHT_DP = 199 + val WALKING_DAX_INTERPOLATOR = PathInterpolator(0.33f, 0f, 0.67f, 1f) } } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentPlacement.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentPlacement.kt new file mode 100644 index 000000000000..a81dcf11f36f --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentPlacement.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.engine + +import com.duckduckgo.app.onboarding.ui.page.configdriven.Embellishment + +/** + * Where each [Embellishment] puts the card, kept free of views so the whole table is unit-testable. + * + * Every embellishment declares its own placement, including [Embellishment.None], whose undrawn band reserves + * the room a decoration would have occupied. Nothing here may depend on which screen rendered previously: the + * onboarding step order is meant to be reshuffled freely. + */ +object EmbellishmentPlacement { + + fun of(embellishment: Embellishment): Placement = when (embellishment) { + // Bias 1 keeps the card pressed down against the dax, on phone and tablet alike. + Embellishment.WalkingDax -> Placement(anchorsCardOnPhone = true, biasPhone = 1f, biasTablet = 1f, drawsArtwork = true) + Embellishment.BottomWing -> Placement(anchorsCardOnPhone = true, biasPhone = 0f, biasTablet = 0.5f, drawsArtwork = true) + // The side decorations reserve no room on a phone, where the card runs down past them instead. + Embellishment.LeftWing -> Placement(anchorsCardOnPhone = false, biasPhone = 0f, biasTablet = 0.5f, drawsArtwork = true) + Embellishment.BobbingDax -> Placement(anchorsCardOnPhone = false, biasPhone = 0f, biasTablet = 0.5f, drawsArtwork = true) + Embellishment.None -> Placement(anchorsCardOnPhone = true, biasPhone = 0f, biasTablet = 0.5f, drawsArtwork = false) + } + + /** + * @param anchorsCardOnPhone whether this embellishment reserves room below the card on a phone. False means + * the card pins to the parent bottom and the artwork overlaps it. + * @param drawsArtwork false for [Embellishment.None]'s band, which reserves room without drawing, so the + * card's bubble arrow has nothing to point at. + */ + data class Placement( + val anchorsCardOnPhone: Boolean, + val biasPhone: Float, + val biasTablet: Float, + val drawsArtwork: Boolean, + ) +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt index 66a4fc124262..7b76999feb87 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/RenderControllers.kt @@ -23,7 +23,5 @@ import android.view.View */ data class SettledDecoration( val view: View, - val anchorsCardOnPhone: Boolean, - val anchoredCardBiasPhone: Float, - val anchoredCardBiasTablet: Float, + val placement: EmbellishmentPlacement.Placement, ) diff --git a/app/src/main/res/layout-land/content_onboarding_welcome_page_update.xml b/app/src/main/res/layout-land/content_onboarding_welcome_page_update.xml index 14667388d43f..6b51719709bc 100644 --- a/app/src/main/res/layout-land/content_onboarding_welcome_page_update.xml +++ b/app/src/main/res/layout-land/content_onboarding_welcome_page_update.xml @@ -218,6 +218,18 @@ tools:lottie_progress="0.5" tools:visibility="visible" /> + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp/content_onboarding_welcome_page_update.xml b/app/src/main/res/layout-sw600dp/content_onboarding_welcome_page_update.xml index 693c0ae99ad6..7249e8691153 100644 --- a/app/src/main/res/layout-sw600dp/content_onboarding_welcome_page_update.xml +++ b/app/src/main/res/layout-sw600dp/content_onboarding_welcome_page_update.xml @@ -212,4 +212,16 @@ tools:lottie_progress="0.5" tools:visibility="visible" /> + + + diff --git a/app/src/main/res/layout/content_onboarding_welcome_page_update.xml b/app/src/main/res/layout/content_onboarding_welcome_page_update.xml index 4c7faf5bf93f..3f1870e5412c 100644 --- a/app/src/main/res/layout/content_onboarding_welcome_page_update.xml +++ b/app/src/main/res/layout/content_onboarding_welcome_page_update.xml @@ -203,4 +203,16 @@ tools:lottie_progress="0.5" tools:visibility="visible" /> + + + \ No newline at end of file diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorResolverTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorResolverTest.kt new file mode 100644 index 000000000000..b18b5e894ed0 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardAnchorResolverTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.engine + +import android.view.View +import com.duckduckgo.app.onboarding.ui.page.configdriven.Embellishment +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.mockito.kotlin.mock + +class CardAnchorResolverTest { + + private val decorationView: View = mock() + + private fun settled(embellishment: Embellishment) = SettledDecoration( + view = decorationView, + placement = EmbellishmentPlacement.of(embellishment), + ) + + @Test + fun `on a phone the card anchors above a bottom wing at its phone bias`() { + val resolution = CardAnchorResolver(isTablet = false).resolve(settled(Embellishment.BottomWing)) + + assertSame(decorationView, resolution.anchorTo) + assertEquals(0f, resolution.verticalBias) + assertEquals(1f, resolution.arrowDepthFraction) + } + + @Test + fun `on a tablet the card anchors above a bottom wing at its tablet bias`() { + val resolution = CardAnchorResolver(isTablet = true).resolve(settled(Embellishment.BottomWing)) + + assertSame(decorationView, resolution.anchorTo) + assertEquals(0.5f, resolution.verticalBias) + } + + @Test + fun `on a phone a side decoration leaves the card pinned to the parent bottom`() { + val resolution = CardAnchorResolver(isTablet = false).resolve(settled(Embellishment.LeftWing)) + + assertNull(resolution.anchorTo) + assertEquals(0f, resolution.verticalBias) + } + + @Test + fun `on a phone a side decoration still gives the card's arrow something to point at`() { + val resolution = CardAnchorResolver(isTablet = false).resolve(settled(Embellishment.BobbingDax)) + + assertEquals(1f, resolution.arrowDepthFraction) + } + + @Test + fun `on a tablet a side decoration anchors the card above it`() { + val resolution = CardAnchorResolver(isTablet = true).resolve(settled(Embellishment.BobbingDax)) + + assertSame(decorationView, resolution.anchorTo) + assertEquals(0.5f, resolution.verticalBias) + } + + @Test + fun `the walking dax presses the card down onto itself`() { + val resolution = CardAnchorResolver(isTablet = false).resolve(settled(Embellishment.WalkingDax)) + + assertSame(decorationView, resolution.anchorTo) + assertEquals(1f, resolution.verticalBias) + } + + @Test + fun `on a phone the undecorated band anchors the card so it sits where a decorated card sits`() { + val resolution = CardAnchorResolver(isTablet = false).resolve(settled(Embellishment.None)) + + assertSame(decorationView, resolution.anchorTo) + assertEquals(0f, resolution.verticalBias) + } + + @Test + fun `on a tablet the undecorated band anchors the card`() { + val resolution = CardAnchorResolver(isTablet = true).resolve(settled(Embellishment.None)) + + assertSame(decorationView, resolution.anchorTo) + assertEquals(0.5f, resolution.verticalBias) + } + + @Test + fun `the undecorated band leaves the card's arrow with nothing to point at`() { + val resolution = CardAnchorResolver(isTablet = true).resolve(settled(Embellishment.None)) + + assertEquals(0f, resolution.arrowDepthFraction) + } + + @Test + fun `a decoration that did not fit pins the card high, since the card is already near-filling its space`() { + val resolution = CardAnchorResolver(isTablet = true).resolve(null) + + assertNull(resolution.anchorTo) + assertEquals(0f, resolution.verticalBias) + assertEquals(0f, resolution.arrowDepthFraction) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt index 0f77033c5e10..6373d73afe12 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt @@ -37,6 +37,7 @@ import com.duckduckgo.onboarding.api.LinearOnboardingStepId import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -109,6 +110,16 @@ class DialogRenderEngineTest { assertEquals(listOf("anchor", "morph"), callOrder) } + @Test + fun `the settled decoration reaches the card anchor`() = runTest { + val settled = SettledDecoration(view = mock(), placement = EmbellishmentPlacement.of(Embellishment.None)) + embellishments.settledResult = settled + + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + + assertSame(settled, cardAnchor.appliedWith) + } + @Test fun `re-emitting the same step and config does not re-render`() = runTest { testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) @@ -452,9 +463,11 @@ private class FakeCardArrowController : CardArrowController { private class FakeCardAnchorController(private val record: (String) -> Unit = {}) : CardAnchorController { var applied = false + var appliedWith: SettledDecoration? = null override fun apply(settled: SettledDecoration?) { applied = true + appliedWith = settled record("anchor") } } @@ -466,6 +479,8 @@ private class FakeEmbellishmentController : EmbellishmentController { var skipped = false var released = false + var settledResult: SettledDecoration? = null + override fun transition( previous: Embellishment?, next: Embellishment, @@ -473,7 +488,7 @@ private class FakeEmbellishmentController : EmbellishmentController { ): SettledDecoration? { applied = previous to next animated = animate - return null + return settledResult } override fun skipRunning() { diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentPlacementTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentPlacementTest.kt new file mode 100644 index 000000000000..2bb175b69718 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentPlacementTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.onboarding.ui.page.configdriven.engine + +import com.duckduckgo.app.onboarding.ui.page.configdriven.Embellishment +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class EmbellishmentPlacementTest { + + @Test + fun `the walking dax presses the card down onto itself on both form factors`() { + val placement = EmbellishmentPlacement.of(Embellishment.WalkingDax) + + assertTrue(placement.anchorsCardOnPhone) + assertEquals(1f, placement.biasPhone) + assertEquals(1f, placement.biasTablet) + assertTrue(placement.drawsArtwork) + } + + @Test + fun `the bottom wing reserves room on both form factors`() { + val placement = EmbellishmentPlacement.of(Embellishment.BottomWing) + + assertTrue(placement.anchorsCardOnPhone) + assertEquals(0f, placement.biasPhone) + assertEquals(0.5f, placement.biasTablet) + assertTrue(placement.drawsArtwork) + } + + @Test + fun `the left wing reserves room on tablet only`() { + val placement = EmbellishmentPlacement.of(Embellishment.LeftWing) + + assertFalse(placement.anchorsCardOnPhone) + assertEquals(0.5f, placement.biasTablet) + assertTrue(placement.drawsArtwork) + } + + @Test + fun `the bobbing dax reserves room on tablet only`() { + val placement = EmbellishmentPlacement.of(Embellishment.BobbingDax) + + assertFalse(placement.anchorsCardOnPhone) + assertEquals(0.5f, placement.biasTablet) + assertTrue(placement.drawsArtwork) + } + + @Test + fun `the undecorated band reserves room on both form factors and draws nothing`() { + val placement = EmbellishmentPlacement.of(Embellishment.None) + + assertTrue(placement.anchorsCardOnPhone) + assertEquals(0f, placement.biasPhone) + assertEquals(0.5f, placement.biasTablet) + assertFalse(placement.drawsArtwork) + } +} From 100271732cd17a7cec153cb66f2088bd8f6187b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Fri, 31 Jul 2026 15:17:52 +0200 Subject: [PATCH 21/28] cleanup comments --- .../onboarding/ui/page/OnboardingDecorationFitCorrector.kt | 5 ----- .../layout-land/content_onboarding_welcome_page_update.xml | 4 ---- .../content_onboarding_welcome_page_update.xml | 4 ---- .../res/layout/content_onboarding_welcome_page_update.xml | 4 ---- 4 files changed, 17 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingDecorationFitCorrector.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingDecorationFitCorrector.kt index 3c549d1c36d4..64a712acdb72 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingDecorationFitCorrector.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingDecorationFitCorrector.kt @@ -24,11 +24,6 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.isGone import androidx.core.view.updateLayoutParams -/** - * @param onDecorationHidden the tracked decoration no longer fits and has been hidden. The card's own constraints - * are the host's to write, including re-anchoring it to the parent bottom now the decoration is gone: a second - * writer here would compete with whatever rule the host applies at render time. - */ class OnboardingDecorationFitCorrector( private val root: View, private val dialog: View, diff --git a/app/src/main/res/layout-land/content_onboarding_welcome_page_update.xml b/app/src/main/res/layout-land/content_onboarding_welcome_page_update.xml index 6b51719709bc..7baf19e32d1b 100644 --- a/app/src/main/res/layout-land/content_onboarding_welcome_page_update.xml +++ b/app/src/main/res/layout-land/content_onboarding_welcome_page_update.xml @@ -218,10 +218,6 @@ tools:lottie_progress="0.5" tools:visibility="visible" /> - - - Date: Fri, 31 Jul 2026 15:51:55 +0200 Subject: [PATCH 22/28] release background controller --- .../onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt index b8b71a7cd6a0..1b2c278d01ec 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt @@ -140,6 +140,7 @@ class DialogRenderEngine( afterFadeAnimator?.cancel() afterFadeAnimator = null unbindCurrent() + background.release() cardStage.release() embellishments.release() stepIndicator.release() From 722e8734ff3dc08962b974bbb2fe92672bfedf67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Fri, 31 Jul 2026 15:54:22 +0200 Subject: [PATCH 23/28] fix walking dax entry --- .../ui/page/configdriven/engine/EmbellishmentController.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt index 07158e8758d7..b6da5a6da844 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt @@ -228,6 +228,8 @@ class EmbellishmentControllerImpl( maxHeightPx = { WALKING_DAX_MAX_HEIGHT_DP.toPx() }, minHeightPx = { WALKING_DAX_MIN_HEIGHT_DP.toPx() }, enter = { + view.isVisible = true + view.alpha = 0f val fade = ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f) .setDuration(WALKING_DAX_FADE_DURATION) val slide = ObjectAnimator.ofFloat( From 7fd87f29fdab68b5e4c16a11dd27986103263959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Fri, 31 Jul 2026 16:03:29 +0200 Subject: [PATCH 24/28] fix lambda access --- .../ui/page/configdriven/engine/EmbellishmentController.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt index b6da5a6da844..f2a2720570a9 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt @@ -107,7 +107,7 @@ class EmbellishmentControllerImpl( if (previous == next) { // The drain may have cut this decoration's own entrance short, so snap it to where that entrance was // heading before reporting the fit. - decorations[next]?.snap() + decorations[next]?.snap?.invoke() return applyFit(next) } @@ -124,7 +124,7 @@ class EmbellishmentControllerImpl( entering.snap() } } else { - decorations[next]?.hide() + decorations[next]?.hide?.invoke() } // Started last so the outgoing decoration begins leaving in the same frame the incoming one enters, which @@ -135,7 +135,7 @@ class EmbellishmentControllerImpl( override fun skipRunning() { drainInFlight() - currentDecoration?.snap() + currentDecoration?.snap?.invoke() } override fun release() { From 713795f97cfb8a3878f64757612e96407d664bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Tue, 4 Aug 2026 10:10:25 +0200 Subject: [PATCH 25/28] address review comments --- .../app/onboarding/ui/OnboardingViewModel.kt | 22 ++++++++------ .../configdriven/ConfigDrivenWelcomePage.kt | 30 +++++++------------ .../configdriven/binders/AddressBarBinder.kt | 1 + .../engine/CardArrowController.kt | 6 ++++ .../ui/page/configdriven/engine/CardStage.kt | 3 ++ .../configdriven/engine/ContentController.kt | 20 +++++-------- .../configdriven/engine/DialogRenderEngine.kt | 1 + .../engine/EmbellishmentController.kt | 6 +++- .../engine/DialogRenderEngineTest.kt | 18 +++++++++++ 9 files changed, 65 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModel.kt index fe90cf5ac301..a0a79748a558 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModel.kt @@ -57,18 +57,20 @@ class OnboardingViewModel @Inject constructor( val viewState = _viewState.asStateFlow() suspend fun initializePages() { - val isBrandDesignUpdateEnabled = withContext(dispatchers.io()) { - onboardingBrandDesignUpdateToggles.brandDesignUpdate().isEnabled() - } - val isConfigDrivenDialogsEnabled = isBrandDesignUpdateEnabled && - withContext(dispatchers.io()) { onboardingBrandDesignUpdateToggles.configDrivenDialogs().isEnabled() } - when { - isConfigDrivenDialogsEnabled -> pageLayoutManager.buildConfigDrivenPageBlueprints() - isBrandDesignUpdateEnabled -> pageLayoutManager.buildBrandDesignUpdatePageBlueprints() - else -> pageLayoutManager.buildPageBlueprints() + val renderer = withContext(dispatchers.io()) { resolveRenderer() } + when (renderer) { + OnboardingRenderer.ConfigDriven -> pageLayoutManager.buildConfigDrivenPageBlueprints() + OnboardingRenderer.BrandDesignUpdate -> pageLayoutManager.buildBrandDesignUpdatePageBlueprints() + OnboardingRenderer.Legacy -> pageLayoutManager.buildPageBlueprints() } } + private fun resolveRenderer(): OnboardingRenderer = when { + !onboardingBrandDesignUpdateToggles.brandDesignUpdate().isEnabled() -> OnboardingRenderer.Legacy + onboardingBrandDesignUpdateToggles.configDrivenDialogs().isEnabled() -> OnboardingRenderer.ConfigDriven + else -> OnboardingRenderer.BrandDesignUpdate + } + fun pageCount(): Int { return pageLayoutManager.pageCount() } @@ -156,4 +158,6 @@ class OnboardingViewModel @Inject constructor( DUCK_AI_FOCUSED, DEFAULT_WITHOUT_INTRO_CTA, } + + private enum class OnboardingRenderer { Legacy, BrandDesignUpdate, ConfigDriven } } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt index 3ed30bdb757d..be5b7dd9baf5 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt @@ -93,8 +93,6 @@ class ConfigDrivenWelcomePage : OnboardingPageFragment(R.layout.content_onboardi /** Fed to the embellishment controller's fit corrector; kept in sync by the window-insets listener below. */ private var cardBottomInsetPx = 0 - private var hasRenderedOnce = false - private val requestNotificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (view?.windowVisibility == View.VISIBLE) { viewModel.notificationPermissionFlowFinished(granted) @@ -200,25 +198,19 @@ class ConfigDrivenWelcomePage : OnboardingPageFragment(R.layout.content_onboardi private fun renderConfig(state: ConfigDrivenOnboardingPageViewModel.ViewState) { val engine = engine ?: return - val stepId = state.stepId ?: return - val config = state.config ?: return + settleIntroViews() - if (!hasRenderedOnce) { - hasRenderedOnce = true - settleIntroViews() - // A retained view model emits before a recreated view has been laid out, and the decoration fit - // check measures the root's height — measuring at 0 would hide the decoration for good. - binding.root.doOnLayout { - val live = viewModel.viewState.value - val liveStepId = live.stepId ?: return@doOnLayout - val liveConfig = live.config ?: return@doOnLayout - engine.render(liveStepId, liveConfig, live.animateEntry) - viewModel.onDialogRendered(liveStepId) - } - } else { - engine.render(stepId, config, state.animateEntry) - viewModel.onDialogRendered(stepId) + // A retained view model emits before a recreated view has been laid out, and the decoration fit + // check measures the root's height, so measuring at 0 would hide the decoration for good. + if (!binding.root.isLaidOut) { + binding.root.doOnLayout { renderConfig(viewModel.viewState.value) } + return } + + val stepId = state.stepId ?: return + val config = state.config ?: return + engine.render(stepId, config, state.animateEntry) + viewModel.onDialogRendered(stepId) } private fun handleCommand(command: ConfigDrivenOnboardingPageViewModel.Command) { diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/AddressBarBinder.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/AddressBarBinder.kt index 43a5e79f36ad..77a9459857b8 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/AddressBarBinder.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/AddressBarBinder.kt @@ -54,6 +54,7 @@ class AddressBarBinder( title = addressBarTitle, fadeTargets = listOf(addressBarPicker), result = { NewUserOnboardingEvent.AddressBarConfirmed(state.value.position) }, + unbind = { addressBarPicker.setOnSelectionChangedListener {} }, ) } } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardArrowController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardArrowController.kt index 7fd8048a78f6..59b50f6be256 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardArrowController.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardArrowController.kt @@ -25,6 +25,7 @@ import com.duckduckgo.common.ui.view.toPx interface CardArrowController { fun apply(previous: CardArrowConfig?, next: CardArrowConfig, animate: Boolean) fun skipRunning() + fun release() } /** @@ -68,6 +69,11 @@ class CardArrowControllerImpl( slide = null } + override fun release() { + slide?.cancel() + slide = null + } + private companion object { const val ARROW_TARGET_OFFSET_END_DP = 80 const val SLIDE_DURATION_MS = 400L diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt index 5765e4e83de2..eba8848e1b45 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt @@ -160,6 +160,9 @@ class CardStageImpl(private val binding: ContentOnboardingWelcomePageUpdateBindi } override fun settle() { + // Ends the transition: a superseded ChangeBounds is paused and resumed by the + // next beginDelayedTransition rather than ended, so it would otherwise fire into the next render's slot. + TransitionManager.endTransitions(binding.root as ViewGroup) pendingMorph?.let { continuation -> pendingMorph = null continuation() diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/ContentController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/ContentController.kt index f5ff91ff541b..0020025a05d2 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/ContentController.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/ContentController.kt @@ -17,6 +17,7 @@ package com.duckduckgo.app.onboarding.ui.page.configdriven.engine import android.view.View +import androidx.core.view.children import androidx.core.view.isVisible import com.duckduckgo.app.browser.databinding.PreOnboardingDaxDialogCtaBrandDesignUpdateBinding import com.duckduckgo.app.onboarding.ui.page.configdriven.BindScope @@ -51,21 +52,14 @@ class ContentControllerImpl( private var boundView: View? = null /** - * Covers every content include, not only the ones with a binder: some default to visible in the card - * layout, so a first render of any other screen would otherwise leave one stacked above it, reserving - * blank height inside the card. + * Covers every content include, not only the ones with a binder: `welcomeContent` defaults to visible in the + * card layout, so a first render of any other screen would otherwise leave it stacked above, reserving blank + * height inside the card. The CTAs share the container but belong to the card stage. */ override fun resetStage() { - listOf( - binding.welcomeContent.root, - binding.comparisonChartContent.root, - binding.addressBarContent.root, - binding.inputScreenContent.root, - binding.inputScreenPreviewContent.root, - binding.reinstallerQuickSetupContent.root, - binding.addToDockContent.root, - binding.widgetPromptContent.root, - ).forEach { it.isVisible = false } + binding.cardContainer.children + .filter { it !== binding.primaryCta && it !== binding.secondaryCta } + .forEach { it.isVisible = false } } override fun bind( diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt index 1b2c278d01ec..677282b62c38 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt @@ -144,6 +144,7 @@ class DialogRenderEngine( cardStage.release() embellishments.release() stepIndicator.release() + cardArrow.release() } private fun animating(animate: Boolean) = animate && !settling diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt index f2a2720570a9..4c70ce664727 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/EmbellishmentController.kt @@ -108,7 +108,11 @@ class EmbellishmentControllerImpl( // The drain may have cut this decoration's own entrance short, so snap it to where that entrance was // heading before reporting the fit. decorations[next]?.snap?.invoke() - return applyFit(next) + val settled = applyFit(next) + // A reused decoration can stop fitting when the incoming card is taller, and the card anchors to the + // parent bottom without it, so it has to leave the stage too. + if (settled == null) decorations[next]?.hide?.invoke() + return settled } val exiting = previous?.let { decorations[it] } diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt index 6373d73afe12..09be554818b9 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngineTest.kt @@ -268,6 +268,19 @@ class DialogRenderEngineTest { assertTrue(embellishments.released) } + @Test + fun `release hands teardown to every axis that owns animators`() = runTest { + testee.render(COMPARISON_STEP, comparisonConfig(), animate = true) + + testee.release() + + assertTrue(background.released) + assertTrue(cardStage.released) + assertTrue(embellishments.released) + assertTrue(stepIndicator.released) + assertTrue(cardArrow.released) + } + @Test fun `a superseded render does not continue its pipeline`() = runTest { cardStage.autoComplete = false @@ -450,6 +463,7 @@ private class FakeCardArrowController : CardArrowController { var applied: Pair? = null var skipped = false + var released = false override fun apply(previous: CardArrowConfig?, next: CardArrowConfig, animate: Boolean) { applied = previous to next @@ -458,6 +472,10 @@ private class FakeCardArrowController : CardArrowController { override fun skipRunning() { skipped = true } + + override fun release() { + released = true + } } private class FakeCardAnchorController(private val record: (String) -> Unit = {}) : CardAnchorController { From faae5562b9f6f91b528bd03d61d257a08b805225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Tue, 4 Aug 2026 10:12:43 +0200 Subject: [PATCH 26/28] rename ConfigDrivenWelcomePage to ConfigDrivenWelcomePageFragment --- .../duckduckgo/app/onboarding/ui/OnboardingPageBuilder.kt | 6 +++--- .../duckduckgo/app/onboarding/ui/OnboardingPageManager.kt | 4 ++-- ...venWelcomePage.kt => ConfigDrivenWelcomePageFragment.kt} | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/{ConfigDrivenWelcomePage.kt => ConfigDrivenWelcomePageFragment.kt} (99%) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageBuilder.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageBuilder.kt index 7c83fe1d4775..c3deac79a85e 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageBuilder.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageBuilder.kt @@ -20,12 +20,12 @@ import com.duckduckgo.app.onboarding.ui.page.BrandDesignUpdateDefaultBrowserPage import com.duckduckgo.app.onboarding.ui.page.BrandDesignUpdateWelcomePage import com.duckduckgo.app.onboarding.ui.page.DefaultBrowserPage import com.duckduckgo.app.onboarding.ui.page.WelcomePage -import com.duckduckgo.app.onboarding.ui.page.configdriven.ConfigDrivenWelcomePage +import com.duckduckgo.app.onboarding.ui.page.configdriven.ConfigDrivenWelcomePageFragment interface OnboardingPageBuilder { fun buildWelcomePage(): WelcomePage fun buildBrandDesignUpdateWelcomePage(): BrandDesignUpdateWelcomePage - fun buildConfigDrivenWelcomePage(): ConfigDrivenWelcomePage + fun buildConfigDrivenWelcomePage(): ConfigDrivenWelcomePageFragment fun buildDefaultBrowserPage(): DefaultBrowserPage fun buildBrandDesignUpdateDefaultBrowserPage(): BrandDesignUpdateDefaultBrowserPage @@ -42,7 +42,7 @@ class OnboardingFragmentPageBuilder : OnboardingPageBuilder { override fun buildWelcomePage() = WelcomePage() override fun buildBrandDesignUpdateWelcomePage() = BrandDesignUpdateWelcomePage() - override fun buildConfigDrivenWelcomePage() = ConfigDrivenWelcomePage() + override fun buildConfigDrivenWelcomePage() = ConfigDrivenWelcomePageFragment() override fun buildDefaultBrowserPage() = DefaultBrowserPage() override fun buildBrandDesignUpdateDefaultBrowserPage() = BrandDesignUpdateDefaultBrowserPage() } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageManager.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageManager.kt index 1bf335e7ed2b..5919e79004d2 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageManager.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingPageManager.kt @@ -29,7 +29,7 @@ import com.duckduckgo.app.onboarding.ui.page.BrandDesignUpdateWelcomePage import com.duckduckgo.app.onboarding.ui.page.DefaultBrowserPage import com.duckduckgo.app.onboarding.ui.page.OnboardingPageFragment import com.duckduckgo.app.onboarding.ui.page.WelcomePage -import com.duckduckgo.app.onboarding.ui.page.configdriven.ConfigDrivenWelcomePage +import com.duckduckgo.app.onboarding.ui.page.configdriven.ConfigDrivenWelcomePageFragment interface OnboardingPageManager { fun pageCount(): Int @@ -104,7 +104,7 @@ class OnboardingPageManagerWithTrackerBlocking( return onboardingPageBuilder.buildBrandDesignUpdateWelcomePage() } - private fun buildConfigDrivenWelcomePage(): ConfigDrivenWelcomePage { + private fun buildConfigDrivenWelcomePage(): ConfigDrivenWelcomePageFragment { return onboardingPageBuilder.buildConfigDrivenWelcomePage() } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePageFragment.kt similarity index 99% rename from app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt rename to app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePageFragment.kt index be5b7dd9baf5..f447c3cb992f 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePageFragment.kt @@ -66,7 +66,7 @@ import javax.inject.Inject import com.duckduckgo.mobile.android.R as CommonR @InjectWith(FragmentScope::class) -class ConfigDrivenWelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_page_update) { +class ConfigDrivenWelcomePageFragment : OnboardingPageFragment(R.layout.content_onboarding_welcome_page_update) { @Inject lateinit var viewModelFactory: FragmentViewModelFactory From 6c492692813cc70b89776554db1c050f2684e0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Tue, 4 Aug 2026 10:28:04 +0200 Subject: [PATCH 27/28] escape isLaidOut check loop --- .../configdriven/ConfigDrivenWelcomePageFragment.kt | 13 ++++++++++++- .../page/configdriven/engine/DialogRenderEngine.kt | 3 +++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePageFragment.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePageFragment.kt index f447c3cb992f..baa6aad9d03d 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePageFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/ConfigDrivenWelcomePageFragment.kt @@ -203,10 +203,21 @@ class ConfigDrivenWelcomePageFragment : OnboardingPageFragment(R.layout.content_ // A retained view model emits before a recreated view has been laid out, and the decoration fit // check measures the root's height, so measuring at 0 would hide the decoration for good. if (!binding.root.isLaidOut) { - binding.root.doOnLayout { renderConfig(viewModel.viewState.value) } + binding.root.doOnLayout { + // isLaidOut is only set after the layout listeners have run, it still reads false from in here, + // so we need to dispatch to a separate function + render(engine, viewModel.viewState.value) + } return } + render(engine, state) + } + + private fun render( + engine: DialogRenderEngine, + state: ConfigDrivenOnboardingPageViewModel.ViewState, + ) { val stepId = state.stepId ?: return val config = state.config ?: return engine.render(stepId, config, state.animateEntry) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt index 677282b62c38..3a467a9c1eee 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/DialogRenderEngine.kt @@ -64,6 +64,9 @@ class DialogRenderEngine( /** * Renders [config] for [stepId], animated when [animate]. + * + * Re-rendering the [stepId] + [config] that is currently bound is a no-op, [animate] included, so a caller that can fire more + * than once for one state does not need to de-duplicate itself. */ fun render( stepId: LinearOnboardingStepId, From b4d0f59a0af9dd74e17b8a76e5d9f4b0caf22bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Tue, 4 Aug 2026 11:25:15 +0200 Subject: [PATCH 28/28] fix abrupt background reposition on transition start --- .../configdriven/engine/BackgroundController.kt | 2 +- .../ui/page/configdriven/engine/CardStage.kt | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/BackgroundController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/BackgroundController.kt index c67a266cd837..fa79121fb0b8 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/BackgroundController.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/BackgroundController.kt @@ -40,7 +40,7 @@ class BackgroundControllerImpl(private val animator: OnboardingBackgroundAnimato if (previous == next) return if (animate) { transitioningTo = next - animator.transitionTo(next) + animator.transitionTo(next, onAnimationEnd = { if (transitioningTo == next) transitioningTo = null }) } else { transitioningTo = null animator.snapTo(next) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt index eba8848e1b45..1bb432da28e9 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/CardStage.kt @@ -56,6 +56,9 @@ class CardStageImpl(private val binding: ContentOnboardingWelcomePageUpdateBindi private val runningAnimators = mutableListOf() + private val morphScene: ViewGroup + get() = binding.daxDialogCta.root + /** * The morph continuation waiting on a `ChangeBounds` that has not ended yet. Held so [settle] can run it * early, and nulled first so the transition's own `onTransitionEnd` does not run it a second time. @@ -91,9 +94,9 @@ class CardStageImpl(private val binding: ContentOnboardingWelcomePageUpdateBindi } override fun morph(animate: Boolean, onEnd: () -> Unit) { - // A delayed transition no-ops on a root that has not been laid out and its end callback never fires, so - // the continuation has to run directly; the first layout pass places everything anyway. - if (!animate || !binding.root.isLaidOut) { + // A delayed transition no-ops on a scene root that has not been laid out and its end callback never + // fires, so the continuation has to run directly; the first layout pass places everything anyway. + if (!animate || !morphScene.isLaidOut) { onEnd() return } @@ -108,11 +111,9 @@ class CardStageImpl(private val binding: ContentOnboardingWelcomePageUpdateBindi }, ) pendingMorph = onEnd - // ViewBinding types the root as View because the layout has multiple variants; the page root is always - // a ViewGroup. - TransitionManager.beginDelayedTransition(binding.root as ViewGroup, transition) + TransitionManager.beginDelayedTransition(morphScene, transition) // Guarantees a layout pass is observed even if none of this render's view mutations triggered one. - binding.root.requestLayout() + morphScene.requestLayout() } override fun showCtaButtons( @@ -162,7 +163,7 @@ class CardStageImpl(private val binding: ContentOnboardingWelcomePageUpdateBindi override fun settle() { // Ends the transition: a superseded ChangeBounds is paused and resumed by the // next beginDelayedTransition rather than ended, so it would otherwise fire into the next render's slot. - TransitionManager.endTransitions(binding.root as ViewGroup) + TransitionManager.endTransitions(morphScene) pendingMorph?.let { continuation -> pendingMorph = null continuation()