diff --git a/BitwardenShared/UI/Platform/Application/Extensions/CGRect+Extensions.swift b/BitwardenShared/UI/Platform/Application/Extensions/CGRect+Extensions.swift new file mode 100644 index 000000000..28f4d959f --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Extensions/CGRect+Extensions.swift @@ -0,0 +1,18 @@ +import Foundation + +/// A extension to `CGRect` that allows enlarging the rect by a given value. +extension CGRect { + /// Returns a new `CGRect` that is enlarged by the given value, will add the value to each side of the rect. + /// + /// - Parameter value: The value to enlarge the `CGRect` by. + /// - Returns: A new `CGRect` that is enlarged by the given value. + /// + func enlarged(by value: CGFloat) -> CGRect { + CGRect( + x: origin.x - value, + y: origin.y - value, + width: size.width + 2 * value, + height: size.height + 2 * value + ) + } +} diff --git a/BitwardenShared/UI/Platform/Application/Extensions/View+GuidedTourStep.swift b/BitwardenShared/UI/Platform/Application/Extensions/View+GuidedTourStep.swift new file mode 100644 index 000000000..d40a9d8d9 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Extensions/View+GuidedTourStep.swift @@ -0,0 +1,18 @@ +import SwiftUI + +extension View { + /// A view modifier that informs the guided tour the spotlight region of the + /// view and assigns an identifier to the view. + /// + /// - Parameters: + /// - step: The guided tour step. + /// - perform: A closure called when the size or origin of the view changes. + /// - Returns: A copy of the view with the guided tour step modifier applied. + /// + func guidedTourStep(_ step: GuidedTourStep, perform: @escaping (CGRect) -> Void) -> some View { + onFrameChanged { origin, size in + perform(CGRect(origin: origin, size: size)) + } + .id(step) + } +} diff --git a/BitwardenShared/UI/Platform/Application/Extensions/View+OnSizeChanged.swift b/BitwardenShared/UI/Platform/Application/Extensions/View+OnSizeChanged.swift index 49fb6cb3c..3dfcc21a8 100644 --- a/BitwardenShared/UI/Platform/Application/Extensions/View+OnSizeChanged.swift +++ b/BitwardenShared/UI/Platform/Application/Extensions/View+OnSizeChanged.swift @@ -15,6 +15,29 @@ extension View { ) .onPreferenceChange(ViewSizeKey.self, perform: perform) } + + /// A view modifier that calculates the origin and size of the containing view. + /// + /// - Parameter perform: A closure called when the size or origin of the view changes. + /// - Returns: A copy of the view with the sizing and origin modifier applied. + /// + func onFrameChanged(perform: @escaping (CGPoint, CGSize) -> Void) -> some View { + background( + GeometryReader { geometry in + Color.clear + .preference( + key: ViewFrameKey.self, + value: CGRect( + origin: geometry.frame(in: .global).origin, + size: geometry.size + ) + ) + } + ) + .onPreferenceChange(ViewFrameKey.self) { value in + perform(value.origin, value.size) + } + } } /// A `PreferenceKey` used to calculate the size of a view. @@ -26,3 +49,15 @@ private struct ViewSizeKey: PreferenceKey { value = nextValue() } } + +/// A `PreferenceKey` used to calculate the size and origin of a view. +/// +private struct ViewFrameKey: PreferenceKey { + static var defaultValue = CGRect.zero + + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + if nextValue() != defaultValue { + value = nextValue() + } + } +} diff --git a/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-down.imageset/Contents.json b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-down.imageset/Contents.json new file mode 100644 index 000000000..29ede229b --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-down.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "arrow-down.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "arrow-darkmode-down.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-down.imageset/arrow-darkmode-down.pdf b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-down.imageset/arrow-darkmode-down.pdf new file mode 100644 index 000000000..1fb3d0c4d Binary files /dev/null and b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-down.imageset/arrow-darkmode-down.pdf differ diff --git a/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-down.imageset/arrow-down.pdf b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-down.imageset/arrow-down.pdf new file mode 100644 index 000000000..22e3cc435 Binary files /dev/null and b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-down.imageset/arrow-down.pdf differ diff --git a/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-up.imageset/Contents.json b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-up.imageset/Contents.json new file mode 100644 index 000000000..305cec8cb --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-up.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "arrow-up.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "arrow-darkmode-up.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-up.imageset/arrow-darkmode-up.pdf b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-up.imageset/arrow-darkmode-up.pdf new file mode 100644 index 000000000..2a9588726 Binary files /dev/null and b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-up.imageset/arrow-darkmode-up.pdf differ diff --git a/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-up.imageset/arrow-up.pdf b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-up.imageset/arrow-up.pdf new file mode 100644 index 000000000..e9c8c09f9 Binary files /dev/null and b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/arrow-up.imageset/arrow-up.pdf differ 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 6b5ecf5d8..dc895235a 100644 --- a/BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings +++ b/BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings @@ -67,6 +67,9 @@ "Tools" = "Tools"; "URI" = "URI"; "UseFingerprintToUnlock" = "Use fingerprint to unlock"; +"UseThisButtonToGenerateANewUniquePassword" = "Use this button to generate a new unique password."; +"YouWillOnlyNeedToSetUpAnAuthenticatorKeyDescriptionLong" = "You'll only need to set up Authenticator Key for logins that require two-factor authentication with a code. The key will continuously generate six-digit codes you can use to log in."; +"YouMustAddAWebAddressToUseAutofillToAccessThisAccount" = "You must add a web address to use autofill to access this account."; "Username" = "Username"; "ValidationFieldRequired" = "The %1$@ field is required."; "ValueHasBeenCopied" = "%1$@ copied"; @@ -79,6 +82,7 @@ "Website" = "Website"; "Yes" = "Yes"; "Account" = "Account"; +"StepOfStep" = "%1$d OF %2$d"; "AccountCreated" = "Your new account has been created! You may now log in."; "AddAnItem" = "Add an Item"; "AppExtension" = "App extension"; diff --git a/BitwardenShared/UI/Platform/Application/Views/FullScreenCoverBackgroundRemovalView.swift b/BitwardenShared/UI/Platform/Application/Views/FullScreenCoverBackgroundRemovalView.swift new file mode 100644 index 000000000..976f338d8 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Views/FullScreenCoverBackgroundRemovalView.swift @@ -0,0 +1,21 @@ +import SwiftUI + +/// A view that removes the background of a full-screen cover. +/// This view is being used when we present guided tour view where we need to present a full-screen cover +/// and remove the default background provided by SwiftUI. +/// +struct FullScreenCoverBackgroundRemovalView: UIViewRepresentable { + private class BackgroundRemovalView: UIView { + override func didMoveToWindow() { + super.didMoveToWindow() + + superview?.superview?.backgroundColor = .clear + } + } + + func makeUIView(context: Context) -> UIView { + BackgroundRemovalView() + } + + func updateUIView(_ uiView: UIView, context: Context) {} +} diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourScrollView.swift b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourScrollView.swift new file mode 100644 index 000000000..b3b408aed --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourScrollView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/// A scroll view that contains the guided tour content. +struct GuidedTourScrollView: View { + /// The store for the guided tour view. + @ObservedObject var store: Store + + /// The content of the scroll view. + @ViewBuilder var content: Content + + /// An environment variable for getting the vertical size class of the view. + @Environment(\.verticalSizeClass) var verticalSizeClass + + /// The ID for scrolling to top of the view. + let top = "top" + + var body: some View { + ScrollViewReader { reader in + ScrollView { + // Dummy spacer view for scroll view to locate when scrolling to top + Spacer() + .frame(height: 0) + .id(top) + + content + } + .fullScreenCover(isPresented: store.binding(get: { state in + state.showGuidedTour + }, send: { state in + .toggleGuidedTourVisibilityChanged(state) + })) { + guidedTourView() + } + .transaction { transaction in + // disable the default FullScreenCover modal animation + transaction.disablesAnimations = true + } + .onChange(of: verticalSizeClass) { _ in + handleLandscapeScroll(reader) + } + .onChange(of: store.state.currentIndex) { _ in + handleLandscapeScroll(reader) + } + .onChange(of: store.state.showGuidedTour) { newValue in + if newValue == false { + reader.scrollTo(top) + } + } + } + } + + /// A view that presents the guided tour. + @ViewBuilder + private func guidedTourView() -> some View { + GuidedTourView( + store: store + ) + } + + /// Scrolls to the guided tour step when in landscape mode. + private func handleLandscapeScroll(_ reader: ScrollViewProxy) { + reader.scrollTo(GuidedTourStep(rawValue: store.state.currentIndex)) + } +} diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourStepState.swift b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourStepState.swift new file mode 100644 index 000000000..25cd62100 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourStepState.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Encapsulates the state of a guided tour step. +/// +struct GuidedTourStepState: Equatable { + /// The horizontal position of the arrow. + var arrowHorizontalPosition: ArrowHorizontalPosition + + /// The padding of the card from the leading edge. + var cardLeadingPadding: CGFloat = 24 + + /// The padding of the card from the trailing edge. + var cardTrailingPadding: CGFloat = 24 + + /// The region of the view to spotlight. + var spotlightRegion: CGRect = .zero + + /// The shape of the spotlight. + var spotlightShape: SpotlightShape + + /// The title of the guided tour card. + var title: String +} + +/// The shape of the spotlight. +/// +enum SpotlightShape: Equatable { + /// The spotlight is a circle. + case circle + + /// The spotlight is a rectangle with rounded corners. + case rectangle(cornerRadius: CGFloat) +} + +/// The horizontal position of the arrow. +enum ArrowHorizontalPosition { + /// The arrow is horizontally positioned at the left side of the spotlight. + /// The position is calculated by dividing the width of the spotlight by 3 + /// and placing the arrow at the center of the first part. + case left + + /// The arrow is horizontally positioned at the center of spotlight. + case center + + /// The arrow is horizontally positioned at the left side of the spotlight. + /// The position is calculated by dividing the width of the spotlight by 3 + /// and placing the arrow at the center of the last part. + case right +} + +/// The vertical position of the coach mark. +enum CoachMarkVerticalPosition { + /// The coach mark is positioned at the top of spotlight. + case top + + /// The coach mark is positioned at the bottom of spotlight. + case bottom +} + +/// Common steps used by different guided tours. +/// +enum GuidedTourStep: Int, Equatable { + /// The first step of the guided tour. + case step1 + + /// The second step of the guided tour. + case step2 + + /// The third step of the guided tour. + case step3 + + /// The fourth step of the guided tour. + case step4 + + /// The fifth step of the guided tour. + case step5 + + /// The sixth step of the guided tour. + case step6 +} diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourView.swift b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourView.swift new file mode 100644 index 000000000..945da3348 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourView.swift @@ -0,0 +1,378 @@ +import SwiftUI + +/// A view that displays dimmed background with a spotlight and a coach-mark card. +/// +struct GuidedTourView: View { + /// An environment variable for getting the vertical size class of the view. + @Environment(\.verticalSizeClass) var verticalSizeClass + + /// The store for the guided tour view. + @ObservedObject var store: Store + + // MARK: Private Properties + + /// The actual size of the coach-mark card. + @SwiftUI.State private var cardSize: CGSize = .zero + + /// The opacity of the guided tour view. + @SwiftUI.State private var opacity: Double = 0.0 + + /// The size of the view. + @SwiftUI.State private var viewSize: CGSize = .zero + + /// The size of the coach-mark arrow icon. + let arrowSize = CGSize(width: 47, height: 13) + + /// The maximum dynamic type size for the view + /// Default is `.xxLarge` + let maxDynamicTypeSize: DynamicTypeSize = .xxxLarge + + /// The margin between the spotlight and the coach-mark. + let spotLightAndCoachMarkMargin: CGFloat = 3 + + /// The padding of the coach-mark card from the leading edge. + var cardLeadingPadding: CGFloat { + store.state.currentStepState.cardLeadingPadding + } + + /// The padding of the coach-mark card from the trailing edge. + var cardTrailingPadding: CGFloat { + store.state.currentStepState.cardTrailingPadding + } + + /// The max width of the coach-mark card. + var cardMaxWidth: CGFloat { + if verticalSizeClass == .compact { + 480 + } else { + 320 + } + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .top) { + Color.black.opacity(0.4).edgesIgnoringSafeArea(.all) + .mask( + Spotlight( + spotlight: store.state.currentStepState.spotlightRegion, + spotlightShape: store.state.currentStepState.spotlightShape + ) + .fill(style: FillStyle(eoFill: true)) + ) + + VStack(alignment: .leading, spacing: 0) { + let coachMarkVerticalPosition = calculateCoachMarkPosition() + if coachMarkVerticalPosition == .bottom { + Image(asset: Asset.Images.arrowUp) + .offset(x: calculateArrowHorizontalOffset()) + } + VStack(alignment: .leading) { + cardContent() + .frame(maxWidth: cardMaxWidth) + .onFrameChanged { _, size in + cardSize = size + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .offset(x: calculateCoachMarkCardHorizontalOffset()) + .padding(.leading, cardLeadingPadding) + .padding(.trailing, cardTrailingPadding) + + if coachMarkVerticalPosition == .top { + Image(asset: Asset.Images.arrowDown) + .offset(x: calculateArrowHorizontalOffset()) + } + } + .padding(0) + .frame(maxWidth: .infinity) + .offset(y: calculateCoachMarkOffsetY()) + } + .opacity(opacity) + .ignoresSafeArea(.all) + .background(FullScreenCoverBackgroundRemovalView()) + .transition(.opacity) + .task(id: verticalSizeClass) { + viewSize = geometry.size + } + .onAppear { + withAnimation(.easeInOut(duration: UI.duration(0.3))) { + opacity = 1 + } + viewSize = geometry.size + } + } + } + + // MARK: - Private Methods + + /// The content of the coach-mark card. + @ViewBuilder + private func cardContent() -> some View { + VStack(alignment: .leading) { + HStack(spacing: 0) { + Text(store.state.progressText) + .foregroundColor(Asset.Colors.textSecondary.swiftUIColor) + .styleGuide(.caption1, weight: .bold) + .dynamicTypeSize(...maxDynamicTypeSize) + + Spacer() + + Button { + store.send(.dismissTapped) + } label: { + Image(asset: Asset.Images.close16, label: Text(Localizations.dismiss)) + .imageStyle(.accessoryIcon16(color: Asset.Colors.iconPrimary.swiftUIColor)) + } + } + + Text(store.state.currentStepState.title) + .dynamicTypeSize(...maxDynamicTypeSize) + .foregroundColor(Asset.Colors.textPrimary.swiftUIColor) + .styleGuide(.body) + + cardNavigationButtons() + } + .padding(16) + .background(Asset.Colors.backgroundSecondary.swiftUIColor) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + /// The navigation `Next` and `Back` buttons of the coach-mark card. + @ViewBuilder + private func cardNavigationButtons() -> some View { + HStack(spacing: 0) { + if store.state.step > 1 { + Button { + store.send(.backTapped) + } label: { + Text(Localizations.back) + .styleGuide(.callout, weight: .semibold) + .foregroundStyle(Asset.Colors.textInteraction.swiftUIColor) + .multilineTextAlignment(.leading) + .dynamicTypeSize(...maxDynamicTypeSize) + } + } + + Spacer() + + Button { + if store.state.step < store.state.totalSteps { + store.send(.nextTapped) + } else { + store.send(.doneTapped) + } + + } label: { + Text( + store.state.step < store.state.totalSteps ? Localizations.next : Localizations.done + ) + .styleGuide(.callout, weight: .semibold) + .foregroundStyle(Asset.Colors.textInteraction.swiftUIColor) + .multilineTextAlignment(.leading) + .dynamicTypeSize(...maxDynamicTypeSize) + } + } + .padding(0) + .frame(maxWidth: .infinity) + } +} + +extension GuidedTourView { + /// Calculates the X offset of the coach-mark card. + /// + /// - Returns: The X offset value. + /// + private func calculateCoachMarkCardHorizontalOffset() -> CGFloat { + let arrowOffset = calculateArrowHorizontalOffset() + + // If the card is too far from the coach mark arrow, + // calculate the offset to show the card near the arrow. + if cardLeadingPadding + cardSize.width < arrowOffset + arrowSize.width { + return (arrowOffset + arrowSize.width) - (cardLeadingPadding + cardSize.width) + } + + // If there is enough space to show the card as center of the arrow, + // calculate the offset to show the card as center of the arrow. + if viewSize.width - arrowOffset + arrowSize.width > cardSize.width / 2, + arrowOffset > cardSize.width / 2 { + return (arrowOffset + arrowSize.width / 2 + cardSize.width / 2) + - (cardLeadingPadding + cardSize.width) + } + return 0 + } + + /// Calculates the horizontal offset of the coach-mark arrow. + /// + /// - Returns: The horizontal offset value. + /// + private func calculateArrowHorizontalOffset() -> CGFloat { + switch store.state.currentStepState.arrowHorizontalPosition { + case .left: + calculateArrowHorizontalOffsetForLeft() + case .center: + calculateArrowHorizontalOffsetForCenter() + case .right: + calculateArrowHorizontalOffsetForRight() + } + } + + /// Calculates the horizontal offset for centering the coach mark arrow. + /// + /// - Returns: The horizontal offset value. + /// + private func calculateArrowHorizontalOffsetForCenter() -> CGFloat { + store.state.currentStepState.spotlightRegion.origin.x + + store.state.currentStepState.spotlightRegion.size.width / 2 + - (arrowSize.width / 2) + } + + /// Calculates the horizontal offset for positioning the coach mark arrow when the arrow is on the left. + /// + /// - Returns: The horizontal offset value. + /// + private func calculateArrowHorizontalOffsetForLeft() -> CGFloat { + let result = store.state.currentStepState.spotlightRegion.origin.x + + (store.state.currentStepState.spotlightRegion.size.width / 3) / 2 + - (arrowSize.width / 2) + return result + } + + /// Calculates the horizontal offset for positioning the coach mark arrow when the arrow is on the right. + /// + /// - Returns: The horizontal offset value. + /// + private func calculateArrowHorizontalOffsetForRight() -> CGFloat { + let result = store.state.currentStepState.spotlightRegion.origin.x + + store.state.currentStepState.spotlightRegion.size.width + - (store.state.currentStepState.spotlightRegion.size.width / 3) / 2 + - (arrowSize.width / 2) + return result + } + + /// Calculates the Y offset of the coach-mark card. + /// + /// - Returns: The Y offset value. + /// + private func calculateCoachMarkOffsetY() -> CGFloat { + if calculateCoachMarkPosition() == .top { + return store.state.currentStepState.spotlightRegion.origin.y + - spotLightAndCoachMarkMargin + - cardSize.height + - arrowSize.height + } else { + return store.state.currentStepState.spotlightRegion.origin.y + + store.state.currentStepState.spotlightRegion.size.height + + spotLightAndCoachMarkMargin + } + } + + /// Calculates the vertical position of the coach-mark. + /// + /// - Returns: The vertical position of the coach-mark. + /// + private func calculateCoachMarkPosition() -> CoachMarkVerticalPosition { + let topSpace = store.state.currentStepState.spotlightRegion.origin.y + + let bottomSpace = viewSize.height + - ( + store.state.currentStepState.spotlightRegion.origin.y + + store.state.currentStepState.spotlightRegion.size.height + ) + + if topSpace > bottomSpace { + return .top + } else { + return .bottom + } + } +} + +// MARK: Previews + +#if DEBUG +struct GuidedTourView_Previews: PreviewProvider { + static let loginStep1: GuidedTourStepState = { + var step = GuidedTourStepState.loginStep1 + step.spotlightRegion = CGRect(x: 338, y: 120, width: 40, height: 40) + return step + + }() + + static let loginStep2: GuidedTourStepState = { + var step = GuidedTourStepState.loginStep2 + step.spotlightRegion = CGRect(x: 10, y: 185, width: 380, height: 94) + return step + + }() + + static let guidedTourViewState: GuidedTourViewState = { + var state = GuidedTourViewState(guidedTourStepStates: [loginStep1, loginStep2]) + state.currentIndex = 1 + return state + }() + + static let loginItemView = AddEditLoginItemView( + store: Store( + processor: StateProcessor( + state: LoginItemState( + isTOTPAvailable: false, + totpState: .none + ) + ) + ) + ) + + static var previews: some View { + NavigationView { + ScrollView { + LazyVStack(spacing: 20) { + loginItemView + } + .padding(16) + } + .fullScreenCover(isPresented: .constant(true)) { + GuidedTourView( + store: Store( + processor: StateProcessor( + state: GuidedTourViewState(guidedTourStepStates: [loginStep1, loginStep2]) + ) + ) + ) + } + .transaction { transaction in + // disable the default FullScreenCover modal animation + transaction.disablesAnimations = true + } + .background(Asset.Colors.backgroundPrimary.swiftUIColor) + .ignoresSafeArea() + } + .previewDisplayName("Circle") + + NavigationView { + ScrollView { + LazyVStack(spacing: 20) { + loginItemView + } + .padding(16) + } + .fullScreenCover(isPresented: .constant(true)) { + GuidedTourView( + store: Store( + processor: StateProcessor( + state: guidedTourViewState + ) + ) + ) + } + .transaction { transaction in + // disable the default FullScreenCover modal animation + transaction.disablesAnimations = true + } + .background(Asset.Colors.backgroundPrimary.swiftUIColor) + .ignoresSafeArea() + } + .previewDisplayName("Rectangle") + } +} +#endif diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourViewAction.swift b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourViewAction.swift new file mode 100644 index 000000000..1b62a69c4 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourViewAction.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Actions that can be handled by a processor of the `GuidedTourView`. +/// +enum GuidedTourViewAction: Equatable, Sendable { + /// The user has tapped the back button. + case backTapped + + /// A region to be spotlit for step was rendered and is ready to have the spotlight drawn using the supplied frame. + case didRenderViewToSpotlight(frame: CGRect, step: GuidedTourStep) + + /// The user has tapped the dismiss button. + case dismissTapped + + /// The user has tapped the done button. + case doneTapped + + /// The user has tapped the next button. + case nextTapped + + /// The guided tour visibility was toggled. + case toggleGuidedTourVisibilityChanged(Bool) +} diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourViewState.swift b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourViewState.swift new file mode 100644 index 000000000..886c4ba96 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourViewState.swift @@ -0,0 +1,60 @@ +import Foundation + +/// An object that defines the current state of a `GuidedTourView`. +/// +struct GuidedTourViewState: Equatable { + /// The index of the current step. + var currentIndex = 0 + + /// The current state of the guided tour. + var currentStepState: GuidedTourStepState { + guidedTourStepStates[currentIndex] + } + + /// The state of each step in the guided tour. + var guidedTourStepStates: [GuidedTourStepState] + + /// Progress text (e.g. "1 OF 3") to show above title. + var progressText: String { + Localizations.stepOfStep(step, totalSteps) + } + + /// The flag to show/hide guided tour. + var showGuidedTour: Bool = false + + /// The spotlight region for the current step. + var spotlightRegion: CGRect { + currentStepState.spotlightRegion + } + + /// the current step. + var step: Int { + currentIndex + 1 + } + + /// The total number of steps in the guided tour. + var totalSteps: Int { + guidedTourStepStates.count + } +} + +extension GuidedTourViewState { + /// Updates the state of the guided tour view based on the given `GuidedTourViewAction`. + /// + /// - Parameter action: The action to process. + /// + mutating func updateStateForGuidedTourViewAction(_ action: GuidedTourViewAction) { + switch action { + case .backTapped: + currentIndex -= 1 + case let .didRenderViewToSpotlight(frame, step): + guidedTourStepStates[step.rawValue].spotlightRegion = frame + case .dismissTapped, .doneTapped: + showGuidedTour = false + case .nextTapped: + currentIndex += 1 + case let .toggleGuidedTourVisibilityChanged(show): + showGuidedTour = show + } + } +} diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourViewStateTests.swift b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourViewStateTests.swift new file mode 100644 index 000000000..8a79f1481 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourViewStateTests.swift @@ -0,0 +1,112 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - GuidedTourViewStateTests + +class GuidedTourViewStateTests: BitwardenTestCase { + // MARK: Properties + + var subject: GuidedTourViewState! + + override func setUp() { + super.setUp() + subject = GuidedTourViewState( + currentIndex: 0, + guidedTourStepStates: [ + .loginStep1, + .loginStep2, + .loginStep3, + ] + ) + } + + override func tearDown() { + super.tearDown() + + subject = nil + } + + // MARK: Tests + + /// Tests the `currentStepState` computed property. + func test_currentStepState() { + XCTAssertEqual(subject.currentStepState, .loginStep1) + + subject.currentIndex = 1 + XCTAssertEqual(subject.currentStepState, .loginStep2) + + subject.currentIndex = 2 + XCTAssertEqual(subject.currentStepState, .loginStep3) + } + + /// Tests the `progressText` computed property. + func test_progressText() { + XCTAssertEqual(subject.progressText, "1 OF 3") + + subject.currentIndex = 1 + XCTAssertEqual(subject.progressText, "2 OF 3") + + subject.currentIndex = 2 + XCTAssertEqual(subject.progressText, "3 OF 3") + } + + /// Tests the `step` computed property. + func test_step() { + XCTAssertEqual(subject.step, 1) + + subject.currentIndex = 1 + XCTAssertEqual(subject.step, 2) + + subject.currentIndex = 2 + XCTAssertEqual(subject.step, 3) + } + + /// Tests the `totalSteps` computed property. + func test_totalSteps() { + XCTAssertEqual(subject.totalSteps, 3) + } + + /// Tests the `updateStateForGuidedTourViewAction(_:)` method with `.backTapped` action. + func test_updateStateForGuidedTourViewAction_backTapped() { + subject.currentIndex = 1 + subject.updateStateForGuidedTourViewAction(.backTapped) + XCTAssertEqual(subject.currentIndex, 0) + } + + /// Tests the `updateStateForGuidedTourViewAction(_:)` method with `.didRenderViewToSpotlight` action. + func test_updateStateForGuidedTourViewAction_didRenderViewToSpotlight() { + let frame = CGRect(x: 10, y: 10, width: 100, height: 100) + subject.updateStateForGuidedTourViewAction(.didRenderViewToSpotlight(frame: frame, step: .step1)) + XCTAssertEqual(subject.guidedTourStepStates[0].spotlightRegion, frame) + } + + /// Tests the `updateStateForGuidedTourViewAction(_:)` method with `.dismissTapped` action. + func test_updateStateForGuidedTourViewAction_dismissTapped() { + subject.showGuidedTour = true + subject.updateStateForGuidedTourViewAction(.dismissTapped) + XCTAssertFalse(subject.showGuidedTour) + } + + /// Tests the `updateStateForGuidedTourViewAction(_:)` method with `.doneTapped` action. + func test_updateStateForGuidedTourViewAction_doneTapped() { + subject.showGuidedTour = true + subject.updateStateForGuidedTourViewAction(.doneTapped) + XCTAssertFalse(subject.showGuidedTour) + } + + /// Tests the `updateStateForGuidedTourViewAction(_:)` method with `.nextTapped` action. + func test_updateStateForGuidedTourViewAction_nextTapped() { + subject.updateStateForGuidedTourViewAction(.nextTapped) + XCTAssertEqual(subject.currentIndex, 1) + } + + /// Tests the `updateStateForGuidedTourViewAction(_:)` method with `.toggleGuidedTourVisibilityChanged` action. + func test_updateStateForGuidedTourViewAction_toggleGuidedTourVisibilityChanged() { + subject.updateStateForGuidedTourViewAction(.toggleGuidedTourVisibilityChanged(true)) + XCTAssertTrue(subject.showGuidedTour) + + subject.updateStateForGuidedTourViewAction(.toggleGuidedTourVisibilityChanged(false)) + XCTAssertFalse(subject.showGuidedTour) + } +} diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourViewTests.swift b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourViewTests.swift new file mode 100644 index 000000000..f248f8615 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/GuidedTourViewTests.swift @@ -0,0 +1,139 @@ +import SnapshotTesting +import SwiftUI +import ViewInspector +import XCTest + +@testable import BitwardenShared + +// MARK: - GuidedTourViewTests + +class GuidedTourViewTests: BitwardenTestCase { + // MARK: Properties + + var processor: MockProcessor! + var subject: GuidedTourView! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + processor = MockProcessor( + state: GuidedTourViewState(currentIndex: 0, guidedTourStepStates: [ + .loginStep1, + .loginStep2, + .loginStep3, + ]) + ) + let store = Store(processor: processor) + subject = GuidedTourView(store: store) + } + + override func tearDown() { + super.tearDown() + processor = nil + subject = nil + } + + // MARK: Tests + + /// Tap the `back` button should dispatch the `backTapped` action. + @MainActor + func test_backButton_tap() async throws { + processor.state.currentIndex = 1 + let button = try subject.inspect().find(button: Localizations.back) + try button.tap() + XCTAssertEqual(processor.dispatchedActions, [.backTapped]) + } + + /// Tapping the `done` button should dispatch the `doneTapped` action. + @MainActor + func test_doneButton_tap() async throws { + processor.state.currentIndex = 2 + let button = try subject.inspect().find(button: Localizations.done) + try button.tap() + XCTAssertEqual(processor.dispatchedActions, [.doneTapped]) + } + + /// Tapping the dismiss button dispatches the `.dismissTapped` action. + @MainActor + func test_dismissButton_tap() async throws { + processor.state.currentIndex = 2 + let button = try subject.inspect().find(button: Localizations.dismiss) + try button.tap() + XCTAssertEqual(processor.dispatchedActions, [.dismissTapped]) + } + + /// Tapping the `next` button should dispatch the `nextTapped` action. + @MainActor + func test_nextButton_tap() async throws { + let button = try subject.inspect().find(button: Localizations.next) + try button.tap() + XCTAssertEqual(processor.dispatchedActions, [.nextTapped]) + } + + /// Test the snapshot of the step 1 of the learn new login guided tour. + @MainActor + func test_snapshot_loginStep1() { + processor.state.currentIndex = 0 + processor.state.guidedTourStepStates[0].spotlightRegion = CGRect(x: 320, y: 470, width: 40, height: 40) + assertSnapshots( + of: subject, + as: [.defaultPortrait, .defaultPortraitDark] + ) + } + + /// Test the snapshot of the step 1 of the learn new login guided tour in landscape. + @MainActor + func test_snapshot_loginStep1_landscape() { + processor.state.currentIndex = 0 + processor.state.guidedTourStepStates[0].spotlightRegion = CGRect(x: 650, y: 150, width: 40, height: 40) + assertSnapshots( + of: subject, + as: [.defaultLandscape] + ) + } + + /// Test the snapshot of the step 2 of the learn new login guided tour. + @MainActor + func test_snapshot_loginStep2() { + processor.state.currentIndex = 1 + processor.state.guidedTourStepStates[1].spotlightRegion = CGRect(x: 40, y: 470, width: 320, height: 95) + assertSnapshots( + of: subject, + as: [.defaultPortrait, .defaultPortraitDark] + ) + } + + /// Test the snapshot of the step 2 of the learn new login guided tour in landscape. + @MainActor + func test_snapshot_loginStep2_landscape() { + processor.state.currentIndex = 1 + processor.state.guidedTourStepStates[1].spotlightRegion = CGRect(x: 40, y: 60, width: 460, height: 95) + assertSnapshots( + of: subject, + as: [.defaultLandscape] + ) + } + + /// Test the snapshot of the step 3 of the learn new login guided tour. + @MainActor + func test_snapshot_loginStep3() { + processor.state.currentIndex = 2 + processor.state.guidedTourStepStates[2].spotlightRegion = CGRect(x: 40, y: 500, width: 320, height: 90) + assertSnapshots( + of: subject, + as: [.defaultPortrait, .defaultPortraitDark] + ) + } + + /// Test the snapshot of the step 3 of the learn new login guided tour in landscape. + @MainActor + func test_snapshot_loginStep3_landscape() { + processor.state.currentIndex = 2 + processor.state.guidedTourStepStates[2].spotlightRegion = CGRect(x: 40, y: 60, width: 460, height: 90) + assertSnapshots( + of: subject, + as: [.defaultLandscape] + ) + } +} diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/Spotlight.swift b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/Spotlight.swift new file mode 100644 index 000000000..6b34e03ef --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/Spotlight.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// A shape that represents a spotlight effect over a view. +/// +struct Spotlight: Shape { + /// The region of the view to spotlight. + let spotlight: CGRect + + /// The shape of the spotlight. + let spotlightShape: SpotlightShape + + /// Creates a new `Spotlight` shape. + func path(in rect: CGRect) -> Path { + var path = Rectangle().path(in: rect) + let spotlightRect = spotlight + if case let .rectangle(cornerRadius) = spotlightShape { + path.addPath(RoundedRectangle(cornerRadius: cornerRadius).path(in: spotlightRect)) + } else { + path.addPath(Circle().path(in: spotlightRect)) + } + return path + } +} diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep1.1.png b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep1.1.png new file mode 100644 index 000000000..52b4c49aa Binary files /dev/null and b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep1.1.png differ diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep1.2.png b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep1.2.png new file mode 100644 index 000000000..a8592a824 Binary files /dev/null and b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep1.2.png differ diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep1_landscape.1.png b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep1_landscape.1.png new file mode 100644 index 000000000..3d71f03e3 Binary files /dev/null and b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep1_landscape.1.png differ diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep2.1.png b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep2.1.png new file mode 100644 index 000000000..4e3bcef2e Binary files /dev/null and b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep2.1.png differ diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep2.2.png b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep2.2.png new file mode 100644 index 000000000..c66ad2188 Binary files /dev/null and b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep2.2.png differ diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep2_landscape.1.png b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep2_landscape.1.png new file mode 100644 index 000000000..b0c9b4f05 Binary files /dev/null and b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep2_landscape.1.png differ diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep3.1.png b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep3.1.png new file mode 100644 index 000000000..832681b21 Binary files /dev/null and b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep3.1.png differ diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep3.2.png b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep3.2.png new file mode 100644 index 000000000..ca717ebb2 Binary files /dev/null and b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep3.2.png differ diff --git a/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep3_landscape.1.png b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep3_landscape.1.png new file mode 100644 index 000000000..b83bf590c Binary files /dev/null and b/BitwardenShared/UI/Platform/Application/Views/GuidedTourView/__Snapshots__/GuidedTourViewTests/test_snapshot_loginStep3_landscape.1.png differ diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemAction.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemAction.swift index e9f0afd06..9d568f610 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemAction.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemAction.swift @@ -1,6 +1,8 @@ // MARK: - AddEditItemAction import BitwardenSdk +import Foundation +import SwiftUI /// Actions that can be handled by an `AddEditItemProcessor`. enum AddEditItemAction: Equatable, Sendable { @@ -31,6 +33,9 @@ enum AddEditItemAction: Equatable, Sendable { /// The generate username button was pressed. case generateUsernamePressed + /// A forwarded action from the guided tour view. + case guidedTourViewAction(GuidedTourViewAction) + /// The identity field was changed. case identityFieldChanged(AddEditIdentityItemAction) diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift index 8472d1087..efcd61d59 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift @@ -147,9 +147,10 @@ final class AddEditItemProcessor: StateProcessor] { get set } + /// The state for guided tour view. + var guidedTourViewState: GuidedTourViewState { get set } + /// The state for a identity type item. var identityState: IdentityItemState { get set } @@ -104,3 +107,27 @@ protocol AddEditItemState: Sendable { /// mutating func toggleCollection(newValue: Bool, collectionId: String) } + +/// extension for `GuidedTourStepState` to provide states for learn new login guided tour. +extension GuidedTourStepState { + /// The first step of the learn new login guided tour. + static let loginStep1 = GuidedTourStepState( + arrowHorizontalPosition: .center, + spotlightShape: .circle, + title: Localizations.useThisButtonToGenerateANewUniquePassword + ) + + /// The second step of the learn new login guided tour. + static let loginStep2 = GuidedTourStepState( + arrowHorizontalPosition: .center, + spotlightShape: .rectangle(cornerRadius: 8), + title: Localizations.youWillOnlyNeedToSetUpAnAuthenticatorKeyDescriptionLong + ) + + /// The third step of the learn new login guided tour. + static let loginStep3 = GuidedTourStepState( + arrowHorizontalPosition: .center, + spotlightShape: .rectangle(cornerRadius: 8), + title: Localizations.youMustAddAWebAddressToUseAutofillToAccessThisAccount + ) +} diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView.swift index 13d510fb2..61d8e8848 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemView.swift @@ -58,7 +58,13 @@ struct AddEditItemView: View { } private var content: some View { - ScrollView { + GuidedTourScrollView( + store: store.child( + state: \.guidedTourViewState, + mapAction: AddEditItemAction.guidedTourViewAction, + mapEffect: nil + ) + ) { VStack(spacing: 20) { if isPolicyEnabled { InfoContainer(Localizations.personalOwnershipPolicyInEffect) @@ -208,7 +214,16 @@ struct AddEditItemView: View { }, mapAction: { $0 }, mapEffect: { $0 } - ) + ), + didRenderFrame: { step, frame in + let enlargedFrame = frame.enlarged(by: 8) + store.send( + .guidedTourViewAction(.didRenderViewToSpotlight( + frame: enlargedFrame, + step: step + )) + ) + } ) } diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/AddEditLoginItemView.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/AddEditLoginItemView.swift index cf4ed2d8d..7e441e5ef 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/AddEditLoginItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/AddEditLoginItemView.swift @@ -22,6 +22,9 @@ struct AddEditLoginItemView: View { /// The `Store` for this view. @ObservedObject var store: Store + /// The closure to call when the fields are rendered for the guided tour. + var didRenderFrame: ((GuidedTourStep, CGRect) -> Void)? + // MARK: View var body: some View { @@ -32,6 +35,9 @@ struct AddEditLoginItemView: View { fidoField totpView + .guidedTourStep(.step2) { frame in + didRenderFrame?(.step2, frame) + } uriSection } @@ -85,6 +91,9 @@ struct AddEditLoginItemView: View { AccessoryButton(asset: Asset.Images.generate24, accessibilityLabel: Localizations.generatePassword) { store.send(.generatePasswordPressed) } + .guidedTourStep(.step1) { frame in + didRenderFrame?(.step1, frame) + } .accessibilityIdentifier("RegeneratePasswordButton") } } @@ -194,6 +203,9 @@ struct AddEditLoginItemView: View { } .textFieldConfiguration(.url) } + .guidedTourStep(.step3) { frame in + didRenderFrame?(.step3, frame) + } Button(Localizations.newUri) { withAnimation { diff --git a/BitwardenShared/UI/Vault/VaultItem/CipherItemState.swift b/BitwardenShared/UI/Vault/VaultItem/CipherItemState.swift index 96cfabbac..dca6aecb6 100644 --- a/BitwardenShared/UI/Vault/VaultItem/CipherItemState.swift +++ b/BitwardenShared/UI/Vault/VaultItem/CipherItemState.swift @@ -58,6 +58,15 @@ struct CipherItemState: Equatable { /// The list of all folders that the item could be added to. var folders: [DefaultableType] + /// The state for guided tour view. + var guidedTourViewState = GuidedTourViewState( + guidedTourStepStates: [ + .loginStep1, + .loginStep2, + .loginStep3, + ] + ) + /// The state for a identity type item. var identityState: IdentityItemState