mirror of
https://github.com/bitwarden/ios.git
synced 2026-05-31 05:46:35 -05:00
374 lines
12 KiB
Swift
374 lines
12 KiB
Swift
import BitwardenKit
|
|
import BitwardenResources
|
|
import SwiftUI
|
|
|
|
// MARK: - ProfileSwitcherRow
|
|
|
|
/// A row view that allows the user to view, select, or add profiles.
|
|
///
|
|
struct ProfileSwitcherRow: View {
|
|
// MARK: Properties
|
|
|
|
@Environment(\.accessibilityVoiceOverEnabled) private var isVoiceoverEnabled: Bool
|
|
|
|
/// The `Store` for this view.
|
|
@ObservedObject var store: Store<ProfileSwitcherRowState, ProfileSwitcherRowAction, ProfileSwitcherRowEffect>
|
|
|
|
/// Defines the accessibility focus state
|
|
@AccessibilityFocusState var isFocused: Bool
|
|
|
|
var body: some View {
|
|
button
|
|
.onChange(of: store.state.shouldTakeAccessibilityFocus) { shouldTakeFocus in
|
|
updateFocusIfNeeded(shouldTakeFocus: shouldTakeFocus)
|
|
}
|
|
.accessibilityIdentifier("AccountCell")
|
|
}
|
|
|
|
// MARK: Private Properties
|
|
|
|
/// A button with accessibility traits for active accounts
|
|
@ViewBuilder private var button: some View {
|
|
switch store.state.rowType {
|
|
case .addAccount:
|
|
AsyncButton {
|
|
await store.perform(.pressed(rowType))
|
|
} label: {
|
|
rowContents
|
|
}
|
|
.accessibilityAsyncAction(named: Localizations.addAccount) {
|
|
await store.perform(.pressed(rowType))
|
|
}
|
|
case let .alternate(account):
|
|
accountRow(for: account, isSelected: false)
|
|
case let .active(account):
|
|
accountRow(for: account, isSelected: true)
|
|
}
|
|
}
|
|
|
|
/// The row contents
|
|
private var rowContents: some View {
|
|
VStack(alignment: .leading, spacing: 0.0) {
|
|
HStack(spacing: 0) {
|
|
leadingIcon
|
|
.padding(.trailing, 16)
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(spacing: 0) {
|
|
VStack(alignment: .leading, spacing: -4) {
|
|
Text(title)
|
|
.styleGuide(.body)
|
|
.accessibilityIdentifier("AccountEmailLabel")
|
|
.foregroundColor(SharedAsset.Colors.textPrimary.swiftUIColor)
|
|
if let hostSubtitle {
|
|
Text(hostSubtitle)
|
|
.styleGuide(.subheadline)
|
|
.accessibilityIdentifier("AccountHostUrlLabel")
|
|
.foregroundColor(SharedAsset.Colors.textSecondary.swiftUIColor)
|
|
}
|
|
if let statusSubtitle {
|
|
Text(statusSubtitle)
|
|
.styleGuide(.subheadline, isItalic: true)
|
|
.accessibilityIdentifier("AccountStatusLabel")
|
|
.foregroundColor(SharedAsset.Colors.textSecondary.swiftUIColor)
|
|
}
|
|
}
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
Spacer()
|
|
trailingIcon?
|
|
.imageStyle(.rowIcon(color: trailingIconColor))
|
|
.accessibilityIdentifier(store.state.trailingIconAccessibilityID)
|
|
}
|
|
.padding([.top, .bottom], (statusSubtitle != nil || hostSubtitle != nil) ? 9 : 19)
|
|
.padding([.trailing], 16)
|
|
divider
|
|
}
|
|
.padding([.leading], 4)
|
|
}
|
|
.padding([.leading], 16)
|
|
}
|
|
.background(SharedAsset.Colors.backgroundSecondary.swiftUIColor)
|
|
}
|
|
|
|
/// A row divider view
|
|
@ViewBuilder private var divider: some View {
|
|
if store.state.showDivider {
|
|
Rectangle()
|
|
.frame(height: 1.0)
|
|
.frame(maxWidth: .infinity)
|
|
.foregroundColor(SharedAsset.Colors.strokeDivider.swiftUIColor)
|
|
} else {
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
/// A leading icon for the row
|
|
@ViewBuilder private var leadingIcon: some View {
|
|
switch store.state.rowType {
|
|
case let .active(account),
|
|
let .alternate(account):
|
|
profileSwitcherIcon(
|
|
color: account.color,
|
|
initials: account.userInitials,
|
|
textColor: account.profileIconTextColor,
|
|
)
|
|
.accessibilityLabel(Localizations.account)
|
|
case .addAccount:
|
|
SharedAsset.Icons.plus16.swiftUIImage
|
|
.imageStyle(.accessoryIcon16(color: SharedAsset.Colors.iconSecondary.swiftUIColor))
|
|
.padding(4)
|
|
}
|
|
}
|
|
|
|
/// The type of the row
|
|
private var rowType: ProfileSwitcherRowState.RowType {
|
|
store.state.rowType
|
|
}
|
|
|
|
/// A title for the row
|
|
private var title: String {
|
|
switch store.state.rowType {
|
|
case let .active(account),
|
|
let .alternate(account):
|
|
account.email
|
|
case .addAccount:
|
|
Localizations.addAccount
|
|
}
|
|
}
|
|
|
|
/// A subtitle for the row, used to indicate vault host
|
|
private var hostSubtitle: String? {
|
|
switch store.state.rowType {
|
|
case let .active(account),
|
|
let .alternate(account):
|
|
account.webVault
|
|
case .addAccount:
|
|
nil
|
|
}
|
|
}
|
|
|
|
/// A subtitle for the row, used to indicate lock status
|
|
private var statusSubtitle: String? {
|
|
switch store.state.rowType {
|
|
case .active,
|
|
.addAccount:
|
|
nil
|
|
case let .alternate(account):
|
|
switch (account.isUnlocked, account.isLoggedOut) {
|
|
case (true, false):
|
|
Localizations.accountUnlocked.lowercased()
|
|
case (false, false):
|
|
Localizations.accountLocked.lowercased()
|
|
case (_, true):
|
|
Localizations.accountLoggedOut.lowercased()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A trailing icon for the row
|
|
private var trailingIcon: Image? {
|
|
switch store.state.rowType {
|
|
case .active:
|
|
SharedAsset.Icons.checkCircle24.swiftUIImage
|
|
case let .alternate(account):
|
|
if account.isUnlocked {
|
|
SharedAsset.Icons.unlocked24.swiftUIImage
|
|
} else {
|
|
SharedAsset.Icons.locked24.swiftUIImage
|
|
}
|
|
case .addAccount:
|
|
nil
|
|
}
|
|
}
|
|
|
|
/// A trailing icon color for the row
|
|
private var trailingIconColor: Color {
|
|
switch store.state.rowType {
|
|
case .active:
|
|
SharedAsset.Colors.iconPrimary.swiftUIColor
|
|
case .alternate:
|
|
SharedAsset.Colors.textSecondary.swiftUIColor
|
|
case .addAccount:
|
|
SharedAsset.Colors.backgroundSecondary.swiftUIColor
|
|
}
|
|
}
|
|
|
|
/// Builds an account row for a given row type
|
|
///
|
|
/// - Parameters
|
|
/// - profileSwitcherItem: The item used to construct the account row.
|
|
/// - isSelected: Is this item selected?
|
|
///
|
|
@ViewBuilder
|
|
private func accountRow(
|
|
for profileSwitcherItem: ProfileSwitcherItem,
|
|
isSelected: Bool,
|
|
) -> some View {
|
|
AsyncButton {} label: {
|
|
rowContents
|
|
.onTapGesture {
|
|
await store.perform(
|
|
.pressed(
|
|
isSelected
|
|
? .active(profileSwitcherItem)
|
|
: .alternate(profileSwitcherItem),
|
|
),
|
|
)
|
|
}
|
|
.onLongPressGesture(if: store.state.allowLockAndLogout) {
|
|
await store.perform(
|
|
.longPressed(
|
|
isSelected
|
|
? .active(profileSwitcherItem)
|
|
: .alternate(profileSwitcherItem),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
.accessibilityAction {
|
|
Task {
|
|
await store.perform(.accessibility(.select(profileSwitcherItem)))
|
|
}
|
|
}
|
|
.conditionalAccessibilityAsyncAction(
|
|
if: store.state.allowLockAndLogout && profileSwitcherItem.canBeLocked
|
|
&& profileSwitcherItem.isUnlocked,
|
|
named: Localizations.lock,
|
|
) {
|
|
await store.perform(.accessibility(.lock(profileSwitcherItem)))
|
|
}
|
|
.conditionalAccessibilityAction(
|
|
if: store.state.allowLockAndLogout && !profileSwitcherItem.isLoggedOut,
|
|
named: Localizations.logOut,
|
|
) {
|
|
store.send(.accessibility(.logout(profileSwitcherItem)))
|
|
}
|
|
.conditionalAccessibilityAction(
|
|
if: store.state.allowLockAndLogout && profileSwitcherItem.isLoggedOut,
|
|
named: Localizations.remove,
|
|
) {
|
|
store.send(.accessibility(.remove(profileSwitcherItem)))
|
|
}
|
|
.accessibility(
|
|
if: isSelected,
|
|
addTraits: .isSelected,
|
|
)
|
|
}
|
|
|
|
/// Helper function to set accessibility focus state inside the view body
|
|
private func updateFocusIfNeeded(shouldTakeFocus: Bool) {
|
|
if shouldTakeFocus,
|
|
isVoiceoverEnabled {
|
|
isFocused = true
|
|
}
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
#Preview("Active Unlocked Account") {
|
|
NavigationView {
|
|
ProfileSwitcherRow(
|
|
store: Store(
|
|
processor: StateProcessor(
|
|
state: ProfileSwitcherRowState(
|
|
shouldTakeAccessibilityFocus: true,
|
|
rowType: .active(.fixtureUnlocked),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview("Active Locked Account") {
|
|
NavigationView {
|
|
ProfileSwitcherRow(
|
|
store: Store(
|
|
processor: StateProcessor(
|
|
state: ProfileSwitcherRowState(
|
|
shouldTakeAccessibilityFocus: true,
|
|
rowType: .active(.fixtureLocked),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview("Active Account, No Divider") {
|
|
NavigationView {
|
|
ProfileSwitcherRow(
|
|
store: Store(
|
|
processor: StateProcessor(
|
|
state: ProfileSwitcherRowState(
|
|
shouldTakeAccessibilityFocus: true,
|
|
showDivider: false,
|
|
rowType: .active(.fixtureLocked),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview("Alternate Unlocked Account") {
|
|
NavigationView {
|
|
ProfileSwitcherRow(
|
|
store: Store(
|
|
processor: StateProcessor(
|
|
state: ProfileSwitcherRowState(
|
|
shouldTakeAccessibilityFocus: true,
|
|
rowType: .alternate(.fixtureUnlocked),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview("Alternate Locked Account") {
|
|
NavigationView {
|
|
ProfileSwitcherRow(
|
|
store: Store(
|
|
processor: StateProcessor(
|
|
state: ProfileSwitcherRowState(
|
|
shouldTakeAccessibilityFocus: true,
|
|
rowType: .alternate(.fixtureLocked),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview("Alternate Logged Out Account") {
|
|
NavigationView {
|
|
ProfileSwitcherRow(
|
|
store: Store(
|
|
processor: StateProcessor(
|
|
state: ProfileSwitcherRowState(
|
|
shouldTakeAccessibilityFocus: true,
|
|
rowType: .alternate(.fixtureLoggedOut),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview("Add Account") {
|
|
NavigationView {
|
|
ProfileSwitcherRow(
|
|
store: Store(
|
|
processor: StateProcessor(
|
|
state: ProfileSwitcherRowState(
|
|
shouldTakeAccessibilityFocus: false,
|
|
rowType: .addAccount,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
#endif
|