Skip to content

Commit c205ed8

Browse files
PM-10051: Add the intro carousel with the first content page (#780)
1 parent daa0088 commit c205ed8

20 files changed

+446
-0
lines changed

BitwardenShared/UI/Auth/AuthCoordinator.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
129129
showDuo2FA(authURL: authURL, delegate: context as? DuoAuthenticationFlowDelegate)
130130
case let .enterpriseSingleSignOn(email):
131131
showEnterpriseSingleSignOn(email: email)
132+
case .introCarousel:
133+
showIntroCarousel()
132134
case .landing:
133135
showLanding()
134136
case let .login(username):
@@ -317,6 +319,18 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
317319
stackNavigator?.present(navigationController)
318320
}
319321

322+
/// Shows the intro carousel screen.
323+
///
324+
private func showIntroCarousel() {
325+
let processor = IntroCarouselProcessor(
326+
coordinator: asAnyCoordinator(),
327+
state: IntroCarouselState()
328+
)
329+
let view = IntroCarouselView(store: Store(processor: processor))
330+
stackNavigator?.setNavigationBarHidden(true, animated: false)
331+
stackNavigator?.replace(view, animated: false)
332+
}
333+
320334
/// Shows the landing screen.
321335
///
322336
private func showLanding() {
@@ -329,6 +343,7 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt
329343
)
330344
let store = Store(processor: processor)
331345
let view = LandingView(store: store)
346+
stackNavigator.setNavigationBarHidden(false, animated: false)
332347
stackNavigator.replace(view, animated: false)
333348
}
334349
}

BitwardenShared/UI/Auth/AuthCoordinatorTests.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,15 @@ class AuthCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this type_b
119119
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<SingleSignOnView>)
120120
}
121121

122+
/// `navigate(to:)` with `.introCarousel` replaces the navigation stack with the intro carousel.
123+
func test_navigate_introCarousel() {
124+
subject.navigate(to: .introCarousel)
125+
126+
XCTAssertTrue(stackNavigator.actions.last?.view is IntroCarouselView)
127+
XCTAssertEqual(stackNavigator.actions.last?.type, .replaced)
128+
XCTAssertTrue(stackNavigator.isNavigationBarHidden)
129+
}
130+
122131
/// `navigate(to:)` with `.landing` pushes the landing view onto the stack navigator.
123132
func test_navigate_landing() {
124133
subject.navigate(to: .landing)

BitwardenShared/UI/Auth/AuthRoute.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ public enum AuthRoute: Equatable {
2929
///
3030
case enterpriseSingleSignOn(email: String)
3131

32+
/// A route to the intro carousel.
33+
case introCarousel
34+
3235
/// A route to the landing screen.
3336
case landing
3437

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// MARK: - IntroCarouselAction
2+
3+
/// Actions that can be processed by a `IntroCarouselProcessor`.
4+
///
5+
enum IntroCarouselAction: Equatable {
6+
/// The create account button was tapped.
7+
case createAccount
8+
9+
/// The log in button was tapped.
10+
case logIn
11+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import Combine
2+
import SwiftUI
3+
4+
// MARK: - IntroCarouselProcessor
5+
6+
/// The processor used to manage state and handle actions for the intro carousel screen.
7+
///
8+
class IntroCarouselProcessor: StateProcessor<IntroCarouselState, IntroCarouselAction, Void> {
9+
// MARK: Private Properties
10+
11+
/// The coordinator that handles navigation.
12+
private let coordinator: AnyCoordinator<AuthRoute, AuthEvent>
13+
14+
// MARK: Initialization
15+
16+
/// Creates a new `IntroCarouselProcessor`.
17+
///
18+
/// - Parameters:
19+
/// - coordinator: The coordinator that handles navigation.
20+
/// - services: The services required by this processor.
21+
/// - state: The initial state of the processor.
22+
///
23+
init(
24+
coordinator: AnyCoordinator<AuthRoute, AuthEvent>,
25+
state: IntroCarouselState
26+
) {
27+
self.coordinator = coordinator
28+
super.init(state: state)
29+
}
30+
31+
// MARK: Methods
32+
33+
override func receive(_ action: IntroCarouselAction) {
34+
switch action {
35+
case .createAccount:
36+
coordinator.navigate(to: .createAccount)
37+
case .logIn:
38+
coordinator.navigate(to: .landing)
39+
}
40+
}
41+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import XCTest
2+
3+
@testable import BitwardenShared
4+
5+
class IntroCarouselProcessorTests: BitwardenTestCase {
6+
// MARK: Properties
7+
8+
var coordinator: MockCoordinator<AuthRoute, AuthEvent>!
9+
var subject: IntroCarouselProcessor!
10+
11+
// MARK: Setup & Teardown
12+
13+
override func setUp() {
14+
super.setUp()
15+
16+
coordinator = MockCoordinator()
17+
18+
subject = IntroCarouselProcessor(
19+
coordinator: coordinator.asAnyCoordinator(),
20+
state: IntroCarouselState()
21+
)
22+
}
23+
24+
override func tearDown() {
25+
super.tearDown()
26+
27+
coordinator = nil
28+
subject = nil
29+
}
30+
31+
// MARK: Tests
32+
33+
/// `receive(_:)` with `.createAccount` navigates to the create account view.
34+
func test_receive_createAccount() {
35+
subject.receive(.createAccount)
36+
XCTAssertEqual(coordinator.routes.last, .createAccount)
37+
}
38+
39+
/// `receive(_:)` with `.logIn` navigates to the landing view.
40+
func test_receive_logIn() {
41+
subject.receive(.logIn)
42+
XCTAssertEqual(coordinator.routes.last, .landing)
43+
}
44+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import SwiftUI
2+
3+
// MARK: - IntroCarouselState
4+
5+
/// An object that defines the current state of a `IntroCarouselView`.
6+
///
7+
struct IntroCarouselState: Equatable {
8+
// MARK: Types
9+
10+
/// A model representing the data to display on a single page in the carousel.
11+
///
12+
struct CarouselPage: Equatable, Identifiable {
13+
// MARK:
14+
15+
/// A unique identifier of the page.
16+
let id: String = UUID().uuidString
17+
18+
/// An image to display.
19+
let image: Image
20+
21+
/// A message to display on the page.
22+
let message: String
23+
24+
/// A title to display on the page.
25+
let title: String
26+
}
27+
28+
// MARK: Properties
29+
30+
/// The list of scrollable pages displayed in the carousel.
31+
let pages: [CarouselPage] = [
32+
CarouselPage(
33+
image: Asset.Images.partnership.swiftUIImage,
34+
message: Localizations.introCarouselPage1Message,
35+
title: Localizations.introCarouselPage1Title
36+
),
37+
]
38+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import SwiftUI
2+
3+
// MARK: - IntroCarouselView
4+
5+
/// A view that allows the user to swipe through the intro carousel and then proceed to creating an
6+
/// account or logging in.
7+
///
8+
struct IntroCarouselView: View {
9+
// MARK: Properties
10+
11+
/// An environment variable for getting the vertical size class of the view.
12+
@Environment(\.verticalSizeClass) var verticalSizeClass
13+
14+
/// The `Store` for this view.
15+
@ObservedObject var store: Store<IntroCarouselState, IntroCarouselAction, Void>
16+
17+
/// The index of the currently visible page in the carousel.
18+
@SwiftUI.State private var tabSelection = 0
19+
20+
// MARK: View
21+
22+
var body: some View {
23+
VStack(spacing: 0) {
24+
TabView(selection: $tabSelection.animation()) {
25+
ForEachIndexed(store.state.pages) { index, page in
26+
pageView(page)
27+
.tag(index)
28+
}
29+
}
30+
.tabViewStyle(.page(indexDisplayMode: .never))
31+
32+
HStack(spacing: 8) {
33+
ForEachIndexed(store.state.pages) { index, _ in
34+
Image(systemName: "circle.fill")
35+
.resizable()
36+
.frame(width: 8, height: 8)
37+
.foregroundStyle(
38+
tabSelection == index ?
39+
Asset.Colors.textPrimary.swiftUIColor :
40+
Asset.Colors.textPrimary.swiftUIColor.opacity(0.3)
41+
)
42+
}
43+
}
44+
.padding(16)
45+
46+
VStack(spacing: 12) {
47+
Button(Localizations.createAccount) {
48+
store.send(.createAccount)
49+
}
50+
.buttonStyle(.primary())
51+
52+
Button(Localizations.logIn) {
53+
store.send(.logIn)
54+
}
55+
.buttonStyle(.transparent)
56+
}
57+
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
58+
.padding(.horizontal, 16)
59+
.padding(.vertical, 12)
60+
}
61+
.frame(maxWidth: .infinity, maxHeight: .infinity)
62+
.background(Asset.Colors.backgroundSecondary.swiftUIColor.ignoresSafeArea())
63+
.foregroundStyle(Asset.Colors.textPrimary.swiftUIColor)
64+
.multilineTextAlignment(.center)
65+
}
66+
67+
/// A dynamic stack view that lays out content vertically when in a regular vertical size class
68+
/// and horizontally for the compact vertical size class.
69+
@ViewBuilder
70+
private func dynamicStackView(
71+
minHeight: CGFloat,
72+
@ViewBuilder imageContent: () -> some View,
73+
@ViewBuilder textContent: () -> some View
74+
) -> some View {
75+
if verticalSizeClass == .regular {
76+
VStack(spacing: 80) {
77+
imageContent()
78+
textContent()
79+
}
80+
.padding(.vertical, 16)
81+
.frame(maxWidth: .infinity, minHeight: minHeight)
82+
.scrollView(addVerticalPadding: false)
83+
} else {
84+
HStack(alignment: .top, spacing: 40) {
85+
VStack(spacing: 0) {
86+
Spacer(minLength: 0)
87+
imageContent()
88+
.padding(.leading, 36)
89+
.padding(.vertical, 16)
90+
Spacer(minLength: 0)
91+
}
92+
.frame(minHeight: minHeight)
93+
94+
textContent()
95+
.padding(.vertical, 16)
96+
.frame(maxWidth: .infinity, minHeight: minHeight)
97+
.scrollView(addVerticalPadding: false)
98+
}
99+
}
100+
}
101+
102+
/// A view that displays a carousel page.
103+
@ViewBuilder
104+
private func pageView(_ page: IntroCarouselState.CarouselPage) -> some View {
105+
GeometryReader { reader in
106+
dynamicStackView(minHeight: reader.size.height) {
107+
page.image
108+
.resizable()
109+
.frame(
110+
width: verticalSizeClass == .regular ? 200 : 132,
111+
height: verticalSizeClass == .regular ? 200 : 132
112+
)
113+
.accessibilityHidden(true)
114+
} textContent: {
115+
VStack(spacing: 16) {
116+
Text(page.title)
117+
.styleGuide(.title, weight: .bold)
118+
119+
Text(page.message)
120+
.styleGuide(.title3)
121+
}
122+
}
123+
}
124+
}
125+
}
126+
127+
// MARK: - Previews
128+
129+
#if DEBUG
130+
#Preview("Carousel") {
131+
IntroCarouselView(store: Store(processor: StateProcessor(state: IntroCarouselState())))
132+
}
133+
134+
@available(iOS 17, *)
135+
#Preview("Carousel Landscape", traits: .landscapeRight) {
136+
IntroCarouselView(store: Store(processor: StateProcessor(state: IntroCarouselState())))
137+
}
138+
#endif
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import SnapshotTesting
2+
import ViewInspector
3+
import XCTest
4+
5+
@testable import BitwardenShared
6+
7+
class IntroCarouselViewTests: BitwardenTestCase {
8+
// MARK: Properties
9+
10+
var processor: MockProcessor<IntroCarouselState, IntroCarouselAction, Void>!
11+
var subject: IntroCarouselView!
12+
13+
// MARK: Setup & Teardown
14+
15+
override func setUp() {
16+
super.setUp()
17+
18+
processor = MockProcessor(state: IntroCarouselState())
19+
20+
subject = IntroCarouselView(store: Store(processor: processor))
21+
}
22+
23+
override func tearDown() {
24+
super.tearDown()
25+
26+
processor = nil
27+
subject = nil
28+
}
29+
30+
// MARK: Tests
31+
32+
/// Tapping the create account button dispatches the create account action.
33+
func test_createAccount_tap() throws {
34+
let button = try subject.inspect().find(button: Localizations.createAccount)
35+
try button.tap()
36+
XCTAssertEqual(processor.dispatchedActions.last, .createAccount)
37+
}
38+
39+
/// Tapping the log in button dispatches the login action.
40+
func test_login_tap() throws {
41+
let button = try subject.inspect().find(button: Localizations.logIn)
42+
try button.tap()
43+
XCTAssertEqual(processor.dispatchedActions.last, .logIn)
44+
}
45+
46+
// MARK: Snapshots
47+
48+
/// The intro carousel renders correctly.
49+
func test_snapshot_introCarousel() {
50+
assertSnapshots(
51+
of: subject.navStackWrapped,
52+
as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5, .defaultLandscape]
53+
)
54+
}
55+
}

0 commit comments

Comments
 (0)