fix: make sign up error i18n display for each text filed. Fix memory leaking issue for pick server scene

This commit is contained in:
CMK 2021-03-04 15:29:46 +08:00
parent fab1deb7c8
commit 2ed2a7d8a1
15 changed files with 588 additions and 454 deletions

View File

@ -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"
}
}
}
}

View File

@ -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 = "<group>"; };
2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = "<group>"; };
2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entidy+ErrorDetailReason.swift"; sourceTree = "<group>"; };
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = "<group>"; };
2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = "<group>"; };
2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = "<group>"; };
2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = "<group>"; };
@ -343,6 +344,7 @@
DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = "<group>"; };
DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; };
DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = "<group>"; };
DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = "<group>"; };
DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = "<group>"; };
DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@ -885,6 +887,15 @@
path = NavigationController;
sourceTree = "<group>";
};
DB6C8C0525F0921200AAA452 /* MastodonSDK */ = {
isa = PBXGroup;
children = (
DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */,
2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */,
);
path = MastodonSDK;
sourceTree = "<group>";
};
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 */,

View File

@ -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()
}
}

View File

@ -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) }
}
}

View File

@ -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
}
}
}
}

View File

@ -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
)
}
}

View File

@ -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")

View File

@ -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 havent.";
@ -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";

View File

@ -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:

View File

@ -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)
}

View File

@ -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

View File

@ -26,6 +26,11 @@ final class MastodonRegisterViewModel {
let reason = CurrentValueSubject<String, Never>("")
let avatarImage = CurrentValueSubject<UIImage?, Never>(nil)
let usernameErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
let emailErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
let passwordErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
let reasonErrorPrompt = CurrentValueSubject<NSAttributedString?, Never>(nil)
// output
let approvalRequired: Bool
let applicationAuthorization: Mastodon.API.OAuth.Authorization
@ -33,10 +38,8 @@ final class MastodonRegisterViewModel {
let displayNameValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
let emailValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
let passwordValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
let inviteValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
let isUsernameTaken = CurrentValueSubject<Bool, Never>(false)
let reasonValidateState = CurrentValueSubject<ValidateState, Never>(.empty)
let isRegistering = CurrentValueSubject<Bool, Never>(false)
let isAllValid = CurrentValueSubject<Bool, Never>(false)
let error = CurrentValueSubject<Error?, Never>(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
}
}

View File

@ -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
}
}
}

View File

@ -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"
}
}
}

View File

@ -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