From 1daf87fc8618d2b7895b1f2becb3c19c8f829589 Mon Sep 17 00:00:00 2001 From: Ryan Nystrom Date: Sun, 15 Jul 2018 19:50:01 -0400 Subject: [PATCH] New ContextMenu UX for managing Issues & PRs (#1912) * wip * New managing UX using ContextMenu --- Classes/Issues/IssueManageButton.swift | 32 ++ .../IssueManagingContextController.swift | 331 ++++++++++++++++++ Classes/Issues/IssuesViewController.swift | 44 +-- .../IssueManagingSectionController.swift | 2 + Classes/Labels/LabelMenuCell.swift | 10 +- Classes/Labels/LabelSectionController.swift | 2 +- Classes/Labels/LabelsViewController.swift | 10 +- Classes/Milestones/MilestoneCell.swift | 12 +- .../MilestoneSectionController.swift | 3 +- .../Milestones/MilestonesViewController.swift | 10 +- Classes/People/PeopleCell.swift | 11 +- Classes/People/PeopleSectionController.swift | 2 +- Classes/People/PeopleViewController.swift | 8 +- .../ContrastContextMenu.swift | 108 ++++++ .../UINavigationItem+TitleSubtitle.swift | 16 + Classes/Views/Constants.swift | 4 + Classes/Views/Styles.swift | 2 + Classes/Views/UIImageView+Avatar.swift | 8 +- Freetime.xcodeproj/project.pbxproj | 14 +- 19 files changed, 582 insertions(+), 47 deletions(-) create mode 100644 Classes/Issues/IssueManageButton.swift create mode 100644 Classes/Issues/IssueManagingContextController.swift create mode 100644 Classes/View Controllers/ContrastContextMenu.swift diff --git a/Classes/Issues/IssueManageButton.swift b/Classes/Issues/IssueManageButton.swift new file mode 100644 index 00000000..197f2e74 --- /dev/null +++ b/Classes/Issues/IssueManageButton.swift @@ -0,0 +1,32 @@ +// +// IssueManageButton.swift +// Freetime +// +// Created by Ryan Nystrom on 7/15/18. +// Copyright © 2018 Ryan Nystrom. All rights reserved. +// + +import UIKit + +func IssueManageButton() -> UIButton { + let button = UIButton() + if let image = UIImage(named: "three-bars")?.withRenderingMode(.alwaysTemplate) { + button.setImage(image, for: .normal) + } + button.tintColor = .white + button.backgroundColor = Styles.Colors.Blue.medium.color + let width = Styles.Sizes.tableCellHeight + button.frame = CGRect( + origin: .zero, + size: CGSize(width: width, height: width) + ) + button.layer.cornerRadius = width / 2 + button.layer.shadowOpacity = 0.12 + button.layer.shadowOffset = CGSize(width: 0, height: 2) + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowRadius = 4 + button.layer.borderColor = UIColor(white: 1, alpha: 0.2).cgColor + button.layer.borderWidth = 1 + button.accessibilityLabel = NSLocalizedString("Show options", comment: "") + return button +} diff --git a/Classes/Issues/IssueManagingContextController.swift b/Classes/Issues/IssueManagingContextController.swift new file mode 100644 index 00000000..61ec7e65 --- /dev/null +++ b/Classes/Issues/IssueManagingContextController.swift @@ -0,0 +1,331 @@ +// +// IssueManagingContextController.swift +// Freetime +// +// Created by Ryan Nystrom on 7/15/18. +// Copyright © 2018 Ryan Nystrom. All rights reserved. +// + +import UIKit +import ContextMenu +import GitHubAPI + +final class IssueManagingContextController: NSObject, ContextMenuDelegate { + + // Int with lowers-highest permissions to do rank comparisons + enum Permissions: Int { + case none + case author + case collaborator + } + + let manageButton: UIView + + private var _permissions: Permissions = .none { + didSet { + updateButtonVisibility() + } + } + var permissions: Permissions { + get { return _permissions } + set (newValue) { + // only allow setting to the higher permissions + if newValue.rawValue > _permissions.rawValue { + _permissions = newValue + } + } + } + let model: IssueDetailsModel + var resultID: String? { + didSet { + guard let result = self.result else { return } + if result.viewerCanUpdate { + permissions = .author + } + updateButtonVisibility() + } + } + let client: GithubClient + weak var viewController: UIViewController? + + init(model: IssueDetailsModel, client: GithubClient) { + let button = IssueManageButton() + manageButton = button + self.client = client + self.model = model + + super.init() + + button.isHidden = true + button.addTarget(self, action: #selector(onButton(sender:)), for: .touchUpInside) + } + + var result: IssueResult? { + guard let id = resultID else { return nil } + return client.cache.get(id: id) as IssueResult? + } + + func updateButtonVisibility() { + let hidden: Bool + if resultID == nil { + hidden = true + } else { + switch permissions { + case .none: hidden = true + case .author, .collaborator: hidden = false + } + } + manageButton.isHidden = hidden + } + + enum Action { + case labels + case milestone + case assignees + case reviewers + case unlock + case lock + case reopen + case close + } + + func item(_ action: Action) -> ContrastContextMenuItem { + let title: String + let icon: String + + switch action { + case .labels: + title = Constants.Strings.labels + icon = "tag" + case .milestone: + title = Constants.Strings.milestone + icon = "milestone" + case .assignees: + title = Constants.Strings.assignees + icon = "person" + case .reviewers: + title = Constants.Strings.reviewers + icon = "reviewer" + case .unlock: + title = NSLocalizedString("Unlock", comment: "") + icon = "key" + case .lock: + title = NSLocalizedString("Lock", comment: "") + icon = "lock" + case .reopen: + title = Constants.Strings.reopen + icon = "sync" + case .close: + title = Constants.Strings.close + icon = "x" + } + + let separator: Bool + switch action { + case .reopen, .close: separator = true + default: separator = false + } + + return ContrastContextMenuItem(title: title, iconName: icon, separator: separator, action: actionBlock(action)) + } + + func actionBlock(_ action: Action) -> (ContrastContextMenu) -> Void { + return { [weak self] menu in + menu.dismiss(animated: true) + guard let strongSelf = self else { return } + switch action { + case .labels: strongSelf.presentContextMenu(with: strongSelf.newLabelsController()) + case .milestone: strongSelf.presentContextMenu(with: strongSelf.newMilestonesController()) + case .assignees: strongSelf.presentContextMenu(with: strongSelf.newPeopleController(type: .assignee)) + case .reviewers: strongSelf.presentContextMenu(with: strongSelf.newPeopleController(type: .reviewer)) + case .unlock: strongSelf.lock(false) + case .lock: strongSelf.lock(true) + case .reopen: strongSelf.close(false) + case .close: strongSelf.close(true) + } + } + } + + @objc func onButton(sender: UIButton) { + guard let result = self.result, + let viewController = self.viewController + else { return } + + var items = [ContrastContextMenuItem]() + + if case .collaborator = permissions { + items += [ item(.labels), item(.milestone), item(.assignees) ] + if result.pullRequest { + items.append(item(.reviewers)) + } + if result.status.locked { + items.append(item(.unlock)) + } else { + items.append(item(.lock)) + } + } + + switch result.status.status { + case .closed: + items.append(item(.reopen)) + case .open: + items.append(item(.close)) + case .merged: break + } + + ContextMenu.shared.show( + sourceViewController: viewController, + viewController: ContrastContextMenu(items: items), + options: ContextMenu.Options( + containerStyle: ContextMenu.ContainerStyle( + xPadding: 0, + yPadding: 4, + backgroundColor: Styles.Colors.menuBackgroundColor.color + ), + menuStyle: .minimal + ), + sourceView: sender + ) + } + + func newLabelsController() -> UIViewController { + return LabelsViewController( + selected: result?.labels.labels ?? [], + client: client, + owner: model.owner, + repo: model.repo + ) + } + + func newMilestonesController() -> UIViewController { + return MilestonesViewController( + client: client, + owner: model.owner, + repo: model.repo, + selected: result?.milestone + ) + } + + func newPeopleController(type: PeopleViewController.PeopleType) -> UIViewController { + let result = self.result + let selections: [String] + let exclusions: [String] + switch type { + case .assignee: + selections = result?.assignee.users.map { $0.login } ?? [] + exclusions = [] + case .reviewer: + selections = result?.reviewers?.users.map { $0.login } ?? [] + if let isPullRequest = result?.pullRequest, + let pullRequestAuthor = result?.rootComment?.details.login, + isPullRequest { + exclusions = [pullRequestAuthor] + } else { + exclusions = [] + } + } + return PeopleViewController( + selections: selections, + exclusions: exclusions, + type: type, + client: client, + owner: model.owner, + repo: model.repo + ) + } + + func presentContextMenu(with controller: UIViewController) { + guard let viewController = self.viewController else { return } + ContextMenu.shared.show( + sourceViewController: viewController, + viewController: controller, + options: ContextMenu.Options( + containerStyle: ContextMenu.ContainerStyle( + backgroundColor: Styles.Colors.menuBackgroundColor.color + ) + ), + delegate: self + ) + } + + func close(_ doClose: Bool) { + guard let previous = result else { return } + client.setStatus( + previous: previous, + owner: model.owner, + repo: model.repo, + number: model.number, + close: doClose + ) + Haptic.triggerNotification(.success) + } + + func lock(_ doLock: Bool) { + guard let previous = result else { return } + client.setLocked( + previous: previous, + owner: model.owner, + repo: model.repo, + number: model.number, + locked: doLock + ) + Haptic.triggerNotification(.success) + } + + func didDismiss(selected labels: [RepositoryLabel]) { + guard let previous = result else { return } + client.mutateLabels( + previous: previous, + owner: model.owner, + repo: model.repo, + number: model.number, + labels: labels + ) + } + + func didDismiss(controller: PeopleViewController) { + guard let previous = result else { return } + + let mutationType: V3AddPeopleRequest.PeopleType + switch controller.type { + case .assignee: mutationType = .assignees + case .reviewer: mutationType = .reviewers + } + + client.addPeople( + type: mutationType, + previous: previous, + owner: model.owner, + repo: model.repo, + number: model.number, + people: controller.selected + ) + } + + func didDismiss(controller: MilestonesViewController) { + guard let previous = result else { return } + client.setMilestone( + previous: previous, + owner: model.owner, + repo: model.repo, + number: model.number, + milestone: controller.selected + ) + } + + // MARK: ContextMenuDelegate + + func contextMenuWillDismiss(viewController: UIViewController, animated: Bool) { + if let milestones = viewController as? MilestonesViewController { + didDismiss(controller: milestones) + } else if let people = viewController as? PeopleViewController { + didDismiss(controller: people) + } else if let labels = viewController as? LabelsViewController { + didDismiss(selected: labels.selected) + } + } + + func contextMenuDidDismiss(viewController: UIViewController, animated: Bool) {} + +} + diff --git a/Classes/Issues/IssuesViewController.swift b/Classes/Issues/IssuesViewController.swift index 80f4c95e..b96af9c9 100644 --- a/Classes/Issues/IssuesViewController.swift +++ b/Classes/Issues/IssuesViewController.swift @@ -14,6 +14,8 @@ import SnapKit import FlatCache import MessageViewController import Squawk +import ContextMenu +import GitHubAPI final class IssuesViewController: MessageViewController, @@ -23,7 +25,6 @@ final class IssuesViewController: FeedSelectionProviding, IssueNeckLoadSectionControllerDelegate, FlatCacheListener, - IssueManagingNavSectionControllerDelegate, IssueCommentSectionControllerDelegate { private let client: GithubClient @@ -32,6 +33,7 @@ final class IssuesViewController: private let textActionsController = TextActionsController() private var bookmarkNavController: BookmarkNavigationController? = nil private var autocompleteController: AutocompleteController! + private let manageController: IssueManagingContextController private var needsScrollToBottom = false private var lastTimelineElement: ListDiffable? @@ -67,6 +69,8 @@ final class IssuesViewController: hidden = true } self.setMessageView(hidden: hidden, animated: trueUnlessReduceMotionEnabled) + + self.manageController.resultID = resultID } } @@ -94,6 +98,7 @@ final class IssuesViewController: self.model = model self.addCommentClient = AddCommentClient(client: client) self.needsScrollToBottom = scrollToBottom + self.manageController = IssueManagingContextController(model: model, client: client) super.init(nibName: nil, bundle: nil) @@ -109,6 +114,8 @@ final class IssuesViewController: self.addCommentClient.addListener(listener: self) cacheKey = "issue.\(model.owner).\(model.repo).\(model.number)" + + manageController.viewController = self } required init?(coder aDecoder: NSCoder) { @@ -167,10 +174,11 @@ final class IssuesViewController: actions.frame = CGRect(x: 0, y: 0, width: 0, height: 32) messageView.add(contentView: actions) - //show disabled bookmark button until issue has finished loading navigationItem.rightBarButtonItems = [ moreOptionsItem, BookmarkNavigationController.disabledNavigationItem ] + + view.addSubview(manageController.manageButton) } override func viewDidAppear(_ animated: Bool) { @@ -198,6 +206,16 @@ final class IssuesViewController: feed.collectionView.updateSafeInset(container: view, base: Styles.Sizes.threadInset) } + override func didLayout() { + let manageButtonSize = manageController.manageButton.bounds.size + manageController.manageButton.frame = CGRect( + origin: CGPoint( + x: view.bounds.width - manageButtonSize.width - Styles.Sizes.gutter - view.safeAreaInsets.right, + y: messageView.frame.minY - manageButtonSize.height - Styles.Sizes.gutter), + size: manageButtonSize + ) + } + // MARK: Private API @objc func didPressButton(_ sender: Any?) { @@ -269,6 +287,9 @@ final class IssuesViewController: case .read, .none: collab = false } self?.viewerIsCollaborator = collab + if collab { + self?.manageController.permissions = .collaborator + } // avoid finishLoading() so empty view doesn't appear self?.feed.adapter.performUpdates(animated: trueUnlessReduceMotionEnabled) case .error: @@ -397,14 +418,6 @@ final class IssuesViewController: // side effect so to jump to the last element when auto scrolling lastTimelineElement = objects.last - if viewerIsCollaborator || current.viewerCanUpdate { - objects.append(IssueManagingModel( - objectId: current.id, - pullRequest: current.pullRequest, - role: viewerIsCollaborator ? .collaborator : .author - )) - } - if viewerIsCollaborator, current.status.status == .open, let merge = current.mergeModel { @@ -415,10 +428,6 @@ final class IssuesViewController: } func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController { - if let object = object as? ListDiffable, object === manageKey { - return IssueManagingNavSectionController(delegate: self) - } - switch object { case is IssueTitleModel: return IssueTitleSectionController() case is IssueCommentModel: @@ -448,7 +457,6 @@ final class IssuesViewController: case is IssueNeckLoadModel: return IssueNeckLoadSectionController(delegate: self) case is Milestone: return IssueMilestoneSectionController(issueModel: model) case is IssueFileChangesModel: return IssueViewFilesSectionController(issueModel: model, client: client) - case is IssueManagingModel: return IssueManagingSectionController(model: model, client: client) case is IssueMergeModel: return IssueMergeSectionController(model: model, client: client, resultID: resultID) case is IssueTargetBranchModel: return IssueTargetBranchSectionController() default: fatalError("Unhandled object: \(object)") @@ -537,12 +545,6 @@ final class IssuesViewController: } } - // MARK: IssueManagingNavSectionControllerDelegate - - func didSelect(managingNavController: IssueManagingNavSectionController) { - feed.collectionView.scrollToBottom(animated: true) - } - // MARK: IssueCommentSectionControllerDelegate func didSelectReply(to sectionController: IssueCommentSectionController, commentModel: IssueCommentModel) { diff --git a/Classes/Issues/Managing/IssueManagingSectionController.swift b/Classes/Issues/Managing/IssueManagingSectionController.swift index 400216ef..3392b196 100644 --- a/Classes/Issues/Managing/IssueManagingSectionController.swift +++ b/Classes/Issues/Managing/IssueManagingSectionController.swift @@ -74,7 +74,9 @@ ContextMenuDelegate { init(model: IssueDetailsModel, client: GithubClient) { self.model = model self.client = client + super.init() + inset = UIEdgeInsets(top: Styles.Sizes.gutter, left: 0, bottom: Styles.Sizes.gutter, right: 0) minimumInteritemSpacing = Styles.Sizes.rowSpacing minimumLineSpacing = Styles.Sizes.rowSpacing diff --git a/Classes/Labels/LabelMenuCell.swift b/Classes/Labels/LabelMenuCell.swift index e9e96d84..9552e711 100644 --- a/Classes/Labels/LabelMenuCell.swift +++ b/Classes/Labels/LabelMenuCell.swift @@ -17,7 +17,8 @@ final class LabelMenuCell: SelectableCell { override init(frame: CGRect) { super.init(frame: frame) - backgroundColor = .white + backgroundColor = nil + contentView.backgroundColor = nil isAccessibilityElement = true accessibilityTraits |= UIAccessibilityTraitButton @@ -30,10 +31,8 @@ final class LabelMenuCell: SelectableCell { } contentView.addSubview(button) - button.titleLabel?.font = Styles.Text.secondaryBold.preferredFont + button.titleLabel?.font = Styles.Text.bodyBold.preferredFont button.layer.cornerRadius = Styles.Sizes.avatarCornerRadius - button.layer.borderColor = Styles.Colors.Gray.border.color.cgColor - button.layer.borderWidth = 1 / UIScreen.main.scale button.clipsToBounds = true button.isUserInteractionEnabled = false button.contentEdgeInsets = UIEdgeInsets( @@ -48,7 +47,8 @@ final class LabelMenuCell: SelectableCell { make.centerY.equalToSuperview() } - contentView.addBorder(.bottom, left: Styles.Sizes.gutter, right: -Styles.Sizes.gutter) + let border = contentView.addBorder(.bottom, left: Styles.Sizes.gutter, right: -Styles.Sizes.gutter) + border.backgroundColor = Styles.Colors.Gray.medium.color } required init?(coder aDecoder: NSCoder) { diff --git a/Classes/Labels/LabelSectionController.swift b/Classes/Labels/LabelSectionController.swift index 5ca2859c..a0860384 100644 --- a/Classes/Labels/LabelSectionController.swift +++ b/Classes/Labels/LabelSectionController.swift @@ -25,7 +25,7 @@ final class LabelSectionController: ListSwiftSectionController size: { return CGSize( width: $0.collection.containerSize.width, - height: Styles.Sizes.tableCellHeight + height: Styles.Sizes.tableCellHeightLarge ) }, configure: { [selected] in diff --git a/Classes/Labels/LabelsViewController.swift b/Classes/Labels/LabelsViewController.swift index cb8504bf..41e45e07 100644 --- a/Classes/Labels/LabelsViewController.swift +++ b/Classes/Labels/LabelsViewController.swift @@ -27,8 +27,9 @@ final class LabelsViewController: BaseListViewController2, BaseListViewC self.client = client self.request = RepositoryLabelsQuery(owner: owner, repo: repo) super.init(emptyErrorMessage: NSLocalizedString("No labels found", comment: "")) - title = NSLocalizedString("Labels", comment: "") - preferredContentSize = CGSize(width: 200, height: 240) + preferredContentSize = Styles.Sizes.contextMenuSize + title = Constants.Strings.labels + feed.collectionView.backgroundColor = Styles.Colors.menuBackgroundColor.color dataSource = self } @@ -36,6 +37,11 @@ final class LabelsViewController: BaseListViewController2, BaseListViewC fatalError("init(coder:) has not been implemented") } + override func viewDidLoad() { + super.viewDidLoad() + navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor.white] + } + // MARK: Public API var selected: [RepositoryLabel] { diff --git a/Classes/Milestones/MilestoneCell.swift b/Classes/Milestones/MilestoneCell.swift index 8c087e7c..4f2956fa 100644 --- a/Classes/Milestones/MilestoneCell.swift +++ b/Classes/Milestones/MilestoneCell.swift @@ -18,7 +18,8 @@ final class MilestoneCell2: SelectableCell { override init(frame: CGRect) { super.init(frame: frame) - backgroundColor = .white + backgroundColor = nil + contentView.backgroundColor = nil isAccessibilityElement = true accessibilityTraits |= UIAccessibilityTraitButton @@ -31,8 +32,8 @@ final class MilestoneCell2: SelectableCell { } contentView.addSubview(label) - label.font = Styles.Text.secondaryBold.preferredFont - label.textColor = Styles.Colors.Gray.dark.color + label.font = Styles.Text.bodyBold.preferredFont + label.textColor = .white label.snp.makeConstraints { make in make.left.equalTo(Styles.Sizes.gutter) make.right.lessThanOrEqualTo(checkedImageView.snp.left) @@ -41,14 +42,15 @@ final class MilestoneCell2: SelectableCell { contentView.addSubview(detailLabel) detailLabel.font = Styles.Text.secondary.preferredFont - detailLabel.textColor = Styles.Colors.Gray.medium.color + detailLabel.textColor = Styles.Colors.Gray.light.color detailLabel.snp.makeConstraints { make in make.left.equalTo(label) make.right.lessThanOrEqualTo(checkedImageView.snp.left) make.top.equalTo(label.snp.bottom) } - contentView.addBorder(.bottom, left: Styles.Sizes.gutter, right: -Styles.Sizes.gutter) + let border = contentView.addBorder(.bottom, left: Styles.Sizes.gutter, right: -Styles.Sizes.gutter) + border.backgroundColor = Styles.Colors.Gray.medium.color } required init?(coder aDecoder: NSCoder) { diff --git a/Classes/Milestones/MilestoneSectionController.swift b/Classes/Milestones/MilestoneSectionController.swift index 327f750e..76625b5b 100644 --- a/Classes/Milestones/MilestoneSectionController.swift +++ b/Classes/Milestones/MilestoneSectionController.swift @@ -24,7 +24,7 @@ final class MilestoneSectionController: ListSwiftSectionController Void)? + + init( + title: String, + iconName: String? = nil, + separator: Bool = false, + action: ((ContrastContextMenu) -> Void)? = nil + ) { + self.title = title + self.iconName = iconName + self.separator = separator + self.action = action + } + +} + +final class ContrastContextMenu: UITableViewController { + + private class Cell: UITableViewCell { + static let reuseIdentifier = "cell" + var border: UIView? = nil + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + selectedBackgroundView = UIView() + selectedBackgroundView?.backgroundColor = Styles.Colors.Gray.medium.color + contentView.backgroundColor = nil + backgroundColor = nil + + textLabel?.font = Styles.Text.bodyBold.preferredFont + textLabel?.textColor = .white + + imageView?.tintColor = Styles.Colors.Blue.medium.color + + border = contentView.addBorder(.top) + border?.backgroundColor = Styles.Colors.Gray.medium.color + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } + + private let items: [ContrastContextMenuItem] + + init(items: [ContrastContextMenuItem]) { + self.items = items + super.init(style: .plain) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(Cell.self, forCellReuseIdentifier: Cell.reuseIdentifier) + tableView.rowHeight = Styles.Sizes.tableCellHeight + tableView.separatorStyle = .none + tableView.reloadData() + tableView.layoutIfNeeded() + preferredContentSize = CGSize(width: 180, height: tableView.contentSize.height) + } + + // MARK: UITableViewDataSource + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return items.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let item = items[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: Cell.reuseIdentifier, for: indexPath) + cell.textLabel?.text = item.title + if let iconName = item.iconName { + cell.imageView?.image = UIImage(named: iconName)?.withRenderingMode(.alwaysTemplate) + } + + if let cell = cell as? Cell { + cell.border?.isHidden = !item.separator + } + + return cell + } + + // MARK: UITableViewDelegate + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + items[indexPath.row].action?(self) + } + +} diff --git a/Classes/View Controllers/UINavigationItem+TitleSubtitle.swift b/Classes/View Controllers/UINavigationItem+TitleSubtitle.swift index 8aba5ebf..3924f905 100644 --- a/Classes/View Controllers/UINavigationItem+TitleSubtitle.swift +++ b/Classes/View Controllers/UINavigationItem+TitleSubtitle.swift @@ -69,4 +69,20 @@ extension UINavigationItem { } } + func configure(title: NSAttributedString, accessibilityLabel: String? = nil) { + let label = UILabel() + label.backgroundColor = .clear + label.numberOfLines = 0 + label.textAlignment = .center + label.attributedText = title + label.lineBreakMode = .byTruncatingHead + label.adjustsFontSizeToFitWidth = true + label.sizeToFit() + + titleView = label + if let accessibilityLabel = accessibilityLabel { // prevent from setting to nil if we don't provide anything, use default instead + titleView?.accessibilityLabel = accessibilityLabel + } + } + } diff --git a/Classes/Views/Constants.swift b/Classes/Views/Constants.swift index 51bbc066..dc8c7292 100644 --- a/Classes/Views/Constants.swift +++ b/Classes/Views/Constants.swift @@ -49,5 +49,9 @@ enum Constants { static let bookmark = NSLocalizedString("Bookmark", comment: "") static let removeBookmark = NSLocalizedString("Remove Bookmark", comment: "") static let bookmarks = NSLocalizedString("Bookmarks", comment: "") + static let labels = NSLocalizedString("Labels", comment: "") + static let milestone = NSLocalizedString("Milestone", comment: "") + static let assignees = NSLocalizedString("Assignees", comment: "") + static let reviewers = NSLocalizedString("Reviewers", comment: "") } } diff --git a/Classes/Views/Styles.swift b/Classes/Views/Styles.swift index 8105b686..b9179813 100644 --- a/Classes/Views/Styles.swift +++ b/Classes/Views/Styles.swift @@ -52,6 +52,7 @@ enum Styles { right: Styles.Sizes.commentGutter ) static let maxImageHeight: CGFloat = 300 + static let contextMenuSize: CGSize = CGSize(width: 280, height: 240) enum HTML { static let boldWeight = 600 @@ -87,6 +88,7 @@ enum Styles { static let background = Styles.Colors.Gray.lighter.color static let purple = "6f42c1" static let blueGray = "8697af" + static let menuBackgroundColor = "292D35" enum Red { static let medium = "cb2431" diff --git a/Classes/Views/UIImageView+Avatar.swift b/Classes/Views/UIImageView+Avatar.swift index f7169799..0d9da1ec 100644 --- a/Classes/Views/UIImageView+Avatar.swift +++ b/Classes/Views/UIImageView+Avatar.swift @@ -10,12 +10,14 @@ import UIKit extension UIImageView { - func configureForAvatar() { + func configureForAvatar(border: Bool = true) { contentMode = .scaleAspectFill backgroundColor = Styles.Colors.Gray.lighter.color layer.cornerRadius = Styles.Sizes.avatarCornerRadius - layer.borderColor = Styles.Colors.Gray.light.color.cgColor - layer.borderWidth = 1.0 / UIScreen.main.scale + if border { + layer.borderColor = Styles.Colors.Gray.light.color.cgColor + layer.borderWidth = 1.0 / UIScreen.main.scale + } clipsToBounds = true } diff --git a/Freetime.xcodeproj/project.pbxproj b/Freetime.xcodeproj/project.pbxproj index a816606d..8b7ee0d9 100644 --- a/Freetime.xcodeproj/project.pbxproj +++ b/Freetime.xcodeproj/project.pbxproj @@ -204,6 +204,9 @@ 296B4E341F7C80B800C16887 /* GraphQLIDDecodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 296B4E331F7C80B800C16887 /* GraphQLIDDecodeTests.swift */; }; 2971722B1F069E6B005E43AC /* SpinnerSectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2971722A1F069E6B005E43AC /* SpinnerSectionController.swift */; }; 2971722D1F069E96005E43AC /* SpinnerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2971722C1F069E96005E43AC /* SpinnerCell.swift */; }; + 2973E80D20FBD7BF0050233F /* ContrastContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2973E80C20FBD7BF0050233F /* ContrastContextMenu.swift */; }; + 2973E80F20FBDB620050233F /* IssueManageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2973E80E20FBDB620050233F /* IssueManageButton.swift */; }; + 2973E81120FBF73D0050233F /* IssueManagingContextController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2973E81020FBF73D0050233F /* IssueManagingContextController.swift */; }; 297403D11F184F8D00ABA95A /* IssueAssigneesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 297403D01F184F8D00ABA95A /* IssueAssigneesModel.swift */; }; 297403D31F1850DC00ABA95A /* IssueAssigneeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 297403D21F1850DC00ABA95A /* IssueAssigneeViewModel.swift */; }; 297403D51F18515A00ABA95A /* IssueAssigneeSummaryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 297403D41F18515A00ABA95A /* IssueAssigneeSummaryCell.swift */; }; @@ -702,6 +705,9 @@ 2971722A1F069E6B005E43AC /* SpinnerSectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerSectionController.swift; sourceTree = ""; }; 2971722C1F069E96005E43AC /* SpinnerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerCell.swift; sourceTree = ""; }; 2971CED22055C8F600342296 /* Freetime.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Freetime.entitlements; sourceTree = ""; }; + 2973E80C20FBD7BF0050233F /* ContrastContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContrastContextMenu.swift; sourceTree = ""; }; + 2973E80E20FBDB620050233F /* IssueManageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueManageButton.swift; sourceTree = ""; }; + 2973E81020FBF73D0050233F /* IssueManagingContextController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueManagingContextController.swift; sourceTree = ""; }; 297403D01F184F8D00ABA95A /* IssueAssigneesModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IssueAssigneesModel.swift; sourceTree = ""; }; 297403D21F1850DC00ABA95A /* IssueAssigneeViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IssueAssigneeViewModel.swift; sourceTree = ""; }; 297403D41F18515A00ABA95A /* IssueAssigneeSummaryCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IssueAssigneeSummaryCell.swift; sourceTree = ""; }; @@ -1116,9 +1122,9 @@ 292FCAC71EDFCC510026635E /* Issues */ = { isa = PBXGroup; children = ( - 15245483205DC004005810A6 /* Branches */, 290744A81F24D2DA00FD9E48 /* AddCommentClient.swift */, 297403CF1F184F8400ABA95A /* Assignees */, + 15245483205DC004005810A6 /* Branches */, 292FCAC81EDFCC510026635E /* Comments */, 293A45A01F29953800DD1006 /* Commit */, 292CD3B91F0AF26900D3D57B /* DiffHunk */, @@ -1128,10 +1134,12 @@ 294563EB1EE5012100DBCD35 /* Issue+IssueType.swift */, 292CD3C41F0C9EB200D3D57B /* IssueCommentModelHandling.swift */, 291929461F3EAB250012067B /* IssueDetailsModel.swift */, + 2973E80E20FBDB620050233F /* IssueManageButton.swift */, 29F7F05B1F2A751B00F6075D /* IssueResult.swift */, 290D2A411F04D3470082E6CC /* IssueStatus.swift */, 295C31CE1F0AA67600521CED /* IssueStatus+ButtonState.swift */, 292FCAE91EDFCC510026635E /* IssuesViewController.swift */, + 2973E81020FBF73D0050233F /* IssueManagingContextController.swift */, 292FF8AF1F2FDC33009E63F7 /* IssueTextActionsView.swift */, 294563EF1EE5036A00DBCD35 /* IssueType.swift */, 292FCAEA1EDFCC510026635E /* IssueViewModels.swift */, @@ -1616,6 +1624,7 @@ 2950AB1A2082E47200C6F19A /* AppSplitViewController.swift */, 29CEA5CC1F84DB1B009827DB /* BaseListViewController.swift */, 29C8F9AA208BF64D0075931C /* BaseListViewController2.swift */, + 2973E80C20FBD7BF0050233F /* ContrastContextMenu.swift */, 297DD5E01F061BBE006E7E63 /* CreateProfileViewController.swift */, 29316DBE1ECC95DB007CAE3F /* RootViewControllers.swift */, 29C33FDA1F127DBB00EC8D40 /* SplitPlaceholderViewController.swift */, @@ -2692,6 +2701,7 @@ 2949674C1EF9716400B1CF1A /* IssueCommentHrModel.swift in Sources */, 294967531EFC1EDB00B1CF1A /* IssueCommentHtmlCell.swift in Sources */, 294967511EFC1E9E00B1CF1A /* IssueCommentHtmlModel.swift in Sources */, + 2973E80D20FBD7BF0050233F /* ContrastContextMenu.swift in Sources */, 292FCAFB1EDFCC510026635E /* IssueCommentImageCell.swift in Sources */, 299997362031DAC000995FFD /* MergeButton.swift in Sources */, 29AF1E8A1F8AB1C30008A0EF /* TextActionsController.swift in Sources */, @@ -2735,6 +2745,7 @@ 292CD3C31F0AF3D400D3D57B /* IssueDiffHunkPreviewCell.swift in Sources */, 29CC294F1FF93AD7006B6DE7 /* IssueCommentEmptyCell.swift in Sources */, 292CD3BD1F0AF3B800D3D57B /* IssueDiffHunkSectionController.swift in Sources */, + 2973E80F20FBDB620050233F /* IssueManageButton.swift in Sources */, 291929531F3F9E490012067B /* IssueFileCell.swift in Sources */, 29C8F9AF208C02BE0075931C /* String+ListSwift.swift in Sources */, 98F9F4011F9CCFFE005A0266 /* ImgurClient.swift in Sources */, @@ -2769,6 +2780,7 @@ 29F7F05F1F2A839100F6075D /* IssueNeckLoadSectionController.swift in Sources */, 2919295F1F3FD1F40012067B /* IssuePatchContentViewController.swift in Sources */, 292FF8B91F303DB0009E63F7 /* IssuePreviewModel.swift in Sources */, + 2973E81120FBF73D0050233F /* IssueManagingContextController.swift in Sources */, 292FF8B51F303BD0009E63F7 /* IssuePreviewSectionController.swift in Sources */, 292FF8B71F303BD9009E63F7 /* IssuePreviewViewController.swift in Sources */, 292FCB211EDFCF870026635E /* IssueReactionCell.swift in Sources */,