8000 PM-12565: Add settings badges for account setup steps by matt-livefront · Pull Request #972 · bitwarden/ios · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

PM-12565: Add settings badges for account setup steps #972

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions BitwardenShared/Core/Platform/Services/StateService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,12 @@ protocol StateService: AnyObject {
///
func lastSyncTimePublisher() async throws -> AnyPublisher<Date?, Never>

/// A publisher for showing badges in the settings tab.
///
/// - Returns: A publisher for showing badges in the settings tab.
///
func settingsBadgePublisher() async throws -> AnyPublisher<String?, Never>

/// A publisher for whether or not to show the web icons.
///
/// - Returns: A publisher for whether or not to show the web icons.
Expand Down Expand Up @@ -1178,6 +1184,9 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
/// A service used to access data in the keychain.
private let keychainRepository: KeychainRepository

/// A subject containing the settings badge value mapped to user ID.
private let settingsBadgeByUserIdSubject = CurrentValueSubject<[String: String?], Never>([:])

/// A subject containing whether to show the website icons.
private var showWebIconsSubject: CurrentValueSubject<Bool, Never>

Expand Down Expand Up @@ -1513,11 +1522,13 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
func setAccountSetupAutofill(_ autofillSetup: AccountSetupProgress?, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
appSettingsStore.setAccountSetupAutofill(autofillSetup, userId: userId)
try await updateSettingsBadgePublisher(userId: userId)
}

func setAccountSetupVaultUnlock(_ vaultUnlockSetup: AccountSetupProgress?, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
appSettingsStore.setAccountSetupVaultUnlock(vaultUnlockSetup, userId: userId)
try await updateSettingsBadgePublisher(userId: userId)
Comment on lines +1525 to +1531
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 @matt-livefront these methods are currently called in loginWithMasterPassword so potentially if updateSettingsBadgePublisher throws then loginWithMasterPassword would fail which is not correct IMO. The badge update is a secondary effect but that shouldn't block the flow of logging in.
I think the updateSettingsBadgePublisher should not throw and just wrap the code in a do...catch and log any error to the errorReporter so it doesn't affect any caller flows.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, great point! I have a fix here: #988.

}

func setActiveAccount(userId: String) async throws {
Expand Down Expand Up @@ -1761,6 +1772,12 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
return lastSyncTimeByUserIdSubject.map { $0[userId] }.eraseToAnyPublisher()
}

func settingsBadgePublisher() async throws -> AnyPublisher<String?, Never> {
let userId = try getActiveAccountUserId()
try await updateSettingsBadgePublisher(userId: userId)
return settingsBadgeByUserIdSubject.compactMap { $0[userId] }.eraseToAnyPublisher()
}

func showWebIconsPublisher() async -> AnyPublisher<Bool, Never> {
showWebIconsSubject.eraseToAnyPublisher()
}
Expand Down Expand Up @@ -1790,6 +1807,20 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
}
return activeUserId
}

/// Updates the settings badge publisher by determining the settings badge count for the user.
///
/// - Parameter userId: The user ID whose settings badge count should be updated.
///
private func updateSettingsBadgePublisher(userId: String) async throws {
let autofillSetupProgress = try await getAccountSetupAutofill(userId: userId)
let vaultUnlockSetupProgress = try await getAccountSetupVaultUnlock(userId: userId)
let badgeCount = [autofillSetupProgress, vaultUnlockSetupProgress]
.compactMap { $0 }
.filter { $0 != .complete }
.count
settingsBadgeByUserIdSubject.value[userId] = badgeCount > 0 ? String(badgeCount) : nil
}
}
8000
// MARK: - AccountVolatileData
Expand Down
27 changes: 27 additions & 0 deletions BitwardenShared/Core/Platform/Services/StateServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1721,6 +1721,33 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertTrue(appSettingsStore.syncToAuthenticator(userId: "1"))
}

/// `settingsBadgePublisher()` publishes the settings badge value for the active user.
func test_settingsBadgePublisher() async throws {
await subject.addAccount(.fixture())

var badgeValues = [String?]()
let publisher = try await subject.settingsBadgePublisher()
.sink { badgeValue in
badgeValues.append(badgeValue)
}
defer { publisher.cancel() }

try await subject.setAccountSetupAutofill(.setUpLater)
try await subject.setAccountSetupVaultUnlock(.setUpLater)

try await subject.setAccountSetupAutofill(.complete)
try await subject.setAccountSetupVaultUnlock(.complete)

XCTAssertEqual(badgeValues, [nil, "1", "2", "1", nil])
}

/// `settingsBadgePublisher()` throws an error if there's no active account.
func test_settingsBadgePublisher_error() async throws {
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
_ = try await subject.settingsBadgePublisher()
}
}

/// `setTwoFactorToken(_:email:)` sets the two-factor code for the email.
func test_setTwoFactorToken() async {
await subject.setTwoFactorToken("yay_you_win!", email: "winner@email.com")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
var setAccountHasBeenUnlockedInteractivelyResult: Result<Void, Error> = .success(())
var setBiometricAuthenticationEnabledResult: Result<Void, Error> = .success(())
var setBiometricIntegrityStateError: Error?
var settingsBadgeSubject = CurrentValueSubject<String?, Never>(nil)
var shouldTrustDevice = [String: Bool?]()
var syncToAuthenticatorByUserId = [String: Bool]()
var syncToAuthenticatorResult: Result<Void, Error> = .success(())
Expand Down Expand Up @@ -623,6 +624,11 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
lastSyncTimeSubject.eraseToAnyPublisher()
}

func settingsBadgePublisher() async throws -> AnyPublisher<String?, Never> {
_ = try unwrapUserId(nil)
return settingsBadgeSubject.eraseToAnyPublisher()
}

func showWebIconsPublisher() async -> AnyPublisher<Bool, Never> {
showWebIconsSubject.eraseToAnyPublisher()
}
Expand Down
10 changes: 10 additions & 0 deletions BitwardenShared/UI/Platform/Application/Appearance/UI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ public enum UI {
let largeTitleFont = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(
for: FontFamily.DMSans.bold.font(size: 26)
)
let iconBadgeBackground = Asset.Colors.iconBadgeBackground.color
let iconBadgeTextAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: Asset.Colors.iconBadgeForeground.color,
]

let navigationBarAppearance = UINavigationBarAppearance()
navigationBarAppearance.backgroundColor = Asset.Colors.backgroundSecondary.color
Expand All @@ -76,6 +80,12 @@ public enum UI {
let tabBarAppearance = UITabBarAppearance()
tabBarAppearance.configureWithOpaqueBackground()
tabBarAppearance.backgroundColor = Asset.Colors.backgroundSecondary.color
tabBarAppearance.compactInlineLayoutAppearance.normal.badgeBackgroundColor = iconBadgeBackground
tabBarAppearance.compactInlineLayoutAppearance.normal.badgeTextAttributes = iconBadgeTextAttributes
tabBarAppearance.inlineLayoutAppearance.normal.badgeBackgroundColor = iconBadgeBackground
tabBarAppearance.inlineLayoutAppearance.normal.badgeTextAttributes = iconBadgeTextAttributes
tabBarAppearance.stackedLayoutAppearance.normal.badgeBackgroundColor = iconBadgeBackground
tabBarAppearance.stackedLayoutAppearance.normal.badgeTextAttributes = iconBadgeTextAttributes
UITabBar.appearance().scrollEdgeAppearance = tabBarAppearance
UITabBar.appearance().standardAppearance = tabBarAppearance
UITabBar.appearance().tintColor = Asset.Colors.iconSecondary.color
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,18 @@ class UITabBarControllerExtensionsTests: BitwardenTestCase {
}

/// Tests that the tab bar items are laid out correctly with settings selected in light mode.
@MainActor
func test_snapshot_tabBarItems_settingsSelected_lightMode() {
module.settingsNavigator?.rootViewController?.tabBarItem.badgeValue = "1"
subject.overrideUserInterfaceStyle = .light
subject.selectedIndex = 3
assertSnapshot(of: subject, as: .image)
}

/// Tests that the tab bar items are laid out correctly with settings selected in dark mode.
@MainActor
func test_snapshot_tabBarItems_settingsSelected_darkMode() {
module.settingsNavigator?.rootViewController?.tabBarItem.badgeValue = "1"
subject.overrideUserInterfaceStyle = .dark
subject.selectedIndex = 3
assertSnapshot(of: subject, as: .image)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,32 +1,80 @@
// MARK: - SettingsProcessorDelegate

/// A delegate of `SettingsProcessor` that is notified when the settings tab badge needs to be updated.
///
@MainActor
protocol SettingsProcessorDelegate: AnyObject {
/// Called when the settings tab badge needs to be updated.
///
/// - Parameter badgeValue: The value to display in the settings tab badge.
///
func updateSettingsTabBadge(_ badgeValue: String?)
}

// MARK: - SettingsProcessor

/// The processor used to manage state and handle actions for the settings screen.
///
final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Void> {
// MARK: Types

typealias Services = HasErrorReporter
& HasSettingsRepository
typealias Services = HasConfigService
& HasErrorReporter
& HasStateService

// MARK: Private Properties

/// The task used to update the tab's badge count.
private var badgeUpdateTask: Task<Void, Never>?

/// The `Coordinator` that handles navigation.
private let coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>

/// A delegate of the processor that is notified when the settings tab badge needs to be updated.
private weak var delegate: SettingsProcessorDelegate?

/// The services used by this processor.
private var services: Services

// MARK: Initialization

/// Creates a new `SettingsProcessor`.
///
/// - Parameters:
/// - coordinator: The `Coordinator` that handles navigation.
/// - delegate: A delegate of the processor that is notified when the settings tab badge needs
/// to be updated.
/// - services: The services used by the processor.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>,
delegate: SettingsProcessorDelegate,
services: Services,
state: SettingsState
) {
self.coordinator = coordinator
self.delegate = delegate
self.services = services
super.init(state: state)

// Kick off this task in init so that the tab bar badge will be updated immediately when
// the tab bar is shown vs once the user navigates to the settings tab.
badgeUpdateTask = Task { @MainActor [weak self] in
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just for my understanding, whats the benefit of storing this task in a variable vs just kicking off the Task in the init?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing a reference to the task allows us to cancel it when the processor goes away (e.g. when you switch accounts or logout, etc).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, thanks!

guard await self?.services.configService.getFeatureFlag(.nativeCreateAccountFlow) == true else { return }
do {
guard let publisher = try await self?.services.stateService.settingsBadgePublisher() else { return }
for await badgeValue in publisher.values {
self?.delegate?.updateSettingsTabBadge(badgeValue)
}
} catch {
self?.services.errorReporter.log(error: error)
}
}
}

deinit {
badgeUpdateTask?.cancel()
}

// MARK: Methods
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,87 @@ import XCTest
class SettingsProcessorTests: BitwardenTestCase {
// MARK: Properties

var configService: MockConfigService!
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
var delegate: MockSettingsProcessorDelegate!
var errorReporter: MockErrorReporter!
var subject: SettingsProcessor!
var stateService: MockStateService!

// MARK: Setup & Teardown

override func setUp() {
super.setUp()

configService = MockConfigService()
coordinator = MockCoordinator()
subject = SettingsProcessor(
coordinator: coordinator.asAnyCoordinator(),
state: SettingsState()
)
delegate = MockSettingsProcessorDelegate()
errorReporter = MockErrorReporter()
stateService = MockStateService()

setUpSubject()
}

override func tearDown() {
super.tearDown()

configService = nil
coordinator = nil
delegate = nil
errorReporter = nil
subject = nil
stateService = nil
}

@MainActor
func setUpSubject() {
subject = SettingsProcessor(
coordinator: coordinator.asAnyCoordinator(),
delegate: delegate,
services: ServiceContainer.withMocks(
configService: configService,
errorReporter: errorReporter,
stateService: stateService
),
state: SettingsState()
)
}

// MARK: Tests

/// `init()` subscribes to the badge publisher and notifies the delegate to update the badge
/// count when it changes.
@MainActor
func test_init_subscribesToBadgePublisher() async throws {
configService.featureFlagsBool[.nativeCreateAccountFlow] = true
stateService.activeAccount = .fixture()
setUpSubject()

stateService.settingsBadgeSubject.send("1")
try await waitForAsync { self.delegate.badgeValue == "1" }
XCTAssertEqual(delegate.badgeValue, "1")

stateService.settingsBadgeSubject.send("2")
try await waitForAsync { self.delegate.badgeValue == "2" }
XCTAssertEqual(delegate.badgeValue, "2")

stateService.settingsBadgeSubject.send(nil)
try await waitForAsync { self.delegate.badgeValue == nil }
XCTAssertNil(delegate.badgeValue)
}

/// `init()` subscribes to the badge publisher and logs an error if one occurs.
@MainActor
func test_init_subscribesToBadgePublisher_error() async throws {
configService.featureFlagsBool[.nativeCreateAccountFlow] = true
setUpSubject()

stateService.settingsBadgeSubject.send("1")
try await waitForAsync { !self.errorReporter.errors.isEmpty }

XCTAssertEqual(errorReporter.errors as? [StateServiceError], [.noActiveAccount])
}

/// Receiving `.aboutPressed` navigates to the about screen.
@MainActor
func test_receive_aboutPressed() {
Expand Down Expand Up @@ -77,3 +134,11 @@ class SettingsProcessorTests: BitwardenTestCase {
XCTAssertEqual(coordinator.routes.last, .vault)
}
}

class MockSettingsProcessorDelegate: SettingsProcessorDelegate {
var badgeValue: String?

func updateSettingsTabBadge(_ badgeValue: String?) {
self.badgeValue = badgeValue
}
}
10 changes: 10 additions & 0 deletions BitwardenShared/UI/Platform/Settings/SettingsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 FD64 +401,8 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
private func showSettings() {
let processor = SettingsProcessor(
coordinator: asAnyCoordinator(),
delegate: self,
services: services,
state: SettingsState()
)
let view = SettingsView(store: Store(processor: processor))
Expand All @@ -421,3 +423,11 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator { // swiftlint:d
stackNavigator?.push(viewController, navigationTitle: Localizations.vault)
}
}

// MARK: SettingsProcessorDelegate

extension SettingsCoordinator: SettingsProcessorDelegate {
func updateSettingsTabBadge(_ badgeValue: String?) {
stackNavigator?.rootViewController?.tabBarItem.badgeValue = badgeValue
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,21 @@ class SettingsCoordinatorTests: BitwardenTestCase {

XCTAssertTrue(stackNavigator.actions.last?.view is SettingsView)
}

/// `updateSettingsTabBadge(_:)` updates the badge value on the root view controller's tab bar item.
@MainActor
func test_updateSettingsTabBadge() {
stackNavigator.rootViewController = UIViewController()

subject.updateSettingsTabBadge("1")
XCTAssertEqual(stackNavigator.rootViewController?.tabBarItem.badgeValue, "1")

subject.updateSettingsTabBadge("2")
XCTAssertEqual(stackNavigator.rootViewController?.tabBarItem.badgeValue, "2")

subject.updateSettingsTabBadge(nil)
XCTAssertNil(stackNavigator.rootViewController?.tabBarItem.badgeValue)
}
}

class MockSettingsCoordinatorDelegate: SettingsCoordinatorDelegate {
Expand Down
Loading
Loading
0