diff --git a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift b/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift index 7991502a7..f6c6945f7 100644 --- a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift +++ b/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift @@ -15,6 +15,9 @@ enum FeatureFlag: String, CaseIterable, Codable { /// A flag that enables individual cipher encryption. case enableCipherKeyEncryption + /// A feature flag for the import logins flow for new accounts. + case importLoginsFlow = "import-logins-flow" + /// A feature flag for the intro carousel flow. case nativeCarouselFlow = "native-carousel-flow" @@ -75,6 +78,7 @@ enum FeatureFlag: String, CaseIterable, Codable { switch self { case .enableAuthenticatorSync, .enableCipherKeyEncryption, + .importLoginsFlow, .nativeCarouselFlow, .nativeCreateAccountFlow, .testLocalFeatureFlag, diff --git a/BitwardenShared/Core/Platform/Utilities/ExternalLinksConstants.swift b/BitwardenShared/Core/Platform/Utilities/ExternalLinksConstants.swift index ef795fe4a..1426c5003 100644 --- a/BitwardenShared/Core/Platform/Utilities/ExternalLinksConstants.swift +++ b/BitwardenShared/Core/Platform/Utilities/ExternalLinksConstants.swift @@ -28,6 +28,9 @@ enum ExternalLinksConstants { /// A link to Bitwarden's general help and feedback page. static let helpAndFeedback = URL(string: "https://bitwarden.com/help/")! + /// A link to the import logins help page. + static let importHelp = URL(string: "https://bitwarden.com/help/import-data/")! + /// A link to the password options within the passwords section of the settings menu. static let passwordOptions = URL(string: "App-prefs:PASSWORDS&path=PASSWORD_OPTIONS")! diff --git a/BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings b/BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings index a98d855e7..c8357ba82 100644 --- a/BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings +++ b/BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings @@ -1016,3 +1016,10 @@ "YouCanReturnToCompleteThisStepAnytimeInVaultUnderSettings" = "You can return to complete this step anytime in Vault under Settings."; "DoYouHaveAComputerAvailable" = "Do you have a computer available?"; "DoYouHaveAComputerAvailableDescriptionLong" = "The following instructions will guide you through importing logins from your desktop or laptop computer."; +"StepXOfY" = "Step %1$@ of %2$@"; +"ExportYourSavedLogins" = "Export your saved logins"; +"ExportLoginsStep1" = "On your computer, **log in to your current browser or password manager.**"; +"ExportLoginsStep2" = "**Export your passwords.** This option is usually found in your settings."; +"ExportLoginsStep3" = "**Select Import data** in the web app, then Done to finish syncing."; +"ExportLoginsStep3Subtitle" = "You’ll delete this file after import is complete."; +"NeedHelpCheckOutImportHelp" = "Need help? Check out **[import help](%1$@)**"; diff --git a/BitwardenShared/UI/Platform/Application/Views/NumberedList.swift b/BitwardenShared/UI/Platform/Application/Views/NumberedList.swift new file mode 100644 index 000000000..473dda040 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Views/NumberedList.swift @@ -0,0 +1,79 @@ +import SwiftUI + +// MARK: - NumberedList + +/// A view that displays a numbered list of views, separated by a divider. The list has the +/// secondary background color applied with rounded corners. +/// +/// Adapted from: https://movingparts.io/variadic-views-in-swiftui +/// +struct NumberedList: View { + // MARK: Properties + + /// The content to display in the numbered list. Each child view will receive it's own number. + let content: Content + + // MARK: View + + var body: some View { + _VariadicView.Tree(Layout()) { + content + } + } + + // MARK: Initialization + + /// Initialize a `NumberedList`. + /// + /// - Parameter content: The content to display in the numbered list. + /// + init(@ViewBuilder content: () -> Content) { + self.content = content() + } +} + +extension NumberedList { + /// The layout for the numbered list. + private struct Layout: _VariadicView_UnaryViewRoot { + func body(children: _VariadicView.Children) -> some View { + let last = children.last?.id + + VStack(spacing: 0) { + ForEachIndexed(children) { index, child in + HStack(spacing: 12) { + Text(String(index + 1)) + .styleGuide(.title2, weight: .bold) + .foregroundStyle(Asset.Colors.textInteraction.swiftUIColor) + .frame(minWidth: 24, alignment: .center) + .padding(.leading, 12) + + VStack(alignment: .leading, spacing: 0) { + child + + if child.id != last { + Divider() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .background(Asset.Colors.backgroundSecondary.swiftUIColor) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } +} + +// MARK: Previews + +#if DEBUG +#Preview { + NumberedList { + NumberedListRow(title: "Apple 🍎") + NumberedListRow(title: "Banana 🍌") + NumberedListRow(title: "Grapes 🍇") + } + .padding() + .background(Asset.Colors.backgroundPrimary.swiftUIColor) +} +#endif diff --git a/BitwardenShared/UI/Platform/Application/Views/NumberedListRow.swift b/BitwardenShared/UI/Platform/Application/Views/NumberedListRow.swift new file mode 100644 index 000000000..c3d128ab7 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Views/NumberedListRow.swift @@ -0,0 +1,56 @@ +import SwiftUI + +/// A view that displays a single row within a `NumberedList`. +/// +struct NumberedListRow: View { + // MARK: Properties + + /// The title to display in the row. + let title: String + + /// An optional subtitle to display in the row. + let subtitle: String? + + // MARK: View + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(LocalizedStringKey(title)) + .styleGuide(.body) + .foregroundStyle(Asset.Colors.textPrimary.swiftUIColor) + + if let subtitle { + Text(LocalizedStringKey(subtitle)) + .styleGuide(.subheadline) + .foregroundStyle(Asset.Colors.textSecondary.swiftUIColor) + } + } + .padding(.vertical, 12) + .padding(.trailing, 16) // Leading padding is handled by `NumberedList`. + } + + // MARK: Initialization + + /// Initializes a `NumberedListRow`. + /// + /// - Parameters: + /// - title: The title to display in the row. + /// - subtitle: An optional subtitle to display in the row. + /// + init(title: String, subtitle: String? = nil) { + self.title = title + self.subtitle = subtitle + } +} + +// MARK: Previews + +#if DEBUG +#Preview { + NumberedList { + NumberedListRow(title: "Apple 🍎") + NumberedListRow(title: "Banana 🍌") + NumberedListRow(title: "Grapes 🍇") + } +} +#endif diff --git a/BitwardenShared/UI/Vault/Vault/ImportLogins/ImportLoginsAction.swift b/BitwardenShared/UI/Vault/Vault/ImportLogins/ImportLoginsAction.swift index f1ed355df..72ed3cc72 100644 --- a/BitwardenShared/UI/Vault/Vault/ImportLogins/ImportLoginsAction.swift +++ b/BitwardenShared/UI/Vault/Vault/ImportLogins/ImportLoginsAction.swift @@ -3,6 +3,12 @@ /// Actions that can be processed by a `ImportLoginsProcessor`. /// enum ImportLoginsAction: Equatable { + /// Advance to the next page of instructions. + case advanceNextPage + + /// Advance to the previous page of instructions. + case advancePreviousPage + /// Dismiss the view. case dismiss diff --git a/BitwardenShared/UI/Vault/Vault/ImportLogins/ImportLoginsProcessor.swift b/BitwardenShared/UI/Vault/Vault/ImportLogins/ImportLoginsProcessor.swift index 7565ff4cd..5ad2741e5 100644 --- a/BitwardenShared/UI/Vault/Vault/ImportLogins/ImportLoginsProcessor.swift +++ b/BitwardenShared/UI/Vault/Vault/ImportLogins/ImportLoginsProcessor.swift @@ -46,6 +46,10 @@ class ImportLoginsProcessor: StateProcessor some View { VStack(spacing: 32) { PageHeaderView( image: Asset.Images.import, @@ -35,20 +56,83 @@ struct ImportLoginsView: View { .padding(.top, 8) .frame(maxWidth: .infinity) .scrollView() - .navigationBar(title: Localizations.importLogins, titleDisplayMode: .inline) - .toolbar { - cancelToolbarItem { - store.send(.dismiss) + } + + /// The step 1 page view. + @ViewBuilder + private func step1() -> some View { + stepView(step: 1, totalSteps: 3, title: Localizations.exportYourSavedLogins) { + NumberedListRow(title: Localizations.exportLoginsStep1) + NumberedListRow(title: Localizations.exportLoginsStep2) + NumberedListRow( + title: Localizations.exportLoginsStep3, + subtitle: Localizations.exportLoginsStep3Subtitle + ) + } + } + + /// Returns a scroll view for displaying the instructions for a step. + /// + /// - Parameters: + /// - step: The step number of this page. + /// - totalSteps: The total number of steps. + /// - title: The title of the step. + /// - list: A closure that returns the views to display in a numbered list. + /// + @ViewBuilder + private func stepView( + step: Int, + totalSteps: Int, + title: String, + @ViewBuilder list: () -> some View + ) -> some View { + VStack(spacing: 32) { + VStack(spacing: 12) { + Text(Localizations.stepXOfY(step, totalSteps)) + .styleGuide(.subheadline, weight: .bold) + + Text(title) + .styleGuide(.title2, weight: .bold) + } + .multilineTextAlignment(.center) + + NumberedList(content: list) + + Text(LocalizedStringKey( + Localizations.needHelpCheckOutImportHelp(ExternalLinksConstants.importHelp) + )) + .multilineTextAlignment(.center) + .styleGuide(.footnote) + .tint(Asset.Colors.textInteraction.swiftUIColor) + + VStack(spacing: 12) { + Button(Localizations.continue) { + store.send(.advanceNextPage) + } + .buttonStyle(.primary()) + + Button(Localizations.back) { + store.send(.advancePreviousPage) + } + .buttonStyle(.transparent) } } + .foregroundStyle(Asset.Colors.textPrimary.swiftUIColor) + .frame(maxWidth: .infinity) + .scrollView() } } // MARK: - Previews #if DEBUG -#Preview { +#Preview("Intro") { ImportLoginsView(store: Store(processor: StateProcessor(state: ImportLoginsState()))) .navStackWrapped } + +#Preview("Step 1") { + ImportLoginsView(store: Store(processor: StateProcessor(state: ImportLoginsState(page: .step1)))) + .navStackWrapped +} #endif diff --git a/BitwardenShared/UI/Vault/Vault/ImportLogins/ImportLoginsViewTests.swift b/BitwardenShared/UI/Vault/Vault/ImportLogins/ImportLoginsViewTests.swift index 9d2d5d5c7..38c1c7d9f 100644 --- a/BitwardenShared/UI/Vault/Vault/ImportLogins/ImportLoginsViewTests.swift +++ b/BitwardenShared/UI/Vault/Vault/ImportLogins/ImportLoginsViewTests.swift @@ -53,14 +53,42 @@ class ImportLoginsViewTests: BitwardenTestCase { XCTAssertEqual(processor.effects.last, .importLoginsLater) } + /// Tapping the back button for a step dispatches the `advancePreviousPage` action. + @MainActor + func test_step_back_tap() throws { + processor.state.page = .step1 + let button = try subject.inspect().find(button: Localizations.back) + try button.tap() + XCTAssertEqual(processor.dispatchedActions.last, .advancePreviousPage) + } + + /// Tapping the continue button for a step dispatches the `advanceNextPage` action. + @MainActor + func test_step_continue_tap() throws { + processor.state.page = .step1 + let button = try subject.inspect().find(button: Localizations.continue) + try button.tap() + XCTAssertEqual(processor.dispatchedActions.last, .advanceNextPage) + } + // MARK: Snapshots - /// The import logins view renders correctly. + /// The import logins intro page renders correctly. @MainActor - func test_snapshot_importLogins() { + func test_snapshot_importLoginsIntro() { assertSnapshots( of: subject.navStackWrapped, as: [.defaultPortrait, .defaultPortraitDark, .tallPortraitAX5(heightMultiple: 2), .defaultLandscape] ) } + + /// The import logins step 1 page renders correctly. + @MainActor + func test_snapshot_importLoginsStep1() { + processor.state.page = .step1 + assertSnapshots( + of: subject.navStackWrapped, + as: [.defaultPortrait, .defaultPortraitDark, .tallPortraitAX5(heightMultiple: 2.5), .defaultLandscape] + ) + } } diff --git a/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLogins.1.png b/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsIntro.1.png similarity index 100% rename from BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLogins.1.png rename to BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsIntro.1.png diff --git a/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLogins.2.png b/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsIntro.2.png similarity index 100% rename from BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLogins.2.png rename to BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsIntro.2.png diff --git a/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLogins.3.png b/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsIntro.3.png similarity index 100% rename from BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLogins.3.png rename to BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsIntro.3.png diff --git a/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLogins.4.png b/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsIntro.4.png similarity index 100% rename from BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLogins.4.png rename to BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsIntro.4.png diff --git a/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsStep1.1.png b/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsStep1.1.png new file mode 100644 index 000000000..00308c4ed Binary files /dev/null and b/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsStep1.1.png differ diff --git a/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsStep1.2.png b/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsStep1.2.png new file mode 100644 index 000000000..fb9e4078f Binary files /dev/null and b/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsStep1.2.png differ diff --git a/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsStep1.3.png b/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsStep1.3.png new file mode 100644 index 000000000..d333b4498 Binary files /dev/null and b/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsStep1.3.png differ diff --git a/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsStep1.4.png b/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsStep1.4.png new file mode 100644 index 000000000..366d48cae Binary files /dev/null and b/BitwardenShared/UI/Vault/Vault/ImportLogins/__Snapshots__/ImportLoginsViewTests/test_snapshot_importLoginsStep1.4.png differ diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift index a74ee74ac..1fb2356f8 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift @@ -292,7 +292,7 @@ extension VaultListProcessor { /// Streams the user's account setup progress. /// private func streamAccountSetupProgress() async { - guard await services.configService.getFeatureFlag(.nativeCreateAccountFlow) else { return } + guard await services.configService.getFeatureFlag(.importLoginsFlow) else { return } do { for await badgeState in try await services.stateService.settingsBadgePublisher().values { state.importLoginsSetupProgress = badgeState.importLoginsSetupProgress diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift index cecf4b839..461830154 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift @@ -629,7 +629,7 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ /// whenever it changes. @MainActor func test_perform_streamAccountSetupProgress() { - configService.featureFlagsBool[.nativeCreateAccountFlow] = true + configService.featureFlagsBool[.importLoginsFlow] = true stateService.activeAccount = .fixture() let task = Task { @@ -648,7 +648,7 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ /// setup progress fails. @MainActor func test_perform_streamAccountSetupProgress_error() async { - configService.featureFlagsBool[.nativeCreateAccountFlow] = true + configService.featureFlagsBool[.importLoginsFlow] = true await subject.perform(.streamAccountSetupProgress) @@ -656,10 +656,10 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ } /// `perform(_:)` with `.streamAccountSetupProgress` doesn't load the account setup progress - /// if the create account feature flag is disabled. + /// if the import logins feature flag is disabled. @MainActor - func test_perform_streamAccountSetupProgress_nativeCreateAccountFlowDisabled() async { - configService.featureFlagsBool[.nativeCreateAccountFlow] = false + func test_perform_streamAccountSetupProgress_importLoginsFlowDisabled() async { + configService.featureFlagsBool[.importLoginsFlow] = false stateService.activeAccount = .fixture() stateService.settingsBadgeSubject.send(.fixture())