diff --git a/RIADigiDoc/RIADigiDocApp.swift b/RIADigiDoc/RIADigiDocApp.swift index 4b53e453..d144dc7f 100644 --- a/RIADigiDoc/RIADigiDocApp.swift +++ b/RIADigiDoc/RIADigiDocApp.swift @@ -77,6 +77,7 @@ struct RIADigiDocApp: App { await dataStore.setIsRecentDocumentsMigrationDone(true) } + await languageSettings.loadSelectedLanguage() isInitialLanguageSelected = await dataStore.getIsInitialLanguageSelected() await MainActor.run { self.isSetupComplete = true diff --git a/RIADigiDoc/Supporting files/Localizable.xcstrings b/RIADigiDoc/Supporting files/Localizable.xcstrings index 350c0bce..325b58bc 100644 --- a/RIADigiDoc/Supporting files/Localizable.xcstrings +++ b/RIADigiDoc/Supporting files/Localizable.xcstrings @@ -1843,7 +1843,7 @@ "et" : { "stringUnit" : { "state" : "translated", - "value" : "Adressaati eemaldamine ebaõnnestus" + "value" : "Adressaadi eemaldamine ebaõnnestus" } } } @@ -6220,6 +6220,24 @@ } } }, + "Recipient added" : { + "comment" : "Toast message when recipient added in encryption view", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recipient added" + } + }, + "et" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adressaat lisatud" + } + } + } + }, "Recipient already exists in the container" : { "comment" : "Error shown in Encrypt Recipients view when adding recipient fails", "extractionState" : "manual", diff --git a/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift b/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift index f02f4523..564e9df9 100644 --- a/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift +++ b/RIADigiDoc/UI/Component/Container/Crypto/Recipient/EncryptRecipientView.swift @@ -80,8 +80,12 @@ struct EncryptRecipientView: View { VStack(alignment: .leading, spacing: Dimensions.Padding.ZeroPadding) { if noSearchResults { Text(verbatim: languageSettings.localized("Added recipients")) + .accessibilityHeading(.h2) + .accessibilityAddTraits([.isHeader]) } else { Text(verbatim: languageSettings.localized("Recently added")) + .accessibilityHeading(.h2) + .accessibilityAddTraits([.isHeader]) } Spacer().frame(height: Dimensions.Padding.MSPadding) @@ -237,6 +241,49 @@ struct EncryptRecipientView: View { } } .accessibilitySortPriority(filteredRecipients.isEmpty ? 2 : 0) + + HStack { + Spacer() + + Button(action: { + encryptionButtonEnabled = false + pathManager.replaceLast(to: .encryptView(isWithEncryption: true)) + }, label: { + HStack(spacing: Dimensions.Padding.XSPadding) { + Image("ic_m3_encrypted_48pt_wght400") + .resizable() + .scaledToFit() + .frame( + width: Dimensions.Icon.IconSizeXXS, + height: Dimensions.Icon.IconSizeXXS + ) + .foregroundStyle(theme.onPrimaryContainer) + + Text(verbatim: encryptLabel) + .foregroundStyle(theme.onPrimaryContainer) + .font(typography.bodyLarge) + } + .accessibilityHidden(true) + }) + .contentShape(Rectangle()) + .disabled(!encryptionButtonEnabled) + .padding(Dimensions.Padding.MSPadding) + .background( + RoundedRectangle(cornerRadius: Dimensions.Corner.MSCornerRadius) + .fill(theme.primaryContainer) + .shadow( + color: theme.onSurfaceVariant.opacity(Dimensions.Shadow.SOpacity), + radius: Dimensions.Shadow.radius, + x: Dimensions.Shadow.xOffset, + y: Dimensions.Shadow.yOffset + ) + ) + .padding(Dimensions.Padding.MPadding) + .accessibilityElement(children: .ignore) + .accessibilityLabel(encryptLabel.lowercased()) + .accessibilityAddTraits(.isButton) + .accessibilityIdentifier("bottomEncryptButton") + } } .padding(.horizontal, Dimensions.Padding.SPadding) .accessibilityElement(children: .contain) @@ -265,52 +312,6 @@ struct EncryptRecipientView: View { ) } } - .overlay(alignment: .bottom) { - HStack(spacing: Dimensions.Padding.XSPadding) { - if encryptionButtonEnabled { - Button(action: { - if encryptionButtonEnabled { - encryptionButtonEnabled = false - pathManager.replaceLast(to: .encryptView(isWithEncryption: true)) - encryptionButtonEnabled = true - } - }, label: { - HStack(spacing: Dimensions.Padding.XSPadding) { - Image("ic_m3_encrypted_48pt_wght400") - .resizable() - .scaledToFit() - .frame( - width: Dimensions.Icon.IconSizeXXS, - height: Dimensions.Icon.IconSizeXXS - ) - .foregroundStyle(theme.onPrimaryContainer) - .accessibilityHidden(true) - - Text(verbatim: encryptLabel) - .foregroundStyle(theme.onPrimaryContainer) - .font(typography.bodyLarge) - .accessibilityHidden(true) - } - }) - .accessibilityLabel(encryptLabel.lowercased()) - .accessibilityAddTraits([.isButton]) - .accessibilityIdentifier("bottomEncryptButton") - } - } - .padding(Dimensions.Padding.MSPadding) - .background( - RoundedRectangle(cornerRadius: Dimensions.Corner.MSCornerRadius) - .fill(theme.primaryContainer) - .shadow( - color: theme.onSurfaceVariant.opacity(Dimensions.Shadow.SOpacity), - radius: Dimensions.Shadow.radius, - x: Dimensions.Shadow.xOffset, - y: Dimensions.Shadow.yOffset - ) - ) - .frame(maxWidth: .infinity, alignment: .trailing) - .padding(Dimensions.Padding.MPadding) - } .onAppear { Task { @MainActor in await viewModel.loadRecipients() @@ -321,15 +322,14 @@ struct EncryptRecipientView: View { showNoRecipientsFoundMessage = false } .onChange(of: viewModel.errorMessage) { _, error in - guard let errorMessage = error, !errorMessage.isEmpty else { return } + guard let error, !error.key.isEmpty else { return } isTitleFocused = false - let localizedMessage = languageSettings.localized(errorMessage) + let localizedMessage = languageSettings.localized(error.key, [error.args.joined(separator: ", ")]) Toast.show(localizedMessage) if voiceOverEnabled { - encryptionButtonEnabled = false AccessibilityUtil.announceMessage(localizedMessage) DispatchQueue.main.asyncAfter(deadline: .now() + 2) { isTitleFocused = true @@ -337,7 +337,22 @@ struct EncryptRecipientView: View { } } - viewModel.errorMessage = nil + encryptionButtonEnabled = true + + viewModel.resetErrorMessage() + } + .onChange(of: viewModel.successMessage) { _, message in + guard let message, !message.key.isEmpty else { return } + let localizedMessage = languageSettings.localized(message.key, [message.args.joined(separator: ", ")]) + Toast.show(localizedMessage, type: .success) + + if voiceOverEnabled { + AccessibilityUtil.announceMessage(localizedMessage) + } + + encryptionButtonEnabled = true + + viewModel.resetSuccessMessage() } } ) diff --git a/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift b/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift index 3d300cad..bd2cee94 100644 --- a/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift +++ b/RIADigiDoc/UI/Component/Shared/FloatingLabelTextField.swift @@ -174,6 +174,10 @@ struct FloatingLabelTextField: View { .joined(separator: " ") } + private var shouldShowToolbar: Bool { + fieldIsFocused && (showDashButton || keyboardType.needsDoneButton) + } + // MARK: - Body var body: some View { @@ -361,30 +365,38 @@ struct FloatingLabelTextField: View { @ToolbarContentBuilder private var keyboardToolbar: some ToolbarContent { ToolbarItem(placement: .keyboard) { - if fieldIsFocused { + if shouldShowToolbar { HStack { if showDashButton { - Button( - action: { text.append("-") }, - label: { Text(verbatim: "-") } - ) + dashButton } if keyboardType.needsDoneButton { - Button( - action: { - fieldIsFocused = false - isAccessibilityFocused = true - onDone() - }, - label: { Text(verbatim: languageSettings.localized("Done")) } - ) + doneButton } } } } } + private var dashButton: some View { + Button( + action: { text.append("-") }, + label: { Text(verbatim: "-") } + ) + } + + private var doneButton: some View { + Button( + action: { + fieldIsFocused = false + isAccessibilityFocused = true + onDone() + }, + label: { Text(verbatim: languageSettings.localized("Done")) } + ) + } + // MARK: - Icons @ViewBuilder diff --git a/RIADigiDoc/Util/Language/LanguageSettings.swift b/RIADigiDoc/Util/Language/LanguageSettings.swift index 4da88bf3..c7d2294e 100644 --- a/RIADigiDoc/Util/Language/LanguageSettings.swift +++ b/RIADigiDoc/Util/Language/LanguageSettings.swift @@ -25,6 +25,8 @@ public final class LanguageSettings: LanguageSettingsProtocol { private(set) var selectedLanguage: String = DefaultValues.language private let dataStore: DataStoreProtocol + private var localizedBundle = Bundle.main.path(forResource: "en", ofType: "lproj").flatMap(Bundle.init) + public let supportedLanguages: [SupportedLanguage] = [ SupportedLanguage(code: "et", titleKey: "Init lang locale et", accessibilityInputLabel: "Estonian"), SupportedLanguage(code: "en", titleKey: "Init lang locale en", accessibilityInputLabel: "English") @@ -34,30 +36,30 @@ public final class LanguageSettings: LanguageSettingsProtocol { dataStore: DataStoreProtocol ) { self.dataStore = dataStore - Task { - self.selectedLanguage = await dataStore.getSelectedLanguage() - } } // MARK: - Public Methods + public func loadSelectedLanguage() async { + self.selectedLanguage = await dataStore.getSelectedLanguage() + localizedBundle = Bundle.main.path(forResource: selectedLanguage, ofType: "lproj").flatMap(Bundle.init) + } + public func getSelectedLanguage() -> String { return selectedLanguage } public func setSelectedLanguage(newLanguageCode: String) async { selectedLanguage = newLanguageCode + localizedBundle = Bundle.main.path(forResource: newLanguageCode, ofType: "lproj").flatMap(Bundle.init) await dataStore.setSelectedLanguage(newLanguageCode: newLanguageCode) } public func localized(_ key: String, _ args: [CVarArg] = []) -> String { - guard let path = Bundle.main.path(forResource: selectedLanguage, ofType: "lproj"), - let bundle = Bundle(path: path) else { - return key - } - + let bundle = localizedBundle ?? + Bundle.main.path(forResource: selectedLanguage, ofType: "lproj").flatMap(Bundle.init) ?? Bundle.main let format = bundle.localizedString(forKey: key, value: nil, table: nil) - return String.localizedStringWithFormat(format, args) + return args.isEmpty ? format : String.localizedStringWithFormat(format, args) } // MARK: - Constants diff --git a/RIADigiDoc/Util/Language/LanguageSettingsProtocol.swift b/RIADigiDoc/Util/Language/LanguageSettingsProtocol.swift index 73328b45..d2262e8d 100644 --- a/RIADigiDoc/Util/Language/LanguageSettingsProtocol.swift +++ b/RIADigiDoc/Util/Language/LanguageSettingsProtocol.swift @@ -22,6 +22,7 @@ import Foundation /// @mockable @MainActor public protocol LanguageSettingsProtocol: Sendable { + func loadSelectedLanguage() async func getSelectedLanguage() -> String func setSelectedLanguage(newLanguageCode: String) async func localized(_ key: String, _ args: [CVarArg]) -> String diff --git a/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift b/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift index 17b6dc44..d28cfb6f 100644 --- a/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift +++ b/RIADigiDoc/ViewModel/EncryptRecipientViewModel.swift @@ -31,7 +31,8 @@ class EncryptRecipientViewModel: EncryptRecipientViewModelProtocol, Loggable { var isImporting = false var recipients: [Addressee] = [] var searchText: String = "" - var errorMessage: String? + private(set) var successMessage: ToastMessage? + private(set) var errorMessage: ToastMessage? private let sharedContainerViewModel: SharedContainerViewModelProtocol private let openLdap: OpenLdapProtocol @@ -64,12 +65,15 @@ class EncryptRecipientViewModel: EncryptRecipientViewModelProtocol, Loggable { let recipients = await cryptoContainer.getRecipients() for recipient in recipients where chosenRecipient.data == recipient.data { - errorMessage = "Recipient already exists in the container" + errorMessage = ToastMessage(key: "Recipient already exists in the container", args: []) EncryptRecipientViewModel.logger().error("Recipient already exists in the container") return } await cryptoContainer.addRecipients([chosenRecipient]) + + successMessage = ToastMessage(key: "Recipient added", args: []) + EncryptRecipientViewModel.logger().info("Recipient added") } func loadRecipients() async { @@ -77,11 +81,11 @@ class EncryptRecipientViewModel: EncryptRecipientViewModelProtocol, Loggable { let result = await openLdap.search(identityCode: searchText) if result.tooManyResults { recipients = [] - errorMessage = "Too many results" + errorMessage = ToastMessage(key: "Too many results", args: []) EncryptRecipientViewModel.logger().error("Too many results for \(self.searchText)") } else if result.addressees.isEmpty { recipients = [] - errorMessage = "No recipients found" + errorMessage = ToastMessage(key: "Person or company does not own a valid certificate", args: []) EncryptRecipientViewModel.logger().error("No recipients found for \(self.searchText)") } else { recipients = result.addressees @@ -120,8 +124,16 @@ class EncryptRecipientViewModel: EncryptRecipientViewModelProtocol, Loggable { try await cryptoContainer.removeRecipient(recipient) } catch { - errorMessage = "Failed to remove recipient" + errorMessage = ToastMessage(key: "Failed to remove recipient", args: []) EncryptRecipientViewModel.logger().error("Unable to delete recipient: \(error)") } } + + func resetErrorMessage() { + errorMessage = nil + } + + func resetSuccessMessage() { + successMessage = nil + } } diff --git a/RIADigiDoc/ViewModel/Protocols/EncryptRecipientViewModelProtocol.swift b/RIADigiDoc/ViewModel/Protocols/EncryptRecipientViewModelProtocol.swift index 9e1cb2c7..93271bb9 100644 --- a/RIADigiDoc/ViewModel/Protocols/EncryptRecipientViewModelProtocol.swift +++ b/RIADigiDoc/ViewModel/Protocols/EncryptRecipientViewModelProtocol.swift @@ -30,4 +30,6 @@ public protocol EncryptRecipientViewModelProtocol: Sendable { func loadRecipients() async func getContainerRecipientList() async -> [Addressee] func deleteRecipient(_ recipient: Addressee) async + func resetErrorMessage() + func resetSuccessMessage() }