mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-11 23:33:36 -06:00
[PM-26063] Add reusable component for the Flight Recorder settings view (#2141)
This commit is contained in:
parent
0e2991802d
commit
b8161ee72c
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,22 +1,21 @@
|
|||||||
import BitwardenKit
|
|
||||||
import BitwardenResources
|
import BitwardenResources
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import BitwardenShared
|
@testable import BitwardenKit
|
||||||
|
|
||||||
class AboutStateTests: BitwardenTestCase {
|
class FlightRecorderSettingsSectionStateTests: BitwardenTestCase {
|
||||||
/// `flightRecorderToggleAccessibilityLabel` returns the flight recorder toggle's accessibility
|
/// `flightRecorderToggleAccessibilityLabel` returns the flight recorder toggle's accessibility
|
||||||
/// label when the flight recorder is off.
|
/// label when the flight recorder is off.
|
||||||
func test_flightRecorderToggleAccessibilityLabel_flightRecorderOff() {
|
func test_flightRecorderToggleAccessibilityLabel_flightRecorderOff() {
|
||||||
let subject = AboutState(flightRecorderActiveLog: nil)
|
let subject = FlightRecorderSettingsSectionState()
|
||||||
XCTAssertEqual(subject.flightRecorderToggleAccessibilityLabel, Localizations.flightRecorder)
|
XCTAssertEqual(subject.flightRecorderToggleAccessibilityLabel, Localizations.flightRecorder)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `flightRecorderToggleAccessibilityLabel` returns the flight recorder toggle's accessibility
|
/// `flightRecorderToggleAccessibilityLabel` returns the flight recorder toggle's accessibility
|
||||||
/// label when the flight recorder is on.
|
/// label when the flight recorder is on.
|
||||||
func test_flightRecorderToggleAccessibilityLabel_flightRecorderOn() {
|
func test_flightRecorderToggleAccessibilityLabel_flightRecorderOn() {
|
||||||
let subject = AboutState(
|
let subject = FlightRecorderSettingsSectionState(
|
||||||
flightRecorderActiveLog: FlightRecorderData.LogMetadata(
|
activeLog: FlightRecorderData.LogMetadata(
|
||||||
duration: .eightHours,
|
duration: .eightHours,
|
||||||
startDate: Date(year: 2025, month: 5, day: 1),
|
startDate: Date(year: 2025, month: 5, day: 1),
|
||||||
),
|
),
|
||||||
@ -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],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
@ -11,6 +11,9 @@ enum AboutAction: Equatable {
|
|||||||
/// The url has been opened so clear the value in the state.
|
/// The url has been opened so clear the value in the state.
|
||||||
case clearURL
|
case clearURL
|
||||||
|
|
||||||
|
/// An action for the Flight Recorder feature.
|
||||||
|
case flightRecorder(FlightRecorderSettingsSectionAction)
|
||||||
|
|
||||||
/// The help center button was tapped.
|
/// The help center button was tapped.
|
||||||
case helpCenterTapped
|
case helpCenterTapped
|
||||||
|
|
||||||
@ -32,9 +35,6 @@ enum AboutAction: Equatable {
|
|||||||
/// The version was tapped.
|
/// The version was tapped.
|
||||||
case versionTapped
|
case versionTapped
|
||||||
|
|
||||||
/// The view flight recorder logs button was tapped.
|
|
||||||
case viewFlightRecorderLogsTapped
|
|
||||||
|
|
||||||
/// The web vault button was tapped.
|
/// The web vault button was tapped.
|
||||||
case webVaultTapped
|
case webVaultTapped
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
|
import BitwardenKit
|
||||||
|
|
||||||
// MARK: - AboutEffect
|
// MARK: - AboutEffect
|
||||||
|
|
||||||
/// Effects that can be processed by the `AboutProcessor`.
|
/// Effects that can be processed by the `AboutProcessor`.
|
||||||
///
|
///
|
||||||
enum AboutEffect: Equatable {
|
enum AboutEffect: Equatable {
|
||||||
|
/// An effect for the Flight Recorder feature.
|
||||||
|
case flightRecorder(FlightRecorderSettingsSectionEffect)
|
||||||
|
|
||||||
/// Stream the active flight recorder log.
|
/// Stream the active flight recorder log.
|
||||||
case streamFlightRecorderLog
|
case streamFlightRecorderLog
|
||||||
|
|
||||||
/// The flight recorder toggle value changed.
|
|
||||||
case toggleFlightRecorder(Bool)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,8 +53,8 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, AboutEffect>
|
|||||||
|
|
||||||
override func perform(_ effect: AboutEffect) async {
|
override func perform(_ effect: AboutEffect) async {
|
||||||
switch effect {
|
switch effect {
|
||||||
case .streamFlightRecorderLog:
|
case let .flightRecorder(flightRecorderEffect):
|
||||||
await streamFlightRecorderLog()
|
switch flightRecorderEffect {
|
||||||
case let .toggleFlightRecorder(isOn):
|
case let .toggleFlightRecorder(isOn):
|
||||||
if isOn {
|
if isOn {
|
||||||
coordinator.navigate(to: .flightRecorder(.enableFlightRecorder))
|
coordinator.navigate(to: .flightRecorder(.enableFlightRecorder))
|
||||||
@ -62,6 +62,9 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, AboutEffect>
|
|||||||
await services.flightRecorder.disableFlightRecorder()
|
await services.flightRecorder.disableFlightRecorder()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case .streamFlightRecorderLog:
|
||||||
|
await streamFlightRecorderLog()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func receive(_ action: AboutAction) {
|
override func receive(_ action: AboutAction) {
|
||||||
@ -70,6 +73,11 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, AboutEffect>
|
|||||||
state.appReviewUrl = nil
|
state.appReviewUrl = nil
|
||||||
case .clearURL:
|
case .clearURL:
|
||||||
state.url = nil
|
state.url = nil
|
||||||
|
case let .flightRecorder(flightRecorderAction):
|
||||||
|
switch flightRecorderAction {
|
||||||
|
case .viewLogsTapped:
|
||||||
|
coordinator.navigate(to: .flightRecorder(.flightRecorderLogs))
|
||||||
|
}
|
||||||
case .helpCenterTapped:
|
case .helpCenterTapped:
|
||||||
state.url = ExternalLinksConstants.helpAndFeedback
|
state.url = ExternalLinksConstants.helpAndFeedback
|
||||||
case .learnAboutOrganizationsTapped:
|
case .learnAboutOrganizationsTapped:
|
||||||
@ -91,8 +99,6 @@ final class AboutProcessor: StateProcessor<AboutState, AboutAction, AboutEffect>
|
|||||||
services.errorReporter.isEnabled = isOn
|
services.errorReporter.isEnabled = isOn
|
||||||
case .versionTapped:
|
case .versionTapped:
|
||||||
handleVersionTapped()
|
handleVersionTapped()
|
||||||
case .viewFlightRecorderLogsTapped:
|
|
||||||
coordinator.navigate(to: .flightRecorder(.flightRecorderLogs))
|
|
||||||
case .webVaultTapped:
|
case .webVaultTapped:
|
||||||
coordinator.showAlert(.webVaultAlert {
|
coordinator.showAlert(.webVaultAlert {
|
||||||
self.state.url = self.services.environmentService.webVaultURL
|
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.
|
/// Streams the flight recorder's active log metadata.
|
||||||
private func streamFlightRecorderLog() async {
|
private func streamFlightRecorderLog() async {
|
||||||
for await activeLog in await services.flightRecorder.activeLogPublisher().values {
|
for await activeLog in await services.flightRecorder.activeLogPublisher().values {
|
||||||
state.flightRecorderActiveLog = activeLog
|
state.flightRecorderState.activeLog = activeLog
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,10 +81,35 @@ class AboutProcessorTests: BitwardenTestCase {
|
|||||||
XCTAssertEqual(subject.state.version, "1.0 (1)")
|
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.
|
/// `perform(_:)` with `.streamFlightRecorderLog` subscribes to the active flight recorder log.
|
||||||
@MainActor
|
@MainActor
|
||||||
func test_perform_streamFlightRecorderLog() async throws {
|
func test_perform_streamFlightRecorderLog() async throws {
|
||||||
XCTAssertNil(subject.state.flightRecorderActiveLog)
|
XCTAssertNil(subject.state.flightRecorderState.activeLog)
|
||||||
|
|
||||||
let task = Task {
|
let task = Task {
|
||||||
await subject.perform(.streamFlightRecorderLog)
|
await subject.perform(.streamFlightRecorderLog)
|
||||||
@ -93,36 +118,12 @@ class AboutProcessorTests: BitwardenTestCase {
|
|||||||
|
|
||||||
let log = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now)
|
let log = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now)
|
||||||
flightRecorder.activeLogSubject.send(log)
|
flightRecorder.activeLogSubject.send(log)
|
||||||
try await waitForAsync { self.subject.state.flightRecorderActiveLog != nil }
|
try await waitForAsync { self.subject.state.flightRecorderState.activeLog != nil }
|
||||||
XCTAssertEqual(subject.state.flightRecorderActiveLog, log)
|
XCTAssertEqual(subject.state.flightRecorderState.activeLog, log)
|
||||||
|
|
||||||
flightRecorder.activeLogSubject.send(nil)
|
flightRecorder.activeLogSubject.send(nil)
|
||||||
try await waitForAsync { self.subject.state.flightRecorderActiveLog == nil }
|
try await waitForAsync { self.subject.state.flightRecorderState.activeLog == nil }
|
||||||
XCTAssertNil(subject.state.flightRecorderActiveLog)
|
XCTAssertNil(subject.state.flightRecorderState.activeLog)
|
||||||
}
|
|
||||||
|
|
||||||
/// `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)])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `receive(_:)` with `.clearAppReviewURL` clears the app review URL in the state.
|
/// `receive(_:)` with `.clearAppReviewURL` clears the app review URL in the state.
|
||||||
@ -141,6 +142,15 @@ class AboutProcessorTests: BitwardenTestCase {
|
|||||||
XCTAssertNil(subject.state.url)
|
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.
|
/// `receive(_:)` with `.helpCenterTapped` set the URL to open in the state.
|
||||||
@MainActor
|
@MainActor
|
||||||
func test_receive_helpCenterTapped() {
|
func test_receive_helpCenterTapped() {
|
||||||
@ -226,15 +236,6 @@ class AboutProcessorTests: BitwardenTestCase {
|
|||||||
XCTAssertEqual(subject.state.toast, Toast(title: Localizations.valueHasBeenCopied(Localizations.appInfo)))
|
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
|
/// `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.
|
/// When `Continue` is tapped on the alert, sets the URL to open in the state.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@ -15,8 +15,8 @@ struct AboutState {
|
|||||||
/// The copyright text.
|
/// The copyright text.
|
||||||
var copyrightText = ""
|
var copyrightText = ""
|
||||||
|
|
||||||
/// The flight recorder's active log metadata, if logging is enabled.
|
/// The state for the Flight Recorder feature.
|
||||||
var flightRecorderActiveLog: FlightRecorderData.LogMetadata?
|
var flightRecorderState = FlightRecorderSettingsSectionState()
|
||||||
|
|
||||||
/// Whether the submit crash logs toggle is on.
|
/// Whether the submit crash logs toggle is on.
|
||||||
var isSubmitCrashLogsToggleOn: Bool = false
|
var isSubmitCrashLogsToggleOn: Bool = false
|
||||||
@ -29,22 +29,4 @@ struct AboutState {
|
|||||||
|
|
||||||
/// The version of the app.
|
/// The version of the app.
|
||||||
var version = ""
|
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: ", ")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,18 +45,30 @@ class AboutViewTests: BitwardenTestCase {
|
|||||||
|
|
||||||
/// The flight recorder toggle turns logging on and off.
|
/// The flight recorder toggle turns logging on and off.
|
||||||
@MainActor
|
@MainActor
|
||||||
func test_flightRecorderToggle_tap() async throws {
|
func test_flightRecorder_toggle_tap() async throws {
|
||||||
let toggle = try subject.inspect().find(toggleWithAccessibilityLabel: Localizations.flightRecorder)
|
let toggle = try subject.inspect().find(toggleWithAccessibilityLabel: Localizations.flightRecorder)
|
||||||
|
|
||||||
try toggle.tap()
|
try toggle.tap()
|
||||||
try await waitForAsync { !self.processor.effects.isEmpty }
|
try await waitForAsync { !self.processor.effects.isEmpty }
|
||||||
XCTAssertEqual(processor.effects, [.toggleFlightRecorder(true)])
|
XCTAssertEqual(processor.effects, [.flightRecorder(.toggleFlightRecorder(true))])
|
||||||
processor.effects.removeAll()
|
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 toggle.tap()
|
||||||
try await waitForAsync { !self.processor.effects.isEmpty }
|
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.
|
/// Tapping the privacy policy button dispatches the `.privacyPolicyTapped` action.
|
||||||
@ -83,14 +95,6 @@ class AboutViewTests: BitwardenTestCase {
|
|||||||
XCTAssertEqual(processor.dispatchedActions.last, .versionTapped)
|
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.
|
/// Tapping the web vault button dispatches the `.webVaultTapped` action.
|
||||||
@MainActor
|
@MainActor
|
||||||
func test_webVaultButton_tap() throws {
|
func test_webVaultButton_tap() throws {
|
||||||
|
|||||||
@ -27,7 +27,7 @@ struct AboutView: View {
|
|||||||
|
|
||||||
copyrightNotice
|
copyrightNotice
|
||||||
}
|
}
|
||||||
.animation(.default, value: store.state.flightRecorderActiveLog)
|
.animation(.default, value: store.state.flightRecorderState.activeLog)
|
||||||
.scrollView()
|
.scrollView()
|
||||||
.navigationBar(title: Localizations.about, titleDisplayMode: .inline)
|
.navigationBar(title: Localizations.about, titleDisplayMode: .inline)
|
||||||
.task {
|
.task {
|
||||||
@ -62,41 +62,13 @@ struct AboutView: View {
|
|||||||
|
|
||||||
/// The section for the flight recorder.
|
/// The section for the flight recorder.
|
||||||
@ViewBuilder private var flightRecorderSection: some View {
|
@ViewBuilder private var flightRecorderSection: some View {
|
||||||
ContentBlock(dividerLeadingPadding: 16) {
|
FlightRecorderSettingsSectionView(
|
||||||
BitwardenToggle(
|
store: store.child(
|
||||||
isOn: store.bindingAsync(
|
state: \.flightRecorderState,
|
||||||
get: { $0.flightRecorderActiveLog != nil },
|
mapAction: { .flightRecorder($0) },
|
||||||
perform: AboutEffect.toggleFlightRecorder,
|
mapEffect: { .flightRecorder($0) },
|
||||||
),
|
),
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The section of miscellaneous about items.
|
/// The section of miscellaneous about items.
|
||||||
@ -154,9 +126,9 @@ struct AboutView: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
AboutView(store: Store(processor: StateProcessor(state: AboutState(
|
AboutView(store: Store(processor: StateProcessor(state: AboutState(
|
||||||
flightRecorderActiveLog: FlightRecorderData.LogMetadata(
|
flightRecorderState: FlightRecorderSettingsSectionState(activeLog: FlightRecorderData.LogMetadata(
|
||||||
duration: .eightHours,
|
duration: .eightHours,
|
||||||
startDate: Date(timeIntervalSinceNow: 60 * 60 * -4),
|
startDate: Date(timeIntervalSinceNow: 60 * 60 * -4),
|
||||||
),
|
)),
|
||||||
))))
|
))))
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user