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)