diff --git a/PixelDefinitions/pixels/definitions/onboarding.json5 b/PixelDefinitions/pixels/definitions/onboarding.json5
index 8ba6eba06ccd..b273ed410acb 100644
--- a/PixelDefinitions/pixels/definitions/onboarding.json5
+++ b/PixelDefinitions/pixels/definitions/onboarding.json5
@@ -596,6 +596,36 @@
{ "key": "value", "type": "string", "description": "Only set when event=clicked.", "enum": ["engage", "dismiss"] }
]
},
+ "onboarding_password-import": {
+ "description": "Password-import-from-Google onboarding step. shown when the page is displayed; clicked engage/dismiss on the Import / Skip CTAs; confirmed carries the import outcome. Unique once per user per (event, value).",
+ "owners": ["catalinradoiu"],
+ "triggers": ["other"],
+ "suffixes": ["form_factor"],
+ "parameters": [
+ "appVersion",
+ {
+ "key": "event",
+ "type": "string",
+ "description": "Event type for the onboarding instrumentation standard",
+ "enum": ["shown", "clicked", "confirmed"]
+ },
+ { "key": "installType", "type": "string", "description": "Install type", "enum": ["new", "reinstall"] },
+ {
+ "key": "daysSinceInstall",
+ "type": "string",
+ "description": "Days since install, bucketed.",
+ "enum": ["0", "1-3", "4-10", "11-28", "28+"]
+ },
+ { "key": "flow", "type": "string", "description": "Onboarding flow type", "enum": ["default", "duckai"] },
+ { "key": "pixelSource", "type": "string", "description": "Device form factor", "enum": ["phone", "tablet"] },
+ {
+ "key": "value",
+ "type": "string",
+ "description": "For event=clicked: engage|dismiss. For event=confirmed: the import outcome. No count is sent.",
+ "enum": ["engage", "dismiss", "success", "cancelled", "error"]
+ }
+ ]
+ },
"onboarding_subscription-promo": {
"description": "Contextual onboarding subscription-promo step. shown when the Privacy Pro DAX bubble is displayed; clicked engage when the user taps the primary CTA, dismiss when the user closes. Unique once per user per (event, value).",
"owners": ["catalinradoiu"],
diff --git a/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/view/shape/DaxBubbleBottomEdgeTreatment.kt b/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/view/shape/DaxBubbleBottomEdgeTreatment.kt
index 941c68c31a49..b99db613e1d2 100644
--- a/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/view/shape/DaxBubbleBottomEdgeTreatment.kt
+++ b/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/view/shape/DaxBubbleBottomEdgeTreatment.kt
@@ -34,6 +34,15 @@ open class DaxBubbleBottomEdgeTreatment(
var depthFraction: Float = 1f
+ /**
+ * The tail is asymmetric — it hooks towards one side — so a card whose artwork sits on the opposite side
+ * needs the shape reflected rather than just repositioned.
+ *
+ * 0 is the default hook direction and 1 is fully reflected. Intermediate values morph between the two so
+ * the reflection can be animated
+ */
+ var mirrorFraction: Float = 0f
+
override fun getEdgePath(
length: Float,
center: Float,
@@ -46,92 +55,80 @@ open class DaxBubbleBottomEdgeTreatment(
shapePath.lineTo(arrowStart, 0f)
- shapePath.cubicToPoint(
- arrowStart + 2.8355f * scaleFactor,
- 0f,
- arrowStart + 4.9409f * scaleFactor,
- -1.32054f * scaleFactor,
- arrowStart + 6.8544f * scaleFactor,
- -3.33789f * scaleFactor,
- )
-
- shapePath.cubicToPoint(
- arrowStart + 8.7314f * scaleFactor,
- -5.31666f * scaleFactor,
- arrowStart + 10.5271f * scaleFactor,
- -8.08434f * scaleFactor,
- arrowStart + 12.6835f * scaleFactor,
- -11.06444f * scaleFactor,
- )
-
- shapePath.cubicToPoint(
- arrowStart + 17.0304f * scaleFactor,
- -17.07144f * scaleFactor,
- arrowStart + 23.1365f * scaleFactor,
- -24.39164f * scaleFactor,
- arrowStart + 35.3339f * scaleFactor,
- -29.80464f * scaleFactor,
- )
-
- shapePath.cubicToPoint(
- arrowStart + 36.846f * scaleFactor,
- -30.47574f * scaleFactor,
- arrowStart + 38.3232f * scaleFactor,
- -30.09324f * scaleFactor,
- arrowStart + 39.3369f * scaleFactor,
- -29.13864f * scaleFactor,
- )
-
- shapePath.cubicToPoint(
- arrowStart + 40.3586f * scaleFactor,
- -28.17644f * scaleFactor,
- arrowStart + 40.9016f * scaleFactor,
- -26.63464f * scaleFactor,
- arrowStart + 40.4628f * scaleFactor,
- -24.99804f * scaleFactor,
- )
-
- shapePath.cubicToPoint(
- arrowStart + 39.6477f * scaleFactor,
- -21.95764f * scaleFactor,
- arrowStart + 38.7778f * scaleFactor,
- -18.57714f * scaleFactor,
- arrowStart + 38.1083f * scaleFactor,
- -15.63474f * scaleFactor,
- )
+ TAIL.indices.forEach { index ->
+ val curve = tailCurveAt(index)
+ shapePath.cubicToPoint(
+ arrowStart + curve.control1X * scaleFactor,
+ curve.control1Y * scaleFactor,
+ arrowStart + curve.control2X * scaleFactor,
+ curve.control2Y * scaleFactor,
+ arrowStart + curve.endX * scaleFactor,
+ curve.endY * scaleFactor,
+ )
+ }
- shapePath.cubicToPoint(
- arrowStart + 37.4462f * scaleFactor,
- -12.72454f * scaleFactor,
- arrowStart + 36.9582f * scaleFactor,
- -10.15444f * scaleFactor,
- arrowStart + 36.9453f * scaleFactor,
- -8.78514f * scaleFactor,
- )
+ shapePath.lineTo(arrowStart + arrowWidth, 0f)
+ }
- shapePath.cubicToPoint(
- arrowStart + 36.9208f * scaleFactor,
- -6.20757f * scaleFactor,
- arrowStart + 38.2915f * scaleFactor,
- -3.99944f * scaleFactor,
- arrowStart + 40.2158f * scaleFactor,
- -2.46093f * scaleFactor,
+ private fun tailCurveAt(index: Int): Curve {
+ val plain = TAIL[index]
+ if (mirrorFraction <= 0f) return plain
+ val mirrored = MIRRORED_TAIL[index]
+ if (mirrorFraction >= 1f) return mirrored
+ return Curve(
+ control1X = lerp(plain.control1X, mirrored.control1X),
+ control1Y = lerp(plain.control1Y, mirrored.control1Y),
+ control2X = lerp(plain.control2X, mirrored.control2X),
+ control2Y = lerp(plain.control2Y, mirrored.control2Y),
+ endX = lerp(plain.endX, mirrored.endX),
+ endY = lerp(plain.endY, mirrored.endY),
)
+ }
- shapePath.cubicToPoint(
- arrowStart + 42.1375f * scaleFactor,
- -0.92451f * scaleFactor,
- arrowStart + 44.6734f * scaleFactor,
- 0f,
- arrowStart + arrowWidth,
- 0f,
- )
+ private fun lerp(from: Float, to: Float): Float = from + (to - from) * mirrorFraction
- shapePath.lineTo(arrowStart + arrowWidth, 0f)
- }
+ /** One cubic of the tail outline, in unscaled dp relative to the tail's leading edge. */
+ private class Curve(
+ val control1X: Float,
+ val control1Y: Float,
+ val control2X: Float,
+ val control2Y: Float,
+ val endX: Float,
+ val endY: Float,
+ )
companion object {
const val ORIGINAL_BOTTOM_ARROW_HEIGHT_DP = 30
const val ORIGINAL_BOTTOM_ARROW_WIDTH_DP = 47.14058f
+
+ private val TAIL = listOf(
+ Curve(2.8355f, 0f, 4.9409f, -1.32054f, 6.8544f, -3.33789f),
+ Curve(8.7314f, -5.31666f, 10.5271f, -8.08434f, 12.6835f, -11.06444f),
+ Curve(17.0304f, -17.07144f, 23.1365f, -24.39164f, 35.3339f, -29.80464f),
+ Curve(36.846f, -30.47574f, 38.3232f, -30.09324f, 39.3369f, -29.13864f),
+ Curve(40.3586f, -28.17644f, 40.9016f, -26.63464f, 40.4628f, -24.99804f),
+ Curve(39.6477f, -21.95764f, 38.7778f, -18.57714f, 38.1083f, -15.63474f),
+ Curve(37.4462f, -12.72454f, 36.9582f, -10.15444f, 36.9453f, -8.78514f),
+ Curve(36.9208f, -6.20757f, 38.2915f, -3.99944f, 40.2158f, -2.46093f),
+ Curve(42.1375f, -0.92451f, 44.6734f, 0f, ORIGINAL_BOTTOM_ARROW_WIDTH_DP, 0f),
+ )
+
+ /**
+ * Reflected about the tail's vertical centre. The edge path has to stay left-to-right, so the curves
+ * are walked backwards: each one runs from its own end point to its predecessor's, which swaps its
+ * control points too.
+ */
+ private val MIRRORED_TAIL = TAIL.indices.reversed().map { index ->
+ val curve = TAIL[index]
+ val start = if (index == 0) null else TAIL[index - 1]
+ Curve(
+ control1X = ORIGINAL_BOTTOM_ARROW_WIDTH_DP - curve.control2X,
+ control1Y = curve.control2Y,
+ control2X = ORIGINAL_BOTTOM_ARROW_WIDTH_DP - curve.control1X,
+ control2Y = curve.control1Y,
+ endX = ORIGINAL_BOTTOM_ARROW_WIDTH_DP - (start?.endX ?: 0f),
+ endY = start?.endY ?: 0f,
+ )
+ }
}
}
diff --git a/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/view/shape/DaxOnboardingBubbleBrandDesignUpdateCardView.kt b/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/view/shape/DaxOnboardingBubbleBrandDesignUpdateCardView.kt
index 43b82bc947ca..6d1f64780e7e 100644
--- a/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/view/shape/DaxOnboardingBubbleBrandDesignUpdateCardView.kt
+++ b/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/view/shape/DaxOnboardingBubbleBrandDesignUpdateCardView.kt
@@ -176,6 +176,25 @@ constructor(
shapeAppearanceModel = shapeAppearanceModel
}
+ /**
+ * Reflects the arrow's shape horizontally, for a card whose artwork sits on the side the tail
+ * would otherwise hook away from. Independent of where along the edge the arrow sits.
+ */
+ fun setArrowMirrored(mirrored: Boolean) = setArrowMirrorFraction(if (mirrored) 1f else 0f)
+
+ /**
+ * Drive the reflection as an animation.
+ *
+ * @param fraction 0 = default hook direction, 1 = fully reflected. Intermediate values morph the tail
+ * through a symmetric shape rather than snapping between the two.
+ */
+ fun setArrowMirrorFraction(fraction: Float) {
+ if (!showArrow) return
+ if (baseBottomEdgeTreatment.mirrorFraction == fraction) return
+ baseBottomEdgeTreatment.mirrorFraction = fraction
+ shapeAppearanceModel = shapeAppearanceModel
+ }
+
/**
* Set the target position for the arrow animation.
*
diff --git a/android-design-system/design-system/src/main/res/values/design-system-rebrand-onboarding-theming.xml b/android-design-system/design-system/src/main/res/values/design-system-rebrand-onboarding-theming.xml
index f1b469432275..5da6622c0bc6 100644
--- a/android-design-system/design-system/src/main/res/values/design-system-rebrand-onboarding-theming.xml
+++ b/android-design-system/design-system/src/main/res/values/design-system-rebrand-onboarding-theming.xml
@@ -36,6 +36,7 @@
+
@@ -83,6 +84,7 @@
- #D6242323
- #99383838
- #66242323
+ - @color/lilypad60
- ?attr/onboardingSurfaceBackdrop
@@ -148,6 +150,7 @@
- #D6FBFAF9
- #7AFBFAF9
- #7AFBFAF9
+ - @color/lilypad30
- ?attr/onboardingSurfaceBackdrop
diff --git a/android-design-system/design-system/src/test/java/com/duckduckgo/common/ui/view/shape/DaxBubbleBottomEdgeTreatmentTest.kt b/android-design-system/design-system/src/test/java/com/duckduckgo/common/ui/view/shape/DaxBubbleBottomEdgeTreatmentTest.kt
index 902405cf2c4e..b6119ab05b30 100644
--- a/android-design-system/design-system/src/test/java/com/duckduckgo/common/ui/view/shape/DaxBubbleBottomEdgeTreatmentTest.kt
+++ b/android-design-system/design-system/src/test/java/com/duckduckgo/common/ui/view/shape/DaxBubbleBottomEdgeTreatmentTest.kt
@@ -39,6 +39,16 @@ class DaxBubbleBottomEdgeTreatmentTest {
val y3: Float,
)
val cubics = mutableListOf()
+ val lines = mutableListOf>()
+
+ override fun lineTo(
+ x: Float,
+ y: Float,
+ ) {
+ lines.add(x to y)
+ super.lineTo(x, y)
+ }
+
override fun cubicToPoint(
x1: Float,
y1: Float,
@@ -85,6 +95,45 @@ class DaxBubbleBottomEdgeTreatmentTest {
assertTrue("Expected non-zero tail Y values at full depth, got max|Y|=$maxAbsY", maxAbsY > 1f)
}
+ @Test
+ fun `mirroring is off by default`() {
+ val treatment = DaxBubbleBottomEdgeTreatment(heightPx = 100)
+ assertEquals(0f, treatment.mirrorFraction, 0.0001f)
+ }
+
+ @Test
+ fun `mirroring reflects every tail coordinate about the centre`() {
+ val plain = tailOf(mirrorFraction = 0f)
+ val mirrored = tailOf(mirrorFraction = 1f)
+
+ assertEquals(plain.size, mirrored.size)
+ val reflected = plain.map { (x, y) -> (2 * CENTER - x) to y }.sortedBy { it.first }
+ mirrored.sortedBy { it.first }.zip(reflected).forEach { (actual, expected) ->
+ assertEquals(expected.first, actual.first, 0.01f)
+ assertEquals(expected.second, actual.second, 0.01f)
+ }
+ }
+
+ @Test
+ fun `mirroring moves the tail hook to the other side of the centre`() {
+ val plainHook = tailOf(mirrorFraction = 0f).minByOrNull { it.second }!!.first
+ val mirroredHook = tailOf(mirrorFraction = 1f).minByOrNull { it.second }!!.first
+
+ assertTrue("Expected the plain hook past the centre, got $plainHook", plainHook > CENTER)
+ assertTrue("Expected the mirrored hook before the centre, got $mirroredHook", mirroredHook < CENTER)
+ }
+
+ @Test
+ fun `a half mirror fraction produces a tail that is symmetric about the centre`() {
+ val halfway = tailOf(mirrorFraction = 0.5f)
+
+ val reflected = halfway.map { (x, y) -> (2 * CENTER - x) to y }.sortedBy { it.first }
+ halfway.sortedBy { it.first }.zip(reflected).forEach { (actual, expected) ->
+ assertEquals(expected.first, actual.first, 0.01f)
+ assertEquals(expected.second, actual.second, 0.01f)
+ }
+ }
+
@Test
fun `depth fraction 0_5 scales y coordinates linearly`() {
val fullPath = RecordingShapePath()
@@ -102,4 +151,17 @@ class DaxBubbleBottomEdgeTreatmentTest {
assertEquals(full * 0.5f, half, 0.01f)
}
}
+
+ private fun tailOf(mirrorFraction: Float): List> {
+ val path = RecordingShapePath()
+ DaxBubbleBottomEdgeTreatment(heightPx = 100).apply { this.mirrorFraction = mirrorFraction }
+ .getEdgePath(length = 400f, center = CENTER, interpolation = 1f, shapePath = path)
+ // Distinct because the closing lineTo repeats the last curve's end point, and mirroring moves that
+ // duplicate to the opposite end of the tail.
+ return (path.lines + path.cubics.flatMap { listOf(it.x1 to it.y1, it.x2 to it.y2, it.x3 to it.y3) }).distinct()
+ }
+
+ private companion object {
+ const val CENTER = 200f
+ }
}
diff --git a/app/build.gradle b/app/build.gradle
index 9ab0cb1860aa..91dbe16b7e55 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -569,6 +569,7 @@ dependencies {
implementation "io.reactivex.rxjava2:rxjava:_"
implementation "io.reactivex.rxjava2:rxandroid:_"
implementation "com.squareup.logcat:logcat:_"
+ implementation "com.facebook.shimmer:shimmer:_"
implementation 'nl.dionsegijn:konfetti:1.2.5'
implementation("io.github.pcmind:leveldb:_") {
exclude group: 'com.google.guava', module: 'guava'
diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingActivityDialog.kt b/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingActivityDialog.kt
index 282d5bb09862..1f3030a9d80e 100644
--- a/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingActivityDialog.kt
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingActivityDialog.kt
@@ -39,6 +39,12 @@ sealed interface NewUserOnboardingActivityDialog {
data object WidgetPrompt : NewUserOnboardingActivityDialog
data object AddWidget : NewUserOnboardingActivityDialog
+
+ data object ImportPasswords : NewUserOnboardingActivityDialog
+
+ data object ImportPasswordsLaunch : NewUserOnboardingActivityDialog
+
+ data object ImportComplete : NewUserOnboardingActivityDialog
data class AddressBarPosition(val showSplitOption: Boolean) : NewUserOnboardingActivityDialog
data object InputScreen : NewUserOnboardingActivityDialog
diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingActivityStep.kt b/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingActivityStep.kt
index 634b7d1f7dd6..869cd0a219ad 100644
--- a/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingActivityStep.kt
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingActivityStep.kt
@@ -28,8 +28,8 @@ import com.duckduckgo.onboarding.api.LinearOnboardingTransition
* An [com.duckduckgo.app.onboarding.ui.OnboardingActivity]-hosted step. Adds a [resolveDialog] rendering hook to the generic
* [LinearOnboardingStep]; the VM reads it to know what to present for the current step.
*
- * [showsStepIndicator] marks the step as part of the onboarding progress indicator. The "step N of M" the
- * renderer shows is derived from the position of these steps in the plan (see [stepIndicatorProgress]), so
+ * [indicator] places the step in the onboarding progress indicator. The "step N of M" the renderer shows is
+ * derived from the position of [StepIndicatorMode.COUNTED] steps in the plan (see [stepIndicatorProgress]), so
* numbering follows plan order with no hardcoded page numbers.
*/
data class NewUserOnboardingActivityStep(
@@ -38,22 +38,41 @@ data class NewUserOnboardingActivityStep(
override val host: LinearOnboardingHost = LinearOnboardingHost.OnboardingActivity,
override val precondition: suspend () -> Boolean = { true },
override val transition: suspend (LinearOnboardingEvent) -> LinearOnboardingTransition,
- val showsStepIndicator: Boolean = false,
+ val indicator: StepIndicatorMode = StepIndicatorMode.NONE,
val resolveDialog: suspend () -> NewUserOnboardingActivityDialog,
) : LinearOnboardingStep
+/** How a step appears in the progress indicator. */
+enum class StepIndicatorMode {
+ /** No indicator at all. */
+ NONE,
+
+ /** Takes the next position in the sequence, and counts towards the total. */
+ COUNTED,
+
+ /**
+ * Shows the number of the [COUNTED] step before it, and adds nothing to the total. For a step that continues
+ * an interaction the user already started — the outcome of the step they are on, not a step of its own.
+ */
+ CONTINUES_PREVIOUS,
+}
+
/** 1-based [current] position of a step within its plan's step-indicator sequence, and the sequence length [total]. */
data class StepProgress(val current: Int, val total: Int)
/**
* Progress to show in the onboarding step indicator for the current step, or null when the current step shows
- * none. Derived from the position of [NewUserOnboardingActivityStep.showsStepIndicator] steps in the current
- * plan, so adding/reordering steps keeps the numbering correct without touching hardcoded page numbers.
+ * none. Derived from the position of [StepIndicatorMode.COUNTED] steps in the current plan, so adding or
+ * reordering steps keeps the numbering correct without touching hardcoded page numbers.
+ *
+ * Counting up to and including the current index gives a [StepIndicatorMode.COUNTED] step its own position and
+ * a [StepIndicatorMode.CONTINUES_PREVIOUS] step the position of the counted step before it.
*/
fun LinearOnboardingState.InProgress.stepIndicatorProgress(): StepProgress? {
- if ((currentStep as? NewUserOnboardingActivityStep)?.showsStepIndicator != true) return null
- val indicatorIndices = currentPlan.steps.indices.filter {
- (currentPlan.steps[it] as? NewUserOnboardingActivityStep)?.showsStepIndicator == true
+ val step = currentStep as? NewUserOnboardingActivityStep ?: return null
+ if (step.indicator == StepIndicatorMode.NONE) return null
+ val countedIndices = currentPlan.steps.indices.filter {
+ (currentPlan.steps[it] as? NewUserOnboardingActivityStep)?.indicator == StepIndicatorMode.COUNTED
}
- return StepProgress(current = indicatorIndices.indexOf(currentStepIndex) + 1, total = indicatorIndices.size)
+ return StepProgress(current = countedIndices.count { it <= currentStepIndex }, total = countedIndices.size)
}
diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingEvent.kt b/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingEvent.kt
index f57d3a3b5eb7..f91d6462fde7 100644
--- a/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingEvent.kt
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingEvent.kt
@@ -59,4 +59,14 @@ sealed interface NewUserOnboardingEvent : LinearOnboardingEvent {
data class PreferenceSelectorConfirmed(val selections: Map) : NewUserOnboardingEvent
data class SingleChoiceConfirmed(val option: Option) : NewUserOnboardingEvent
+
+ data object PasswordImportRequested : NewUserOnboardingEvent
+
+ data object PasswordImportSkipped : NewUserOnboardingEvent
+
+ data class PasswordImportWebFlowFinished(val outcome: PasswordImportOutcome) : NewUserOnboardingEvent
+
+ data class PasswordImportParsed(val outcome: PasswordImportOutcome) : NewUserOnboardingEvent
}
+
+enum class PasswordImportOutcome { SUCCESS, CANCELLED, ERROR }
diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingPlanContext.kt b/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingPlanContext.kt
index 857cb356e5ba..4a97f6214a0c 100644
--- a/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingPlanContext.kt
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingPlanContext.kt
@@ -42,6 +42,22 @@ class NewUserOnboardingPlanContext {
@Volatile
var completionResult: LinearOnboardingResult? = null
+ /**
+ * Set by the [NewUserOnboardingStepIds.PASSWORD_IMPORT_LAUNCH] step once the import web flow returns
+ * successfully. Gates [NewUserOnboardingStepIds.PASSWORD_IMPORT_COMPLETE], and keeps the launch step
+ * eligible until then. The counts are not held here: they arrive after the outcome card is already on
+ * screen, so they live in that card's content state instead.
+ */
+ @Volatile
+ var passwordImportSucceeded: Boolean = false
+
+ /**
+ * Set true by the [NewUserOnboardingStepIds.PASSWORD_IMPORT] step when the user taps "Skip",
+ * read by the [NewUserOnboardingStepIds.PASSWORD_IMPORT_LAUNCH] precondition to skip launching the passwords import flow.
+ */
+ @Volatile
+ var skipPasswordsImport: Boolean = false
+
/**
* Chat prompt captured by the custom-AI [NewUserOnboardingStepIds.INPUT_SCREEN_PREVIEW] step,
* read by the [NewUserOnboardingStepIds.DUCK_AI_DEMO] step.
diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingPlanProvider.kt b/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingPlanProvider.kt
index 892bca6a3670..7322d3be3e1a 100644
--- a/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingPlanProvider.kt
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingPlanProvider.kt
@@ -30,6 +30,8 @@ import com.duckduckgo.app.onboarding.CustomAiOnboardingResolver
import com.duckduckgo.app.onboarding.DuckAiOnboardingAvailability
import com.duckduckgo.app.onboarding.DuckAiOnboardingDemo
import com.duckduckgo.app.onboarding.OnboardingInputScreenLaunchTarget
+import com.duckduckgo.app.onboarding.OnboardingPasswordImportExperimentManager
+import com.duckduckgo.app.onboarding.OnboardingPasswordImportExperimentManager.OnboardingPasswordImportVariant
import com.duckduckgo.app.onboarding.OnboardingPreference
import com.duckduckgo.app.onboarding.OnboardingPreferenceApplier
import com.duckduckgo.app.onboarding.OnboardingPromptsExperimentManager
@@ -69,6 +71,7 @@ import com.duckduckgo.onboarding.api.LinearOnboardingStep
import com.duckduckgo.onboarding.api.LinearOnboardingTransition
import com.duckduckgo.onboarding.api.LinearOnboardingTransition.AbortPlan
import com.duckduckgo.onboarding.api.LinearOnboardingTransition.Advance
+import com.duckduckgo.onboarding.api.LinearOnboardingTransition.GoBack
import com.duckduckgo.onboarding.api.LinearOnboardingTransition.Stay
import com.duckduckgo.onboarding.api.LinearOnboardingTransition.SwitchTo
import com.duckduckgo.onboarding.api.OnboardingSingleChoiceDataPlugin
@@ -111,6 +114,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
private val duckAiOnboardingDemo: DuckAiOnboardingDemo,
private val onboardingPromptsExperimentManager: OnboardingPromptsExperimentManager,
private val segmentedOnboardingExperimentManager: SegmentedOnboardingExperimentManager,
+ private val onboardingPasswordImportExperimentManager: OnboardingPasswordImportExperimentManager,
private val onboardingPreferenceApplier: OnboardingPreferenceApplier,
private val singleChoiceDataPlugins: ActivePluginPoint,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
@@ -145,6 +149,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
onboardingPromptExperimentVariant != null -> buildDefaultPlan(ctx, onCompleted, onSkipped, onboardingPromptExperimentVariant)
segmentedOnboardingExperimentManager.enroll() == SegmentedOnboardingExperimentVariant.TREATMENT ->
buildSegmentedPlan(ctx, onCompleted, onSkipped)
+
else -> buildDefaultPlan(ctx, onCompleted, onSkipped)
}
}
@@ -170,6 +175,8 @@ class NewUserOnboardingPlanProvider @Inject constructor(
onboardingPromptExperimentVariant == OnboardingPromptsExperimentManager.OnboardingPromptExperimentVariant.TREATMENT_DOCK_AND_WIDGET
val showWidget = variantAllowsWidget && withContext(dispatchers.io()) { !widgetCapabilities.hasInstalledWidgets }
+ val showPasswordImport = onboardingPasswordImportExperimentManager.enroll() == OnboardingPasswordImportVariant.TREATMENT
+
return rootPlan(
ctx = ctx,
onCompleted = onCompleted,
@@ -189,6 +196,11 @@ class NewUserOnboardingPlanProvider @Inject constructor(
add(widgetPromptStep(ctx))
add(addWidgetStep(ctx))
}
+ if (showPasswordImport) {
+ add(passwordImportStep(ctx))
+ add(passwordImportLaunchStep(ctx))
+ add(passwordImportCompleteStep(ctx))
+ }
add(addressBarPositionStep())
add(inputScreenStep(ctx))
add(
@@ -309,7 +321,10 @@ class NewUserOnboardingPlanProvider @Inject constructor(
private fun sidePlan(id: LinearOnboardingPlanId, steps: List): LinearOnboardingPlan =
LinearOnboardingPlan(id = id, steps = steps.firingShownPixels().abortingOnDevSkip())
- private fun quickSetupPlan(ctx: NewUserOnboardingPlanContext, forceWithAiInput: Boolean = false): LinearOnboardingPlan =
+ private fun quickSetupPlan(
+ ctx: NewUserOnboardingPlanContext,
+ forceWithAiInput: Boolean = false,
+ ): LinearOnboardingPlan =
sidePlan(QUICK_SETUP_PLAN_ID, listOf(quickSetupStep(ctx, forceWithAiInput)))
/**
@@ -403,6 +418,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
}
Advance
}
+
else -> Stay
}
},
@@ -459,6 +475,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
false
}
}
+
FirstDialog.REINSTALL -> true
FirstDialog.INITIAL -> false
}
@@ -470,6 +487,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
onboardingPixelSender.fire(pixelName, OnboardingPixelAction.Clicked(engaged = true))
Advance
}
+
is NewUserOnboardingEvent.SkipRequested -> {
pixel.fire(PREONBOARDING_SKIP_ONBOARDING_PRESSED)
onboardingPixelSender.fire(pixelName, OnboardingPixelAction.Clicked(engaged = false))
@@ -497,6 +515,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
onboardingPixelSender.fire(pixelName, OnboardingPixelAction.Clicked(engaged = true))
Advance
}
+
else -> Stay
}
},
@@ -632,7 +651,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
return NewUserOnboardingActivityStep(
id = NewUserOnboardingStepIds.PREFERENCE_SELECTOR,
pixelName = null,
- showsStepIndicator = true,
+ indicator = StepIndicatorMode.COUNTED,
precondition = { preferenceSelections().isNotEmpty() },
resolveDialog = {
NewUserOnboardingActivityDialog.PreferenceSelector(
@@ -667,7 +686,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
return NewUserOnboardingActivityStep(
id = NewUserOnboardingStepIds.MODEL_PROVIDER,
pixelName = null,
- showsStepIndicator = true,
+ indicator = StepIndicatorMode.COUNTED,
precondition = { options().size > 1 },
resolveDialog = {
NewUserOnboardingActivityDialog.SingleChoice(
@@ -695,7 +714,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
return NewUserOnboardingActivityStep(
id = NewUserOnboardingStepIds.TOGGLE_POSITION,
pixelName = null,
- showsStepIndicator = true,
+ indicator = StepIndicatorMode.COUNTED,
precondition = { options().size > 1 },
resolveDialog = { NewUserOnboardingActivityDialog.TogglePosition(options()) },
transition = { event ->
@@ -720,7 +739,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
return NewUserOnboardingActivityStep(
id = NewUserOnboardingStepIds.DUCK_AI_STATE,
pixelName = null,
- showsStepIndicator = true,
+ indicator = StepIndicatorMode.COUNTED,
precondition = { options().size > 1 },
resolveDialog = { NewUserOnboardingActivityDialog.DuckAiState(options()) },
transition = { event ->
@@ -746,7 +765,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
return NewUserOnboardingActivityStep(
id = NewUserOnboardingStepIds.COMPARISON_CHART,
pixelName = pixelName,
- showsStepIndicator = true,
+ indicator = StepIndicatorMode.COUNTED,
resolveDialog = { dialog },
transition = { event ->
when {
@@ -759,6 +778,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
onboardingPixelSender.fire(pixelName, OnboardingPixelAction.Clicked(engaged = true))
Advance
}
+
else -> Stay
}
},
@@ -780,6 +800,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
)
Advance
}
+
else -> Stay
}
},
@@ -790,7 +811,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
return NewUserOnboardingActivityStep(
id = NewUserOnboardingStepIds.ADD_TO_DOCK,
pixelName = pixelName,
- showsStepIndicator = true,
+ indicator = StepIndicatorMode.COUNTED,
resolveDialog = { NewUserOnboardingActivityDialog.AddToDock },
transition = { event ->
when {
@@ -798,6 +819,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
onboardingPixelSender.fire(pixelName, OnboardingPixelAction.Clicked(engaged = true))
Advance
}
+
else -> Stay
}
},
@@ -809,7 +831,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
return NewUserOnboardingActivityStep(
id = NewUserOnboardingStepIds.WIDGET_PROMPT,
pixelName = pixelName,
- showsStepIndicator = true,
+ indicator = StepIndicatorMode.COUNTED,
resolveDialog = { NewUserOnboardingActivityDialog.WidgetPrompt },
transition = { event ->
when (event) {
@@ -817,15 +839,18 @@ class NewUserOnboardingPlanProvider @Inject constructor(
onboardingStore.linearPlanWidgetPromptShown = true
Stay
}
+
is NewUserOnboardingEvent.AddWidgetRequested -> {
onboardingPixelSender.fire(pixelName, OnboardingPixelAction.Clicked(engaged = true))
Advance
}
+
is NewUserOnboardingEvent.WidgetPromptSkipped -> {
onboardingPixelSender.fire(pixelName, OnboardingPixelAction.Clicked(engaged = false))
ctx.skipAddWidget = true
Advance
}
+
else -> Stay
}
},
@@ -851,6 +876,88 @@ class NewUserOnboardingPlanProvider @Inject constructor(
)
Advance
}
+
+ else -> Stay
+ }
+ },
+ )
+ }
+
+ private fun passwordImportStep(ctx: NewUserOnboardingPlanContext): NewUserOnboardingActivityStep {
+ val pixelName = OnboardingPixelName.ONBOARDING_PASSWORD_IMPORT
+ return NewUserOnboardingActivityStep(
+ id = NewUserOnboardingStepIds.PASSWORD_IMPORT,
+ pixelName = pixelName,
+ indicator = StepIndicatorMode.COUNTED,
+ resolveDialog = { NewUserOnboardingActivityDialog.ImportPasswords },
+ transition = { event ->
+ when (event) {
+ is NewUserOnboardingEvent.PasswordImportRequested -> {
+ onboardingPixelSender.fire(pixelName, OnboardingPixelAction.Clicked(engaged = true))
+ Advance
+ }
+
+ is NewUserOnboardingEvent.PasswordImportSkipped -> {
+ ctx.skipPasswordsImport = true
+ onboardingPixelSender.fire(pixelName, OnboardingPixelAction.Clicked(engaged = false))
+ Advance
+ }
+
+ else -> Stay
+ }
+ },
+ )
+ }
+
+ private fun passwordImportLaunchStep(ctx: NewUserOnboardingPlanContext): NewUserOnboardingActivityStep {
+ val pixelName = OnboardingPixelName.ONBOARDING_PASSWORD_IMPORT
+ return NewUserOnboardingActivityStep(
+ id = NewUserOnboardingStepIds.PASSWORD_IMPORT_LAUNCH,
+ pixelName = null,
+ precondition = { !ctx.passwordImportSucceeded && !ctx.skipPasswordsImport },
+ resolveDialog = { NewUserOnboardingActivityDialog.ImportPasswordsLaunch },
+ transition = { event ->
+ when (event) {
+ is NewUserOnboardingEvent.PasswordImportWebFlowFinished -> when (event.outcome) {
+ PasswordImportOutcome.SUCCESS -> {
+ ctx.passwordImportSucceeded = true
+ Advance
+ }
+
+ PasswordImportOutcome.CANCELLED -> {
+ onboardingPixelSender.fire(pixelName, OnboardingPixelAction.PasswordImportConfirmed(event.outcome))
+ GoBack
+ }
+
+ PasswordImportOutcome.ERROR -> {
+ onboardingPixelSender.fire(pixelName, OnboardingPixelAction.PasswordImportConfirmed(event.outcome))
+ Stay
+ }
+ }
+
+ is NewUserOnboardingEvent.ContinueClicked -> Advance
+ else -> Stay
+ }
+ },
+ )
+ }
+
+ private fun passwordImportCompleteStep(ctx: NewUserOnboardingPlanContext): NewUserOnboardingActivityStep {
+ val pixelName = OnboardingPixelName.ONBOARDING_PASSWORD_IMPORT
+ return NewUserOnboardingActivityStep(
+ id = NewUserOnboardingStepIds.PASSWORD_IMPORT_COMPLETE,
+ pixelName = null,
+ indicator = StepIndicatorMode.CONTINUES_PREVIOUS,
+ precondition = { ctx.passwordImportSucceeded },
+ resolveDialog = { NewUserOnboardingActivityDialog.ImportComplete },
+ transition = { event ->
+ when (event) {
+ is NewUserOnboardingEvent.PasswordImportParsed -> {
+ onboardingPixelSender.fire(pixelName, OnboardingPixelAction.PasswordImportConfirmed(event.outcome))
+ Stay
+ }
+
+ is NewUserOnboardingEvent.ContinueClicked -> Advance
else -> Stay
}
},
@@ -862,7 +969,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
return NewUserOnboardingActivityStep(
id = NewUserOnboardingStepIds.ADDRESS_BAR_POSITION,
pixelName = pixelName,
- showsStepIndicator = true,
+ indicator = StepIndicatorMode.COUNTED,
resolveDialog = { NewUserOnboardingActivityDialog.AddressBarPosition(showSplitOption = isSplitOmnibarEnabled()) },
transition = { event ->
when {
@@ -873,6 +980,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
onboardingPixelSender.fire(pixelName, OnboardingPixelAction.AddressBarClicked(position = resolved))
Advance
}
+
else -> Stay
}
},
@@ -884,7 +992,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
return NewUserOnboardingActivityStep(
id = NewUserOnboardingStepIds.INPUT_SCREEN,
pixelName = pixelName,
- showsStepIndicator = true,
+ indicator = StepIndicatorMode.COUNTED,
resolveDialog = { NewUserOnboardingActivityDialog.InputScreen },
transition = { event ->
when {
@@ -894,6 +1002,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
onboardingPixelSender.fire(pixelName, OnboardingPixelAction.SearchExperienceClicked(withAi = event.withAi))
Advance
}
+
else -> Stay
}
},
@@ -919,7 +1028,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
return NewUserOnboardingActivityStep(
id = NewUserOnboardingStepIds.INPUT_SCREEN_PREVIEW,
pixelName = pixelName,
- showsStepIndicator = showsStepIndicator,
+ indicator = if (showsStepIndicator) StepIndicatorMode.COUNTED else StepIndicatorMode.NONE,
precondition = { !shownOnlyWithModeToggle || showModeToggle() },
resolveDialog = {
val modeToggleShown = showModeToggle()
@@ -969,7 +1078,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
return NewUserOnboardingActivityStep(
id = NewUserOnboardingStepIds.AI_COMPARISON_CHART,
pixelName = pixelName,
- showsStepIndicator = true,
+ indicator = StepIndicatorMode.COUNTED,
resolveDialog = { NewUserOnboardingActivityDialog.AiComparisonChart },
transition = { event ->
when {
@@ -977,6 +1086,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
onboardingPixelSender.fire(pixelName, OnboardingPixelAction.Clicked(engaged = true))
Advance
}
+
else -> Stay
}
},
@@ -995,13 +1105,17 @@ class NewUserOnboardingPlanProvider @Inject constructor(
onboardingPixelSender.fire(pixelName, OnboardingPixelAction.Clicked(engaged = true))
Advance
}
+
else -> Stay
}
},
)
}
- private fun quickSetupStep(ctx: NewUserOnboardingPlanContext, forceWithAiInput: Boolean): NewUserOnboardingActivityStep {
+ private fun quickSetupStep(
+ ctx: NewUserOnboardingPlanContext,
+ forceWithAiInput: Boolean,
+ ): NewUserOnboardingActivityStep {
val pixelName = OnboardingPixelName.ONBOARDING_QUICK_SETUP
return NewUserOnboardingActivityStep(
id = NewUserOnboardingStepIds.QUICK_SETUP,
@@ -1036,6 +1150,7 @@ class NewUserOnboardingPlanProvider @Inject constructor(
)
AbortPlan
}
+
else -> Stay
}
},
@@ -1076,7 +1191,11 @@ class NewUserOnboardingPlanProvider @Inject constructor(
androidBrowserConfigFeature.splitOmnibarWelcomePage().isEnabled()
}
- private enum class FirstDialog { SYNC_RESTORE, REINSTALL, INITIAL }
+ private enum class FirstDialog {
+ SYNC_RESTORE,
+ REINSTALL,
+ INITIAL,
+ }
companion object {
diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingStepIds.kt b/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingStepIds.kt
index 925ba60758af..f9b8aede34b9 100644
--- a/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingStepIds.kt
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingStepIds.kt
@@ -35,6 +35,9 @@ internal object NewUserOnboardingStepIds {
const val ADD_TO_DOCK: LinearOnboardingStepId = "add_to_dock"
const val WIDGET_PROMPT: LinearOnboardingStepId = "widget_prompt"
const val ADD_WIDGET: LinearOnboardingStepId = "add_widget"
+ const val PASSWORD_IMPORT: LinearOnboardingStepId = "password_import"
+ const val PASSWORD_IMPORT_LAUNCH: LinearOnboardingStepId = "password_import_launch"
+ const val PASSWORD_IMPORT_COMPLETE: LinearOnboardingStepId = "password_import_complete"
const val ADDRESS_BAR_POSITION: LinearOnboardingStepId = "address_bar_position"
const val INPUT_SCREEN: LinearOnboardingStepId = "input_screen"
const val INPUT_SCREEN_PREVIEW: LinearOnboardingStepId = "input_screen_preview"
diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BrandDesignUpdatePageViewModel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BrandDesignUpdatePageViewModel.kt
index 9c9804ed036f..bdb89c661685 100644
--- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BrandDesignUpdatePageViewModel.kt
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BrandDesignUpdatePageViewModel.kt
@@ -514,6 +514,13 @@ class BrandDesignUpdatePageViewModel @Inject constructor(
setCurrentDialog(INITIAL_REINSTALL_USER)
}
NewUserOnboardingActivityDialog.Initial -> setCurrentDialog(INITIAL)
+ NewUserOnboardingActivityDialog.DownloadReason,
+ NewUserOnboardingActivityDialog.ImportPasswords,
+ is NewUserOnboardingActivityDialog.ImportComplete,
+ NewUserOnboardingActivityDialog.ImportPasswordsLaunch,
+ -> {
+ // no-op in this VM
+ }
NewUserOnboardingActivityDialog.ComparisonChart ->
setCurrentDialog(COMPARISON_CHART, stepIndicator = progress)
NewUserOnboardingActivityDialog.AiComparisonChart ->
diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingBackgroundAnimator.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingBackgroundAnimator.kt
index 3d255fdeecf5..11a67152b0b5 100644
--- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingBackgroundAnimator.kt
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingBackgroundAnimator.kt
@@ -60,6 +60,10 @@ sealed class OnboardingBackgroundStep(
backgroundRes = R.drawable.onboarding_browser_comparison_background,
maxHeightDp = 216,
)
+ data object IslandWithHorizon : OnboardingBackgroundStep(
+ backgroundRes = R.drawable.onboarding_island_with_horizon,
+ maxHeightDp = 272,
+ )
data object AddressBar : OnboardingBackgroundStep(
backgroundRes = R.drawable.onboarding_address_bar_background,
maxHeightDp = 360,
diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingPixelSender.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingPixelSender.kt
index 9cbcfdb3f05a..34c1e4846043 100644
--- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingPixelSender.kt
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/OnboardingPixelSender.kt
@@ -22,6 +22,7 @@ import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.global.install.AppInstallStore
import com.duckduckgo.app.global.install.daysInstalled
import com.duckduckgo.app.onboarding.CustomAiOnboardingStore
+import com.duckduckgo.app.onboarding.orchestrator.PasswordImportOutcome
import com.duckduckgo.app.pixels.OnboardingPixelName
import com.duckduckgo.app.statistics.pixels.Pixel
import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique
@@ -65,6 +66,8 @@ sealed interface OnboardingPixelAction {
val addressBarPosition: OmnibarType,
val inputScreenSelected: Boolean,
) : OnboardingPixelAction
+
+ data class PasswordImportConfirmed(val outcome: PasswordImportOutcome) : OnboardingPixelAction
}
interface OnboardingPixelSender {
@@ -152,6 +155,9 @@ class RealOnboardingPixelSender @Inject constructor(
is OnboardingPixelAction.QuickSetupClicked ->
fireQuickSetupClicked(pixelName, action.addressBarPosition, action.inputScreenSelected)
+
+ is OnboardingPixelAction.PasswordImportConfirmed ->
+ fireStep(pixelName, PIXEL_EVENT_CONFIRMED, action.outcome.name.lowercase())
}
}
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 6185b430e0f7..36992ac4e597 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
@@ -459,6 +459,12 @@ class ConfigDrivenOnboardingPageViewModel @Inject constructor(
_commands.send(Command.LaunchAddWidgetPrompt)
}
+ NewUserOnboardingActivityDialog.ImportPasswordsLaunch -> {
+ // TODO: launch the Google password import flow and report its outcome back as
+ // PasswordImportWebFlowFinished / PasswordImportParsed. Until that lands, this step has no
+ // side effect and the outcome card stays on its parsing state.
+ }
+
NewUserOnboardingActivityDialog.SyncRestore,
NewUserOnboardingActivityDialog.InitialReinstallUser,
NewUserOnboardingActivityDialog.Initial,
@@ -469,6 +475,8 @@ class ConfigDrivenOnboardingPageViewModel @Inject constructor(
NewUserOnboardingActivityDialog.DownloadReason,
NewUserOnboardingActivityDialog.AddToDock,
NewUserOnboardingActivityDialog.WidgetPrompt,
+ NewUserOnboardingActivityDialog.ImportPasswords,
+ NewUserOnboardingActivityDialog.ImportComplete,
is NewUserOnboardingActivityDialog.AddressBarPosition,
NewUserOnboardingActivityDialog.InputScreen,
is NewUserOnboardingActivityDialog.InputScreenPreview,
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
index 9127219ba8e7..92d2b53cb5c5 100644
--- 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
@@ -155,6 +155,28 @@ sealed interface ContentConfig {
require(options.isNotEmpty()) { "A toggle position screen needs at least one option" }
}
}
+
+ data class ImportPasswords(
+ override val title: TextConfig,
+ val body: TextConfig,
+ ) : ContentConfig
+
+ data class ImportComplete(
+ override val title: TextConfig,
+ val parsingTitle: TextConfig,
+ val parsingBody: TextConfig,
+ val failedTitle: TextConfig,
+ val failedRow: TextConfig,
+ ) : ContentConfig, Stateful {
+ override fun initialState(): ImportCompleteContentState = ImportCompleteContentState.Parsing
+ }
+}
+
+sealed interface ImportCompleteContentState {
+ data object Parsing : ImportCompleteContentState
+ data class Finished(val imported: Int, val skipped: Int) : ImportCompleteContentState
+
+ data object Failed : ImportCompleteContentState
}
data class AddressBarContentState(val position: OmnibarType)
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
index b06d4bface40..abd28cd663b8 100644
--- 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
@@ -27,9 +27,18 @@ class ContentValueStore {
private val states = mutableMapOf>()
- @Suppress("UNCHECKED_CAST")
fun contentState(
stepId: LinearOnboardingStepId,
content: Stateful,
- ): MutableStateFlow = states.getOrPut(stepId) { MutableStateFlow(content.initialState()) } as MutableStateFlow
+ ): MutableStateFlow = contentState(stepId, content::initialState)
+
+ /**
+ * The same flow by step id alone, for a writer that has no config to hand: the view model pushes into a
+ * step's state before that step is current, so the binder finds the value already there when it binds.
+ */
+ @Suppress("UNCHECKED_CAST")
+ fun contentState(
+ stepId: LinearOnboardingStepId,
+ initialState: () -> S,
+ ): MutableStateFlow = states.getOrPut(stepId) { MutableStateFlow(initialState()) } as MutableStateFlow
}
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
index df287a3d8a2c..bd5c52804eec 100644
--- 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
@@ -36,9 +36,21 @@ data class DialogConfig(
)
/** 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 Embellishment { WalkingDax, BobbingDax, BottomWing, LeftWing, RightWing, None }
-enum class CardArrowConfig { Hidden, AtStart, AtEnd }
+/**
+ * Where the card's bubble tail sits, and which way it hooks. The tail is asymmetric, so a screen whose
+ * artwork sits opposite the default hook needs a mirrored variant rather than just a different position.
+ */
+enum class CardArrowConfig(
+ val atEnd: Boolean,
+ val mirrored: Boolean,
+) {
+ Hidden(atEnd = false, mirrored = false),
+ AtStart(atEnd = false, mirrored = false),
+ AtEnd(atEnd = true, mirrored = false),
+ AtStartMirrored(atEnd = false, mirrored = true),
+}
/** When the card's one-time fade-in starts. [AfterBackgroundTransition] holds it back until an animated background transition has finished. */
enum class CardEntry { Immediate, AfterBackgroundTransition }
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
index c4239f474843..47874d86228f 100644
--- 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
@@ -243,10 +243,46 @@ class DialogConfigResolver @Inject constructor(
),
)
+ NewUserOnboardingActivityDialog.ImportPasswords -> DialogConfig(
+ background = OnboardingBackgroundStep.IslandWithHorizon,
+ embellishment = Embellishment.RightWing,
+ cardArrow = CardArrowConfig.AtStartMirrored,
+ content = ContentConfig.ImportPasswords(
+ title = TextConfig.Resource(R.string.preOnboardingImportPasswordsTitle),
+ body = TextConfig.Resource(R.string.preOnboardingImportPasswordsBody),
+ ),
+ primaryCta = CtaConfig(
+ text = TextConfig.Resource(R.string.preOnboardingImportPasswordsPrimaryCta),
+ action = CtaAction.Emit(NewUserOnboardingEvent.PasswordImportRequested),
+ ),
+ secondaryCta = CtaConfig(
+ text = TextConfig.Resource(R.string.preOnboardingImportPasswordsSecondaryCta),
+ action = CtaAction.Emit(NewUserOnboardingEvent.PasswordImportSkipped),
+ ),
+ )
+
+ NewUserOnboardingActivityDialog.ImportComplete -> DialogConfig(
+ background = OnboardingBackgroundStep.IslandWithHorizon,
+ embellishment = Embellishment.RightWing,
+ cardArrow = CardArrowConfig.AtStartMirrored,
+ content = ContentConfig.ImportComplete(
+ title = TextConfig.Resource(R.string.preOnboardingImportCompleteTitle),
+ parsingTitle = TextConfig.Resource(R.string.preOnboardingImportCompleteParsingTitle),
+ parsingBody = TextConfig.Resource(R.string.preOnboardingImportCompleteParsingBody),
+ failedTitle = TextConfig.Resource(R.string.preOnboardingImportCompleteFailedTitle),
+ failedRow = TextConfig.Resource(R.string.preOnboardingImportCompleteFailed),
+ ),
+ primaryCta = CtaConfig(
+ text = TextConfig.Resource(R.string.preOnboardingImportCompleteCta),
+ action = CtaAction.Emit(NewUserOnboardingEvent.ContinueClicked),
+ ),
+ )
+
is NewUserOnboardingActivityDialog.IntroAnimation,
NewUserOnboardingActivityDialog.NotificationPermission,
NewUserOnboardingActivityDialog.DefaultBrowserPrompt,
NewUserOnboardingActivityDialog.AddWidget,
+ NewUserOnboardingActivityDialog.ImportPasswordsLaunch,
-> null // command-only: no card to render
}
diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/OnboardingDialogShownPixels.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/OnboardingDialogShownPixels.kt
index 63494a653e94..cf76e3ef23e0 100644
--- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/OnboardingDialogShownPixels.kt
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/OnboardingDialogShownPixels.kt
@@ -61,6 +61,9 @@ class OnboardingDialogShownPixels @Inject constructor(private val pixel: Pixel)
is NewUserOnboardingActivityDialog.SingleChoice,
is NewUserOnboardingActivityDialog.TogglePosition,
is NewUserOnboardingActivityDialog.DuckAiState,
+ NewUserOnboardingActivityDialog.ImportPasswords,
+ is NewUserOnboardingActivityDialog.ImportComplete,
+ NewUserOnboardingActivityDialog.ImportPasswordsLaunch,
-> Unit
}
}
diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ImportCompleteBinder.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ImportCompleteBinder.kt
new file mode 100644
index 000000000000..4a702fbf661b
--- /dev/null
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ImportCompleteBinder.kt
@@ -0,0 +1,197 @@
+/*
+ * 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.content.Context
+import android.view.View
+import androidx.core.view.isVisible
+import com.duckduckgo.app.browser.R
+import com.duckduckgo.app.browser.databinding.IncludeBrandDesignImportCompleteBinding
+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.CtaState
+import com.duckduckgo.app.onboarding.ui.page.configdriven.ImportCompleteContentState
+import com.duckduckgo.app.onboarding.ui.page.configdriven.StatefulDialogBinder
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import com.duckduckgo.mobile.android.R as CommonR
+
+class ImportCompleteBinder(
+ private val binding: IncludeBrandDesignImportCompleteBinding,
+) : StatefulDialogBinder {
+
+ override val view: View = binding.root
+
+ private val outcomeVisible = MutableStateFlow(false)
+
+ private var rendered: ImportCompleteContentState? = null
+ private var stateJob: Job? = null
+ private var transition: Animator? = null
+
+ override fun bind(
+ content: ContentConfig.ImportComplete,
+ state: MutableStateFlow,
+ scope: BindScope,
+ ): ContentHandle = with(binding) {
+ val context = root.context
+
+ importCompleteImportedRow.resultRowIcon.setImageResource(CommonR.drawable.ic_cross_recolorable_gray_24)
+ importCompleteSkippedRow.resultRowIcon.setImageResource(CommonR.drawable.ic_cross_recolorable_gray_24)
+ importCompleteFailedRow.resultRowIcon.setImageResource(CommonR.drawable.ic_cross_recolorable_gray_24)
+
+ val initial = state.value
+ rendered = initial
+ importCompleteTitle.setTitle(titleOf(initial, content).resolve(context))
+ apply(initial, content, context)
+ outcomeVisible.value = initial !is ImportCompleteContentState.Parsing
+
+ ContentHandle(
+ title = importCompleteTitle,
+ preTitleFadeTargets = listOf(importCompletePictogram),
+ fadeTargets = fadeTargets(),
+ onContentReady = {
+ stateJob = state
+ .onEach { renderStateChange(it, content, scope) }
+ .launchIn(scope.coroutineScope)
+ },
+ primaryCtaState = CtaState(
+ enabled = outcomeVisible,
+ defaultValue = outcomeVisible.value,
+ ),
+ unbind = {
+ stateJob?.cancel()
+ transition?.cancel()
+ },
+ )
+ }
+
+ private fun renderStateChange(
+ current: ImportCompleteContentState,
+ content: ContentConfig.ImportComplete,
+ scope: BindScope,
+ ) = with(binding) {
+ if (current == rendered) return@with
+ rendered = current
+ val context = root.context
+
+ transition?.cancel()
+ val leaving = stateFadeTargets()
+ transition = fade(leaving, to = 0f) {
+ importCompleteTitle.setTitle(titleOf(current, content).resolve(context))
+ importCompleteTitle.snapTitle()
+
+ scope.animateCardBounds(STATE_CHANGE_DURATION_MS)
+ apply(current, content, context)
+ outcomeVisible.value = current !is ImportCompleteContentState.Parsing
+
+ val arriving = stateFadeTargets()
+ arriving.forEach { it.alpha = 0f }
+
+ leaving.filterNot { it in arriving }.forEach { it.alpha = 1f }
+
+ transition = fade(arriving, to = 1f).also { it.start() }
+ }.also { it.start() }
+ }
+
+ private fun fade(
+ views: List,
+ to: Float,
+ onFaded: () -> Unit = {},
+ ): Animator = AnimatorSet().apply {
+ duration = STATE_FADE_DURATION_MS
+ playTogether(views.map { view -> ObjectAnimator.ofFloat(view, View.ALPHA, to) })
+ addListener(
+ object : AnimatorListenerAdapter() {
+ private var cancelled = false
+
+ override fun onAnimationCancel(animation: Animator) {
+ cancelled = true
+ }
+
+ override fun onAnimationEnd(animation: Animator) {
+ views.forEach { it.alpha = to }
+ if (!cancelled) onFaded()
+ }
+ },
+ )
+ }
+
+ private fun apply(
+ state: ImportCompleteContentState,
+ content: ContentConfig.ImportComplete,
+ context: Context,
+ ) = with(binding) {
+ importCompleteShimmer.isVisible = state is ImportCompleteContentState.Parsing
+ importCompletePictogram.setImageResource(pictogramOf(state))
+ importCompleteImportedRow.root.isVisible = state is ImportCompleteContentState.Finished
+ importCompleteSkippedRow.root.isVisible = state is ImportCompleteContentState.Finished && state.skipped > 0
+ importCompleteFailedRow.root.isVisible = state is ImportCompleteContentState.Failed
+ importCompleteResultContainer.isVisible = state !is ImportCompleteContentState.Parsing
+ importCompleteBody.isVisible = state is ImportCompleteContentState.Parsing
+
+ when (state) {
+ ImportCompleteContentState.Parsing -> importCompleteBody.text = content.parsingBody.resolve(context)
+ ImportCompleteContentState.Failed -> importCompleteFailedRow.resultRowText.text = content.failedRow.resolve(context)
+ is ImportCompleteContentState.Finished -> {
+ importCompleteImportedRow.resultRowText.text =
+ context.getString(R.string.preOnboardingImportCompleteImported, state.imported)
+ if (state.skipped > 0) {
+ importCompleteSkippedRow.resultRowText.text =
+ context.getString(R.string.preOnboardingImportCompleteSkipped, state.skipped)
+ }
+ }
+ }
+ }
+
+ private fun pictogramOf(state: ImportCompleteContentState) = when (state) {
+ is ImportCompleteContentState.Finished -> R.drawable.ic_success_96
+ ImportCompleteContentState.Failed -> R.drawable.ic_passwords_alert_96
+ ImportCompleteContentState.Parsing -> R.drawable.ic_passwords_import_96
+ }
+
+ private fun titleOf(
+ state: ImportCompleteContentState,
+ content: ContentConfig.ImportComplete,
+ ) = when (state) {
+ ImportCompleteContentState.Parsing -> content.parsingTitle
+ ImportCompleteContentState.Failed -> content.failedTitle
+ is ImportCompleteContentState.Finished -> content.title
+ }
+
+ private fun stateFadeTargets(): List = fadeTargets() + binding.importCompletePictogram
+
+ private fun fadeTargets(): List = with(binding) {
+ buildList {
+ if (importCompleteBody.isVisible) add(importCompleteBody)
+ if (importCompleteShimmer.isVisible) add(importCompleteShimmer)
+ if (importCompleteResultContainer.isVisible) add(importCompleteResultContainer)
+ }
+ }
+
+ private companion object {
+ const val STATE_CHANGE_DURATION_MS = 300L
+
+ const val STATE_FADE_DURATION_MS = 150L
+ }
+}
diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ImportPasswordsBinder.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ImportPasswordsBinder.kt
new file mode 100644
index 000000000000..7d13db310894
--- /dev/null
+++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/binders/ImportPasswordsBinder.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.IncludeBrandDesignImportPasswordsBinding
+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.utils.extensions.preventWidows
+
+class ImportPasswordsBinder(
+ private val binding: IncludeBrandDesignImportPasswordsBinding,
+) : DialogBinder {
+
+ override val view: View = binding.root
+
+ override fun bind(content: ContentConfig.ImportPasswords, scope: BindScope): ContentHandle = with(binding) {
+ val context = root.context
+ importPasswordsTitle.setTitle(content.title.resolve(context))
+ importPasswordsBody.text = content.body.resolve(context).preventWidows()
+
+ ContentHandle(
+ title = importPasswordsTitle,
+ preTitleFadeTargets = listOf(importPasswordsPictogram),
+ fadeTargets = listOf(importPasswordsBody),
+ )
+ }
+}
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 59b50f6be256..9052b4ea4a82 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
@@ -29,51 +29,64 @@ interface CardArrowController {
}
/**
- * 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.
+ * Owns the card's bubble arrow: whether it shows, where along the card's edge it sits, and which way its tail
+ * hooks. Either property only animates across a transition that actually changes it; every other render snaps
+ * it. When a transition changes both, one animator drives them so the tail travels and reflects as a single
+ * gesture instead of the reflection popping before the slide.
*/
class CardArrowControllerImpl(
private val cardView: DaxOnboardingBubbleBrandDesignUpdateCardView,
) : CardArrowController {
- private var slide: ValueAnimator? = null
+ private var transition: ValueAnimator? = null
override fun apply(
previous: CardArrowConfig?,
next: CardArrowConfig,
animate: Boolean,
) {
- slide?.cancel()
- slide = null
+ transition?.cancel()
+ transition = 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 {
+ val positionTarget = if (next.atEnd) 1f else 0f
+ val mirrorTarget = if (next.mirrored) 1f else 0f
+
+ val from = previous?.takeIf { it != CardArrowConfig.Hidden && next != CardArrowConfig.Hidden }
+ val moves = animate && from != null && from.atEnd != next.atEnd
+ val flips = animate && from != null && from.mirrored != next.mirrored
+
+ if (!moves) cardView.setArrowAnimationFraction(positionTarget)
+ if (!flips) cardView.setArrowMirrorFraction(mirrorTarget)
+
+ if (moves || flips) {
+ transition = ValueAnimator.ofFloat(0f, 1f).apply {
duration = SLIDE_DURATION_MS
interpolator = FastOutSlowInInterpolator()
- addUpdateListener { cardView.setArrowAnimationFraction(it.animatedValue as Float) }
+ addUpdateListener {
+ val progress = it.animatedValue as Float
+ if (moves) cardView.setArrowAnimationFraction(lerp(1f - positionTarget, positionTarget, progress))
+ if (flips) cardView.setArrowMirrorFraction(lerp(1f - mirrorTarget, mirrorTarget, progress))
+ }
start()
}
- } else {
- cardView.setArrowAnimationFraction(target)
}
}
override fun skipRunning() {
- slide?.end()
- slide = null
+ transition?.end()
+ transition = null
}
override fun release() {
- slide?.cancel()
- slide = null
+ transition?.cancel()
+ transition = null
}
+ private fun lerp(from: Float, to: Float, fraction: Float): Float = from + (to - from) * fraction
+
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/ContentController.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/configdriven/engine/ContentController.kt
index 5e099361f34b..6cb9c36803d1 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
@@ -29,6 +29,8 @@ import com.duckduckgo.app.onboarding.ui.page.configdriven.binders.AddressBarBind
import com.duckduckgo.app.onboarding.ui.page.configdriven.binders.ComparisonChartBinder
import com.duckduckgo.app.onboarding.ui.page.configdriven.binders.DownloadReasonBinder
import com.duckduckgo.app.onboarding.ui.page.configdriven.binders.DuckAiStateBinder
+import com.duckduckgo.app.onboarding.ui.page.configdriven.binders.ImportCompleteBinder
+import com.duckduckgo.app.onboarding.ui.page.configdriven.binders.ImportPasswordsBinder
import com.duckduckgo.app.onboarding.ui.page.configdriven.binders.InputScreenBinder
import com.duckduckgo.app.onboarding.ui.page.configdriven.binders.InputScreenPreviewBinder
import com.duckduckgo.app.onboarding.ui.page.configdriven.binders.PreferenceSelectorBinder
@@ -65,6 +67,8 @@ class ContentControllerImpl(
private val welcome = WelcomeBinder(binding.welcomeContent)
private val addToDock = AddToDockBinder(binding.addToDockContent)
private val widgetPrompt = WidgetPromptBinder(binding.widgetPromptContent)
+ private val importPasswords = ImportPasswordsBinder(binding.importPasswordsContent)
+ private val importComplete = ImportCompleteBinder(binding.importCompleteContent)
private val downloadReason = DownloadReasonBinder(binding.downloadReasonContent)
private val preferenceSelector = PreferenceSelectorBinder(binding.preferenceSelectorContent)
private val singleChoice = SingleChoiceBinder(binding.singleChoiceContent)
@@ -122,6 +126,14 @@ class ContentControllerImpl(
boundView = widgetPrompt.view
widgetPrompt.bind(content, scope)
}
+ is ContentConfig.ImportPasswords -> {
+ boundView = importPasswords.view
+ importPasswords.bind(content, scope)
+ }
+ is ContentConfig.ImportComplete -> {
+ boundView = importComplete.view
+ importComplete.bind(content, contentValues.contentState(stepId, content), scope)
+ }
is ContentConfig.DownloadReason -> {
boundView = downloadReason.view
downloadReason.bind(content, contentValues.contentState(stepId, content), scope)
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 3f32dd086721..e2846b6af393 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
@@ -92,6 +92,7 @@ class EmbellishmentControllerImpl(
Embellishment.WalkingDax to buildWalkingDax(),
Embellishment.BottomWing to buildBottomWing(),
Embellishment.LeftWing to buildLeftWing(),
+ Embellishment.RightWing to buildRightWing(),
Embellishment.BobbingDax to buildBobbingDax(),
Embellishment.None to buildUndecoratedBand(),
)
@@ -228,9 +229,9 @@ class EmbellishmentControllerImpl(
}
}
- private fun leftWingBottomOverlapPx(): Int {
+ private fun sideWingBottomOverlapPx(): Int {
val cardBottomMargin = (binding.daxDialogCta.cardView.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin ?: return 0
- return (cardBottomMargin - LEFT_WING_CARD_GAP_DP.toPx()).coerceAtLeast(0)
+ return (cardBottomMargin - SIDE_WING_CARD_GAP_DP.toPx()).coerceAtLeast(0)
}
private fun buildWalkingDax(): Decoration {
@@ -368,7 +369,51 @@ class EmbellishmentControllerImpl(
placement = EmbellishmentPlacement.of(Embellishment.LeftWing),
maxHeightPx = { LEFT_WING_MAX_HEIGHT_DP.toPx() },
minHeightPx = { LEFT_WING_MIN_HEIGHT_DP.toPx() },
- bottomOverlapPx = { leftWingBottomOverlapPx() },
+ bottomOverlapPx = { sideWingBottomOverlapPx() },
+ 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 = {
+ view.setMinProgress(WING_STOP_PROGRESS)
+ view.setMaxProgress(1f)
+ view.speed = 1f
+ exitViaLottie(view, applyFinalState = { view.isGone = true })
+ emptyList()
+ },
+ hide = instantHideOf(view),
+ snap = {
+ view.cancelAnimation()
+ view.isVisible = true
+ view.alpha = 1f
+ view.setMinAndMaxProgress(0f, WING_STOP_PROGRESS)
+ view.progress = WING_STOP_PROGRESS
+ },
+ )
+ }
+
+ private fun buildRightWing(): Decoration {
+ val view = binding.rightWingAnimation
+ return Decoration(
+ view = view,
+ placement = EmbellishmentPlacement.of(Embellishment.RightWing),
+ maxHeightPx = { RIGHT_WING_MAX_HEIGHT_DP.toPx() },
+ minHeightPx = { RIGHT_WING_MIN_HEIGHT_DP.toPx() },
+ bottomOverlapPx = { sideWingBottomOverlapPx() },
enter = {
view.isVisible = true
view.alpha = 0f
@@ -540,7 +585,9 @@ class EmbellishmentControllerImpl(
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 RIGHT_WING_MAX_HEIGHT_DP = 196
+ const val RIGHT_WING_MIN_HEIGHT_DP = 130
+ const val SIDE_WING_CARD_GAP_DP = 8
const val BOBBING_DAX_MAX_HEIGHT_DP = 156
const val BOBBING_DAX_MIN_HEIGHT_DP = 130
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
index b58036317089..3e6fe84e4621 100644
--- 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
@@ -33,7 +33,9 @@ object EmbellishmentPlacement {
Embellishment.WalkingDax -> Placement(anchorsCardOnPhone = true, biasPhone = 1f, biasTablet = 0.5f, 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.LeftWing,
+ Embellishment.RightWing,
+ -> 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)
}
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 b781c7a66939..1c98eb86c9a6 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
@@ -60,7 +60,7 @@ class StepIndicatorControllerImpl(private val indicator: OnboardingStepIndicator
indicator.alpha = 1f
indicator.isVisible = false
}
- !animate || previous == null -> {
+ !animate || previous == null || previous == next -> {
indicator.alpha = 1f
indicator.isVisible = true
indicator.setSteps(totalSteps = next.total, currentStep = next.current)
diff --git a/app/src/main/java/com/duckduckgo/app/pixels/OnboardingPixelName.kt b/app/src/main/java/com/duckduckgo/app/pixels/OnboardingPixelName.kt
index ed6317ff9c70..f5f7090acffe 100644
--- a/app/src/main/java/com/duckduckgo/app/pixels/OnboardingPixelName.kt
+++ b/app/src/main/java/com/duckduckgo/app/pixels/OnboardingPixelName.kt
@@ -36,4 +36,5 @@ enum class OnboardingPixelName(override val pixelName: String) : Pixel.PixelName
ONBOARDING_TRACKERS_BLOCKED("onboarding_trackers-blocked"),
ONBOARDING_END("onboarding_end"),
ONBOARDING_SUBSCRIPTION_PROMO("onboarding_subscription-promo"),
+ ONBOARDING_PASSWORD_IMPORT("onboarding_password-import"),
}
diff --git a/app/src/main/java/com/duckduckgo/app/pixels/OnboardingPixelParamRemovalPlugin.kt b/app/src/main/java/com/duckduckgo/app/pixels/OnboardingPixelParamRemovalPlugin.kt
index f8f8bd862ad8..8ddd35898e3c 100644
--- a/app/src/main/java/com/duckduckgo/app/pixels/OnboardingPixelParamRemovalPlugin.kt
+++ b/app/src/main/java/com/duckduckgo/app/pixels/OnboardingPixelParamRemovalPlugin.kt
@@ -43,6 +43,7 @@ class OnboardingPixelParamRemovalPlugin @Inject constructor() : PixelParamRemova
OnboardingPixelName.ONBOARDING_END.pixelName to PixelParameter.removeAtb(),
OnboardingPixelName.ONBOARDING_SUBSCRIPTION_PROMO.pixelName to PixelParameter.removeAtb(),
OnboardingPixelName.ONBOARDING_WIDGET_PROMPT.pixelName to PixelParameter.removeAtb(),
+ OnboardingPixelName.ONBOARDING_PASSWORD_IMPORT.pixelName to PixelParameter.removeAtb(),
)
}
}
diff --git a/app/src/main/res/drawable-hdpi/onboarding_island_with_horizon.webp b/app/src/main/res/drawable-hdpi/onboarding_island_with_horizon.webp
new file mode 100644
index 000000000000..fc6e6a0d945c
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/onboarding_island_with_horizon.webp differ
diff --git a/app/src/main/res/drawable-mdpi/onboarding_island_with_horizon.webp b/app/src/main/res/drawable-mdpi/onboarding_island_with_horizon.webp
new file mode 100644
index 000000000000..428e8968be14
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/onboarding_island_with_horizon.webp differ
diff --git a/app/src/main/res/drawable-night-hdpi/onboarding_island_with_horizon.webp b/app/src/main/res/drawable-night-hdpi/onboarding_island_with_horizon.webp
new file mode 100644
index 000000000000..9242e03b2f12
Binary files /dev/null and b/app/src/main/res/drawable-night-hdpi/onboarding_island_with_horizon.webp differ
diff --git a/app/src/main/res/drawable-night-mdpi/onboarding_island_with_horizon.webp b/app/src/main/res/drawable-night-mdpi/onboarding_island_with_horizon.webp
new file mode 100644
index 000000000000..ab21d832dee8
Binary files /dev/null and b/app/src/main/res/drawable-night-mdpi/onboarding_island_with_horizon.webp differ
diff --git a/app/src/main/res/drawable-night-xhdpi/onboarding_island_with_horizon.webp b/app/src/main/res/drawable-night-xhdpi/onboarding_island_with_horizon.webp
new file mode 100644
index 000000000000..c5f7a33a1c10
Binary files /dev/null and b/app/src/main/res/drawable-night-xhdpi/onboarding_island_with_horizon.webp differ
diff --git a/app/src/main/res/drawable-night-xxhdpi/onboarding_island_with_horizon.webp b/app/src/main/res/drawable-night-xxhdpi/onboarding_island_with_horizon.webp
new file mode 100644
index 000000000000..2499be37b7d1
Binary files /dev/null and b/app/src/main/res/drawable-night-xxhdpi/onboarding_island_with_horizon.webp differ
diff --git a/app/src/main/res/drawable-night-xxxhdpi/onboarding_island_with_horizon.webp b/app/src/main/res/drawable-night-xxxhdpi/onboarding_island_with_horizon.webp
new file mode 100644
index 000000000000..7b309916714f
Binary files /dev/null and b/app/src/main/res/drawable-night-xxxhdpi/onboarding_island_with_horizon.webp differ
diff --git a/app/src/main/res/drawable-xhdpi/onboarding_island_with_horizon.webp b/app/src/main/res/drawable-xhdpi/onboarding_island_with_horizon.webp
new file mode 100644
index 000000000000..1b8fea33227c
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/onboarding_island_with_horizon.webp differ
diff --git a/app/src/main/res/drawable-xxhdpi/onboarding_island_with_horizon.webp b/app/src/main/res/drawable-xxhdpi/onboarding_island_with_horizon.webp
new file mode 100644
index 000000000000..f4f40a8e88b6
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/onboarding_island_with_horizon.webp differ
diff --git a/app/src/main/res/drawable-xxxhdpi/onboarding_island_with_horizon.webp b/app/src/main/res/drawable-xxxhdpi/onboarding_island_with_horizon.webp
new file mode 100644
index 000000000000..5ccc7b5f95e1
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/onboarding_island_with_horizon.webp differ
diff --git a/app/src/main/res/drawable/background_onboarding_import_result.xml b/app/src/main/res/drawable/background_onboarding_import_result.xml
new file mode 100644
index 000000000000..c2f8807ca6de
--- /dev/null
+++ b/app/src/main/res/drawable/background_onboarding_import_result.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_passwords_alert_96.xml b/app/src/main/res/drawable/ic_passwords_alert_96.xml
new file mode 100644
index 000000000000..a32f42e5faad
--- /dev/null
+++ b/app/src/main/res/drawable/ic_passwords_alert_96.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_passwords_import_96.xml b/app/src/main/res/drawable/ic_passwords_import_96.xml
new file mode 100644
index 000000000000..7338213a13c9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_passwords_import_96.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_success_96.xml b/app/src/main/res/drawable/ic_success_96.xml
new file mode 100644
index 000000000000..2971b62a83b5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_success_96.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 7baf19e32d1b..6d48ded41be9 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
@@ -217,6 +217,21 @@
tools:alpha="1"
tools:lottie_progress="0.5"
tools:visibility="visible" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/include_brand_design_import_passwords.xml b/app/src/main/res/layout/include_brand_design_import_passwords.xml
new file mode 100644
index 000000000000..54f32290c043
--- /dev/null
+++ b/app/src/main/res/layout/include_brand_design_import_passwords.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/include_brand_design_import_result_row.xml b/app/src/main/res/layout/include_brand_design_import_result_row.xml
new file mode 100644
index 000000000000..ad3791616d43
--- /dev/null
+++ b/app/src/main/res/layout/include_brand_design_import_result_row.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/pre_onboarding_dax_dialog_cta_brand_design_update.xml b/app/src/main/res/layout/pre_onboarding_dax_dialog_cta_brand_design_update.xml
index dca238b37edb..936f42bb5fee 100644
--- a/app/src/main/res/layout/pre_onboarding_dax_dialog_cta_brand_design_update.xml
+++ b/app/src/main/res/layout/pre_onboarding_dax_dialog_cta_brand_design_update.xml
@@ -168,6 +168,24 @@
android:layout_marginEnd="20dp"
android:visibility="gone" />
+
+
+
+
Delete %1$d chat?
- Delete %1$d chats?
+
+
+ Import passwords from Google
+ Google may ask you to sign in or enter your password to confirm
+ Import From Google
+ Skip
+ Import complete
+ Continue
+ Importing passwords
+ This will only take a moment.
+ Import failed
+ Password import failed
+ Passwords imported: %1$d
+ Skipped (duplicate or invalid): %1$d
+ Something went wrong
+ Please try again.
+ Try again
+ Cancel
diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingPlanProviderTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingPlanProviderTest.kt
index 2c29e264a5e6..e5bbe0337f28 100644
--- a/app/src/test/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingPlanProviderTest.kt
+++ b/app/src/test/java/com/duckduckgo/app/onboarding/orchestrator/NewUserOnboardingPlanProviderTest.kt
@@ -29,6 +29,7 @@ import com.duckduckgo.app.onboarding.DuckAiOnboardingAvailability
import com.duckduckgo.app.onboarding.DuckAiOnboardingDemo
import com.duckduckgo.app.onboarding.FakeOnboardingSingleChoiceDataPlugin
import com.duckduckgo.app.onboarding.OnboardingInputScreenLaunchTarget
+import com.duckduckgo.app.onboarding.OnboardingPasswordImportExperimentManager
import com.duckduckgo.app.onboarding.OnboardingPreference
import com.duckduckgo.app.onboarding.OnboardingPreferenceApplier
import com.duckduckgo.app.onboarding.OnboardingPromptsExperimentManager
@@ -134,6 +135,10 @@ class NewUserOnboardingPlanProviderTest {
override suspend fun getPlugins(): Collection = singleChoicePlugins
}
+ // Password import is off in these tests: its steps are then left out of the plan entirely, so every
+ // existing step-order and indicator expectation below is unaffected by the feature.
+ private val passwordImportExperiment: OnboardingPasswordImportExperimentManager = mock()
+
private lateinit var provider: NewUserOnboardingPlanProvider
private val orchestrator = LinearOnboardingOrchestratorImpl()
@@ -154,6 +159,7 @@ class NewUserOnboardingPlanProviderTest {
whenever(homeScreenPromptsExperiment.enroll())
.thenReturn(OnboardingPromptsExperimentManager.OnboardingPromptExperimentVariant.CONTROL)
whenever(segmentedOnboardingExperiment.enroll()).thenReturn(null)
+ whenever(passwordImportExperiment.enroll()).thenReturn(null)
}
provider = NewUserOnboardingPlanProvider(
syncAutoRestore = syncAutoRestore,
@@ -176,6 +182,7 @@ class NewUserOnboardingPlanProviderTest {
duckAiOnboardingDemo = duckAiOnboardingDemo,
onboardingPromptsExperimentManager = homeScreenPromptsExperiment,
segmentedOnboardingExperimentManager = segmentedOnboardingExperiment,
+ onboardingPasswordImportExperimentManager = passwordImportExperiment,
onboardingPreferenceApplier = onboardingPreferenceApplier,
singleChoiceDataPlugins = singleChoiceDataPlugins,
appCoroutineScope = coroutineRule.testScope,
@@ -1645,7 +1652,7 @@ class NewUserOnboardingPlanProviderTest {
): Int {
whenever(homeScreenPromptsExperiment.enroll()).thenReturn(onboardingPromptExperimentVariant)
return provider.buildRootPlan(onCompleted = {}, onSkipped = {}).steps
- .count { (it as? NewUserOnboardingActivityStep)?.showsStepIndicator == true }
+ .count { (it as? NewUserOnboardingActivityStep)?.indicator == StepIndicatorMode.COUNTED }
}
@Test
diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/BrandDesignUpdatePageViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/BrandDesignUpdatePageViewModelTest.kt
index d00c85f2e60d..c99acd8efeaa 100644
--- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/BrandDesignUpdatePageViewModelTest.kt
+++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/BrandDesignUpdatePageViewModelTest.kt
@@ -36,6 +36,7 @@ import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingPlanBootstrap
import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingPlanProvider
import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingResult
import com.duckduckgo.app.onboarding.orchestrator.NewUserOnboardingStepIds
+import com.duckduckgo.app.onboarding.orchestrator.StepIndicatorMode
import com.duckduckgo.app.onboarding.orchestrator.StepProgress
import com.duckduckgo.app.onboarding.store.OnboardingStore
import com.duckduckgo.app.onboarding.ui.page.BrandDesignUpdatePageViewModel.Command
@@ -709,14 +710,14 @@ class BrandDesignUpdatePageViewModelTest {
NewUserOnboardingActivityStep(
id = "ai_comparison_chart",
pixelName = null,
- showsStepIndicator = true,
+ indicator = StepIndicatorMode.COUNTED,
transition = { LinearOnboardingTransition.Advance },
resolveDialog = { NewUserOnboardingActivityDialog.AiComparisonChart },
),
NewUserOnboardingActivityStep(
id = "input_screen_preview",
pixelName = null,
- showsStepIndicator = true,
+ indicator = StepIndicatorMode.COUNTED,
transition = recordAndStay,
resolveDialog = {
NewUserOnboardingActivityDialog.InputScreenPreview(
@@ -961,7 +962,7 @@ class BrandDesignUpdatePageViewModelTest {
NewUserOnboardingActivityStep(
id = NewUserOnboardingStepIds.ADD_TO_DOCK,
pixelName = null,
- showsStepIndicator = true,
+ indicator = StepIndicatorMode.COUNTED,
transition = { LinearOnboardingTransition.Stay },
resolveDialog = { NewUserOnboardingActivityDialog.AddToDock },
)
@@ -970,7 +971,7 @@ class BrandDesignUpdatePageViewModelTest {
NewUserOnboardingActivityStep(
id = NewUserOnboardingStepIds.WIDGET_PROMPT,
pixelName = null,
- showsStepIndicator = true,
+ indicator = StepIndicatorMode.COUNTED,
transition = { LinearOnboardingTransition.Stay },
resolveDialog = { NewUserOnboardingActivityDialog.WidgetPrompt },
)
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
index 8a83a53837ed..1304ae5acfd1 100644
--- 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
@@ -481,4 +481,20 @@ class DialogConfigResolverTest {
assertNull(config.primaryCta)
assertNull(config.secondaryCta)
}
+
+ @Test
+ fun `resolves the import passwords dialog with a right wing and a mirrored arrow`() {
+ val config = testee.resolve(NewUserOnboardingActivityDialog.ImportPasswords, isCustomAiFlow = false)!!
+
+ assertEquals(Embellishment.RightWing, config.embellishment)
+ assertEquals(CardArrowConfig.AtStartMirrored, config.cardArrow)
+ }
+
+ @Test
+ fun `resolves the import complete dialog with a right wing and a mirrored arrow`() {
+ val config = testee.resolve(NewUserOnboardingActivityDialog.ImportComplete, isCustomAiFlow = false)!!
+
+ assertEquals(Embellishment.RightWing, config.embellishment)
+ assertEquals(CardArrowConfig.AtStartMirrored, config.cardArrow)
+ }
}
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
index 6089e611d4a4..c4e9b3c1daa7 100644
--- 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
@@ -53,6 +53,15 @@ class EmbellishmentPlacementTest {
assertTrue(placement.drawsArtwork)
}
+ @Test
+ fun `the right wing reserves room on tablet only`() {
+ val placement = EmbellishmentPlacement.of(Embellishment.RightWing)
+
+ 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)