From 2ed2a7d8a1559a18af5fd113d77630a56cd43062 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 4 Mar 2021 15:29:46 +0800 Subject: [PATCH 01/11] 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 From 54292d6b6ab497b5b44d7bb96fa68d285430be8a Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 4 Mar 2021 16:42:43 +0800 Subject: [PATCH 02/11] fix: typo. Remove period --- Localization/app.json | 8 ++--- .../Mastodon+Entity+Error+Detail.swift | 2 +- Mastodon/Generated/Strings.swift | 30 +++++++++---------- .../Resources/en.lproj/Localizable.strings | 28 ++++++++--------- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 920a8abe7..9c438b568 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -109,10 +109,10 @@ "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)." + "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_short": "Password is too short (must be at least 8 characters)" } } }, diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift index 1e993a8c3..312e4e3f0 100644 --- a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift @@ -63,7 +63,7 @@ extension Mastodon.Entity.Error.Detail { case (.email, .ERR_INVALID): return L10n.Scene.Register.Error.Special.emailInvalid case (.password, .ERR_TOO_SHORT): - return L10n.Scene.Register.Error.Special.passwordTooShrot + return L10n.Scene.Register.Error.Special.passwordTooShort 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) diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index c14e7ce01..75a661fad 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -144,55 +144,55 @@ internal enum L10n { internal static let username = L10n.tr("Localizable", "Scene.Register.Error.Item.Username") } internal enum Reason { - /// %@ must be accepted. + /// %@ must be accepted internal static func accepted(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Register.Error.Reason.Accepted", String(describing: p1)) } - /// %@ is required. + /// %@ 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. + /// %@ 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. + /// %@ 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. + /// %@ 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. + /// %@ 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. + /// %@ 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. + /// %@ 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. + /// %@ 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. + /// %@ 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. + /// 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. + /// Password is too short (must be at least 8 characters) + internal static let passwordTooShort = L10n.tr("Localizable", "Scene.Register.Error.Special.PasswordTooShort") + /// 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). + /// Username is too long (can't be longer than 30 characters) internal static let usernameTooLong = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameTooLong") } } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index acdf89f98..0ab1c70e4 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -43,20 +43,20 @@ tap the link to confirm your account."; "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.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.PasswordTooShort" = "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?"; From 7ad09468e227a0324cde6a8db35069b14787d4da Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 19:30:17 +0800 Subject: [PATCH 03/11] fix: set image missing corner radius --- .../PickServer/TableViewCell/PickServerCell.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 6e36651fb..52133c4ba 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -7,7 +7,7 @@ import UIKit import MastodonSDK -import Kingfisher +import AlamofireImage import Kanna protocol PickServerCellDelegate: class { @@ -362,9 +362,11 @@ extension PickServerCell { guard let serverInfo = server else { return } thumbnailActivityIdicator.startAnimating() + let placeholderImage = UIImage.placeholder(color: .systemFill).af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: true) thumbnailImageView.af.setImage( withURL: URL(string: serverInfo.proxiedThumbnail ?? "")!, - placeholderImage: UIImage.placeholder(color: .systemFill), + placeholderImage: placeholderImage, + filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: thumbnailImageView.frame.size, radius: 3), imageTransition: .crossDissolve(0.33), completion: { [weak self] response in guard let self = self else { return } From 087413009dc75f41e077a3fc51c11f3166be39a7 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 19:30:52 +0800 Subject: [PATCH 04/11] chore: update function signature --- .../Onboarding/Register/MastodonRegisterViewController.swift | 4 ++-- .../Scene/Onboarding/Register/MastodonRegisterViewModel.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index fbb952834..1ce33f475 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -228,7 +228,7 @@ extension MastodonRegisterViewController { domainLabel.text = "@" + viewModel.domain + " " domainLabel.sizeToFit() - passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(isValid: false) + passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: .empty) usernameTextField.rightView = domainLabel usernameTextField.rightViewMode = .always usernameTextField.delegate = self @@ -441,7 +441,7 @@ extension MastodonRegisterViewController { .sink { [weak self] validateState in guard let self = self else { return } self.setTextFieldValidAppearance(self.passwordTextField, validateState: validateState) - self.passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(isValid: validateState == .valid) + self.passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: validateState) } .store(in: &disposeBag) viewModel.passwordErrorPrompt diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 8f03cb124..7089aef7c 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -180,12 +180,12 @@ extension MastodonRegisterViewModel { return NSAttributedString(attachment: attachment) } - static func attributeStringForPassword(isValid: Bool) -> NSAttributedString { + static func attributeStringForPassword(validateState: ValidateState) -> NSAttributedString { let font = UIFont.preferredFont(forTextStyle: .caption1) let attributeString = NSMutableAttributedString() let image = MastodonRegisterViewModel.checkmarkImage(font: font) - attributeString.append(attributedStringImage(with: image, tintColor: isValid ? .black : .clear)) + attributeString.append(attributedStringImage(with: image, tintColor: validateState == .valid ? .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) From 652c286c719a0c6c481fbf342e41b2211962da7d Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 20:45:48 +0800 Subject: [PATCH 05/11] fix: password error prompt layout issue --- .../xcschemes/xcschememanagement.plist | 27 +++---------------- .../MastodonRegisterViewController.swift | 9 ++++--- 2 files changed, 9 insertions(+), 27 deletions(-) diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index bc78dfa4b..747fe7df0 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 10 + 7 Mastodon - RTL.xcscheme_^#shared#^_ @@ -22,31 +22,10 @@ Mastodon.xcscheme_^#shared#^_ orderHint - 1 + 12 SuppressBuildableAutocreation - - DB427DD125BAA00100D1B89D - - primary - - - DB427DE725BAA00100D1B89D - - primary - - - DB427DF225BAA00100D1B89D - - primary - - - DB89B9F525C10FD0008580ED - - primary - - - + diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 1ce33f475..d1ef11c67 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -220,6 +220,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O } extension MastodonRegisterViewController { + override func viewDidLoad() { super.viewDidLoad() @@ -277,7 +278,7 @@ extension MastodonRegisterViewController { passwordErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false stackView.addSubview(passwordErrorPromptLabel) NSLayoutConstraint.activate([ - passwordErrorPromptLabel.topAnchor.constraint(equalTo: passwordTextField.bottomAnchor, constant: 6), + passwordErrorPromptLabel.topAnchor.constraint(equalTo: passwordCheckLabel.bottomAnchor, constant: 2), passwordErrorPromptLabel.leadingAnchor.constraint(equalTo: passwordTextField.leadingAnchor), passwordErrorPromptLabel.trailingAnchor.constraint(equalTo: passwordTextField.trailingAnchor), ]) @@ -552,11 +553,13 @@ extension MastodonRegisterViewController { avatarButton.addTarget(self, action: #selector(MastodonRegisterViewController.avatarButtonPressed(_:)), for: .touchUpInside) signUpButton.addTarget(self, action: #selector(MastodonRegisterViewController.signUpButtonPressed(_:)), for: .touchUpInside) } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - plusIconImageView.layer.cornerRadius = plusIconImageView.frame.width/2 - plusIconImageView.clipsToBounds = true + plusIconImageView.layer.cornerRadius = plusIconImageView.frame.width / 2 + plusIconImageView.layer.masksToBounds = true } + } extension MastodonRegisterViewController: UITextFieldDelegate { From 54c7610c7f4fda93b9b9c237f27f8ee057467f94 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 22:50:20 +0800 Subject: [PATCH 06/11] chore: [WIP] refactor pick server scene with diffable data source --- Mastodon.xcodeproj/project.pbxproj | 26 +- .../xcschemes/xcschememanagement.plist | 2 +- .../Diffiable/Item/CategoryPickerItem.swift | 76 ++++++ Mastodon/Diffiable/Item/Item.swift | 3 +- .../Section/CategoryPickerSection.swift | 31 +++ .../Diffiable/Section/PickServerItem.swift | 67 +++++ .../Diffiable/Section/PickServerSection.swift | 100 +++++++ ...PickServerCategoryCollectionViewCell.swift | 6 - .../MastodonPickServerViewController.swift | 243 ++++++++--------- ...MastodonPickServerViewModel+Diffable.swift | 37 +++ ...rverViewModel+LoadIndexedServerState.swift | 84 ++++++ .../MastodonPickServerViewModel.swift | 249 +++++++++--------- .../PickServerCategoriesCell.swift | 74 +++--- .../TableViewCell/PickServerCell.swift | 176 ++++++------- .../TableViewCell/PickServerSearchCell.swift | 12 +- .../View/PickServerCategoryView.swift | 80 +++--- ...PinBasedAuthenticationViewController.swift | 2 - .../APIService/APIService+Onboarding.swift | 2 +- 18 files changed, 830 insertions(+), 440 deletions(-) create mode 100644 Mastodon/Diffiable/Item/CategoryPickerItem.swift create mode 100644 Mastodon/Diffiable/Section/CategoryPickerSection.swift create mode 100644 Mastodon/Diffiable/Section/PickServerItem.swift create mode 100644 Mastodon/Diffiable/Section/PickServerSection.swift create mode 100644 Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift create mode 100644 Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index f66c14c40..cfe6a3ed0 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -96,6 +96,12 @@ DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; }; + DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */; }; + DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; }; + DB1FD44A25F26CD7004CFCFC /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44925F26CD7004CFCFC /* PickServerItem.swift */; }; + DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */; }; + DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */; }; + DB1FD46025F278AF004CFCFC /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */; }; DB2B3ABC25E37E15007045F9 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */; }; DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; @@ -310,6 +316,12 @@ DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; + DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+LoadIndexedServerState.swift"; sourceTree = ""; }; + DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSection.swift; sourceTree = ""; }; + DB1FD44925F26CD7004CFCFC /* PickServerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PickServerItem.swift; path = Mastodon/Diffiable/Section/PickServerItem.swift; sourceTree = SOURCE_ROOT; }; + DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+Diffable.swift"; sourceTree = ""; }; + DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerItem.swift; sourceTree = ""; }; + DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = ""; }; DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; @@ -456,11 +468,13 @@ 0FAA102525E1125D0017CCDE /* PickServer */ = { isa = PBXGroup; children = ( - 0FB3D31825E525DE00AAD544 /* CollectionViewCell */, 0FB3D30D25E525C000AAD544 /* View */, + 0FB3D31825E525DE00AAD544 /* CollectionViewCell */, 0FB3D2FC25E4CB4B00AAD544 /* TableViewCell */, 0FAA102625E1126A0017CCDE /* MastodonPickServerViewController.swift */, 0FB3D2F625E4C24D00AAD544 /* MastodonPickServerViewModel.swift */, + DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */, + DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */, ); path = PickServer; sourceTree = ""; @@ -642,6 +656,8 @@ isa = PBXGroup; children = ( 2D76319E25C1521200929FB9 /* StatusSection.swift */, + DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */, + DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */, ); path = Section; sourceTree = ""; @@ -683,6 +699,8 @@ isa = PBXGroup; children = ( 2D7631B225C159F700929FB9 /* Item.swift */, + DB1FD44925F26CD7004CFCFC /* PickServerItem.swift */, + DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, ); path = Item; sourceTree = ""; @@ -1464,8 +1482,10 @@ 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, + DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */, 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, + DB1FD44A25F26CD7004CFCFC /* PickServerItem.swift in Sources */, DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */, 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, @@ -1511,12 +1531,14 @@ 2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, + DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, + DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */, @@ -1533,10 +1555,12 @@ DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, + DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, + DB1FD46025F278AF004CFCFC /* CategoryPickerSection.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 747fe7df0..60ccd3d87 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -22,7 +22,7 @@ Mastodon.xcscheme_^#shared#^_ orderHint - 12 + 8 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Item/CategoryPickerItem.swift b/Mastodon/Diffiable/Item/CategoryPickerItem.swift new file mode 100644 index 000000000..9a8f8bd6c --- /dev/null +++ b/Mastodon/Diffiable/Item/CategoryPickerItem.swift @@ -0,0 +1,76 @@ +// +// CategoryPickerItem.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import Foundation +import MastodonSDK + +enum CategoryPickerItem { + case all + case category(category: Mastodon.Entity.Category) +} + +extension CategoryPickerItem { + var title: String { + switch self { + case .all: + return L10n.Scene.ServerPicker.Button.Category.all + case .category(let category): + switch category.category { + case .academia: + return "📚" + case .activism: + return "✊" + case .food: + return "🍕" + case .furry: + return "🦁" + case .games: + return "🕹" + case .general: + return "💬" + case .journalism: + return "📰" + case .lgbt: + return "🏳️‍🌈" + case .regional: + return "📍" + case .art: + return "🎨" + case .music: + return "🎼" + case .tech: + return "📱" + case ._other: + return "❓" + } + } + } +} + +extension CategoryPickerItem: Equatable { + static func == (lhs: CategoryPickerItem, rhs: CategoryPickerItem) -> Bool { + switch (lhs, rhs) { + case (.all, .all): + return true + case (.category(let categoryLeft), .category(let categoryRight)): + return categoryLeft.category.rawValue == categoryRight.category.rawValue + default: + return false + } + } +} + +extension CategoryPickerItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .all: + hasher.combine(String(describing: CategoryPickerItem.all.self)) + case .category(let category): + hasher.combine(category.category.rawValue) + } + } +} diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index c6a182b4d..818c33ea8 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -30,7 +30,7 @@ protocol StatusContentWarningAttribute { } extension Item { - class StatusTimelineAttribute: Hashable, StatusContentWarningAttribute { + class StatusTimelineAttribute: Equatable, Hashable, StatusContentWarningAttribute { var isStatusTextSensitive: Bool var isStatusSensitive: Bool @@ -51,7 +51,6 @@ extension Item { hasher.combine(isStatusTextSensitive) hasher.combine(isStatusSensitive) } - } } diff --git a/Mastodon/Diffiable/Section/CategoryPickerSection.swift b/Mastodon/Diffiable/Section/CategoryPickerSection.swift new file mode 100644 index 000000000..5582cb531 --- /dev/null +++ b/Mastodon/Diffiable/Section/CategoryPickerSection.swift @@ -0,0 +1,31 @@ +// +// CategoryPickerSection.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import UIKit + +enum CategoryPickerSection: Equatable, Hashable { + case main +} + +extension CategoryPickerSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView, + dependency: NeedsDependency + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell + switch item { + case .all: + cell.categoryView.titleLabel.font = .systemFont(ofSize: 17) + case .category: + cell.categoryView.titleLabel.font = .systemFont(ofSize: 28) + } + cell.categoryView.titleLabel.text = item.title + return cell + } + } +} diff --git a/Mastodon/Diffiable/Section/PickServerItem.swift b/Mastodon/Diffiable/Section/PickServerItem.swift new file mode 100644 index 000000000..09ca72c32 --- /dev/null +++ b/Mastodon/Diffiable/Section/PickServerItem.swift @@ -0,0 +1,67 @@ +// +// PickServerItem.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import Foundation +import MastodonSDK + +/// Note: update Equatable when change case +enum PickServerItem { + case header + case categoryPicker(items: [CategoryPickerItem]) + case search + case server(server: Mastodon.Entity.Server, attribute: ServerItemAttribute) +} + +extension PickServerItem { + final class ServerItemAttribute: Equatable, Hashable { + var isExpand: Bool + + init(isExpand: Bool) { + self.isExpand = isExpand + } + + static func == (lhs: PickServerItem.ServerItemAttribute, rhs: PickServerItem.ServerItemAttribute) -> Bool { + return lhs.isExpand == rhs.isExpand + } + + func hash(into hasher: inout Hasher) { + hasher.combine(isExpand) + } + } +} + +extension PickServerItem: Equatable { + static func == (lhs: PickServerItem, rhs: PickServerItem) -> Bool { + switch (lhs, rhs) { + case (.header, .header): + return true + case (.categoryPicker(let itemsLeft), .categoryPicker(let itemsRight)): + return itemsLeft == itemsRight + case (.search, .search): + return true + case (.server(let serverLeft, _), .server(let serverRight, _)): + return serverLeft.domain == serverRight.domain + default: + return false + } + } +} + +extension PickServerItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .header: + hasher.combine(String(describing: PickServerItem.header.self)) + case .categoryPicker(let items): + hasher.combine(items) + case .search: + hasher.combine(String(describing: PickServerItem.search.self)) + case .server(let server, _): + hasher.combine(server.domain) + } + } +} diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/PickServerSection.swift new file mode 100644 index 000000000..d76cf4c66 --- /dev/null +++ b/Mastodon/Diffiable/Section/PickServerSection.swift @@ -0,0 +1,100 @@ +// +// PickServerSection.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import UIKit +import MastodonSDK +import Kanna + +enum PickServerSection: Equatable, Hashable { + case header + case category + case search + case servers +} + +extension PickServerSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + pickServerSearchCellDelegate: PickServerSearchCellDelegate, + pickServerCellDelegate: PickServerCellDelegate + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { [weak pickServerSearchCellDelegate, weak pickServerCellDelegate] tableView, indexPath, item -> UITableViewCell? in + switch item { + case .header: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell + return cell + case .categoryPicker(let items): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell + cell.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource( + for: cell.collectionView, + dependency: dependency + ) + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(items, toSection: .main) + cell.diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + return cell + case .search: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerSearchCell.self), for: indexPath) as! PickServerSearchCell + cell.delegate = pickServerSearchCellDelegate + return cell + case .server(let server, let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell + PickServerSection.configure(cell: cell, server: server, attribute: attribute) + cell.delegate = pickServerCellDelegate + // cell.server = server + // if expandServerDomainSet.contains(server.domain) { + // cell.mode = .expand + // } else { + // cell.mode = .collapse + // } +// if server == viewModel.selectedServer.value { +// tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) +// } else { +// tableView.deselectRow(at: indexPath, animated: false) +// } +// +// cell.delegate = self + return cell + } + } + } +} + +extension PickServerSection { + + static func configure(cell: PickServerCell, server: Mastodon.Entity.Server, attribute: PickServerItem.ServerItemAttribute) { + cell.domainLabel.text = server.domain + cell.descriptionLabel.text = { + guard let html = try? HTML(html: server.description, encoding: .utf8) else { + return server.description + } + + return html.text ?? server.description + }() + cell.langValueLabel.text = server.language.uppercased() + cell.usersValueLabel.text = parseUsersCount(server.totalUsers) + cell.categoryValueLabel.text = server.category.uppercased() + + cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse) +// UIView.animate(withDuration: 0.33) { +// cell.expandBox.layoutIfNeeded() +// } + } + + private static func parseUsersCount(_ usersCount: Int) -> String { + switch usersCount { + case 0..<1000: + return "\(usersCount)" + default: + let usersCountInThousand = Float(usersCount) / 1000.0 + return String(format: "%.1fK", usersCountInThousand) + } + } + +} diff --git a/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift b/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift index 1f02baad6..5008ad3a3 100644 --- a/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift @@ -9,12 +9,6 @@ import UIKit class PickServerCategoryCollectionViewCell: UICollectionViewCell { - var category: MastodonPickServerViewModel.Category? { - didSet { - categoryView.category = category - } - } - var categoryView: PickServerCategoryView = { let view = PickServerCategoryView() view.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 909d6ec7b..06e75c0cd 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -5,10 +5,9 @@ // Created by BradGao on 2021/2/20. // +import os.log import UIKit import Combine -import OSLog -import MastodonSDK final class MastodonPickServerViewController: UIViewController, NeedsDependency { @@ -22,13 +21,6 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency private var isAuthenticating = CurrentValueSubject(false) private var expandServerDomainSet = Set() - - enum Section: CaseIterable { - case title - case categories - case search - case serverList - } let tableView: UITableView = { let tableView = ControlContainableTableView() @@ -95,31 +87,16 @@ extension MastodonPickServerViewController { nextStepButton.addTarget(self, action: #selector(nextStepButtonDidClicked(_:)), for: .touchUpInside) tableView.delegate = self - tableView.dataSource = self - - viewModel - .searchedServers - .receive(on: DispatchQueue.main) - .sink { _ in - - } receiveValue: { [weak self] servers in - self?.tableView.beginUpdates() - self?.tableView.reloadSections(IndexSet(integer: 3), with: .automatic) - self?.tableView.endUpdates() - if let selectedServer = self?.viewModel.selectedServer.value, servers.contains(selectedServer) { - // Previously selected server is still in the list, do nothing - } else { - // Previously selected server is not in the updated list, reset the selectedServer's value - self?.viewModel.selectedServer.send(nil) - } - } - .store(in: &disposeBag) + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + pickServerSearchCellDelegate: self, + pickServerCellDelegate: self + ) viewModel .selectedServer - .map { - $0 != nil - } + .map { $0 != nil } .assign(to: \.isEnabled, on: nextStepButton) .store(in: &disposeBag) @@ -165,8 +142,6 @@ extension MastodonPickServerViewController { isAuthenticating ? self.nextStepButton.showLoading() : self.nextStepButton.stopLoading() } .store(in: &disposeBag) - - viewModel.fetchAllServers() } @objc @@ -292,142 +267,150 @@ extension MastodonPickServerViewController { } extension MastodonPickServerViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + return UIView() + } + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - let category = Section.allCases[section] - switch category { - case .title: + guard let diffableDataSource = viewModel.diffableDataSource else { return 0 } + let sections = diffableDataSource.snapshot().sectionIdentifiers + let section = sections[section] + switch section { + case .header: return 20 - case .categories: + case .category: // Since category view has a blur shadow effect, its height need to be large than the actual height, // Thus we reduce the section header's height by 10, and make the category cell height 60+20(10 inset for top and bottom) return 10 case .search: // Same reason as above return 10 - case .serverList: + case .servers: return 0 } } func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } + guard case let .server(server) = item else { return nil } + if tableView.indexPathForSelectedRow == indexPath { tableView.deselectRow(at: indexPath, animated: false) viewModel.selectedServer.send(nil) return nil } + return indexPath } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard case let .server(server, _) = item else { return } tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) - viewModel.selectedServer.send(viewModel.searchedServers.value[indexPath.row]) + viewModel.selectedServer.send(server) } - + func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) viewModel.selectedServer.send(nil) } + } -extension MastodonPickServerViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - return UIView() - } - - func numberOfSections(in tableView: UITableView) -> Int { - return Self.Section.allCases.count - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - let section = Self.Section.allCases[section] - switch section { - case .title, - .categories, - .search: - return 1 - case .serverList: - return viewModel.searchedServers.value.count - } - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - let section = Self.Section.allCases[indexPath.section] - switch section { - case .title: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell - return cell - case .categories: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell - cell.dataSource = self - cell.delegate = self - return cell - case .search: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerSearchCell.self), for: indexPath) as! PickServerSearchCell - cell.delegate = self - return cell - case .serverList: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell - let server = viewModel.searchedServers.value[indexPath.row] - cell.server = server - if expandServerDomainSet.contains(server.domain) { - cell.mode = .expand - } else { - cell.mode = .collapse - } - if server == viewModel.selectedServer.value { - tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) - } else { - tableView.deselectRow(at: indexPath, animated: false) - } - - cell.delegate = self - return cell - } - } -} +//extension MastodonPickServerViewController: UITableViewDataSource { -extension MastodonPickServerViewController: PickServerCellDelegate { - func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) { - if newMode == .collapse { - expandServerDomainSet.remove(server.domain) - } else { - expandServerDomainSet.insert(server.domain) - } - - tableView.beginUpdates() - updates() - tableView.endUpdates() - - if newMode == .expand, let modeChangeIndex = self.viewModel.searchedServers.value.firstIndex(where: { $0 == server }), self.tableView.indexPathsForVisibleRows?.last?.row == modeChangeIndex { - self.tableView.scrollToRow(at: IndexPath(row: modeChangeIndex, section: 3), at: .bottom, animated: true) - } - } -} +// func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { +// +// let section = Self.Section.allCases[indexPath.section] +// switch section { +// case .title: +// +// case .categories: +// +// case .search: +// +// case .serverList: +// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell +// let server = viewModel.servers.value[indexPath.row] +// // cell.server = server +//// if expandServerDomainSet.contains(server.domain) { +//// cell.mode = .expand +//// } else { +//// cell.mode = .collapse +//// } +// if server == viewModel.selectedServer.value { +// tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) +// } else { +// tableView.deselectRow(at: indexPath, animated: false) +// } +// +// cell.delegate = self +// return cell +// } +// } +//} +// MARK: - PickServerSearchCellDelegate extension MastodonPickServerViewController: PickServerSearchCellDelegate { - func pickServerSearchCell(didChange searchText: String?) { + func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) { viewModel.searchText.send(searchText) } } -extension MastodonPickServerViewController: PickServerCategoriesDataSource, PickServerCategoriesDelegate { - func numberOfCategories() -> Int { - return viewModel.categories.count - } - - func category(at index: Int) -> MastodonPickServerViewModel.Category { - return viewModel.categories[index] - } - - func selectedIndex() -> Int { - return viewModel.selectCategoryIndex.value - } - - func pickServerCategoriesCell(didSelect index: Int) { - return viewModel.selectCategoryIndex.send(index) +// MARK: - PickServerCellDelegate +extension MastodonPickServerViewController: PickServerCellDelegate { + func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard case let .server(_, attribute) = item else { return } + + attribute.isExpand.toggle() + tableView.beginUpdates() + cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse) + tableView.endUpdates() + + // expand attribute change do not needs apply snapshot to diffable data source + // but should I block the viewModel data binding during tableView.beginUpdates/endUpdates? } + +// func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) { +// if newMode == .collapse { +// expandServerDomainSet.remove(server.domain) +// } else { +// expandServerDomainSet.insert(server.domain) +// } +// +// tableView.beginUpdates() +// updates() +// tableView.endUpdates() +// +// if newMode == .expand, let modeChangeIndex = self.viewModel.servers.value.firstIndex(where: { $0 == server }), self.tableView.indexPathsForVisibleRows?.last?.row == modeChangeIndex { +// self.tableView.scrollToRow(at: IndexPath(row: modeChangeIndex, section: 3), at: .bottom, animated: true) +// } +// } } +//extension MastodonPickServerViewController: PickServerCategoriesDataSource, PickServerCategoriesCellDelegate { +// func numberOfCategories() -> Int { +// return viewModel.categories.count +// } +// +// func category(at index: Int) -> MastodonPickServerViewModel.Category { +// return viewModel.categories[index] +// } +// +// func selectedIndex() -> Int { +// return viewModel.selectCategoryIndex.value +// } +// +// func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, didSelect index: Int) { +// return viewModel.selectCategoryIndex.send(index) +// } +//} + // MARK: - OnboardingViewControllerAppearance extension MastodonPickServerViewController: OnboardingViewControllerAppearance { } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift new file mode 100644 index 000000000..506cbbc48 --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift @@ -0,0 +1,37 @@ +// +// MastodonPickServerViewController+Diffable.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import UIKit + +extension MastodonPickServerViewModel { + + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + pickServerSearchCellDelegate: PickServerSearchCellDelegate, + pickServerCellDelegate: PickServerCellDelegate + ) { + diffableDataSource = PickServerSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + pickServerSearchCellDelegate: pickServerSearchCellDelegate, + pickServerCellDelegate: pickServerCellDelegate + ) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.header, .category, .search, .servers]) + snapshot.appendItems([.header], toSection: .header) + snapshot.appendItems([.categoryPicker(items: categoryPickerItems)], toSection: .category) + snapshot.appendItems([.search], toSection: .search) + diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + + loadIndexedServerStateMachine.enter(LoadIndexedServerState.Loading.self) + } + +} + + diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift new file mode 100644 index 000000000..172973b5c --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift @@ -0,0 +1,84 @@ +// +// MastodonPickServerViewModel+LoadIndexedServerState.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension MastodonPickServerViewModel { + class LoadIndexedServerState: GKState { + weak var viewModel: MastodonPickServerViewModel? + + init(viewModel: MastodonPickServerViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension MastodonPickServerViewModel.LoadIndexedServerState { + + class Initial: MastodonPickServerViewModel.LoadIndexedServerState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: MastodonPickServerViewModel.LoadIndexedServerState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } + viewModel.context.apiService.servers(language: nil, category: nil) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + break + case .finished: + break + } + } receiveValue: { [weak self] response in + guard let _ = self else { return } + stateMachine.enter(Idle.self) + viewModel.indexedServers.value = response.value + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: MastodonPickServerViewModel.LoadIndexedServerState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let self = self else { return } + stateMachine.enter(Loading.self) + } + } + } + + class Idle: MastodonPickServerViewModel.LoadIndexedServerState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + } + +} diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index a3b2a8768..2e764f9b1 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -5,9 +5,10 @@ // Created by BradGao on 2021/2/23. // +import os.log import UIKit -import OSLog import Combine +import GameplayKit import MastodonSDK import CoreDataStack @@ -17,69 +18,41 @@ class MastodonPickServerViewModel: NSObject { case signIn } - enum Category { - // `all` means search for all categories - case all - // `some` means search for specific category - case some(Mastodon.Entity.Category) - - var title: String { - switch self { - case .all: - return L10n.Scene.ServerPicker.Button.Category.all - case .some(let masCategory): - // TODO: Use emoji as placeholders - switch masCategory.category { - case .academia: - return "📚" - case .activism: - return "✊" - case .food: - return "🍕" - case .furry: - return "🦁" - case .games: - return "🕹" - case .general: - return "GE" - case .journalism: - return "📰" - case .lgbt: - return "🏳️‍🌈" - case .regional: - return "📍" - case .art: - return "🎨" - case .music: - return "🎼" - case .tech: - return "📱" - case ._other: - return "❓" - } - } - } - } - + var disposeBag = Set() + + // input let mode: PickServerMode let context: AppContext - - var categories = [Category]() + var categoryPickerItems: [CategoryPickerItem] = { + var items: [CategoryPickerItem] = [] + items.append(.all) + items.append(contentsOf: APIService.stubCategories().map { CategoryPickerItem.category(category: $0) }) + return items + }() let selectCategoryIndex = CurrentValueSubject(0) - let searchText = CurrentValueSubject(nil) + let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) + let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Instance], Never>([]) - let allServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) - let searchedServers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([]) + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var loadIndexedServerStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadIndexedServerState.Initial(viewModel: self), + LoadIndexedServerState.Loading(viewModel: self), + LoadIndexedServerState.Fail(viewModel: self), + LoadIndexedServerState.Idle(viewModel: self), + ]) + stateMachine.enter(LoadIndexedServerState.Initial.self) + return stateMachine + }() + let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([]) let selectedServer = CurrentValueSubject(nil) let error = PassthroughSubject() let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>() - - private var disposeBag = Set() - - weak var tableView: UITableView? - + var mastodonPinBasedAuthenticationViewController: UIViewController? init(context: AppContext, mode: PickServerMode) { @@ -91,83 +64,115 @@ class MastodonPickServerViewModel: NSObject { } private func configure() { - let masCategories = context.apiService.stubCategories() - categories.append(.all) - categories.append(contentsOf: masCategories.map { Category.some($0) }) - Publishers.CombineLatest3( - selectCategoryIndex, - searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(), - allServers + indexedServers, + unindexedServers, + searchText ) - .flatMap { [weak self] (selectCategoryIndex, searchText, allServers) -> AnyPublisher, Never> in - guard let self = self else { return Just(Result.success([])).eraseToAnyPublisher() } + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] indexedServers, unindexedServers, searchText in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } - // 1. Search from the servers recorded in joinmastodon.org - let searchedServersFromAPI = self.searchServersFromAPI(category: self.categories[selectCategoryIndex], searchText: searchText, allServers: allServers) - if !searchedServersFromAPI.isEmpty { - // If found servers, just return - return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher() - } - // 2. No server found in the recorded list, check if searchText is a valid mastodon server domain - if let toSearchText = searchText, !toSearchText.isEmpty, let _ = URL(string: "https://\(toSearchText)") { - return self.context.apiService.instance(domain: toSearchText) - .map { return Result.success([Mastodon.Entity.Server(instance: $0.value)]) } - .catch({ error -> Just> in - return Just(Result.failure(error)) - }) - .eraseToAnyPublisher() - } - return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher() - } - .sink { _ in - - } receiveValue: { [weak self] result in - switch result { - case .success(let servers): - self?.searchedServers.send(servers) - case .failure(let error): - // TODO: What should be presented when user inputs invalid search text? - self?.searchedServers.send([]) + let oldSnapshot = diffableDataSource.snapshot() + var oldSnapshotServerItemAttributeDict: [String : PickServerItem.ServerItemAttribute] = [:] + for item in oldSnapshot.itemIdentifiers { + guard case let .server(server, attribute) = item else { continue } + oldSnapshotServerItemAttributeDict[server.domain] = attribute } - } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.header, .category, .search, .servers]) + snapshot.appendItems([.header], toSection: .header) + snapshot.appendItems([.categoryPicker(items: self.categoryPickerItems)], toSection: .category) + snapshot.appendItems([.search], toSection: .search) + // TODO: handle filter + var serverItems: [PickServerItem] = [] + for server in indexedServers { + let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isExpand: false) + let item = PickServerItem.server(server: server, attribute: attribute) + serverItems.append(item) + } + snapshot.appendItems(serverItems, toSection: .servers) + + diffableDataSource.apply(snapshot) + }) .store(in: &disposeBag) + + +// Publishers.CombineLatest3( +// selectCategoryIndex, +// searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(), +// indexedServers +// ) +// .flatMap { [weak self] (selectCategoryIndex, searchText, allServers) -> AnyPublisher, Never> in +// guard let self = self else { return Just(Result.success([])).eraseToAnyPublisher() } +// +// // 1. Search from the servers recorded in joinmastodon.org +// let searchedServersFromAPI = self.searchServersFromAPI(category: self.categories[selectCategoryIndex], searchText: searchText, allServers: allServers) +// if !searchedServersFromAPI.isEmpty { +// // If found servers, just return +// return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher() +// } +// // 2. No server found in the recorded list, check if searchText is a valid mastodon server domain +// if let toSearchText = searchText, !toSearchText.isEmpty, let _ = URL(string: "https://\(toSearchText)") { +// return self.context.apiService.instance(domain: toSearchText) +// .map { return Result.success([Mastodon.Entity.Server(instance: $0.value)]) } +// .catch({ error -> Just> in +// return Just(Result.failure(error)) +// }) +// .eraseToAnyPublisher() +// } +// return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher() +// } +// .sink { _ in +// +// } receiveValue: { [weak self] result in +// switch result { +// case .success(let servers): +// self?.servers.send(servers) +// case .failure(let error): +// // TODO: What should be presented when user inputs invalid search text? +// self?.servers.send([]) +// } +// +// } +// .store(in: &disposeBag) } - func fetchAllServers() { - context.apiService.servers(language: nil, category: nil) - .sink { completion in - // TODO: Add a reload button when fails to fetch servers initially - } receiveValue: { [weak self] result in - self?.allServers.send(result.value) - } - .store(in: &disposeBag) - - } - - private func searchServersFromAPI(category: Category, searchText: String?, allServers: [Mastodon.Entity.Server]) -> [Mastodon.Entity.Server] { - return allServers - // 1. Filter the category - .filter { - switch category { - case .all: - return true - case .some(let masCategory): - return $0.category.caseInsensitiveCompare(masCategory.category.rawValue) == .orderedSame - } - } - // 2. Filter the searchText - .filter { - if let searchText = searchText, !searchText.isEmpty { - return $0.domain.lowercased().contains(searchText.lowercased()) - } else { - return true - } - } - } +// func fetchAllServers() { +// context.apiService.servers(language: nil, category: nil) +// .sink { completion in +// // TODO: Add a reload button when fails to fetch servers initially +// } receiveValue: { [weak self] result in +// self?.indexedServers.send(result.value) +// } +// .store(in: &disposeBag) +// +// } +// +// private func searchServersFromAPI(category: Category, searchText: String?, allServers: [Mastodon.Entity.Server]) -> [Mastodon.Entity.Server] { +// return allServers +// // 1. Filter the category +// .filter { +// switch category { +// case .all: +// return true +// case .some(let masCategory): +// return $0.category.caseInsensitiveCompare(masCategory.category.rawValue) == .orderedSame +// } +// } +// // 2. Filter the searchText +// .filter { +// if let searchText = searchText, !searchText.isEmpty { +// return $0.domain.lowercased().contains(searchText.lowercased()) +// } else { +// return true +// } +// } +// } } // MARK: - SignIn methods & structs diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift index 8f66e9847..1fd366555 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift @@ -5,24 +5,20 @@ // Created by BradGao on 2021/2/23. // +import os.log import UIKit import MastodonSDK -protocol PickServerCategoriesDataSource: class { - func numberOfCategories() -> Int - func category(at index: Int) -> MastodonPickServerViewModel.Category - func selectedIndex() -> Int -} - -protocol PickServerCategoriesDelegate: class { - func pickServerCategoriesCell(didSelect index: Int) +protocol PickServerCategoriesCellDelegate: class { + func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) } final class PickServerCategoriesCell: UITableViewCell { - weak var dataSource: PickServerCategoriesDataSource! - weak var delegate: PickServerCategoriesDelegate! + weak var delegate: PickServerCategoriesCellDelegate? + var diffableDataSource: UICollectionViewDiffableDataSource? + let metricView = UIView() let collectionView: UICollectionView = { @@ -38,6 +34,12 @@ final class PickServerCategoriesCell: UITableViewCell { return view }() + override func prepareForReuse() { + super.prepareForReuse() + + delegate = nil + } + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) _init() @@ -75,7 +77,6 @@ extension PickServerCategoriesCell { ]) collectionView.delegate = self - collectionView.dataSource = self } override func layoutSubviews() { @@ -86,45 +87,46 @@ extension PickServerCategoriesCell { } +// MARK: - UICollectionViewDelegateFlowLayout extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) - delegate.pickServerCategoriesCell(didSelect: indexPath.row) +// delegate.pickServerCategoriesCell(self, didSelect: indexPath.row) } - + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { layoutIfNeeded() return UIEdgeInsets(top: 0, left: metricView.frame.minX - collectionView.frame.minX, bottom: 0, right: collectionView.frame.maxX - metricView.frame.maxX) } - + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { return 16 } - + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { return CGSize(width: 60, height: 80) } } -extension PickServerCategoriesCell: UICollectionViewDataSource { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return dataSource.numberOfCategories() - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let category = dataSource.category(at: indexPath.row) - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell - cell.category = category - - // Select the default category by default - if indexPath.row == dataSource.selectedIndex() { - // Use `[]` as the scrollPosition to avoid contentOffset change - collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) - cell.isSelected = true - } - return cell - } - - -} +//extension PickServerCategoriesCell: UICollectionViewDataSource { +// func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { +// return dataSource.numberOfCategories() +// } +// +// func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { +// let category = dataSource.category(at: indexPath.row) +// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell +// cell.category = category +// +// // Select the default category by default +// if indexPath.row == dataSource.selectedIndex() { +// // Use `[]` as the scrollPosition to avoid contentOffset change +// collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) +// cell.isSelected = true +// } +// return cell +// } +// +// +//} diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 52133c4ba..cadfc74ba 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -5,25 +5,21 @@ // Created by BradGao on 2021/2/24. // +import os.log import UIKit import MastodonSDK import AlamofireImage import Kanna protocol PickServerCellDelegate: class { - func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) + func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) } class PickServerCell: UITableViewCell { weak var delegate: PickServerCellDelegate? - enum Mode { - case collapse - case expand - } - - private var containerView: UIView = { + let containerView: UIView = { let view = UIView() view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16) view.backgroundColor = Asset.Colors.lightWhite.color @@ -31,7 +27,7 @@ class PickServerCell: UITableViewCell { return view }() - private var domainLabel: UILabel = { + let domainLabel: UILabel = { let label = UILabel() label.font = .preferredFont(forTextStyle: .headline) label.textColor = Asset.Colors.lightDarkGray.color @@ -40,7 +36,7 @@ class PickServerCell: UITableViewCell { return label }() - private var checkbox: UIImageView = { + let checkbox: UIImageView = { let imageView = UIImageView() imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) imageView.tintColor = Asset.Colors.lightSecondaryText.color @@ -49,7 +45,7 @@ class PickServerCell: UITableViewCell { return imageView }() - private var descriptionLabel: UILabel = { + let descriptionLabel: UILabel = { let label = UILabel() label.font = .preferredFont(forTextStyle: .subheadline) label.numberOfLines = 0 @@ -59,9 +55,9 @@ class PickServerCell: UITableViewCell { return label }() - private let thumbnailActivityIdicator = UIActivityIndicatorView(style: .medium) + let thumbnailActivityIdicator = UIActivityIndicatorView(style: .medium) - private var thumbnailImageView: UIImageView = { + let thumbnailImageView: UIImageView = { let imageView = UIImageView() imageView.clipsToBounds = true imageView.contentMode = .scaleAspectFill @@ -69,7 +65,7 @@ class PickServerCell: UITableViewCell { return imageView }() - private var infoStackView: UIStackView = { + let infoStackView: UIStackView = { let stackView = UIStackView() stackView.axis = .horizontal stackView.alignment = .fill @@ -78,14 +74,14 @@ class PickServerCell: UITableViewCell { return stackView }() - private var expandBox: UIView = { + let expandBox: UIView = { let view = UIView() view.backgroundColor = .clear view.translatesAutoresizingMaskIntoConstraints = false return view }() - private var expandButton: UIButton = { + let expandButton: UIButton = { let button = UIButton(type: .custom) button.setTitle(L10n.Scene.ServerPicker.Button.seemore, for: .normal) button.setTitle(L10n.Scene.ServerPicker.Button.seeless, for: .selected) @@ -95,14 +91,14 @@ class PickServerCell: UITableViewCell { return button }() - private var seperator: UIView = { + let seperator: UIView = { let view = UIView() view.backgroundColor = Asset.Colors.lightBackground.color view.translatesAutoresizingMaskIntoConstraints = false return view }() - private var langValueLabel: UILabel = { + let langValueLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.lightDarkGray.color label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) @@ -112,7 +108,7 @@ class PickServerCell: UITableViewCell { return label }() - private var usersValueLabel: UILabel = { + let usersValueLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.lightDarkGray.color label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) @@ -122,7 +118,7 @@ class PickServerCell: UITableViewCell { return label }() - private var categoryValueLabel: UILabel = { + let categoryValueLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.lightDarkGray.color label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) @@ -132,7 +128,7 @@ class PickServerCell: UITableViewCell { return label }() - private var langTitleLabel: UILabel = { + let langTitleLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.lightDarkGray.color label.font = .preferredFont(forTextStyle: .caption2) @@ -143,7 +139,7 @@ class PickServerCell: UITableViewCell { return label }() - private var usersTitleLabel: UILabel = { + let usersTitleLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.lightDarkGray.color label.font = .preferredFont(forTextStyle: .caption2) @@ -154,7 +150,7 @@ class PickServerCell: UITableViewCell { return label }() - private var categoryTitleLabel: UILabel = { + let categoryTitleLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.lightDarkGray.color label.font = .preferredFont(forTextStyle: .caption2) @@ -168,22 +164,12 @@ class PickServerCell: UITableViewCell { private var collapseConstraints: [NSLayoutConstraint] = [] private var expandConstraints: [NSLayoutConstraint] = [] - var mode: PickServerCell.Mode = .collapse { - didSet { - updateMode() - } - } - - var server: Mastodon.Entity.Server? { - didSet { - updateServerInfo() - } - } - override func prepareForReuse() { super.prepareForReuse() + thumbnailImageView.isHidden = false thumbnailImageView.af.cancelImageRequest() + thumbnailActivityIdicator.stopAnimating() } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -195,6 +181,7 @@ class PickServerCell: UITableViewCell { super.init(coder: coder) _init() } + } // MARK: - Methods to configure appearance @@ -224,7 +211,7 @@ extension PickServerCell { infoStackView.addArrangedSubview(verticalInfoStackViewUsers) infoStackView.addArrangedSubview(verticalInfoStackViewCategory) - let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required) + let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required - 1) collapseConstraints.append(expandButtonTopConstraintInCollapse) let expandButtonTopConstraintInExpand = expandButton.topAnchor.constraint(equalTo: expandBox.bottomAnchor, constant: 8).priority(.defaultHigh) @@ -292,7 +279,7 @@ extension PickServerCell { descriptionLabel.setContentHuggingPriority(.required - 2, for: .vertical) descriptionLabel.setContentCompressionResistancePriority(.required - 2, for: .vertical) - expandButton.addTarget(self, action: #selector(expandButtonDidClicked(_:)), for: .touchUpInside) + expandButton.addTarget(self, action: #selector(expandButtonDidPressed(_:)), for: .touchUpInside) } private func makeVerticalInfoStackView(arrangedView: UIView...) -> UIStackView { @@ -305,8 +292,31 @@ extension PickServerCell { arrangedView.forEach { stackView.addArrangedSubview($0) } return stackView } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + if selected { + checkbox.image = UIImage(systemName: "checkmark.circle.fill") + } else { + checkbox.image = UIImage(systemName: "circle") + } + } - private func updateMode() { + @objc + private func expandButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.pickServerCell(self, expandButtonPressed: sender) + } +} + +extension PickServerCell { + + enum ExpandMode { + case collapse + case expand + } + + func updateExpandMode(mode: ExpandMode) { switch mode { case .collapse: expandBox.isHidden = true @@ -318,73 +328,35 @@ extension PickServerCell { expandButton.isSelected = true NSLayoutConstraint.activate(expandConstraints) NSLayoutConstraint.deactivate(collapseConstraints) - - updateThumbnail() } } - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - if selected { - checkbox.image = UIImage(systemName: "checkmark.circle.fill") - } else { - checkbox.image = UIImage(systemName: "circle") - } - } +// private func updateThumbnail() { +// guard let serverInfo = server, +// let proxiedThumbnail = serverInfo.proxiedThumbnail, +// let url = URL(string: proxiedThumbnail) else { +// thumbnailImageView.isHidden = true +// thumbnailActivityIdicator.stopAnimating() +// return +// } +// +// thumbnailImageView.isHidden = false +// thumbnailActivityIdicator.startAnimating() +// +// let placeholderImage = UIImage.placeholder(color: .systemFill).af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: true) +// thumbnailImageView.af.setImage( +// withURL: url, +// placeholderImage: placeholderImage, +// filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: thumbnailImageView.frame.size, radius: 3), +// imageTransition: .crossDissolve(0.33), +// completion: { [weak self] response in +// guard let self = self else { return } +// switch response.result { +// case .success, .failure: +// self.thumbnailActivityIdicator.stopAnimating() +// } +// } +// ) +// } - @objc - private func expandButtonDidClicked(_ sender: UIButton) { - let newMode: Mode = mode == .collapse ? .expand : .collapse - delegate?.pickServerCell(modeChange: server!, newMode: newMode, updates: { [weak self] in - self?.mode = newMode - }) - } -} - -// MARK: - Methods to update data -extension PickServerCell { - private func updateServerInfo() { - guard let serverInfo = server else { return } - domainLabel.text = serverInfo.domain - descriptionLabel.text = { - guard let html = try? HTML(html: serverInfo.description, encoding: .utf8) else { - return serverInfo.description - } - - return html.text ?? serverInfo.description - }() - 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() - let placeholderImage = UIImage.placeholder(color: .systemFill).af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: true) - thumbnailImageView.af.setImage( - withURL: URL(string: serverInfo.proxiedThumbnail ?? "")!, - placeholderImage: placeholderImage, - filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: thumbnailImageView.frame.size, radius: 3), - 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: - return "\(usersCount)" - default: - let usersCountInThousand = Float(usersCount) / 1000.0 - return String(format: "%.1fK", usersCountInThousand) - } - } } diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift index 6df8affa2..2de66fa65 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift @@ -8,7 +8,7 @@ import UIKit protocol PickServerSearchCellDelegate: class { - func pickServerSearchCell(didChange searchText: String?) + func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) } class PickServerSearchCell: UITableViewCell { @@ -55,6 +55,12 @@ class PickServerSearchCell: UITableViewCell { return textField }() + override func prepareForReuse() { + super.prepareForReuse() + + delegate = nil + } + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) _init() @@ -97,7 +103,7 @@ extension PickServerSearchCell { } extension PickServerSearchCell { - @objc func textFieldDidChange(_ textField: UITextField) { - delegate?.pickServerSearchCell(didChange: textField.text) + @objc private func textFieldDidChange(_ textField: UITextField) { + delegate?.pickServerSearchCell(self, searchTextDidChange: textField.text) } } diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift index 30fcbc1f9..2c9bd240f 100644 --- a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift @@ -9,14 +9,14 @@ import UIKit import MastodonSDK class PickServerCategoryView: UIView { - var category: MastodonPickServerViewModel.Category? { - didSet { - updateCategory() - } - } +// var category: MastodonPickServerViewModel.Category? { +// didSet { +// updateCategory() +// } +// } var selected: Bool = false { didSet { - updateSelectStatus() +// updateSelectStatus() } } @@ -56,44 +56,56 @@ extension PickServerCategoryView { private func configure() { addSubview(bgView) addSubview(titleLabel) - + bgView.backgroundColor = Asset.Colors.lightWhite.color - + NSLayoutConstraint.activate([ bgView.leadingAnchor.constraint(equalTo: self.leadingAnchor), bgView.trailingAnchor.constraint(equalTo: self.trailingAnchor), bgView.topAnchor.constraint(equalTo: self.topAnchor), bgView.bottomAnchor.constraint(equalTo: self.bottomAnchor), - + titleLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor), titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor), ]) } - - private func updateCategory() { - guard let category = category else { return } - titleLabel.text = category.title - switch category { - case .all: - titleLabel.font = UIFont.systemFont(ofSize: 17) - case .some: - titleLabel.font = UIFont.systemFont(ofSize: 28) - } - } - - private func updateSelectStatus() { - if selected { - bgView.backgroundColor = Asset.Colors.lightBrandBlue.color - bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) - if case .all = category { - titleLabel.textColor = Asset.Colors.lightWhite.color - } - } else { - bgView.backgroundColor = Asset.Colors.lightWhite.color - bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) - if case .all = category { - titleLabel.textColor = Asset.Colors.lightBrandBlue.color - } + +// private func updateCategory() { +// guard let category = category else { return } +// titleLabel.text = category.title +// switch category { +// case .all: +// titleLabel.font = UIFont.systemFont(ofSize: 17) +// case .some: +// titleLabel.font = UIFont.systemFont(ofSize: 28) +// } +// } +// +// private func updateSelectStatus() { +// if selected { +// bgView.backgroundColor = Asset.Colors.lightBrandBlue.color +// bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) +// if case .all = category { +// titleLabel.textColor = Asset.Colors.lightWhite.color +// } +// } else { +// bgView.backgroundColor = Asset.Colors.lightWhite.color +// bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) +// if case .all = category { +// titleLabel.textColor = Asset.Colors.lightBrandBlue.color +// } +// } +// } +} + +#if DEBUG && canImport(SwiftUI) +import SwiftUI + +struct PickServerCategoryView_Previews: PreviewProvider { + static var previews: some View { + UIViewPreview { + PickServerCategoryView() } } } +#endif diff --git a/Mastodon/Scene/Onboarding/PinBasedAuthentication/MastodonPinBasedAuthenticationViewController.swift b/Mastodon/Scene/Onboarding/PinBasedAuthentication/MastodonPinBasedAuthenticationViewController.swift index fa57ddfd4..d566da4c3 100644 --- a/Mastodon/Scene/Onboarding/PinBasedAuthentication/MastodonPinBasedAuthenticationViewController.swift +++ b/Mastodon/Scene/Onboarding/PinBasedAuthentication/MastodonPinBasedAuthenticationViewController.swift @@ -39,8 +39,6 @@ final class MastodonPinBasedAuthenticationViewController: UIViewController, Need } - - extension MastodonPinBasedAuthenticationViewController { override func viewDidLoad() { diff --git a/Mastodon/Service/APIService/APIService+Onboarding.swift b/Mastodon/Service/APIService/APIService+Onboarding.swift index 450dc141c..5cbf455a0 100644 --- a/Mastodon/Service/APIService/APIService+Onboarding.swift +++ b/Mastodon/Service/APIService/APIService+Onboarding.swift @@ -23,7 +23,7 @@ extension APIService { return Mastodon.API.Onboarding.categories(session: session) } - func stubCategories() -> [Mastodon.Entity.Category] { + static func stubCategories() -> [Mastodon.Entity.Category] { return Mastodon.Entity.Category.Kind.allCases.map { kind in return Mastodon.Entity.Category(category: kind.rawValue, serversCount: 0) } From e70fd532c458a7d3d6d8037d7401ad1ee73bb878 Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 6 Mar 2021 12:55:52 +0800 Subject: [PATCH 07/11] feat: [WIP] display empty state when fetching server list --- Localization/app.json | 6 +- Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Section/PickServerSection.swift | 39 ++++- Mastodon/Generated/Strings.swift | 6 + .../Resources/en.lproj/Localizable.strings | 2 + .../MastodonPickServerViewController.swift | 115 +++++++++++++-- ...rverViewModel+LoadIndexedServerState.swift | 12 +- .../MastodonPickServerViewModel.swift | 25 +++- .../PickServerCategoriesCell.swift | 4 +- .../TableViewCell/PickServerCell.swift | 36 ++--- .../TableViewCell/PickServerSearchCell.swift | 4 +- .../TableViewCell/PickServerTitleCell.swift | 4 +- .../View/PickServerEmptyStateView.swift | 135 ++++++++++++++++++ 13 files changed, 337 insertions(+), 55 deletions(-) create mode 100644 Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift diff --git a/Localization/app.json b/Localization/app.json index 9c438b568..58807fc2f 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -64,6 +64,10 @@ }, "input": { "placeholder": "Find a server or join your own..." + }, + "empty_state": { + "finding_servers": "Finding available servers...", + "bad_network": "Something went wrong while loading data. Check your internet connection." } }, "register": { @@ -150,4 +154,4 @@ "title": "Public" } } -} \ No newline at end of file +} diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index cfe6a3ed0..ecec29164 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -156,6 +156,7 @@ DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */; }; DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; }; DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; + DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */; }; DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; }; DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; }; DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; }; @@ -383,6 +384,7 @@ DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = ""; }; DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = ""; }; + DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerEmptyStateView.swift; sourceTree = ""; }; DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = ""; }; DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = ""; }; DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = ""; }; @@ -494,6 +496,7 @@ isa = PBXGroup; children = ( 0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */, + DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */, ); path = View; sourceTree = ""; @@ -1554,6 +1557,7 @@ 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, + DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/PickServerSection.swift index d76cf4c66..813ef9b20 100644 --- a/Mastodon/Diffiable/Section/PickServerSection.swift +++ b/Mastodon/Diffiable/Section/PickServerSection.swift @@ -8,6 +8,7 @@ import UIKit import MastodonSDK import Kanna +import AlamofireImage enum PickServerSection: Equatable, Hashable { case header @@ -82,9 +83,41 @@ extension PickServerSection { cell.categoryValueLabel.text = server.category.uppercased() cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse) -// UIView.animate(withDuration: 0.33) { -// cell.expandBox.layoutIfNeeded() -// } + + cell.expandMode + .receive(on: DispatchQueue.main) + .sink { mode in + switch mode { + case .collapse: + // do nothing + break + case .expand: + let placeholderImage = UIImage.placeholder(size: cell.thumbnailImageView.frame.size, color: .systemFill) + .af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: false) + guard let proxiedThumbnail = server.proxiedThumbnail, + let url = URL(string: proxiedThumbnail) else { + cell.thumbnailImageView.image = placeholderImage + cell.thumbnailActivityIdicator.stopAnimating() + return + } + cell.thumbnailImageView.isHidden = false + cell.thumbnailActivityIdicator.startAnimating() + + cell.thumbnailImageView.af.setImage( + withURL: url, + placeholderImage: placeholderImage, + filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: cell.thumbnailImageView.frame.size, radius: 3), + imageTransition: .crossDissolve(0.33), + completion: { [weak cell] response in + switch response.result { + case .success, .failure: + cell?.thumbnailActivityIdicator.stopAnimating() + } + } + ) + } + } + .store(in: &cell.disposeBag) } private static func parseUsersCount(_ usersCount: Int) -> String { diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 75a661fad..035c0011a 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -236,6 +236,12 @@ internal enum L10n { internal static let all = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.All") } } + internal enum EmptyState { + /// Something went wrong while loading data. Check your internet connection. + internal static let badNetwork = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.BadNetwork") + /// Finding available servers... + internal static let findingServers = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.FindingServers") + } internal enum Input { /// Find a server or join your own... internal static let placeholder = L10n.tr("Localizable", "Scene.ServerPicker.Input.Placeholder") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 0ab1c70e4..4cf8ea52e 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -68,6 +68,8 @@ tap the link to confirm your account."; "Scene.ServerPicker.Button.Category.All" = "All"; "Scene.ServerPicker.Button.Seeless" = "See Less"; "Scene.ServerPicker.Button.Seemore" = "See More"; +"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection."; +"Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers..."; "Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; "Scene.ServerPicker.Label.Category" = "CATEGORY"; "Scene.ServerPicker.Label.Language" = "LANGUAGE"; diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 06e75c0cd..d803e0054 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -12,16 +12,19 @@ import Combine final class MastodonPickServerViewController: UIViewController, NeedsDependency { private var disposeBag = Set() + private var tableViewObservation: NSKeyValueObservation? weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var viewModel: MastodonPickServerViewModel! - private var isAuthenticating = CurrentValueSubject(false) - private var expandServerDomainSet = Set() - + + let emptyStateView = PickServerEmptyStateView() + let tableViewTopPaddingView = UIView() // fix empty state view background display when tableView bounce scrolling + var tableViewTopPaddingViewHeightLayoutConstraint: NSLayoutConstraint! + let tableView: UITableView = { let tableView = ControlContainableTableView() tableView.register(PickServerTitleCell.self, forCellReuseIdentifier: String(describing: PickServerTitleCell.self)) @@ -45,6 +48,7 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency }() deinit { + tableViewObservation = nil os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } @@ -52,10 +56,6 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency extension MastodonPickServerViewController { - override var preferredStatusBarStyle: UIStatusBarStyle { - return .darkContent - } - override func viewDidLoad() { super.viewDidLoad() @@ -70,6 +70,38 @@ extension MastodonPickServerViewController { view.layoutMarginsGuide.bottomAnchor.constraint(equalTo: nextStepButton.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight), ]) + emptyStateView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(emptyStateView) + NSLayoutConstraint.activate([ + emptyStateView.topAnchor.constraint(equalTo: view.topAnchor), + emptyStateView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + emptyStateView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 7) + ]) + + // fix AutoLayout warning when observe before view appear + viewModel.viewWillAppear + .receive(on: DispatchQueue.main) + .sink { [weak self] in + guard let self = self else { return } + self.tableViewObservation = self.tableView.observe(\.contentSize, options: [.initial, .new]) { [weak self] tableView, _ in + guard let self = self else { return } + self.updateEmptyStateViewLayout() + } + } + .store(in: &disposeBag) + + tableViewTopPaddingView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableViewTopPaddingView) + tableViewTopPaddingViewHeightLayoutConstraint = tableViewTopPaddingView.heightAnchor.constraint(equalToConstant: 0.0).priority(.defaultHigh) + NSLayoutConstraint.activate([ + tableViewTopPaddingView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + tableViewTopPaddingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableViewTopPaddingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableViewTopPaddingViewHeightLayoutConstraint, + ]) + tableViewTopPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.addSubview(tableView) NSLayoutConstraint.activate([ tableView.topAnchor.constraint(equalTo: view.topAnchor), @@ -135,15 +167,50 @@ extension MastodonPickServerViewController { } .store(in: &disposeBag) - isAuthenticating + viewModel.isAuthenticating .receive(on: DispatchQueue.main) .sink { [weak self] isAuthenticating in guard let self = self else { return } isAuthenticating ? self.nextStepButton.showLoading() : self.nextStepButton.stopLoading() } .store(in: &disposeBag) + + viewModel.emptyStateViewState + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + switch state { + case .none: + self.emptyStateView.networkIndicatorImageView.isHidden = true + self.emptyStateView.activityIndicatorView.stopAnimating() + self.emptyStateView.infoLabel.isHidden = true + case .loading: + self.emptyStateView.networkIndicatorImageView.isHidden = true + self.emptyStateView.activityIndicatorView.startAnimating() + self.emptyStateView.infoLabel.isHidden = false + self.emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.findingServers + self.emptyStateView.infoLabel.textAlignment = self.traitCollection.layoutDirection == .rightToLeft ? .right : .left + case .badNetwork: + self.emptyStateView.networkIndicatorImageView.isHidden = false + self.emptyStateView.activityIndicatorView.stopAnimating() + self.emptyStateView.infoLabel.isHidden = false + self.emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.badNetwork + self.emptyStateView.infoLabel.textAlignment = .center + } + } + .store(in: &disposeBag) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.viewWillAppear.send() + } + + +} + +extension MastodonPickServerViewController { + @objc private func nextStepButtonDidClicked(_ sender: UIButton) { switch viewModel.mode { @@ -156,7 +223,7 @@ extension MastodonPickServerViewController { private func doSignIn() { guard let server = viewModel.selectedServer.value else { return } - isAuthenticating.send(true) + viewModel.isAuthenticating.send(true) context.apiService.createApplication(domain: server.domain) .tryMap { response -> MastodonPickServerViewModel.AuthenticateInfo in let application = response.value @@ -168,7 +235,7 @@ extension MastodonPickServerViewController { .receive(on: DispatchQueue.main) .sink { [weak self] completion in guard let self = self else { return } - self.isAuthenticating.send(false) + self.viewModel.isAuthenticating.send(false) switch completion { case .failure(let error): @@ -196,7 +263,7 @@ extension MastodonPickServerViewController { private func doSignUp() { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard let server = viewModel.selectedServer.value else { return } - isAuthenticating.send(true) + viewModel.isAuthenticating.send(true) context.apiService.instance(domain: server.domain) .compactMap { [weak self] response -> AnyPublisher? in @@ -232,7 +299,7 @@ extension MastodonPickServerViewController { .receive(on: DispatchQueue.main) .sink { [weak self] completion in guard let self = self else { return } - self.isAuthenticating.send(false) + self.viewModel.isAuthenticating.send(false) switch completion { case .failure(let error): @@ -266,10 +333,23 @@ extension MastodonPickServerViewController { } } +// MARK: - UITableViewDelegate extension MastodonPickServerViewController: UITableViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView === tableView else { return } + let offsetY = scrollView.contentOffset.y + scrollView.safeAreaInsets.top + if offsetY < 0 { + tableViewTopPaddingViewHeightLayoutConstraint.constant = abs(offsetY) + } else { + tableViewTopPaddingViewHeightLayoutConstraint.constant = 0 + } + } + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - return UIView() + let headerView = UIView() + headerView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + return headerView } func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { @@ -320,6 +400,15 @@ extension MastodonPickServerViewController: UITableViewDelegate { } +extension MastodonPickServerViewController { + private func updateEmptyStateViewLayout() { + guard let diffableDataSource = self.viewModel.diffableDataSource else { return } + guard let indexPath = diffableDataSource.indexPath(for: .search) else { return } + let rectInTableView = tableView.rectForRow(at: indexPath) + + emptyStateView.topPaddingViewTopLayoutConstraint.constant = rectInTableView.maxY + } +} //extension MastodonPickServerViewController: UITableViewDataSource { // func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift index 172973b5c..85c6ab289 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift @@ -41,6 +41,7 @@ extension MastodonPickServerViewModel.LoadIndexedServerState { super.didEnter(from: previousState) guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } + viewModel.isLoadingIndexedServers.value = true viewModel.context.apiService.servers(language: nil, category: nil) .sink { completion in switch completion { @@ -67,9 +68,9 @@ extension MastodonPickServerViewModel.LoadIndexedServerState { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) - guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } + guard let stateMachine = self.stateMachine else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in - guard let self = self else { return } + guard let _ = self else { return } stateMachine.enter(Loading.self) } } @@ -79,6 +80,13 @@ extension MastodonPickServerViewModel.LoadIndexedServerState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { return false } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } + viewModel.isLoadingIndexedServers.value = false + } } } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index 2e764f9b1..8953e2ed9 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -13,11 +13,18 @@ import MastodonSDK import CoreDataStack class MastodonPickServerViewModel: NSObject { + enum PickServerMode { case signUp case signIn } + enum EmptyStateViewState { + case none + case loading + case badNetwork + } + var disposeBag = Set() // input @@ -33,6 +40,7 @@ class MastodonPickServerViewModel: NSObject { let searchText = CurrentValueSubject(nil) let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Instance], Never>([]) + let viewWillAppear = PassthroughSubject() // output var diffableDataSource: UITableViewDiffableDataSource? @@ -47,12 +55,15 @@ class MastodonPickServerViewModel: NSObject { stateMachine.enter(LoadIndexedServerState.Initial.self) return stateMachine }() - let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([]) let selectedServer = CurrentValueSubject(nil) let error = PassthroughSubject() let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>() - + let isAuthenticating = CurrentValueSubject(false) + + let isLoadingIndexedServers = CurrentValueSubject(false) + let emptyStateViewState = CurrentValueSubject(.none) + var mastodonPinBasedAuthenticationViewController: UIViewController? init(context: AppContext, mode: PickServerMode) { @@ -99,6 +110,16 @@ class MastodonPickServerViewModel: NSObject { }) .store(in: &disposeBag) + isLoadingIndexedServers + .map { isLoadingIndexedServers -> EmptyStateViewState in + if isLoadingIndexedServers { + return .loading + } else { + return .none + } + } + .assign(to: \.value, on: emptyStateViewState) + .store(in: &disposeBag) // Publishers.CombineLatest3( // selectCategoryIndex, diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift index 1fd366555..66f8caac1 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift @@ -54,8 +54,8 @@ final class PickServerCategoriesCell: UITableViewCell { extension PickServerCategoriesCell { private func _init() { - self.selectionStyle = .none - backgroundColor = .clear + selectionStyle = .none + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color metricView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(metricView) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index cadfc74ba..95a5491cc 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -7,6 +7,7 @@ import os.log import UIKit +import Combine import MastodonSDK import AlamofireImage import Kanna @@ -19,6 +20,10 @@ class PickServerCell: UITableViewCell { weak var delegate: PickServerCellDelegate? + var disposeBag = Set() + + let expandMode = CurrentValueSubject(.collapse) + let containerView: UIView = { let view = UIView() view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16) @@ -170,6 +175,7 @@ class PickServerCell: UITableViewCell { thumbnailImageView.isHidden = false thumbnailImageView.af.cancelImageRequest() thumbnailActivityIdicator.stopAnimating() + disposeBag.removeAll() } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -329,34 +335,8 @@ extension PickServerCell { NSLayoutConstraint.activate(expandConstraints) NSLayoutConstraint.deactivate(collapseConstraints) } + + expandMode.value = mode } -// private func updateThumbnail() { -// guard let serverInfo = server, -// let proxiedThumbnail = serverInfo.proxiedThumbnail, -// let url = URL(string: proxiedThumbnail) else { -// thumbnailImageView.isHidden = true -// thumbnailActivityIdicator.stopAnimating() -// return -// } -// -// thumbnailImageView.isHidden = false -// thumbnailActivityIdicator.startAnimating() -// -// let placeholderImage = UIImage.placeholder(color: .systemFill).af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: true) -// thumbnailImageView.af.setImage( -// withURL: url, -// placeholderImage: placeholderImage, -// filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: thumbnailImageView.frame.size, radius: 3), -// imageTransition: .crossDissolve(0.33), -// completion: { [weak self] response in -// guard let self = self else { return } -// switch response.result { -// case .success, .failure: -// self.thumbnailActivityIdicator.stopAnimating() -// } -// } -// ) -// } - } diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift index 2de66fa65..510df3a0b 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift @@ -74,8 +74,8 @@ class PickServerSearchCell: UITableViewCell { extension PickServerSearchCell { private func _init() { - self.selectionStyle = .none - backgroundColor = .clear + selectionStyle = .none + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift index 82d155535..30d24ddc0 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift @@ -34,8 +34,8 @@ final class PickServerTitleCell: UITableViewCell { extension PickServerTitleCell { private func _init() { - self.selectionStyle = .none - backgroundColor = .clear + selectionStyle = .none + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color contentView.addSubview(titleLabel) NSLayoutConstraint.activate([ diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift new file mode 100644 index 000000000..c553b51f6 --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift @@ -0,0 +1,135 @@ +// +// PickServerEmptyStateView.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/6. +// + +import UIKit + +final class PickServerEmptyStateView: UIView { + + var topPaddingViewTopLayoutConstraint: NSLayoutConstraint! + + let networkIndicatorImageView: UIImageView = { + let imageView = UIImageView() + let configuration = UIImage.SymbolConfiguration(pointSize: 64, weight: .regular) + imageView.image = UIImage(systemName: "wifi.exclamationmark", withConfiguration: configuration) + imageView.tintColor = Asset.Colors.Label.secondary.color + return imageView + }() + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + let infoLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17) + label.textAlignment = .center + label.textColor = Asset.Colors.Label.secondary.color + label.text = "info" + label.numberOfLines = 0 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension PickServerEmptyStateView { + + private func _init() { + backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + + let topPaddingView = UIView() + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(topPaddingView) + topPaddingViewTopLayoutConstraint = topPaddingView.topAnchor.constraint(equalTo: topAnchor, constant: 0) + NSLayoutConstraint.activate([ + topPaddingViewTopLayoutConstraint, + topPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), + topPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.alignment = .center + containerStackView.distribution = .fill + containerStackView.spacing = 16 + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + containerStackView.addArrangedSubview(networkIndicatorImageView) + + let infoContainerView = UIView() + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + infoContainerView.addSubview(activityIndicatorView) + NSLayoutConstraint.activate([ + activityIndicatorView.leadingAnchor.constraint(equalTo: infoContainerView.leadingAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: infoContainerView.centerYAnchor), + activityIndicatorView.bottomAnchor.constraint(equalTo: infoContainerView.bottomAnchor), + ]) + infoLabel.translatesAutoresizingMaskIntoConstraints = false + infoContainerView.addSubview(infoLabel) + NSLayoutConstraint.activate([ + infoLabel.leadingAnchor.constraint(equalTo: activityIndicatorView.trailingAnchor, constant: 4), + infoLabel.centerYAnchor.constraint(equalTo: infoContainerView.centerYAnchor), + infoLabel.trailingAnchor.constraint(equalTo: infoContainerView.trailingAnchor), + ]) + containerStackView.addArrangedSubview(infoContainerView) + + let bottomPaddingView = UIView() + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(bottomPaddingView) + NSLayoutConstraint.activate([ + bottomPaddingView.topAnchor.constraint(equalTo: containerStackView.bottomAnchor), + bottomPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), + bottomPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomPaddingView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + NSLayoutConstraint.activate([ + bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 2.0), + ]) + + activityIndicatorView.hidesWhenStopped = true + activityIndicatorView.startAnimating() + } + +} + +#if DEBUG && canImport(SwiftUI) +import SwiftUI + +struct PickServerEmptyStateView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let emptyStateView = PickServerEmptyStateView() + emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.badNetwork + emptyStateView.infoLabel.textAlignment = .center + emptyStateView.activityIndicatorView.stopAnimating() + return emptyStateView + } + .previewLayout(.fixed(width: 375, height: 400)) + UIViewPreview(width: 375) { + let emptyStateView = PickServerEmptyStateView() + emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.findingServers + emptyStateView.infoLabel.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? .right : .left + emptyStateView.activityIndicatorView.startAnimating() + return emptyStateView + } + .previewLayout(.fixed(width: 375, height: 400)) + } + } +} +#endif From 29653ca6123c69733e4d2ebf8ea3c5ea20becc4b Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 6 Mar 2021 13:29:45 +0800 Subject: [PATCH 08/11] feat: set corner radius for the last cell layer --- Mastodon.xcodeproj/project.pbxproj | 4 ++++ .../xcschemes/xcschememanagement.plist | 4 ++-- Mastodon/Diffiable/Section/PickServerItem.swift | 4 +++- Mastodon/Diffiable/Section/PickServerSection.swift | 11 +++++++++++ .../PickServer/MastodonPickServerAppearance.swift | 12 ++++++++++++ .../MastodonPickServerViewController.swift | 10 +++++----- .../PickServer/MastodonPickServerViewModel.swift | 6 +++++- .../TableViewCell/PickServerSearchCell.swift | 2 +- .../PickServer/View/PickServerEmptyStateView.swift | 6 ++++++ 9 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 Mastodon/Scene/Onboarding/PickServer/MastodonPickServerAppearance.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index ecec29164..115a32edd 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -170,6 +170,7 @@ DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; }; DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; + DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; @@ -398,6 +399,7 @@ DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = ""; }; DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; + DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; @@ -473,6 +475,7 @@ 0FB3D30D25E525C000AAD544 /* View */, 0FB3D31825E525DE00AAD544 /* CollectionViewCell */, 0FB3D2FC25E4CB4B00AAD544 /* TableViewCell */, + DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */, 0FAA102625E1126A0017CCDE /* MastodonPickServerViewController.swift */, 0FB3D2F625E4C24D00AAD544 /* MastodonPickServerViewModel.swift */, DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */, @@ -1563,6 +1566,7 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, + DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, DB1FD46025F278AF004CFCFC /* CategoryPickerSection.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 60ccd3d87..d9c64a5ed 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 7 + 8 Mastodon - RTL.xcscheme_^#shared#^_ @@ -22,7 +22,7 @@ Mastodon.xcscheme_^#shared#^_ orderHint - 8 + 7 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Section/PickServerItem.swift b/Mastodon/Diffiable/Section/PickServerItem.swift index 09ca72c32..13acefeae 100644 --- a/Mastodon/Diffiable/Section/PickServerItem.swift +++ b/Mastodon/Diffiable/Section/PickServerItem.swift @@ -18,9 +18,11 @@ enum PickServerItem { extension PickServerItem { final class ServerItemAttribute: Equatable, Hashable { + var isLast: Bool var isExpand: Bool - init(isExpand: Bool) { + init(isLast: Bool, isExpand: Bool) { + self.isLast = isLast self.isExpand = isExpand } diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/PickServerSection.swift index 813ef9b20..3ae917983 100644 --- a/Mastodon/Diffiable/Section/PickServerSection.swift +++ b/Mastodon/Diffiable/Section/PickServerSection.swift @@ -84,6 +84,17 @@ extension PickServerSection { cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse) + if attribute.isLast { + cell.containerView.layer.maskedCorners = [ + .layerMinXMaxYCorner, + .layerMaxXMaxYCorner + ] + cell.containerView.layer.cornerCurve = .continuous + cell.containerView.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius + } else { + cell.containerView.layer.cornerRadius = 0 + } + cell.expandMode .receive(on: DispatchQueue.main) .sink { mode in diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerAppearance.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerAppearance.swift new file mode 100644 index 000000000..c5bc56c0c --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerAppearance.swift @@ -0,0 +1,12 @@ +// +// MastodonPickServerAppearance.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/6. +// + +import UIKit + +enum MastodonPickServerAppearance { + static let tableViewCornerRadius: CGFloat = 10 +} diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index d803e0054..6af592f85 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -76,7 +76,7 @@ extension MastodonPickServerViewController { emptyStateView.topAnchor.constraint(equalTo: view.topAnchor), emptyStateView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), emptyStateView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), - nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 7) + nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21), ]) // fix AutoLayout warning when observe before view appear @@ -107,7 +107,7 @@ extension MastodonPickServerViewController { tableView.topAnchor.constraint(equalTo: view.topAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - nextStepButton.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7) + nextStepButton.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7), ]) switch viewModel.mode { @@ -181,16 +181,16 @@ extension MastodonPickServerViewController { guard let self = self else { return } switch state { case .none: - self.emptyStateView.networkIndicatorImageView.isHidden = true - self.emptyStateView.activityIndicatorView.stopAnimating() - self.emptyStateView.infoLabel.isHidden = true + self.emptyStateView.isHidden = true case .loading: + self.emptyStateView.isHidden = false self.emptyStateView.networkIndicatorImageView.isHidden = true self.emptyStateView.activityIndicatorView.startAnimating() self.emptyStateView.infoLabel.isHidden = false self.emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.findingServers self.emptyStateView.infoLabel.textAlignment = self.traitCollection.layoutDirection == .rightToLeft ? .right : .left case .badNetwork: + self.emptyStateView.isHidden = false self.emptyStateView.networkIndicatorImageView.isHidden = false self.emptyStateView.activityIndicatorView.stopAnimating() self.emptyStateView.infoLabel.isHidden = false diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index 8953e2ed9..e86d1c590 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -100,10 +100,14 @@ class MastodonPickServerViewModel: NSObject { // TODO: handle filter var serverItems: [PickServerItem] = [] for server in indexedServers { - let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isExpand: false) + let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false) + attribute.isLast = false let item = PickServerItem.server(server: server, attribute: attribute) serverItems.append(item) } + if case let .server(_, attribute) = serverItems.last { + attribute.isLast = true + } snapshot.appendItems(serverItems, toSection: .servers) diffableDataSource.apply(snapshot) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift index 510df3a0b..23c93a7da 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift @@ -24,7 +24,7 @@ class PickServerSearchCell: UITableViewCell { .layerMaxXMinYCorner ] view.layer.cornerCurve = .continuous - view.layer.cornerRadius = 10 + view.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius return view }() diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift index c553b51f6..af744fa92 100644 --- a/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift @@ -45,6 +45,12 @@ extension PickServerEmptyStateView { private func _init() { backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + layer.maskedCorners = [ + .layerMinXMaxYCorner, + .layerMaxXMaxYCorner + ] + layer.cornerCurve = .continuous + layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius let topPaddingView = UIView() topPaddingView.translatesAutoresizingMaskIntoConstraints = false From 8568debab0bc3d1ad1b58c2e1790c28b93158b69 Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 6 Mar 2021 14:21:52 +0800 Subject: [PATCH 09/11] feat: make diffable data source work with search text --- .../MastodonPickServerViewController.swift | 2 +- .../MastodonPickServerViewModel.swift | 166 +++++++++--------- .../Share/AuthenticationViewModel.swift | 36 ++-- .../Response/Mastodon+Response+Content.swift | 13 ++ 4 files changed, 121 insertions(+), 96 deletions(-) diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 6af592f85..e894cda34 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -445,7 +445,7 @@ extension MastodonPickServerViewController { // MARK: - PickServerSearchCellDelegate extension MastodonPickServerViewController: PickServerSearchCellDelegate { func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) { - viewModel.searchText.send(searchText) + viewModel.searchText.send(searchText ?? "") } } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index e86d1c590..e136e86ce 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -36,10 +36,10 @@ class MastodonPickServerViewModel: NSObject { items.append(contentsOf: APIService.stubCategories().map { CategoryPickerItem.category(category: $0) }) return items }() - let selectCategoryIndex = CurrentValueSubject(0) - let searchText = CurrentValueSubject(nil) + let selectCategoryItem = CurrentValueSubject(.all) + let searchText = CurrentValueSubject("") let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) - let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Instance], Never>([]) + let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) let viewWillAppear = PassthroughSubject() // output @@ -55,6 +55,7 @@ class MastodonPickServerViewModel: NSObject { stateMachine.enter(LoadIndexedServerState.Initial.self) return stateMachine }() + let filteredIndexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([]) let selectedServer = CurrentValueSubject(nil) let error = PassthroughSubject() @@ -75,13 +76,12 @@ class MastodonPickServerViewModel: NSObject { } private func configure() { - Publishers.CombineLatest3( - indexedServers, - unindexedServers, - searchText + Publishers.CombineLatest( + filteredIndexedServers.eraseToAnyPublisher(), + unindexedServers.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] indexedServers, unindexedServers, searchText in + .sink(receiveValue: { [weak self] indexedServers, unindexedServers in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } @@ -103,6 +103,14 @@ class MastodonPickServerViewModel: NSObject { let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false) attribute.isLast = false let item = PickServerItem.server(server: server, attribute: attribute) + guard !serverItems.contains(item) else { continue } + serverItems.append(item) + } + for server in unindexedServers { + let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false) + attribute.isLast = false + let item = PickServerItem.server(server: server, attribute: attribute) + guard !serverItems.contains(item) else { continue } serverItems.append(item) } if case let .server(_, attribute) = serverItems.last { @@ -110,7 +118,8 @@ class MastodonPickServerViewModel: NSObject { } snapshot.appendItems(serverItems, toSection: .servers) - diffableDataSource.apply(snapshot) + diffableDataSource.defaultRowAnimation = .fade + diffableDataSource.apply(snapshot, animatingDifferences: true, completion: nil) }) .store(in: &disposeBag) @@ -125,80 +134,77 @@ class MastodonPickServerViewModel: NSObject { .assign(to: \.value, on: emptyStateViewState) .store(in: &disposeBag) -// Publishers.CombineLatest3( -// selectCategoryIndex, -// searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(), -// indexedServers -// ) -// .flatMap { [weak self] (selectCategoryIndex, searchText, allServers) -> AnyPublisher, Never> in -// guard let self = self else { return Just(Result.success([])).eraseToAnyPublisher() } -// -// // 1. Search from the servers recorded in joinmastodon.org -// let searchedServersFromAPI = self.searchServersFromAPI(category: self.categories[selectCategoryIndex], searchText: searchText, allServers: allServers) -// if !searchedServersFromAPI.isEmpty { -// // If found servers, just return -// return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher() -// } -// // 2. No server found in the recorded list, check if searchText is a valid mastodon server domain -// if let toSearchText = searchText, !toSearchText.isEmpty, let _ = URL(string: "https://\(toSearchText)") { -// return self.context.apiService.instance(domain: toSearchText) -// .map { return Result.success([Mastodon.Entity.Server(instance: $0.value)]) } -// .catch({ error -> Just> in -// return Just(Result.failure(error)) -// }) -// .eraseToAnyPublisher() -// } -// return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher() -// } -// .sink { _ in -// -// } receiveValue: { [weak self] result in -// switch result { -// case .success(let servers): -// self?.servers.send(servers) -// case .failure(let error): -// // TODO: What should be presented when user inputs invalid search text? -// self?.servers.send([]) -// } -// -// } -// .store(in: &disposeBag) - + Publishers.CombineLatest3( + indexedServers.eraseToAnyPublisher(), + selectCategoryItem.eraseToAnyPublisher(), + searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates() + ) + .map { indexedServers, selectCategoryItem, searchText -> [Mastodon.Entity.Server] in + // Filter the indexed servers from joinmastodon.org + switch selectCategoryItem { + case .all: + return MastodonPickServerViewModel.filterServers(servers: indexedServers, category: nil, searchText: searchText) + case .category(let category): + return MastodonPickServerViewModel.filterServers(servers: indexedServers, category: category.category.rawValue, searchText: searchText) + } + } + .assign(to: \.value, on: filteredIndexedServers) + .store(in: &disposeBag) + searchText + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .removeDuplicates() + .compactMap { [weak self] searchText -> AnyPublisher, Error>, Never>? in + // Check if searchText is a valid mastodon server domain + guard let self = self else { return nil } + guard let domain = AuthenticationViewModel.parseDomain(from: searchText) else { + return Just(Result.failure(APIService.APIError.implicit(.badRequest))).eraseToAnyPublisher() + } + return self.context.apiService.instance(domain: domain) + .map { response -> Result, Error>in + let newResponse = response.map { [Mastodon.Entity.Server(instance: $0)] } + return Result.success(newResponse) + } + .catch { error in + return Just(Result.failure(error)) + } + .eraseToAnyPublisher() + } + .switchToLatest() + .sink(receiveValue: { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let response): + self.unindexedServers.send(response.value) + case .failure(let error): + // TODO: What should be presented when user inputs invalid search text? + self.unindexedServers.send([]) + } + }) + .store(in: &disposeBag) } - -// func fetchAllServers() { -// context.apiService.servers(language: nil, category: nil) -// .sink { completion in -// // TODO: Add a reload button when fails to fetch servers initially -// } receiveValue: { [weak self] result in -// self?.indexedServers.send(result.value) -// } -// .store(in: &disposeBag) -// -// } -// -// private func searchServersFromAPI(category: Category, searchText: String?, allServers: [Mastodon.Entity.Server]) -> [Mastodon.Entity.Server] { -// return allServers -// // 1. Filter the category -// .filter { -// switch category { -// case .all: -// return true -// case .some(let masCategory): -// return $0.category.caseInsensitiveCompare(masCategory.category.rawValue) == .orderedSame -// } -// } -// // 2. Filter the searchText -// .filter { -// if let searchText = searchText, !searchText.isEmpty { -// return $0.domain.lowercased().contains(searchText.lowercased()) -// } else { -// return true -// } -// } -// } + } + +extension MastodonPickServerViewModel { + private static func filterServers(servers: [Mastodon.Entity.Server], category: String?, searchText: String) -> [Mastodon.Entity.Server] { + return servers + // 1. Filter the category + .filter { + guard let category = category else { return true } + return $0.category.caseInsensitiveCompare(category) == .orderedSame + } + // 2. Filter the searchText + .filter { + let searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !searchText.isEmpty else { + return true + } + return $0.domain.lowercased().contains(searchText.lowercased()) + } + } +} + // MARK: - SignIn methods & structs extension MastodonPickServerViewModel { diff --git a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift index cb197dc0a..0bd1bf09b 100644 --- a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift +++ b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift @@ -42,21 +42,7 @@ final class AuthenticationViewModel { input .map { input in - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !trimmed.isEmpty else { return nil } - - let urlString = trimmed.hasPrefix("https://") ? trimmed : "https://" + trimmed - guard let url = URL(string: urlString), - let host = url.host else { - return nil - } - let components = host.components(separatedBy: ".") - guard !components.contains(where: { $0.isEmpty }) else { return nil } - guard components.count >= 2 else { return nil } - - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: iput host: %s", ((#file as NSString).lastPathComponent), #line, #function, host) - - return host + AuthenticationViewModel.parseDomain(from: input) } .assign(to: \.value, on: domain) .store(in: &disposeBag) @@ -77,6 +63,26 @@ final class AuthenticationViewModel { } +extension AuthenticationViewModel { + static func parseDomain(from input: String) -> String? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return nil } + + let urlString = trimmed.hasPrefix("https://") ? trimmed : "https://" + trimmed + guard let url = URL(string: urlString), + let host = url.host else { + return nil + } + let components = host.components(separatedBy: ".") + guard !components.contains(where: { $0.isEmpty }) else { return nil } + guard components.count >= 2 else { return nil } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: input host: %s", ((#file as NSString).lastPathComponent), #line, #function, host) + + return host + } +} + extension AuthenticationViewModel { enum AuthenticationError: Error, LocalizedError { case badCredentials diff --git a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift index f99614311..a74d0fcaa 100644 --- a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift +++ b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift @@ -39,10 +39,23 @@ extension Mastodon.Response { }() } + init(value: T, old: Mastodon.Response.Content) { + self.value = value + self.date = old.date + self.rateLimit = old.rateLimit + self.responseTime = old.responseTime + } + } } extension Mastodon.Response.Content { + public func map(_ transform: (T) -> R) -> Mastodon.Response.Content { + return Mastodon.Response.Content(value: transform(value), old: self) + } +} + +extension Mastodon.Response { public struct RateLimit { public let limit: Int From 893dc2a66827cea7b26492afe7662da30aa45ea0 Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 6 Mar 2021 14:46:04 +0800 Subject: [PATCH 10/11] feat: make diffable data source works with category picker --- .../Section/CategoryPickerSection.swift | 16 ++++ .../Diffiable/Section/PickServerSection.swift | 17 +--- ...PickServerCategoryCollectionViewCell.swift | 9 +- .../MastodonPickServerViewController.swift | 96 ++++++------------- ...MastodonPickServerViewModel+Diffable.swift | 2 + .../PickServerCategoriesCell.swift | 28 +----- .../TableViewCell/PickServerSearchCell.swift | 2 +- .../View/PickServerCategoryView.swift | 39 +------- 8 files changed, 64 insertions(+), 145 deletions(-) diff --git a/Mastodon/Diffiable/Section/CategoryPickerSection.swift b/Mastodon/Diffiable/Section/CategoryPickerSection.swift index 5582cb531..2164d9ebc 100644 --- a/Mastodon/Diffiable/Section/CategoryPickerSection.swift +++ b/Mastodon/Diffiable/Section/CategoryPickerSection.swift @@ -25,6 +25,22 @@ extension CategoryPickerSection { cell.categoryView.titleLabel.font = .systemFont(ofSize: 28) } cell.categoryView.titleLabel.text = item.title + cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in + if cell.isSelected { + cell.categoryView.bgView.backgroundColor = Asset.Colors.lightBrandBlue.color + cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) + if case .all = item { + cell.categoryView.titleLabel.textColor = Asset.Colors.lightWhite.color + } + } else { + cell.categoryView.bgView.backgroundColor = Asset.Colors.lightWhite.color + cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) + if case .all = item { + cell.categoryView.titleLabel.textColor = Asset.Colors.lightBrandBlue.color + } + } + } + .store(in: &cell.observations) return cell } } diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/PickServerSection.swift index 3ae917983..083e9b813 100644 --- a/Mastodon/Diffiable/Section/PickServerSection.swift +++ b/Mastodon/Diffiable/Section/PickServerSection.swift @@ -21,16 +21,18 @@ extension PickServerSection { static func tableViewDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, + pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate, pickServerSearchCellDelegate: PickServerSearchCellDelegate, pickServerCellDelegate: PickServerCellDelegate ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak pickServerSearchCellDelegate, weak pickServerCellDelegate] tableView, indexPath, item -> UITableViewCell? in + UITableViewDiffableDataSource(tableView: tableView) { [weak pickServerCategoriesCellDelegate, weak pickServerSearchCellDelegate, weak pickServerCellDelegate] tableView, indexPath, item -> UITableViewCell? in switch item { case .header: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell return cell case .categoryPicker(let items): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell + cell.delegate = pickServerCategoriesCellDelegate cell.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource( for: cell.collectionView, dependency: dependency @@ -48,19 +50,6 @@ extension PickServerSection { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell PickServerSection.configure(cell: cell, server: server, attribute: attribute) cell.delegate = pickServerCellDelegate - // cell.server = server - // if expandServerDomainSet.contains(server.domain) { - // cell.mode = .expand - // } else { - // cell.mode = .collapse - // } -// if server == viewModel.selectedServer.value { -// tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) -// } else { -// tableView.deselectRow(at: indexPath, animated: false) -// } -// -// cell.delegate = self return cell } } diff --git a/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift b/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift index 5008ad3a3..9793d40fb 100644 --- a/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift @@ -9,16 +9,17 @@ import UIKit class PickServerCategoryCollectionViewCell: UICollectionViewCell { + var observations = Set() + var categoryView: PickServerCategoryView = { let view = PickServerCategoryView() view.translatesAutoresizingMaskIntoConstraints = false return view }() - override var isSelected: Bool { - didSet { - categoryView.selected = isSelected - } + override func prepareForReuse() { + super.prepareForReuse() + observations.removeAll() } override init(frame: CGRect) { diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index e894cda34..f3e811b9f 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -122,6 +122,7 @@ extension MastodonPickServerViewController { viewModel.setupDiffableDataSource( for: tableView, dependency: self, + pickServerCategoriesCellDelegate: self, pickServerSearchCellDelegate: self, pickServerCellDelegate: self ) @@ -398,6 +399,28 @@ extension MastodonPickServerViewController: UITableViewDelegate { viewModel.selectedServer.send(nil) } + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + switch item { + case .categoryPicker: + guard let cell = cell as? PickServerCategoriesCell else { return } + guard let diffableDataSource = cell.diffableDataSource else { return } + let snapshot = diffableDataSource.snapshot() + + let item = viewModel.selectCategoryItem.value + guard let section = snapshot.indexOfSection(.main), + let row = snapshot.indexOfItem(item) else { return } + cell.collectionView.selectItem(at: IndexPath(item: row, section: section), animated: false, scrollPosition: .centeredHorizontally) + case .search: + guard let cell = cell as? PickServerSearchCell else { return } + cell.searchTextField.text = viewModel.searchText.value + default: + break + } + } + } extension MastodonPickServerViewController { @@ -409,38 +432,15 @@ extension MastodonPickServerViewController { emptyStateView.topPaddingViewTopLayoutConstraint.constant = rectInTableView.maxY } } -//extension MastodonPickServerViewController: UITableViewDataSource { -// func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { -// -// let section = Self.Section.allCases[indexPath.section] -// switch section { -// case .title: -// -// case .categories: -// -// case .search: -// -// case .serverList: -// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell -// let server = viewModel.servers.value[indexPath.row] -// // cell.server = server -//// if expandServerDomainSet.contains(server.domain) { -//// cell.mode = .expand -//// } else { -//// cell.mode = .collapse -//// } -// if server == viewModel.selectedServer.value { -// tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) -// } else { -// tableView.deselectRow(at: indexPath, animated: false) -// } -// -// cell.delegate = self -// return cell -// } -// } -//} +// MARK: - PickServerCategoriesCellDelegate +extension MastodonPickServerViewController: PickServerCategoriesCellDelegate { + func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let diffableDataSource = cell.diffableDataSource else { return } + let item = diffableDataSource.itemIdentifier(for: indexPath) + viewModel.selectCategoryItem.value = item ?? .all + } +} // MARK: - PickServerSearchCellDelegate extension MastodonPickServerViewController: PickServerSearchCellDelegate { @@ -465,41 +465,7 @@ extension MastodonPickServerViewController: PickServerCellDelegate { // expand attribute change do not needs apply snapshot to diffable data source // but should I block the viewModel data binding during tableView.beginUpdates/endUpdates? } - -// func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) { -// if newMode == .collapse { -// expandServerDomainSet.remove(server.domain) -// } else { -// expandServerDomainSet.insert(server.domain) -// } -// -// tableView.beginUpdates() -// updates() -// tableView.endUpdates() -// -// if newMode == .expand, let modeChangeIndex = self.viewModel.servers.value.firstIndex(where: { $0 == server }), self.tableView.indexPathsForVisibleRows?.last?.row == modeChangeIndex { -// self.tableView.scrollToRow(at: IndexPath(row: modeChangeIndex, section: 3), at: .bottom, animated: true) -// } -// } } -//extension MastodonPickServerViewController: PickServerCategoriesDataSource, PickServerCategoriesCellDelegate { -// func numberOfCategories() -> Int { -// return viewModel.categories.count -// } -// -// func category(at index: Int) -> MastodonPickServerViewModel.Category { -// return viewModel.categories[index] -// } -// -// func selectedIndex() -> Int { -// return viewModel.selectCategoryIndex.value -// } -// -// func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, didSelect index: Int) { -// return viewModel.selectCategoryIndex.send(index) -// } -//} - // MARK: - OnboardingViewControllerAppearance extension MastodonPickServerViewController: OnboardingViewControllerAppearance { } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift index 506cbbc48..9da0399e1 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift @@ -12,12 +12,14 @@ extension MastodonPickServerViewModel { func setupDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, + pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate, pickServerSearchCellDelegate: PickServerSearchCellDelegate, pickServerCellDelegate: PickServerCellDelegate ) { diffableDataSource = PickServerSection.tableViewDiffableDataSource( for: tableView, dependency: dependency, + pickServerCategoriesCellDelegate: pickServerCategoriesCellDelegate, pickServerSearchCellDelegate: pickServerSearchCellDelegate, pickServerCellDelegate: pickServerCellDelegate ) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift index 66f8caac1..84ee6017c 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift @@ -89,9 +89,11 @@ extension PickServerCategoriesCell { // MARK: - UICollectionViewDelegateFlowLayout extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) -// delegate.pickServerCategoriesCell(self, didSelect: indexPath.row) + delegate?.pickServerCategoriesCell(self, collectionView: collectionView, didSelectItemAt: indexPath) } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { @@ -106,27 +108,5 @@ extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { return CGSize(width: 60, height: 80) } + } - -//extension PickServerCategoriesCell: UICollectionViewDataSource { -// func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { -// return dataSource.numberOfCategories() -// } -// -// func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { -// let category = dataSource.category(at: indexPath.row) -// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell -// cell.category = category -// -// // Select the default category by default -// if indexPath.row == dataSource.selectedIndex() { -// // Use `[]` as the scrollPosition to avoid contentOffset change -// collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) -// cell.isSelected = true -// } -// return cell -// } -// -// -//} - diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift index 23c93a7da..f35f586a4 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift @@ -38,7 +38,7 @@ class PickServerSearchCell: UITableViewCell { return view }() - private var searchTextField: UITextField = { + let searchTextField: UITextField = { let textField = UITextField() textField.translatesAutoresizingMaskIntoConstraints = false textField.font = .preferredFont(forTextStyle: .headline) diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift index 2c9bd240f..7ea147e0a 100644 --- a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift @@ -9,16 +9,6 @@ import UIKit import MastodonSDK class PickServerCategoryView: UIView { -// var category: MastodonPickServerViewModel.Category? { -// didSet { -// updateCategory() -// } -// } - var selected: Bool = false { - didSet { -// updateSelectStatus() - } - } var bgShadowView: UIView = { let view = UIView() @@ -53,6 +43,7 @@ class PickServerCategoryView: UIView { } extension PickServerCategoryView { + private func configure() { addSubview(bgView) addSubview(titleLabel) @@ -69,33 +60,7 @@ extension PickServerCategoryView { titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor), ]) } - -// private func updateCategory() { -// guard let category = category else { return } -// titleLabel.text = category.title -// switch category { -// case .all: -// titleLabel.font = UIFont.systemFont(ofSize: 17) -// case .some: -// titleLabel.font = UIFont.systemFont(ofSize: 28) -// } -// } -// -// private func updateSelectStatus() { -// if selected { -// bgView.backgroundColor = Asset.Colors.lightBrandBlue.color -// bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) -// if case .all = category { -// titleLabel.textColor = Asset.Colors.lightWhite.color -// } -// } else { -// bgView.backgroundColor = Asset.Colors.lightWhite.color -// bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) -// if case .all = category { -// titleLabel.textColor = Asset.Colors.lightBrandBlue.color -// } -// } -// } + } #if DEBUG && canImport(SwiftUI) From c6103eed358e8312aca7a8711fecb44b228e9788 Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 6 Mar 2021 14:59:27 +0800 Subject: [PATCH 11/11] fix: add missing request retry logic --- .../MastodonPickServerViewModel+LoadIndexedServerState.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift index 85c6ab289..b9cdcc7e4 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift @@ -47,7 +47,7 @@ extension MastodonPickServerViewModel.LoadIndexedServerState { switch completion { case .failure(let error): // TODO: handle error - break + stateMachine.enter(Fail.self) case .finished: break }