mirror of
https://github.com/zhigang1992/GitHawk.git
synced 2026-04-27 19:14:15 +08:00
User can receive search results while typing (#611)
* Cancelled networking when the cancel button is pressed * Responded to comments: code formatting & cleanup * Can now search while typing Slight formatting changes * Prevented search term from showing up in recent before search succeeds * Show loading screen while typing * Less complicated network cancellation * Cleaned up groups * Recently viewed repos (#631) * Recently Viewed Repos saved in search store * Styling changes * Equatable changes and visual updates * Visually update after selecting a repo * Rebased * Slowed down the debouncer a bit
This commit is contained in:
committed by
Ryan Nystrom
parent
5bae4ba89d
commit
a52e533b5f
@@ -8,8 +8,14 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct RepositoryDetails {
|
||||
struct RepositoryDetails: Codable, Equatable {
|
||||
let owner: String
|
||||
let name: String
|
||||
let hasIssuesEnabled: Bool
|
||||
}
|
||||
|
||||
func == (lhs: RepositoryDetails, rhs: RepositoryDetails) -> Bool {
|
||||
return lhs.owner == rhs.owner &&
|
||||
lhs.name == rhs.name &&
|
||||
lhs.hasIssuesEnabled == rhs.hasIssuesEnabled
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import Apollo
|
||||
extension GithubClient {
|
||||
|
||||
enum SearchResultType {
|
||||
case error
|
||||
case error(Error?)
|
||||
case success(String?, [SearchRepoResult])
|
||||
}
|
||||
|
||||
@@ -26,9 +26,11 @@ extension GithubClient {
|
||||
let query = SearchReposQuery(search: query, before: before)
|
||||
|
||||
return fetch(query: query) { (result, error) in
|
||||
guard errorIsNilOrCancelled(error), result?.errors == nil else {
|
||||
ShowErrorStatusBar(graphQLErrors: result?.errors, networkError: error)
|
||||
completion(.error)
|
||||
guard error == nil, result?.errors == nil else {
|
||||
if !isCancellationError(error) {
|
||||
ShowErrorStatusBar(graphQLErrors: result?.errors, networkError: error)
|
||||
}
|
||||
completion(.error(error))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,8 +73,3 @@ extension GithubClient {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func errorIsNilOrCancelled(_ error: Error?) -> Bool {
|
||||
guard let error = error else { return true }
|
||||
return (error as NSError).code == -999
|
||||
}
|
||||
|
||||
56
Classes/Search/SearchQuery.swift
Normal file
56
Classes/Search/SearchQuery.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// SearchQuery.swift
|
||||
// Freetime
|
||||
//
|
||||
// Created by Hesham Salman on 10/21/17.
|
||||
// Copyright © 2017 Ryan Nystrom. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum SearchQuery: Codable, Equatable {
|
||||
case search(String), recentlyViewed(RepositoryDetails)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case search
|
||||
case recentlyViewed
|
||||
}
|
||||
|
||||
enum SearchQueryCodingError: Error {
|
||||
case decoding(String)
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let value = try? values.decode(String.self, forKey: .search) {
|
||||
self = .search(value)
|
||||
return
|
||||
}
|
||||
if let value = try? values.decode(RepositoryDetails.self, forKey: .recentlyViewed) {
|
||||
self = .recentlyViewed(value)
|
||||
return
|
||||
}
|
||||
throw SearchQueryCodingError.decoding("Unable to decode SearchQuery! \(dump(values))")
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case .search(let text):
|
||||
try container.encode(text, forKey: .search)
|
||||
case .recentlyViewed(let repo):
|
||||
try container.encode(repo, forKey: .recentlyViewed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func == (lhs: SearchQuery, rhs: SearchQuery) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (let .search(lhsText), let .search(rhsText)):
|
||||
return lhsText == rhsText
|
||||
case (let .recentlyViewed(lhsRepo), let .recentlyViewed(rhsRepo)):
|
||||
return lhsRepo == rhsRepo
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,29 @@ import SnapKit
|
||||
final class SearchRecentCell: SwipeSelectableCell {
|
||||
|
||||
private let label = UILabel()
|
||||
private let imageView = UIImageView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
backgroundColor = .white
|
||||
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.clipsToBounds = true
|
||||
imageView.tintColor = Styles.Colors.Gray.light.color
|
||||
contentView.addSubview(imageView)
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(contentView)
|
||||
make.left.equalTo(Styles.Sizes.gutter)
|
||||
make.width.equalTo(Styles.Sizes.icon.width)
|
||||
}
|
||||
|
||||
label.textColor = Styles.Colors.Gray.dark.color
|
||||
label.font = Styles.Fonts.body
|
||||
contentView.addSubview(label)
|
||||
label.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(contentView)
|
||||
make.left.equalTo(Styles.Sizes.gutter)
|
||||
make.left.equalTo(imageView.snp.right).offset(Styles.Sizes.gutter)
|
||||
make.right.lessThanOrEqualTo(-Styles.Sizes.gutter)
|
||||
}
|
||||
|
||||
@@ -41,8 +52,9 @@ final class SearchRecentCell: SwipeSelectableCell {
|
||||
|
||||
// MARK: Public API
|
||||
|
||||
func configure(_ text: String) {
|
||||
label.text = text
|
||||
func configure(viewModel: SearchRecentViewModel) {
|
||||
imageView.image = viewModel.icon.withRenderingMode(.alwaysTemplate)
|
||||
label.attributedText = viewModel.displayText
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -11,13 +11,12 @@ import IGListKit
|
||||
import SwipeCellKit
|
||||
|
||||
protocol SearchRecentSectionControllerDelegate: class {
|
||||
func didSelect(recentSectionController: SearchRecentSectionController, text: String)
|
||||
func didDelete(recentSectionController: SearchRecentSectionController, text: String)
|
||||
func didSelect(recentSectionController: SearchRecentSectionController, viewModel: SearchRecentViewModel)
|
||||
func didDelete(recentSectionController: SearchRecentSectionController, viewModel: SearchRecentViewModel)
|
||||
}
|
||||
|
||||
// bridge to NSString for NSObject conformance
|
||||
final class SearchRecentSectionController: ListGenericSectionController<NSString>,
|
||||
SwipeCollectionViewCellDelegate {
|
||||
final class SearchRecentSectionController: ListGenericSectionController<SearchRecentViewModel>, SwipeCollectionViewCellDelegate {
|
||||
|
||||
weak var delegate: SearchRecentSectionControllerDelegate? = nil
|
||||
lazy var recentStore = SearchRecentStore()
|
||||
@@ -36,13 +35,13 @@ SwipeCollectionViewCellDelegate {
|
||||
guard let cell = collectionContext?.dequeueReusableCell(of: SearchRecentCell.self, for: self, at: index) as? SearchRecentCell
|
||||
else { fatalError("Missing context or wrong cell type") }
|
||||
cell.delegate = self
|
||||
cell.configure(text)
|
||||
cell.configure(viewModel: searchViewModel)
|
||||
return cell
|
||||
}
|
||||
|
||||
override func didSelectItem(at index: Int) {
|
||||
collectionContext?.deselectItem(at: index, sectionController: self, animated: true)
|
||||
delegate?.didSelect(recentSectionController: self, text: text)
|
||||
delegate?.didSelect(recentSectionController: self, viewModel: searchViewModel)
|
||||
}
|
||||
|
||||
// MARK: SwipeCollectionViewCellDelegate
|
||||
@@ -52,7 +51,7 @@ SwipeCollectionViewCellDelegate {
|
||||
|
||||
let action = SwipeAction(style: .destructive, title: Constants.Strings.delete) { [weak self] _, _ in
|
||||
guard let strongSelf = self, let object = strongSelf.object else { return }
|
||||
strongSelf.delegate?.didDelete(recentSectionController: strongSelf, text: object as String)
|
||||
strongSelf.delegate?.didDelete(recentSectionController: strongSelf, viewModel: object)
|
||||
}
|
||||
action.image = #imageLiteral(resourceName: "trashcan").withRenderingMode(.alwaysTemplate)
|
||||
action.backgroundColor = Styles.Colors.Red.medium.color
|
||||
@@ -70,8 +69,8 @@ SwipeCollectionViewCellDelegate {
|
||||
|
||||
// MARK: Private API
|
||||
|
||||
var text: String {
|
||||
return (object ?? "") as String
|
||||
var searchViewModel: SearchRecentViewModel {
|
||||
return object ?? SearchRecentViewModel(query: .search(""))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -15,48 +15,63 @@ final class SearchRecentStore {
|
||||
}
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private var _recents: NSMutableOrderedSet
|
||||
private var _recents: [SearchQuery]
|
||||
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
init() {
|
||||
if let arr = defaults.object(forKey: Keys.results) as? [String] {
|
||||
_recents = NSMutableOrderedSet(array: arr)
|
||||
if let data = defaults.object(forKey: Keys.results) as? Data,
|
||||
let array = try? decoder.decode([SearchQuery].self, from: data) {
|
||||
_recents = array
|
||||
} else {
|
||||
_recents = NSMutableOrderedSet()
|
||||
_recents = []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Public API
|
||||
|
||||
func add(recent: String) {
|
||||
_recents.remove(recent)
|
||||
_recents.insert(recent, at: 0)
|
||||
func add(query: SearchQuery) {
|
||||
remove(query: query)
|
||||
_recents.insert(query, at: 0)
|
||||
|
||||
// keep recents trimmed
|
||||
while _recents.count > 15 {
|
||||
_recents.removeObject(at: _recents.count - 1)
|
||||
_recents.removeLast()
|
||||
}
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
func remove(recent: String) {
|
||||
_recents.remove(recent)
|
||||
func remove(query: SearchQuery) {
|
||||
guard let offset = _recents.index(of: query) else { return }
|
||||
let index = _recents.startIndex.distance(to: offset)
|
||||
_recents.remove(at: index)
|
||||
save()
|
||||
}
|
||||
|
||||
func clear() {
|
||||
_recents.removeAllObjects()
|
||||
_recents.removeAll()
|
||||
save()
|
||||
}
|
||||
|
||||
var recents: [String] {
|
||||
return _recents.array as? [String] ?? []
|
||||
var recents: [SearchQuery] {
|
||||
return _recents
|
||||
}
|
||||
|
||||
func removeLast() {
|
||||
guard !recents.isEmpty else { return }
|
||||
_recents.removeLast()
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: Private API
|
||||
|
||||
func save() {
|
||||
defaults.set(_recents.array, forKey: Keys.results)
|
||||
guard let data = try? encoder.encode(recents) else {
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: Keys.results)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
77
Classes/Search/SearchRecentViewModel.swift
Normal file
77
Classes/Search/SearchRecentViewModel.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// SearchRecentViewModel.swift
|
||||
// Freetime
|
||||
//
|
||||
// Created by Hesham Salman on 10/21/17.
|
||||
// Copyright © 2017 Ryan Nystrom. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import IGListKit
|
||||
|
||||
private enum Keys {
|
||||
static let search = "search"
|
||||
static let viewed = "viewed"
|
||||
}
|
||||
|
||||
final class SearchRecentViewModel: NSObject, ListDiffable {
|
||||
|
||||
let query: SearchQuery
|
||||
|
||||
init(query: SearchQuery) {
|
||||
self.query = query
|
||||
}
|
||||
|
||||
var displayText: NSAttributedString {
|
||||
switch query {
|
||||
case .search(let text):
|
||||
return NSAttributedString(string: text, attributes: standardAttributes)
|
||||
case .recentlyViewed(let repoDetails):
|
||||
let text = NSMutableAttributedString(string: repoDetails.owner + "/", attributes: standardAttributes)
|
||||
text.append(NSAttributedString(string: repoDetails.name, attributes: boldAttributes))
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
var icon: UIImage {
|
||||
switch query {
|
||||
case .search:
|
||||
return #imageLiteral(resourceName: "search")
|
||||
case .recentlyViewed:
|
||||
return #imageLiteral(resourceName: "repo")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: ListDiffable
|
||||
|
||||
func diffIdentifier() -> NSObjectProtocol {
|
||||
var identifier: String
|
||||
switch query {
|
||||
case .recentlyViewed:
|
||||
identifier = Keys.viewed
|
||||
case .search:
|
||||
identifier = Keys.search
|
||||
}
|
||||
return (identifier + displayText.string) as NSObjectProtocol
|
||||
}
|
||||
|
||||
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: Private API
|
||||
|
||||
private var standardAttributes: [NSAttributedStringKey: Any] {
|
||||
return [
|
||||
.font: Styles.Fonts.body,
|
||||
.foregroundColor: Styles.Colors.Gray.dark.color
|
||||
]
|
||||
}
|
||||
|
||||
private var boldAttributes: [NSAttributedStringKey: Any] {
|
||||
return [
|
||||
.font: Styles.Fonts.bodyBold,
|
||||
.foregroundColor: Styles.Colors.Gray.dark.color
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -9,44 +9,45 @@
|
||||
import IGListKit
|
||||
|
||||
protocol SearchResultSectionControllerDelegate: class {
|
||||
func didSelect(sectionController: SearchResultSectionController)
|
||||
func didSelect(sectionController: SearchResultSectionController, repo: RepositoryDetails)
|
||||
|
||||
}
|
||||
|
||||
final class SearchResultSectionController: ListGenericSectionController<SearchRepoResult> {
|
||||
|
||||
private weak var delegate: SearchResultSectionControllerDelegate? = nil
|
||||
private let client: GithubClient
|
||||
|
||||
|
||||
init(client: GithubClient, delegate: SearchResultSectionControllerDelegate) {
|
||||
self.client = client
|
||||
self.delegate = delegate
|
||||
super.init()
|
||||
}
|
||||
|
||||
|
||||
override func sizeForItem(at index: Int) -> CGSize {
|
||||
guard let width = collectionContext?.containerSize.width else { fatalError("Missing context") }
|
||||
return CGSize(width: width, height: Styles.Sizes.tableCellHeight + Styles.Sizes.rowSpacing * 2)
|
||||
}
|
||||
|
||||
|
||||
override func cellForItem(at index: Int) -> UICollectionViewCell {
|
||||
guard let cell = collectionContext?.dequeueReusableCell(of: SearchRepoResultCell.self, for: self, at: index) as? SearchRepoResultCell,
|
||||
let object = object else {
|
||||
fatalError("Missing context, object, or cell is wrong type")
|
||||
}
|
||||
|
||||
|
||||
cell.configure(result: object)
|
||||
return cell
|
||||
}
|
||||
|
||||
|
||||
override func didSelectItem(at index: Int) {
|
||||
guard let object = object else { return }
|
||||
|
||||
delegate?.didSelect(sectionController: self)
|
||||
|
||||
let repo = RepositoryDetails(owner: object.owner, name: object.name, hasIssuesEnabled: object.hasIssuesEnabled)
|
||||
|
||||
delegate?.didSelect(sectionController: self, repo: repo)
|
||||
|
||||
let repoViewController = RepositoryViewController(client: client, repo: repo)
|
||||
let navigation = UINavigationController(rootViewController: repoViewController)
|
||||
viewController?.showDetailViewController(navigation, sender: nil)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -24,14 +24,21 @@ SearchResultSectionControllerDelegate {
|
||||
private let noResultsKey = "com.freetime.SearchViewController.no-results-key" as ListDiffable
|
||||
private let recentHeaderKey = "com.freetime.SearchViewController.recent-header-key" as ListDiffable
|
||||
private let recentStore = SearchRecentStore()
|
||||
private let debouncer = Debouncer()
|
||||
|
||||
enum State {
|
||||
case idle
|
||||
case loading(Cancellable)
|
||||
case loading(Cancellable?)
|
||||
case results([ListDiffable])
|
||||
case error
|
||||
}
|
||||
private var state: State = .idle
|
||||
private var state: State = .idle {
|
||||
willSet {
|
||||
if case let .loading(request) = state {
|
||||
request?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let searchBar = UISearchBar()
|
||||
private lazy var adapter: ListAdapter = { ListAdapter(updater: ListAdapterUpdater(), viewController: self) }()
|
||||
@@ -91,6 +98,8 @@ SearchResultSectionControllerDelegate {
|
||||
|
||||
private func handle(resultType: GithubClient.SearchResultType, animated: Bool) {
|
||||
switch resultType {
|
||||
case let .error(error) where isCancellationError(error):
|
||||
self.state = .loading(nil)
|
||||
case .error:
|
||||
self.state = .error
|
||||
case .success(_, let results):
|
||||
@@ -100,15 +109,15 @@ SearchResultSectionControllerDelegate {
|
||||
}
|
||||
|
||||
func search(term: String) {
|
||||
recentStore.add(recent: term)
|
||||
|
||||
update(animated: false)
|
||||
recentStore.add(query: .search(term))
|
||||
|
||||
let request = client.search(query: term, containerWidth: view.bounds.width) { [weak self] resultType in
|
||||
guard let state = self?.state, case .loading = state else { return }
|
||||
self?.handle(resultType: resultType, animated: true)
|
||||
}
|
||||
state = .loading(request)
|
||||
|
||||
update(animated: false)
|
||||
}
|
||||
|
||||
// MARK: ListAdapterDataSource
|
||||
@@ -116,7 +125,7 @@ SearchResultSectionControllerDelegate {
|
||||
func objects(for listAdapter: ListAdapter) -> [ListDiffable] {
|
||||
switch state {
|
||||
case .idle:
|
||||
var recents = recentStore.recents as [ListDiffable]
|
||||
var recents: [ListDiffable] = recentStore.recents.flatMap { SearchRecentViewModel(query: $0) }
|
||||
if recents.count > 0 {
|
||||
recents.insert(recentHeaderKey, at: 0)
|
||||
}
|
||||
@@ -142,7 +151,7 @@ SearchResultSectionControllerDelegate {
|
||||
return SearchRecentHeaderSectionController(delegate: self)
|
||||
} else if object is SearchRepoResult {
|
||||
return SearchResultSectionController(client: client, delegate: self)
|
||||
} else if object is String {
|
||||
} else if object is SearchRecentViewModel {
|
||||
return SearchRecentSectionController(delegate: self)
|
||||
}
|
||||
|
||||
@@ -169,11 +178,17 @@ SearchResultSectionControllerDelegate {
|
||||
// MARK: UISearchBarDelegate
|
||||
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
guard let term = searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
term.isEmpty else { return }
|
||||
|
||||
state = .idle
|
||||
update(animated: false)
|
||||
guard let term = searchTerm(for: searchBar.text) else {
|
||||
state = .idle
|
||||
update(animated: false)
|
||||
return
|
||||
}
|
||||
|
||||
if case .loading = state {
|
||||
recentStore.removeLast()
|
||||
}
|
||||
|
||||
debouncer.action = { [weak self] in self?.search(term: term) }
|
||||
}
|
||||
|
||||
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
||||
@@ -182,16 +197,12 @@ SearchResultSectionControllerDelegate {
|
||||
|
||||
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
|
||||
searchBar.resignFirstResponder()
|
||||
guard let term = searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!term.isEmpty else { return }
|
||||
|
||||
guard let term = searchTerm(for: searchBar.text) else { return }
|
||||
search(term: term)
|
||||
}
|
||||
|
||||
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
||||
if case let .loading(request) = state {
|
||||
request.cancel()
|
||||
}
|
||||
|
||||
searchBar.setShowsCancelButton(false, animated: true)
|
||||
searchBar.text = ""
|
||||
searchBar.resignFirstResponder()
|
||||
@@ -209,12 +220,31 @@ SearchResultSectionControllerDelegate {
|
||||
|
||||
// MARK: SearchRecentSectionControllerDelegate
|
||||
|
||||
func didSelect(recentSectionController: SearchRecentSectionController, text: String) {
|
||||
func didSelect(recentSectionController: SearchRecentSectionController, viewModel: SearchRecentViewModel) {
|
||||
searchBar.resignFirstResponder()
|
||||
|
||||
if case let .search(text) = viewModel.query {
|
||||
didSelectSearch(text: text)
|
||||
} else if case let .recentlyViewed(repo) = viewModel.query {
|
||||
didSelectRepo(repo: repo)
|
||||
}
|
||||
}
|
||||
|
||||
private func didSelectSearch(text: String) {
|
||||
searchBar.setShowsCancelButton(true, animated: false)
|
||||
searchBar.text = text
|
||||
search(term: text)
|
||||
}
|
||||
|
||||
private func didSelectRepo(repo: RepositoryDetails) {
|
||||
recentStore.add(query: .recentlyViewed(repo))
|
||||
update(animated: false)
|
||||
|
||||
let repoViewController = RepositoryViewController(client: client, repo: repo)
|
||||
let navigation = UINavigationController(rootViewController: repoViewController)
|
||||
showDetailViewController(navigation, sender: nil)
|
||||
}
|
||||
|
||||
// MARK: SearchRecentHeaderSectionControllerDelegate
|
||||
|
||||
func didTapClear(sectionController: SearchRecentHeaderSectionController) {
|
||||
@@ -248,13 +278,22 @@ SearchResultSectionControllerDelegate {
|
||||
|
||||
// MARK: SearchResultSectionControllerDelegate
|
||||
|
||||
func didSelect(sectionController: SearchResultSectionController) {
|
||||
func didSelect(sectionController: SearchResultSectionController, repo: RepositoryDetails) {
|
||||
recentStore.add(query: .recentlyViewed(repo))
|
||||
update(animated: false)
|
||||
searchBar.resignFirstResponder()
|
||||
}
|
||||
|
||||
func didDelete(recentSectionController: SearchRecentSectionController, text: String) {
|
||||
recentStore.remove(recent: text)
|
||||
func didDelete(recentSectionController: SearchRecentSectionController, viewModel: SearchRecentViewModel) {
|
||||
recentStore.remove(query: viewModel.query)
|
||||
update(animated: true)
|
||||
}
|
||||
|
||||
|
||||
// MARK: Private API
|
||||
|
||||
private func searchTerm(for searchBarText: String?) -> String? {
|
||||
guard let term = searchBarText?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!term.isEmpty else { return nil }
|
||||
return term
|
||||
}
|
||||
}
|
||||
|
||||
44
Classes/Systems/Debouncer.swift
Normal file
44
Classes/Systems/Debouncer.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// Debouncer.swift
|
||||
// Freetime
|
||||
//
|
||||
// Created by Hesham Salman on 10/18/17.
|
||||
// Copyright © 2017 Ryan Nystrom. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class Debouncer {
|
||||
private let delay: Double
|
||||
private weak var timer: Timer?
|
||||
|
||||
var action: (() -> Void)? {
|
||||
didSet {
|
||||
debounce()
|
||||
}
|
||||
}
|
||||
|
||||
init(delay: Double = 0.35) {
|
||||
self.delay = delay
|
||||
}
|
||||
|
||||
convenience init(delay: Double = 0.35, action: @escaping (() -> Void)) {
|
||||
self.init(delay: delay)
|
||||
self.action = action
|
||||
}
|
||||
|
||||
private func debounce() {
|
||||
timer?.invalidate()
|
||||
timer = Timer.scheduledTimer(
|
||||
timeInterval: delay,
|
||||
target: self,
|
||||
selector: #selector(Debouncer.executeAction(_:)),
|
||||
userInfo: nil,
|
||||
repeats: false
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func executeAction(_ sender: Timer) {
|
||||
action?()
|
||||
}
|
||||
}
|
||||
14
Classes/Utility/IsCancellationError.swift
Normal file
14
Classes/Utility/IsCancellationError.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// IsCancellationError.swift
|
||||
// Freetime
|
||||
//
|
||||
// Created by Hesham Salman on 10/19/17.
|
||||
// Copyright © 2017 Ryan Nystrom. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
func isCancellationError(_ error: Error?) -> Bool {
|
||||
guard let error = error else { return false }
|
||||
return (error as NSError).code == -999
|
||||
}
|
||||
@@ -368,10 +368,16 @@
|
||||
98B5A0861F6D0FFE000617D6 /* UINavigationController+Replace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98B5A0851F6D0FFE000617D6 /* UINavigationController+Replace.swift */; };
|
||||
98F0A0431F27BC4B0062A2CA /* emoji.json in Resources */ = {isa = PBXBuildFile; fileRef = 98F0A0421F27BC4B0062A2CA /* emoji.json */; };
|
||||
C2FAAA639721846A5B477E5C /* Pods_FreetimeTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F2F33C5360D6CF79F44FFF42 /* Pods_FreetimeTests.framework */; };
|
||||
DC3238991F9C3213007DD924 /* SearchRecentStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3238981F9C3213007DD924 /* SearchRecentStoreTests.swift */; };
|
||||
DC60C6C81F9803BF00241271 /* SearchRecentStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC60C6C71F9803BF00241271 /* SearchRecentStoreTests.swift */; };
|
||||
DC60C6CB1F98341900241271 /* SearchEmptyViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC60C6CA1F98341900241271 /* SearchEmptyViewTests.swift */; };
|
||||
DC60C6CE1F98346400241271 /* MockSearchEmptyViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC60C6CD1F98346400241271 /* MockSearchEmptyViewDelegate.swift */; };
|
||||
DC60C6D31F983BB900241271 /* SignatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC60C6D21F983BB900241271 /* SignatureTests.swift */; };
|
||||
DC60C6D51F983DF800241271 /* IssueLabelCellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC60C6D41F983DF800241271 /* IssueLabelCellTests.swift */; };
|
||||
DC60C6DC1F99414E00241271 /* IsCancellationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC60C6DB1F99414E00241271 /* IsCancellationError.swift */; };
|
||||
DC7857101F97F546009BADDA /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC78570F1F97F546009BADDA /* Debouncer.swift */; };
|
||||
DC3238911F9B9E1A007DD924 /* SearchRecentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3238901F9B9E1A007DD924 /* SearchRecentViewModel.swift */; };
|
||||
DC3238931F9BA29D007DD924 /* SearchQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3238921F9BA29D007DD924 /* SearchQuery.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -723,10 +729,16 @@
|
||||
98F0A0421F27BC4B0062A2CA /* emoji.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = emoji.json; sourceTree = "<group>"; };
|
||||
B9D4562D9138A112D9EDD9DD /* Pods-Freetime.testflight.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Freetime.testflight.xcconfig"; path = "Pods/Target Support Files/Pods-Freetime/Pods-Freetime.testflight.xcconfig"; sourceTree = "<group>"; };
|
||||
CD6851CDA1BB3DB6F779C307 /* Pods-FreetimeTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FreetimeTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-FreetimeTests/Pods-FreetimeTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
DC3238981F9C3213007DD924 /* SearchRecentStoreTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchRecentStoreTests.swift; sourceTree = "<group>"; };
|
||||
DC60C6C71F9803BF00241271 /* SearchRecentStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecentStoreTests.swift; sourceTree = "<group>"; };
|
||||
DC60C6CA1F98341900241271 /* SearchEmptyViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchEmptyViewTests.swift; sourceTree = "<group>"; };
|
||||
DC60C6CD1F98346400241271 /* MockSearchEmptyViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSearchEmptyViewDelegate.swift; sourceTree = "<group>"; };
|
||||
DC60C6D21F983BB900241271 /* SignatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignatureTests.swift; sourceTree = "<group>"; };
|
||||
DC60C6D41F983DF800241271 /* IssueLabelCellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueLabelCellTests.swift; sourceTree = "<group>"; };
|
||||
DC60C6DB1F99414E00241271 /* IsCancellationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsCancellationError.swift; sourceTree = "<group>"; };
|
||||
DC78570F1F97F546009BADDA /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = "<group>"; };
|
||||
DC3238901F9B9E1A007DD924 /* SearchRecentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecentViewModel.swift; sourceTree = "<group>"; };
|
||||
DC3238921F9BA29D007DD924 /* SearchQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQuery.swift; sourceTree = "<group>"; };
|
||||
EA08BE0B8263E4898B1DF86B /* Pods-Freetime.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Freetime.release.xcconfig"; path = "Pods/Target Support Files/Pods-Freetime/Pods-Freetime.release.xcconfig"; sourceTree = "<group>"; };
|
||||
F2F33C5360D6CF79F44FFF42 /* Pods_FreetimeTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FreetimeTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -1148,6 +1160,7 @@
|
||||
297AE86D1EC0D5C200B44A1F /* Resources */,
|
||||
296CD8231F014130001190B9 /* Sample */,
|
||||
2930F2701F894AA10082BA26 /* Settings.bundle */,
|
||||
DC3238971F9C3208007DD924 /* Recovered References */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -1163,21 +1176,21 @@
|
||||
297AE84B1EC0D58A00B44A1F /* FreetimeTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DC60C6CF1F9836C500241271 /* Search Tests */,
|
||||
DC60C6CC1F98344B00241271 /* Mocks */,
|
||||
297AE84C1EC0D58A00B44A1F /* DateDisplayTests.swift */,
|
||||
2981A8A61EFEBEF900E25EF1 /* EmojiTests.swift */,
|
||||
296B4E331F7C80B800C16887 /* GraphQLIDDecodeTests.swift */,
|
||||
297AE84E1EC0D58A00B44A1F /* Info.plist */,
|
||||
DC60C6D41F983DF800241271 /* IssueLabelCellTests.swift */,
|
||||
29A476B11ED24D99005D0953 /* IssueTests.swift */,
|
||||
29C2950D1EC7B43B00D46CD2 /* ListKitTestCase.swift */,
|
||||
29C295091EC7AFA500D46CD2 /* ListTestKit.swift */,
|
||||
295F52A81EF1B9E2000B53CF /* MMMarkdownASTTests.swift */,
|
||||
DC60C6CC1F98344B00241271 /* Mocks */,
|
||||
49D028FF1F91D90C00E39094 /* ReactionTests.swift */,
|
||||
DC60C6CF1F9836C500241271 /* Search Tests */,
|
||||
DC60C6D21F983BB900241271 /* SignatureTests.swift */,
|
||||
29EDFE7B1F65C580005BCCEB /* SplitViewTests.swift */,
|
||||
295F52A61EF1B9D2000B53CF /* Test.md */,
|
||||
DC60C6D21F983BB900241271 /* SignatureTests.swift */,
|
||||
DC60C6D41F983DF800241271 /* IssueLabelCellTests.swift */,
|
||||
);
|
||||
path = FreetimeTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -1283,6 +1296,7 @@
|
||||
292CD3CF1F0DBB5C00D3D57B /* WebviewCellHeightCache.swift */,
|
||||
2930F2721F8A27750082BA26 /* WidthCache.swift */,
|
||||
3E79A2FE1F8A7DA700E1126B /* ShortcutHandler.swift */,
|
||||
DC78570F1F97F546009BADDA /* Debouncer.swift */,
|
||||
);
|
||||
path = Systems;
|
||||
sourceTree = "<group>";
|
||||
@@ -1510,6 +1524,8 @@
|
||||
986B87241F2B990A00AAB55C /* SearchRepoResultCell.swift */,
|
||||
986B87221F2B98AD00AAB55C /* SearchResultSectionController.swift */,
|
||||
986B871C1F2B8FCD00AAB55C /* SearchViewController.swift */,
|
||||
DC3238901F9B9E1A007DD924 /* SearchRecentViewModel.swift */,
|
||||
DC3238921F9BA29D007DD924 /* SearchQuery.swift */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
@@ -1550,6 +1566,7 @@
|
||||
754488B01F7ADF8D0032D08C /* UIAlertController+Action.swift */,
|
||||
98835BCD1F1965E2005BA24F /* UIDevice+Model.swift */,
|
||||
98B5A0851F6D0FFE000617D6 /* UINavigationController+Replace.swift */,
|
||||
DC60C6DB1F99414E00241271 /* IsCancellationError.swift */,
|
||||
);
|
||||
path = Utility;
|
||||
sourceTree = "<group>";
|
||||
@@ -1589,6 +1606,14 @@
|
||||
name = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DC3238971F9C3208007DD924 /* Recovered References */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DC60C6C71F9803BF00241271 /* SearchRecentStoreTests.swift */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DC60C6CC1F98344B00241271 /* Mocks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -1601,6 +1626,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DC60C6CA1F98341900241271 /* SearchEmptyViewTests.swift */,
|
||||
DC3238981F9C3213007DD924 /* SearchRecentStoreTests.swift */,
|
||||
);
|
||||
path = "Search Tests";
|
||||
sourceTree = "<group>";
|
||||
@@ -2001,6 +2027,7 @@
|
||||
292FCAF61EDFCC510026635E /* IssueCommentCodeBlockCell.swift in Sources */,
|
||||
292FCAF71EDFCC510026635E /* IssueCommentCodeBlockModel.swift in Sources */,
|
||||
292FCAF91EDFCC510026635E /* IssueCommentDetailCell.swift in Sources */,
|
||||
DC3238931F9BA29D007DD924 /* SearchQuery.swift in Sources */,
|
||||
7BBFEE5B1F8A8A0400C68E47 /* SearchBarSectionController.swift in Sources */,
|
||||
292FCAFA1EDFCC510026635E /* IssueCommentDetailsViewModel.swift in Sources */,
|
||||
2949674E1EF9719300B1CF1A /* IssueCommentHrCell.swift in Sources */,
|
||||
@@ -2017,6 +2044,7 @@
|
||||
295840D81EEA0686007723C6 /* IssueCommentQuoteModel.swift in Sources */,
|
||||
292FCB071EDFCC510026635E /* IssueCommentReactionCell.swift in Sources */,
|
||||
292FCB1F1EDFCD750026635E /* IssueCommentReactionViewModel.swift in Sources */,
|
||||
DC3238911F9B9E1A007DD924 /* SearchRecentViewModel.swift in Sources */,
|
||||
292FCAFF1EDFCC510026635E /* IssueCommentSectionController.swift in Sources */,
|
||||
292FCB081EDFCC510026635E /* IssueCommentSummaryCell.swift in Sources */,
|
||||
292FCB091EDFCC510026635E /* IssueCommentSummaryModel.swift in Sources */,
|
||||
@@ -2170,8 +2198,10 @@
|
||||
29DA1E771F5DA27D0050C64B /* SearchEmptyView.swift in Sources */,
|
||||
29DA1E791F5DEE8F0050C64B /* SearchLoadingView.swift in Sources */,
|
||||
986B872D1F2C846700AAB55C /* SearchNoResultsCell.swift in Sources */,
|
||||
DC60C6DC1F99414E00241271 /* IsCancellationError.swift in Sources */,
|
||||
986B872B1F2C842000AAB55C /* SearchNoResultsSectionController.swift in Sources */,
|
||||
29DA1E841F5E26310050C64B /* SearchRecentCell.swift in Sources */,
|
||||
DC7857101F97F546009BADDA /* Debouncer.swift in Sources */,
|
||||
29DA1E881F5E2B8A0050C64B /* SearchRecentHeaderCell.swift in Sources */,
|
||||
29DA1E8A1F5E2DEC0050C64B /* SearchRecentHeaderSectionController.swift in Sources */,
|
||||
29AF1E841F8AAB4A0008A0EF /* UITextView+GitHawk.swift in Sources */,
|
||||
@@ -2280,6 +2310,8 @@
|
||||
DC60C6D51F983DF800241271 /* IssueLabelCellTests.swift in Sources */,
|
||||
294B11251F7B40CA00E04F2D /* IssueDetailsModel.swift in Sources */,
|
||||
293A45941F296D5B00DD1006 /* CollapsibleCell.swift in Sources */,
|
||||
DC60C6C81F9803BF00241271 /* SearchRecentStoreTests.swift in Sources */,
|
||||
DC3238991F9C3213007DD924 /* SearchRecentStoreTests.swift in Sources */,
|
||||
293A45961F298C9500DD1006 /* UIColor+Hex.swift in Sources */,
|
||||
293A459D1F298EDB00DD1006 /* IssueCommentQuoteCell.swift in Sources */,
|
||||
293A458E1F296CF300DD1006 /* MMElement+Image.swift in Sources */,
|
||||
|
||||
62
FreetimeTests/Search Tests/SearchRecentStoreTests.swift
Normal file
62
FreetimeTests/Search Tests/SearchRecentStoreTests.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// SearchRecentStoreTests.swift
|
||||
// FreetimeTests
|
||||
//
|
||||
// Created by Hesham Salman on 10/18/17.
|
||||
// Copyright © 2017 Ryan Nystrom. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import Freetime
|
||||
class SearchRecentStoreTests: XCTestCase {
|
||||
var store: SearchRecentStore!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
store = SearchRecentStore()
|
||||
store.clear() // in case you've used the app in-simulator before running these tests
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
store?.clear()
|
||||
}
|
||||
|
||||
func test_add_canReorderObjects() {
|
||||
store.add(recent: "samurai jack")
|
||||
store.add(recent: "aku")
|
||||
store.add(recent: "samurai jack")
|
||||
|
||||
let expected = "samurai jack"
|
||||
let actual = store.recents.first
|
||||
|
||||
XCTAssertEqual(expected, actual)
|
||||
}
|
||||
|
||||
func test_clear_removesAllObjects() {
|
||||
store.add(recent: "cat")
|
||||
store.add(recent: "bug")
|
||||
store.clear()
|
||||
|
||||
XCTAssertTrue(store.recents.isEmpty)
|
||||
}
|
||||
|
||||
func test_removesLast_doesntCrashOnEmpty() {
|
||||
XCTAssertTrue(store.recents.isEmpty)
|
||||
store.removeLast()
|
||||
}
|
||||
|
||||
func test_removesLast_removesLastObject() {
|
||||
store.add(recent: "finn")
|
||||
store.add(recent: "jake")
|
||||
store.removeLast()
|
||||
|
||||
let expectedCount = 1
|
||||
let actualCount = store.recents.count
|
||||
|
||||
XCTAssertEqual(expectedCount, actualCount)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user