diff --git a/Vault/Sources/VaultFeed/Presentation/Backup/BackupImportFlowViewModel.swift b/Vault/Sources/VaultFeed/Presentation/Backup/BackupImportFlowViewModel.swift index 179a3f25a..55156c844 100644 --- a/Vault/Sources/VaultFeed/Presentation/Backup/BackupImportFlowViewModel.swift +++ b/Vault/Sources/VaultFeed/Presentation/Backup/BackupImportFlowViewModel.swift @@ -132,6 +132,19 @@ public final class BackupImportFlowViewModel { payloadState = .ready(payload, UUID()) } + /// Clears the pending password prompt when the user abandons it. + /// + /// `PayloadState` is `Equatable`, so leaving the state at `.needsPasswordEntry` would make a + /// second import of the same document compare equal to the first and produce no change for the + /// UI to react to — the prompt could never be re-presented. Resetting on cancel keeps that + /// transition observable. + /// + /// Guarded so a dismissal that follows a successful decode cannot clobber the `.ready` payload. + public func cancelPasswordEntry() { + guard case .needsPasswordEntry = payloadState else { return } + payloadState = .none + } + public func importPayload(payload: VaultApplicationPayload) async { do { switch importContext { diff --git a/Vault/Sources/VaultiOS/Views/Backup/AutoBackupSettingsView.swift b/Vault/Sources/VaultiOS/Views/Backup/AutoBackupSettingsView.swift index 7f1bde3d0..f13db5abf 100644 --- a/Vault/Sources/VaultiOS/Views/Backup/AutoBackupSettingsView.swift +++ b/Vault/Sources/VaultiOS/Views/Backup/AutoBackupSettingsView.swift @@ -19,21 +19,27 @@ struct AutoBackupSettingsView: View { @State private var configuration: AutoBackupConfiguration = .init() var body: some View { - VStack(alignment: .leading, spacing: 16) { - headerView + Section { + enabledToggle if configuration.isEnabled { - enabledContentView - } else { - disabledContentView + destinationRow + + if selectedProviderIsConfigured { + retentionPicker + } + + if case let .error(error) = status { + errorRow(error) + } + + if selectedProviderIsConfigured { + backupNowButton + } } + } footer: { + Text(footerText) } - .padding(16) - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: borderColor, - padding: .init(), - ))) .sheet(isPresented: $isShowingFolderPicker) { FolderPickerView { url in configureSelectedProvider(with: url) @@ -57,59 +63,19 @@ struct AutoBackupSettingsView: View { } } - // MARK: - Header + // MARK: - Rows - private var headerView: some View { - HStack(spacing: 12) { - Image(systemName: statusIconName) - .font(.title2) - .foregroundStyle(statusColor) - .frame(width: 40, height: 40) - .background(statusColor.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - VStack(alignment: .leading, spacing: 4) { + private var enabledToggle: some View { + Toggle(isOn: Binding( + get: { configuration.isEnabled }, + set: { enabled in + Task { + await autoBackupService.setEnabled(enabled) + } + }, + )) { + FormRow(image: Image(systemName: statusIconName), color: statusColor) { Text("Auto-Backup") - .font(.headline.bold()) - .foregroundStyle(.primary) - - Text(statusDescription) - .font(.callout) - .foregroundStyle(.secondary) - } - - Spacer() - - Toggle("", isOn: Binding( - get: { configuration.isEnabled }, - set: { enabled in - Task { - await autoBackupService.setEnabled(enabled) - } - }, - )) - .labelsHidden() - } - } - - // MARK: - Enabled Content - - private var enabledContentView: some View { - VStack(alignment: .leading, spacing: 12) { - Divider() - - destinationRow - - if selectedProviderIsConfigured { - retentionPickerView - } - - if case let .error(error) = status { - errorView(error) - } - - if selectedProviderIsConfigured { - backupNowButton } } } @@ -124,117 +90,72 @@ struct AutoBackupSettingsView: View { isShowingFolderPicker = true } } label: { - HStack(spacing: 12) { - Image(systemName: "folder.fill") - .foregroundStyle(.green) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 2) { + LabeledContent { + Text(selectedProviderSummary ?? "Choose a folder") + } label: { + FormRow(image: Image(systemName: "folder.fill"), color: .green) { Text("Destination") - .font(.subheadline) - .foregroundStyle(.secondary) - - if let summary = selectedProviderSummary { - Text(summary) - .font(.body) - .foregroundStyle(.primary) - } else { - Text("Choose a folder") - .font(.body) - .foregroundStyle(Color.accentColor) - } } - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(Color(uiColor: .tertiaryLabel)) } } - .buttonStyle(.plain) } - private var retentionPickerView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Keep backups for") - .font(.subheadline) - .foregroundStyle(.secondary) - - Picker("Retention", selection: Binding( - get: { configuration.retentionDays }, - set: { retention in - Task { - await autoBackupService.setRetention(retention) - } - }, - )) { - ForEach(AutoBackupRetention.allCases, id: \.self) { retention in - Text(retention.localizedTitle).tag(retention) + private var retentionPicker: some View { + Picker(selection: Binding( + get: { configuration.retentionDays }, + set: { retention in + Task { + await autoBackupService.setRetention(retention) } + }, + )) { + ForEach(AutoBackupRetention.allCases, id: \.self) { retention in + Text(retention.localizedTitle).tag(retention) + } + } label: { + FormRow(image: Image(systemName: "clock.arrow.circlepath"), color: .blue) { + Text("Keep Backups For") } - .pickerStyle(.segmented) } } - private func errorView(_ error: AutoBackupError) -> some View { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - - VStack(alignment: .leading, spacing: 2) { - Text(error.errorDescription ?? "An error occurred") - .font(.callout) - .foregroundStyle(.primary) - - if let recovery = error.recoverySuggestion { - Text(recovery) - .font(.caption) - .foregroundStyle(.secondary) - } - } + private func errorRow(_ error: AutoBackupError) -> some View { + FormRow( + image: Image(systemName: "exclamationmark.triangle.fill"), + color: .orange, + alignment: .firstTextBaseline, + ) { + TextAndSubtitle( + title: error.errorDescription ?? "An error occurred", + subtitle: error.recoverySuggestion, + ) } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.orange.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) } private var backupNowButton: some View { - AsyncButton(progressAlignment: .center) { + AsyncButton { await autoBackupService.forceBackup() } label: { - Label("Backup Now", systemImage: "arrow.clockwise.icloud") - .frame(maxWidth: .infinity) + FormRow(image: Image(systemName: "arrow.clockwise.icloud"), color: .accentColor) { + Text("Backup Now") + } } loading: { - ProgressView() - .tint(.white) + FormRow(image: Image(systemName: "arrow.clockwise.icloud"), color: .accentColor) { + ProgressView() + } } - .modifier(ProminentButtonModifier()) .disabled(isBackingUp) } - // MARK: - Disabled Content - - private var disabledContentView: some View { - Text("Enable to automatically back up your vault to cloud storage whenever changes are made.") - .font(.callout) - .foregroundStyle(.secondary) - } - // MARK: - Helpers - private var borderColor: Color { - switch status { - case .disabled: - .gray - case .idle, .completed: - .green - case .backingUp, .cleaningUp: - .accentColor - case .error: - .orange + /// When auto-backup is off the footer explains what the feature does; once it is on, the footer + /// carries the live status so the state is visible without a separate status row. + private var footerText: String { + guard configuration.isEnabled else { + return "Enable to automatically back up your vault to cloud storage whenever changes are made." } + return statusDescription } private var statusColor: Color { diff --git a/Vault/Sources/VaultiOS/Views/Backup/BackupCreatePDFView.swift b/Vault/Sources/VaultiOS/Views/Backup/BackupCreatePDFView.swift index e585b77d8..03b0aa1b9 100644 --- a/Vault/Sources/VaultiOS/Views/Backup/BackupCreatePDFView.swift +++ b/Vault/Sources/VaultiOS/Views/Backup/BackupCreatePDFView.swift @@ -17,6 +17,7 @@ struct BackupCreatePDFView: View { var body: some View { Form { optionsSection + createSection } .navigationTitle(Text("Create PDF")) .navigationBarTitleDisplayMode(.inline) @@ -43,32 +44,31 @@ struct BackupCreatePDFView: View { .frame(minHeight: 150) .keyboardType(.default) .listRowInsets(EdgeInsets()) + } header: { + Text("Options") } footer: { - VStack(alignment: .center, spacing: 8) { - createPDFButton - if case let .error(presentationError) = viewModel.state { - Label( - presentationError.userDescription ?? presentationError.userTitle, - systemImage: "exclamationmark.triangle.fill", - ) - .foregroundStyle(.red) - .font(.caption) - } - } - .padding(16) - .frame(maxWidth: .infinity) + Text("An optional hint printed on the document to help you remember its password.") } } - private var createPDFButton: some View { - AsyncButton { - await viewModel.createPDF() - } label: { - Label("Make PDF", systemImage: "checkmark.circle.fill") - } loading: { - ProgressView() - .tint(.white) + private var createSection: some View { + Section { + AsyncButton { + await viewModel.createPDF() + } label: { + FormRow(image: Image(systemName: "checkmark.circle.fill"), color: .accentColor) { + Text("Make PDF") + } + } loading: { + FormRow(image: Image(systemName: "checkmark.circle.fill"), color: .accentColor) { + ProgressView() + } + } + } footer: { + if case let .error(presentationError) = viewModel.state { + Text(presentationError.userDescription ?? presentationError.userTitle) + .foregroundStyle(.red) + } } - .modifier(ProminentButtonModifier()) } } diff --git a/Vault/Sources/VaultiOS/Views/Backup/BackupCreateView.swift b/Vault/Sources/VaultiOS/Views/Backup/BackupCreateView.swift index 73749c595..a0217e8f1 100644 --- a/Vault/Sources/VaultiOS/Views/Backup/BackupCreateView.swift +++ b/Vault/Sources/VaultiOS/Views/Backup/BackupCreateView.swift @@ -19,23 +19,20 @@ struct BackupCreateView: View { } var body: some View { - ScrollView(.vertical) { - VStack(spacing: 16) { - switch dataModel.backupPassword { - case .error: - authenticateCard(isError: true) - case .notFetched: - authenticateCard(isError: false) - case .notCreated: - passwordNotCreatedCard - case let .fetched(password): - passwordExistsCard - AutoBackupSettingsView(autoBackupService: injector.autoBackupService) - pdfBackupCard(password: password) - deviceTransferCard(password: password) - } + Form { + switch dataModel.backupPassword { + case .error: + authenticateSection(isError: true) + case .notFetched: + authenticateSection(isError: false) + case .notCreated: + passwordNotCreatedSection + case let .fetched(password): + passwordExistsSection + AutoBackupSettingsView(autoBackupService: injector.autoBackupService) + pdfBackupSection(password: password) + deviceTransferSection(password: password) } - .padding(16) } .navigationTitle(Text(viewModel.strings.homeTitle)) .task { @@ -100,213 +97,112 @@ struct BackupCreateView: View { } } - // MARK: - Authenticate Card - - private func authenticateCard(isError: Bool) -> some View { - VStack(alignment: .leading, spacing: 16) { - PlaceholderView( - systemIcon: isError ? "key.slash.fill" : "lock.fill", - title: isError ? viewModel.strings.backupPasswordErrorTitle : viewModel.strings - .backupPasswordLoadingTitle, - subtitle: isError ? viewModel.strings - .backupPasswordErrorDetail : "Authenticate to access backup settings.", - ) + // MARK: - Authenticate Section - AsyncButton(progressAlignment: .center) { + private func authenticateSection(isError: Bool) -> some View { + Section { + AsyncButton { await dataModel.loadBackupPassword() } label: { - Label("Authenticate", systemImage: "key.horizontal.fill") - .frame(maxWidth: .infinity) + FormRow(image: Image(systemName: "key.horizontal.fill"), color: .accentColor) { + Text("Authenticate") + } } loading: { - ProgressView() - .tint(.white) + FormRow(image: Image(systemName: "key.horizontal.fill"), color: .accentColor) { + ProgressView() + } } - .modifier(ProminentButtonModifier()) + } header: { + Text( + isError + ? viewModel.strings.backupPasswordErrorTitle + : viewModel.strings.backupPasswordLoadingTitle, + ) + } footer: { + Text( + isError + ? viewModel.strings.backupPasswordErrorDetail + : "Authenticate to access backup settings.", + ) + .foregroundStyle(isError ? Color.red : Color.secondary) } - .padding(16) - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: isError ? .red : .accentColor, - padding: .init(), - ))) - .transition(.slide) } - // MARK: - Password Not Created Card - - private var passwordNotCreatedCard: some View { - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - Image(systemName: "key.horizontal.fill") - .font(.title2) - .foregroundStyle(Color.accentColor) - .frame(width: 40, height: 40) - .background(Color.accentColor.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - VStack(alignment: .leading, spacing: 4) { - Text("Backup Password Not Set") - .font(.title3.bold()) - .foregroundStyle(.primary) - - Text("Create a backup password to protect your vault backups.") - .font(.callout) - .foregroundStyle(.secondary) - } - } + // MARK: - Password Not Created Section + private var passwordNotCreatedSection: some View { + Section { Button { modal = .updatePassword } label: { - Label("Create Backup Password", systemImage: "key.horizontal.fill") - .frame(maxWidth: .infinity) + FormRow(image: Image(systemName: "key.horizontal.fill"), color: .accentColor) { + Text("Create Backup Password") + } } - .modifier(ProminentButtonModifier()) + } header: { + Text("Backup Password") + } footer: { + Text("Create a backup password to protect your vault backups.") } - .padding(16) - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: Color.accentColor, - padding: .init(), - ))) - .transition(.slide) } - // MARK: - Password Exists Card - - private var passwordExistsCard: some View { - VStack(spacing: 0) { - HStack(spacing: 12) { - Image(systemName: "checkmark.shield.fill") - .font(.title2) - .foregroundStyle(Color.green) - .frame(width: 40, height: 40) - .background(Color.green.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + // MARK: - Password Exists Section - VStack(alignment: .leading, spacing: 4) { - Text("Backup Password Active") - .font(.headline.bold()) - .foregroundStyle(.primary) - - Text("Your backups are protected with encryption.") - .font(.callout) - .foregroundStyle(.secondary) + private var passwordExistsSection: some View { + Section { + LabeledContent { + Text("Active") + } label: { + FormRow(image: Image(systemName: "checkmark.shield.fill"), color: .green) { + Text("Backup Password") } - - Spacer() } - .padding(16) - - Divider() - .padding(.horizontal, 16) Button { modal = .updatePassword } label: { - Label("Change Password", systemImage: "key.2.on.ring.fill") - .frame(maxWidth: .infinity) + FormRow(image: Image(systemName: "key.2.on.ring.fill"), color: .gray) { + Text("Change Password") + } } - .modifier(ProminentButtonModifier(color: .gray)) - .padding(16) + } footer: { + Text("Your backups are protected with encryption.") } - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: Color.green, - padding: .init(), - ))) - .transition(.slide) } - // MARK: - PDF Backup Card - - private func pdfBackupCard(password: DerivedEncryptionKey) -> some View { - VStack(spacing: 0) { - HStack(spacing: 12) { - Image(systemName: "doc.richtext") - .font(.title2) - .foregroundStyle(Color.accentColor) - .frame(width: 40, height: 40) - .background(Color.accentColor.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - VStack(alignment: .leading, spacing: 4) { - Text("PDF Backup") - .font(.headline.bold()) - .foregroundStyle(.primary) - - Text("Create an offline backup you can print or save.") - .font(.callout) - .foregroundStyle(.secondary) - } - - Spacer() - } - .padding(16) - - Divider() - .padding(.horizontal, 16) + // MARK: - PDF Backup Section + private func pdfBackupSection(password: DerivedEncryptionKey) -> some View { + Section { Button { modal = .pdfBackup(password) } label: { - Label("Create PDF Backup", systemImage: "printer.filled.and.paper") - .frame(maxWidth: .infinity) + FormRow(image: Image(systemName: "printer.filled.and.paper"), color: .accentColor) { + Text("Create PDF Backup") + } } - .modifier(ProminentButtonModifier()) - .padding(16) + } header: { + Text("PDF Backup") + } footer: { + Text("Create an offline backup you can print or save.") } - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: Color.accentColor, - padding: .init(), - ))) - .transition(.slide) } - // MARK: - Device Transfer Card - - private func deviceTransferCard(password: DerivedEncryptionKey) -> some View { - VStack(spacing: 0) { - HStack(spacing: 12) { - Image(systemName: "qrcode") - .font(.title2) - .foregroundStyle(Color.accentColor) - .frame(width: 40, height: 40) - .background(Color.accentColor.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - VStack(alignment: .leading, spacing: 4) { - Text("Transfer to Another Device") - .font(.headline.bold()) - .foregroundStyle(.primary) - - Text("Display QR codes to scan with another device.") - .font(.callout) - .foregroundStyle(.secondary) - } - - Spacer() - } - .padding(16) - - Divider() - .padding(.horizontal, 16) + // MARK: - Device Transfer Section + private func deviceTransferSection(password: DerivedEncryptionKey) -> some View { + Section { Button { modal = .deviceTransfer(password) } label: { - Label("Start Transfer", systemImage: "qrcode") - .frame(maxWidth: .infinity) + FormRow(image: Image(systemName: "qrcode"), color: .accentColor) { + Text("Start Transfer") + } } - .modifier(ProminentButtonModifier()) - .padding(16) + } header: { + Text("Transfer to Another Device") + } footer: { + Text("Display QR codes to scan with another device.") } - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: Color.accentColor, - padding: .init(), - ))) - .transition(.slide) } } diff --git a/Vault/Sources/VaultiOS/Views/Backup/BackupGeneratedPDFView.swift b/Vault/Sources/VaultiOS/Views/Backup/BackupGeneratedPDFView.swift index f450b3330..5ad2687c7 100644 --- a/Vault/Sources/VaultiOS/Views/Backup/BackupGeneratedPDFView.swift +++ b/Vault/Sources/VaultiOS/Views/Backup/BackupGeneratedPDFView.swift @@ -21,6 +21,7 @@ struct BackupGeneratedPDFView: View { var body: some View { Form { pdfPreviewSection + exportSection } .navigationTitle(Text("PDF")) .navigationBarTitleDisplayMode(.inline) @@ -90,39 +91,20 @@ struct BackupGeneratedPDFView: View { .listRowInsets(EdgeInsets()) } header: { Text("Generated Document Preview") - } footer: { - VStack(alignment: .center, spacing: 16) { - exportButton - - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.title3) - .foregroundStyle(Color.red) - - Text("Make sure you export and save the PDF, or your data will not be backed up.") - .font(.footnote) - .foregroundStyle(Color.red) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: .red, - padding: .init(), - ))) - } - .padding(16) - .frame(maxWidth: .infinity) } } - private var exportButton: some View { - ShareLink(item: pdf.diskURL, subject: .init("Vault Export")) { - Label("Export & Save", systemImage: "square.and.arrow.up.fill") + private var exportSection: some View { + Section { + ShareLink(item: pdf.diskURL, subject: .init("Vault Export")) { + FormRow(image: Image(systemName: "square.and.arrow.up.fill"), color: .accentColor) { + Text("Export & Save") + } + } + } footer: { + Text("Make sure you export and save the PDF, or your data will not be backed up.") + .foregroundStyle(.red) } - .modifier(ProminentButtonModifier()) } private func thumbnail(pageIndex: Int) -> Image? { diff --git a/Vault/Sources/VaultiOS/Views/Backup/BackupImportFlowView.swift b/Vault/Sources/VaultiOS/Views/Backup/BackupImportFlowView.swift index 91700e5d1..6aa7f1c4d 100644 --- a/Vault/Sources/VaultiOS/Views/Backup/BackupImportFlowView.swift +++ b/Vault/Sources/VaultiOS/Views/Backup/BackupImportFlowView.swift @@ -31,7 +31,7 @@ struct BackupImportFlowView: View { rootContent } .interactiveDismissDisabled(!viewModel.importState.isFinished) - .sheet(item: $modal, onDismiss: nil) { item in + .sheet(item: $modal, onDismiss: { viewModel.cancelPasswordEntry() }, content: { item in switch item { case let .generateDecryptionKey(encryptedVault): NavigationStack { @@ -55,7 +55,7 @@ struct BackupImportFlowView: View { .navigationBarTitleDisplayMode(.inline) } } - } + }) .onReceive(decryptedVaultSubject) { @MainActor vaultApplicationPayload in viewModel.handleVaultDecoded(payload: vaultApplicationPayload) } @@ -63,27 +63,28 @@ struct BackupImportFlowView: View { switch newValue { case let .ready(payload, _): navPath.append(payload) - case .none, .error, .needsPasswordEntry: + case let .needsPasswordEntry(encryptedVault): + // Go straight to password entry. There is nothing to decide at this point — the + // document is encrypted and the only way forward is the password — so an + // intermediate screen would just add a tap. + modal = .generateDecryptionKey(encryptedVault) + case .none, .error: break } } } private var rootContent: some View { - ScrollView(.vertical) { - VStack(spacing: 16) { - switch viewModel.payloadState { - case .none, .ready: - EmptyView() - case let .needsPasswordEntry(vault): - passwordNeededCard(vault: vault) - case let .error(presentationError): - errorCard(error: presentationError) - } - - filePickerCards + Form { + switch viewModel.payloadState { + case .none, .ready, .needsPasswordEntry: + EmptyView() + case let .error(presentationError): + errorSection(error: presentationError) } - .padding(16) + + automaticImportSection + qrCodeImportSection } .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -104,185 +105,77 @@ struct BackupImportFlowView: View { } } - // MARK: - Password Needed Card + // MARK: - Error Section - private func passwordNeededCard(vault: EncryptedVault) -> some View { - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - Image(systemName: "lock.badge.clock.fill") - .font(.title2) - .foregroundStyle(Color.accentColor) - .frame(width: 40, height: 40) - .background(Color.accentColor.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - VStack(alignment: .leading, spacing: 4) { - Text("Decryption Password Needed") - .font(.headline.bold()) - .foregroundStyle(.primary) - - Text("You need to enter the password that was used to encrypt this export.") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - Button { - modal = .generateDecryptionKey(vault) - } label: { - Label("Enter Password", systemImage: "square.and.pencil") - .frame(maxWidth: .infinity) - } - .modifier(ProminentButtonModifier()) + private func errorSection(error: PresentationError) -> some View { + Section { + PlaceholderView( + systemIcon: "exclamationmark.triangle.fill", + title: error.userTitle, + subtitle: error.userDescription, + ) + .padding() + .containerRelativeFrame(.horizontal) + .foregroundStyle(.red) } - .padding(16) - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: Color.accentColor, - padding: .init(), - ))) } - // MARK: - Error Card + // MARK: - Import Source Sections - private func errorCard(error: PresentationError) -> some View { - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.title2) - .foregroundStyle(Color.red) - .frame(width: 40, height: 40) - .background(Color.red.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - VStack(alignment: .leading, spacing: 4) { - Text(error.userTitle) - .font(.headline.bold()) - .foregroundStyle(.primary) - - if let description = error.userDescription { - Text(description) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } + private var automaticImportSection: some View { + Section { + Button { + isImporting = true + } label: { + FormRow(image: Image(systemName: "arrow.down.document.fill"), color: .accentColor) { + Text("Select PDF File") } } + } header: { + Text("Automatic Import") + } footer: { + Text("Select your Vault Export PDF from your files.") + } + .fileImporter(isPresented: $isImporting, allowedContentTypes: [.pdf]) { result in + importTask = Task { + await viewModel.handleImport(fromPDF: result.tryMap { url in + _ = url.startAccessingSecurityScopedResource() + defer { url.stopAccessingSecurityScopedResource() } + return try Data(contentsOf: url) + }) + } } - .padding(16) - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: Color.red, - padding: .init(), - ))) } - // MARK: - File Picker Cards - - private var filePickerCards: some View { - VStack(spacing: 16) { - // Automatic Import Card - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - Image(systemName: "arrow.down.document.fill") - .font(.title2) - .foregroundStyle(Color.accentColor) - .frame(width: 40, height: 40) - .background(Color.accentColor.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - Text("Automatic Import") - .font(.headline.bold()) - .foregroundStyle(.primary) - - Spacer() - } - - Text("Select your Vault Export PDF from your files") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Button { - isImporting = true - } label: { - Label("Select PDF File", systemImage: "arrow.down.document.fill") - .frame(maxWidth: .infinity) - } - .modifier(ProminentButtonModifier()) - } - .padding(16) - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: Color.accentColor, - padding: .init(), - ))) - .fileImporter(isPresented: $isImporting, allowedContentTypes: [.pdf]) { result in - importTask = Task { - await viewModel.handleImport(fromPDF: result.tryMap { url in - _ = url.startAccessingSecurityScopedResource() - defer { url.stopAccessingSecurityScopedResource() } - return try Data(contentsOf: url) - }) - } - } - - // Manual Import Card - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - Image(systemName: "qrcode.viewfinder") - .font(.title2) - .foregroundStyle(Color.accentColor) - .frame(width: 40, height: 40) - .background(Color.accentColor.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - Text("QR Code Import") - .font(.headline.bold()) - .foregroundStyle(.primary) - - Spacer() - } - - Text("Use your camera to scan the QR codes from a PDF backup or another device") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Button { - modal = .cameraScanning - } label: { - Label("Start Scanning", systemImage: "qrcode.viewfinder") - .frame(maxWidth: .infinity) + private var qrCodeImportSection: some View { + Section { + Button { + modal = .cameraScanning + } label: { + FormRow(image: Image(systemName: "qrcode.viewfinder"), color: .accentColor) { + Text("Start Scanning") } - .modifier(ProminentButtonModifier()) } - .padding(16) - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: Color.accentColor, - padding: .init(), - ))) + } header: { + Text("QR Code Import") + } footer: { + Text("Use your camera to scan the QR codes from a PDF backup or another device.") } } // MARK: - Ready to Import View private func readyToImportView(vaultApplicationPayload: VaultApplicationPayload) -> some View { - ScrollView(.vertical) { - VStack(spacing: 16) { - switch viewModel.importState { - case .notStarted: - readyToImportCard(payload: vaultApplicationPayload) - case let .error(error): - importErrorCard(error: error) - importButton(vault: vaultApplicationPayload) - case .success: - successCard - } + Form { + switch viewModel.importState { + case .notStarted: + readyToImportSection(payload: vaultApplicationPayload) + case let .error(error): + errorSection(error: error) + importSection(vault: vaultApplicationPayload) + case .success: + successSection } - .padding(16) } .animation(.easeOut, value: viewModel.importState) .animation(.easeOut, value: viewModel.payloadState) @@ -299,120 +192,51 @@ struct BackupImportFlowView: View { } } - // MARK: - Ready to Import Card - - private func readyToImportCard(payload: VaultApplicationPayload) -> some View { - VStack(spacing: 16) { - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - Image(systemName: "square.and.arrow.down.fill") - .font(.title2) - .foregroundStyle(Color.accentColor) - .frame(width: 40, height: 40) - .background(Color.accentColor.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + // MARK: - Ready to Import Section - VStack(alignment: .leading, spacing: 4) { - Text(viewModel.importContext.readyToImportTitle) - .font(.headline.bold()) - .foregroundStyle(.primary) - - Text(viewModel.importContext.readyToImportDescription) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .padding(16) - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: Color.accentColor, - padding: .init(), - ))) - - importButton(vault: payload) + private func readyToImportSection(payload: VaultApplicationPayload) -> some View { + Section { + importRow(vault: payload) + } header: { + Text(viewModel.importContext.readyToImportTitle) + } footer: { + Text(viewModel.importContext.readyToImportDescription) } } - // MARK: - Import Error Card - - private func importErrorCard(error: PresentationError) -> some View { - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.title2) - .foregroundStyle(Color.red) - .frame(width: 40, height: 40) - .background(Color.red.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - VStack(alignment: .leading, spacing: 4) { - Text(error.userTitle) - .font(.headline.bold()) - .foregroundStyle(.primary) - - if let description = error.userDescription { - Text(description) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } + private func importSection(vault: VaultApplicationPayload) -> some View { + Section { + importRow(vault: vault) } - .padding(16) - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: Color.red, - padding: .init(), - ))) } - // MARK: - Success Card - - private var successCard: some View { - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - Image(systemName: "checkmark.circle.fill") - .font(.title2) - .foregroundStyle(Color.green) - .frame(width: 40, height: 40) - .background(Color.green.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + // MARK: - Success Section - VStack(alignment: .leading, spacing: 4) { - Text("Imported") - .font(.headline.bold()) - .foregroundStyle(.primary) - - Text("Your vault has been updated with the items from this backup.") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } + private var successSection: some View { + Section { + PlaceholderView( + systemIcon: "checkmark.circle.fill", + title: "Imported", + subtitle: "Your vault has been updated with the items from this backup.", + ) + .padding() + .containerRelativeFrame(.horizontal) } - .padding(16) - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: Color.green, - padding: .init(), - ))) } - // MARK: - Import Button + // MARK: - Import Row - private func importButton(vault: VaultApplicationPayload) -> some View { + private func importRow(vault: VaultApplicationPayload) -> some View { AsyncButton { await viewModel.importPayload(payload: vault) } label: { - Label("Import Now", systemImage: "checkmark.circle.fill") - .frame(maxWidth: .infinity) + FormRow(image: Image(systemName: "checkmark.circle.fill"), color: .accentColor) { + Text("Import Now") + } } loading: { - ProgressView() - .tint(.white) + FormRow(image: Image(systemName: "checkmark.circle.fill"), color: .accentColor) { + ProgressView() + } } - .modifier(ProminentButtonModifier()) } } diff --git a/Vault/Sources/VaultiOS/Views/Backup/BackupKeyChangeView.swift b/Vault/Sources/VaultiOS/Views/Backup/BackupKeyChangeView.swift index e97c8ddec..968ccfa83 100644 --- a/Vault/Sources/VaultiOS/Views/Backup/BackupKeyChangeView.swift +++ b/Vault/Sources/VaultiOS/Views/Backup/BackupKeyChangeView.swift @@ -14,20 +14,17 @@ struct BackupKeyChangeView: View { } var body: some View { - ScrollView(.vertical) { - VStack(spacing: 16) { - switch viewModel.permissionState { - case .undetermined: - authenticateCard(isError: false) - case .allowed: - passwordCard - warningCard - detailsCard - case .denied: - authenticateCard(isError: true) - } + Form { + switch viewModel.permissionState { + case .undetermined: + authenticateSection(isError: false) + case .allowed: + passwordSection + generateSection + detailsSection + case .denied: + authenticateSection(isError: true) } - .padding(16) } .navigationTitle(Text("Backup Password")) .navigationBarTitleDisplayMode(.inline) @@ -65,262 +62,169 @@ struct BackupKeyChangeView: View { } } - // MARK: - Authenticate Card - - private func authenticateCard(isError: Bool) -> some View { - VStack(alignment: .leading, spacing: 16) { - PlaceholderView( - systemIcon: isError ? "key.slash.fill" : "lock.fill", - title: isError ? "Authentication Failed" : "Locked", - subtitle: isError ? "Unable to verify your identity. Please try again." - : "Authenticate to change the backup password.", - ) + // MARK: - Authenticate Section - AsyncButton(progressAlignment: .center) { + private func authenticateSection(isError: Bool) -> some View { + Section { + AsyncButton { await viewModel.onAppear() } label: { - Label("Authenticate", systemImage: "key.horizontal.fill") - .frame(maxWidth: .infinity) + FormRow(image: Image(systemName: "key.horizontal.fill"), color: .accentColor) { + Text("Authenticate") + } } loading: { - ProgressView() - .tint(.white) + FormRow(image: Image(systemName: "key.horizontal.fill"), color: .accentColor) { + ProgressView() + } } - .modifier(ProminentButtonModifier()) + } header: { + Text(isError ? "Authentication Failed" : "Locked") + } footer: { + Text( + isError + ? "Unable to verify your identity. Please try again." + : "Authenticate to change the backup password.", + ) + .foregroundStyle(isError ? Color.red : Color.secondary) } - .padding(16) - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: isError ? .red : .accentColor, - padding: .init(), - ))) } - // MARK: - Warning Card + // MARK: - Password Section - private var warningCard: some View { - VStack(alignment: .leading, spacing: 16) { - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.title2) - .foregroundStyle(.orange) - .frame(width: 40, height: 40) - .background(Color.orange.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + private var passwordSection: some View { + Section { + SecureField("New Password", text: $viewModel.newlyEnteredPassword) + .disabled(viewModel.newPassword.isLoading) - VStack(alignment: .leading, spacing: 4) { - Text("Historical Backups") - .font(.headline.bold()) - .foregroundStyle(.primary) + if viewModel.newlyEnteredPassword.isNotEmpty { + HStack { + SecureField("Confirm Password", text: $viewModel.newlyEnteredPasswordConfirm) - Text( - "Changing your password will not update existing backups. To restore from a previous backup, you must use the password that was active when that backup was created.", + Image( + systemName: viewModel + .passwordConfirmMatches ? "checkmark.circle.fill" : "xmark.circle.fill", ) - .font(.callout) - .foregroundStyle(.secondary) + .foregroundStyle(viewModel.passwordConfirmMatches ? .green : .red) } + .disabled(viewModel.newPassword.isLoading) } + } header: { + Text("New Password") + } footer: { + Text("Enter a new password to generate an encryption key.") } - .padding(16) - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: .orange, - padding: .init(), - ))) + .animation(.easeOut, value: viewModel.newlyEnteredPassword) } - // MARK: - Password Card - - private var passwordCard: some View { - VStack(spacing: 0) { - HStack(spacing: 12) { - Image(systemName: "key.horizontal.fill") - .font(.title2) - .foregroundStyle(Color.accentColor) - .frame(width: 40, height: 40) - .background(Color.accentColor.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - VStack(alignment: .leading, spacing: 4) { - Text("New Password") - .font(.headline.bold()) - .foregroundStyle(.primary) + // MARK: - Generate Section - Text("Enter a new password to generate an encryption key.") - .font(.callout) - .foregroundStyle(.secondary) + private var generateSection: some View { + Section { + Button { + keyGenerationTask?.cancel() + keyGenerationTask = Task { + await viewModel.saveEnteredPassword() } - - Spacer() - } - .padding(16) - - Divider() - .padding(.horizontal, 16) - - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 12) { - Image(systemName: "lock.fill") - .frame(width: 28, height: 28) - .foregroundStyle(.primary) - - SecureField("New Password", text: $viewModel.newlyEnteredPassword) - } - .disabled(viewModel.newPassword.isLoading) - - if viewModel.newlyEnteredPassword.isNotEmpty { - HStack(spacing: 12) { - Image( - systemName: viewModel - .passwordConfirmMatches ? "checkmark.circle.fill" : "xmark.circle.fill", - ) - .frame(width: 28, height: 28) - .foregroundStyle(viewModel.passwordConfirmMatches ? .green : .red) - - SecureField("Confirm Password", text: $viewModel.newlyEnteredPasswordConfirm) - } - .disabled(viewModel.newPassword.isLoading) + } label: { + FormRow(image: Image(systemName: "key.2.on.ring.fill"), color: .accentColor) { + Text("Generate Key") } } - .padding(16) - - Divider() - .padding(.horizontal, 16) - - VStack(spacing: 8) { - Button { - keyGenerationTask?.cancel() - keyGenerationTask = Task { - await viewModel.saveEnteredPassword() - } - } label: { - Label("Generate Key", systemImage: "key.2.on.ring.fill") - .frame(maxWidth: .infinity) - } - .modifier(ProminentButtonModifier()) - .animation(.none, value: viewModel.newPassword) - .disabled(!viewModel.canGenerateNewPassword) - .opacity(viewModel.canGenerateNewPassword ? 1 : 0.5) + .animation(.none, value: viewModel.newPassword) + .disabled(!viewModel.canGenerateNewPassword) + } footer: { + generationStatus + } + } - Group { - switch viewModel.newPassword { - case .success: - Label("Vault encryption key updated successfully", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - case .keygenError, .keygenCancelled: - Label("Error generating encryption key", systemImage: "xmark.octagon.fill") - .foregroundStyle(.red) - case .creating: - HStack(alignment: .center, spacing: 4) { - ProgressView() - Text("Generating encryption key") - } - .foregroundStyle(.secondary) - case .passwordConfirmError: - Label("Passwords do not match", systemImage: "xmark") - .foregroundStyle(.red) - case .initial: - EmptyView() - } - } - .font(.caption) + @ViewBuilder + private var generationStatus: some View { + switch viewModel.newPassword { + case .success: + Label("Vault encryption key updated successfully", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + case .keygenError, .keygenCancelled: + Label("Error generating encryption key", systemImage: "xmark.octagon.fill") + .foregroundStyle(.red) + case .creating: + HStack(alignment: .center, spacing: 4) { + ProgressView() + Text("Generating encryption key") } - .padding(16) + case .passwordConfirmError: + Label("Passwords do not match", systemImage: "xmark") + .foregroundStyle(.red) + case .initial: + EmptyView() } - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: Color.accentColor, - padding: .init(), - ))) - .animation(.easeOut, value: viewModel.newlyEnteredPassword) } - // MARK: - Details Card + // MARK: - Details Section - private var detailsCard: some View { - VStack(spacing: 0) { - HStack(spacing: 12) { - Image(systemName: "info.circle.fill") - .font(.title2) - .foregroundStyle(.gray) - .frame(width: 40, height: 40) - .background(Color.gray.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - VStack(alignment: .leading, spacing: 4) { - Text("Details") - .font(.headline.bold()) - .foregroundStyle(.primary) + private var detailsSection: some View { + Section { + DisclosureGroup { + Text( + "Changing your password will not update existing backups. To restore from a previous backup, you must use the password that was active when that backup was created.", + ) + .font(.callout) + .foregroundStyle(.secondary) + } label: { + Label("Historical Backups", systemImage: "clock.arrow.circlepath") + } - Text("Encryption algorithm and key generation info.") - .font(.callout) - .foregroundStyle(.secondary) + DisclosureGroup { + VStack(alignment: .leading, spacing: 8) { + Text("Your password is used to generate an encryption key that is used to secure your vault.") + Text( + "For security, this key generation process may take up to 3 minutes, even on a very fast device.", + ) + Text( + "Your encryption key is not shared between devices.", + ) } - - Spacer() + .font(.callout) + .foregroundStyle(.secondary) + } label: { + Label("About", systemImage: "questionmark.circle.fill") } - .padding(16) - Divider() - .padding(.horizontal, 16) - - VStack(alignment: .leading, spacing: 16) { - DisclosureGroup { - VStack(alignment: .leading, spacing: 8) { - Text("Your password is used to generate an encryption key that is used to secure your vault.") - Text( - "For security, this key generation process may take up to 3 minutes, even on a very fast device.", - ) - Text( - "Your encryption key is not shared between devices.", - ) - } - .font(.callout) - .foregroundStyle(.secondary) + DisclosureGroup { + LabeledContent { + Text(viewModel.encryptionKeyDeriverSignature.userVisibleDescription) } label: { - Label("About", systemImage: "questionmark.circle.fill") + Text("Algorithm") } - DisclosureGroup { - VStack(alignment: .leading, spacing: 8) { - LabeledContent { - Text(viewModel.encryptionKeyDeriverSignature.userVisibleDescription) - } label: { - Text("Algorithm") - } - - LabeledContent { - Text(viewModel.encryptionKeyDeriverSignature.id) - .font(.caption2) - .fontDesign(.monospaced) - } label: { - Text("ID") - } - } + LabeledContent { + Text(viewModel.encryptionKeyDeriverSignature.id) + .font(.caption2) + .fontDesign(.monospaced) } label: { - Label("Keygen Information", systemImage: "key.horizontal.fill") + Text("ID") } + } label: { + Label("Keygen Information", systemImage: "key.horizontal.fill") + } - #if DEBUG - DisclosureGroup { - AsyncButton { - await viewModel.loadExistingPassword() - } label: { - Text("Fetch existing password") - } loading: { - ProgressView() - } + #if DEBUG + DisclosureGroup { + AsyncButton { + await viewModel.loadExistingPassword() } label: { - Text("DEBUG: Keygen Information") + Text("Fetch existing password") + } loading: { + ProgressView() } - .foregroundStyle(.secondary) - #endif + } label: { + Text("DEBUG: Keygen Information") } - .padding(16) + .foregroundStyle(.secondary) + #endif + } header: { + Text("Details") + } footer: { + Text("Encryption algorithm and key generation info.") } - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: .gray, - padding: .init(), - ))) } } diff --git a/Vault/Sources/VaultiOS/Views/Backup/BackupKeyDecryptorView.swift b/Vault/Sources/VaultiOS/Views/Backup/BackupKeyDecryptorView.swift index b51125630..28e2cb6c1 100644 --- a/Vault/Sources/VaultiOS/Views/Backup/BackupKeyDecryptorView.swift +++ b/Vault/Sources/VaultiOS/Views/Backup/BackupKeyDecryptorView.swift @@ -16,6 +16,7 @@ struct BackupKeyDecryptorView: View { Form { informationSection entrySection + decryptSection } .navigationTitle(Text("Decrypt Backup")) .interactiveDismissDisabled(viewModel.isDecrypting) @@ -56,21 +57,23 @@ struct BackupKeyDecryptorView: View { SecureField("Enter decryption password...", text: $viewModel.enteredPassword) } .disabled(viewModel.isDecrypting) - } footer: { - VStack(alignment: .center) { - AsyncButton { - await viewModel.attemptDecryption() - } label: { - Label("Decrypt", systemImage: "checkmark.circle.fill") - } loading: { + } + } + + private var decryptSection: some View { + Section { + AsyncButton { + await viewModel.attemptDecryption() + } label: { + FormRow(image: Image(systemName: "checkmark.circle.fill"), color: .accentColor) { + Text("Decrypt") + } + } loading: { + FormRow(image: Image(systemName: "checkmark.circle.fill"), color: .accentColor) { ProgressView() } - .modifier(ProminentButtonModifier()) - .transition(.opacity) - .disabled(!viewModel.canAttemptDecryption || viewModel.isDecrypting) } - .padding() - .modifier(HorizontallyCenter()) + .disabled(!viewModel.canAttemptDecryption || viewModel.isDecrypting) } .animation(.easeOut, value: viewModel.canAttemptDecryption) } diff --git a/Vault/Sources/VaultiOS/Views/Backup/BackupRestoreView.swift b/Vault/Sources/VaultiOS/Views/Backup/BackupRestoreView.swift index a9c32bf0a..461f0f43d 100644 --- a/Vault/Sources/VaultiOS/Views/Backup/BackupRestoreView.swift +++ b/Vault/Sources/VaultiOS/Views/Backup/BackupRestoreView.swift @@ -17,15 +17,13 @@ struct BackupRestoreView: View { } var body: some View { - ScrollView(.vertical) { - VStack(spacing: 16) { - if dataModel.hasAnyItems { - hasExistingCodesImportCards - } else { - noExistingCodesImportCard - } + Form { + if dataModel.hasAnyItems { + mergeImportSection + overrideImportSection + } else { + emptyVaultImportSection } - .padding(16) } .navigationTitle(Text(viewModel.strings.homeTitle)) .task { @@ -62,124 +60,78 @@ struct BackupRestoreView: View { // MARK: - BackupRestoreView Extensions extension BackupRestoreView { - private var noExistingCodesImportCard: some View { - ImportOptionCard( - icon: "square.and.arrow.down.fill", - iconColor: .accentColor, - title: "Import Backup", - subtitle: "Import data from a Vault backup using a PDF file or by scanning QR codes from another device.", - buttonLabel: "Import Backup", - buttonIcon: "square.and.arrow.down.fill", - isDestructive: false, - showRecommended: false, - ) { - await dataModel.loadBackupPassword() - modal = .importToCurrentlyEmpty(dataModel.backupPassword.fetchedPassword) + private var emptyVaultImportSection: some View { + Section { + importButton( + title: "Import Backup", + icon: "square.and.arrow.down.fill", + color: .accentColor, + ) { + modal = .importToCurrentlyEmpty(dataModel.backupPassword.fetchedPassword) + } + } footer: { + Text("Import data from a Vault backup using a PDF file or by scanning QR codes from another device.") } } - private var hasExistingCodesImportCards: some View { - VStack(spacing: 16) { - // Merge option (recommended) - ImportOptionCard( - icon: "square.and.arrow.down.on.square.fill", - iconColor: .accentColor, + private var mergeImportSection: some View { + Section { + importButton( title: "Import & Merge", - subtitle: "Import from a PDF file or scan QR codes from another device. Merges with existing data, keeping the most recent version of each item.", - buttonLabel: "Import & Merge", - buttonIcon: "square.and.arrow.down.on.square.fill", - isDestructive: false, - showRecommended: true, + icon: "square.and.arrow.down.on.square.fill", + color: .accentColor, ) { - await dataModel.loadBackupPassword() modal = .importAndMerge(dataModel.backupPassword.fetchedPassword) } + } header: { + Text("Recommended") + } footer: { + Text( + "Import from a PDF file or scan QR codes from another device. Merges with existing data, keeping the most recent version of each item.", + ) + } + } - // Override option (destructive) - ImportOptionCard( - icon: "exclamationmark.triangle.fill", - iconColor: .red, + private var overrideImportSection: some View { + Section { + importButton( title: "Import & Override", - subtitle: "⚠️ Warning! Import from a PDF file or scan QR codes and replace all existing data. On-device data will be lost if not in the backup.", - buttonLabel: "Import & Override", - buttonIcon: "square.and.arrow.down.fill", + icon: "exclamationmark.triangle.fill", + color: .red, isDestructive: true, - showRecommended: false, ) { - await dataModel.loadBackupPassword() modal = .importAndOverride(dataModel.backupPassword.fetchedPassword) } + } footer: { + Text( + "Import from a PDF file or scan QR codes and replace all existing data. On-device data will be lost if it is not in the backup.", + ) } } -} - -// MARK: - Import Option Card Component - -private struct ImportOptionCard: View { - let icon: String - let iconColor: Color - let title: String - let subtitle: String - let buttonLabel: String - let buttonIcon: String - let isDestructive: Bool - let showRecommended: Bool - let action: () async -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - // Header with icon and title - HStack(spacing: 12) { - Image(systemName: icon) - .font(.title2) - .foregroundStyle(iconColor) - .frame(width: 40, height: 40) - .background(iconColor.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + /// A single import action row. + /// + /// Every import path needs the backup password loaded before the sheet can be presented, so that + /// is done here rather than repeated at each call site. + private func importButton( + title: String, + icon: String, + color: Color, + isDestructive: Bool = false, + presentModal: @escaping () -> Void, + ) -> some View { + AsyncButton { + await dataModel.loadBackupPassword() + presentModal() + } label: { + FormRow(image: Image(systemName: icon), color: color) { Text(title) - .font(.headline.bold()) - .foregroundStyle(.primary) - - Spacer() - - if showRecommended { - Text("Recommended") - .textCase(.uppercase) - .font(.caption2.weight(.bold)) - .foregroundStyle(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.accentColor) - .clipShape(Capsule()) - } + .foregroundStyle(isDestructive ? Color.red : Color.accentColor) } - - // Description - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - // Action button - AsyncButton { - await action() - } label: { - Label(buttonLabel, systemImage: buttonIcon) - .frame(maxWidth: .infinity) - } loading: { + } loading: { + FormRow(image: Image(systemName: icon), color: color) { ProgressView() - .tint(.white) } - .modifier(ProminentButtonModifier( - color: isDestructive ? .red : .accentColor, - )) } - .padding(16) - .modifier(VaultCardModifier(configuration: .init( - style: .secondary, - border: isDestructive ? .red : .accentColor, - padding: .init(), - ))) } } diff --git a/Vault/Sources/VaultiOS/Views/Backup/DeviceTransferExportView.swift b/Vault/Sources/VaultiOS/Views/Backup/DeviceTransferExportView.swift index 91560672b..682b559fe 100644 --- a/Vault/Sources/VaultiOS/Views/Backup/DeviceTransferExportView.swift +++ b/Vault/Sources/VaultiOS/Views/Backup/DeviceTransferExportView.swift @@ -123,10 +123,10 @@ struct DeviceTransferExportView: View { await viewModel.generateShards() } } label: { - Label("Try Again", systemImage: "arrow.clockwise") - .frame(maxWidth: .infinity) + FormRow(image: Image(systemName: "arrow.clockwise"), color: .accentColor) { + Text("Try Again") + } } - .modifier(ProminentButtonModifier()) } } diff --git a/Vault/Tests/VaultFeedTests/Presentation/BackupImportFlowViewModelTests.swift b/Vault/Tests/VaultFeedTests/Presentation/BackupImportFlowViewModelTests.swift index 7017390f3..f3a770967 100644 --- a/Vault/Tests/VaultFeedTests/Presentation/BackupImportFlowViewModelTests.swift +++ b/Vault/Tests/VaultFeedTests/Presentation/BackupImportFlowViewModelTests.swift @@ -93,6 +93,47 @@ struct BackupImportFlowViewModelTests { #expect(sut.importState == .notStarted) } + @Test + func cancelPasswordEntry_clearsPendingPromptSoItCanBeShownAgain() async { + let encryptedVaultDecoder = EncryptedVaultDecoderMock() + encryptedVaultDecoder.decryptAndDecodeHandler = { _, _ in + anyVaultApplicationPayload() + } + let sut = makeSUT( + existingBackupPassword: nil, + encryptedVaultDecoder: encryptedVaultDecoder, + ) + let encryptedVault = anyEncryptedVault() + await sut.handleImport(fromEncryptedVault: encryptedVault) + #expect(sut.payloadState == .needsPasswordEntry(encryptedVault)) + + sut.cancelPasswordEntry() + + // Must not stay on `.needsPasswordEntry`. The state is `Equatable`, so importing the same + // document again would compare equal and the UI would see no change to react to. + #expect(sut.payloadState == .none) + + await sut.handleImport(fromEncryptedVault: encryptedVault) + + #expect(sut.payloadState == .needsPasswordEntry(encryptedVault)) + } + + @Test + func cancelPasswordEntry_doesNotDiscardAnAlreadyDecodedPayload() { + let sut = makeSUT() + let payload = anyVaultApplicationPayload() + sut.handleVaultDecoded(payload: payload) + + sut.cancelPasswordEntry() + + // A dismissal that follows a successful decode must leave the ready payload intact. + guard case let .ready(readyPayload, _) = sut.payloadState else { + Issue.record("Expected payload state to remain ready, got \(sut.payloadState)") + return + } + #expect(readyPayload == payload) + } + @Test func handleImportFromPDF_errorUpdatesPresentationError() async { let sut = makeSUT() diff --git a/Vault/Tests/VaultiOSTests/BackupKeyChangeViewSnapshotTests.swift b/Vault/Tests/VaultiOSTests/BackupKeyChangeViewSnapshotTests.swift index e4de9e1d9..b08ed8a5d 100644 --- a/Vault/Tests/VaultiOSTests/BackupKeyChangeViewSnapshotTests.swift +++ b/Vault/Tests/VaultiOSTests/BackupKeyChangeViewSnapshotTests.swift @@ -10,43 +10,47 @@ import VaultSettings final class BackupKeyChangeViewSnapshotTests { @Test func layout() { - let viewModel = BackupKeyChangeViewModel( - dataModel: anyVaultDataModel(), - authenticationService: DeviceAuthenticationService(policy: DeviceAuthenticationPolicyAlwaysAllow()), - deriverFactory: VaultKeyDeriverFactoryImpl(), - ) - let sut = BackupKeyChangeView(viewModel: viewModel) - - snapshotScenarios(view: sut) + snapshotScenarios { + BackupKeyChangeView(viewModel: makeViewModel()) + } } @Test func layoutAuthenticated() { - let viewModel = BackupKeyChangeViewModel( - dataModel: anyVaultDataModel(), - authenticationService: DeviceAuthenticationService(policy: DeviceAuthenticationPolicyAlwaysAllow()), - deriverFactory: VaultKeyDeriverFactoryImpl(), - ) - viewModel.permissionState = .allowed - let sut = BackupKeyChangeView(viewModel: viewModel) - - snapshotScenarios(view: sut) + snapshotScenarios { + let viewModel = makeViewModel() + viewModel.permissionState = .allowed + return BackupKeyChangeView(viewModel: viewModel) + } } } // MARK: - Helpers extension BackupKeyChangeViewSnapshotTests { + private func makeViewModel() -> BackupKeyChangeViewModel { + BackupKeyChangeViewModel( + dataModel: anyVaultDataModel(), + authenticationService: DeviceAuthenticationService(policy: DeviceAuthenticationPolicyAlwaysAllow()), + deriverFactory: VaultKeyDeriverFactoryImpl(), + ) + } + + /// Builds a fresh view for every scenario. + /// + /// The view resets `permissionState` to `.undetermined` in `onDisappear`, so sharing one view — + /// and therefore one view model — across the loop let the first snapshot tear down the state that + /// the remaining five depended on. Every scenario now gets its own instance. private func snapshotScenarios( - view: some View, deviceAuthenticationPolicy: some DeviceAuthenticationPolicy = DeviceAuthenticationPolicyAlwaysAllow(), testName: String = #function, + makeView: () -> some View, ) { let colorSchemes: [ColorScheme] = [.light, .dark] let dynamicTypeSizes: [DynamicTypeSize] = [.xSmall, .medium, .xxLarge] for colorScheme in colorSchemes { for dynamicTypeSize in dynamicTypeSizes { - let snapshottingView = view + let snapshottingView = makeView() .dynamicTypeSize(dynamicTypeSize) .preferredColorScheme(colorScheme) .framedForTest() diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.dark_medium.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.dark_medium.png index b7bfd77cf..2c07b8132 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.dark_medium.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.dark_medium.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.dark_xSmall.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.dark_xSmall.png index 77d5da51d..505c101a9 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.dark_xSmall.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.dark_xSmall.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.dark_xxLarge.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.dark_xxLarge.png index 5c71cbc62..15bc05fef 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.dark_xxLarge.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.dark_xxLarge.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.light_medium.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.light_medium.png index b7bfd77cf..2c07b8132 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.light_medium.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.light_medium.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.light_xSmall.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.light_xSmall.png index 77d5da51d..505c101a9 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.light_xSmall.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.light_xSmall.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.light_xxLarge.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.light_xxLarge.png index 5c71cbc62..15bc05fef 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.light_xxLarge.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layout.light_xxLarge.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_medium.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_medium.png index b7bfd77cf..3c93d6ee2 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_medium.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_medium.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xSmall.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xSmall.png index 77d5da51d..a87b3608d 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xSmall.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xSmall.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xxLarge.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xxLarge.png index 5c71cbc62..02b3a9df6 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xxLarge.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xxLarge.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_medium.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_medium.png index b7bfd77cf..3c93d6ee2 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_medium.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_medium.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xSmall.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xSmall.png index 44187f663..a87b3608d 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xSmall.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xSmall.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xxLarge.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xxLarge.png index 5c71cbc62..02b3a9df6 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xxLarge.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xxLarge.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordError.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordError.1.png index f60b6575e..f7453daf7 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordError.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordError.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordFetched.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordFetched.1.png index b7bc9e5f1..ce5f78989 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordFetched.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordFetched.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordNotCreated.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordNotCreated.1.png index af22b89b1..513e5c4a0 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordNotCreated.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordNotCreated.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordNotFetched.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordNotFetched.1.png index bd2daca65..b82a5dd69 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordNotFetched.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordNotFetched.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupRestore_hasItems.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupRestore_hasItems.1.png index aa0e7ff3b..b311f3c89 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupRestore_hasItems.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupRestore_hasItems.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupRestore_noItems.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupRestore_noItems.1.png index 3d720d495..03d8bb8e1 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupRestore_noItems.1.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupRestore_noItems.1.png differ