From 2ed2a7d8a1559a18af5fd113d77630a56cd43062 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 4 Mar 2021 15:29:46 +0800 Subject: [PATCH] fix: make sign up error i18n display for each text filed. Fix memory leaking issue for pick server scene --- Localization/app.json | 63 +++--- Mastodon.xcodeproj/project.pbxproj | 20 +- .../Mastodon+Entidy+ErrorDetailReason.swift | 99 --------- .../Mastodon+Entity+Error+Detail.swift | 112 ++++++++++ .../MastodonSDK/Mastodon+Entity+Error.swift | 41 ++++ Mastodon/Extension/UIAlertController.swift | 41 ---- Mastodon/Generated/Strings.swift | 120 +++++----- .../Resources/en.lproj/Localizable.strings | 44 ++-- .../TableViewCell/PickServerCell.swift | 58 +++-- ...astodonRegisterViewController+Avatar.swift | 10 +- .../MastodonRegisterViewController.swift | 208 +++++++++++------- .../Register/MastodonRegisterViewModel.swift | 100 ++++++--- .../API/Error/Mastodon+API+Error.swift | 24 -- ...ift => Mastodon+Entity+Error+Detail.swift} | 98 +++++---- .../Entity/Mastodon+Entity+Error.swift | 4 +- 15 files changed, 588 insertions(+), 454 deletions(-) delete mode 100644 Mastodon/Extension/Mastodon+Entidy+ErrorDetailReason.swift create mode 100644 Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift create mode 100644 Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error.swift rename MastodonSDK/Sources/MastodonSDK/Entity/{Mastodon+Entity+ErrorDetail.swift => Mastodon+Entity+Error+Detail.swift} (57%) diff --git a/Localization/app.json b/Localization/app.json index 43cdf01ad..920a8abe7 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -1,31 +1,5 @@ { "common": { - "errors": { - "item": { - "username": "username", - "email": "email", - "password": "password", - "agreement": "agreement", - "locale": "locale", - "reason": "reason" - }, - "itemDetail": { - "email_invalid": "This is not a valid e-mail address", - "username_invalid": "Username must only contain alphanumeric characters and underscores", - "password_too_shrot": "password is too short (must be at least 8 characters)", - "username_too_long": "username is too long (can't be longer than 30 characters)" - }, - "ERR_BLOCKED": "contains a disallowed e-mail provider", - "ERR_UNREACHABLE": "does not seem to exist", - "ERR_TAKEN": "is already in use", - "ERR_RESERVED": "is a reserved keyword", - "ERR_ACCEPTED": "must be accepted", - "ERR_BLANK": "is required", - "ERR_INVALID": "is invalid", - "ERR_TOO_LONG": "is too long", - "ERR_TOO_SHORT": "is too short", - "ERR_INCLUSION": "is not a supported value" - }, "alerts": { "sign_up_failure": { "title": "Sign Up Failure" @@ -33,7 +7,6 @@ "server_error": { "title": "Server Error" } - }, "controls": { "actions": { @@ -108,14 +81,40 @@ }, "password": { "placeholder": "password", - "hint": "Your password needs at least Eight characters" + "hint": "Your password needs at least eight characters" }, "invite": { - "registration_user_invite_request": "Why do you want to join?" + "registration_user_invite_request": "Why do you want to join?" } }, - "success": "Success", - "check_email": "Regsiter request sent. Please check your email." + "error": { + "item": { + "username": "Username", + "email": "Email", + "password": "Password", + "agreement": "Agreement", + "locale": "Locale", + "reason": "Reason" + }, + "reason": { + "blocked": "%s contains a disallowed e-mail provider", + "unreachable": "%s does not seem to exist", + "taken": "%s is already in use", + "reserved": "%s is a reserved keyword", + "accepted": "%s must be accepted", + "blank": "%s is required", + "invalid": "%s is invalid", + "too_long": "%s is too long", + "too_short": "%s is too short", + "inclusion": "%s is not a supported value" + }, + "special": { + "username_invalid": "Username must only contain alphanumeric characters and underscores.", + "username_too_long": "Username is too long (can't be longer than 30 characters).", + "email_invalid": "This is not a valid e-mail address.", + "password_too_shrot": "Password is too short (must be at least 8 characters)." + } + } }, "server_rules": { "title": "Some ground rules.", @@ -151,4 +150,4 @@ "title": "Public" } } -} +} \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 2a7c70420..f66c14c40 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -55,7 +55,7 @@ 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; - 2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */; }; + 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; }; 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; }; 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; }; 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; @@ -125,6 +125,7 @@ DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; + DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; @@ -264,7 +265,7 @@ 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = ""; }; 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; - 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entidy+ErrorDetailReason.swift"; sourceTree = ""; }; + 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = ""; }; 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = ""; }; 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; @@ -343,6 +344,7 @@ DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; + DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -885,6 +887,15 @@ path = NavigationController; sourceTree = ""; }; + DB6C8C0525F0921200AAA452 /* MastodonSDK */ = { + isa = PBXGroup; + children = ( + DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */, + 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */, + ); + path = MastodonSDK; + sourceTree = ""; + }; DB72602125E36A2500235243 /* ServerRules */ = { isa = PBXGroup; children = ( @@ -1001,6 +1012,7 @@ isa = PBXGroup; children = ( DB084B5125CBC56300F898ED /* CoreDataStack */, + DB6C8C0525F0921200AAA452 /* MastodonSDK */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, @@ -1015,7 +1027,6 @@ DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 0FAA101B25E10E760017CCDE /* UIFont.swift */, - 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */, 2D939AB425EDD8A90076FA61 /* String.swift */, ); path = Extension; @@ -1505,6 +1516,7 @@ DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, + DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */, @@ -1517,7 +1529,7 @@ 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, - 2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift in Sources */, + 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, diff --git a/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReason.swift b/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReason.swift deleted file mode 100644 index cc1a47907..000000000 --- a/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReason.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// Mastodon+Entity+ErrorDetailReason.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/1. -// -import MastodonSDK - -extension Mastodon.Entity.ErrorDetailReason { - func localizedDescription() -> String { - switch self.error { - case .ERR_BLOCKED: - return L10n.Common.Errors.errBlocked - case .ERR_UNREACHABLE: - return L10n.Common.Errors.errUnreachable - case .ERR_TAKEN: - return L10n.Common.Errors.errTaken - case .ERR_RESERVED: - return L10n.Common.Errors.errReserved - case .ERR_ACCEPTED: - return L10n.Common.Errors.errAccepted - case .ERR_BLANK: - return L10n.Common.Errors.errBlank - case .ERR_INVALID: - return L10n.Common.Errors.errInvalid - case .ERR_TOO_LONG: - return L10n.Common.Errors.errTooLong - case .ERR_TOO_SHORT: - return L10n.Common.Errors.errTooShort - case .ERR_INCLUSION: - return L10n.Common.Errors.errInclusion - case ._other: - return self.errorDescription ?? "" - } - } -} - -extension Mastodon.Entity.ErrorDetail { - func localizedDescription() -> String { - var messages: [String?] = [] - - if let username = self.username, !username.isEmpty { - let errors = username.map { errorDetailReason -> String in - switch errorDetailReason.error { - case .ERR_INVALID: - return L10n.Common.Errors.Itemdetail.usernameInvalid - case .ERR_TOO_LONG: - return L10n.Common.Errors.Itemdetail.usernameTooLong - default: - return L10n.Common.Errors.Item.username + " " + errorDetailReason.localizedDescription() - } - } - messages.append(contentsOf: errors) - } - - if let email = self.email, !email.isEmpty { - let errors = email.map { errorDetailReason -> String in - if errorDetailReason.error == .ERR_INVALID { - return L10n.Common.Errors.Itemdetail.emailInvalid - } else { - return L10n.Common.Errors.Item.email + " " + errorDetailReason.localizedDescription() - } - } - messages.append(contentsOf: errors) - } - if let password = self.password,!password.isEmpty { - let errors = password.map { errorDetailReason -> String in - if errorDetailReason.error == .ERR_TOO_SHORT { - return L10n.Common.Errors.Itemdetail.passwordTooShrot - } else { - return L10n.Common.Errors.Item.password + " " + errorDetailReason.localizedDescription() - } - } - messages.append(contentsOf: errors) - } - if let agreement = self.agreement, !agreement.isEmpty { - let errors = agreement.map { - L10n.Common.Errors.Item.agreement + " " + $0.localizedDescription() - } - messages.append(contentsOf: errors) - } - if let locale = self.locale, !locale.isEmpty { - let errors = locale.map { - L10n.Common.Errors.Item.locale + " " + $0.localizedDescription() - } - messages.append(contentsOf: errors) - } - if let reason = self.reason, !reason.isEmpty { - let errors = reason.map { - L10n.Common.Errors.Item.reason + " " + $0.localizedDescription() - } - messages.append(contentsOf: errors) - } - let message = messages - .compactMap { $0 } - .joined(separator: ", ") - return message.capitalizingFirstLetter() - } -} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift new file mode 100644 index 000000000..1e993a8c3 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift @@ -0,0 +1,112 @@ +// +// Mastodon+Entity+ErrorDetailReason.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/1. +// + +import Foundation +import MastodonSDK + +extension Mastodon.Entity.Error.Detail: LocalizedError { + + public var failureReason: String? { + let reasons: [[String]] = [ + usernameErrorDescriptions, + emailErrorDescriptions, + passwordErrorDescriptions, + agreementErrorDescriptions, + localeErrorDescriptions, + reasonErrorDescriptions, + ] + + guard !reasons.isEmpty else { + return nil + } + + return reasons + .flatMap { $0 } + .joined(separator: "; ") + + } + +} + +extension Mastodon.Entity.Error.Detail { + + enum Item: String { + case username + case email + case password + case agreement + case locale + case reason + + var localized: String { + switch self { + case .username: return L10n.Scene.Register.Error.Item.username + case .email: return L10n.Scene.Register.Error.Item.email + case .password: return L10n.Scene.Register.Error.Item.password + case .agreement: return L10n.Scene.Register.Error.Item.agreement + case .locale: return L10n.Scene.Register.Error.Item.locale + case .reason: return L10n.Scene.Register.Error.Item.reason + } + } + } + + private static func localizeError(item: Item, for reason: Reason) -> String { + switch (item, reason.error) { + case (.username, .ERR_INVALID): + return L10n.Scene.Register.Error.Special.usernameInvalid + case (.username, .ERR_TOO_LONG): + return L10n.Scene.Register.Error.Special.usernameTooLong + case (.email, .ERR_INVALID): + return L10n.Scene.Register.Error.Special.emailInvalid + case (.password, .ERR_TOO_SHORT): + return L10n.Scene.Register.Error.Special.passwordTooShrot + case (_, .ERR_BLOCKED): return L10n.Scene.Register.Error.Reason.blocked(item.localized) + case (_, .ERR_UNREACHABLE): return L10n.Scene.Register.Error.Reason.unreachable(item.localized) + case (_, .ERR_TAKEN): return L10n.Scene.Register.Error.Reason.taken(item.localized) + case (_, .ERR_RESERVED): return L10n.Scene.Register.Error.Reason.reserved(item.localized) + case (_, .ERR_ACCEPTED): return L10n.Scene.Register.Error.Reason.accepted(item.localized) + case (_, .ERR_BLANK): return L10n.Scene.Register.Error.Reason.blank(item.localized) + case (_, .ERR_INVALID): return L10n.Scene.Register.Error.Reason.invalid(item.localized) + case (_, .ERR_TOO_LONG): return L10n.Scene.Register.Error.Reason.tooLong(item.localized) + case (_, .ERR_TOO_SHORT): return L10n.Scene.Register.Error.Reason.tooShort(item.localized) + case (_, .ERR_INCLUSION): return L10n.Scene.Register.Error.Reason.inclusion(item.localized) + case (_, ._other(let reason)): + assertionFailure("Needs handle new error description here") + return item.rawValue + " " + reason.description + } + } + + var usernameErrorDescriptions: [String] { + guard let username = username, !username.isEmpty else { return [] } + return username.map { Mastodon.Entity.Error.Detail.localizeError(item: .username, for: $0) } + } + + var emailErrorDescriptions: [String] { + guard let email = email, !email.isEmpty else { return [] } + return email.map { Mastodon.Entity.Error.Detail.localizeError(item: .email, for: $0) } + } + + var passwordErrorDescriptions: [String] { + guard let password = password, !password.isEmpty else { return [] } + return password.map { Mastodon.Entity.Error.Detail.localizeError(item: .password, for: $0) } + } + + var agreementErrorDescriptions: [String] { + guard let agreement = agreement, !agreement.isEmpty else { return [] } + return agreement.map { Mastodon.Entity.Error.Detail.localizeError(item: .agreement, for: $0) } + } + + var localeErrorDescriptions: [String] { + guard let locale = locale, !locale.isEmpty else { return [] } + return locale.map { Mastodon.Entity.Error.Detail.localizeError(item: .locale, for: $0) } + } + + var reasonErrorDescriptions: [String] { + guard let reason = reason, !reason.isEmpty else { return [] } + return reason.map { Mastodon.Entity.Error.Detail.localizeError(item: .reason, for: $0) } + } +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error.swift new file mode 100644 index 000000000..de3fd2f32 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error.swift @@ -0,0 +1,41 @@ +// +// Mastodon+Entity+Error.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-4. +// + +import Foundation +import MastodonSDK + +extension Mastodon.API.Error: LocalizedError { + + public var errorDescription: String? { + guard let mastodonError = mastodonError else { + return "HTTP \(httpResponseStatus.code)" + } + switch mastodonError { + case .generic(let error): + if let _ = error.details { + return nil // Duplicated with the details + } else { + return error.error + } + } + } + + public var failureReason: String? { + guard let mastodonError = mastodonError else { + return httpResponseStatus.reasonPhrase + } + switch mastodonError { + case .generic(let error): + if let details = error.details { + return details.failureReason + } else { + return error.errorDescription + } + } + } + +} diff --git a/Mastodon/Extension/UIAlertController.swift b/Mastodon/Extension/UIAlertController.swift index 755acc1ae..2b598f2a9 100644 --- a/Mastodon/Extension/UIAlertController.swift +++ b/Mastodon/Extension/UIAlertController.swift @@ -42,44 +42,3 @@ extension UIAlertController { ) } } - -extension UIAlertController { - convenience init( - for error: Mastodon.API.Error, - title: String?, - preferredStyle: UIAlertController.Style - ) { - let _title: String - let message: String? - switch error.mastodonError { - case .generic(let mastodonEntityError): - - if let title = title { - _title = title - } else { - _title = error.errorDescription ?? "Error" - } - var messages: [String?] = [] - if let details = mastodonEntityError.details { - message = details.localizedDescription() - } else { - messages.append(contentsOf: [ - error.failureReason, - error.recoverySuggestion - ]) - message = messages - .compactMap { $0 } - .joined(separator: " ") - } - default: - _title = "Internal Error" - message = error.localizedDescription - } - - self.init( - title: _title, - message: message, - preferredStyle: preferredStyle - ) - } -} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index dfd69f0c6..c14e7ce01 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -82,52 +82,6 @@ internal enum L10n { internal static let single = L10n.tr("Localizable", "Common.Countable.Photo.Single") } } - internal enum Errors { - /// must be accepted - internal static let errAccepted = L10n.tr("Localizable", "Common.Errors.ErrAccepted") - /// is required - internal static let errBlank = L10n.tr("Localizable", "Common.Errors.ErrBlank") - /// contains a disallowed e-mail provider - internal static let errBlocked = L10n.tr("Localizable", "Common.Errors.ErrBlocked") - /// is not a supported value - internal static let errInclusion = L10n.tr("Localizable", "Common.Errors.ErrInclusion") - /// is invalid - internal static let errInvalid = L10n.tr("Localizable", "Common.Errors.ErrInvalid") - /// is a reserved keyword - internal static let errReserved = L10n.tr("Localizable", "Common.Errors.ErrReserved") - /// is already in use - internal static let errTaken = L10n.tr("Localizable", "Common.Errors.ErrTaken") - /// is too long - internal static let errTooLong = L10n.tr("Localizable", "Common.Errors.ErrTooLong") - /// is too short - internal static let errTooShort = L10n.tr("Localizable", "Common.Errors.ErrTooShort") - /// does not seem to exist - internal static let errUnreachable = L10n.tr("Localizable", "Common.Errors.ErrUnreachable") - internal enum Item { - /// agreement - internal static let agreement = L10n.tr("Localizable", "Common.Errors.Item.Agreement") - /// email - internal static let email = L10n.tr("Localizable", "Common.Errors.Item.Email") - /// locale - internal static let locale = L10n.tr("Localizable", "Common.Errors.Item.Locale") - /// password - internal static let password = L10n.tr("Localizable", "Common.Errors.Item.Password") - /// reason - internal static let reason = L10n.tr("Localizable", "Common.Errors.Item.Reason") - /// username - internal static let username = L10n.tr("Localizable", "Common.Errors.Item.Username") - } - internal enum Itemdetail { - /// This is not a valid e-mail address - internal static let emailInvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.EmailInvalid") - /// password is too short (must be at least 8 characters) - internal static let passwordTooShrot = L10n.tr("Localizable", "Common.Errors.Itemdetail.PasswordTooShrot") - /// Username must only contain alphanumeric characters and underscores - internal static let usernameInvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameInvalid") - /// username is too long (can't be longer than 30 characters) - internal static let usernameTooLong = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameTooLong") - } - } } internal enum Scene { @@ -172,12 +126,76 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "Scene.PublicTimeline.Title") } internal enum Register { - /// Regsiter request sent. Please check your email. - internal static let checkEmail = L10n.tr("Localizable", "Scene.Register.CheckEmail") - /// Success - internal static let success = L10n.tr("Localizable", "Scene.Register.Success") /// Tell us about you. internal static let title = L10n.tr("Localizable", "Scene.Register.Title") + internal enum Error { + internal enum Item { + /// Agreement + internal static let agreement = L10n.tr("Localizable", "Scene.Register.Error.Item.Agreement") + /// Email + internal static let email = L10n.tr("Localizable", "Scene.Register.Error.Item.Email") + /// Locale + internal static let locale = L10n.tr("Localizable", "Scene.Register.Error.Item.Locale") + /// Password + internal static let password = L10n.tr("Localizable", "Scene.Register.Error.Item.Password") + /// Reason + internal static let reason = L10n.tr("Localizable", "Scene.Register.Error.Item.Reason") + /// Username + internal static let username = L10n.tr("Localizable", "Scene.Register.Error.Item.Username") + } + internal enum Reason { + /// %@ must be accepted. + internal static func accepted(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Accepted", String(describing: p1)) + } + /// %@ is required. + internal static func blank(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blank", String(describing: p1)) + } + /// %@ contains a disallowed e-mail provider. + internal static func blocked(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blocked", String(describing: p1)) + } + /// %@ is not a supported value. + internal static func inclusion(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Inclusion", String(describing: p1)) + } + /// %@ is invalid. + internal static func invalid(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Invalid", String(describing: p1)) + } + /// %@ is a reserved keyword. + internal static func reserved(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Reserved", String(describing: p1)) + } + /// %@ is already in use. + internal static func taken(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Taken", String(describing: p1)) + } + /// %@ is too long. + internal static func tooLong(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.TooLong", String(describing: p1)) + } + /// %@ is too short. + internal static func tooShort(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.TooShort", String(describing: p1)) + } + /// %@ does not seem to exist. + internal static func unreachable(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Unreachable", String(describing: p1)) + } + } + internal enum Special { + /// This is not a valid e-mail address. + internal static let emailInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.EmailInvalid") + /// Password is too short (must be at least 8 characters). + internal static let passwordTooShrot = L10n.tr("Localizable", "Scene.Register.Error.Special.PasswordTooShrot") + /// Username must only contain alphanumeric characters and underscores. + internal static let usernameInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameInvalid") + /// Username is too long (can't be longer than 30 characters). + internal static let usernameTooLong = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameTooLong") + } + } internal enum Input { internal enum DisplayName { /// display name @@ -192,7 +210,7 @@ internal enum L10n { internal static let registrationUserInviteRequest = L10n.tr("Localizable", "Scene.Register.Input.Invite.RegistrationUserInviteRequest") } internal enum Password { - /// Your password needs at least Eight characters + /// Your password needs at least eight characters internal static let hint = L10n.tr("Localizable", "Scene.Register.Input.Password.Hint") /// password internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.Password.Placeholder") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index d333460e9..acdf89f98 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -23,26 +23,6 @@ "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; -"Common.Errors.ErrAccepted" = "must be accepted"; -"Common.Errors.ErrBlank" = "is required"; -"Common.Errors.ErrBlocked" = "contains a disallowed e-mail provider"; -"Common.Errors.ErrInclusion" = "is not a supported value"; -"Common.Errors.ErrInvalid" = "is invalid"; -"Common.Errors.ErrReserved" = "is a reserved keyword"; -"Common.Errors.ErrTaken" = "is already in use"; -"Common.Errors.ErrTooLong" = "is too long"; -"Common.Errors.ErrTooShort" = "is too short"; -"Common.Errors.ErrUnreachable" = "does not seem to exist"; -"Common.Errors.Item.Agreement" = "agreement"; -"Common.Errors.Item.Email" = "email"; -"Common.Errors.Item.Locale" = "locale"; -"Common.Errors.Item.Password" = "password"; -"Common.Errors.Item.Reason" = "reason"; -"Common.Errors.Item.Username" = "username"; -"Common.Errors.Itemdetail.EmailInvalid" = "This is not a valid e-mail address"; -"Common.Errors.Itemdetail.PasswordTooShrot" = "password is too short (must be at least 8 characters)"; -"Common.Errors.Itemdetail.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores"; -"Common.Errors.Itemdetail.UsernameTooLong" = "username is too long (can't be longer than 30 characters)"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; @@ -57,15 +37,33 @@ tap the link to confirm your account."; "Scene.ConfirmEmail.Title" = "One last thing."; "Scene.HomeTimeline.Title" = "Home"; "Scene.PublicTimeline.Title" = "Public"; -"Scene.Register.CheckEmail" = "Regsiter request sent. Please check your email."; +"Scene.Register.Error.Item.Agreement" = "Agreement"; +"Scene.Register.Error.Item.Email" = "Email"; +"Scene.Register.Error.Item.Locale" = "Locale"; +"Scene.Register.Error.Item.Password" = "Password"; +"Scene.Register.Error.Item.Reason" = "Reason"; +"Scene.Register.Error.Item.Username" = "Username"; +"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted."; +"Scene.Register.Error.Reason.Blank" = "%@ is required."; +"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed e-mail provider."; +"Scene.Register.Error.Reason.Inclusion" = "%@ is not a supported value."; +"Scene.Register.Error.Reason.Invalid" = "%@ is invalid."; +"Scene.Register.Error.Reason.Reserved" = "%@ is a reserved keyword."; +"Scene.Register.Error.Reason.Taken" = "%@ is already in use."; +"Scene.Register.Error.Reason.TooLong" = "%@ is too long."; +"Scene.Register.Error.Reason.TooShort" = "%@ is too short."; +"Scene.Register.Error.Reason.Unreachable" = "%@ does not seem to exist."; +"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail address."; +"Scene.Register.Error.Special.PasswordTooShrot" = "Password is too short (must be at least 8 characters)."; +"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores."; +"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can't be longer than 30 characters)."; "Scene.Register.Input.DisplayName.Placeholder" = "display name"; "Scene.Register.Input.Email.Placeholder" = "email"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Why do you want to join?"; -"Scene.Register.Input.Password.Hint" = "Your password needs at least Eight characters"; +"Scene.Register.Input.Password.Hint" = "Your password needs at least eight characters"; "Scene.Register.Input.Password.Placeholder" = "password"; "Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken."; "Scene.Register.Input.Username.Placeholder" = "username"; -"Scene.Register.Success" = "Success"; "Scene.Register.Title" = "Tell us about you."; "Scene.ServerPicker.Button.Category.All" = "All"; "Scene.ServerPicker.Button.Seeless" = "See Less"; diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index fef09b955..6e36651fb 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -59,7 +59,9 @@ class PickServerCell: UITableViewCell { return label }() - private var thumbImageView: UIImageView = { + private let thumbnailActivityIdicator = UIActivityIndicatorView(style: .medium) + + private var thumbnailImageView: UIImageView = { let imageView = UIImageView() imageView.clipsToBounds = true imageView.contentMode = .scaleAspectFill @@ -178,6 +180,12 @@ class PickServerCell: UITableViewCell { } } + override func prepareForReuse() { + super.prepareForReuse() + + thumbnailImageView.af.cancelImageRequest() + } + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) _init() @@ -205,7 +213,7 @@ extension PickServerCell { // Always add the expandbox which contains elements only visible in expand mode containerView.addSubview(expandBox) - expandBox.addSubview(thumbImageView) + expandBox.addSubview(thumbnailImageView) expandBox.addSubview(infoStackView) expandBox.isHidden = true @@ -254,20 +262,29 @@ extension PickServerCell { expandBox.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 8), expandBox.bottomAnchor.constraint(equalTo: infoStackView.bottomAnchor).priority(.defaultHigh), - thumbImageView.topAnchor.constraint(equalTo: expandBox.topAnchor), - thumbImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor), - expandBox.trailingAnchor.constraint(equalTo: thumbImageView.trailingAnchor), - thumbImageView.heightAnchor.constraint(equalTo: thumbImageView.widthAnchor, multiplier: 151.0 / 303.0).priority(.defaultHigh), + thumbnailImageView.topAnchor.constraint(equalTo: expandBox.topAnchor), + thumbnailImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor), + expandBox.trailingAnchor.constraint(equalTo: thumbnailImageView.trailingAnchor), + thumbnailImageView.heightAnchor.constraint(equalTo: thumbnailImageView.widthAnchor, multiplier: 151.0 / 303.0).priority(.defaultHigh), infoStackView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor), expandBox.trailingAnchor.constraint(equalTo: infoStackView.trailingAnchor), - infoStackView.topAnchor.constraint(equalTo: thumbImageView.bottomAnchor, constant: 16), + infoStackView.topAnchor.constraint(equalTo: thumbnailImageView.bottomAnchor, constant: 16), expandButton.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: expandButton.trailingAnchor), containerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: expandButton.bottomAnchor), ]) + thumbnailActivityIdicator.translatesAutoresizingMaskIntoConstraints = false + thumbnailImageView.addSubview(thumbnailActivityIdicator) + NSLayoutConstraint.activate([ + thumbnailActivityIdicator.centerXAnchor.constraint(equalTo: thumbnailImageView.centerXAnchor), + thumbnailActivityIdicator.centerYAnchor.constraint(equalTo: thumbnailImageView.centerYAnchor), + ]) + thumbnailActivityIdicator.hidesWhenStopped = true + thumbnailActivityIdicator.stopAnimating() + NSLayoutConstraint.activate(collapseConstraints) domainLabel.setContentHuggingPriority(.required - 1, for: .vertical) @@ -301,6 +318,8 @@ extension PickServerCell { expandButton.isSelected = true NSLayoutConstraint.activate(expandConstraints) NSLayoutConstraint.deactivate(collapseConstraints) + + updateThumbnail() } } @@ -334,18 +353,29 @@ extension PickServerCell { return html.text ?? serverInfo.description }() - let processor = RoundCornerImageProcessor(cornerRadius: 3) - thumbImageView.kf.indicatorType = .activity - thumbImageView.kf.setImage(with: URL(string: serverInfo.proxiedThumbnail ?? "")!, placeholder: UIImage.placeholder(color: Asset.Colors.lightBackground.color), options: [ - .processor(processor), - .scaleFactor(UIScreen.main.scale), - .transition(.fade(1)) - ]) langValueLabel.text = serverInfo.language.uppercased() usersValueLabel.text = parseUsersCount(serverInfo.totalUsers) categoryValueLabel.text = serverInfo.category.uppercased() } + private func updateThumbnail() { + guard let serverInfo = server else { return } + + thumbnailActivityIdicator.startAnimating() + thumbnailImageView.af.setImage( + withURL: URL(string: serverInfo.proxiedThumbnail ?? "")!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.33), + completion: { [weak self] response in + guard let self = self else { return } + switch response.result { + case .success, .failure: + self.thumbnailActivityIdicator.stopAnimating() + } + } + ) + } + private func parseUsersCount(_ usersCount: Int) -> String { switch usersCount { case 0..<1000: diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift index bb4e3f3eb..a23585271 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -10,7 +10,8 @@ import Foundation import PhotosUI import UIKit -extension MastodonRegisterViewController: CropViewControllerDelegate, PHPickerViewControllerDelegate { +// MARK: - PHPickerViewControllerDelegate +extension MastodonRegisterViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { guard let itemProvider = results.first?.itemProvider, itemProvider.canLoadObject(ofClass: UIImage.self) else { picker.dismiss(animated: true, completion: {}) @@ -44,13 +45,18 @@ extension MastodonRegisterViewController: CropViewControllerDelegate, PHPickerVi } } } +} +// MARK: - CropViewControllerDelegate +extension MastodonRegisterViewController: CropViewControllerDelegate { public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { self.viewModel.avatarImage.value = image - self.photoButton.setImage(image, for: .normal) + self.avatarButton.setImage(image, for: .normal) cropViewController.dismiss(animated: true, completion: nil) } +} +extension MastodonRegisterViewController { @objc func avatarButtonPressed(_ sender: UIButton) { self.present(imagePicker, animated: true, completion: nil) } diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index ba515e2da..fbb952834 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -49,13 +49,13 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return label }() - let photoView: UIView = { + let avatarView: UIView = { let view = UIView() view.backgroundColor = .clear return view }() - let photoButton: UIButton = { + let avatarButton: UIButton = { let button = UIButton(type: .custom) let boldFont = UIFont.systemFont(ofSize: 42) let configuration = UIImage.SymbolConfiguration(font: boldFont) @@ -67,11 +67,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O button.layer.cornerRadius = 45 button.clipsToBounds = true - button.addTarget(self, action: #selector(MastodonRegisterViewController.avatarButtonPressed(_:)), for: .touchUpInside) return button }() - let plusIcon: UIImageView = { + let plusIconImageView: UIImageView = { let icon = UIImageView() let image = Asset.Circles.plusCircleFill.image.withRenderingMode(.alwaysTemplate) @@ -105,22 +104,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return textField }() - let usernameIsTakenLabel: UILabel = { + let usernameErrorPromptLabel: UILabel = { let label = UILabel() let color = Asset.Colors.lightDangerRed.color let font = UIFont.preferredFont(forTextStyle: .caption1) - let attributeString = NSMutableAttributedString() - - let errorImage = NSTextAttachment() - let configuration = UIImage.SymbolConfiguration(font: font) - errorImage.image = UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)?.withTintColor(color) - let errorImageAttachment = NSAttributedString(attachment: errorImage) - attributeString.append(errorImageAttachment) - - let errorString = NSAttributedString(string: L10n.Common.Errors.Item.username + " " + L10n.Common.Errors.errTaken, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) - attributeString.append(errorString) - label.attributedText = attributeString - return label }() @@ -157,9 +144,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return textField }() - let passwordCheckLabel: UILabel = { + let emailErrorPromptLabel: UILabel = { let label = UILabel() - label.numberOfLines = 0 + let color = Asset.Colors.lightDangerRed.color + let font = UIFont.preferredFont(forTextStyle: .caption1) return label }() @@ -181,7 +169,21 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return textField }() - lazy var inviteTextField: UITextField = { + let passwordCheckLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + return label + }() + + let passwordErrorPromptLabel: UILabel = { + let label = UILabel() + let color = Asset.Colors.lightDangerRed.color + let font = UIFont.preferredFont(forTextStyle: .caption1) + return label + }() + + + lazy var reasonTextField: UITextField = { let textField = UITextField() textField.autocapitalizationType = .none textField.autocorrectionType = .no @@ -197,6 +199,13 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return textField }() + let reasonErrorPromptLabel: UILabel = { + let label = UILabel() + let color = Asset.Colors.lightDangerRed.color + let font = UIFont.preferredFont(forTextStyle: .caption1) + return label + }() + let buttonContainer = UIView() let signUpButton: PrimaryActionButton = { let button = PrimaryActionButton() @@ -217,20 +226,9 @@ extension MastodonRegisterViewController { setupOnboardingAppearance() defer { setupNavigationBarBackgroundView() } - - photoButton.publisher(for: \.isHighlighted, options: .new) - .receive(on: DispatchQueue.main) - .sink { [weak self] isHighlighted in - guard let self = self else { return } - let alpha: CGFloat = isHighlighted ? 0.8 : 1 - self.plusIcon.alpha = alpha - self.photoButton.alpha = alpha - } - .store(in: &disposeBag) - domainLabel.text = "@" + viewModel.domain + " " domainLabel.sizeToFit() - passwordCheckLabel.attributedText = viewModel.attributeStringForPassword() + passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(isValid: false) usernameTextField.rightView = domainLabel usernameTextField.rightViewMode = .always usernameTextField.delegate = self @@ -250,16 +248,40 @@ extension MastodonRegisterViewController { stackView.layoutMargins = UIEdgeInsets(top: 20, left: 0, bottom: 26, right: 0) stackView.isLayoutMarginsRelativeArrangement = true stackView.addArrangedSubview(largeTitleLabel) - stackView.addArrangedSubview(photoView) + stackView.addArrangedSubview(avatarView) stackView.addArrangedSubview(usernameTextField) - stackView.addArrangedSubview(usernameIsTakenLabel) stackView.addArrangedSubview(displayNameTextField) stackView.addArrangedSubview(emailTextField) stackView.addArrangedSubview(passwordTextField) stackView.addArrangedSubview(passwordCheckLabel) if viewModel.approvalRequired { - stackView.addArrangedSubview(inviteTextField) + stackView.addArrangedSubview(reasonTextField) } + + usernameErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false + stackView.addSubview(usernameErrorPromptLabel) + NSLayoutConstraint.activate([ + usernameErrorPromptLabel.topAnchor.constraint(equalTo: usernameTextField.bottomAnchor, constant: 6), + usernameErrorPromptLabel.leadingAnchor.constraint(equalTo: usernameTextField.leadingAnchor), + usernameErrorPromptLabel.trailingAnchor.constraint(equalTo: usernameTextField.trailingAnchor), + ]) + + emailErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false + stackView.addSubview(emailErrorPromptLabel) + NSLayoutConstraint.activate([ + emailErrorPromptLabel.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 6), + emailErrorPromptLabel.leadingAnchor.constraint(equalTo: emailTextField.leadingAnchor), + emailErrorPromptLabel.trailingAnchor.constraint(equalTo: emailTextField.trailingAnchor), + ]) + + passwordErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false + stackView.addSubview(passwordErrorPromptLabel) + NSLayoutConstraint.activate([ + passwordErrorPromptLabel.topAnchor.constraint(equalTo: passwordTextField.bottomAnchor, constant: 6), + passwordErrorPromptLabel.leadingAnchor.constraint(equalTo: passwordTextField.leadingAnchor), + passwordErrorPromptLabel.trailingAnchor.constraint(equalTo: passwordTextField.trailingAnchor), + ]) + // scrollView view.addSubview(scrollView) NSLayoutConstraint.activate([ @@ -282,24 +304,24 @@ extension MastodonRegisterViewController { ]) // photoview - photoView.translatesAutoresizingMaskIntoConstraints = false - photoView.addSubview(photoButton) + avatarView.translatesAutoresizingMaskIntoConstraints = false + avatarView.addSubview(avatarButton) NSLayoutConstraint.activate([ - photoView.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), + avatarView.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), ]) - photoButton.translatesAutoresizingMaskIntoConstraints = false + avatarButton.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - photoButton.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), - photoButton.widthAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), - photoButton.centerXAnchor.constraint(equalTo: photoView.centerXAnchor), - photoButton.centerYAnchor.constraint(equalTo: photoView.centerYAnchor), + avatarButton.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), + avatarButton.widthAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), + avatarButton.centerXAnchor.constraint(equalTo: avatarView.centerXAnchor), + avatarButton.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor), ]) - plusIcon.translatesAutoresizingMaskIntoConstraints = false - photoView.addSubview(plusIcon) + plusIconImageView.translatesAutoresizingMaskIntoConstraints = false + avatarView.addSubview(plusIconImageView) NSLayoutConstraint.activate([ - plusIcon.trailingAnchor.constraint(equalTo: photoButton.trailingAnchor), - plusIcon.bottomAnchor.constraint(equalTo: photoButton.bottomAnchor), + plusIconImageView.trailingAnchor.constraint(equalTo: avatarButton.trailingAnchor), + plusIconImageView.bottomAnchor.constraint(equalTo: avatarButton.bottomAnchor), ]) // textfield @@ -360,6 +382,16 @@ extension MastodonRegisterViewController { } }) .store(in: &disposeBag) + + avatarButton.publisher(for: \.isHighlighted, options: .new) + .receive(on: DispatchQueue.main) + .sink { [weak self] isHighlighted in + guard let self = self else { return } + let alpha: CGFloat = isHighlighted ? 0.8 : 1 + self.plusIconImageView.alpha = alpha + self.avatarButton.alpha = alpha + } + .store(in: &disposeBag) viewModel.isRegistering .receive(on: DispatchQueue.main) @@ -376,6 +408,13 @@ extension MastodonRegisterViewController { self.setTextFieldValidAppearance(self.usernameTextField, validateState: validateState) } .store(in: &disposeBag) + viewModel.usernameErrorPrompt + .receive(on: DispatchQueue.main) + .sink { [weak self] prompt in + guard let self = self else { return } + self.usernameErrorPromptLabel.attributedText = prompt + } + .store(in: &disposeBag) viewModel.displayNameValidateState .receive(on: DispatchQueue.main) .sink { [weak self] validateState in @@ -390,12 +429,33 @@ extension MastodonRegisterViewController { self.setTextFieldValidAppearance(self.emailTextField, validateState: validateState) } .store(in: &disposeBag) + viewModel.emailErrorPrompt + .receive(on: DispatchQueue.main) + .sink { [weak self] prompt in + guard let self = self else { return } + self.emailErrorPromptLabel.attributedText = prompt + } + .store(in: &disposeBag) viewModel.passwordValidateState .receive(on: DispatchQueue.main) .sink { [weak self] validateState in guard let self = self else { return } self.setTextFieldValidAppearance(self.passwordTextField, validateState: validateState) - self.passwordCheckLabel.attributedText = self.viewModel.attributeStringForPassword(eightCharacters: validateState == .valid) + self.passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(isValid: validateState == .valid) + } + .store(in: &disposeBag) + viewModel.passwordErrorPrompt + .receive(on: DispatchQueue.main) + .sink { [weak self] prompt in + guard let self = self else { return } + self.passwordErrorPromptLabel.attributedText = prompt + } + .store(in: &disposeBag) + viewModel.reasonErrorPrompt + .receive(on: DispatchQueue.main) + .sink { [weak self] prompt in + guard let self = self else { return } + self.reasonErrorPromptLabel.attributedText = prompt } .store(in: &disposeBag) @@ -407,37 +467,11 @@ extension MastodonRegisterViewController { } .store(in: &disposeBag) - viewModel.isUsernameTaken - .receive(on: DispatchQueue.main) - .sink { [weak self] isUsernameTaken in - guard let self = self else { return } - if isUsernameTaken { - self.usernameIsTakenLabel.isHidden = false - stackView.setCustomSpacing(6, after: self.usernameTextField) - stackView.setCustomSpacing(16, after: self.usernameIsTakenLabel) - } else { - self.usernameIsTakenLabel.isHidden = true - stackView.setCustomSpacing(40, after: self.usernameTextField) - } - } - .store(in: &disposeBag) viewModel.error - .compactMap { $0 } .receive(on: DispatchQueue.main) .sink { [weak self] error in guard let self = self else { return } guard let error = error as? Mastodon.API.Error else { return } - switch error.mastodonError { - case .generic(let mastodonEntityError): - if let usernameTakenError = mastodonEntityError.details?.username { - let isUsernameAvaliable = usernameTakenError.filter { errorDetailReason -> Bool in - errorDetailReason.error == .ERR_TAKEN - }.isEmpty - self.viewModel.isUsernameTaken.value = !isUsernameAvaliable - } - default: - break - } let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert) let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) alertController.addAction(okAction) @@ -486,34 +520,42 @@ extension MastodonRegisterViewController { .store(in: &disposeBag) if viewModel.approvalRequired { - inviteTextField.delegate = self + reasonTextField.delegate = self NSLayoutConstraint.activate([ - inviteTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), + reasonTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), + ]) + reasonErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false + stackView.addSubview(reasonErrorPromptLabel) + NSLayoutConstraint.activate([ + reasonErrorPromptLabel.topAnchor.constraint(equalTo: reasonTextField.bottomAnchor, constant: 6), + reasonErrorPromptLabel.leadingAnchor.constraint(equalTo: reasonTextField.leadingAnchor), + reasonErrorPromptLabel.trailingAnchor.constraint(equalTo: reasonTextField.trailingAnchor), ]) - viewModel.inviteValidateState + viewModel.reasonValidateState .receive(on: DispatchQueue.main) .sink { [weak self] validateState in guard let self = self else { return } - self.setTextFieldValidAppearance(self.inviteTextField, validateState: validateState) + self.setTextFieldValidAppearance(self.reasonTextField, validateState: validateState) } .store(in: &disposeBag) NotificationCenter.default - .publisher(for: UITextField.textDidChangeNotification, object: inviteTextField) + .publisher(for: UITextField.textDidChangeNotification, object: reasonTextField) .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } - self.viewModel.reason.value = self.inviteTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.viewModel.reason.value = self.reasonTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } .store(in: &disposeBag) } + avatarButton.addTarget(self, action: #selector(MastodonRegisterViewController.avatarButtonPressed(_:)), for: .touchUpInside) signUpButton.addTarget(self, action: #selector(MastodonRegisterViewController.signUpButtonPressed(_:)), for: .touchUpInside) } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - plusIcon.layer.cornerRadius = plusIcon.frame.width/2 - plusIcon.clipsToBounds = true + plusIconImageView.layer.cornerRadius = plusIconImageView.frame.width/2 + plusIconImageView.clipsToBounds = true } } @@ -530,7 +572,7 @@ extension MastodonRegisterViewController: UITextFieldDelegate { viewModel.email.value = text case passwordTextField: viewModel.password.value = text - case inviteTextField: + case reasonTextField: viewModel.reason.value = text default: break diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index f0c11849a..8f03cb124 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -26,6 +26,11 @@ final class MastodonRegisterViewModel { let reason = CurrentValueSubject("") let avatarImage = CurrentValueSubject(nil) + let usernameErrorPrompt = CurrentValueSubject(nil) + let emailErrorPrompt = CurrentValueSubject(nil) + let passwordErrorPrompt = CurrentValueSubject(nil) + let reasonErrorPrompt = CurrentValueSubject(nil) + // output let approvalRequired: Bool let applicationAuthorization: Mastodon.API.OAuth.Authorization @@ -33,10 +38,8 @@ final class MastodonRegisterViewModel { let displayNameValidateState = CurrentValueSubject(.empty) let emailValidateState = CurrentValueSubject(.empty) let passwordValidateState = CurrentValueSubject(.empty) - let inviteValidateState = CurrentValueSubject(.empty) - - let isUsernameTaken = CurrentValueSubject(false) - + let reasonValidateState = CurrentValueSubject(.empty) + let isRegistering = CurrentValueSubject(false) let isAllValid = CurrentValueSubject(false) let error = CurrentValueSubject(nil) @@ -102,25 +105,43 @@ final class MastodonRegisterViewModel { guard !invite.isEmpty else { return .empty } return .valid } - .assign(to: \.value, on: inviteValidateState) + .assign(to: \.value, on: reasonValidateState) .store(in: &disposeBag) } + + error + .sink { [weak self] error in + guard let self = self else { return } + let error = error as? Mastodon.API.Error + let mastodonError = error?.mastodonError + if case let .generic(genericMastodonError) = mastodonError, + let details = genericMastodonError.details { + self.usernameErrorPrompt.value = details.usernameErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } + self.emailErrorPrompt.value = details.emailErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } + self.passwordErrorPrompt.value = details.passwordErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } + self.reasonErrorPrompt.value = details.reasonErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } + } else { + self.usernameErrorPrompt.value = nil + self.emailErrorPrompt.value = nil + self.passwordErrorPrompt.value = nil + self.reasonErrorPrompt.value = nil + } + } + .store(in: &disposeBag) + let publisherOne = Publishers.CombineLatest4( usernameValidateState.eraseToAnyPublisher(), displayNameValidateState.eraseToAnyPublisher(), emailValidateState.eraseToAnyPublisher(), passwordValidateState.eraseToAnyPublisher() - ).map { - $0.0 == .valid && $0.1 == .valid && $0.2 == .valid && $0.3 == .valid - } + ) + .map { $0.0 == .valid && $0.1 == .valid && $0.2 == .valid && $0.3 == .valid } Publishers.CombineLatest( publisherOne, - approvalRequired ? inviteValidateState.map {$0 == .valid}.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher() + approvalRequired ? reasonValidateState.map {$0 == .valid}.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher() ) - .map { - return $0 && $1 - } + .map { $0 && $1 } .assign(to: \.value, on: isAllValid) .store(in: &disposeBag) } @@ -135,6 +156,7 @@ extension MastodonRegisterViewModel { } extension MastodonRegisterViewModel { + static func isValidEmail(_ email: String) -> Bool { let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" @@ -142,37 +164,47 @@ extension MastodonRegisterViewModel { return emailPred.evaluate(with: email) } - func attributeStringForUsername() -> NSAttributedString { - let resultAttributeString = NSMutableAttributedString() - let redImage = NSTextAttachment() - let font = UIFont.preferredFont(forTextStyle: .caption1) + static func checkmarkImage(font: UIFont = .preferredFont(forTextStyle: .caption1)) -> UIImage { let configuration = UIImage.SymbolConfiguration(font: font) - redImage.image = UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)?.withTintColor(Asset.Colors.lightDangerRed.color) - let imageAttribute = NSAttributedString(attachment: redImage) - let stringAttribute = NSAttributedString(string: "This username is taken.", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.lightDangerRed.color]) - resultAttributeString.append(imageAttribute) - resultAttributeString.append(stringAttribute) - return resultAttributeString + return UIImage(systemName: "checkmark.circle.fill", withConfiguration: configuration)! + } + + static func xmarkImage(font: UIFont = .preferredFont(forTextStyle: .caption1)) -> UIImage { + let configuration = UIImage.SymbolConfiguration(font: font) + return UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)! } - func attributeStringForPassword(eightCharacters: Bool = false) -> NSAttributedString { + static func attributedStringImage(with image: UIImage, tintColor: UIColor) -> NSAttributedString { + let attachment = NSTextAttachment() + attachment.image = image.withTintColor(tintColor) + return NSAttributedString(attachment: attachment) + } + + static func attributeStringForPassword(isValid: Bool) -> NSAttributedString { let font = UIFont.preferredFont(forTextStyle: .caption1) - let color = UIColor.black - let falseColor = UIColor.clear let attributeString = NSMutableAttributedString() - - attributeString.append(checkmarkImage(color: eightCharacters ? color : falseColor)) - let eightCharactersDescription = NSAttributedString(string: L10n.Scene.Register.Input.Password.hint, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) + + let image = MastodonRegisterViewModel.checkmarkImage(font: font) + attributeString.append(attributedStringImage(with: image, tintColor: isValid ? .black : .clear)) + attributeString.append(NSAttributedString(string: " ")) + let eightCharactersDescription = NSAttributedString(string: L10n.Scene.Register.Input.Password.hint, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: UIColor.black]) attributeString.append(eightCharactersDescription) return attributeString } - - func checkmarkImage(color: UIColor) -> NSAttributedString { - let checkmarkImage = NSTextAttachment() + + static func errorPromptAttributedString(for prompt: String) -> NSAttributedString { let font = UIFont.preferredFont(forTextStyle: .caption1) - let configuration = UIImage.SymbolConfiguration(font: font) - checkmarkImage.image = UIImage(systemName: "checkmark.circle.fill", withConfiguration: configuration)?.withTintColor(color) - return NSAttributedString(attachment: checkmarkImage) + let attributeString = NSMutableAttributedString() + + let image = MastodonRegisterViewModel.xmarkImage(font: font) + attributeString.append(attributedStringImage(with: image, tintColor: Asset.Colors.lightDangerRed.color)) + attributeString.append(NSAttributedString(string: " ")) + + let promptAttributedString = NSAttributedString(string: prompt, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.lightDangerRed.color]) + attributeString.append(promptAttributedString) + + return attributeString } + } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift index 05540feda..94d063c40 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift @@ -34,27 +34,3 @@ extension Mastodon.API { } } - -extension Mastodon.API.Error: LocalizedError { - - public var errorDescription: String? { - guard let mastodonError = mastodonError else { - return "HTTP \(httpResponseStatus.code)" - } - switch mastodonError { - case .generic(let error): - return error.error - } - } - - public var failureReason: String? { - guard let mastodonError = mastodonError else { - return httpResponseStatus.reasonPhrase - } - switch mastodonError { - case .generic(let error): - return error.errorDescription - } - } - -} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error+Detail.swift similarity index 57% rename from MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift rename to MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error+Detail.swift index 7881aaa0d..ef7f1b640 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error+Detail.swift @@ -6,27 +6,70 @@ // import Foundation + extension Mastodon.Entity.Error { - /// ERR_BLOCKED When e-mail provider is not allowed - /// ERR_UNREACHABLE When e-mail address does not resolve to any IP via DNS (MX, A, AAAA) - /// ERR_TAKEN When username or e-mail are already taken - /// ERR_RESERVED When a username is reserved, e.g. "webmaster" or "admin" - /// ERR_ACCEPTED When agreement has not been accepted - /// ERR_BLANK When a required attribute is blank - /// ERR_INVALID When an attribute is malformed, e.g. wrong characters or invalid e-mail address - /// ERR_TOO_LONG When an attribute is over the character limit - /// ERR_INCLUSION When an attribute is not one of the allowed values, e.g. unsupported locale - public enum SignUpError: RawRepresentable, Codable { + public struct Detail: Codable { + public let username: [Reason]? + public let email: [Reason]? + public let password: [Reason]? + public let agreement: [Reason]? + public let locale: [Reason]? + public let reason: [Reason]? + + enum CodingKeys: String, CodingKey { + case username + case email + case password + case agreement + case locale + case reason + } + } +} + + + +extension Mastodon.Entity.Error.Detail { + public struct Reason: Codable { + public let error: Error + public let description: String + + enum CodingKeys: String, CodingKey { + case error + case description + } + } +} + +extension Mastodon.Entity.Error.Detail.Reason { + /// - Since: 3.3.1 + /// - Version: 3.3.1 + /// # Last Update + /// 2021/3/4 + /// # Reference + /// [Document](https://github.com/tootsuite/mastodon/pull/15803) + public enum Error: RawRepresentable, Codable { + /// When e-mail provider is not allowed case ERR_BLOCKED + /// When e-mail address does not resolve to any IP via DNS (MX, A, AAAA) case ERR_UNREACHABLE + /// When username or e-mail are already taken case ERR_TAKEN + /// When a username is reserved, e.g. "webmaster" or "admin" case ERR_RESERVED + /// When agreement has not been accepted case ERR_ACCEPTED + /// When a required attribute is blank case ERR_BLANK + /// When an attribute is malformed, e.g. wrong characters or invalid e-mail address case ERR_INVALID + /// When an attribute is over the character limit case ERR_TOO_LONG + /// When an attribute is under the character requirement case ERR_TOO_SHORT + /// When an attribute is not one of the allowed values, e.g. unsupported locale case ERR_INCLUSION + /// Not handled error case _other(String) public init?(rawValue: String) { @@ -65,38 +108,3 @@ extension Mastodon.Entity.Error { } } } -extension Mastodon.Entity { - public struct ErrorDetail: Codable { - public let username: [ErrorDetailReason]? - public let email: [ErrorDetailReason]? - public let password: [ErrorDetailReason]? - public let agreement: [ErrorDetailReason]? - public let locale: [ErrorDetailReason]? - public let reason: [ErrorDetailReason]? - - enum CodingKeys: String, CodingKey { - case username - case email - case password - case agreement - case locale - case reason - } - } - - public struct ErrorDetailReason: Codable { - public init(error: String, errorDescription: String?) { - self.error = Mastodon.Entity.Error.SignUpError(rawValue: error) ?? ._other(error) - self.errorDescription = errorDescription - } - - public let error: Mastodon.Entity.Error.SignUpError - public let errorDescription: String? - - - enum CodingKeys: String, CodingKey { - case error - case errorDescription = "description" - } - } -} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift index a36025745..daf47bbd7 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift @@ -13,13 +13,13 @@ extension Mastodon.Entity { /// - Since: 0.6.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/1/28 + /// 2021/3/4 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/error/) public struct Error: Codable { public let error: String public let errorDescription: String? - public let details: ErrorDetail? + public let details: Detail? enum CodingKeys: String, CodingKey { case error