mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-10 17:46:07 -06:00
[PM-26063] Add Flight Recorder toast banner to Authenticator's item list (#2151)
This commit is contained in:
parent
28d94d4be2
commit
bdc2c53927
@ -134,6 +134,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
|
||||
let tabNavigator = BitwardenTabBarController()
|
||||
let coordinator = module.makeTabCoordinator(
|
||||
errorReporter: services.errorReporter,
|
||||
itemListDelegate: self,
|
||||
rootNavigator: rootNavigator,
|
||||
tabNavigator: tabNavigator,
|
||||
)
|
||||
@ -198,3 +199,11 @@ extension AppCoordinator: AuthCoordinatorDelegate {
|
||||
extension AppCoordinator: HasErrorAlertServices {
|
||||
var errorAlertServices: ErrorAlertServices { services }
|
||||
}
|
||||
|
||||
// MARK: - ItemListCoordinatorDelegate
|
||||
|
||||
extension AppCoordinator: ItemListCoordinatorDelegate {
|
||||
func switchToSettingsTab(route: SettingsRoute) {
|
||||
showTab(route: .settings(route))
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
import BitwardenKit
|
||||
import BitwardenKitMocks
|
||||
import Testing
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
@MainActor
|
||||
struct AppCoordinatorTests {
|
||||
// MARK: Properties
|
||||
|
||||
let module = MockAppModule()
|
||||
let rootNavigator = MockRootNavigator()
|
||||
let services = ServiceContainer.withMocks()
|
||||
let subject: AppCoordinator
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
init() {
|
||||
subject = AppCoordinator(
|
||||
appContext: .mainApp,
|
||||
module: module,
|
||||
rootNavigator: rootNavigator,
|
||||
services: services,
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `switchToSettingsTab(route:)` navigates to the settings tab with the specified route.
|
||||
@Test
|
||||
func switchToSettingsTab_navigatesToSettingsTab() {
|
||||
subject.switchToSettingsTab(route: .settings)
|
||||
|
||||
#expect(module.tabCoordinator.routes.last == .settings(.settings))
|
||||
}
|
||||
|
||||
/// `switchToSettingsTab(route:)` navigates to the settings tab with the export items route.
|
||||
@Test
|
||||
func switchToSettingsTab_navigatesToExportItems() {
|
||||
subject.switchToSettingsTab(route: .exportItems)
|
||||
|
||||
#expect(module.tabCoordinator.routes.last == .settings(.exportItems))
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,9 @@ final class TabCoordinator: Coordinator, HasTabNavigator {
|
||||
/// The coordinator used to navigate to `ItemListRoute`s.
|
||||
private var itemListCoordinator: AnyCoordinator<ItemListRoute, ItemListEvent>?
|
||||
|
||||
/// A delegate of the `ItemListCoordinator`.
|
||||
private weak var itemListDelegate: ItemListCoordinatorDelegate?
|
||||
|
||||
/// The module used to create child coordinators.
|
||||
private let module: Module
|
||||
|
||||
@ -45,20 +48,20 @@ final class TabCoordinator: Coordinator, HasTabNavigator {
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - errorReporter: The error reporter used by the tab coordinator.
|
||||
/// - itemListDelegate: A delegate of the `ItemListCoordinator`.
|
||||
/// - module: The module used to create child coordinators.
|
||||
/// - rootNavigator: The root navigator used to display this coordinator's interface.
|
||||
/// - settingsDelegate: A delegate of the `SettingsCoordinator`.
|
||||
/// - tabNavigator: The tab navigator that is managed by this coordinator.
|
||||
/// - vaultDelegate: A delegate of the `VaultCoordinator`.
|
||||
/// - vaultRepository: A vault repository used to the vault tab title.
|
||||
///
|
||||
init(
|
||||
errorReporter: ErrorReporter,
|
||||
itemListDelegate: ItemListCoordinatorDelegate,
|
||||
module: Module,
|
||||
rootNavigator: RootNavigator,
|
||||
tabNavigator: TabNavigator,
|
||||
) {
|
||||
self.errorReporter = errorReporter
|
||||
self.itemListDelegate = itemListDelegate
|
||||
self.module = module
|
||||
self.rootNavigator = rootNavigator
|
||||
self.tabNavigator = tabNavigator
|
||||
@ -82,13 +85,14 @@ final class TabCoordinator: Coordinator, HasTabNavigator {
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard let rootNavigator, let tabNavigator else { return }
|
||||
guard let itemListDelegate, let rootNavigator, let tabNavigator else { return }
|
||||
|
||||
rootNavigator.show(child: tabNavigator)
|
||||
|
||||
let itemListNavigator = module.makeNavigationController()
|
||||
itemListNavigator.navigationBar.prefersLargeTitles = true
|
||||
itemListCoordinator = module.makeItemListCoordinator(
|
||||
delegate: itemListDelegate,
|
||||
stackNavigator: itemListNavigator,
|
||||
)
|
||||
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
import BitwardenKit
|
||||
import BitwardenKitMocks
|
||||
import Testing
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
@MainActor
|
||||
struct TabCoordinatorTests {
|
||||
// MARK: Properties
|
||||
|
||||
let errorReporter = MockErrorReporter()
|
||||
let itemListDelegate = MockItemListCoordinatorDelegate()
|
||||
let module = MockAppModule()
|
||||
let rootNavigator = MockRootNavigator()
|
||||
let tabNavigator = MockTabNavigator()
|
||||
let subject: TabCoordinator
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
init() {
|
||||
subject = TabCoordinator(
|
||||
errorReporter: errorReporter,
|
||||
itemListDelegate: itemListDelegate,
|
||||
module: module,
|
||||
rootNavigator: rootNavigator,
|
||||
tabNavigator: tabNavigator,
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `start()` shows the tab navigator as a child of the root navigator.
|
||||
@Test
|
||||
func start_showsTabNavigator() {
|
||||
subject.start()
|
||||
|
||||
#expect(rootNavigator.navigatorShown === tabNavigator)
|
||||
}
|
||||
|
||||
/// `start()` sets up the tab navigator with the correct navigators.
|
||||
@Test
|
||||
func start_setsNavigators() {
|
||||
subject.start()
|
||||
|
||||
#expect(tabNavigator.navigators.count == 2)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.itemList` navigates to the item list route.
|
||||
@Test
|
||||
func navigateTo_itemList() {
|
||||
subject.start()
|
||||
|
||||
subject.navigate(to: .itemList(.list), context: nil)
|
||||
|
||||
#expect(module.itemListCoordinator.routes == [.list])
|
||||
#expect(module.itemListCoordinatorDelegate === itemListDelegate)
|
||||
#expect(tabNavigator.selectedIndex == TabRoute.itemList(.list).index)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.settings` navigates to the settings route.
|
||||
@Test
|
||||
func navigateTo_settings() {
|
||||
subject.start()
|
||||
|
||||
subject.navigate(to: .settings(.settings), context: nil)
|
||||
|
||||
#expect(module.settingsCoordinator.routes == [.settings])
|
||||
#expect(tabNavigator.selectedIndex == TabRoute.settings(.settings).index)
|
||||
}
|
||||
}
|
||||
@ -5,20 +5,20 @@ import UIKit
|
||||
|
||||
/// An object that builds coordinators for the tab interface.
|
||||
///
|
||||
@MainActor
|
||||
protocol TabModule: AnyObject {
|
||||
/// Initializes a coordinator for navigating to `TabRoute`s.
|
||||
///
|
||||
/// - Parameter:
|
||||
/// - errorReporter: The error reporter used by the tab module.
|
||||
/// - itemListDelegate: The delegate of the `ItemListCoordinator`.
|
||||
/// - rootNavigator: The root navigator used to display this coordinator's interface.
|
||||
/// - settingsDelegate: The delegate for the settings coordinator.
|
||||
/// - tabNavigator: The navigator used by the coordinator to navigate between routes.
|
||||
/// - vaultDelegate: The delegate for the vault coordinator.
|
||||
/// - vaultRepository: The vault repository used by the tab module.
|
||||
/// - Returns: A new coordinator that can navigate to any `TabRoute`.
|
||||
///
|
||||
func makeTabCoordinator(
|
||||
errorReporter: ErrorReporter,
|
||||
itemListDelegate: ItemListCoordinatorDelegate,
|
||||
rootNavigator: RootNavigator,
|
||||
tabNavigator: TabNavigator,
|
||||
) -> AnyCoordinator<TabRoute, Void>
|
||||
@ -29,11 +29,13 @@ protocol TabModule: AnyObject {
|
||||
extension DefaultAppModule: TabModule {
|
||||
func makeTabCoordinator(
|
||||
errorReporter: ErrorReporter,
|
||||
itemListDelegate: ItemListCoordinatorDelegate,
|
||||
rootNavigator: RootNavigator,
|
||||
tabNavigator: TabNavigator,
|
||||
) -> AnyCoordinator<TabRoute, Void> {
|
||||
TabCoordinator(
|
||||
errorReporter: errorReporter,
|
||||
itemListDelegate: itemListDelegate,
|
||||
module: self,
|
||||
rootNavigator: rootNavigator,
|
||||
tabNavigator: tabNavigator,
|
||||
|
||||
@ -11,8 +11,8 @@ final class TutorialCoordinator: Coordinator, HasStackNavigator {
|
||||
/// The module types required for creating child coordinators.
|
||||
typealias Module = DefaultAppModule
|
||||
|
||||
typealias Services = HasErrorReporter
|
||||
& HasErrorAlertServices.ErrorAlertServices
|
||||
typealias Services = HasErrorAlertServices.ErrorAlertServices
|
||||
& HasErrorReporter
|
||||
& HasStateService
|
||||
|
||||
// MARK: Private Properties
|
||||
@ -84,4 +84,3 @@ final class TutorialCoordinator: Coordinator, HasStackNavigator {
|
||||
extension TutorialCoordinator: HasErrorAlertServices {
|
||||
var errorAlertServices: ErrorAlertServices { services }
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,9 @@ enum ItemListAction: Equatable {
|
||||
///
|
||||
case itemPressed(_ item: ItemListItem)
|
||||
|
||||
/// The user tapped the go to settings button in the flight recorder banner.
|
||||
case navigateToFlightRecorderSettings
|
||||
|
||||
/// The user has started or stopped searching.
|
||||
case searchStateChanged(isSearching: Bool)
|
||||
|
||||
|
||||
@ -15,6 +15,9 @@ enum ItemListEffect: Equatable {
|
||||
///
|
||||
case copyPressed(_ item: ItemListItem)
|
||||
|
||||
/// The flight recorder toast banner was dismissed.
|
||||
case dismissFlightRecorderToastBanner
|
||||
|
||||
/// The Move to Bitwarden item button was pressed.
|
||||
///
|
||||
/// - Parameter item: The item that should be moved.
|
||||
@ -27,6 +30,9 @@ enum ItemListEffect: Equatable {
|
||||
/// Searches based on the keyword.
|
||||
case search(String)
|
||||
|
||||
/// Stream the active flight recorder log.
|
||||
case streamFlightRecorderLog
|
||||
|
||||
/// Stream the vault list for the user.
|
||||
case streamItemList
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
|
||||
& HasCameraService
|
||||
& HasConfigService
|
||||
& HasErrorReporter
|
||||
& HasFlightRecorder
|
||||
& HasNotificationCenterService
|
||||
& HasPasteboardService
|
||||
& HasTOTPExpirationManagerFactory
|
||||
@ -99,6 +100,8 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
|
||||
else { return }
|
||||
await generateAndCopyTotpCode(totpKey: totpKey)
|
||||
}
|
||||
case .dismissFlightRecorderToastBanner:
|
||||
await dismissFlightRecorderToastBanner()
|
||||
case let .moveToBitwardenPressed(item):
|
||||
guard case let .totp(model) = item.itemType else { return }
|
||||
await moveItemToBitwarden(item: model.itemView)
|
||||
@ -107,6 +110,8 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
|
||||
await streamItemList()
|
||||
case let .search(text):
|
||||
await searchItems(for: text)
|
||||
case .streamFlightRecorderLog:
|
||||
await streamFlightRecorderLog()
|
||||
case .streamItemList:
|
||||
await streamItemList()
|
||||
}
|
||||
@ -127,6 +132,8 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
|
||||
|
||||
services.pasteboardService.copy(totpCode.code)
|
||||
state.toast = Toast(title: Localizations.valueHasBeenCopied(Localizations.verificationCode))
|
||||
case .navigateToFlightRecorderSettings:
|
||||
coordinator.navigate(to: .flightRecorderSettings)
|
||||
case let .searchStateChanged(isSearching: isSearching):
|
||||
guard isSearching else {
|
||||
state.searchText = ""
|
||||
@ -159,6 +166,12 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismisses the flight recorder toast banner.
|
||||
///
|
||||
private func dismissFlightRecorderToastBanner() async {
|
||||
await services.flightRecorder.setFlightRecorderBannerDismissed()
|
||||
}
|
||||
|
||||
/// Initializes the TOTP expiration managers so the TOTP codes are refreshed automatically.
|
||||
///
|
||||
func initTotpExpirationManagers() {
|
||||
@ -347,6 +360,14 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
|
||||
}
|
||||
}
|
||||
|
||||
/// Streams the flight recorder enabled status.
|
||||
///
|
||||
private func streamFlightRecorderLog() async {
|
||||
for await log in await services.flightRecorder.activeLogPublisher().values {
|
||||
state.flightRecorderToastBanner.activeLog = log
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream the items list.
|
||||
private func streamItemList() async {
|
||||
do {
|
||||
|
||||
@ -20,6 +20,7 @@ class ItemListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
|
||||
var configService: MockConfigService!
|
||||
var coordinator: MockCoordinator<ItemListRoute, ItemListEvent>!
|
||||
var errorReporter: MockErrorReporter!
|
||||
var flightRecorder: MockFlightRecorder!
|
||||
var notificationCenterService: MockNotificationCenterService!
|
||||
var pasteboardService: MockPasteboardService!
|
||||
var totpService: MockTOTPService!
|
||||
@ -40,6 +41,7 @@ class ItemListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
|
||||
configService = MockConfigService()
|
||||
coordinator = MockCoordinator()
|
||||
errorReporter = MockErrorReporter()
|
||||
flightRecorder = MockFlightRecorder()
|
||||
notificationCenterService = MockNotificationCenterService()
|
||||
pasteboardService = MockPasteboardService()
|
||||
totpService = MockTOTPService()
|
||||
@ -59,6 +61,7 @@ class ItemListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
|
||||
cameraService: cameraService,
|
||||
configService: configService,
|
||||
errorReporter: errorReporter,
|
||||
flightRecorder: flightRecorder,
|
||||
notificationCenterService: notificationCenterService,
|
||||
pasteboardService: pasteboardService,
|
||||
totpExpirationManagerFactory: totpExpirationManagerFactory,
|
||||
@ -81,6 +84,7 @@ class ItemListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
|
||||
cameraService = nil
|
||||
configService = nil
|
||||
errorReporter = nil
|
||||
flightRecorder = nil
|
||||
notificationCenterService = nil
|
||||
pasteboardService = nil
|
||||
totpService = nil
|
||||
@ -300,6 +304,15 @@ class ItemListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
|
||||
XCTAssertNil(pasteboardService.copiedString)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.dismissFlightRecorderToastBanner` hides the flight recorder toast banner.
|
||||
@MainActor
|
||||
func test_perform_dismissFlightRecorderToastBanner() async {
|
||||
await subject.perform(.dismissFlightRecorderToastBanner)
|
||||
|
||||
XCTAssertFalse(subject.state.flightRecorderToastBanner.isToastBannerVisible)
|
||||
XCTAssertTrue(flightRecorder.setFlightRecorderBannerDismissedCalled)
|
||||
}
|
||||
|
||||
/// `perform(:_)` with `.moveToBitwardenPressed()` with a local item stores the item in the shared
|
||||
/// store and launches the Bitwarden app via the new item deep link.
|
||||
@MainActor
|
||||
@ -451,6 +464,24 @@ class ItemListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
|
||||
XCTAssertEqual(subject.state.searchResults, [firstItemRefreshed])
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.streamFlightRecorderLog` streams the flight recorder log and displays
|
||||
/// the flight recorder banner if the user hasn't dismissed it previously.
|
||||
@MainActor
|
||||
func test_perform_streamFlightRecorderLog() async throws {
|
||||
let task = Task {
|
||||
await subject.perform(.streamFlightRecorderLog)
|
||||
}
|
||||
defer { task.cancel() }
|
||||
|
||||
flightRecorder.activeLogSubject.send(FlightRecorderData.LogMetadata(duration: .eightHours, startDate: .now))
|
||||
try await waitForAsync { self.subject.state.flightRecorderToastBanner.isToastBannerVisible }
|
||||
XCTAssertEqual(subject.state.flightRecorderToastBanner.isToastBannerVisible, true)
|
||||
|
||||
flightRecorder.activeLogSubject.send(nil)
|
||||
try await waitForAsync { !self.subject.state.flightRecorderToastBanner.isToastBannerVisible }
|
||||
XCTAssertEqual(subject.state.flightRecorderToastBanner.isToastBannerVisible, false)
|
||||
}
|
||||
|
||||
/// `perform(_:)` with `.streamItemList` starts streaming vault items. When there are no shared
|
||||
/// account items, does not show a toast.
|
||||
@MainActor
|
||||
@ -639,6 +670,14 @@ class ItemListProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
|
||||
XCTAssertNil(subject.state.url)
|
||||
}
|
||||
|
||||
/// `receive(_:)` with `.navigateToFlightRecorderSettings` navigates to the flight recorder settings.
|
||||
@MainActor
|
||||
func test_receive_navigateToFlightRecorderSettings() {
|
||||
subject.receive(.navigateToFlightRecorderSettings)
|
||||
|
||||
XCTAssertEqual(coordinator.routes.last, .flightRecorderSettings)
|
||||
}
|
||||
|
||||
/// `refreshTOTPCodes(for:)` does nothing if state.loadingState is nil
|
||||
@MainActor
|
||||
func test_refreshTOTPCodes_forItemsReturnsEmpty() {
|
||||
|
||||
@ -12,6 +12,9 @@ struct ItemListState: Equatable {
|
||||
loadingState.data.isEmptyOrNil
|
||||
}
|
||||
|
||||
/// 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?
|
||||
|
||||
|
||||
@ -41,6 +41,16 @@ class ItemListViewTests: BitwardenTestCase {
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
@MainActor
|
||||
func disabletest_snapshot_flightRecorderToastBanner() {
|
||||
processor.state.loadingState = .data([])
|
||||
processor.state.flightRecorderToastBanner.activeLog = FlightRecorderData.LogMetadata(
|
||||
duration: .twentyFourHours,
|
||||
startDate: Date(year: 2025, month: 4, day: 3),
|
||||
)
|
||||
assertSnapshot(of: subject, as: .defaultPortrait)
|
||||
}
|
||||
|
||||
/// Test a snapshot of the ItemListView previews.
|
||||
func disabletest_snapshot_ItemListView_previews() {
|
||||
for preview in ItemListView_Previews._allPreviews {
|
||||
|
||||
@ -78,4 +78,17 @@ class ItemListViewTests: BitwardenTestCase {
|
||||
|
||||
XCTAssertEqual(processor.effects.last, .closeCard(.passwordManagerSync))
|
||||
}
|
||||
|
||||
/// Tapping the go to settings button in the flight recorder toast banner dispatches the
|
||||
/// `.navigateToFlightRecorderSettings` action.
|
||||
@MainActor
|
||||
func test_flightRecorderToastBannerGoToSettings_tap() async throws {
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import SwiftUI
|
||||
// MARK: - SearchableItemListView
|
||||
|
||||
/// A view that displays the items in a single vault group.
|
||||
private struct SearchableItemListView: View {
|
||||
private struct SearchableItemListView: View { // swiftlint:disable:this type_body_length
|
||||
// MARK: Properties
|
||||
|
||||
/// A flag indicating if the search bar is focused.
|
||||
@ -56,11 +56,17 @@ private struct SearchableItemListView: View {
|
||||
get: \.toast,
|
||||
send: ItemListAction.toastShown,
|
||||
))
|
||||
.flightRecorderToastBanner(
|
||||
activeLog: store.state.flightRecorderToastBanner.activeLog,
|
||||
isVisible: store.bindingAsync(
|
||||
get: \.flightRecorderToastBanner.isToastBannerVisible,
|
||||
perform: { _ in .dismissFlightRecorderToastBanner },
|
||||
),
|
||||
goToSettingsAction: {
|
||||
store.send(.navigateToFlightRecorderSettings)
|
||||
},
|
||||
)
|
||||
.animation(.default, value: isSearching)
|
||||
.toast(store.binding(
|
||||
get: \.toast,
|
||||
send: ItemListAction.toastShown,
|
||||
))
|
||||
.onChange(of: store.state.url) { newValue in
|
||||
guard let url = newValue else { return }
|
||||
openURL(url)
|
||||
@ -381,6 +387,9 @@ struct ItemListView: View {
|
||||
.task {
|
||||
await store.perform(.appeared)
|
||||
}
|
||||
.task {
|
||||
await store.perform(.streamFlightRecorderLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
@ -2,6 +2,20 @@ import BitwardenKit
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - ItemListCoordinatorDelegate
|
||||
|
||||
/// A delegate of `ItemListCoordinator` that is notified when navigation events occur that
|
||||
/// require coordination with other parts of the app.
|
||||
///
|
||||
@MainActor
|
||||
public protocol ItemListCoordinatorDelegate: AnyObject {
|
||||
/// Called when the user needs to switch to the settings tab and navigate to a `SettingsRoute`.
|
||||
///
|
||||
/// - Parameter route: The route to navigate to in the settings tab.
|
||||
///
|
||||
func switchToSettingsTab(route: SettingsRoute)
|
||||
}
|
||||
|
||||
// MARK: - ItemListCoordinator
|
||||
|
||||
/// A coordinator that manages navigation on the Item List screen.
|
||||
@ -13,13 +27,16 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
|
||||
& ItemListModule
|
||||
& NavigatorBuilderModule
|
||||
|
||||
typealias Services = HasTimeProvider
|
||||
& HasErrorAlertServices.ErrorAlertServices
|
||||
& ItemListProcessor.Services
|
||||
typealias Services = HasErrorAlertServices.ErrorAlertServices
|
||||
& HasTimeProvider
|
||||
& HasTOTPExpirationManagerFactory
|
||||
& ItemListProcessor.Services
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
/// The delegate for this coordinator, notified for navigation to other parts of the app.
|
||||
private weak var delegate: ItemListCoordinatorDelegate?
|
||||
|
||||
/// The module used by this coordinator to create child coordinators.
|
||||
private let module: Module
|
||||
|
||||
@ -36,15 +53,18 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
|
||||
/// Creates a new `ItemListCoordinator`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - delegate: The delegate for this coordinator, notified for navigation to other parts of the app.
|
||||
/// - module: The module used by this coordinator to create child coordinators.
|
||||
/// - services: The services used by this coordinator.
|
||||
/// - stackNavigator: The stack navigator that is managed by this coordinator.
|
||||
///
|
||||
init(
|
||||
delegate: ItemListCoordinatorDelegate,
|
||||
module: Module,
|
||||
services: Services,
|
||||
stackNavigator: StackNavigator,
|
||||
) {
|
||||
self.delegate = delegate
|
||||
self.module = module
|
||||
self.services = services
|
||||
self.stackNavigator = stackNavigator
|
||||
@ -66,6 +86,8 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
|
||||
break
|
||||
case let .editItem(item):
|
||||
showItem(route: .editAuthenticatorItem(item), delegate: context as? AuthenticatorItemOperationDelegate)
|
||||
case .flightRecorderSettings:
|
||||
delegate?.switchToSettingsTab(route: .settings)
|
||||
case .list:
|
||||
showList()
|
||||
case .setupTotpManual:
|
||||
@ -142,4 +164,3 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
|
||||
extension ItemListCoordinator: HasErrorAlertServices {
|
||||
var errorAlertServices: ErrorAlertServices { services }
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
import BitwardenKit
|
||||
import BitwardenKitMocks
|
||||
import Testing
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
@MainActor
|
||||
struct ItemListCoordinatorTests {
|
||||
// MARK: Properties
|
||||
|
||||
let delegate = MockItemListCoordinatorDelegate()
|
||||
let module = MockAppModule()
|
||||
let stackNavigator = MockStackNavigator()
|
||||
let subject: ItemListCoordinator
|
||||
let totpExpirationManagerFactory = MockTOTPExpirationManagerFactory()
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
init() {
|
||||
totpExpirationManagerFactory.createResults = [
|
||||
MockTOTPExpirationManager(),
|
||||
MockTOTPExpirationManager(),
|
||||
]
|
||||
|
||||
subject = ItemListCoordinator(
|
||||
delegate: delegate,
|
||||
module: module,
|
||||
services: ServiceContainer.withMocks(
|
||||
totpExpirationManagerFactory: totpExpirationManagerFactory,
|
||||
),
|
||||
stackNavigator: stackNavigator,
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `navigate(to:)` with `.editItem` presents the edit item view.
|
||||
@Test
|
||||
func navigateTo_editItem() throws {
|
||||
let item = AuthenticatorItemView.fixture()
|
||||
|
||||
subject.navigate(to: .editItem(item: item), context: nil)
|
||||
|
||||
#expect(module.authenticatorItemCoordinator.isStarted)
|
||||
#expect(module.authenticatorItemCoordinator.routes == [.editAuthenticatorItem(item)])
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.flightRecorderSettings` calls the delegate's `switchToSettingsTab` method.
|
||||
@Test
|
||||
func navigateTo_flightRecorderSettings() {
|
||||
subject.navigate(to: .flightRecorderSettings, context: nil)
|
||||
|
||||
#expect(delegate.switchToSettingsTabRoute == .settings)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.list` pushes the item list view onto the stack.
|
||||
@Test
|
||||
func navigateTo_list() throws {
|
||||
subject.navigate(to: .list, context: nil)
|
||||
|
||||
let action = try #require(stackNavigator.actions.last)
|
||||
#expect(action.type == .replaced)
|
||||
#expect(action.view is ItemListView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MockItemListCoordinatorDelegate
|
||||
|
||||
class MockItemListCoordinatorDelegate: ItemListCoordinatorDelegate {
|
||||
var switchToSettingsTabRoute: SettingsRoute?
|
||||
|
||||
func switchToSettingsTab(route: SettingsRoute) {
|
||||
switchToSettingsTabRoute = route
|
||||
}
|
||||
}
|
||||
@ -14,15 +14,18 @@ protocol ItemListModule {
|
||||
/// - Returns: A coordinator that can navigate to an `ItemListRoute`
|
||||
///
|
||||
func makeItemListCoordinator(
|
||||
delegate: ItemListCoordinatorDelegate,
|
||||
stackNavigator: StackNavigator,
|
||||
) -> AnyCoordinator<ItemListRoute, ItemListEvent>
|
||||
}
|
||||
|
||||
extension DefaultAppModule: ItemListModule {
|
||||
func makeItemListCoordinator(
|
||||
delegate: ItemListCoordinatorDelegate,
|
||||
stackNavigator: StackNavigator,
|
||||
) -> AnyCoordinator<ItemListRoute, ItemListEvent> {
|
||||
ItemListCoordinator(
|
||||
delegate: delegate,
|
||||
module: self,
|
||||
services: services,
|
||||
stackNavigator: stackNavigator,
|
||||
|
||||
@ -11,6 +11,9 @@ public enum ItemListRoute: Equatable, Hashable {
|
||||
/// A route to the edit item screen
|
||||
case editItem(item: AuthenticatorItemView)
|
||||
|
||||
/// A route to the flight recorder settings in the settings tab.
|
||||
case flightRecorderSettings
|
||||
|
||||
/// A route to the base item list screen.
|
||||
case list
|
||||
|
||||
|
||||
@ -8,21 +8,26 @@ import UIKit
|
||||
class MockAppModule:
|
||||
AppModule,
|
||||
AuthModule,
|
||||
AuthenticatorItemModule,
|
||||
DebugMenuModule,
|
||||
FileSelectionModule,
|
||||
FlightRecorderModule,
|
||||
ItemListModule,
|
||||
NavigatorBuilderModule,
|
||||
TutorialModule,
|
||||
TabModule {
|
||||
SettingsModule,
|
||||
TabModule,
|
||||
TutorialModule {
|
||||
var appCoordinator = MockCoordinator<AppRoute, AppEvent>()
|
||||
var authCoordinator = MockCoordinator<AuthRoute, AuthEvent>()
|
||||
var authRouter = MockRouter<AuthEvent, AuthRoute>(routeForEvent: { _ in .vaultUnlock })
|
||||
var authenticatorItemCoordinator = MockCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent>()
|
||||
var debugMenuCoordinator = MockCoordinator<DebugMenuRoute, Void>()
|
||||
var fileSelectionDelegate: FileSelectionDelegate?
|
||||
var fileSelectionCoordinator = MockCoordinator<FileSelectionRoute, FileSelectionEvent>()
|
||||
var flightRecorderCoordinator = MockCoordinator<FlightRecorderRoute, Void>()
|
||||
var itemListCoordinator = MockCoordinator<ItemListRoute, ItemListEvent>()
|
||||
var itemListCoordinatorDelegate: ItemListCoordinatorDelegate?
|
||||
var settingsCoordinator = MockCoordinator<SettingsRoute, SettingsEvent>()
|
||||
var tabCoordinator = MockCoordinator<TabRoute, Void>()
|
||||
var tutorialCoordinator = MockCoordinator<TutorialRoute, TutorialEvent>()
|
||||
|
||||
@ -45,6 +50,12 @@ class MockAppModule:
|
||||
authRouter.asAnyRouter()
|
||||
}
|
||||
|
||||
func makeAuthenticatorItemCoordinator(
|
||||
stackNavigator _: StackNavigator,
|
||||
) -> AnyCoordinator<AuthenticatorItemRoute, AuthenticatorItemEvent> {
|
||||
authenticatorItemCoordinator.asAnyCoordinator()
|
||||
}
|
||||
|
||||
func makeDebugMenuCoordinator(
|
||||
stackNavigator: StackNavigator,
|
||||
) -> AnyCoordinator<DebugMenuRoute, Void> {
|
||||
@ -66,9 +77,17 @@ class MockAppModule:
|
||||
}
|
||||
|
||||
func makeItemListCoordinator(
|
||||
delegate: ItemListCoordinatorDelegate,
|
||||
stackNavigator _: StackNavigator,
|
||||
) -> AnyCoordinator<ItemListRoute, ItemListEvent> {
|
||||
itemListCoordinator.asAnyCoordinator()
|
||||
itemListCoordinatorDelegate = delegate
|
||||
return itemListCoordinator.asAnyCoordinator()
|
||||
}
|
||||
|
||||
func makeSettingsCoordinator(
|
||||
stackNavigator _: StackNavigator,
|
||||
) -> AnyCoordinator<SettingsRoute, SettingsEvent> {
|
||||
settingsCoordinator.asAnyCoordinator()
|
||||
}
|
||||
|
||||
func makeNavigationController() -> UINavigationController {
|
||||
@ -77,6 +96,7 @@ class MockAppModule:
|
||||
|
||||
func makeTabCoordinator(
|
||||
errorReporter _: ErrorReporter,
|
||||
itemListDelegate _: ItemListCoordinatorDelegate,
|
||||
rootNavigator _: RootNavigator,
|
||||
tabNavigator _: TabNavigator,
|
||||
) -> AnyCoordinator<TabRoute, Void> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user