Add view to view item details (#12)

This commit is contained in:
Katherine Bertelsen 2024-04-04 16:08:37 -05:00 committed by GitHub
parent fb94f87bf2
commit 55a2e9c505
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 946 additions and 561 deletions

View File

@ -1,13 +0,0 @@
import SwiftUI
//@main
//struct AuthenticatorApp: App {
// let persistenceController = PersistenceController.shared
//
// var body: some Scene {
// WindowGroup {
// ContentView()
// .environment(\.managedObjectContext, persistenceController.container.viewContext)
// }
// }
//}

View File

@ -1,84 +0,0 @@
//import CoreData
//import SwiftUI
//
//struct ContentView: View {
// @Environment(\.managedObjectContext) private var viewContext
//
// @FetchRequest(
// sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
// animation: .default
// )
// private var items: FetchedResults<Item>
//
// var body: some View {
// NavigationView {
// List {
// ForEach(items) { item in
// NavigationLink {
// Text("Item at \(item.timestamp!, formatter: itemFormatter)")
// } label: {
// Text(item.timestamp!, formatter: itemFormatter)
// }
// }
// .onDelete(perform: deleteItems)
// }
// .toolbar {
// ToolbarItem(placement: .navigationBarTrailing) {
// EditButton()
// }
// ToolbarItem {
// Button(action: addItem) {
// Label("Add Item", systemImage: "plus")
// }
// }
// }
// Text("Select an item")
// }
// }
//
// private func addItem() {
// withAnimation {
// let newItem = Item(context: viewContext)
// newItem.timestamp = Date()
//
// do {
// try viewContext.save()
// } catch {
// // Replace this implementation with code to handle the error appropriately.
// // fatalError() causes the application to generate a crash log and terminate.
// // You should not use this function in a shipping application,
// // although it may be useful during development.
// let nsError = error as NSError
// fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
// }
// }
// }
//
// private func deleteItems(offsets: IndexSet) {
// withAnimation {
// offsets.map { items[$0] }.forEach(viewContext.delete)
//
// do {
// try viewContext.save()
// } catch {
// // Replace this implementation with code to handle the error appropriately.
// // fatalError() causes the application to generate a crash log and terminate.
// // You should not use this function in a shipping application,
// // although it may be useful during development.
// let nsError = error as NSError
// fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
// }
// }
// }
//}
//
//private let itemFormatter: DateFormatter = {
// let formatter = DateFormatter()
// formatter.dateStyle = .short
// formatter.timeStyle = .medium
// return formatter
//}()
//
//#Preview {
// ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
//}

View File

@ -1,55 +0,0 @@
//import CoreData
//struct Persistence {}
//struct PersistenceController {
// static let shared = PersistenceController()
//
// static var preview: PersistenceController = {
// let result = PersistenceController(inMemory: true)
// let viewContext = result.container.viewContext
// for _ in 0 ..< 10 {
// let newItem = Item(context: viewContext)
// newItem.timestamp = Date()
// }
// do {
// try viewContext.save()
// } catch {
// // Replace this implementation with code to handle the error appropriately.
// // fatalError() causes the application to generate a crash log and terminate.
// // You should not use this function in a shipping application, although it may be useful during development.
// let nsError = error as NSError
// fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
// }
// return result
// }()
//
// let container: NSPersistentContainer
//
// init(inMemory: Bool = false) {
// container = NSPersistentContainer(name: "Authenticator")
// if inMemory {
// container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
// }
// container.loadPersistentStores(completionHandler: { _, error in
// if let error = error as NSError? {
// // Replace this implementation with code to handle the error appropriately.
// // fatalError() causes the application to generate a crash log and terminate.
// // You should not use this function in a shipping application,
// // although it may be useful during development.
//
// //
// // Typical reasons for an error here include:
// // * The parent directory does not exist, cannot be created, or disallows writing.
// // * The persistent store is not accessible,
// // due to permissions or data protection when the device is locked.
// // * The device is out of space.
// // * The store could not be migrated to the current model version.
// // Check the error message to determine what the actual problem was.
// //
// fatalError("Unresolved error \(error), \(error.userInfo)")
// }
// })
// container.viewContext.automaticallyMergesChangesFromParent = true
// }
//}

View File

@ -113,9 +113,20 @@ extension DefaultItemRepository: ItemRepository {
func deleteItem(_ id: String) {}
func fetchItem(withId id: String) async throws -> BitwardenSdk.CipherView? { nil }
func fetchItem(withId id: String) async throws -> BitwardenSdk.CipherView? {
ciphers.first { $0.id == id }
}
func refreshTOTPCode(for key: TOTPKeyModel) async throws -> LoginTOTPState { .none }
func refreshTOTPCode(for key: TOTPKeyModel) async throws -> LoginTOTPState {
let codeState = try await clientVault.generateTOTPCode(
for: key.rawAuthenticatorKey,
date: timeProvider.presentTime
)
return LoginTOTPState(
authKeyModel: key,
codeModel: codeState
)
}
func refreshTOTPCodes(for items: [VaultListItem]) async throws -> [VaultListItem] {
await items.asyncMap { item in
@ -129,7 +140,7 @@ extension DefaultItemRepository: ItemRepository {
}
var updatedModel = model
updatedModel.totpCode = code
return .init(
return VaultListItem(
id: item.id,
itemType: .totp(name: name, totpModel: updatedModel)
)

View File

@ -0,0 +1,84 @@
import Foundation
import SwiftUI
// MARK: - ImageStyle
/// A struct containing configuration properties for applying common properties to images across
/// the app.
///
struct ImageStyle {
// MARK: Properties
/// The foreground color of the image.
let color: Color
/// The width of the image.
let width: CGFloat
/// The height of the image.
let height: CGFloat
}
extension ImageStyle {
/// An `ImageStyle` for applying common properties to a circular accessory icon.
///
/// - Size: 16x16pt
/// - Color: `Asset.Colors.primaryBitwarden`
///
static let accessoryIcon = accessoryIcon()
/// An `ImageStyle` for applying common properties for icons within a row.
///
/// - Size: 22x22pt
/// - Color: `Asset.Colors.textSecondary`
///
static let rowIcon = rowIcon()
/// An `ImageStyle` for applying common properties for icons within a toolbar.
///
/// - Size: 19x19pt
/// - Color: `Asset.Colors.primaryBitwarden`
///
static let toolbarIcon = ImageStyle(
color: Asset.Colors.primaryBitwarden.swiftUIColor,
width: 19,
height: 19
)
/// An `ImageStyle` for applying common properties to a circular accessory icon.
///
/// - Size: 16x16pt
/// - Color: Defaults to `Asset.Colors.primaryBitwarden`
///
/// - Parameter color: The foreground color of the image. Defaults to `Asset.Colors.primaryBitwarden`.
///
static func accessoryIcon(color: Color = Asset.Colors.primaryBitwarden.swiftUIColor) -> ImageStyle {
ImageStyle(color: color, width: 16, height: 16)
}
/// An `ImageStyle` for applying common properties for icons within a row.
///
/// - Size: 22x22pt
/// - Color: Defaults to `Asset.Colors.textSecondary`
///
/// - Parameter color: The foreground color of the image. Defaults to `Asset.Colors.textSecondary`.
///
static func rowIcon(color: Color = Asset.Colors.textSecondary.swiftUIColor) -> ImageStyle {
ImageStyle(color: color, width: 22, height: 22)
}
}
// MARK: - Image
extension Image {
/// A view extension that applies common image properties based on a style.
///
/// - Parameter style: The configuration used to set common image properties.
/// - Returns: The wrapped view modified with the common image modifiers applied.
///
func imageStyle(_ style: ImageStyle) -> some View {
resizable()
.frame(width: style.width, height: style.height)
.foregroundStyle(style.color)
}
}

View File

@ -15,6 +15,7 @@ extension View {
func addToolbarButton(hidden: Bool = false, action: @escaping () -> Void) -> some View {
toolbarButton(asset: Asset.Images.plus, label: Localizations.add, action: action)
.hidden(hidden)
.accessibilityIdentifier("AddItemButton")
}
/// Returns a toolbar button configured for cancelling an operation in a view.
@ -24,7 +25,7 @@ extension View {
///
func cancelToolbarButton(action: @escaping () -> Void) -> some View {
toolbarButton(asset: Asset.Images.cancel, label: Localizations.cancel, action: action)
.accessibilityIdentifier("CLOSE")
.accessibilityIdentifier("CancelButton")
}
/// Returns a toolbar button configured for closing a view.
@ -34,7 +35,17 @@ extension View {
///
func closeToolbarButton(action: @escaping () -> Void) -> some View {
toolbarButton(asset: Asset.Images.cancel, label: Localizations.close, action: action)
.accessibilityIdentifier("CLOSE")
.accessibilityIdentifier("CloseButton")
}
/// Returns a toolbar button configured for editing an item.
///
/// - Parameter action: The action to perform when the button is tapped.
/// - Returns: A `Button` configured for closing a view.
///
func editToolbarButton(action: @escaping () -> Void) -> some View {
toolbarButton(Localizations.edit, action: action)
.accessibilityIdentifier("EditItemButton")
}
/// Returns a `Button` that displays an image for use in a toolbar.
@ -48,9 +59,7 @@ extension View {
func toolbarButton(asset: ImageAsset, label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(asset: asset, label: Text(label))
.resizable()
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
.frame(width: 19, height: 19)
.imageStyle(.toolbarIcon)
}
// Ideally we would set both `minHeight` and `minWidth` to 44. Setting `minWidth` causes
// padding to be applied equally on both sides of the image. This results in extra padding
@ -86,9 +95,8 @@ extension View {
content()
} label: {
Image(asset: Asset.Images.verticalKabob, label: Text(Localizations.options))
.resizable()
.frame(width: 19, height: 19)
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
.imageStyle(.toolbarIcon)
.accessibilityIdentifier("HeaderBarOptionsButton")
}
// Ideally we would set both `minHeight` and `minWidth` to 44. Setting `minWidth` causes
// padding to be applied equally on both sides of the image. This results in extra padding
@ -107,8 +115,7 @@ extension View {
///
func addToolbarItem(hidden: Bool = false, _ action: @escaping () -> Void) -> some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
toolbarButton(asset: Asset.Images.plus, label: Localizations.add, action: action)
.hidden(hidden)
addToolbarButton(hidden: hidden, action: action)
}
}

View File

@ -8,9 +8,15 @@ struct BitwardenField<Content, AccessoryContent>: View where Content: View, Acce
/// The (optional) title of the field.
var title: String?
/// The (optional) accessibility identifier to apply to the title of the field (if it exists)
var titleAccessibilityIdentifier: String?
/// The (optional) footer to display underneath the field.
var footer: String?
/// The (optional) accessibility identifier to apply to the fooder of the field (if it exists)
var footerAccessibilityIdentifier: String?
/// The vertical padding to apply around `content`. Defaults to `8`.
var verticalPadding: CGFloat
@ -27,6 +33,7 @@ struct BitwardenField<Content, AccessoryContent>: View where Content: View, Acce
Text(title)
.styleGuide(.subheadline, weight: .semibold)
.foregroundColor(Asset.Colors.textSecondary.swiftUIColor)
.accessibilityIdentifier(titleAccessibilityIdentifier ?? title)
}
HStack(spacing: 8) {
@ -47,6 +54,7 @@ struct BitwardenField<Content, AccessoryContent>: View where Content: View, Acce
Text(footer)
.styleGuide(.footnote)
.foregroundColor(Asset.Colors.textSecondary.swiftUIColor)
.accessibilityIdentifier(footerAccessibilityIdentifier ?? footer)
}
}
}
@ -57,7 +65,11 @@ struct BitwardenField<Content, AccessoryContent>: View where Content: View, Acce
///
/// - Parameters:
/// - title: The (optional) title of the field.
/// - titleAccessibilityIdentifier: The (optional) accessibility identifier to apply
/// to the title of the field (if it exists)
/// - footer: The (optional) footer to display underneath the field.
/// - footerAccessibilityIdentifier: The (optional) accessibility identifier to apply
/// to the fooder of the field (if it exists)
/// - verticalPadding: The vertical padding to apply around `content`. Defaults to `8`.
/// - content: The content that should be displayed in the field.
/// - accessoryContent: Any accessory content that should be displayed on the trailing edge of
@ -65,13 +77,17 @@ struct BitwardenField<Content, AccessoryContent>: View where Content: View, Acce
///
init(
title: String? = nil,
titleAccessibilityIdentifier: String? = nil,
footer: String? = nil,
footerAccessibilityIdentifier: String? = nil,
verticalPadding: CGFloat = 8,
@ViewBuilder content: () -> Content,
@ViewBuilder accessoryContent: () -> AccessoryContent
) {
self.title = title
self.titleAccessibilityIdentifier = titleAccessibilityIdentifier
self.footer = footer
self.footerAccessibilityIdentifier = footerAccessibilityIdentifier
self.verticalPadding = verticalPadding
self.content = content()
self.accessoryContent = accessoryContent()
@ -83,18 +99,26 @@ extension BitwardenField where AccessoryContent == EmptyView {
///
/// - Parameters:
/// - title: The (optional) title of the field.
/// - titleAccessibilityIdentifier: The (optional) accessibility identifier to apply
/// to the title of the field (if it exists)
/// - footer: The (optional) footer to display underneath the field.
/// - footerAccessibilityIdentifier: The (optional) accessibility identifier to apply
/// to the fooder of the field (if it exists)
/// - verticalPadding: The vertical padding to apply around `content`. Defaults to `8`.
/// - content: The content that should be displayed in the field.
///
init(
title: String? = nil,
titleAccessibilityIdentifier: String? = nil,
footer: String? = nil,
footerAccessibilityIdentifier: String? = nil,
verticalPadding: CGFloat = 8,
@ViewBuilder content: () -> Content
) {
self.title = title
self.titleAccessibilityIdentifier = titleAccessibilityIdentifier
self.footer = footer
self.footerAccessibilityIdentifier = footerAccessibilityIdentifier
self.verticalPadding = verticalPadding
self.content = content()
accessoryContent = nil

View File

@ -0,0 +1,107 @@
import SwiftUI
/// A standardized view used to display some text into a row of a list. This is commonly used in
/// forms.
struct BitwardenTextValueField<AccessoryContent>: View where AccessoryContent: View {
// MARK: Properties
/// The (optional) title of the field.
var title: String?
/// The (optional) accessibility identifier to apply to the title of the field (if it exists)
var titleAccessibilityIdentifier: String?
/// The text value to display in this field.
var value: String
/// The (optional) accessibility identifier to apply to the displayed value of the field
var valueAccessibilityIdentifier: String?
/// Any accessory content that should be displayed on the trailing edge of the field. This
/// content automatically has the `AccessoryButtonStyle` applied to it.
var accessoryContent: AccessoryContent?
// MARK: View
var body: some View {
BitwardenField(title: title, titleAccessibilityIdentifier: titleAccessibilityIdentifier) {
Text(value)
.styleGuide(.body)
.multilineTextAlignment(.leading)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
.accessibilityIdentifier(valueAccessibilityIdentifier ?? value)
} accessoryContent: {
accessoryContent
}
}
// MARK: Initialization
/// Creates a new `BitwardenTextValueField`.
///
/// - Parameters:
/// - title: The (optional) title of the field.
/// - titleAccessibilityIdentifier: The (optional) accessibility identifier to apply
/// to the title of the field (if it exists)
/// - value: The text value to display in this field.
/// - valueAccessibilityIdentifier: The (optional) accessibility identifier to apply
/// to the displayed value of the field
/// - accessoryContent: Any accessory content that should be displayed on the trailing edge of
/// the field. This content automatically has the `AccessoryButtonStyle` applied to it.
///
init(
title: String? = nil,
titleAccessibilityIdentifier: String? = "ItemName",
value: String,
valueAccessibilityIdentifier: String? = "ItemValue",
@ViewBuilder accessoryContent: () -> AccessoryContent
) {
self.title = title
self.titleAccessibilityIdentifier = titleAccessibilityIdentifier
self.value = value
self.valueAccessibilityIdentifier = valueAccessibilityIdentifier
self.accessoryContent = accessoryContent()
}
}
extension BitwardenTextValueField where AccessoryContent == EmptyView {
/// Creates a new `BitwardenTextValueField` without accessory content.
///
/// - Parameters:
/// - title: The (optional) title of the field.
/// - titleAccessibilityIdentifier: The (optional) accessibility identifier to apply
/// to the title of the field (if it exists)
/// - value: The text value to display in this field.
///
init(
title: String? = nil,
titleAccessibilityIdentifier: String? = "ItemName",
value: String,
valueAccessibilityIdentifier: String? = "ItemValue"
) {
self.init(
title: title,
titleAccessibilityIdentifier: titleAccessibilityIdentifier,
value: value,
valueAccessibilityIdentifier: valueAccessibilityIdentifier
) {
EmptyView()
}
}
}
// MARK: Previews
#if DEBUG
#Preview {
VStack {
BitwardenTextValueField(
title: "Title",
value: "Text field text"
)
.padding()
}
.background(Color(.systemGroupedBackground))
.previewDisplayName("No buttons")
}
#endif

View File

@ -76,8 +76,8 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
case let .copyTOTPCode(code):
services.pasteboardService.copy(code)
state.toast = Toast(text: Localizations.valueHasBeenCopied(Localizations.verificationCode))
case .itemPressed:
break
case let .itemPressed(item):
coordinator.navigate(to: .viewToken(id: item.id))
case .morePressed:
break
case let .toastShown(newValue):

View File

@ -9,6 +9,7 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
// MARK: - Types
typealias Module = ItemListModule
& TokenModule
typealias Services = HasTimeProvider
& ItemListProcessor.Services
@ -64,7 +65,8 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
case .setupTotpManual:
guard let delegate = context as? AuthenticatorKeyCaptureDelegate else { return }
showManualTotp(delegate: delegate)
case let .viewToken(id):
showToken(route: .viewToken(id: id))
}
}
@ -114,4 +116,17 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
)
stackNavigator?.replace(view, animated: false)
}
/// Presents a token coordinator, which will navigate to the provided route.
///
/// - Parameter route: The route to navigate to in the coordinator.
///
private func showToken(route: TokenRoute) {
let navigationController = UINavigationController()
let coordinator = module.makeTokenCoordinator(stackNavigator: navigationController)
coordinator.start()
coordinator.navigate(to: route, context: self)
stackNavigator?.present(navigationController)
}
}

View File

@ -13,6 +13,12 @@ public enum ItemListRoute: Equatable, Hashable {
/// A route to the manual totp screen for setting up TOTP.
case setupTotpManual
/// A route to the view token screen.
///
/// - Parameter id: The id of the token to display.
///
case viewToken(id: String)
}
enum ItemListEvent {

View File

@ -0,0 +1,90 @@
import BitwardenSdk
import SwiftUI
// MARK: - TokenCoordinator
/// A coordinator that manages navigation for displaying, editing, and adding individual tokens.
///
class TokenCoordinator: NSObject, Coordinator, HasStackNavigator {
// MARK: Types
typealias Module = TokenModule
typealias Services = HasErrorReporter
& HasTimeProvider
& ViewTokenProcessor.Services
// MARK: - Private Properties
/// The module used by this coordinator to create child coordinators.
private let module: Module
/// The services used by this coordinator.
private let services: Services
// MARK: Properties
/// The stack navigator that is managed by this coordinator.
private(set) weak var stackNavigator: StackNavigator?
// MARK: Initialization
/// Creates a new `TokenCoordinator`.
///
/// - Parameters:
/// - 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(
module: Module,
services: Services,
stackNavigator: StackNavigator
) {
self.module = module
self.services = services
self.stackNavigator = stackNavigator
}
// MARK: Methods
func handleEvent(_ event: TokenEvent, context: AnyObject?) async {}
func navigate(to route: TokenRoute, context: AnyObject?) {
switch route {
case let .alert(alert):
stackNavigator?.present(alert)
case let .dismiss(onDismiss):
stackNavigator?.dismiss(animated: true, completion: {
onDismiss?.action()
})
case let .viewToken(id):
showViewToken(id: id)
}
}
func start() {}
// MARK: Private Methods
/// Shows the view token screen.
///
/// - Parameters:
/// - id: The id of the token to show.
/// - delegate: The delegate.
///
private func showViewToken(id: String) {
let processor = ViewTokenProcessor(
coordinator: asAnyCoordinator(),
itemId: id,
services: services,
state: ViewTokenState()
)
let store = Store(processor: processor)
let view = ViewTokenView(
store: store,
timeProvider: services.timeProvider
)
stackNavigator?.replace(view)
}
}

View File

@ -0,0 +1,120 @@
import BitwardenSdk
import Foundation
// MARK: - TokenState
/// An object that defines the current state of any view interacting with a token.
///
struct TokenItemState: Equatable {
// MARK: Types
/// An enum defining if the state is a new or existing token.
enum Configuration: Equatable {
/// We are creating a new token.
case add
/// We are viewing or editing an existing token.
case existing(cipherView: CipherView)
/// The existing `CipherView` if the configuration is `existing`.
var existingCipherView: CipherView? {
guard case let .existing(cipherView) = self else { return nil }
return cipherView
}
}
// MARK: Properties
/// The Add or Existing Configuration.
let configuration: Configuration
/// The name of this item.
var name: String
/// A toast for views
var toast: Toast?
/// The TOTP key/code state.
var totpState: LoginTOTPState
// MARK: Initialization
init(
configuration: Configuration,
name: String,
totpState: LoginTOTPState
) {
self.configuration = configuration
self.name = name
self.totpState = totpState
}
init?(existing cipherView: CipherView) {
guard let totp = cipherView.login?.totp else { return nil }
self.init(
configuration: .existing(cipherView: cipherView),
name: cipherView.name,
totpState: LoginTOTPState(totp)
)
}
}
extension TokenItemState: ViewTokenItemState {
var authenticatorKey: String? {
totpState.rawAuthenticatorKeyString
}
var cipher: BitwardenSdk.CipherView {
switch configuration {
case let .existing(cipherView: view):
return view
case .add:
return newCipherView()
}
}
var totpCode: TOTPCodeModel? {
totpState.codeModel
}
}
extension TokenItemState {
/// Returns a `CipherView` based on the properties of the `TokenItemState`.
///
func newCipherView(creationDate: Date = .now) -> CipherView {
CipherView(
id: nil,
organizationId: nil,
folderId: nil,
collectionIds: [],
key: nil,
name: name,
notes: nil,
type: .login,
login: .init(
username: nil,
password: nil,
passwordRevisionDate: nil,
uris: nil,
totp: totpState.rawAuthenticatorKeyString,
autofillOnPageLoad: nil,
fido2Credentials: nil
),
identity: nil,
card: nil,
secureNote: nil,
favorite: false,
reprompt: .none,
organizationUseTotp: false,
edit: false,
viewPassword: false,
localData: nil,
attachments: nil,
fields: nil,
passwordHistory: nil,
creationDate: creationDate,
deletedDate: nil,
revisionDate: creationDate
)
}
}

View File

@ -0,0 +1,28 @@
import Foundation
// MARK: - TokenModule
/// An object that builds coordinators for the token views.
@MainActor
protocol TokenModule {
/// Initializes a coordinator for navigating between `TokenRoute` objects.
///
/// - Parameter stackNavigator: The stack navigator that will be used to navigate between routes.
/// - Returns: A coordinator that can navigate to a `TokenRoute`.
///
func makeTokenCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<TokenRoute, TokenEvent>
}
extension DefaultAppModule: TokenModule {
func makeTokenCoordinator(
stackNavigator: StackNavigator
) -> AnyCoordinator<TokenRoute, TokenEvent> {
TokenCoordinator(
module: self,
services: services,
stackNavigator: stackNavigator
).asAnyCoordinator()
}
}

View File

@ -0,0 +1,27 @@
import BitwardenSdk
import SwiftUI
// MARK: - TokenRoute
/// A route to a screen for a specific token.
enum TokenRoute: Equatable, Hashable {
/// A route to display the specified alert.
///
/// - Parameter alert: The alert to display.
///
case alert(_ alert: Alert)
/// A route to dismiss the screen currently presented modally.
///
/// - Parameter action: The action to perform on dismiss.
///
case dismiss(_ action: DismissAction? = nil)
/// A route to the view token screen.
///
/// - Parameter id: The id of the token to display.
///
case viewToken(id: String)
}
enum TokenEvent {}

View File

@ -0,0 +1,20 @@
import BitwardenSdk
// MARK: - ViewTokenAction
/// Synchronous actions that can be processed by a `ViewTokenProcessor`.
enum ViewTokenAction: Equatable {
/// A copy button was pressed for the given value.
///
/// - Parameters:
/// - value: The value to copy.
/// - field: The field being copied.
///
case copyPressed(value: String)
/// The edit button was pressed.
case editPressed
/// The toast was shown or hidden.
case toastShown(Toast?)
}

View File

@ -0,0 +1,10 @@
// MARK: - ViewTokenEffect
/// Asynchronous effects that can be processed by a `ViewTokenProcessor`.
enum ViewTokenEffect: Equatable {
/// The view token screen appeared.
case appeared
/// The TOTP code for the view expired.
case totpCodeExpired
}

View File

@ -0,0 +1,24 @@
import BitwardenSdk
import Foundation
// MARK: - ViewTokenItemState
// The state for viewing/adding/editing a token item
protocol ViewTokenItemState: Sendable {
// MARK: Properties
/// The TOTP key.
var authenticatorKey: String? { get }
/// The TOTP key/code state.
// var totpState: LoginTOTPState
/// The TOTP code model
var totpCode: TOTPCodeModel? { get }
}
//extension ViewTokenItemState {
// var totpCode: TOTPCodeModel? {
// totpState.codeModel
// }
//}

View File

@ -0,0 +1,82 @@
import BitwardenSdk
import SwiftUI
// MARK: - ViewTokenItemView
/// A view for displaying the contents of a token item.
struct ViewTokenItemView: View {
// MARK: Private Properties
// MARK: Properties
/// The `Store` for this view.
@ObservedObject var store: Store<TokenItemState, ViewTokenAction, ViewTokenEffect>
/// The `TimeProvider` used to calculate TOTP expiration.
var timeProvider: any TimeProvider
// MARK: View
var body: some View {
if let totpModel = store.state.totpCode {
BitwardenField(
title: Localizations.verificationCodeTotp,
content: {
Text(totpModel.displayCode)
.styleGuide(.bodyMonospaced)
.multilineTextAlignment(.leading)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
},
accessoryContent: {
TOTPCountdownTimerView(
timeProvider: timeProvider,
totpCode: totpModel,
onExpiration: {
Task {
await store.perform(.totpCodeExpired)
}
}
)
Button {
store.send(.copyPressed(value: totpModel.code))
} label: {
Asset.Images.copy.swiftUIImage
.imageStyle(.accessoryIcon)
}
.accessibilityLabel(Localizations.copy)
}
)
} else {
Text("Something went wrong.")
}
}
}
// MARK: Previews
#Preview("Code") {
ViewTokenItemView(
store: Store(
processor: StateProcessor(
state:
TokenItemState(
configuration: .add,
name: "Example",
totpState: LoginTOTPState(
authKeyModel: TOTPKeyModel(authenticatorKey: "ASDF")!,
codeModel: TOTPCodeModel(
code: "123123",
codeGenerationDate: Date(timeIntervalSinceReferenceDate: 0),
period: 30
)
)
)
)
),
timeProvider: PreviewTimeProvider(
fixedDate: Date(
timeIntervalSinceReferenceDate: 0
)
)
)
}

View File

@ -0,0 +1,120 @@
import BitwardenSdk
import Foundation
// MARK: - ViewTokenProcessor
/// The processor used to manage state and handle actions for the view token screen.
final class ViewTokenProcessor: StateProcessor<
ViewTokenState,
ViewTokenAction,
ViewTokenEffect
> {
// MARK: Types
typealias Services = HasErrorReporter
& HasItemRepository
& HasPasteboardService
& HasTOTPService
& HasTimeProvider
// MARK: Properties
/// The `Coordinator` that handles navigation, typically a `TokenCoordinator`.
private let coordinator: AnyCoordinator<TokenRoute, TokenEvent>
/// The ID of the item being viewed.
private let itemId: String
/// The services used by this processor.
private let services: Services
// MARK: Initialization
/// Creates a new `ViewTokenProcessor`.
///
/// - Parameters:
/// - coordinator: The `Coordinator` for this processor.
/// - itemId: The ID of the item that is being viewed.
/// - services: The services used by this processor.
/// - state: The initial state of this processor.
///
init(
coordinator: AnyCoordinator<TokenRoute, TokenEvent>,
itemId: String,
services: Services,
state: ViewTokenState
) {
self.coordinator = coordinator
self.itemId = itemId
self.services = services
super.init(state: state)
}
// MARK: Methods
override func perform(_ effect: ViewTokenEffect) async {
switch effect {
case .appeared:
await streamTokenDetails()
case .totpCodeExpired:
await updateTOTPCode()
}
}
override func receive(_ action: ViewTokenAction) {
switch action {
case let .toastShown(newValue):
state.toast = newValue
case let .copyPressed(value):
services.pasteboardService.copy(value)
state.toast = Toast(text: Localizations.valueHasBeenCopied(Localizations.verificationCode))
case .editPressed:
break
}
}
}
private extension ViewTokenProcessor {
// MARK: Private Methods
/// Updates the TOTP code for the view.
func updateTOTPCode() async {
guard case let .data(tokenItemState) = state.loadingState,
let calculationKey = tokenItemState.totpState.authKeyModel
else { return }
do {
let newLoginTotp = try await services.itemRepository.refreshTOTPCode(for: calculationKey)
guard case let .data(tokenItemState) = state.loadingState else { return }
var newState = tokenItemState
newState.totpState = newLoginTotp
state.loadingState = .data(newState)
} catch {
services.errorReporter.log(error: error)
}
}
/// Stream the cipher details.
private func streamTokenDetails() async {
do {
guard let token = try await services.itemRepository.fetchItem(withId: itemId)
else { return }
var totpState = LoginTOTPState(token.login?.totp)
if let key = totpState.authKeyModel,
let updatedState = try? await services.itemRepository.refreshTOTPCode(for: key) {
totpState = updatedState
}
guard var newState = ViewTokenState(cipherView: token) else { return }
if case var .data(tokenState) = newState.loadingState {
tokenState.totpState = totpState
newState.loadingState = .data(tokenState)
}
state = newState
} catch {
services.errorReporter.log(error: error)
}
}
}

View File

@ -0,0 +1,32 @@
import BitwardenSdk
import Foundation
// MARK: - ViewTokenState
/// A `Sendable` type used to describe the state of a `ViewTokenView`
struct ViewTokenState: Sendable {
// MARK: Properties
/// The current state. If this state is not `.loading`, this value will contain an associated value with the
/// appropriate internal state.
var loadingState: LoadingState<TokenItemState> = .loading(nil)
/// A toast message to show in the view.
var toast: Toast?
}
extension ViewTokenState {
// MARK: Initialization
/// Creates a new `ViewTokenState` from a provided `CipherView`.
///
/// - Parameters:
/// - cipherView: The `CipherView` to create this state with.
///
init?(cipherView: CipherView) {
guard let tokenItemState = TokenItemState(
existing: cipherView
) else { return nil }
self.init(loadingState: .data(tokenItemState))
}
}

View File

@ -0,0 +1,123 @@
import BitwardenSdk
import SwiftUI
// MARK: - ViewTokenView
/// A view that displays the information for a token.
struct ViewTokenView: View {
// MARK: Private Properties
// MARK: Properties
/// The `Store` for this view.
@ObservedObject var store: Store<ViewTokenState, ViewTokenAction, ViewTokenEffect>
/// The `TimeProvider` used to calculate TOTP expiration.
var timeProvider: any TimeProvider
// MARK: View
var body: some View {
LoadingView(state: store.state.loadingState) { state in
details(for: state)
}
.background(Asset.Colors.backgroundSecondary.swiftUIColor.ignoresSafeArea())
.navigationTitle(navigationTitle)
.navigationBarTitleDisplayMode(.inline)
.toast(store.binding(
get: \.toast,
send: ViewTokenAction.toastShown
))
.task {
await store.perform(.appeared)
}
}
/// The title of the view
private var navigationTitle: String {
Localizations.viewItem
}
// MARK: Private Views
/// The details of the token.
@ViewBuilder
private func details(for state: TokenItemState) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
BitwardenTextValueField(title: Localizations.name, value: state.name)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("ItemRow")
ViewTokenItemView(
store: store.child(
state: { _ in state },
mapAction: { $0 },
mapEffect: { $0 }
),
timeProvider: timeProvider
)
}.padding(16)
}.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
editToolbarButton {
store.send(.editPressed)
}
}
}
}
}
// MARK: Previews
#if DEBUG
#Preview("Loading") {
NavigationView {
ViewTokenView(
store: Store(
processor: StateProcessor(
state: ViewTokenState(
loadingState: .loading(nil)
)
)
),
timeProvider: PreviewTimeProvider(
fixedDate: Date(timeIntervalSinceReferenceDate: 0)
)
)
}
}
#Preview("Token") {
NavigationView {
ViewTokenView(
store: Store(
processor: StateProcessor(
state: ViewTokenState(
loadingState: .data(
TokenItemState(
configuration: .add,
name: "Example",
totpState: LoginTOTPState(
authKeyModel: TOTPKeyModel(authenticatorKey: "ASDF")!,
codeModel: TOTPCodeModel(
code: "123123",
codeGenerationDate: Date(timeIntervalSinceReferenceDate: 0),
period: 30
)
)
)
)
)
)
),
timeProvider: PreviewTimeProvider(
fixedDate: Date(
timeIntervalSinceReferenceDate: 0
)
)
)
}
}
#endif

View File

@ -1,363 +0,0 @@
//import BitwardenSdk
//import Foundation
//
//// MARK: - CipherItemState
//
///// An object that defines the current state of any view interacting with a cipher item.
/////
//struct CipherItemState: Equatable {
// // MARK: Types
//
// /// An enum defining if the state is a new or existing cipher.
// enum Configuration: Equatable {
// /// A case for new ciphers.
// case add
//
// /// A case to view or edit an existing cipher.
// case existing(cipherView: CipherView)
//
// /// The existing `CipherView` if the configuration is `existing`.
// var existingCipherView: CipherView? {
// guard case let .existing(cipherView) = self else { return nil }
// return cipherView
// }
//
// /// Whether the configuration is for adding a new cipher.
// var isAdding: Bool {
// guard case .add = self else { return false }
// return true
// }
// }
//
// // MARK: Properties
//
// /// A flag indicating if this account has premium features.
// var accountHasPremium: Bool
//
// /// Whether the user should be able to select the type of item to add.
// var allowTypeSelection: Bool
//
// /// The card item state.
//// var cardItemState: CardItemState
//
// /// The list of collection IDs that the cipher is included in.
// var collectionIds: [String]
//
// /// The full list of collections for the user, across all organizations.
// var collections: [CollectionView]
//
// /// The Add or Existing Configuration.
// let configuration: Configuration
//
// /// The custom fields state.
//// var customFieldsState: AddEditCustomFieldsState
//
// /// The identifier of the folder for this item.
// var folderId: String?
//
// /// The list of all folders that the item could be added to.
//// var folders: [DefaultableType<FolderView>]
//
// /// The state for a identity type item.
//// var identityState: IdentityItemState
//
// /// A flag indicating if this item is favorited.
// var isFavoriteOn: Bool
//
// /// A flag indicating if master password re-prompt is required.
// var isMasterPasswordRePromptOn: Bool
//
// /// Whether the policy is enforced to disable personal vault ownership.
// var isPersonalOwnershipDisabled: Bool
//
// /// The state for a login type item.
// var loginState: LoginItemState
//
// /// The name of this item.
// var name: String
//
// /// The notes for this item.
// var notes: String
//
// /// The organization ID of the cipher, if the cipher is owned by an organization.
// var organizationId: String?
//
// /// The list of ownership options that can be selected for the cipher.
//// var ownershipOptions: [CipherOwner]
//
// /// A toast for the AddEditItemView
// var toast: Toast?
//
// /// What cipher type this item is.
// var type: CipherType
//
// /// When this item was last updated.
// var updatedDate: Date
//
// // MARK: DerivedProperties
//
// /// The edit state of the item.
//// var addEditState: AddEditItemState {
//// self
//// }
//
// /// The list of collections that can be selected from for the current owner.
//// var collectionsForOwner: [CollectionView] {
//// guard let owner, !owner.isPersonal else { return [] }
//// return collections.filter { $0.organizationId == owner.organizationId }
//// }
//
// /// The folder this item should be added to.
//// var folder: DefaultableType<FolderView> {
//// get {
//// guard let folderId,
//// let folder = folders.first(where: { $0.customValue?.id == folderId })?.customValue else {
//// return .default
//// }
//// return .custom(folder)
//// } set {
//// folderId = newValue.customValue?.id
//// }
//// }
//
// /// The owner of the cipher.
//// var owner: CipherOwner? {
//// get {
//// guard let organizationId else { return ownershipOptions.first(where: \.isPersonal) }
//// return ownershipOptions.first(where: { $0.organizationId == organizationId })
//// }
//// set {
//// organizationId = newValue?.organizationId
//// collectionIds = []
//// }
//// }
//
// /// The view state of the item.
//// var viewState: ViewVaultItemState? {
//// guard case .existing = configuration else {
//// return nil
//// }
////
//// return self
//// }
//
// // MARK: Initialization
//
// private init(
// accountHasPremium: Bool,
// allowTypeSelection: Bool,
//// cardState: CardItemState,
// collectionIds: [String],
// configuration: Configuration,
//// customFields: [CustomFieldState],
// folderId: String?,
//// identityState: IdentityItemState,
// isFavoriteOn: Bool,
// isMasterPasswordRePromptOn: Bool,
// isPersonalOwnershipDisabled: Bool,
// loginState: LoginItemState,
// name: String,
// notes: String,
// organizationId: String?,
// type: CipherType,
// updatedDate: Date
// ) {
// self.accountHasPremium = accountHasPremium
// self.allowTypeSelection = allowTypeSelection
//// cardItemState = cardState
// self.collectionIds = collectionIds
// collections = []
//// customFieldsState = AddEditCustomFieldsState(cipherType: type, customFields: customFields)
// self.folderId = folderId
//// self.identityState = identityState
// self.isFavoriteOn = isFavoriteOn
// self.isMasterPasswordRePromptOn = isMasterPasswordRePromptOn
// self.isPersonalOwnershipDisabled = isPersonalOwnershipDisabled
//// folders = []
// self.loginState = loginState
// self.name = name
// self.notes = notes
// self.organizationId = organizationId
//// ownershipOptions = []
// self.type = type
// self.updatedDate = updatedDate
// self.configuration = configuration
// }
//
//// init(
//// addItem type: CipherType = .login,
//// allowTypeSelection: Bool = true,
//// collectionIds: [String] = [],
////// customFields: [CustomFieldState] = [],
//// folderId: String? = nil,
//// hasPremium: Bool,
//// name: String? = nil,
//// organizationId: String? = nil,
//// password: String? = nil,
//// totpKeyString: String? = nil,
//// uri: String? = nil,
//// username: String? = nil
//// ) {
//// self.init(
//// accountHasPremium: hasPremium,
//// allowTypeSelection: allowTypeSelection,
////// cardState: .init(),
//// collectionIds: collectionIds,
////// configuration: .add,
//// customFields: customFields,
//// folderId: folderId,
////// identityState: .init(),
//// isFavoriteOn: false,
//// isMasterPasswordRePromptOn: false,
//// isPersonalOwnershipDisabled: false,
//// loginState: .init(
//// isTOTPAvailable: hasPremium,
//// password: password ?? "",
//// totpState: .init(totpKeyString),
//// uris: [UriState(uri: uri ?? "")],
//// username: username ?? ""
//// ),
//// name: name ?? uri.flatMap(URL.init)?.host ?? "",
//// notes: "",
//// organizationId: organizationId,
//// type: type,
//// updatedDate: .now
//// )
//// }
//
//// init(cloneItem cipherView: CipherView, hasPremium: Bool) {
//// self.init(
//// accountHasPremium: hasPremium,
//// allowTypeSelection: false,
//// cardState: cipherView.cardItemState(),
//// collectionIds: cipherView.collectionIds,
//// configuration: .add,
//// customFields: cipherView.customFields,
//// folderId: cipherView.folderId,
//// identityState: cipherView.identityItemState(),
//// isFavoriteOn: cipherView.favorite,
//// isMasterPasswordRePromptOn: cipherView.reprompt == .password,
//// isPersonalOwnershipDisabled: false,
//// loginState: cipherView.loginItemState(showTOTP: hasPremium),
//// name: "\(cipherView.name) - \(Localizations.clone)",
//// notes: cipherView.notes ?? "",
//// organizationId: cipherView.organizationId,
//// type: .init(type: cipherView.type),
//// updatedDate: cipherView.revisionDate
//// )
//// }
//
//// init?(existing cipherView: CipherView, hasPremium: Bool) {
//// guard cipherView.id != nil else { return nil }
//// self.init(
//// accountHasPremium: hasPremium,
//// allowTypeSelection: false,
////// cardState: cipherView.cardItemState(),
//// collectionIds: cipherView.collectionIds,
//// configuration: .existing(cipherView: cipherView),
////// customFields: cipherView.customFields,
//// folderId: cipherView.folderId,
////// identityState: cipherView.identityItemState(),
//// isFavoriteOn: cipherView.favorite,
//// isMasterPasswordRePromptOn: cipherView.reprompt == .password,
//// isPersonalOwnershipDisabled: false,
//// loginState: cipherView.loginItemState(showTOTP: hasPremium),
//// name: cipherView.name,
//// notes: cipherView.notes ?? "",
//// organizationId: cipherView.organizationId,
////// type: .init(type: cipherView.type),
//// updatedDate: cipherView.revisionDate
//// )
//// }
//
// // MARK: Methods
//
// /// Toggles the password visibility for the specified custom field.
// ///
// /// - Parameter customFieldState: The custom field to update.
// ///
//// mutating func togglePasswordVisibility(for customFieldState: CustomFieldState) {
//// if let index = customFieldsState.customFields.firstIndex(of: customFieldState) {
//// customFieldsState.customFields[index].isPasswordVisible.toggle()
//// }
//// }
//
// /// Toggles whether the cipher is included in the specified collection.
// ///
// /// - Parameters:
// /// - newValue: Whether the cipher is included in the collection.
// /// - collectionId: The identifier of the collection.
// ///
// mutating func toggleCollection(newValue: Bool, collectionId: String) {
// if newValue {
// collectionIds.append(collectionId)
// } else {
// collectionIds = collectionIds.filter { $0 != collectionId }
// }
// }
//}
//
////extension CipherItemState: AddEditItemState {}
//
////extension CipherItemState: ViewVaultItemState {
//// var attachments: [AttachmentView]? {
//// cipher.attachments
//// }
////
//// var isSoftDeleted: Bool {
//// cipher.deletedDate != nil
//// }
////
//// var cardItemViewState: any ViewCardItemState {
//// cardItemState
//// }
////
//// var cipher: BitwardenSdk.CipherView {
//// switch configuration {
//// case let .existing(cipherView: view):
//// return view
//// case .add:
//// return newCipherView()
//// }
//// }
////}
//
//extension CipherItemState {
// /// Returns a `CipherView` based on the properties of the `CipherItemState`.
// func newCipherView(creationDate: Date = .now) -> CipherView {
// CipherView(
// id: nil,
// organizationId: organizationId,
// folderId: folderId,
// collectionIds: collectionIds,
// key: nil,
// name: name,
// notes: nil, //notes.nilIfEmpty,
// type: BitwardenSdk.CipherType(type),
// login: type == .login ? loginState.loginView : nil,
// identity: nil, //type == .identity ? identityState.identityView : nil,
// card: nil,//type == .card ? cardItemState.cardView : nil,
// secureNote: type == .secureNote ? .init(type: .generic) : nil,
// favorite: isFavoriteOn,
// reprompt: isMasterPasswordRePromptOn ? .password : .none,
// organizationUseTotp: false,
// edit: true,
// viewPassword: true,
// localData: nil,
// attachments: nil,
// fields: nil,
//// customFieldsState.customFields.isEmpty ? nil : customFieldsState.customFields.map { customField in
//// FieldView(
//// name: customField.name,
//// value: customField.value,
//// type: .init(fieldType: customField.type),
//// linkedId: customField.linkedIdType?.rawValue
//// )
//// },
// passwordHistory: nil,
// creationDate: creationDate,
// deletedDate: nil,
// revisionDate: creationDate
// )
// }
//}

View File

@ -1,30 +0,0 @@
//import BitwardenSdk
//import Foundation
//import XCTest
//
//@testable import AuthenticatorShared
//
//class CipherItemStateTests: AuthenticatorTestCase {
// // MARK: Tests
//
// /// `init(cloneItem: hasPremium)` returns a cloned CipherItemState.
// func test_init_clone() {
// let cipher = CipherView.loginFixture()
// let state = CipherItemState(cloneItem: cipher, hasPremium: true)
// XCTAssertEqual(state.name, "\(cipher.name) - \(Localizations.clone)")
// XCTAssertNil(state.cipher.id)
// XCTAssertEqual(state.accountHasPremium, true)
// XCTAssertEqual(state.allowTypeSelection, false)
// XCTAssertEqual(state.cardItemState, cipher.cardItemState())
// XCTAssertEqual(state.configuration, .add)
// XCTAssertEqual(state.customFieldsState, .init(cipherType: .login, customFields: cipher.customFields))
// XCTAssertEqual(state.folderId, cipher.folderId)
// XCTAssertEqual(state.identityState, cipher.identityItemState())
// XCTAssertEqual(state.isFavoriteOn, cipher.favorite)
// XCTAssertEqual(state.isMasterPasswordRePromptOn, cipher.reprompt == .password)
// XCTAssertEqual(state.loginState, cipher.loginItemState(showTOTP: true))
// XCTAssertEqual(state.notes, cipher.notes ?? "")
// XCTAssertEqual(state.type, .init(type: cipher.type))
// XCTAssertEqual(state.updatedDate, cipher.revisionDate)
// }
//}