Merge pull request #23 from tootsuite/feat/serverPicker

Feat/server picker
This commit is contained in:
BradGao 2021-02-26 09:48:48 +08:00 committed by GitHub
commit 2498254c38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1851 additions and 41 deletions

View File

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

View File

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

View File

@ -51,6 +51,18 @@
}, },
"server_picker": { "server_picker": {
"title": "Pick a Server,\nany server.", "title": "Pick a Server,\nany server.",
"Button": {
"Category": {
"All": "All"
},
"SeeLess": "See Less",
"SeeMore": "See More"
},
"Label": {
"Language": "LANGUAGE",
"Users": "USERS",
"Category": "CATEGORY"
},
"input": { "input": {
"placeholder": "Find a server or join your own..." "placeholder": "Find a server or join your own..."
} }

View File

@ -11,6 +11,13 @@
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; }; 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; };
0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; }; 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; };
0FAA102725E1126A0017CCDE /* PickServerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA102625E1126A0017CCDE /* PickServerViewController.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 */; }; 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 */; }; 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; };
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.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 = "<group>"; }; 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = "<group>"; };
0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; }; 0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
0FAA102625E1126A0017CCDE /* PickServerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerViewController.swift; sourceTree = "<group>"; }; 0FAA102625E1126A0017CCDE /* PickServerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerViewController.swift; sourceTree = "<group>"; };
0FB3D2F625E4C24D00AAD544 /* PickServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerViewModel.swift; sourceTree = "<group>"; };
0FB3D2FD25E4CB6400AAD544 /* PickServerTitleCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerTitleCell.swift; sourceTree = "<group>"; };
0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoriesCell.swift; sourceTree = "<group>"; };
0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoryView.swift; sourceTree = "<group>"; };
0FB3D31D25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCategoryCollectionViewCell.swift; sourceTree = "<group>"; };
0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSearchCell.swift; sourceTree = "<group>"; };
0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = "<group>"; };
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = "<group>"; }; 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = "<group>"; };
2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = "<group>"; }; 2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = "<group>"; };
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
@ -428,11 +442,42 @@
0FAA102525E1125D0017CCDE /* PickServer */ = { 0FAA102525E1125D0017CCDE /* PickServer */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0FB3D31825E525DE00AAD544 /* CollectionViewCell */,
0FB3D30D25E525C000AAD544 /* View */,
0FB3D2FC25E4CB4B00AAD544 /* TableViewCell */,
0FAA102625E1126A0017CCDE /* PickServerViewController.swift */, 0FAA102625E1126A0017CCDE /* PickServerViewController.swift */,
0FB3D2F625E4C24D00AAD544 /* PickServerViewModel.swift */,
); );
path = PickServer; path = PickServer;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
0FB3D2FC25E4CB4B00AAD544 /* TableViewCell */ = {
isa = PBXGroup;
children = (
0FB3D2FD25E4CB6400AAD544 /* PickServerTitleCell.swift */,
0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */,
0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */,
0FB3D33725E6401400AAD544 /* PickServerCell.swift */,
);
path = TableViewCell;
sourceTree = "<group>";
};
0FB3D30D25E525C000AAD544 /* View */ = {
isa = PBXGroup;
children = (
0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */,
);
path = View;
sourceTree = "<group>";
};
0FB3D31825E525DE00AAD544 /* CollectionViewCell */ = {
isa = PBXGroup;
children = (
0FB3D31D25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift */,
);
path = CollectionViewCell;
sourceTree = "<group>";
};
1EBA4F56E920856A3FC84ACB /* Pods */ = { 1EBA4F56E920856A3FC84ACB /* Pods */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -1367,8 +1412,10 @@
2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */,
DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */,
2D7631B325C159F700929FB9 /* Item.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */,
0FB3D2F725E4C24D00AAD544 /* PickServerViewModel.swift in Sources */,
2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */,
2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */,
0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */,
2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */,
0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */, 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */,
DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */,
@ -1403,6 +1450,7 @@
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */,
2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */,
0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */,
2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */,
2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */,
2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */,
@ -1418,6 +1466,8 @@
DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */,
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */,
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */,
0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */,
2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */,
DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */,
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */,
@ -1461,7 +1511,10 @@
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */,
2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */,
2D5A3D1125CF87AA002347D6 /* AvatarBarButtonItem.swift in Sources */,
0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */,
DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View File

@ -39,6 +39,7 @@ extension SceneCoordinator {
enum Scene { enum Scene {
case welcome case welcome
case pickServer(viewMode: PickServerViewModel)
case authentication(viewModel: AuthenticationViewModel) case authentication(viewModel: AuthenticationViewModel)
case mastodonPinBasedAuthentication(viewModel: MastodonPinBasedAuthenticationViewModel) case mastodonPinBasedAuthentication(viewModel: MastodonPinBasedAuthenticationViewModel)
case mastodonRegister(viewModel: MastodonRegisterViewModel) case mastodonRegister(viewModel: MastodonRegisterViewModel)
@ -142,6 +143,10 @@ private extension SceneCoordinator {
case .welcome: case .welcome:
let _viewController = WelcomeViewController() let _viewController = WelcomeViewController()
viewController = _viewController viewController = _viewController
case .pickServer(let viewModel):
let _viewController = PickServerViewController()
_viewController.viewModel = viewModel
viewController = _viewController
case .authentication(let viewModel): case .authentication(let viewModel):
let _viewController = AuthenticationViewController() let _viewController = AuthenticationViewController()
_viewController.viewModel = viewModel _viewController.viewModel = viewModel

View File

@ -2,7 +2,7 @@
// UIFont.swift // UIFont.swift
// Mastodon // Mastodon
// //
// Created by on 2021/2/20. // Created by BradGao on 2021/2/20.
// //
import UIKit import UIKit

View File

@ -31,4 +31,28 @@ extension UIView {
layer.cornerCurve = .continuous layer.cornerCurve = .continuous
return self 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
}
} }

View File

@ -160,10 +160,28 @@ internal enum L10n {
internal enum ServerPicker { internal enum ServerPicker {
/// Pick a Server,\nany server. /// Pick a Server,\nany server.
internal static let title = L10n.tr("Localizable", "Scene.ServerPicker.Title") 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 { internal enum Input {
/// Find a server or join your own... /// Find a server or join your own...
internal static let placeholder = L10n.tr("Localizable", "Scene.ServerPicker.Input.Placeholder") 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 { internal enum ServerRules {
/// By continuing, you're subject to the terms of service and privacy policy for %@. /// By continuing, you're subject to the terms of service and privacy policy for %@.

View File

@ -47,7 +47,13 @@ tap the link to confirm your account.";
"Scene.Register.Input.Username.Placeholder" = "username"; "Scene.Register.Input.Username.Placeholder" = "username";
"Scene.Register.Success" = "Success"; "Scene.Register.Success" = "Success";
"Scene.Register.Title" = "Tell us about you."; "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.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, "Scene.ServerPicker.Title" = "Pick a Server,
any server."; any server.";
"Scene.ServerRules.Button.Confirm" = "I Agree"; "Scene.ServerRules.Button.Confirm" = "I Agree";

View File

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

View File

@ -2,20 +2,418 @@
// PickServerViewController.swift // PickServerViewController.swift
// Mastodon // Mastodon
// //
// Created by on 2021/2/20. // Created by BradGao on 2021/2/20.
// //
import UIKit import UIKit
import Combine
import OSLog
import MastodonSDK
class PickServerViewController: UIViewController { final class PickServerViewController: UIViewController, NeedsDependency {
let titleLabel: UILabel = {
let label = UILabel() private var disposeBag = Set<AnyCancellable>()
label.font = .boldSystemFont(ofSize: 34)
label.textColor = Asset.Colors.Label.primary.color weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
label.text = L10n.Scene.ServerPicker.title weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
label.adjustsFontForContentSizeCategory = true
label.translatesAutoresizingMaskIntoConstraints = false var viewModel: PickServerViewModel!
label.numberOfLines = 0
return label private var isAuthenticating = CurrentValueSubject<Bool, Never>(false)
private var expandServerDomainSet = Set<String>()
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<Result<Bool, Error>, 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<PickServerViewModel.SignUpResponseFirst, Error>? 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<PickServerViewModel.SignUpResponseThird, Error>? 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)
}
}

View File

@ -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<Int, Never>(0)
let searchText = CurrentValueSubject<String?, Never>(nil)
let allServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
let searchedServers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([])
let selectedServer = CurrentValueSubject<Mastodon.Entity.Server?, Never>(nil)
let error = PassthroughSubject<Error, Never>()
let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>()
private var disposeBag = Set<AnyCancellable>()
weak var tableView: UITableView?
// private var expandServerDomainSet = Set<String>()
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<Result<[Mastodon.Entity.Server], Error>, 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<Result<[Mastodon.Entity.Server], Error>> 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<String, Never>) {
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<Mastodon.Response.Content<Mastodon.Entity.Account>, 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<Mastodon.Response.Content<Mastodon.Entity.Account>, 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<Mastodon.Response.Content<Mastodon.Entity.Account>, 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<Mastodon.Response.Content<Mastodon.Entity.Account>, 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<Mastodon.Entity.Instance>
let application: Mastodon.Response.Content<Mastodon.Entity.Application>
}
struct SignUpResponseSecond {
let instance: Mastodon.Response.Content<Mastodon.Entity.Instance>
let authenticateInfo: AuthenticationViewModel.AuthenticateInfo
}
struct SignUpResponseThird {
let instance: Mastodon.Response.Content<Mastodon.Entity.Instance>
let authenticateInfo: AuthenticationViewModel.AuthenticateInfo
let applicationToken: Mastodon.Response.Content<Mastodon.Entity.Token>
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,12 +2,24 @@
// PrimaryActionButton.swift // PrimaryActionButton.swift
// Mastodon // Mastodon
// //
// Created by on 2021/2/20. // Created by BradGao on 2021/2/20.
// //
import UIKit import UIKit
class PrimaryActionButton: UIButton { 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) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
_init() _init()
@ -17,13 +29,39 @@ class PrimaryActionButton: UIButton {
super.init(coder: coder) super.init(coder: coder)
_init() _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 { extension PrimaryActionButton {
private func _init() { private func _init() {
titleLabel?.font = .preferredFont(forTextStyle: .headline) titleLabel?.font = .preferredFont(forTextStyle: .headline)
setTitleColor(Asset.Colors.lightWhite.color, for: .normal) 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) applyCornerRadius(radius: 10)
setInsets(forContentPadding: UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0), imageTitlePadding: 0) setInsets(forContentPadding: UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0), imageTitlePadding: 0)
} }

View File

@ -2,7 +2,7 @@
// WelcomeViewController.swift // WelcomeViewController.swift
// Mastodon // Mastodon
// //
// Created by on 2021/2/20. // Created by BradGao on 2021/2/20.
// //
import os.log import os.log
@ -13,15 +13,6 @@ final class WelcomeViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { 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 logoImageView: UIImageView = {
let imageView = UIImageView(image: Asset.welcomeLogo.image) let imageView = UIImageView(image: Asset.welcomeLogo.image)
imageView.translatesAutoresizingMaskIntoConstraints = false imageView.translatesAutoresizingMaskIntoConstraints = false
@ -65,6 +56,10 @@ extension WelcomeViewController {
overrideUserInterfaceStyle = .light overrideUserInterfaceStyle = .light
view.backgroundColor = Asset.Colors.Background.onboardingBackground.color 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) view.addSubview(logoImageView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -93,8 +88,8 @@ extension WelcomeViewController {
signInButton.topAnchor.constraint(equalTo: signUpButton.bottomAnchor, constant: 5) signInButton.topAnchor.constraint(equalTo: signUpButton.bottomAnchor, constant: 5)
]) ])
signInButton.addTarget(self, action: #selector(WelcomeViewController.signInButtonPressed(_:)), for: .touchUpInside) signUpButton.addTarget(self, action: #selector(signUpButtonDidClicked(_:)), for: .touchUpInside)
signUpButton.addTarget(self, action: #selector(WelcomeViewController.signUpButtonPressed(_:)), for: .touchUpInside) signInButton.addTarget(self, action: #selector(signInButtonDidClicked(_:)), for: .touchUpInside)
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
@ -105,20 +100,13 @@ extension WelcomeViewController {
} }
extension WelcomeViewController { extension WelcomeViewController {
@objc
@objc private func signInButtonPressed(_ sender: UIButton) { private func signUpButtonDidClicked(_ sender: UIButton) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) coordinator.present(scene: .pickServer(viewMode: PickServerViewModel(context: context, mode: .signUp)), from: self, transition: .show)
#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 signUpButtonPressed(_ sender: UIButton) { @objc
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) private func signInButtonDidClicked(_ sender: UIButton) {
coordinator.present(scene: .pickServer(viewMode: PickServerViewModel(context: context, mode: .signIn)), from: self, transition: .show)
} }
} }

View File

@ -9,7 +9,7 @@ import Foundation
extension Mastodon.Entity { extension Mastodon.Entity {
public struct Server: Codable { public struct Server: Codable, Equatable {
public let domain: String public let domain: String
public let version: String public let version: String
public let description: String public let description: String
@ -37,6 +37,25 @@ extension Mastodon.Entity {
case language case language
case category 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
}
} }
} }