Skip to content

Commit b8161ee

Browse files
[PM-26063] Add reusable component for the Flight Recorder settings view (#2141)
1 parent 0e29918 commit b8161ee

20 files changed

+406
-125
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// MARK: - FlightRecorderSettingsSectionAction
2+
3+
/// Actions handled by the Flight Recorder settings section component.
4+
///
5+
/// This is a reusable component that can be integrated into any processor that displays Flight
6+
/// Recorder settings.
7+
///
8+
public enum FlightRecorderSettingsSectionAction: Equatable {
9+
/// The view Flight Recorder logs button was tapped.
10+
case viewLogsTapped
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// MARK: - FlightRecorderSettingsSectionEffect
2+
3+
/// Effects handled by the Flight Recorder settings section component.
4+
///
5+
/// This is a reusable component that can be integrated into any processor that displays Flight
6+
/// Recorder settings.
7+
///
8+
public enum FlightRecorderSettingsSectionEffect: Equatable {
9+
/// The Flight Recorder toggle value changed.
10+
case toggleFlightRecorder(Bool)
11+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import BitwardenResources
2+
import Foundation
3+
4+
// MARK: - FlightRecorderSettingsSectionState
5+
6+
/// The state for the Flight Recorder settings section component.
7+
///
8+
/// This is a reusable component that can be integrated into any processor that displays Flight
9+
/// Recorder settings.
10+
///
11+
public struct FlightRecorderSettingsSectionState: Equatable {
12+
// MARK: Properties
13+
14+
/// The Flight Recorder's active log metadata, if logging is enabled.
15+
public var activeLog: FlightRecorderData.LogMetadata?
16+
17+
// MARK: Computed Properties
18+
19+
/// The accessibility label for the Flight Recorder toggle.
20+
var flightRecorderToggleAccessibilityLabel: String {
21+
var accessibilityLabelComponents = [Localizations.flightRecorder]
22+
if let log = activeLog {
23+
// VoiceOver doesn't read the short date style correctly so use the medium style instead.
24+
let dateFormatter = DateFormatter()
25+
dateFormatter.dateStyle = .medium
26+
27+
accessibilityLabelComponents.append(Localizations.loggingEndsOnDateAtTime(
28+
dateFormatter.string(from: log.endDate),
29+
log.formattedEndTime,
30+
))
31+
}
32+
return accessibilityLabelComponents.joined(separator: ", ")
33+
}
34+
35+
// MARK: Initialization
36+
37+
/// Creates a new `FlightRecorderState`.
38+
///
39+
/// - Parameter activeLog: The Flight Recorder's active log metadata, if logging is enabled.
40+
///
41+
public init(activeLog: FlightRecorderData.LogMetadata? = nil) {
42+
self.activeLog = activeLog
43+
}
44+
}

BitwardenShared/UI/Platform/Settings/Settings/About/AboutStateTests.swift renamed to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionStateTests.swift

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
1-
import BitwardenKit
21
import BitwardenResources
32
import XCTest
43

5-
@testable import BitwardenShared
4+
@testable import BitwardenKit
65

7-
class AboutStateTests: BitwardenTestCase {
6+
class FlightRecorderSettingsSectionStateTests: BitwardenTestCase {
87
/// `flightRecorderToggleAccessibilityLabel` returns the flight recorder toggle's accessibility
98
/// label when the flight recorder is off.
109
func test_flightRecorderToggleAccessibilityLabel_flightRecorderOff() {
11-
let subject = AboutState(flightRecorderActiveLog: nil)
10+
let subject = FlightRecorderSettingsSectionState()
1211
XCTAssertEqual(subject.flightRecorderToggleAccessibilityLabel, Localizations.flightRecorder)
1312
}
1413

1514
/// `flightRecorderToggleAccessibilityLabel` returns the flight recorder toggle's accessibility
1615
/// label when the flight recorder is on.
1716
func test_flightRecorderToggleAccessibilityLabel_flightRecorderOn() {
18-
let subject = AboutState(
19-
flightRecorderActiveLog: FlightRecorderData.LogMetadata(
17+
let subject = FlightRecorderSettingsSectionState(
18+
activeLog: FlightRecorderData.LogMetadata(
2019
duration: .eightHours,
2120
startDate: Date(year: 2025, month: 5, day: 1),
2221
),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// swiftlint:disable:this file_name
2+
import BitwardenKitMocks
3+
import BitwardenResources
4+
import SnapshotTesting
5+
import SwiftUI
6+
import XCTest
7+
8+
@testable import BitwardenKit
9+
10+
class FlightRecorderSettingsSectionViewSnapshotTests: BitwardenTestCase {
11+
// MARK: Properties
12+
13+
var processor: MockProcessor<
14+
FlightRecorderSettingsSectionState,
15+
FlightRecorderSettingsSectionAction,
16+
FlightRecorderSettingsSectionEffect,
17+
>!
18+
var subject: FlightRecorderSettingsSectionView!
19+
20+
// MARK: Computed Properties
21+
22+
/// Returns the subject view wrapped with padding and background for snapshot testing.
23+
var snapshotView: some View {
24+
ZStack(alignment: .top) {
25+
SharedAsset.Colors.backgroundPrimary.swiftUIColor.ignoresSafeArea()
26+
27+
subject
28+
.padding()
29+
}
30+
}
31+
32+
// MARK: Setup & Teardown
33+
34+
override func setUp() {
35+
super.setUp()
36+
37+
processor = MockProcessor(state: FlightRecorderSettingsSectionState())
38+
let store = Store(processor: processor)
39+
40+
subject = FlightRecorderSettingsSectionView(store: store)
41+
}
42+
43+
override func tearDown() {
44+
super.tearDown()
45+
46+
processor = nil
47+
subject = nil
48+
}
49+
50+
// MARK: Snapshots
51+
52+
/// The flight recorder settings section view renders correctly when disabled.
53+
@MainActor
54+
func disabletest_snapshot_flightRecorderSettingsSection_disabled() {
55+
assertSnapshots(
56+
of: snapshotView,
57+
as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5],
58+
)
59+
}
60+
61+
/// The flight recorder settings section view renders correctly when enabled.
62+
@MainActor
63+
func disabletest_snapshot_flightRecorderSettingsSection_enabled() {
64+
processor.state.activeLog = FlightRecorderData.LogMetadata(
65+
duration: .eightHours,
66+
startDate: Date(year: 2025, month: 5, day: 1, hour: 8),
67+
)
68+
assertSnapshots(
69+
of: snapshotView,
70+
as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5],
71+
)
72+
}
73+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// swiftlint:disable:this file_name
2+
import BitwardenKitMocks
3+
import BitwardenResources
4+
import TestHelpers
5+
import ViewInspector
6+
import XCTest
7+
8+
@testable import BitwardenKit
9+
10+
class FlightRecorderSettingsSectionViewTests: BitwardenTestCase {
11+
// MARK: Properties
12+
13+
var processor: MockProcessor<
14+
FlightRecorderSettingsSectionState,
15+
FlightRecorderSettingsSectionAction,
16+
FlightRecorderSettingsSectionEffect,
17+
>!
18+
var subject: FlightRecorderSettingsSectionView!
19+
20+
// MARK: Setup & Teardown
21+
22+
override func setUp() {
23+
super.setUp()
24+
25+
processor = MockProcessor(state: FlightRecorderSettingsSectionState())
26+
let store = Store(processor: processor)
27+
28+
subject = FlightRecorderSettingsSectionView(store: store)
29+
}
30+
31+
override func tearDown() {
32+
super.tearDown()
33+
34+
processor = nil
35+
subject = nil
36+
}
37+
38+
// MARK: Tests
39+
40+
/// Toggling the Flight Recorder toggle on dispatches the `.toggleFlightRecorder(true)` effect.
41+
@MainActor
42+
func test_toggle_on() async throws {
43+
let toggle = try subject.inspect().find(toggleWithAccessibilityLabel: Localizations.flightRecorder)
44+
try toggle.tap()
45+
try await waitForAsync { !self.processor.effects.isEmpty }
46+
XCTAssertEqual(processor.effects.last, .toggleFlightRecorder(true))
47+
}
48+
49+
/// Toggling the Flight Recorder toggle off dispatches the `.toggleFlightRecorder(false)` effect.
50+
@MainActor
51+
func test_toggle_off() async throws {
52+
let toggle = try subject.inspect().find(toggleWithAccessibilityLabel: Localizations.flightRecorder)
53+
processor.state.activeLog = FlightRecorderData.LogMetadata(
54+
duration: .eightHours,
55+
startDate: Date(year: 2025, month: 5, day: 1),
56+
)
57+
try toggle.tap()
58+
try await waitForAsync { !self.processor.effects.isEmpty }
59+
XCTAssertEqual(processor.effects.last, .toggleFlightRecorder(false))
60+
}
61+
62+
/// Tapping the view recorded logs button dispatches the `.viewLogsTapped` action.
63+
@MainActor
64+
func test_viewLogsButton_tap() throws {
65+
let button = try subject.inspect().find(button: Localizations.viewRecordedLogs)
66+
try button.tap()
67+
XCTAssertEqual(processor.dispatchedActions.last, .viewLogsTapped)
68+
}
69+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import BitwardenResources
2+
import SwiftUI
3+
4+
// MARK: - FlightRecorderSettingsSectionView
5+
6+
/// A reusable view component that displays Flight Recorder settings.
7+
///
8+
/// This view provides a toggle to enable/disable flight recording and displays the active log's
9+
/// end date and time when logging is enabled. It also includes a button to view recorded logs
10+
/// and a help link for more information about the Flight Recorder feature.
11+
///
12+
/// This component can be integrated into any settings view that needs to display Flight Recorder
13+
/// settings.
14+
///
15+
public struct FlightRecorderSettingsSectionView: View {
16+
// MARK: Types
17+
18+
public typealias FlightRecorderSettingsSectionStore = Store<
19+
FlightRecorderSettingsSectionState,
20+
FlightRecorderSettingsSectionAction,
21+
FlightRecorderSettingsSectionEffect,
22+
>
23+
24+
// MARK: Properties
25+
26+
/// An object used to open urls from this view.
27+
@Environment(\.openURL) private var openURL
28+
29+
/// The `Store` for this view.
30+
@ObservedObject var store: FlightRecorderSettingsSectionStore
31+
32+
// MARK: View
33+
34+
public var body: some View {
35+
ContentBlock(dividerLeadingPadding: 16) {
36+
BitwardenToggle(
37+
isOn: store.bindingAsync(
38+
get: { $0.activeLog != nil },
39+
perform: FlightRecorderSettingsSectionEffect.toggleFlightRecorder,
40+
),
41+
accessibilityIdentifier: "FlightRecorderSwitch",
42+
accessibilityLabel: store.state.flightRecorderToggleAccessibilityLabel,
43+
) {
44+
VStack(alignment: .leading, spacing: 2) {
45+
HStack(spacing: 8) {
46+
Text(Localizations.flightRecorder)
47+
48+
Button {
49+
openURL(ExternalLinksConstants.flightRecorderHelp)
50+
} label: {
51+
SharedAsset.Icons.questionCircle16.swiftUIImage
52+
.scaledFrame(width: 16, height: 16)
53+
.accessibilityLabel(Localizations.learnMore)
54+
}
55+
.buttonStyle(.fieldLabelIcon)
56+
}
57+
58+
if let log = store.state.activeLog {
59+
Text(Localizations.loggingEndsOnDateAtTime(log.formattedEndDate, log.formattedEndTime))
60+
.foregroundStyle(SharedAsset.Colors.textSecondary.swiftUIColor)
61+
.styleGuide(.subheadline)
62+
}
63+
}
64+
}
65+
66+
SettingsListItem(Localizations.viewRecordedLogs) {
67+
store.send(.viewLogsTapped)
68+
}
69+
}
70+
}
71+
72+
// MARK: Initialization
73+
74+
/// Creates a new `FlightRecorderSettingsSectionView`.
75+
///
76+
/// - Parameter store: The `Store` for managing the Flight Recorder settings section state,
77+
/// actions, and effects.
78+
///
79+
public init(store: FlightRecorderSettingsSectionStore) {
80+
self.store = store
81+
}
82+
}
83+
84+
// MARK: - Previews
85+
86+
#if DEBUG
87+
#Preview("Disabled") {
88+
FlightRecorderSettingsSectionView(
89+
store: Store(processor: StateProcessor(state: FlightRecorderSettingsSectionState())),
90+
)
91+
.padding()
92+
.background(SharedAsset.Colors.backgroundPrimary.swiftUIColor)
93+
}
94+
95+
#Preview("Enabled") {
96+
FlightRecorderSettingsSectionView(
97+
store: Store(processor: StateProcessor(state: FlightRecorderSettingsSectionState(
98+
activeLog: FlightRecorderData.LogMetadata(
99+
duration: .eightHours,
100+
startDate: Date(timeIntervalSinceNow: 60 * 60 * -4),
101+
),
102+
))),
103+
)
104+
.padding()
105+
.background(SharedAsset.Colors.backgroundPrimary.swiftUIColor)
106+
}
107+
#endif

0 commit comments

Comments
 (0)