[PM-26063] Add reusable component for the Flight Recorder settings view (#2141)

This commit is contained in:
Matt Czech 2025-11-17 11:45:54 -06:00 committed by GitHub
parent 0e2991802d
commit b8161ee72c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 410 additions and 129 deletions

View File

@ -0,0 +1,11 @@
// MARK: - FlightRecorderSettingsSectionAction
/// Actions handled by the Flight Recorder settings section component.
///
/// This is a reusable component that can be integrated into any processor that displays Flight
/// Recorder settings.
///
public enum FlightRecorderSettingsSectionAction: Equatable {
/// The view Flight Recorder logs button was tapped.
case viewLogsTapped
}

View File

@ -0,0 +1,11 @@
// MARK: - FlightRecorderSettingsSectionEffect
/// Effects handled by the Flight Recorder settings section component.
///
/// This is a reusable component that can be integrated into any processor that displays Flight
/// Recorder settings.
///
public enum FlightRecorderSettingsSectionEffect: Equatable {
/// The Flight Recorder toggle value changed.
case toggleFlightRecorder(Bool)
}

View File

@ -0,0 +1,44 @@
import BitwardenResources
import Foundation
// MARK: - FlightRecorderSettingsSectionState
/// The state for the Flight Recorder settings section component.
///
/// This is a reusable component that can be integrated into any processor that displays Flight
/// Recorder settings.
///
public struct FlightRecorderSettingsSectionState: Equatable {
// MARK: Properties
/// The Flight Recorder's active log metadata, if logging is enabled.
public var activeLog: FlightRecorderData.LogMetadata?
// MARK: Computed Properties
/// The accessibility label for the Flight Recorder toggle.
var flightRecorderToggleAccessibilityLabel: String {
var accessibilityLabelComponents = [Localizations.flightRecorder]
if let log = activeLog {
// VoiceOver doesn't read the short date style correctly so use the medium style instead.
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
accessibilityLabelComponents.append(Localizations.loggingEndsOnDateAtTime(
dateFormatter.string(from: log.endDate),
log.formattedEndTime,
))
}
return accessibilityLabelComponents.joined(separator: ", ")
}
// MARK: Initialization
/// Creates a new `FlightRecorderState`.
///
/// - Parameter activeLog: The Flight Recorder's active log metadata, if logging is enabled.
///
public init(activeLog: FlightRecorderData.LogMetadata? = nil) {
self.activeLog = activeLog
}
}

View File

@ -1,22 +1,21 @@
import BitwardenKit
import BitwardenResources
import XCTest
@testable import BitwardenShared
@testable import BitwardenKit
class AboutStateTests: BitwardenTestCase {
class FlightRecorderSettingsSectionStateTests: BitwardenTestCase {
/// `flightRecorderToggleAccessibilityLabel` returns the flight recorder toggle's accessibility
/// label when the flight recorder is off.
func test_flightRecorderToggleAccessibilityLabel_flightRecorderOff() {
let subject = AboutState(flightRecorderActiveLog: nil)
let subject = FlightRecorderSettingsSectionState()
XCTAssertEqual(subject.flightRecorderToggleAccessibilityLabel, Localizations.flightRecorder)
}
/// `flightRecorderToggleAccessibilityLabel` returns the flight recorder toggle's accessibility
/// label when the flight recorder is on.
func test_flightRecorderToggleAccessibilityLabel_flightRecorderOn() {
let subject = AboutState(
flightRecorderActiveLog: FlightRecorderData.LogMetadata(
let subject = FlightRecorderSettingsSectionState(
activeLog: FlightRecorderData.LogMetadata(
duration: .eightHours,
startDate: Date(year: 2025, month: 5, day: 1),
),

View File

@ -0,0 +1,73 @@
// swiftlint:disable:this file_name
import BitwardenKitMocks
import BitwardenResources
import SnapshotTesting
import SwiftUI
import XCTest
@testable import BitwardenKit
class FlightRecorderSettingsSectionViewSnapshotTests: BitwardenTestCase {
// MARK: Properties
var processor: MockProcessor<
FlightRecorderSettingsSectionState,
FlightRecorderSettingsSectionAction,
FlightRecorderSettingsSectionEffect,
>!
var subject: FlightRecorderSettingsSectionView!
// MARK: Computed Properties
/// Returns the subject view wrapped with padding and background for snapshot testing.
var snapshotView: some View {
ZStack(alignment: .top) {
SharedAsset.Colors.backgroundPrimary.swiftUIColor.ignoresSafeArea()
subject
.padding()
}
}
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
processor = MockProcessor(state: FlightRecorderSettingsSectionState())
let store = Store(processor: processor)
subject = FlightRecorderSettingsSectionView(store: store)
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Snapshots
/// The flight recorder settings section view renders correctly when disabled.
@MainActor
func disabletest_snapshot_flightRecorderSettingsSection_disabled() {
assertSnapshots(
of: snapshotView,
as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5],
)
}
/// The flight recorder settings section view renders correctly when enabled.
@MainActor
func disabletest_snapshot_flightRecorderSettingsSection_enabled() {
processor.state.activeLog = FlightRecorderData.LogMetadata(
duration: .eightHours,
startDate: Date(year: 2025, month: 5, day: 1, hour: 8),
)
assertSnapshots(
of: snapshotView,
as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5],
)
}
}

View File

@ -0,0 +1,69 @@
// swiftlint:disable:this file_name
import BitwardenKitMocks
import BitwardenResources
import TestHelpers
import ViewInspector
import XCTest
@testable import BitwardenKit
class FlightRecorderSettingsSectionViewTests: BitwardenTestCase {
// MARK: Properties
var processor: MockProcessor<
FlightRecorderSettingsSectionState,
FlightRecorderSettingsSectionAction,
FlightRecorderSettingsSectionEffect,
>!
var subject: FlightRecorderSettingsSectionView!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
processor = MockProcessor(state: FlightRecorderSettingsSectionState())
let store = Store(processor: processor)
subject = FlightRecorderSettingsSectionView(store: store)
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Tests
/// Toggling the Flight Recorder toggle on dispatches the `.toggleFlightRecorder(true)` effect.
@MainActor
func test_toggle_on() async throws {
let toggle = try subject.inspect().find(toggleWithAccessibilityLabel: Localizations.flightRecorder)
try toggle.tap()
try await waitForAsync { !self.processor.effects.isEmpty }
XCTAssertEqual(processor.effects.last, .toggleFlightRecorder(true))
}
/// Toggling the Flight Recorder toggle off dispatches the `.toggleFlightRecorder(false)` effect.
@MainActor
func test_toggle_off() async throws {
let toggle = try subject.inspect().find(toggleWithAccessibilityLabel: Localizations.flightRecorder)
processor.state.activeLog = FlightRecorderData.LogMetadata(
duration: .eightHours,
startDate: Date(year: 2025, month: 5, day: 1),
)
try toggle.tap()
try await waitForAsync { !self.processor.effects.isEmpty }
XCTAssertEqual(processor.effects.last, .toggleFlightRecorder(false))
}
/// Tapping the view recorded logs button dispatches the `.viewLogsTapped` action.
@MainActor
func test_viewLogsButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.viewRecordedLogs)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .viewLogsTapped)
}
}

View File

@ -0,0 +1,107 @@
import BitwardenResources
import SwiftUI
// MARK: - FlightRecorderSettingsSectionView
/// A reusable view component that displays Flight Recorder settings.
///
/// This view provides a toggle to enable/disable flight recording and displays the active log's
/// end date and time when logging is enabled. It also includes a button to view recorded logs
/// and a help link for more information about the Flight Recorder feature.
///
/// This component can be integrated into any settings view that needs to display Flight Recorder
/// settings.
///
public struct FlightRecorderSettingsSectionView: View {
// MARK: Types
public typealias FlightRecorderSettingsSectionStore = Store<
FlightRecorderSettingsSectionState,
FlightRecorderSettingsSectionAction,
FlightRecorderSettingsSectionEffect,
>
// MARK: Properties
/// An object used to open urls from this view.
@Environment(\.openURL) private var openURL
/// The `Store` for this view.
@ObservedObject var store: FlightRecorderSettingsSectionStore
// MARK: View
public var body: some View {
ContentBlock(dividerLeadingPadding: 16) {
BitwardenToggle(
isOn: store.bindingAsync(
get: { $0.activeLog != nil },
perform: FlightRecorderSettingsSectionEffect.toggleFlightRecorder,
),
accessibilityIdentifier: "FlightRecorderSwitch",
accessibilityLabel: store.state.flightRecorderToggleAccessibilityLabel,
) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 8) {
Text(Localizations.flightRecorder)
Button {
openURL(ExternalLinksConstants.flightRecorderHelp)
} label: {
SharedAsset.Icons.questionCircle16.swiftUIImage
.scaledFrame(width: 16, height: 16)
.accessibilityLabel(Localizations.learnMore)
}
.buttonStyle(.fieldLabelIcon)
}
if let log = store.state.activeLog {
Text(Localizations.loggingEndsOnDateAtTime(log.formattedEndDate, log.formattedEndTime))
.foregroundStyle(SharedAsset.Colors.textSecondary.swiftUIColor)
.styleGuide(.subheadline)
}
}
}
SettingsListItem(Localizations.viewRecordedLogs) {
store.send(.viewLogsTapped)
}
}
}
// MARK: Initialization
/// Creates a new `FlightRecorderSettingsSectionView`.
///
/// - Parameter store: The `Store` for managing the Flight Recorder settings section state,
/// actions, and effects.
///
public init(store: FlightRecorderSettingsSectionStore) {
self.store = store
}
}
// MARK: - Previews
#if DEBUG
#Preview("Disabled") {
FlightRecorderSettingsSectionView(
store: Store(processor: StateProcessor(state: FlightRecorderSettingsSectionState())),
)
.padding()
.background(SharedAsset.Colors.backgroundPrimary.swiftUIColor)
}
#Preview("Enabled") {
FlightRecorderSettingsSectionView(
store: Store(processor: StateProcessor(state: FlightRecorderSettingsSectionState(
activeLog: FlightRecorderData.LogMetadata(
duration: .eightHours,
startDate: Date(timeIntervalSinceNow: 60 * 60 * -4),
),
))),
)
.padding()
.background(SharedAsset.Colors.backgroundPrimary.swiftUIColor)
}
#endif

View File

@ -11,6 +11,9 @@ enum AboutAction: Equatable {
/// The url has been opened so clear the value in the state.
case clearURL
/// An action for the Flight Recorder feature.
case flightRecorder(FlightRecorderSettingsSectionAction)
/// The help center button was tapped.
case helpCenterTapped
@ -32,9 +35,6 @@ enum AboutAction: Equatable {
/// The version was tapped.
case versionTapped
/// The view flight recorder logs button was tapped.
case viewFlightRecorderLogsTapped
/// The web vault button was tapped.
case webVaultTapped
}

View File

@ -1,11 +1,13 @@
import BitwardenKit
// MARK: - AboutEffect
/// Effects that can be processed by the `AboutProcessor`.
///
enum AboutEffect: Equatable {
/// An effect for the Flight Recorder feature.
case flightRecorder(FlightRecorderSettingsSectionEffect)
/// Stream the active flight recorder log.
case streamFlightRecorderLog
/// The flight recorder toggle value changed.
case toggleFlightRecorder(Bool)
}

View File

@ -53,14 +53,17 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, AboutEffect>
override func perform(_ effect: AboutEffect) async {
switch effect {
case let .flightRecorder(flightRecorderEffect):
switch flightRecorderEffect {
case let .toggleFlightRecorder(isOn):
if isOn {
coordinator.navigate(to: .flightRecorder(.enableFlightRecorder))
} else {
await services.flightRecorder.disableFlightRecorder()
}
}
case .streamFlightRecorderLog:
await streamFlightRecorderLog()
case let .toggleFlightRecorder(isOn):
if isOn {
coordinator.navigate(to: .flightRecorder(.enableFlightRecorder))
} else {
await services.flightRecorder.disableFlightRecorder()
}
}
}
@ -70,6 +73,11 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, AboutEffect>
state.appReviewUrl = nil
case .clearURL:
state.url = nil
case let .flightRecorder(flightRecorderAction):
switch flightRecorderAction {
case .viewLogsTapped:
coordinator.navigate(to: .flightRecorder(.flightRecorderLogs))
}
case .helpCenterTapped:
state.url = ExternalLinksConstants.helpAndFeedback
case .learnAboutOrganizationsTapped:
@ -91,8 +99,6 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, AboutEffect>
services.errorReporter.isEnabled = isOn
case .versionTapped:
handleVersionTapped()
case .viewFlightRecorderLogsTapped:
coordinator.navigate(to: .flightRecorder(.flightRecorderLogs))
case .webVaultTapped:
coordinator.showAlert(.webVaultAlert {
self.state.url = self.services.environmentService.webVaultURL
@ -111,7 +117,7 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, AboutEffect>
/// Streams the flight recorder's active log metadata.
private func streamFlightRecorderLog() async {
for await activeLog in await services.flightRecorder.activeLogPublisher().values {
state.flightRecorderActiveLog = activeLog
state.flightRecorderState.activeLog = activeLog
}
}
}

View File

@ -81,10 +81,35 @@ class AboutProcessorTests: BitwardenTestCase {
XCTAssertEqual(subject.state.version, "1.0 (1)")
}
/// `perform(_:)` with `.flightRecorder(.toggleFlightRecorder(true))` navigates to the enable
/// flight recorder screen when toggled on.
@MainActor
func test_perform_flightRecorder_toggleFlightRecorder_on() async {
XCTAssertNil(subject.state.flightRecorderState.activeLog)
await subject.perform(.flightRecorder(.toggleFlightRecorder(true)))
XCTAssertEqual(coordinator.routes, [.flightRecorder(.enableFlightRecorder)])
}
/// `perform(_:)` with `.flightRecorder(.toggleFlightRecorder(false))` disables the flight
/// recorder when toggled off.
@MainActor
func test_perform_flightRecorder_toggleFlightRecorder_off() async throws {
subject.state.flightRecorderState.activeLog = FlightRecorderData.LogMetadata(
duration: .eightHours,
startDate: .now,
)
await subject.perform(.flightRecorder(.toggleFlightRecorder(false)))
XCTAssertTrue(flightRecorder.disableFlightRecorderCalled)
}
/// `perform(_:)` with `.streamFlightRecorderLog` subscribes to the active flight recorder log.
@MainActor
func test_perform_streamFlightRecorderLog() async throws {
XCTAssertNil(subject.state.flightRecorderActiveLog)
XCTAssertNil(subject.state.flightRecorderState.activeLog)
let task = Task {
await subject.perform(.streamFlightRecorderLog)
@ -93,36 +118,12 @@ class AboutProcessorTests: BitwardenTestCase {
let log = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now)
flightRecorder.activeLogSubject.send(log)
try await waitForAsync { self.subject.state.flightRecorderActiveLog != nil }
XCTAssertEqual(subject.state.flightRecorderActiveLog, log)
try await waitForAsync { self.subject.state.flightRecorderState.activeLog != nil }
XCTAssertEqual(subject.state.flightRecorderState.activeLog, log)
flightRecorder.activeLogSubject.send(nil)
try await waitForAsync { self.subject.state.flightRecorderActiveLog == nil }
XCTAssertNil(subject.state.flightRecorderActiveLog)
}
/// `perform(_:)` with `.toggleFlightRecorder(false)` disables the flight recorder when toggled off.
@MainActor
func test_perform_toggleFlightRecorder_off() async throws {
subject.state.flightRecorderActiveLog = FlightRecorderData.LogMetadata(
duration: .eightHours,
startDate: .now,
)
await subject.perform(.toggleFlightRecorder(false))
XCTAssertTrue(flightRecorder.disableFlightRecorderCalled)
}
/// `perform(_:)` with `.toggleFlightRecorder(true)` navigates to the enable flight
/// recorder screen when toggled on.
@MainActor
func test_perform_toggleFlightRecorder_on() async {
XCTAssertNil(subject.state.flightRecorderActiveLog)
await subject.perform(.toggleFlightRecorder(true))
XCTAssertEqual(coordinator.routes, [.flightRecorder(.enableFlightRecorder)])
try await waitForAsync { self.subject.state.flightRecorderState.activeLog == nil }
XCTAssertNil(subject.state.flightRecorderState.activeLog)
}
/// `receive(_:)` with `.clearAppReviewURL` clears the app review URL in the state.
@ -141,6 +142,15 @@ class AboutProcessorTests: BitwardenTestCase {
XCTAssertNil(subject.state.url)
}
/// `receive(_:)` with action `.flightRecorder(.viewLogsTapped)` navigates to the view flight
/// recorder logs screen.
@MainActor
func test_receive_flightRecorder_viewFlightRecorderLogsTapped() {
subject.receive(.flightRecorder(.viewLogsTapped))
XCTAssertEqual(coordinator.routes, [.flightRecorder(.flightRecorderLogs)])
}
/// `receive(_:)` with `.helpCenterTapped` set the URL to open in the state.
@MainActor
func test_receive_helpCenterTapped() {
@ -226,15 +236,6 @@ class AboutProcessorTests: BitwardenTestCase {
XCTAssertEqual(subject.state.toast, Toast(title: Localizations.valueHasBeenCopied(Localizations.appInfo)))
}
/// `receive(_:)` with action `.isFlightRecorderToggleOn` navigates to the view flight recorder
/// logs screen.
@MainActor
func test_receive_viewFlightRecorderLogsTapped() {
subject.receive(.viewFlightRecorderLogsTapped)
XCTAssertEqual(coordinator.routes, [.flightRecorder(.flightRecorderLogs)])
}
/// `receive(_:)` with `.webVaultTapped` shows an alert for navigating to the web vault
/// When `Continue` is tapped on the alert, sets the URL to open in the state.
@MainActor

View File

@ -15,8 +15,8 @@ struct AboutState {
/// The copyright text.
var copyrightText = ""
/// The flight recorder's active log metadata, if logging is enabled.
var flightRecorderActiveLog: FlightRecorderData.LogMetadata?
/// The state for the Flight Recorder feature.
var flightRecorderState = FlightRecorderSettingsSectionState()
/// Whether the submit crash logs toggle is on.
var isSubmitCrashLogsToggleOn: Bool = false
@ -29,22 +29,4 @@ struct AboutState {
/// The version of the app.
var version = ""
// MARK: Computed Properties
/// The accessibility label for the flight recorder toggle.
var flightRecorderToggleAccessibilityLabel: String {
var accessibilityLabelComponents = [Localizations.flightRecorder]
if let log = flightRecorderActiveLog {
// VoiceOver doesn't read the short date style correctly so use the medium style instead.
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
accessibilityLabelComponents.append(Localizations.loggingEndsOnDateAtTime(
dateFormatter.string(from: log.endDate),
log.formattedEndTime,
))
}
return accessibilityLabelComponents.joined(separator: ", ")
}
}

View File

@ -45,18 +45,30 @@ class AboutViewTests: BitwardenTestCase {
/// The flight recorder toggle turns logging on and off.
@MainActor
func test_flightRecorderToggle_tap() async throws {
func test_flightRecorder_toggle_tap() async throws {
let toggle = try subject.inspect().find(toggleWithAccessibilityLabel: Localizations.flightRecorder)
try toggle.tap()
try await waitForAsync { !self.processor.effects.isEmpty }
XCTAssertEqual(processor.effects, [.toggleFlightRecorder(true)])
XCTAssertEqual(processor.effects, [.flightRecorder(.toggleFlightRecorder(true))])
processor.effects.removeAll()
processor.state.flightRecorderActiveLog = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now)
processor.state.flightRecorderState.activeLog = FlightRecorderData.LogMetadata(
duration: .eightHours,
startDate: .now,
)
try toggle.tap()
try await waitForAsync { !self.processor.effects.isEmpty }
XCTAssertEqual(processor.effects, [.toggleFlightRecorder(false)])
XCTAssertEqual(processor.effects, [.flightRecorder(.toggleFlightRecorder(false))])
}
/// Tapping the flight recorder view recorded logs button dispatches the
/// `.viewFlightRecorderLogsTapped` action.
@MainActor
func test_flightRecorder_viewRecordedLogsButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.viewRecordedLogs)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .flightRecorder(.viewLogsTapped))
}
/// Tapping the privacy policy button dispatches the `.privacyPolicyTapped` action.
@ -83,14 +95,6 @@ class AboutViewTests: BitwardenTestCase {
XCTAssertEqual(processor.dispatchedActions.last, .versionTapped)
}
/// Tapping the view recorded logs button dispatches the `.viewFlightRecorderLogsTapped` action.
@MainActor
func test_viewRecordedLogsButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.viewRecordedLogs)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .viewFlightRecorderLogsTapped)
}
/// Tapping the web vault button dispatches the `.webVaultTapped` action.
@MainActor
func test_webVaultButton_tap() throws {

View File

@ -27,7 +27,7 @@ struct AboutView: View {
copyrightNotice
}
.animation(.default, value: store.state.flightRecorderActiveLog)
.animation(.default, value: store.state.flightRecorderState.activeLog)
.scrollView()
.navigationBar(title: Localizations.about, titleDisplayMode: .inline)
.task {
@ -62,41 +62,13 @@ struct AboutView: View {
/// The section for the flight recorder.
@ViewBuilder private var flightRecorderSection: some View {
ContentBlock(dividerLeadingPadding: 16) {
BitwardenToggle(
isOn: store.bindingAsync(
get: { $0.flightRecorderActiveLog != nil },
perform: AboutEffect.toggleFlightRecorder,
),
accessibilityIdentifier: "FlightRecorderSwitch",
accessibilityLabel: store.state.flightRecorderToggleAccessibilityLabel,
) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 8) {
Text(Localizations.flightRecorder)
Button {
openURL(ExternalLinksConstants.flightRecorderHelp)
} label: {
SharedAsset.Icons.questionCircle16.swiftUIImage
.scaledFrame(width: 16, height: 16)
.accessibilityLabel(Localizations.learnMore)
}
.buttonStyle(.fieldLabelIcon)
}
if let log = store.state.flightRecorderActiveLog {
Text(Localizations.loggingEndsOnDateAtTime(log.formattedEndDate, log.formattedEndTime))
.foregroundStyle(SharedAsset.Colors.textSecondary.swiftUIColor)
.styleGuide(.subheadline)
}
}
}
SettingsListItem(Localizations.viewRecordedLogs) {
store.send(.viewFlightRecorderLogsTapped)
}
}
FlightRecorderSettingsSectionView(
store: store.child(
state: \.flightRecorderState,
mapAction: { .flightRecorder($0) },
mapEffect: { .flightRecorder($0) },
),
)
}
/// The section of miscellaneous about items.
@ -154,9 +126,9 @@ struct AboutView: View {
#Preview {
AboutView(store: Store(processor: StateProcessor(state: AboutState(
flightRecorderActiveLog: FlightRecorderData.LogMetadata(
flightRecorderState: FlightRecorderSettingsSectionState(activeLog: FlightRecorderData.LogMetadata(
duration: .eightHours,
startDate: Date(timeIntervalSinceNow: 60 * 60 * -4),
),
)),
))))
}