diff --git a/Localization/StringsConvertor/input/en_US/app.json b/Localization/StringsConvertor/input/en_US/app.json new file mode 100644 index 00000000..c970f34f --- /dev/null +++ b/Localization/StringsConvertor/input/en_US/app.json @@ -0,0 +1,95 @@ +{ + "common": { + "alerts": {}, + "controls": { + "actions": { + "add": "Add", + "remove": "Remove", + "edit": "Edit", + "save": "Save", + "ok": "OK", + "confirm": "Confirm", + "continue": "Continue", + "cancel": "Cancel", + "take_photo": "Take photo", + "save_photo": "Save photo", + "sign_in": "Sign in", + "sign_up": "Sign up", + "see_more": "See More", + "preview": "Preview", + "open_in_safari": "Open in Safari" + }, + "status": { + "user_boosted": "%s boosted", + "content_warning": "content warning", + "show_post": "Show Post" + }, + "timeline": { + "load_more": "Load More" + } + }, + "countable": { + "photo": { + "single": "photo", + "multiple": "photos" + } + } + }, + "scene": { + "welcome": { + "slogan": "Social networking\nback in your hands." + }, + "server_picker": { + "title": "Pick a Server,\nany server.", + "Button": { + "Category": { + "All": "All" + }, + "SeeLess": "See Less", + "SeeMore": "See More" + }, + "Label": { + "Language": "LANGUAGE", + "Users": "USERS", + "Category": "CATEGORY" + }, + "input": { + "placeholder": "Find a server or join your own..." + } + }, + "register": { + "title": "Tell us about you.", + "input": { + "username": { + "placeholder": "username", + "duplicate_prompt": "This username is taken." + }, + "display_name": { + "placeholder": "display name" + }, + "email": { + "placeholder": "email" + }, + "password": { + "placeholder": "password", + "prompt": "Your password needs at least:", + "prompt_eight_characters": "Eight characters" + } + } + }, + "server_rules": { + "title": "Some ground rules.", + "subtitle": "These rules are set by the admins of %s.", + "prompt": "By continuing, you're subject to the terms of service and privacy policy for %s.", + "button": { + "confirm": "I Agree" + } + }, + "home_timeline": { + "title": "Home" + }, + "public_timeline": { + "title": "Public" + } + } +} diff --git a/Localization/StringsConvertor/output/en.lproj/Localizable.strings b/Localization/StringsConvertor/output/en.lproj/Localizable.strings new file mode 100644 index 00000000..9aee2d45 --- /dev/null +++ b/Localization/StringsConvertor/output/en.lproj/Localizable.strings @@ -0,0 +1,46 @@ +"Common.Controls.Actions.Add" = "Add"; +"Common.Controls.Actions.Cancel" = "Cancel"; +"Common.Controls.Actions.Confirm" = "Confirm"; +"Common.Controls.Actions.Continue" = "Continue"; +"Common.Controls.Actions.Edit" = "Edit"; +"Common.Controls.Actions.Ok" = "OK"; +"Common.Controls.Actions.OpenInSafari" = "Open in Safari"; +"Common.Controls.Actions.Preview" = "Preview"; +"Common.Controls.Actions.Remove" = "Remove"; +"Common.Controls.Actions.Save" = "Save"; +"Common.Controls.Actions.SavePhoto" = "Save photo"; +"Common.Controls.Actions.SeeMore" = "See More"; +"Common.Controls.Actions.SignIn" = "Sign in"; +"Common.Controls.Actions.SignUp" = "Sign up"; +"Common.Controls.Actions.TakePhoto" = "Take photo"; +"Common.Controls.Status.ContentWarning" = "content warning"; +"Common.Controls.Status.ShowPost" = "Show Post"; +"Common.Controls.Status.UserBoosted" = "%@ boosted"; +"Common.Controls.Timeline.LoadMore" = "Load More"; +"Common.Countable.Photo.Multiple" = "photos"; +"Common.Countable.Photo.Single" = "photo"; +"Scene.HomeTimeline.Title" = "Home"; +"Scene.PublicTimeline.Title" = "Public"; +"Scene.Register.Input.DisplayName.Placeholder" = "display name"; +"Scene.Register.Input.Email.Placeholder" = "email"; +"Scene.Register.Input.Password.Placeholder" = "password"; +"Scene.Register.Input.Password.Prompt" = "Your password needs at least:"; +"Scene.Register.Input.Password.PromptEightCharacters" = "Eight characters"; +"Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken."; +"Scene.Register.Input.Username.Placeholder" = "username"; +"Scene.Register.Title" = "Tell us about you."; +"Scene.ServerPicker.Button.Category.All" = "All"; +"Scene.ServerPicker.Button.Seeless" = "See Less"; +"Scene.ServerPicker.Button.Seemore" = "See More"; +"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; +"Scene.ServerPicker.Label.Category" = "CATEGORY"; +"Scene.ServerPicker.Label.Language" = "LANGUAGE"; +"Scene.ServerPicker.Label.Users" = "USERS"; +"Scene.ServerPicker.Title" = "Pick a Server, +any server."; +"Scene.ServerRules.Button.Confirm" = "I Agree"; +"Scene.ServerRules.Prompt" = "By continuing, you're subject to the terms of service and privacy policy for %@."; +"Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; +"Scene.ServerRules.Title" = "Some ground rules."; +"Scene.Welcome.Slogan" = "Social networking +back in your hands."; \ No newline at end of file diff --git a/Localization/app.json b/Localization/app.json index 6ab65832..538b7a76 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -51,6 +51,18 @@ }, "server_picker": { "title": "Pick a Server,\nany server.", + "Button": { + "Category": { + "All": "All" + }, + "SeeLess": "See Less", + "SeeMore": "See More" + }, + "Label": { + "Language": "LANGUAGE", + "Users": "USERS", + "Category": "CATEGORY" + }, "input": { "placeholder": "Find a server or join your own..." } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 51d06791..d8fff17c 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -11,6 +11,13 @@ 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; }; 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; }; 0FAA102725E1126A0017CCDE /* PickServerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA102625E1126A0017CCDE /* PickServerViewController.swift */; }; + 0FB3D2F725E4C24D00AAD544 /* PickServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D2F625E4C24D00AAD544 /* PickServerViewModel.swift */; }; + 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D2FD25E4CB6400AAD544 /* PickServerTitleCell.swift */; }; + 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */; }; + 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */; }; + 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D31D25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift */; }; + 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */; }; + 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; }; 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; }; @@ -211,6 +218,13 @@ 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = ""; }; 0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; 0FAA102625E1126A0017CCDE /* PickServerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerViewController.swift; sourceTree = ""; }; + 0FB3D2F625E4C24D00AAD544 /* PickServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerViewModel.swift; sourceTree = ""; }; + 0FB3D2FD25E4CB6400AAD544 /* PickServerTitleCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerTitleCell.swift; sourceTree = ""; }; + 0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoriesCell.swift; sourceTree = ""; }; + 0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoryView.swift; sourceTree = ""; }; + 0FB3D31D25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoryCollectionViewCell.swift; sourceTree = ""; }; + 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSearchCell.swift; sourceTree = ""; }; + 0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = ""; }; 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; }; 2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; @@ -428,11 +442,42 @@ 0FAA102525E1125D0017CCDE /* PickServer */ = { isa = PBXGroup; children = ( + 0FB3D31825E525DE00AAD544 /* CollectionViewCell */, + 0FB3D30D25E525C000AAD544 /* View */, + 0FB3D2FC25E4CB4B00AAD544 /* TableViewCell */, 0FAA102625E1126A0017CCDE /* PickServerViewController.swift */, + 0FB3D2F625E4C24D00AAD544 /* PickServerViewModel.swift */, ); path = PickServer; sourceTree = ""; }; + 0FB3D2FC25E4CB4B00AAD544 /* TableViewCell */ = { + isa = PBXGroup; + children = ( + 0FB3D2FD25E4CB6400AAD544 /* PickServerTitleCell.swift */, + 0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */, + 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */, + 0FB3D33725E6401400AAD544 /* PickServerCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; + 0FB3D30D25E525C000AAD544 /* View */ = { + isa = PBXGroup; + children = ( + 0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */, + ); + path = View; + sourceTree = ""; + }; + 0FB3D31825E525DE00AAD544 /* CollectionViewCell */ = { + isa = PBXGroup; + children = ( + 0FB3D31D25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift */, + ); + path = CollectionViewCell; + sourceTree = ""; + }; 1EBA4F56E920856A3FC84ACB /* Pods */ = { isa = PBXGroup; children = ( @@ -1367,8 +1412,10 @@ 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, + 0FB3D2F725E4C24D00AAD544 /* PickServerViewModel.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, + 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, @@ -1403,6 +1450,7 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, + 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, @@ -1418,6 +1466,8 @@ DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, + 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, + 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, @@ -1461,7 +1511,10 @@ 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, + 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, + 2D5A3D1125CF87AA002347D6 /* AvatarBarButtonItem.swift in Sources */, + 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 4ae96f9e..304facf8 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -39,6 +39,7 @@ extension SceneCoordinator { enum Scene { case welcome + case pickServer(viewMode: PickServerViewModel) case authentication(viewModel: AuthenticationViewModel) case mastodonPinBasedAuthentication(viewModel: MastodonPinBasedAuthenticationViewModel) case mastodonRegister(viewModel: MastodonRegisterViewModel) @@ -142,6 +143,10 @@ private extension SceneCoordinator { case .welcome: let _viewController = WelcomeViewController() viewController = _viewController + case .pickServer(let viewModel): + let _viewController = PickServerViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .authentication(let viewModel): let _viewController = AuthenticationViewController() _viewController.viewModel = viewModel diff --git a/Mastodon/Extension/UIFont.swift b/Mastodon/Extension/UIFont.swift index 63f70866..eec4f89e 100644 --- a/Mastodon/Extension/UIFont.swift +++ b/Mastodon/Extension/UIFont.swift @@ -2,7 +2,7 @@ // UIFont.swift // Mastodon // -// Created by 高原 on 2021/2/20. +// Created by BradGao on 2021/2/20. // import UIKit diff --git a/Mastodon/Extension/UIView.swift b/Mastodon/Extension/UIView.swift index d9e3af5b..e62ba3cb 100644 --- a/Mastodon/Extension/UIView.swift +++ b/Mastodon/Extension/UIView.swift @@ -31,4 +31,28 @@ extension UIView { layer.cornerCurve = .continuous return self } + + @discardableResult + func applyShadow( + color: UIColor, + alpha: Float, + x: CGFloat, + y: CGFloat, + blur: CGFloat, + spread: CGFloat = 0) -> Self + { + layer.masksToBounds = false + layer.shadowColor = color.cgColor + layer.shadowOpacity = alpha + layer.shadowOffset = CGSize(width: x, height: y) + layer.shadowRadius = blur / 2.0 + if spread == 0 { + layer.shadowPath = nil + } else { + let dx = -spread + let rect = bounds.insetBy(dx: dx, dy: dx) + layer.shadowPath = UIBezierPath(rect: rect).cgPath + } + return self + } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index c032874d..88a7c016 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -160,10 +160,28 @@ internal enum L10n { internal enum ServerPicker { /// Pick a Server,\nany server. internal static let title = L10n.tr("Localizable", "Scene.ServerPicker.Title") + internal enum Button { + /// See Less + internal static let seeless = L10n.tr("Localizable", "Scene.ServerPicker.Button.Seeless") + /// See More + internal static let seemore = L10n.tr("Localizable", "Scene.ServerPicker.Button.Seemore") + internal enum Category { + /// All + internal static let all = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.All") + } + } internal enum Input { /// Find a server or join your own... internal static let placeholder = L10n.tr("Localizable", "Scene.ServerPicker.Input.Placeholder") } + internal enum Label { + /// CATEGORY + internal static let category = L10n.tr("Localizable", "Scene.ServerPicker.Label.Category") + /// LANGUAGE + internal static let language = L10n.tr("Localizable", "Scene.ServerPicker.Label.Language") + /// USERS + internal static let users = L10n.tr("Localizable", "Scene.ServerPicker.Label.Users") + } } internal enum ServerRules { /// By continuing, you're subject to the terms of service and privacy policy for %@. diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index de05b7ff..19180b69 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -47,7 +47,13 @@ tap the link to confirm your account."; "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"; +"Scene.ServerPicker.Button.Seemore" = "See More"; "Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; +"Scene.ServerPicker.Label.Category" = "CATEGORY"; +"Scene.ServerPicker.Label.Language" = "LANGUAGE"; +"Scene.ServerPicker.Label.Users" = "USERS"; "Scene.ServerPicker.Title" = "Pick a Server, any server."; "Scene.ServerRules.Button.Confirm" = "I Agree"; @@ -55,4 +61,4 @@ any server."; "Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; "Scene.ServerRules.Title" = "Some ground rules."; "Scene.Welcome.Slogan" = "Social networking -back in your hands."; \ No newline at end of file +back in your hands."; diff --git a/Mastodon/Scene/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift b/Mastodon/Scene/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift new file mode 100644 index 00000000..587ffbae --- /dev/null +++ b/Mastodon/Scene/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift @@ -0,0 +1,52 @@ +// +// PickServerCategoryCollectionViewCell.swift +// Mastodon +// +// Created by BradGao on 2021/2/23. +// + +import UIKit + +class PickServerCategoryCollectionViewCell: UICollectionViewCell { + + var category: PickServerViewModel.Category? { + didSet { + categoryView.category = category + } + } + + var categoryView: PickServerCategoryView = { + let view = PickServerCategoryView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + override var isSelected: Bool { + didSet { + categoryView.selected = isSelected + } + } + + override init(frame: CGRect) { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension PickServerCategoryCollectionViewCell { + private func configure() { + contentView.addSubview(categoryView) + + NSLayoutConstraint.activate([ + categoryView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + categoryView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + categoryView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), + contentView.bottomAnchor.constraint(equalTo: categoryView.bottomAnchor, constant: 10), + ]) + } +} diff --git a/Mastodon/Scene/PickServer/PickServerViewController.swift b/Mastodon/Scene/PickServer/PickServerViewController.swift index b05fe745..ef87e814 100644 --- a/Mastodon/Scene/PickServer/PickServerViewController.swift +++ b/Mastodon/Scene/PickServer/PickServerViewController.swift @@ -2,20 +2,418 @@ // PickServerViewController.swift // Mastodon // -// Created by 高原 on 2021/2/20. +// Created by BradGao on 2021/2/20. // import UIKit +import Combine +import OSLog +import MastodonSDK -class PickServerViewController: UIViewController { - let titleLabel: UILabel = { - let label = UILabel() - label.font = .boldSystemFont(ofSize: 34) - label.textColor = Asset.Colors.Label.primary.color - label.text = L10n.Scene.ServerPicker.title - label.adjustsFontForContentSizeCategory = true - label.translatesAutoresizingMaskIntoConstraints = false - label.numberOfLines = 0 - return label +final class PickServerViewController: UIViewController, NeedsDependency { + + private var disposeBag = Set() + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var viewModel: PickServerViewModel! + + 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() + tableView.register(PickServerTitleCell.self, forCellReuseIdentifier: String(describing: PickServerTitleCell.self)) + tableView.register(PickServerCategoriesCell.self, forCellReuseIdentifier: String(describing: PickServerCategoriesCell.self)) + tableView.register(PickServerSearchCell.self, forCellReuseIdentifier: String(describing: PickServerSearchCell.self)) + tableView.register(PickServerCell.self, forCellReuseIdentifier: String(describing: PickServerCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + tableView.keyboardDismissMode = .onDrag + tableView.translatesAutoresizingMaskIntoConstraints = false + + return tableView + }() + + let nextStepButton: PrimaryActionButton = { + let button = PrimaryActionButton(type: .system) + button.setTitle(L10n.Common.Controls.Actions.signUp, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + return button }() } + +extension PickServerViewController { + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.onboardingBackground.color + + view.addSubview(nextStepButton) + NSLayoutConstraint.activate([ + nextStepButton.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: 12), + view.readableContentGuide.trailingAnchor.constraint(equalTo: nextStepButton.trailingAnchor, constant: 12), + view.bottomAnchor.constraint(equalTo: nextStepButton.bottomAnchor, constant: 34), + ]) + + view.addSubview(tableView) + NSLayoutConstraint.activate([ + 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) + ]) + + switch viewModel.mode { + case .signIn: + nextStepButton.setTitle(L10n.Common.Controls.Actions.signIn, for: .normal) + case .signUp: + nextStepButton.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) + } + nextStepButton.addTarget(self, action: #selector(nextStepButtonDidClicked(_:)), for: .touchUpInside) + +// viewModel.tableView = tableView + tableView.delegate = self + tableView.dataSource = self + + viewModel + .searchedServers + .receive(on: DispatchQueue.main) + .sink { _ in + + } receiveValue: { [weak self] servers in + self?.tableView.reloadSections(IndexSet(integer: 3), with: .automatic) + 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 + .selectedServer + .map { + $0 != nil + } + .assign(to: \.isEnabled, on: nextStepButton) + .store(in: &disposeBag) + + viewModel.error + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink { [weak self] error in + guard let self = self else { return } + let alertController = UIAlertController(for: error, title: "Error", preferredStyle: .alert) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) + alertController.addAction(okAction) + self.coordinator.present( + scene: .alertController(alertController: alertController), + from: nil, + transition: .alertController(animated: true, completion: nil) + ) + } + .store(in: &disposeBag) + + viewModel + .authenticated + .receive(on: DispatchQueue.main) + .flatMap { [weak self] (domain, user) -> AnyPublisher, Never> in + guard let self = self else { return Just(.success(false)).eraseToAnyPublisher() } + return self.context.authenticationService.activeMastodonUser(domain: domain, userID: user.id) + } + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .success(let isActived): + assert(isActived) + self.coordinator.setup() + } + } + .store(in: &disposeBag) + + isAuthenticating + .receive(on: DispatchQueue.main) + .sink { [weak self] loading in + if loading { + self?.nextStepButton.showLoading() + } else { + self?.nextStepButton.stopLoading() + } + } + .store(in: &disposeBag) + + viewModel.fetchAllServers() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(false, animated: animated) + } + + @objc + private func nextStepButtonDidClicked(_ sender: UIButton) { + switch viewModel.mode { + case .signIn: + doSignIn() + case .signUp: + doSignUp() + } + } + + private func doSignIn() { + guard let server = viewModel.selectedServer.value else { return } + isAuthenticating.send(true) + context.apiService.createApplication(domain: server.domain) + .tryMap { response -> PickServerViewModel.AuthenticateInfo in + let application = response.value + guard let info = PickServerViewModel.AuthenticateInfo(domain: server.domain, application: application) else { + throw APIService.APIError.explicit(.badResponse) + } + return info + } + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + self.isAuthenticating.send(false) + + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign in fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + self.viewModel.error.send(error) + case .finished: + break + } + } receiveValue: { [weak self] info in + guard let self = self else { return } + let mastodonPinBasedAuthenticationViewModel = MastodonPinBasedAuthenticationViewModel(authenticateURL: info.authorizeURL) + self.viewModel.authenticate( + info: info, + pinCodePublisher: mastodonPinBasedAuthenticationViewModel.pinCodePublisher + ) + self.viewModel.mastodonPinBasedAuthenticationViewController = self.coordinator.present( + scene: .mastodonPinBasedAuthentication(viewModel: mastodonPinBasedAuthenticationViewModel), + from: nil, + transition: .modal(animated: true, completion: nil) + ) + } + .store(in: &disposeBag) + } + + 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) + + context.apiService.instance(domain: server.domain) + .compactMap { [weak self] response -> AnyPublisher? in + guard let self = self else { return nil } + guard response.value.registrations != false else { + return Fail(error: AuthenticationViewModel.AuthenticationError.registrationClosed).eraseToAnyPublisher() + } + return self.context.apiService.createApplication(domain: server.domain) + .map { PickServerViewModel.SignUpResponseFirst(instance: response, application: $0) } + .eraseToAnyPublisher() + } + .switchToLatest() + .tryMap { response -> PickServerViewModel.SignUpResponseSecond in + let application = response.application.value + guard let authenticateInfo = AuthenticationViewModel.AuthenticateInfo(domain: server.domain, application: application) else { + throw APIService.APIError.explicit(.badResponse) + } + return PickServerViewModel.SignUpResponseSecond(instance: response.instance, authenticateInfo: authenticateInfo) + } + .compactMap { [weak self] response -> AnyPublisher? in + guard let self = self else { return nil } + let instance = response.instance + let authenticateInfo = response.authenticateInfo + return self.context.apiService.applicationAccessToken( + domain: server.domain, + clientID: authenticateInfo.clientID, + clientSecret: authenticateInfo.clientSecret + ) + .map { PickServerViewModel.SignUpResponseThird(instance: instance, authenticateInfo: authenticateInfo, applicationToken: $0) } + .eraseToAnyPublisher() + } + .switchToLatest() + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + self.isAuthenticating.send(false) + + switch completion { + case .failure(let error): + self.viewModel.error.send(error) + case .finished: + break + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + let mastodonRegisterViewModel = MastodonRegisterViewModel( + domain: server.domain, + authenticateInfo: response.authenticateInfo, + instance: response.instance.value, + applicationToken: response.applicationToken.value + ) + self.coordinator.present(scene: .mastodonRegister(viewModel: mastodonRegisterViewModel), from: self, transition: .show) + } + .store(in: &disposeBag) + } +} + +extension PickServerViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + let category = Section.allCases[section] + switch category { + case .title: + return 20 + case .categories: + // 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: + // Header with 1 height as the separator + return 1 + } + } + + func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + 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) { + tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) + viewModel.selectedServer.send(viewModel.searchedServers.value[indexPath.row]) + } + + func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + viewModel.selectedServer.send(nil) + } +} + +extension PickServerViewController: 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 PickServerViewController: 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) + } + } +} + +extension PickServerViewController: PickServerSearchCellDelegate { + func pickServerSearchCell(didChange searchText: String?) { + viewModel.searchText.send(searchText) + } +} + +extension PickServerViewController: PickServerCategoriesDataSource, PickServerCategoriesDelegate { + func numberOfCategories() -> Int { + return viewModel.categories.count + } + + func category(at index: Int) -> PickServerViewModel.Category { + return viewModel.categories[index] + } + + func selectedIndex() -> Int { + return viewModel.selectCategoryIndex.value + } + + func pickServerCategoriesCell(didSelect index: Int) { + return viewModel.selectCategoryIndex.send(index) + } +} diff --git a/Mastodon/Scene/PickServer/PickServerViewModel.swift b/Mastodon/Scene/PickServer/PickServerViewModel.swift new file mode 100644 index 00000000..b6bc0798 --- /dev/null +++ b/Mastodon/Scene/PickServer/PickServerViewModel.swift @@ -0,0 +1,341 @@ +// +// PickServerViewModel.swift +// Mastodon +// +// Created by BradGao on 2021/2/23. +// + +import UIKit +import OSLog +import Combine +import MastodonSDK +import CoreDataStack + +class PickServerViewModel: NSObject { + enum PickServerMode { + case signUp + 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 "❓" + } + } + } + } + + let mode: PickServerMode + let context: AppContext + + var categories = [Category]() + let selectCategoryIndex = CurrentValueSubject(0) + + let searchText = CurrentValueSubject(nil) + + let allServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) + let searchedServers = 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? + +// private var expandServerDomainSet = Set() + + var mastodonPinBasedAuthenticationViewController: UIViewController? + + init(context: AppContext, mode: PickServerMode) { + self.context = context + self.mode = mode + super.init() + + configure() + } + + 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 + ) + .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?.searchedServers.send(servers) + case .failure(let error): + // TODO: What should be presented when user inputs invalid search text? + self?.searchedServers.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 + } + } + } +} + +// MARK: - SignIn methods & structs +extension PickServerViewModel { + enum AuthenticationError: Error, LocalizedError { + case badCredentials + case registrationClosed + + var errorDescription: String? { + switch self { + case .badCredentials: return "Bad Credentials" + case .registrationClosed: return "Registration Closed" + } + } + + var failureReason: String? { + switch self { + case .badCredentials: return "Credentials invalid." + case .registrationClosed: return "Server disallow registration." + } + } + + var helpAnchor: String? { + switch self { + case .badCredentials: return "Please try again." + case .registrationClosed: return "Please try another domain." + } + } + } + + struct AuthenticateInfo { + let domain: String + let clientID: String + let clientSecret: String + let authorizeURL: URL + + init?(domain: String, application: Mastodon.Entity.Application) { + self.domain = domain + guard let clientID = application.clientID, + let clientSecret = application.clientSecret else { return nil } + self.clientID = clientID + self.clientSecret = clientSecret + self.authorizeURL = { + let query = Mastodon.API.OAuth.AuthorizeQuery(clientID: clientID) + let url = Mastodon.API.OAuth.authorizeURL(domain: domain, query: query) + return url + }() + } + } + + func authenticate(info: AuthenticateInfo, pinCodePublisher: PassthroughSubject) { + pinCodePublisher + .handleEvents(receiveOutput: { [weak self] _ in + guard let self = self else { return } +// self.isAuthenticating.value = true + self.mastodonPinBasedAuthenticationViewController?.dismiss(animated: true, completion: nil) + self.mastodonPinBasedAuthenticationViewController = nil + }) + .compactMap { [weak self] code -> AnyPublisher, Error>? in + guard let self = self else { return nil } + return self.context.apiService + .userAccessToken( + domain: info.domain, + clientID: info.clientID, + clientSecret: info.clientSecret, + code: code + ) + .flatMap { response -> AnyPublisher, Error> in + let token = response.value + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign in success. Token: %s", ((#file as NSString).lastPathComponent), #line, #function, token.accessToken) + return Self.verifyAndSaveAuthentication( + context: self.context, + info: info, + userToken: token + ) + } + .eraseToAnyPublisher() + } + .switchToLatest() + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: swap user access token swap fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) +// self.isAuthenticating.value = false + self.error.send(error) + case .finished: + break + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + let account = response.value + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: user %s sign in success", ((#file as NSString).lastPathComponent), #line, #function, account.username) + + self.authenticated.send((domain: info.domain, account: account)) + } + .store(in: &self.disposeBag) + } + + static func verifyAndSaveAuthentication( + context: AppContext, + info: AuthenticateInfo, + userToken: Mastodon.Entity.Token + ) -> AnyPublisher, Error> { + let authorization = Mastodon.API.OAuth.Authorization(accessToken: userToken.accessToken) + let managedObjectContext = context.backgroundManagedObjectContext + + return context.apiService.accountVerifyCredentials( + domain: info.domain, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let account = response.value + let mastodonUserRequest = MastodonUser.sortedFetchRequest + mastodonUserRequest.predicate = MastodonUser.predicate(domain: info.domain, id: account.id) + mastodonUserRequest.fetchLimit = 1 + guard let mastodonUser = try? managedObjectContext.fetch(mastodonUserRequest).first else { + return Fail(error: AuthenticationError.badCredentials).eraseToAnyPublisher() + } + + let property = MastodonAuthentication.Property( + domain: info.domain, + userID: mastodonUser.id, + username: mastodonUser.username, + appAccessToken: userToken.accessToken, // TODO: swap app token + userAccessToken: userToken.accessToken, + clientID: info.clientID, + clientSecret: info.clientSecret + ) + return managedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeMastodonAuthentication( + into: managedObjectContext, + for: mastodonUser, + in: info.domain, + property: property, + networkDate: response.networkDate + ) + } + .tryMap { result in + switch result { + case .failure(let error): throw error + case .success: return response + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } +} + +// MARK: - SignUp methods & structs +extension PickServerViewModel { + struct SignUpResponseFirst { + let instance: Mastodon.Response.Content + let application: Mastodon.Response.Content + } + + struct SignUpResponseSecond { + let instance: Mastodon.Response.Content + let authenticateInfo: AuthenticationViewModel.AuthenticateInfo + } + + struct SignUpResponseThird { + let instance: Mastodon.Response.Content + let authenticateInfo: AuthenticationViewModel.AuthenticateInfo + let applicationToken: Mastodon.Response.Content + } +} diff --git a/Mastodon/Scene/PickServer/TableViewCell/PickServerCategoriesCell.swift b/Mastodon/Scene/PickServer/TableViewCell/PickServerCategoriesCell.swift new file mode 100644 index 00000000..b324fe83 --- /dev/null +++ b/Mastodon/Scene/PickServer/TableViewCell/PickServerCategoriesCell.swift @@ -0,0 +1,110 @@ +// +// PickServerCategoriesCell.swift +// Mastodon +// +// Created by BradGao on 2021/2/23. +// + +import UIKit +import MastodonSDK + +protocol PickServerCategoriesDataSource: class { + func numberOfCategories() -> Int + func category(at index: Int) -> PickServerViewModel.Category + func selectedIndex() -> Int +} + +protocol PickServerCategoriesDelegate: class { + func pickServerCategoriesCell(didSelect index: Int) +} + +final class PickServerCategoriesCell: UITableViewCell { + + weak var dataSource: PickServerCategoriesDataSource! + weak var delegate: PickServerCategoriesDelegate! + + let collectionView: UICollectionView = { + let flowLayout = UICollectionViewFlowLayout() + flowLayout.scrollDirection = .horizontal + let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) + view.backgroundColor = .clear + view.showsHorizontalScrollIndicator = false + view.register(PickServerCategoryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self)) + view.showsVerticalScrollIndicator = false + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } +} + +extension PickServerCategoriesCell { + + private func _init() { + self.selectionStyle = .none + backgroundColor = .clear + + contentView.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + collectionView.topAnchor.constraint(equalTo: contentView.topAnchor), + collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + + collectionView.heightAnchor.constraint(equalToConstant: 80), + ]) + + collectionView.delegate = self + collectionView.dataSource = self + } +} + +extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) + delegate.pickServerCategoriesCell(didSelect: indexPath.row) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { + return UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) + } + + 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 + } + + +} + diff --git a/Mastodon/Scene/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/PickServer/TableViewCell/PickServerCell.swift new file mode 100644 index 00000000..d7deba29 --- /dev/null +++ b/Mastodon/Scene/PickServer/TableViewCell/PickServerCell.swift @@ -0,0 +1,355 @@ +// +// PickServerCell.swift +// Mastodon +// +// Created by BradGao on 2021/2/24. +// + +import UIKit +import MastodonSDK +import Kingfisher + +protocol PickServerCellDelegate: class { + func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) +} + +class PickServerCell: UITableViewCell { + + weak var delegate: PickServerCellDelegate? + + enum Mode { + case collapse + case expand + } + + private var bgView: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.lightWhite.color + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var domainLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .headline) + label.textColor = Asset.Colors.lightDarkGray.color + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private var checkbox: UIImageView = { + let imageView = UIImageView() + imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) + imageView.tintColor = Asset.Colors.lightSecondaryText.color + imageView.contentMode = .scaleAspectFill + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private var descriptionLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .subheadline) + label.numberOfLines = 0 + label.textColor = Asset.Colors.lightDarkGray.color + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private var thumbImageView: UIImageView = { + let imageView = UIImageView() + imageView.clipsToBounds = true + imageView.contentMode = .scaleAspectFill + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private var infoStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.alignment = .fill + stackView.distribution = .fillEqually + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + private var expandBox: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var 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) + button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal) + button.titleLabel?.font = .preferredFont(forTextStyle: .footnote) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private var seperator: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.lightBackground.color + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var langValueLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.lightDarkGray.color + label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) + label.textAlignment = .center + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private var usersValueLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.lightDarkGray.color + label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) + label.textAlignment = .center + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private var categoryValueLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.lightDarkGray.color + label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) + label.textAlignment = .center + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private var langTitleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.lightDarkGray.color + label.font = .preferredFont(forTextStyle: .caption2) + label.text = L10n.Scene.ServerPicker.Label.language + label.textAlignment = .center + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private var usersTitleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.lightDarkGray.color + label.font = .preferredFont(forTextStyle: .caption2) + label.text = L10n.Scene.ServerPicker.Label.users + label.textAlignment = .center + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private var categoryTitleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.lightDarkGray.color + label.font = .preferredFont(forTextStyle: .caption2) + label.text = L10n.Scene.ServerPicker.Label.category + label.textAlignment = .center + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private var collapseConstraints: [NSLayoutConstraint] = [] + private var expandConstraints: [NSLayoutConstraint] = [] + + var mode: PickServerCell.Mode = .collapse { + didSet { + updateMode() + } + } + + var server: Mastodon.Entity.Server? { + didSet { + updateServerInfo() + } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } +} + +// MARK: - Methods to configure appearance +extension PickServerCell { + private func _init() { + selectionStyle = .none + backgroundColor = .clear + + contentView.addSubview(bgView) + contentView.addSubview(domainLabel) + contentView.addSubview(checkbox) + contentView.addSubview(descriptionLabel) + contentView.addSubview(seperator) + + contentView.addSubview(expandButton) + + // Always add the expandbox which contains elements only visible in expand mode + contentView.addSubview(expandBox) + expandBox.addSubview(thumbImageView) + expandBox.addSubview(infoStackView) + expandBox.isHidden = true + + let verticalInfoStackViewLang = makeVerticalInfoStackView(arrangedView: langValueLabel, langTitleLabel) + let verticalInfoStackViewUsers = makeVerticalInfoStackView(arrangedView: usersValueLabel, usersTitleLabel) + let verticalInfoStackViewCategory = makeVerticalInfoStackView(arrangedView: categoryValueLabel, categoryTitleLabel) + infoStackView.addArrangedSubview(verticalInfoStackViewLang) + infoStackView.addArrangedSubview(verticalInfoStackViewUsers) + infoStackView.addArrangedSubview(verticalInfoStackViewCategory) + + let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required) + collapseConstraints.append(expandButtonTopConstraintInCollapse) + + let expandButtonTopConstraintInExpand = expandButton.topAnchor.constraint(equalTo: expandBox.bottomAnchor, constant: 8).priority(.required) + expandConstraints.append(expandButtonTopConstraintInExpand) + +// domainLabel.setContentHuggingPriority(.required - 1, for: .vertical) +// domainLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) +// descriptionLabel.setContentHuggingPriority(.required - 2, for: .vertical) +// descriptionLabel.setContentCompressionResistancePriority(.required - 2, for: .vertical) + domainLabel.setContentHuggingPriority(.required, for: .vertical) + domainLabel.setContentCompressionResistancePriority(.required, for: .vertical) + descriptionLabel.setContentHuggingPriority(.defaultHigh, for: .vertical) + descriptionLabel.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + + NSLayoutConstraint.activate([ + // Set background view + bgView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + bgView.topAnchor.constraint(equalTo: contentView.topAnchor), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: bgView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: bgView.bottomAnchor, constant: 1), + + // Set bottom separator + seperator.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: seperator.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: seperator.bottomAnchor), + seperator.heightAnchor.constraint(equalToConstant: 1), + + domainLabel.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 16), + domainLabel.topAnchor.constraint(equalTo: bgView.topAnchor, constant: 16), + + checkbox.widthAnchor.constraint(equalToConstant: 23), + checkbox.heightAnchor.constraint(equalToConstant: 22), + bgView.trailingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 16), + checkbox.leadingAnchor.constraint(equalTo: domainLabel.trailingAnchor, constant: 16), + checkbox.centerYAnchor.constraint(equalTo: domainLabel.centerYAnchor), + + descriptionLabel.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 16), + descriptionLabel.topAnchor.constraint(equalTo: domainLabel.firstBaselineAnchor, constant: 8).priority(.required), + bgView.trailingAnchor.constraint(equalTo: descriptionLabel.trailingAnchor, constant: 16), + + // Set expandBox constraints + expandBox.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 16), + bgView.trailingAnchor.constraint(equalTo: expandBox.trailingAnchor, constant: 16), + expandBox.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 8), + expandBox.bottomAnchor.constraint(equalTo: infoStackView.bottomAnchor).priority(.defaultHigh), + + thumbImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor), + expandBox.trailingAnchor.constraint(equalTo: thumbImageView.trailingAnchor), + thumbImageView.topAnchor.constraint(equalTo: expandBox.topAnchor).priority(.defaultHigh), + thumbImageView.heightAnchor.constraint(equalTo: thumbImageView.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), + + expandButton.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 16), + bgView.trailingAnchor.constraint(equalTo: expandButton.trailingAnchor, constant: 16), + bgView.bottomAnchor.constraint(equalTo: expandButton.bottomAnchor, constant: 8), + ]) + + NSLayoutConstraint.activate(collapseConstraints) + + expandButton.addTarget(self, action: #selector(expandButtonDidClicked(_:)), for: .touchUpInside) + + } + + private func makeVerticalInfoStackView(arrangedView: UIView...) -> UIStackView { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .center + stackView.distribution = .equalCentering + stackView.spacing = 2 + arrangedView.forEach { stackView.addArrangedSubview($0) } + return stackView + } + + private func updateMode() { + switch mode { + case .collapse: + expandBox.isHidden = true + expandButton.isSelected = false + NSLayoutConstraint.deactivate(expandConstraints) + NSLayoutConstraint.activate(collapseConstraints) + case .expand: + expandBox.isHidden = false + expandButton.isSelected = true + NSLayoutConstraint.activate(expandConstraints) + NSLayoutConstraint.deactivate(collapseConstraints) + } + } + + 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") + } + } + + @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 = 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 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/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/PickServer/TableViewCell/PickServerSearchCell.swift new file mode 100644 index 00000000..6df8affa --- /dev/null +++ b/Mastodon/Scene/PickServer/TableViewCell/PickServerSearchCell.swift @@ -0,0 +1,103 @@ +// +// PickServerSearchCell.swift +// Mastodon +// +// Created by BradGao on 2021/2/24. +// + +import UIKit + +protocol PickServerSearchCellDelegate: class { + func pickServerSearchCell(didChange searchText: String?) +} + +class PickServerSearchCell: UITableViewCell { + + weak var delegate: PickServerSearchCellDelegate? + + private var bgView: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.lightWhite.color + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.maskedCorners = [ + .layerMinXMinYCorner, + .layerMaxXMinYCorner + ] + view.layer.cornerCurve = .continuous + view.layer.cornerRadius = 10 + return view + }() + + private var textFieldBgView: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.lightBackground.color.withAlphaComponent(0.6) + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.masksToBounds = true + view.layer.cornerRadius = 6 + view.layer.cornerCurve = .continuous + return view + }() + + private var searchTextField: UITextField = { + let textField = UITextField() + textField.translatesAutoresizingMaskIntoConstraints = false + textField.font = .preferredFont(forTextStyle: .headline) + textField.tintColor = Asset.Colors.lightDarkGray.color + textField.textColor = Asset.Colors.lightDarkGray.color + textField.adjustsFontForContentSizeCategory = true + textField.attributedPlaceholder = + NSAttributedString(string: L10n.Scene.ServerPicker.Input.placeholder, + attributes: [.font: UIFont.preferredFont(forTextStyle: .headline), + .foregroundColor: Asset.Colors.lightSecondaryText.color.withAlphaComponent(0.6)]) + textField.clearButtonMode = .whileEditing + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + return textField + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } +} + +extension PickServerSearchCell { + private func _init() { + self.selectionStyle = .none + backgroundColor = .clear + + searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged) + + contentView.addSubview(bgView) + contentView.addSubview(textFieldBgView) + contentView.addSubview(searchTextField) + + NSLayoutConstraint.activate([ + bgView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + bgView.topAnchor.constraint(equalTo: contentView.topAnchor), + bgView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + bgView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + + textFieldBgView.leadingAnchor.constraint(equalTo: bgView.leadingAnchor, constant: 14), + textFieldBgView.topAnchor.constraint(equalTo: bgView.topAnchor, constant: 12), + bgView.trailingAnchor.constraint(equalTo: textFieldBgView.trailingAnchor, constant: 14), + bgView.bottomAnchor.constraint(equalTo: textFieldBgView.bottomAnchor, constant: 13), + + searchTextField.leadingAnchor.constraint(equalTo: textFieldBgView.leadingAnchor, constant: 11), + searchTextField.topAnchor.constraint(equalTo: textFieldBgView.topAnchor, constant: 4), + textFieldBgView.trailingAnchor.constraint(equalTo: searchTextField.trailingAnchor, constant: 11), + textFieldBgView.bottomAnchor.constraint(equalTo: searchTextField.bottomAnchor, constant: 4), + ]) + } +} + +extension PickServerSearchCell { + @objc func textFieldDidChange(_ textField: UITextField) { + delegate?.pickServerSearchCell(didChange: textField.text) + } +} diff --git a/Mastodon/Scene/PickServer/TableViewCell/PickServerTitleCell.swift b/Mastodon/Scene/PickServer/TableViewCell/PickServerTitleCell.swift new file mode 100644 index 00000000..46966733 --- /dev/null +++ b/Mastodon/Scene/PickServer/TableViewCell/PickServerTitleCell.swift @@ -0,0 +1,48 @@ +// +// PickServerTitleCell.swift +// Mastodon +// +// Created by BradGao on 2021/2/23. +// + +import UIKit + +final class PickServerTitleCell: UITableViewCell { + + let titleLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: UIFont.boldSystemFont(ofSize: 34)) + label.textColor = Asset.Colors.Label.primary.color + label.text = L10n.Scene.ServerPicker.title + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 0 + return label + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } +} + +extension PickServerTitleCell { + + private func _init() { + self.selectionStyle = .none + backgroundColor = .clear + + contentView.addSubview(titleLabel) + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor), + titleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } +} diff --git a/Mastodon/Scene/PickServer/View/PickServerCategoryView.swift b/Mastodon/Scene/PickServer/View/PickServerCategoryView.swift new file mode 100644 index 00000000..a4a4f0ef --- /dev/null +++ b/Mastodon/Scene/PickServer/View/PickServerCategoryView.swift @@ -0,0 +1,99 @@ +// +// PickServerCategoryView.swift +// Mastodon +// +// Created by BradGao on 2021/2/23. +// + +import UIKit +import MastodonSDK + +class PickServerCategoryView: UIView { + var category: PickServerViewModel.Category? { + didSet { + updateCategory() + } + } + var selected: Bool = false { + didSet { + updateSelectStatus() + } + } + + var bgShadowView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + var bgView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.masksToBounds = true + view.layer.cornerRadius = 30 + return view + }() + + var titleLabel: UILabel = { + let label = UILabel() + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + init() { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +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 + } + } + } +} diff --git a/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift b/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift index 5a68a800..5533daed 100644 --- a/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift +++ b/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift @@ -2,12 +2,24 @@ // PrimaryActionButton.swift // Mastodon // -// Created by 高原 on 2021/2/20. +// Created by BradGao on 2021/2/20. // import UIKit class PrimaryActionButton: UIButton { + + var isLoading: Bool = false + + lazy var activityIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.hidesWhenStopped = true + indicator.translatesAutoresizingMaskIntoConstraints = false + return indicator + }() + + private var originalButtonTitle: String? + override init(frame: CGRect) { super.init(frame: frame) _init() @@ -17,13 +29,39 @@ class PrimaryActionButton: UIButton { super.init(coder: coder) _init() } + + func showLoading() { + guard !isLoading else { return } + isEnabled = false + isLoading = true + originalButtonTitle = title(for: .disabled) + self.setTitle("", for: .disabled) + + addSubview(activityIndicator) + NSLayoutConstraint.activate([ + activityIndicator.centerXAnchor.constraint(equalTo: self.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: self.centerYAnchor), + ]) + activityIndicator.startAnimating() + } + + func stopLoading() { + guard isLoading else { return } + isLoading = false + if activityIndicator.superview == self { + activityIndicator.removeFromSuperview() + } + isEnabled = true + self.setTitle(originalButtonTitle, for: .disabled) + } } extension PrimaryActionButton { private func _init() { titleLabel?.font = .preferredFont(forTextStyle: .headline) setTitleColor(Asset.Colors.lightWhite.color, for: .normal) - backgroundColor = Asset.Colors.lightBrandBlue.color + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.lightBrandBlue.color), for: .normal) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.lightDisabled.color), for: .disabled) applyCornerRadius(radius: 10) setInsets(forContentPadding: UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0), imageTitlePadding: 0) } diff --git a/Mastodon/Scene/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Welcome/WelcomeViewController.swift index 3c95fa4f..f2e30670 100644 --- a/Mastodon/Scene/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Welcome/WelcomeViewController.swift @@ -2,7 +2,7 @@ // WelcomeViewController.swift // Mastodon // -// Created by 高原 on 2021/2/20. +// Created by BradGao on 2021/2/20. // import os.log @@ -13,15 +13,6 @@ final class WelcomeViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } - #if DEBUG - lazy var authenticationViewController: AuthenticationViewController = { - let authenticationViewController = AuthenticationViewController() - authenticationViewController.context = context - authenticationViewController.coordinator = coordinator - return authenticationViewController - }() - #endif - let logoImageView: UIImageView = { let imageView = UIImageView(image: Asset.welcomeLogo.image) imageView.translatesAutoresizingMaskIntoConstraints = false @@ -65,6 +56,10 @@ extension WelcomeViewController { overrideUserInterfaceStyle = .light view.backgroundColor = Asset.Colors.Background.onboardingBackground.color + navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default) + navigationController?.navigationBar.shadowImage = UIImage() + navigationController?.navigationBar.isTranslucent = true + navigationController?.view.backgroundColor = .clear view.addSubview(logoImageView) NSLayoutConstraint.activate([ @@ -93,8 +88,8 @@ extension WelcomeViewController { signInButton.topAnchor.constraint(equalTo: signUpButton.bottomAnchor, constant: 5) ]) - signInButton.addTarget(self, action: #selector(WelcomeViewController.signInButtonPressed(_:)), for: .touchUpInside) - signUpButton.addTarget(self, action: #selector(WelcomeViewController.signUpButtonPressed(_:)), for: .touchUpInside) + signUpButton.addTarget(self, action: #selector(signUpButtonDidClicked(_:)), for: .touchUpInside) + signInButton.addTarget(self, action: #selector(signInButtonDidClicked(_:)), for: .touchUpInside) } override func viewWillAppear(_ animated: Bool) { @@ -105,20 +100,13 @@ extension WelcomeViewController { } extension WelcomeViewController { - - @objc private func signInButtonPressed(_ sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - #if DEBUG - authenticationViewController.viewModel = AuthenticationViewModel(context: context, coordinator: coordinator, isAuthenticationExist: true) - authenticationViewController.viewModel.domain.value = "pawoo.net" - let _ = authenticationViewController.view // trigger view load - authenticationViewController.signInButton.sendActions(for: .touchUpInside) - #endif + @objc + private func signUpButtonDidClicked(_ sender: UIButton) { + coordinator.present(scene: .pickServer(viewMode: PickServerViewModel(context: context, mode: .signUp)), from: self, transition: .show) } - @objc private func signUpButtonPressed(_ sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + @objc + private func signInButtonDidClicked(_ sender: UIButton) { + coordinator.present(scene: .pickServer(viewMode: PickServerViewModel(context: context, mode: .signIn)), from: self, transition: .show) } - } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Server.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Server.swift index 2ff335d2..505ed730 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Server.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Server.swift @@ -9,7 +9,7 @@ import Foundation extension Mastodon.Entity { - public struct Server: Codable { + public struct Server: Codable, Equatable { public let domain: String public let version: String public let description: String @@ -37,6 +37,25 @@ extension Mastodon.Entity { case language case category } + + public init(instance: Instance) { + self.domain = instance.uri + self.version = instance.version ?? "" + self.description = instance.shortDescription ?? instance.description + self.language = instance.languages?.first ?? "" + self.languages = instance.languages ?? [] + self.region = "Unknown" // TODO: how to handle properties not in an instance + self.categories = [] + self.category = "Unknown" + self.proxiedThumbnail = instance.thumbnail + self.totalUsers = instance.statistics?.userCount ?? 0 + self.lastWeekUsers = 0 + self.approvalRequired = instance.approvalRequired ?? false + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.domain.caseInsensitiveCompare(rhs.domain) == .orderedSame + } } }