mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-10 17:46:07 -06:00
Add view to view item details (#12)
This commit is contained in:
parent
fb94f87bf2
commit
55a2e9c505
@ -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)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@ -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)
|
||||
//}
|
||||
@ -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
|
||||
// }
|
||||
//}
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
90
AuthenticatorShared/UI/Vault/Token/TokenCoordinator.swift
Normal file
90
AuthenticatorShared/UI/Vault/Token/TokenCoordinator.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
120
AuthenticatorShared/UI/Vault/Token/TokenItemState.swift
Normal file
120
AuthenticatorShared/UI/Vault/Token/TokenItemState.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
28
AuthenticatorShared/UI/Vault/Token/TokenModule.swift
Normal file
28
AuthenticatorShared/UI/Vault/Token/TokenModule.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
27
AuthenticatorShared/UI/Vault/Token/TokenRoute.swift
Normal file
27
AuthenticatorShared/UI/Vault/Token/TokenRoute.swift
Normal 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 {}
|
||||
@ -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?)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
// }
|
||||
//}
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
123
AuthenticatorShared/UI/Vault/Token/ViewToken/ViewTokenView.swift
Normal file
123
AuthenticatorShared/UI/Vault/Token/ViewToken/ViewTokenView.swift
Normal 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
|
||||
@ -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
|
||||
// )
|
||||
// }
|
||||
//}
|
||||
@ -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)
|
||||
// }
|
||||
//}
|
||||
Loading…
x
Reference in New Issue
Block a user