diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionAction.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionAction.swift new file mode 100644 index 000000000..b7572fdc2 --- /dev/null +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionAction.swift @@ -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 +} diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionEffect.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionEffect.swift new file mode 100644 index 000000000..884ce8315 --- /dev/null +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionEffect.swift @@ -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) +} diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionState.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionState.swift new file mode 100644 index 000000000..9cbb18726 --- /dev/null +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionState.swift @@ -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 + } +} diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutStateTests.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionStateTests.swift similarity index 77% rename from BitwardenShared/UI/Platform/Settings/Settings/About/AboutStateTests.swift rename to BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionStateTests.swift index 7a67bfb42..933d54bae 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutStateTests.swift +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionStateTests.swift @@ -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), ), diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionView+SnapshotTests.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionView+SnapshotTests.swift new file mode 100644 index 000000000..013b91ae8 --- /dev/null +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionView+SnapshotTests.swift @@ -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], + ) + } +} diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionView+ViewInspectorTests.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionView+ViewInspectorTests.swift new file mode 100644 index 000000000..314c52d08 --- /dev/null +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionView+ViewInspectorTests.swift @@ -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) + } +} diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionView.swift b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionView.swift new file mode 100644 index 000000000..9e0e3c68b --- /dev/null +++ b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/FlightRecorderSettingsSectionView.swift @@ -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 diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_disabled.1.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_disabled.1.png new file mode 100644 index 000000000..0a3e1fecb Binary files /dev/null and b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_disabled.1.png differ diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_disabled.2.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_disabled.2.png new file mode 100644 index 000000000..a0939e881 Binary files /dev/null and b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_disabled.2.png differ diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_disabled.3.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_disabled.3.png new file mode 100644 index 000000000..d5a2e3013 Binary files /dev/null and b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_disabled.3.png differ diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_enabled.1.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_enabled.1.png new file mode 100644 index 000000000..08aab5c39 Binary files /dev/null and b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_enabled.1.png differ diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_enabled.2.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_enabled.2.png new file mode 100644 index 000000000..999edec8c Binary files /dev/null and b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_enabled.2.png differ diff --git a/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_enabled.3.png b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_enabled.3.png new file mode 100644 index 000000000..0016617e1 Binary files /dev/null and b/BitwardenKit/UI/Platform/Settings/FlightRecorder/FlightRecorderSettingsSection/__Snapshots__/FlightRecorderSettingsSectionView+SnapshotTests/test_snapshot_flightRecorderSettingsSection_enabled.3.png differ diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutAction.swift b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutAction.swift index d74c459e2..83b9fd0c6 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutAction.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutAction.swift @@ -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 } diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutEffect.swift b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutEffect.swift index 71f3d38c1..6d3a925e4 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutEffect.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutEffect.swift @@ -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) } diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessor.swift b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessor.swift index c69adc6f2..7f2bc2e23 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessor.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessor.swift @@ -53,14 +53,17 @@ final class AboutProcessor: StateProcessor 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 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 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 /// 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 } } } diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessorTests.swift b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessorTests.swift index 819aceaff..8d35e2e1b 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessorTests.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutProcessorTests.swift @@ -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 diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutState.swift b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutState.swift index b5c702c06..7b8992381 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutState.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutState.swift @@ -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: ", ") - } } diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutView+ViewInspectorTests.swift b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutView+ViewInspectorTests.swift index 04078d565..dbf3d90ca 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutView+ViewInspectorTests.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutView+ViewInspectorTests.swift @@ -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 { diff --git a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutView.swift b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutView.swift index 0ce7a776a..e138ee51e 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/About/AboutView.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/About/AboutView.swift @@ -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), - ), + )), )))) }