[PM-26063] Move the Flight Recorder's toast banner to BitwardenKit (#2149)

This commit is contained in:
Matt Czech 2025-11-18 09:59:50 -06:00 committed by GitHub
parent b5e7033d54
commit 5bf7e24f97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 122 additions and 51 deletions

View File

@ -0,0 +1,28 @@
// MARK: - FlightRecorderToastBannerState
/// The state for the flight recorder toast banner that displays when a flight recorder log is active.
///
public struct FlightRecorderToastBannerState: Equatable {
// MARK: Properties
/// The active flight recorder log metadata, or `nil` if the flight recorder isn't active.
public var activeLog: FlightRecorderData.LogMetadata?
// MARK: Computed Properties
/// Whether the flight recorder toast banner is visible.
public var isToastBannerVisible: Bool {
!(activeLog?.isBannerDismissed ?? true)
}
// MARK: Initialization
/// Initialize a `FlightRecorderToastBannerState`.
///
/// - Parameter activeLog: The active flight recorder log metadata, or `nil` if the flight recorder
/// isn't active.
///
public init(activeLog: FlightRecorderData.LogMetadata? = nil) {
self.activeLog = activeLog
}
}

View File

@ -0,0 +1,37 @@
import XCTest
@testable import BitwardenKit
class FlightRecorderToastBannerStateTests: BitwardenTestCase {
// MARK: Tests
/// `isToastBannerVisible` returns `false` when `activeLog` is `nil`.
func test_isToastBannerVisible_activeLogNil() {
let subject = FlightRecorderToastBannerState(activeLog: nil)
XCTAssertFalse(subject.isToastBannerVisible)
}
/// `isToastBannerVisible` returns `true` when `activeLog` has `isBannerDismissed` as `false`.
func test_isToastBannerVisible_notDismissed() {
let activeLog = FlightRecorderData.LogMetadata(
duration: .eightHours,
startDate: Date(year: 2025, month: 11, day: 13),
)
let subject = FlightRecorderToastBannerState(activeLog: activeLog)
XCTAssertTrue(subject.isToastBannerVisible)
}
/// `isToastBannerVisible` returns `false` when `activeLog` has `isBannerDismissed` as `true`.
func test_isToastBannerVisible_dismissed() {
var activeLog = FlightRecorderData.LogMetadata(
duration: .eightHours,
startDate: Date(year: 2025, month: 11, day: 13),
)
activeLog.isBannerDismissed = true
let subject = FlightRecorderToastBannerState(activeLog: activeLog)
XCTAssertFalse(subject.isToastBannerVisible)
}
}

View File

@ -0,0 +1,38 @@
import BitwardenResources
import SwiftUI
public extension View {
/// Displays a toast banner indicating that the flight recorder is active.
///
/// - Parameters:
/// - activeLog: The active flight recorder log metadata used to display the end date/time.
/// - additionalBottomPadding: Additional bottom padding to apply to the toast banner.
/// - isVisible: A binding to control the visibility of the toast banner.
/// - goToSettingsAction: The action to perform when the "Go to Settings" button is tapped.
///
/// - Returns: A view with the flight recorder toast banner applied.
///
func flightRecorderToastBanner(
activeLog: FlightRecorderData.LogMetadata?,
additionalBottomPadding: CGFloat = 0,
isVisible: Binding<Bool>,
goToSettingsAction: @escaping () -> Void,
) -> some View {
toastBanner(
title: Localizations.flightRecorderOn,
subtitle: {
guard let activeLog else { return "" }
return Localizations.flightRecorderWillBeActiveUntilDescriptionLong(
activeLog.formattedEndDate,
activeLog.formattedEndTime,
)
}(),
additionalBottomPadding: additionalBottomPadding,
isVisible: isVisible,
) {
Button(Localizations.goToSettings) {
goToSettingsAction()
}
}
}
}

View File

@ -267,7 +267,6 @@ extension VaultListProcessor {
/// Dismisses the flight recorder toast banner for the active user.
///
private func dismissFlightRecorderToastBanner() async {
state.isFlightRecorderToastBannerVisible = false
await services.flightRecorder.setFlightRecorderBannerDismissed()
}
@ -499,8 +498,7 @@ extension VaultListProcessor {
///
private func streamFlightRecorderLog() async {
for await log in await services.flightRecorder.activeLogPublisher().values {
state.activeFlightRecorderLog = log
state.isFlightRecorderToastBannerVisible = !(log?.isBannerDismissed ?? true)
state.flightRecorderToastBanner.activeLog = log
}
}

View File

@ -510,11 +510,9 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
@MainActor
func test_perform_dismissFlightRecorderToastBanner() async {
stateService.activeAccount = .fixture()
subject.state.isFlightRecorderToastBannerVisible = true
await subject.perform(.dismissFlightRecorderToastBanner)
XCTAssertFalse(subject.state.isFlightRecorderToastBannerVisible)
XCTAssertTrue(flightRecorder.setFlightRecorderBannerDismissedCalled)
}
@ -870,31 +868,12 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
defer { task.cancel() }
flightRecorder.activeLogSubject.send(FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now))
try await waitForAsync { self.subject.state.isFlightRecorderToastBannerVisible }
XCTAssertEqual(subject.state.isFlightRecorderToastBannerVisible, true)
try await waitForAsync { self.subject.state.flightRecorderToastBanner.isToastBannerVisible }
XCTAssertEqual(subject.state.flightRecorderToastBanner.isToastBannerVisible, true)
flightRecorder.activeLogSubject.send(nil)
try await waitForAsync { !self.subject.state.isFlightRecorderToastBannerVisible }
XCTAssertEqual(subject.state.isFlightRecorderToastBannerVisible, false)
}
/// `perform(_:)` with `.streamFlightRecorderLog` streams the flight recorder log but doesn't
/// display the flight recorder banner if the user has dismissed it previously.
@MainActor
func test_perform_streamFlightRecorderLog_userDismissed() async throws {
stateService.activeAccount = .fixture()
let task = Task {
await subject.perform(.streamFlightRecorderLog)
}
defer { task.cancel() }
var log = FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now)
log.isBannerDismissed = true
flightRecorder.activeLogSubject.send(log)
try await waitForAsync { self.subject.state.activeFlightRecorderLog != nil }
XCTAssertEqual(subject.state.isFlightRecorderToastBannerVisible, false)
try await waitForAsync { !self.subject.state.flightRecorderToastBanner.isToastBannerVisible }
XCTAssertEqual(subject.state.flightRecorderToastBanner.isToastBannerVisible, false)
}
/// `perform(_:)` with `.streamOrganizations` updates the state's organizations whenever it changes.

View File

@ -9,15 +9,15 @@ import Foundation
struct VaultListState: Equatable {
// MARK: Properties
/// The active flight recorder log metadata, or `nil` if the flight recorder isn't active.
var activeFlightRecorderLog: FlightRecorderData.LogMetadata?
/// List of available item type for creation.
var itemTypesUserCanCreate: [CipherType] = CipherType.canCreateCases
/// Whether the vault filter can be shown.
var canShowVaultFilter = true
/// The state for the flight recorder toast banner displayed in the item list.
var flightRecorderToastBanner = FlightRecorderToastBannerState()
/// The base url used to fetch icons.
var iconBaseURL: URL?
@ -30,9 +30,6 @@ struct VaultListState: Equatable {
/// Whether the user is eligible for an app review prompt.
var isEligibleForAppReview: Bool = false
/// Whether the flight recorder toast banner is visible.
var isFlightRecorderToastBannerVisible = false
/// The loading state of the My Vault screen.
var loadingState: LoadingState<[VaultListSection]> = .loading(nil)

View File

@ -80,8 +80,7 @@ class VaultListViewTests: BitwardenTestCase {
@MainActor
func disabletest_snapshot_flightRecorderToastBanner() {
processor.state.loadingState = .data([])
processor.state.isFlightRecorderToastBannerVisible = true
processor.state.activeFlightRecorderLog = FlightRecorderData.LogMetadata(
processor.state.flightRecorderToastBanner.activeLog = FlightRecorderData.LogMetadata(
duration: .twentyFourHours,
startDate: Date(year: 2025, month: 4, day: 3),
)

View File

@ -231,7 +231,10 @@ class VaultListViewTests: BitwardenTestCase {
/// `.navigateToFlightRecorderSettings` action.
@MainActor
func test_toastBannerGoToSettings_tap() async throws {
processor.state.isFlightRecorderToastBannerVisible = true
processor.state.flightRecorderToastBanner.activeLog = FlightRecorderData.LogMetadata(
duration: .eightHours,
startDate: Date(year: 2025, month: 4, day: 3),
)
let button = try subject.inspect().find(button: Localizations.goToSettings)
try button.tap()
XCTAssertEqual(processor.dispatchedActions, [.navigateToFlightRecorderSettings])

View File

@ -51,25 +51,17 @@ private struct SearchableVaultListView: View {
),
additionalBottomPadding: FloatingActionButton.bottomOffsetPadding,
)
.toastBanner(
title: Localizations.flightRecorderOn,
subtitle: {
guard let log = store.state.activeFlightRecorderLog else { return "" }
return Localizations.flightRecorderWillBeActiveUntilDescriptionLong(
log.formattedEndDate,
log.formattedEndTime,
)
}(),
.flightRecorderToastBanner(
activeLog: store.state.flightRecorderToastBanner.activeLog,
additionalBottomPadding: FloatingActionButton.bottomOffsetPadding,
isVisible: store.bindingAsync(
get: \.isFlightRecorderToastBannerVisible,
get: \.flightRecorderToastBanner.isToastBannerVisible,
perform: { _ in .dismissFlightRecorderToastBanner },
),
) {
Button(Localizations.goToSettings) {
goToSettingsAction: {
store.send(.navigateToFlightRecorderSettings)
}
}
},
)
.onChange(of: store.state.url) { newValue in
guard let url = newValue else { return }
openURL(url)