Skip to content

Commit

Permalink
PM-16142: Guided Tour for Generator screen (#1301)
Browse files Browse the repository at this point in the history
  • Loading branch information
ezimet-livefront authored Feb 3, 2025
1 parent f5c0874 commit a29f941
Show file tree
Hide file tree
Showing 37 changed files with 746 additions and 206 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import SwiftUI

/// A custom view modifier to apply smooth transition effects and animations.
///
struct SmoothTransitionModifier<V: Equatable>: ViewModifier {
/// The animation to apply to the view.
let animation: Animation

/// The value that triggers the animation.
let value: V

func body(content: Content) -> some View {
content
.animation(animation, value: value)
.modifier(SmoothTransitionEffect())
}
}

/// A custom geometry effect that applies a smooth transition effect to a view.
/// This effect allows for animating the translation of a view along the X and Y axes.
struct SmoothTransitionEffect: GeometryEffect {
/// The animatable data representing the translation values for the X and Y axes.
var animatableData: AnimatablePair<CGFloat, CGFloat>

/// Initializes a new instance of the `SmoothTransitionEffect` with default translation values.
init() {
animatableData = AnimatablePair(0, 0)
}

/// Computes the projection transform for the given size, applying the translation effect.
///
/// - Parameter size: The size of the view to which the effect is applied.
/// - Returns: A `ProjectionTransform` representing the translation transformation.
func effectValue(size: CGSize) -> ProjectionTransform {
let translation = CGAffineTransform(translationX: animatableData.first, y: animatableData.second)
return ProjectionTransform(translation)
}
}

// MARK: - Extensions

extension View {
/// A view modifier that applies a smooth transition effect to the view.
///
func smoothTransition<V: Equatable>(animation: Animation, value: V) -> some View {
modifier(SmoothTransitionModifier(animation: animation, value: value))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ extension View {
/// - 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
onFrameChanged(id: step.id) { origin, size in
perform(CGRect(origin: origin, size: size))
}
.id(step)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,33 @@ extension View {

/// 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.
/// - Parameters:
/// - id: A unique identifier for the view. This is necessary to distinguish between multiple
/// views that might be using the same modifier, ensuring that the correct view's changes
/// are tracked and handled.
/// - perform: A closure called when the size or origin of the view changes. The closure receives
/// the new size and origin of the view as parameters.
/// - Returns: A copy of the view with the sizing and origin modifier applied.
///
func onFrameChanged(perform: @escaping (CGPoint, CGSize) -> Void) -> some View {
func onFrameChanged(id: String, 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
)
value: [
id: CGRect(
origin: geometry.frame(in: .global).origin,
size: geometry.size
),
]
)
}
)
.onPreferenceChange(ViewFrameKey.self) { value in
perform(value.origin, value.size)
if let frame = value[id] {
perform(frame.origin, frame.size)
}
}
}
}
Expand All @@ -52,12 +61,15 @@ private struct ViewSizeKey: PreferenceKey {

/// A `PreferenceKey` used to calculate the size and origin of a view.
///
private struct ViewFrameKey: PreferenceKey {
static var defaultValue = CGRect.zero
/// The `ViewFrameKey` stores a dictionary that maps a view's identifier (as a `String`)
/// to the last received frame (`CGRect`) for that view. This allows tracking the size
/// and position of views within a SwiftUI hierarchy.
///
struct ViewFrameKey: PreferenceKey {
static var defaultValue: [String: CGRect] = [:]

static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
if nextValue() != defaultValue {
value = nextValue()
}
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
let newValue = nextValue().filter { $0.value.size != .zero }
value.merge(newValue) { _, new in new }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@
"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.";
"UseTheGeneratorToCreateASecurePasswordPassphrasesAndUsernames" = "Use the generator to create secure passwords, passphrases and usernames.";
"PassphrasesAreOftenEasierToRememberDescriptionLong" = "Passphrases are often easier to remember and type than random passwords. They can be helpful when you need to log into accounts where autofill is not available, like a streaming service on your TV.";
"UniqueUsernamesAddAnExtraLayerOfSecurityAndCanHelpPreventHackersFromFindingYourAccounts" = "Unique usernames add an extra layer of security and can help prevent hackers from finding your accounts.";
"UseTheseOptionsToAdjustYourPasswordToMeetYourAccountWebsitesRequirements" = "Use these options to adjust your password to meet your account website’s requirements.";
"AfterYouSaveYourNewPasswordToBitwardenDontForgetToUpdateItOnYourAccountWebsite" = "After you save your new password to Bitwarden, don’t forget to update it on your account website.";
"YouMustAddAWebAddressToUseAutofillToAccessThisAccount" = "You must add a web address to use autofill to access this account.";
"Username" = "Username";
"ValidationFieldRequired" = "The %1$@ field is required.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,9 @@ enum GuidedTourStep: Int, Equatable {

/// The sixth step of the guided tour.
case step6

/// The identifier of the step.
var id: String {
"\(rawValue)"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// swiftlint:disable:this file_name
import SnapshotTesting
import SwiftUI
import ViewInspector
import XCTest

@testable import BitwardenShared

// MARK: - GuidedTourView+GeneratorTests

class GuidedTourViewGeneratorTests: BitwardenTestCase {
// MARK: Properties

var processor: MockProcessor<GuidedTourViewState, GuidedTourViewAction, Void>!
var subject: GuidedTourView!

// MARK: Setup & Teardown

override func setUp() {
super.setUp()
processor = MockProcessor(
state: GuidedTourViewState(currentIndex: 0, guidedTourStepStates: [
.generatorStep1,
.generatorStep2,
.generatorStep3,
.generatorStep4,
.generatorStep5,
.generatorStep6,
])
)
let store = Store(processor: processor)
subject = GuidedTourView(store: store)
}

override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}

// MARK: Snapshot tests

/// Test the snapshot of the step 1 of the learn generator guided tour.
@MainActor
func test_snapshot_generatorStep1() {
processor.state.currentIndex = 0
processor.state.guidedTourStepStates[0].spotlightRegion = CGRect(x: 25, y: 80, width: 340, height: 40)
assertSnapshots(
of: subject,
as: [.defaultPortrait]
)
}

/// Test the snapshot of the step 2 of the learn generator guided tour.
@MainActor
func test_snapshot_generatorStep2() {
processor.state.currentIndex = 1
processor.state.guidedTourStepStates[1].spotlightRegion = CGRect(x: 25, y: 80, width: 340, height: 40)
assertSnapshots(
of: subject,
as: [.defaultPortrait]
)
}

/// Test the snapshot of the step 3 of the learn generator guided tour.
@MainActor
func test_snapshot_generatorStep3() {
processor.state.currentIndex = 2
processor.state.guidedTourStepStates[2].spotlightRegion = CGRect(x: 25, y: 80, width: 340, height: 40)
assertSnapshots(
of: subject,
as: [.defaultPortrait]
)
}

/// Test the snapshot of the step 4 of the learn generator guided tour.
@MainActor
func test_snapshot_generatorStep4() {
processor.state.currentIndex = 3
processor.state.guidedTourStepStates[3].spotlightRegion = CGRect(x: 25, y: 300, width: 340, height: 400)
assertSnapshots(
of: subject,
as: [.defaultPortrait]
)
}

/// Test the snapshot of the step 5 of the learn generator guided tour.
@MainActor
func test_snapshot_generatorStep5() {
processor.state.currentIndex = 4
processor.state.guidedTourStepStates[4].spotlightRegion = CGRect(x: 300, y: 160, width: 40, height: 40)
assertSnapshots(
of: subject,
as: [.defaultPortrait]
)
}

/// Test the snapshot of the step 6 of the learn generator guided tour.
@MainActor
func test_snapshot_generatorStep6() {
processor.state.currentIndex = 5
processor.state.guidedTourStepStates[5].spotlightRegion = CGRect(x: 25, y: 160, width: 340, height: 60)
assertSnapshots(
of: subject,
as: [.defaultPortrait]
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// swiftlint:disable:this file_name
import SnapshotTesting
import SwiftUI
import ViewInspector
import XCTest

@testable import BitwardenShared

// MARK: - GuidedTourView+LoginTests

class GuidedTourViewLoginTests: BitwardenTestCase {
// MARK: Properties

var processor: MockProcessor<GuidedTourViewState, GuidedTourViewAction, Void>!
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])
}

// MARK: Snapshot tests

/// 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]
)
}
}
Loading

0 comments on commit a29f941

Please sign in to comment.