Streamline settings (#41)

This commit is contained in:
Katherine Bertelsen 2024-04-17 14:14:04 -05:00 committed by GitHub
parent 01fc8a0d32
commit 7389e8d8dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 221 additions and 948 deletions

View File

@ -900,3 +900,4 @@
"WhenUsingTwoStepVerification" = "When using 2-step verification, youll enter your username and password and a code generated in this app.";
"GetStarted" = "Get started";
"LaunchTutorial" = "Launch tutorial";
"Help" = "Help";

View File

@ -1,44 +0,0 @@
// MARK: - AboutAction
/// Actions handled by the `AboutProcessor`.
///
enum AboutAction: Equatable {
/// Clears the app review URL.
case clearAppReviewURL
/// Clears the give feedback URL.
case clearGiveFeedbackURL
/// The url has been opened so clear the value in the state.
case clearURL
/// The give feedback button was tapped.
case giveFeedbackTapped
/// The help center button was tapped.
case helpCenterTapped
/// The learn about organizations button was tapped.
case learnAboutOrganizationsTapped
/// The privacy policy button was tapped.
case privacyPolicyTapped
/// The rate the app button was tapped.
case rateTheAppTapped
/// The toast was shown or hidden.
case toastShown(Toast?)
/// The submit crash logs toggle value changed.
case toggleSubmitCrashLogs(Bool)
/// The tutorial button was tapped
case tutorialTapped
/// The version was tapped.
case versionTapped
/// The web vault button was tapped.
case webVaultTapped
}

View File

@ -1,94 +0,0 @@
// MARK: - AboutProcessor
/// The processor used to manage state and handle actions for the `AboutView`.
///
final class AboutProcessor: StateProcessor<AboutState, AboutAction, Void> {
// MARK: Types
typealias Services = HasErrorReporter
& HasPasteboardService
// MARK: Properties
/// The coordinator used to manage navigation.
private let coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>
/// The services used by this processor.
private let services: Services
// MARK: Initialization
/// Initializes a new `AboutProcessor`.
///
/// - Parameters:
/// - coordinator: The coordinator used to manage navigation.
/// - services: The services used by this processor.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>,
services: Services,
state: AboutState
) {
self.coordinator = coordinator
self.services = services
// Set the initial value of the crash logs toggle.
var state = state
state.isSubmitCrashLogsToggleOn = self.services.errorReporter.isEnabled
super.init(state: state)
}
// MARK: Methods
override func receive(_ action: AboutAction) {
switch action {
case .clearAppReviewURL:
state.appReviewUrl = nil
case .clearGiveFeedbackURL:
state.giveFeedbackUrl = nil
case .clearURL:
state.url = nil
case .giveFeedbackTapped:
coordinator.showAlert(.giveFeedbackAlert {
self.state.giveFeedbackUrl = ExternalLinksConstants.giveFeedback
})
case .helpCenterTapped:
state.url = ExternalLinksConstants.helpAndFeedback
case .learnAboutOrganizationsTapped:
coordinator.showAlert(.learnAboutOrganizationsAlert {
self.state.url = ExternalLinksConstants.aboutOrganizations
})
case .privacyPolicyTapped:
coordinator.showAlert(.privacyPolicyAlert {
self.state.url = ExternalLinksConstants.privacyPolicy
})
case .rateTheAppTapped:
coordinator.showAlert(.appStoreAlert {
self.state.appReviewUrl = ExternalLinksConstants.appReview
})
case let .toastShown(newValue):
state.toast = newValue
case let .toggleSubmitCrashLogs(isOn):
state.isSubmitCrashLogsToggleOn = isOn
services.errorReporter.isEnabled = isOn
case .tutorialTapped:
coordinator.navigate(to: .tutorial)
case .versionTapped:
handleVersionTapped()
case .webVaultTapped:
break
}
}
// MARK: - Private Methods
/// Prepare the text to be copied.
private func handleVersionTapped() {
// Copy the copyright text followed by the version info.
let text = state.copyrightText + "\n\n" + state.version
services.pasteboardService.copy(text)
state.toast = Toast(text: Localizations.valueHasBeenCopied(Localizations.appInfo))
}
}

View File

@ -1,158 +0,0 @@
import XCTest
@testable import AuthenticatorShared
class AboutProcessorTests: AuthenticatorTestCase {
// MARK: Properties
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
var errorReporter: MockErrorReporter!
var pasteboardService: MockPasteboardService!
var subject: AboutProcessor!
// MARK: Setup and Teardown
override func setUp() {
super.setUp()
coordinator = MockCoordinator<SettingsRoute, SettingsEvent>()
errorReporter = MockErrorReporter()
pasteboardService = MockPasteboardService()
subject = AboutProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
errorReporter: errorReporter,
pasteboardService: pasteboardService
),
state: AboutState()
)
}
override func tearDown() {
super.tearDown()
coordinator = nil
errorReporter = nil
pasteboardService = nil
subject = nil
}
// MARK: Tests
/// `init` sets the correct crash logs setting.
func test_init_loadsValues() {
errorReporter.isEnabled = true
subject = AboutProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
errorReporter: errorReporter
),
state: AboutState()
)
XCTAssertTrue(subject.state.isSubmitCrashLogsToggleOn)
}
/// `receive(_:)` with `.clearAppReviewURL` clears the app review URL in the state.
func test_receive_clearAppReviewURL() {
subject.state.appReviewUrl = .example
subject.receive(.clearAppReviewURL)
XCTAssertNil(subject.state.appReviewUrl)
}
/// `receive(_:)` with `.clearGiveFeedbackURL` clears the URL in the state.
func test_receive_clearGiveFeedbackURL() {
subject.state.url = URL(string: "example.com")
subject.receive(.clearGiveFeedbackURL)
XCTAssertNil(subject.state.giveFeedbackUrl)
}
/// `receive(_:)` with `.clearURL` clears the URL in the state.
func test_receive_clearURL() {
subject.state.url = .example
subject.receive(.clearURL)
XCTAssertNil(subject.state.url)
}
/// `receive(_:)` with `.giveFeedbackTapped` populates the URL in the state.
func test_receive_giveFeedback() async throws {
subject.receive(.giveFeedbackTapped)
let alert = try XCTUnwrap(coordinator.alertShown.last)
// Tapping continue navigates the user to the web app.
try await alert.tapAction(title: Localizations.continue)
XCTAssertNotNil(subject.state.giveFeedbackUrl)
}
/// `receive(_:)` with `.helpCenterTapped` set the URL to open in the state.
func test_receive_helpCenterTapped() {
subject.receive(.helpCenterTapped)
XCTAssertEqual(subject.state.url, ExternalLinksConstants.helpAndFeedback)
}
/// `receive(_:)` with `.learnAboutOrganizationsTapped` shows an alert for navigating to the website
/// When `Continue` is tapped on the alert, sets the URL to open in the state.
func test_receive_learnAboutOrganizationsTapped() async throws {
subject.receive(.learnAboutOrganizationsTapped)
let alert = try XCTUnwrap(coordinator.alertShown.last)
try await alert.tapAction(title: Localizations.continue)
XCTAssertEqual(subject.state.url, ExternalLinksConstants.aboutOrganizations)
}
/// `receive(_:)` with `.privacyPolicyTapped` shows an alert for navigating to the Privacy Policy
/// When `Continue` is tapped on the alert, sets the URL to open in the state.
func test_receive_privacyPolicyTapped() async throws {
subject.receive(.privacyPolicyTapped)
let alert = try XCTUnwrap(coordinator.alertShown.last)
try await alert.tapAction(title: Localizations.continue)
XCTAssertEqual(subject.state.url, ExternalLinksConstants.privacyPolicy)
}
/// `receive(_:)` with `.rateTheAppTapped` shows an alert for navigating to the app store.
/// When `Continue` is tapped on the alert, the `appReviewUrl` is populated.
func test_receive_rateTheAppTapped() async throws {
subject.receive(.rateTheAppTapped)
let alert = try XCTUnwrap(coordinator.alertShown.last)
try await alert.tapAction(title: Localizations.continue)
XCTAssertEqual(
subject.state.appReviewUrl?.absoluteString,
"https://itunes.apple.com/us/app/id1137397744?action=write-review"
)
}
/// `receive(_:)` with `.toastShown` updates the state's toast value.
func test_receive_toastShown() {
let toast = Toast(text: "toast!")
subject.receive(.toastShown(toast))
XCTAssertEqual(subject.state.toast, toast)
subject.receive(.toastShown(nil))
XCTAssertNil(subject.state.toast)
}
/// `receive(_:)` with action `.isSubmitCrashLogsToggleOn` updates the toggle value in the state.
func test_receive_toggleSubmitCrashLogs() {
errorReporter.isEnabled = false
XCTAssertFalse(subject.state.isSubmitCrashLogsToggleOn)
subject.receive(.toggleSubmitCrashLogs(true))
XCTAssertTrue(subject.state.isSubmitCrashLogsToggleOn)
XCTAssertTrue(errorReporter.isEnabled)
}
/// `receive(_:)` with action `.versionTapped` copies the version string to the pasteboard.
func test_receive_versionTapped() {
subject.receive(.versionTapped)
let text = subject.state.copyrightText + "\n\n" + subject.state.version
XCTAssertEqual(pasteboardService.copiedString, text)
XCTAssertEqual(subject.state.toast?.text, Localizations.valueHasBeenCopied(Localizations.appInfo))
}
}

View File

@ -1,28 +0,0 @@
import Foundation
// MARK: - AboutState
/// An object that defines the current state of the `AboutView`.
///
struct AboutState {
/// The URL for Bitwarden's app review page in the app store.
var appReviewUrl: URL?
/// The copyright text.
var copyrightText = "© Bitwarden Inc. 2015-\(Calendar.current.component(.year, from: Date.now))"
/// The URL of the feedback webpage.
var giveFeedbackUrl: URL?
/// Whether the submit crash logs toggle is on.
var isSubmitCrashLogsToggleOn: Bool = false
/// A toast message to show in the view.
var toast: Toast?
/// The url to open in the device's web browser.
var url: URL?
/// The version of the app.
var version: String = "\(Localizations.version): \(Bundle.main.appVersion) (\(Bundle.main.buildNumber))"
}

View File

@ -1,121 +0,0 @@
import SwiftUI
// MARK: - AboutView
/// A view that allows users to view miscellaneous information about the app.
///
struct AboutView: View {
// MARK: Properties
/// An object used to open urls from this view.
@Environment(\.openURL) private var openURL
/// The `Store` for this view.
@ObservedObject var store: Store<AboutState, AboutAction, Void>
// MARK: View
var body: some View {
VStack(alignment: .leading, spacing: 16) {
submitCrashLogs
miscSection
copyrightNotice
}
.scrollView()
.navigationBar(title: Localizations.about, titleDisplayMode: .inline)
.toast(store.binding(
get: \.toast,
send: AboutAction.toastShown
))
.onChange(of: store.state.url) { newValue in
guard let url = newValue else { return }
openURL(url)
store.send(.clearURL)
}
.onChange(of: store.state.appReviewUrl) { newValue in
guard let url = newValue else { return }
openURL(url)
store.send(.clearAppReviewURL)
}
.onChange(of: store.state.giveFeedbackUrl) { newValue in
guard let url = newValue else { return }
openURL(url)
store.send(.clearGiveFeedbackURL)
}
}
// MARK: Private views
/// The copyright notice.
private var copyrightNotice: some View {
Text(store.state.copyrightText)
.styleGuide(.caption2)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
/// The section of miscellaneous about items.
private var miscSection: some View {
VStack(spacing: 0) {
externalLinkRow(Localizations.bitwardenHelpCenter, action: .helpCenterTapped)
externalLinkRow(Localizations.privacyPolicy, action: .privacyPolicyTapped)
externalLinkRow(Localizations.webVault, action: .webVaultTapped)
externalLinkRow(Localizations.learnOrg, action: .learnAboutOrganizationsTapped)
externalLinkRow(Localizations.giveFeedback, action: .giveFeedbackTapped)
SettingsListItem(Localizations.launchTutorial) {
store.send(.tutorialTapped)
}
SettingsListItem(store.state.version, hasDivider: false) {
store.send(.versionTapped)
} trailingContent: {
Asset.Images.copy.swiftUIImage
.imageStyle(.rowIcon)
}
}
.cornerRadius(10)
}
/// The submit crash logs toggle.
private var submitCrashLogs: some View {
Toggle(isOn: store.binding(
get: \.isSubmitCrashLogsToggleOn,
send: AboutAction.toggleSubmitCrashLogs
)) {
Text(Localizations.submitCrashLogs)
}
.toggleStyle(.bitwarden)
.styleGuide(.body)
.accessibilityIdentifier("SubmitCrashLogsSwitch")
}
/// Returns a `SettingsListItem` configured for an external web link.
///
/// - Parameters:
/// - name: The localized name of the row.
/// - action: An action to send when the row is tapped.
/// - Returns: A `SettingsListItem` configured for an external web link.
///
private func externalLinkRow(_ name: String, action: AboutAction) -> some View {
SettingsListItem(name) {
store.send(action)
} trailingContent: {
Asset.Images.externalLink2.swiftUIImage
.imageStyle(.rowIcon)
}
}
}
// MARK: - Previews
#Preview {
AboutView(store: Store(processor: StateProcessor(state: AboutState())))
}

View File

@ -1,83 +0,0 @@
import SnapshotTesting
import XCTest
@testable import AuthenticatorShared
class AboutViewTests: AuthenticatorTestCase {
// MARK: Properties
let copyrightText = "© Bitwarden Inc. 2015-2023"
let version = "Version: 1.0.0 (1)"
var processor: MockProcessor<AboutState, AboutAction, Void>!
var subject: AboutView!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
processor = MockProcessor(state: AboutState(copyrightText: copyrightText, version: version))
let store = Store(processor: processor)
subject = AboutView(store: store)
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Tests
/// Tapping the give feedback button dispatches the `.giveFeedbackTapped` action.
func test_giveFeedbackButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.giveFeedback)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .giveFeedbackTapped)
}
/// Tapping the help center button dispatches the `.helpCenterTapped` action.
func test_helpCenterButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.bitwardenHelpCenter)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .helpCenterTapped)
}
/// Tapping the privacy policy button dispatches the `.privacyPolicyTapped` action.
func test_privacyPolicyButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.privacyPolicy)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .privacyPolicyTapped)
}
/// Tapping the learn about organizations button dispatches the `.learnAboutOrganizationsTapped` action.
func test_learnAboutOrganizationsButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.learnOrg)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .learnAboutOrganizationsTapped)
}
/// Tapping the version button dispatches the `.versionTapped` action.
func test_versionButton_tap() throws {
let button = try subject.inspect().find(button: version)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .versionTapped)
}
/// Tapping the web vault button dispatches the `.webVaultTapped` action.
func test_webVaultButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.webVault)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .webVaultTapped)
}
// MARK: Snapshots
/// The default view renders correctly.
func test_snapshot_default() {
assertSnapshots(of: subject, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
}
}

View File

@ -1,14 +0,0 @@
// MARK: - AppearanceAction
/// Actions handled by the `AppearanceProcessor`.
///
enum AppearanceAction: Equatable {
/// The default color theme was changed.
case appThemeChanged(AppTheme)
/// The language option was tapped.
case languageTapped
/// Show website icons was toggled.
case toggleShowWebsiteIcons(Bool)
}

View File

@ -1,7 +0,0 @@
// MARK: - AppearanceEffect
/// Effects that can be processed by an `AppearanceProcessor`.
enum AppearanceEffect {
/// The view appeared so the initial data should be loaded.
case loadData
}

View File

@ -1,76 +0,0 @@
import Foundation
// MARK: - AppearanceProcessor
/// The processor used to manage state and handle actions for the `AppearanceView`.
///
final class AppearanceProcessor: StateProcessor<AppearanceState, AppearanceAction, AppearanceEffect> {
// MARK: Types
typealias Services = HasStateService
// MARK: Properties
/// The `Coordinator` that handles navigation.
private let coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>
/// The services for this processor.
private var services: Services
// MARK: Initialization
/// Initializes a new `AppearanceProcessor`.
///
/// - Parameters:
/// - coordinator: The `Coordinator` that handles navigation.
/// - services: The services for this processor.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>,
services: Services,
state: AppearanceState
) {
self.coordinator = coordinator
self.services = services
super.init(state: state)
}
// MARK: Methods
override func perform(_ effect: AppearanceEffect) async {
switch effect {
case .loadData:
state.currentLanguage = services.stateService.appLanguage
state.appTheme = await services.stateService.getAppTheme()
state.isShowWebsiteIconsToggleOn = await services.stateService.getShowWebIcons()
}
}
override func receive(_ action: AppearanceAction) {
switch action {
case let .appThemeChanged(appTheme):
state.appTheme = appTheme
Task {
await services.stateService.setAppTheme(appTheme)
}
case .languageTapped:
coordinator.navigate(to: .selectLanguage(currentLanguage: state.currentLanguage), context: self)
case let .toggleShowWebsiteIcons(isOn):
state.isShowWebsiteIconsToggleOn = isOn
Task {
await services.stateService.setShowWebIcons(isOn)
}
}
}
}
// MARK: - SelectLanguageDelegate
extension AppearanceProcessor: SelectLanguageDelegate {
/// Update the language selection.
func languageSelected(_ languageOption: LanguageOption) {
state.currentLanguage = languageOption
}
}

View File

@ -1,94 +0,0 @@
import XCTest
@testable import AuthenticatorShared
class AppearanceProcessorTests: AuthenticatorTestCase {
// MARK: Properties
var coordinator: MockCoordinator<SettingsRoute, SettingsEvent>!
var stateService: MockStateService!
var subject: AppearanceProcessor!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
coordinator = MockCoordinator()
stateService = MockStateService()
let services = ServiceContainer.withMocks(
stateService: stateService
)
subject = AppearanceProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: services,
state: AppearanceState()
)
}
override func tearDown() {
super.tearDown()
coordinator = nil
stateService = nil
subject = nil
}
// MARK: Tests
/// The delegate method `languageSelected` should update the language.
func test_languageSelected() {
XCTAssertEqual(subject.state.currentLanguage, .default)
subject.languageSelected(.custom(languageCode: "th"))
XCTAssertEqual(subject.state.currentLanguage, .custom(languageCode: "th"))
}
/// `perform(_:)` with `.loadData` sets the value in the state.
func test_perform_loadData() async {
XCTAssertEqual(subject.state.appTheme, .default)
stateService.appLanguage = .custom(languageCode: "de")
stateService.appTheme = .light
stateService.showWebIcons = false
await subject.perform(.loadData)
XCTAssertEqual(subject.state.currentLanguage, .custom(languageCode: "de"))
XCTAssertEqual(subject.state.appTheme, .light)
XCTAssertFalse(subject.state.isShowWebsiteIconsToggleOn)
}
/// `receive(_:)` with `.appThemeChanged` updates the theme.
func test_receive_appThemeChanged() {
subject.receive(.appThemeChanged(.dark))
XCTAssertEqual(subject.state.appTheme, .dark)
waitFor(stateService.appTheme == .dark)
subject.receive(.appThemeChanged(.light))
XCTAssertEqual(subject.state.appTheme, .light)
waitFor(stateService.appTheme == .light)
}
/// `receive(_:)` with `.languageTapped` navigates to the select language view.
func test_receive_languageTapped() async throws {
subject.state.currentLanguage = .custom(languageCode: "th")
subject.receive(.languageTapped)
XCTAssertEqual(coordinator.routes.last, .selectLanguage(currentLanguage: LanguageOption("th")))
}
/// `receive(_:)` with `.toggleShowWebsiteIcons` updates the value in the state and the cache.
func test_receive_toggleShowWebsiteIcons() {
XCTAssertFalse(subject.state.isShowWebsiteIconsToggleOn)
subject.receive(.toggleShowWebsiteIcons(true))
XCTAssertTrue(subject.state.isShowWebsiteIconsToggleOn)
waitFor(stateService.showWebIcons == true)
}
}

View File

@ -1,14 +0,0 @@
// MARK: - AppearanceState
/// An object that defines the current state of the `AppearanceView`.
///
struct AppearanceState {
/// The selected app theme.
var appTheme: AppTheme = .default
/// Whether or not the show website icons toggle is on.
var isShowWebsiteIconsToggleOn: Bool = false
/// The current language selection.
var currentLanguage: LanguageOption = .default
}

View File

@ -1,95 +0,0 @@
import SwiftUI
// MARK: - AppearanceView
/// A view for configuring appearance settings.
///
struct AppearanceView: View {
// MARK: Properties
/// The store used to render the view.
@ObservedObject var store: Store<AppearanceState, AppearanceAction, AppearanceEffect>
// MARK: View
var body: some View {
VStack(spacing: 16) {
language
theme
}
.scrollView()
.navigationBar(title: Localizations.appearance, titleDisplayMode: .inline)
.task {
await store.perform(.loadData)
}
}
// MARK: Private views
/// The language picker view
private var language: some View {
VStack(alignment: .leading, spacing: 8) {
SettingsListItem(
Localizations.language,
hasDivider: false
) {
store.send(.languageTapped)
} trailingContent: {
Text(store.state.currentLanguage.title)
}
.cornerRadius(10)
Text(Localizations.languageChangeRequiresAppRestart)
.styleGuide(.subheadline)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
}
}
/// The application's color theme picker view
private var theme: some View {
VStack(alignment: .leading, spacing: 8) {
SettingsMenuField(
title: Localizations.theme,
options: AppTheme.allCases,
hasDivider: false,
selection: store.binding(
get: \.appTheme,
send: AppearanceAction.appThemeChanged
)
)
.cornerRadius(10)
.accessibilityIdentifier("ThemeChooser")
Text(Localizations.themeDescription)
.styleGuide(.subheadline)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
}
}
/// The show website icons toggle.
private var webSiteIconsToggle: some View {
VStack(alignment: .leading, spacing: 0) {
Toggle(isOn: store.binding(
get: \.isShowWebsiteIconsToggleOn,
send: AppearanceAction.toggleShowWebsiteIcons
)) {
Text(Localizations.showWebsiteIcons)
}
.toggleStyle(.bitwarden)
.styleGuide(.body)
.accessibilityIdentifier("ShowWebsiteIconsSwitch")
Text(Localizations.showWebsiteIconsDescription)
.styleGuide(.subheadline)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
}
.padding(.bottom, 12)
}
}
// MARK: Previews
#Preview {
AppearanceView(store: Store(processor: StateProcessor(state: AppearanceState())))
}

View File

@ -1,58 +0,0 @@
import SnapshotTesting
import XCTest
// MARK: - AppearanceViewTests
@testable import AuthenticatorShared
class AppearanceViewTests: AuthenticatorTestCase {
// MARK: Properties
var processor: MockProcessor<AppearanceState, AppearanceAction, AppearanceEffect>!
var subject: AppearanceView!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
processor = MockProcessor(state: AppearanceState())
let store = Store(processor: processor)
subject = AppearanceView(store: store)
}
override func tearDown() {
super.tearDown()
processor = nil
subject = nil
}
// MARK: Tests
/// Updating the value of the app theme sends the `.appThemeChanged()` action.
func test_appThemeChanged_updateValue() throws {
processor.state.appTheme = .light
let menuField = try subject.inspect().find(settingsMenuField: Localizations.theme)
try menuField.select(newValue: AppTheme.dark)
XCTAssertEqual(processor.dispatchedActions.last, .appThemeChanged(.dark))
}
/// Tapping the language button dispatches the `.languageTapped` action.
func test_languageButton_tap() throws {
let button = try subject.inspect().find(button: Localizations.language)
try button.tap()
XCTAssertEqual(processor.dispatchedActions.last, .languageTapped)
}
// MARK: Snapshots
/// Tests the view renders correctly.
func test_viewRender() {
assertSnapshots(
of: subject,
as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5]
)
}
}

View File

@ -1,9 +1,27 @@
/// Actions that can be processed by a `SettingsProcessor`.
///
enum SettingsAction: Equatable {
/// The about button was pressed.
case aboutPressed
/// The default color theme was changed.
case appThemeChanged(AppTheme)
/// The appearance button was pressed.
case appearancePressed
/// The url has been opened so clear the value in the state.
case clearURL
/// The help center button was tapped.
case helpCenterTapped
/// The language option was tapped.
case languageTapped
/// The privacy policy button was tapped.
case privacyPolicyTapped
/// The toast was shown or hidden.
case toastShown(Toast?)
/// The tutorial button was tapped
case tutorialTapped
/// The version was tapped.
case versionTapped
}

View File

@ -0,0 +1,7 @@
// MARK: - SettingsEffect
/// Effects that can be processed by an `SettingsProcessor`.
enum SettingsEffect {
/// The view appeared so the initial data should be loaded.
case loadData
}

View File

@ -2,40 +2,93 @@
/// The processor used to manage state and handle actions for the settings screen.
///
final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, Void> {
final class SettingsProcessor: StateProcessor<SettingsState, SettingsAction, SettingsEffect> {
// MARK: Types
typealias Services = HasErrorReporter
& HasPasteboardService
& HasStateService
// MARK: Private Properties
/// The `Coordinator` that handles navigation.
private let coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>
/// The services for this processor.
private var services: Services
// MARK: Initialization
/// Creates a new `SettingsProcessor`.
///
/// - Parameters:
/// - coordinator: The `Coordinator` that handles navigation.
/// - services: The services for this processor.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<SettingsRoute, SettingsEvent>,
services: Services,
state: SettingsState
) {
self.coordinator = coordinator
self.services = services
super.init(state: state)
}
// MARK: Methods
override func receive(_ action: SettingsAction) {
switch action {
case .aboutPressed:
coordinator.navigate(to: .about)
case .appearancePressed:
coordinator.navigate(to: .appearance)
override func perform(_ effect: SettingsEffect) async {
switch effect {
case .loadData:
state.currentLanguage = services.stateService.appLanguage
state.appTheme = await services.stateService.getAppTheme()
}
}
override func receive(_ action: SettingsAction) {
switch action {
case let .appThemeChanged(appTheme):
state.appTheme = appTheme
Task {
await services.stateService.setAppTheme(appTheme)
}
case .clearURL:
state.url = nil
case .helpCenterTapped:
state.url = ExternalLinksConstants.helpAndFeedback
case .languageTapped:
coordinator.navigate(to: .selectLanguage(currentLanguage: state.currentLanguage), context: self)
case .privacyPolicyTapped:
coordinator.showAlert(.privacyPolicyAlert {
self.state.url = ExternalLinksConstants.privacyPolicy
})
case let .toastShown(newValue):
state.toast = newValue
case .tutorialTapped:
coordinator.navigate(to: .tutorial)
case .versionTapped:
handleVersionTapped()
}
}
// MARK: - Private Methods
/// Prepare the text to be copied.
private func handleVersionTapped() {
// Copy the copyright text followed by the version info.
let text = "Bitwarden Authenticator\n\n" + state.copyrightText + "\n\n" + state.version
services.pasteboardService.copy(text)
state.toast = Toast(text: Localizations.valueHasBeenCopied(Localizations.appInfo))
}
}
// MARK: - SelectLanguageDelegate
extension SettingsProcessor: SelectLanguageDelegate {
/// Update the language selection.
func languageSelected(_ languageOption: LanguageOption) {
state.currentLanguage = languageOption
}
}

View File

@ -1,5 +1,25 @@
import Foundation
/// An object that defines the current state of a `SettingsView`.
///
struct SettingsState: Equatable {
// MARK: Properties
/// The selected app theme.
var appTheme: AppTheme = .default
/// The copyright text.
var copyrightText = "© Bitwarden Inc. 2015-\(Calendar.current.component(.year, from: Date.now))"
/// The current language selection.
var currentLanguage: LanguageOption = .default
/// A toast message to show in the view.
var toast: Toast?
/// The url to open in the device's web browser.
var url: URL?
/// The version of the app.
var version: String = "\(Localizations.version): \(Bundle.main.appVersion) (\(Bundle.main.buildNumber))"
}

View File

@ -7,8 +7,11 @@ import SwiftUI
struct SettingsView: View {
// MARK: Properties
/// An object used to open urls from this view.
@Environment(\.openURL) private var openURL
/// The `Store` for this view.
@ObservedObject var store: Store<SettingsState, SettingsAction, Void>
@ObservedObject var store: Store<SettingsState, SettingsAction, SettingsEffect>
// MARK: View
@ -16,6 +19,18 @@ struct SettingsView: View {
settingsItems
.scrollView()
.navigationBar(title: Localizations.settings, titleDisplayMode: .large)
.toast(store.binding(
get: \.toast,
send: SettingsAction.toastShown
))
.onChange(of: store.state.url) { newValue in
guard let url = newValue else { return }
openURL(url)
store.send(.clearURL)
}
.task {
await store.perform(.loadData)
}
}
// MARK: Private views
@ -28,25 +43,109 @@ struct SettingsView: View {
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
}
/// The copyright notice.
private var copyrightNotice: some View {
Text(store.state.copyrightText)
.styleGuide(.caption2)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
/// The language picker view
private var language: some View {
VStack(alignment: .leading, spacing: 8) {
SettingsListItem(
Localizations.language,
hasDivider: false
) {
store.send(.languageTapped)
} trailingContent: {
Text(store.state.currentLanguage.title)
}
.cornerRadius(10)
Text(Localizations.languageChangeRequiresAppRestart)
.styleGuide(.subheadline)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
}
}
/// The settings items.
private var settingsItems: some View {
VStack(spacing: 0) {
SettingsListItem(Localizations.appearance) {
store.send(.appearancePressed)
} trailingContent: {
chevron
SectionView(Localizations.appearance) {
language
theme
}
.accessibilityIdentifier("AppearanceSettingsButton")
.padding(.bottom, 32)
SettingsListItem(Localizations.about, hasDivider: false) {
store.send(.aboutPressed)
} trailingContent: {
chevron
SectionView(Localizations.help, contentSpacing: 0) {
SettingsListItem(Localizations.launchTutorial) {
store.send(.tutorialTapped)
}
externalLinkRow(Localizations.bitwardenHelpCenter, action: .helpCenterTapped, hasDivider: false)
}
.accessibilityIdentifier("AboutSettingsButton")
.padding(.bottom, 32)
SectionView(Localizations.about, contentSpacing: 0) {
externalLinkRow(Localizations.privacyPolicy, action: .privacyPolicyTapped)
SettingsListItem(store.state.version, hasDivider: false) {
store.send(.versionTapped)
} trailingContent: {
Asset.Images.copy.swiftUIImage
.imageStyle(.rowIcon)
}
}
.padding(.bottom, 16)
copyrightNotice
}
.cornerRadius(10)
}
/// The application's color theme picker view
private var theme: some View {
VStack(alignment: .leading, spacing: 8) {
SettingsMenuField(
title: Localizations.theme,
options: AppTheme.allCases,
hasDivider: false,
selection: store.binding(
get: \.appTheme,
send: SettingsAction.appThemeChanged
)
)
.cornerRadius(10)
.accessibilityIdentifier("ThemeChooser")
Text(Localizations.themeDescription)
.styleGuide(.subheadline)
.foregroundColor(Color(asset: Asset.Colors.textSecondary))
}
}
/// Returns a `SettingsListItem` configured for an external web link.
///
/// - Parameters:
/// - name: The localized name of the row.
/// - action: An action to send when the row is tapped.
/// - Returns: A `SettingsListItem` configured for an external web link.
///
private func externalLinkRow(
_ name: String,
action: SettingsAction,
hasDivider: Bool = true
) -> some View {
SettingsListItem(name, hasDivider: hasDivider) {
store.send(action)
} trailingContent: {
Asset.Images.externalLink2.swiftUIImage
.imageStyle(.rowIcon)
}
}
}
// MARK: - Previews

View File

@ -55,10 +55,6 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
func navigate(to route: SettingsRoute, context: AnyObject?) {
switch route {
case .about:
showAbout()
case .appearance:
showAppearance()
case .dismiss:
stackNavigator?.dismiss()
case let .selectLanguage(currentLanguage: currentLanguage):
@ -76,36 +72,6 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
// MARK: Private Methods
/// Shows the about screen.
///
private func showAbout() {
let processor = AboutProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: AboutState()
)
let view = AboutView(store: Store(processor: processor))
let viewController = UIHostingController(rootView: view)
viewController.navigationItem.largeTitleDisplayMode = .never
stackNavigator?.push(viewController, navigationTitle: Localizations.about)
}
/// Shows the appearance screen.
///
private func showAppearance() {
let processor = AppearanceProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: AppearanceState()
)
let view = AppearanceView(store: Store(processor: processor))
let viewController = UIHostingController(rootView: view)
viewController.navigationItem.largeTitleDisplayMode = .never
stackNavigator?.push(viewController, navigationTitle: Localizations.appearance)
}
/// Shows the select language screen.
///
private func showSelectLanguage(currentLanguage: LanguageOption, delegate: SelectLanguageDelegate?) {
@ -125,6 +91,7 @@ final class SettingsCoordinator: Coordinator, HasStackNavigator {
private func showSettings() {
let processor = SettingsProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: SettingsState()
)
let view = SettingsView(store: Store(processor: processor))

View File

@ -4,12 +4,6 @@ import Foundation
/// A route to a specific screen in the settings tab.
///
public enum SettingsRoute: Equatable, Hashable {
/// A route to the about view.
case about
/// A route to the appearance screen.
case appearance
/// A route that dismisses the current view.
case dismiss