Skip to content

Commit

Permalink
Allow user to delete items (#19)
Browse files Browse the repository at this point in the history
  • Loading branch information
KatherineInCode authored Apr 11, 2024
1 parent b99af3c commit 9bce0f7
Show file tree
Hide file tree
Showing 12 changed files with 201 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import Foundation
@testable import AuthenticatorShared

class MockAuthenticatorItemRepository: AuthenticatorItemRepository {

// MARK: Properties

var addAuthenticatorItemAuthenticatorItems = [AuthenticatorItemView]()
Expand Down Expand Up @@ -42,7 +41,7 @@ class MockAuthenticatorItemRepository: AuthenticatorItemRepository {
}

func fetchAllAuthenticatorItems() async throws -> [AuthenticatorShared.AuthenticatorItemView] {
return try fetchAllAuthenticatorItemsResult.get()
try fetchAllAuthenticatorItemsResult.get()
}

func fetchAuthenticatorItem(withId id: String) async throws -> AuthenticatorItemView? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,13 @@ private extension ViewTokenProcessor {
/// Stream the item details.
private func streamItemDetails() async {
do {
guard let authenticatorItemView = try await services.authenticatorItemRepository.fetchAuthenticatorItem(withId: itemId),
let key = authenticatorItemView.totpKey,
guard let item = try await services.authenticatorItemRepository.fetchAuthenticatorItem(withId: itemId),
let key = item.totpKey,
let model = TOTPKeyModel(authenticatorKey: key)
else { return }

let code = try await services.totpService.getTotpCode(for: model)
guard var newAuthenticatorItemState = ViewTokenState(authenticatorItemView: authenticatorItemView)
guard var newAuthenticatorItemState = ViewTokenState(authenticatorItemView: item)
else { return }

if case var .data(authenticatorItemState) = newAuthenticatorItemState.loadingState {
Expand Down
62 changes: 62 additions & 0 deletions AuthenticatorShared/UI/Vault/Extensions/Alert+Vault.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import UIKit

// MARK: Alert+Vault

extension Alert {
/// An alert confirming deletion of an item
///
/// - Parameters:
/// - action: The action to perform if the user confirms
/// - Returns: An alert confirming item deletion
///
static func confirmDeleteItem(action: @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.doYouReallyWantToDelete,
message: nil,
alertActions: [
AlertAction(title: Localizations.yes, style: .default) { _, _ in await action() },
AlertAction(title: Localizations.no, style: .cancel),
]
)
}

/// An alert presenting the user with more options for an item.
///
/// - Parameters:
/// - authenticatorItemView: The item to show
/// - id: The id of the item
/// - action: The action to perform after selecting an option.
///
/// - Returns: An alert presenting the user with options to select an attachment type.
@MainActor
static func moreOptions(
authenticatorItemView: AuthenticatorItemView,
id: String,
action: @escaping (_ action: MoreOptionsAction) async -> Void
) -> Alert {
var alertActions = [AlertAction]()

if let totp = authenticatorItemView.totpKey,
let totpKey = TOTPKeyModel(authenticatorKey: totp) {
alertActions.append(
AlertAction(title: Localizations.copy, style: .default) { _, _ in
await action(.copyTotp(totpKey: totpKey))
})
}

alertActions.append(AlertAction(title: Localizations.edit, style: .default) { _, _ in
await action(.edit(authenticatorItemView: authenticatorItemView))
})

alertActions.append(AlertAction(title: Localizations.delete, style: .destructive) { _, _ in
await action(.delete(id: id))
})

return Alert(
title: authenticatorItemView.name,
message: nil,
preferredStyle: .actionSheet,
alertActions: alertActions + [AlertAction(title: Localizations.cancel, style: .cancel)]
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,6 @@ enum ItemListAction: Equatable {
///
case itemPressed(_ item: ItemListItem)

/// The more button on an item in the vault group was tapped.
///
/// - Parameter item: The item associated with the more button that was tapped.
///
case morePressed(_ item: ItemListItem)

/// The toast was shown or hidden.
case toastShown(Toast?)
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,16 @@ enum ItemListEffect: Equatable {
/// The vault group view appeared on screen.
case appeared

/// The more button on an item in the vault group was tapped.
///
/// - Parameters:
/// - item: The item associated with the more button that was tapped.
///
case morePressed(_ item: ItemListItem)

/// The refresh control was triggered.
case refresh

/// Stream the vault list for the user.
case streamVaultList
case streamItemList
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
// MARK: Private Properties

/// The `Coordinator` for this processor.
private var coordinator: any Coordinator<ItemListRoute, ItemListEvent>
private var coordinator: AnyCoordinator<ItemListRoute, ItemListEvent>

/// The services for this processor.
private var services: Services
Expand All @@ -34,7 +34,7 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
/// - state: The initial state of this processor.
///
init(
coordinator: any Coordinator<ItemListRoute, ItemListEvent>,
coordinator: AnyCoordinator<ItemListRoute, ItemListEvent>,
services: Services,
state: ItemListState
) {
Expand All @@ -61,9 +61,11 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
await setupTotp()
case .appeared:
await streamItemList()
case let .morePressed(item):
await showMoreOptionsAlert(for: item)
case .refresh:
await streamItemList()
case .streamVaultList:
case .streamItemList:
await streamItemList()
}
}
Expand All @@ -76,16 +78,52 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
services.pasteboardService.copy(code)
state.toast = Toast(text: Localizations.valueHasBeenCopied(Localizations.verificationCode))
case let .itemPressed(item):
coordinator.navigate(to: .viewItem(id: item.id))
case .morePressed:
break
switch item.itemType {
case let .totp(model):
services.pasteboardService.copy(model.totpCode.code)
state.toast = Toast(text: Localizations.valueHasBeenCopied(Localizations.verificationCode))
}
case let .toastShown(newValue):
state.toast = newValue
}
}

// MARK: Private Methods

/// Confirm that the user wants to delete the item then delete it if so
private func confirmDeleteItem(_ id: String) {
coordinator.showAlert(.confirmDeleteItem {
await self.deleteItem(id)
})
}

/// Delete the item
private func deleteItem(_ id: String) async {
defer { coordinator.hideLoadingOverlay() }
do {
coordinator.showLoadingOverlay(title: Localizations.deleting)
try await services.authenticatorItemRepository.deleteAuthenticatorItem(id)
state.toast = Toast(text: Localizations.itemDeleted)
} catch {
services.errorReporter.log(error: error)
}
}

/// Generates and copies a TOTP code for the cipher's TOTP key.
///
/// - Parameter totpKey: The TOTP key used to generate a TOTP code.
///
private func generateAndCopyTotpCode(totpKey: TOTPKeyModel) async {
do {
let code = try await services.totpService.getTotpCode(for: totpKey)
services.pasteboardService.copy(code.code)
state.toast = Toast(text: Localizations.valueHasBeenCopied(Localizations.verificationCodeTotp))
} catch {
coordinator.showAlert(.defaultAlert(title: Localizations.anErrorHasOccurred))
services.errorReporter.log(error: error)
}
}

/// Refreshes the vault group's TOTP Codes.
///
private func refreshTOTPCodes(for items: [ItemListItem]) async {
Expand Down Expand Up @@ -127,6 +165,39 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
}
}

/// Show the more options alert for the selected item.
///
/// - Parameter item: The selected item to show the options for.
///
private func showMoreOptionsAlert(for item: ItemListItem) async {
guard case let .totp(model) = item.itemType else { return }

coordinator.showAlert(
.moreOptions(
authenticatorItemView: model.itemView,
id: item.id,
action: handleMoreOptionsAction
)
)
}

/// Handle the result of the selected option on the More Options alert.
///
/// - Parameter action: The selected action.
///
private func handleMoreOptionsAction(_ action: MoreOptionsAction) async {
switch action {
case let .copyTotp(totpKey):
await generateAndCopyTotpCode(totpKey: totpKey)
case let .delete(id):
confirmDeleteItem(id)
case let .edit(item):
coordinator.navigate(to: .editItem(item: item))
case let .view(id):
coordinator.navigate(to: .viewItem(id: id))
}
}

/// Stream the items list.
private func streamItemList() async {
do {
Expand Down Expand Up @@ -291,3 +362,20 @@ extension ItemListProcessor: AuthenticatorKeyCaptureDelegate {
captureCoordinator.navigate(to: .dismiss(dismissAction))
}
}

// MARK: - MoreOptionsAction

/// The actions available from the More Options alert.
enum MoreOptionsAction: Equatable {
/// Generate and copy the TOTP code for the given `totpKey`.
case copyTotp(totpKey: TOTPKeyModel)

/// Delete the item with the given `id`
case delete(id: String)

/// Navigate to the view to edit the `AuthenticatorItemView`.
case edit(authenticatorItemView: AuthenticatorItemView)

/// Navigate to view the item with the given `id`.
case view(id: String)
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ struct ItemListView: View {
.task {
await store.perform(.appeared)
}
.toast(store.binding(
get: \.toast,
send: ItemListAction.toastShown
))
}

// MARK: Private
Expand Down Expand Up @@ -122,11 +126,14 @@ struct ItemListView: View {
switch action {
case let .copyTOTPCode(code):
return .copyTOTPCode(code)
}
},
mapEffect: { effect in
switch effect {
case .morePressed:
return .morePressed(item)
}
},
mapEffect: { .appeared }
}
),
timeProvider: timeProvider
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,14 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
switch route {
case .addItem:
break
case let .editItem(item):
showToken(route: .editAuthenticatorItem(item))
case .list:
showList()
case .setupTotpManual:
guard let delegate = context as? AuthenticatorKeyCaptureDelegate else { return }
showManualTotp(delegate: delegate)
case let .viewItem(id):
Logger.application.log("View token \(id)")
showToken(route: .viewToken(id: id))
}
}
Expand Down Expand Up @@ -104,6 +105,8 @@ final class ItemListCoordinator: Coordinator, HasStackNavigator {
stackNavigator?.present(navigationController)
}

/// Shows the list of items
///
func showList() {
let processor = ItemListProcessor(
coordinator: asAnyCoordinator(),
Expand Down
5 changes: 4 additions & 1 deletion AuthenticatorShared/UI/Vault/ItemList/ItemListRoute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import Foundation
// MARK: - ItemListRoute

/// A route to a specific screen or subscreen of the Item List
public enum ItemListRoute: Equatable, Hashable {
enum ItemListRoute: Equatable {
/// A route to the add item screen.
case addItem

/// A route to the edit item screen
case editItem(item: AuthenticatorItemView)

/// A route to the base item list screen.
case list

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,4 @@
enum ItemListItemRowAction: Equatable {
/// The copy TOTP Code button was pressed.
case copyTOTPCode(_ code: String)

case morePressed
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// MARK: - ItemListItemRowEffect

/// Effects that can be performed from an `ItemListItemRowView`
enum ItemListItemRowEffect: Equatable {
/// The more button was pressed
case morePressed
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ struct ItemListItemRowView: View {
// MARK: Properties

/// The `Store` for this view.
var store: Store<ItemListItemRowState, ItemListItemRowAction, Void>
var store: Store<
ItemListItemRowState,
ItemListItemRowAction,
ItemListItemRowEffect
>

/// The `TimeProvider` used to calculate TOTP expiration.
var timeProvider: any TimeProvider
Expand Down Expand Up @@ -82,12 +86,12 @@ struct ItemListItemRowView: View {
Text(model.displayCode)
.styleGuide(.bodyMonospaced, weight: .regular, monoSpacedDigit: true)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
Button {
Task { @MainActor in
store.send(.copyTOTPCode(model.code))
AsyncButton {
Task {
await store.perform(.morePressed)
}
} label: {
Asset.Images.copy.swiftUIImage
Asset.Images.horizontalKabob.swiftUIImage
}
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
.accessibilityLabel(Localizations.copyTotp)
Expand Down

0 comments on commit 9bce0f7

Please sign in to comment.