feature: add search history
This commit is contained in:
parent
90803fc544
commit
d800e10bd7
@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D75" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
<entity name="Application" representedClassName=".Application" syncable="YES">
|
<entity name="Application" representedClassName=".Application" syncable="YES">
|
||||||
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
<attribute name="name" attributeType="String"/>
|
<attribute name="name" attributeType="String"/>
|
||||||
@ -138,12 +138,18 @@
|
|||||||
<relationship name="poll" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
|
<relationship name="poll" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
|
||||||
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePollOptions" inverseEntity="MastodonUser"/>
|
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePollOptions" inverseEntity="MastodonUser"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="PrivateNote" representedClassName="PrivateNote" syncable="YES">
|
<entity name="PrivateNote" representedClassName=".PrivateNote" syncable="YES">
|
||||||
<attribute name="note" optional="YES" attributeType="String"/>
|
<attribute name="note" optional="YES" attributeType="String"/>
|
||||||
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<relationship name="from" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotesTo" inverseEntity="MastodonUser"/>
|
<relationship name="from" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotesTo" inverseEntity="MastodonUser"/>
|
||||||
<relationship name="to" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotes" inverseEntity="MastodonUser"/>
|
<relationship name="to" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotes" inverseEntity="MastodonUser"/>
|
||||||
</entity>
|
</entity>
|
||||||
|
<entity name="SearchHistory" representedClassName=".SearchHistory" syncable="YES">
|
||||||
|
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser"/>
|
||||||
|
<relationship name="hashTag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag"/>
|
||||||
|
</entity>
|
||||||
<entity name="Status" representedClassName=".Status" syncable="YES">
|
<entity name="Status" representedClassName=".Status" syncable="YES">
|
||||||
<attribute name="content" attributeType="String"/>
|
<attribute name="content" attributeType="String"/>
|
||||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
@ -201,8 +207,9 @@
|
|||||||
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
|
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
|
||||||
<element name="Poll" positionX="72" positionY="162" width="128" height="194"/>
|
<element name="Poll" positionX="72" positionY="162" width="128" height="194"/>
|
||||||
<element name="PollOption" positionX="81" positionY="171" width="128" height="134"/>
|
<element name="PollOption" positionX="81" positionY="171" width="128" height="134"/>
|
||||||
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
|
|
||||||
<element name="Status" positionX="0" positionY="0" width="128" height="569"/>
|
|
||||||
<element name="PrivateNote" positionX="72" positionY="153" width="128" height="89"/>
|
<element name="PrivateNote" positionX="72" positionY="153" width="128" height="89"/>
|
||||||
|
<element name="Status" positionX="0" positionY="0" width="128" height="569"/>
|
||||||
|
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
|
||||||
|
<element name="SearchHistory" positionX="72" positionY="162" width="128" height="89"/>
|
||||||
</elements>
|
</elements>
|
||||||
</model>
|
</model>
|
54
CoreDataStack/Entity/SearchHistory.swift
Normal file
54
CoreDataStack/Entity/SearchHistory.swift
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
//
|
||||||
|
// SearchHistory.swift
|
||||||
|
// CoreDataStack
|
||||||
|
//
|
||||||
|
// Created by sxiaojian on 2021/4/7.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
public final class SearchHistory: NSManagedObject {
|
||||||
|
public typealias ID = UUID
|
||||||
|
@NSManaged public private(set) var identifier: ID
|
||||||
|
@NSManaged public private(set) var createAt: Date
|
||||||
|
|
||||||
|
@NSManaged public private(set) var account: MastodonUser?
|
||||||
|
@NSManaged public private(set) var hashTag: Tag?
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SearchHistory {
|
||||||
|
public override func awakeFromInsert() {
|
||||||
|
super.awakeFromInsert()
|
||||||
|
setPrimitiveValue(UUID(), forKey: #keyPath(SearchHistory.identifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public static func insert(
|
||||||
|
into context: NSManagedObjectContext,
|
||||||
|
account: MastodonUser
|
||||||
|
) -> SearchHistory {
|
||||||
|
let searchHistory: SearchHistory = context.insertObject()
|
||||||
|
searchHistory.account = account
|
||||||
|
searchHistory.createAt = Date()
|
||||||
|
return searchHistory
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public static func insert(
|
||||||
|
into context: NSManagedObjectContext,
|
||||||
|
hashTag: Tag
|
||||||
|
) -> SearchHistory {
|
||||||
|
let searchHistory: SearchHistory = context.insertObject()
|
||||||
|
searchHistory.hashTag = hashTag
|
||||||
|
searchHistory.createAt = Date()
|
||||||
|
return searchHistory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SearchHistory: Managed {
|
||||||
|
public static var defaultSortDescriptors: [NSSortDescriptor] {
|
||||||
|
return [NSSortDescriptor(keyPath: \SearchHistory.createAt, ascending: false)]
|
||||||
|
}
|
||||||
|
}
|
@ -21,6 +21,7 @@
|
|||||||
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 */; };
|
||||||
2D0B7A0B261D5A5600B44727 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A0A261D5A5600B44727 /* Array.swift */; };
|
2D0B7A0B261D5A5600B44727 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A0A261D5A5600B44727 /* Array.swift */; };
|
||||||
|
2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A1C261D839600B44727 /* SearchHistory.swift */; };
|
||||||
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; };
|
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; };
|
||||||
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; };
|
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; };
|
||||||
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
|
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
|
||||||
@ -372,6 +373,7 @@
|
|||||||
0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.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>"; };
|
||||||
2D0B7A0A261D5A5600B44727 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; };
|
2D0B7A0A261D5A5600B44727 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; };
|
||||||
|
2D0B7A1C261D839600B44727 /* SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistory.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>"; };
|
||||||
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
|
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
|
||||||
@ -1398,6 +1400,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
DB89BA2625C110B4008580ED /* Status.swift */,
|
DB89BA2625C110B4008580ED /* Status.swift */,
|
||||||
|
2D0B7A1C261D839600B44727 /* SearchHistory.swift */,
|
||||||
DB8AF52425C131D1002E6C99 /* MastodonUser.swift */,
|
DB8AF52425C131D1002E6C99 /* MastodonUser.swift */,
|
||||||
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */,
|
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */,
|
||||||
2D927F0125C7E4F2004F19B8 /* Mention.swift */,
|
2D927F0125C7E4F2004F19B8 /* Mention.swift */,
|
||||||
@ -2333,6 +2336,7 @@
|
|||||||
2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */,
|
2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */,
|
||||||
2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */,
|
2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */,
|
||||||
DB89BA1D25C1107F008580ED /* URL.swift in Sources */,
|
DB89BA1D25C1107F008580ED /* URL.swift in Sources */,
|
||||||
|
2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */,
|
||||||
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */,
|
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
// Created by sxiaojian on 2021/4/6.
|
// Created by sxiaojian on 2021/4/6.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
import Foundation
|
import Foundation
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
|
||||||
@ -13,6 +14,10 @@ enum SearchResultItem {
|
|||||||
|
|
||||||
case account(account: Mastodon.Entity.Account)
|
case account(account: Mastodon.Entity.Account)
|
||||||
|
|
||||||
|
case accountObjectID(accountObjectID: NSManagedObjectID)
|
||||||
|
|
||||||
|
case hashTagObjectID(hashTagObjectID: NSManagedObjectID)
|
||||||
|
|
||||||
case bottomLoader
|
case bottomLoader
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,6 +30,10 @@ extension SearchResultItem: Equatable {
|
|||||||
return accountLeft == accountRight
|
return accountLeft == accountRight
|
||||||
case (.bottomLoader, .bottomLoader):
|
case (.bottomLoader, .bottomLoader):
|
||||||
return true
|
return true
|
||||||
|
case (.accountObjectID(let idLeft),.accountObjectID(let idRight)):
|
||||||
|
return idLeft == idRight
|
||||||
|
case (.hashTagObjectID(let idLeft),.hashTagObjectID(let idRight)):
|
||||||
|
return idLeft == idRight
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -38,6 +47,10 @@ extension SearchResultItem: Hashable {
|
|||||||
hasher.combine(account)
|
hasher.combine(account)
|
||||||
case .hashTag(let tag):
|
case .hashTag(let tag):
|
||||||
hasher.combine(tag)
|
hasher.combine(tag)
|
||||||
|
case .accountObjectID(let id):
|
||||||
|
hasher.combine(id)
|
||||||
|
case .hashTagObjectID(let id):
|
||||||
|
hasher.combine(id)
|
||||||
case .bottomLoader:
|
case .bottomLoader:
|
||||||
hasher.combine(String(describing: SearchResultItem.bottomLoader.self))
|
hasher.combine(String(describing: SearchResultItem.bottomLoader.self))
|
||||||
}
|
}
|
||||||
|
@ -8,16 +8,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
|
|
||||||
enum SearchResultSection: Equatable, Hashable {
|
enum SearchResultSection: Equatable, Hashable {
|
||||||
case account
|
case account
|
||||||
case hashTag
|
case hashTag
|
||||||
|
case mixed
|
||||||
case bottomLoader
|
case bottomLoader
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SearchResultSection {
|
extension SearchResultSection {
|
||||||
static func tableViewDiffableDataSource(
|
static func tableViewDiffableDataSource(
|
||||||
for tableView: UITableView
|
for tableView: UITableView,
|
||||||
|
dependency: NeedsDependency
|
||||||
) -> UITableViewDiffableDataSource<SearchResultSection, SearchResultItem> {
|
) -> UITableViewDiffableDataSource<SearchResultSection, SearchResultItem> {
|
||||||
UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, result) -> UITableViewCell? in
|
UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, result) -> UITableViewCell? in
|
||||||
switch result {
|
switch result {
|
||||||
@ -29,6 +33,16 @@ extension SearchResultSection {
|
|||||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||||
cell.config(with: tag)
|
cell.config(with: tag)
|
||||||
return cell
|
return cell
|
||||||
|
case .hashTagObjectID(let hashTagObjectID):
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||||
|
let tag = dependency.context.managedObjectContext.object(with: hashTagObjectID) as! Tag
|
||||||
|
cell.config(with: tag)
|
||||||
|
return cell
|
||||||
|
case .accountObjectID(let accountObjectID):
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
|
||||||
|
let user = dependency.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
|
||||||
|
cell.config(with: user)
|
||||||
|
return cell
|
||||||
case .bottomLoader:
|
case .bottomLoader:
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader
|
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader
|
||||||
cell.startAnimating()
|
cell.startAnimating()
|
||||||
|
@ -5,8 +5,12 @@
|
|||||||
// Created by sxiaojian on 2021/4/2.
|
// Created by sxiaojian on 2021/4/2.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
import Foundation
|
import Foundation
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
import OSLog
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
extension SearchViewController {
|
extension SearchViewController {
|
||||||
@ -20,7 +24,7 @@ extension SearchViewController {
|
|||||||
searchingTableView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
searchingTableView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
searchingTableView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
searchingTableView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
searchingTableView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
searchingTableView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||||
searchingTableView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor),
|
searchingTableView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor)
|
||||||
])
|
])
|
||||||
searchingTableView.tableFooterView = UIView()
|
searchingTableView.tableFooterView = UIView()
|
||||||
viewModel.isSearching
|
viewModel.isSearching
|
||||||
@ -29,6 +33,42 @@ extension SearchViewController {
|
|||||||
self?.searchingTableView.isHidden = !isSearching
|
self?.searchingTableView.isHidden = !isSearching
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
Publishers.CombineLatest(
|
||||||
|
viewModel.isSearching,
|
||||||
|
viewModel.searchText
|
||||||
|
)
|
||||||
|
.sink { [weak self] isSearching, text in
|
||||||
|
guard let self = self else { return }
|
||||||
|
if isSearching, text.isEmpty {
|
||||||
|
self.searchingTableView.tableHeaderView = self.searchHeader
|
||||||
|
} else {
|
||||||
|
self.searchingTableView.tableHeaderView = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupSearchHeader() {
|
||||||
|
searchHeader.addSubview(recentSearchesLabel)
|
||||||
|
recentSearchesLabel.constrain([
|
||||||
|
recentSearchesLabel.constraint(.leading, toView: searchHeader, constant: 16),
|
||||||
|
recentSearchesLabel.constraint(.centerY, toView: searchHeader)
|
||||||
|
])
|
||||||
|
|
||||||
|
searchHeader.addSubview(clearSearchHistoryButton)
|
||||||
|
recentSearchesLabel.constrain([
|
||||||
|
searchHeader.trailingAnchor.constraint(equalTo: clearSearchHistoryButton.trailingAnchor, constant: 16),
|
||||||
|
clearSearchHistoryButton.constraint(.centerY, toView: searchHeader)
|
||||||
|
])
|
||||||
|
|
||||||
|
clearSearchHistoryButton.addTarget(self, action: #selector(SearchViewController.clearAction(_:)), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SearchViewController {
|
||||||
|
@objc func clearAction(_ sender: UIButton) {
|
||||||
|
viewModel.deleteSearchHistory()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,5 +83,9 @@ extension SearchViewController: UITableViewDelegate {
|
|||||||
66
|
66
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {}
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
guard let diffableDataSource = viewModel.searchResultDiffableDataSource else { return }
|
||||||
|
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
|
viewModel.saveItemToCoreData(item: item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -76,17 +76,39 @@ final class SearchViewController: UIViewController, NeedsDependency {
|
|||||||
// searching
|
// searching
|
||||||
let searchingTableView: UITableView = {
|
let searchingTableView: UITableView = {
|
||||||
let tableView = UITableView()
|
let tableView = UITableView()
|
||||||
|
tableView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
tableView.separatorStyle = .singleLine
|
tableView.separatorStyle = .singleLine
|
||||||
tableView.backgroundColor = .white
|
|
||||||
return tableView
|
return tableView
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
lazy var searchHeader: UIView = {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||||
|
view.frame = CGRect(origin: .zero, size: CGSize(width: searchingTableView.frame.width, height: 56))
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
let recentSearchesLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
|
||||||
|
label.textColor = Asset.Colors.Label.primary.color
|
||||||
|
label.text = L10n.Scene.Search.Searching.recentSearch
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
let clearSearchHistoryButton: UIButton = {
|
||||||
|
let button = UIButton(type: .custom)
|
||||||
|
button.setTitleColor(Asset.Colors.buttonDefault.color, for: .normal)
|
||||||
|
button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal)
|
||||||
|
return button
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SearchViewController {
|
extension SearchViewController {
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = Asset.Colors.Background.search.color
|
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||||
searchBar.delegate = self
|
searchBar.delegate = self
|
||||||
navigationItem.titleView = searchBar
|
navigationItem.titleView = searchBar
|
||||||
navigationItem.hidesBackButton = true
|
navigationItem.hidesBackButton = true
|
||||||
@ -95,6 +117,7 @@ extension SearchViewController {
|
|||||||
setupAccountsCollectionView()
|
setupAccountsCollectionView()
|
||||||
setupSearchingTableView()
|
setupSearchingTableView()
|
||||||
setupDataSource()
|
setupDataSource()
|
||||||
|
setupSearchHeader()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupScrollView() {
|
func setupScrollView() {
|
||||||
@ -120,7 +143,7 @@ extension SearchViewController {
|
|||||||
func setupDataSource() {
|
func setupDataSource() {
|
||||||
viewModel.hashTagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashTagCollectionView)
|
viewModel.hashTagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashTagCollectionView)
|
||||||
viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView)
|
viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView)
|
||||||
viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView)
|
viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView, dependency: self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,13 +6,15 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
import Foundation
|
import Foundation
|
||||||
import GameplayKit
|
import GameplayKit
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
import OSLog
|
import OSLog
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
final class SearchViewModel {
|
final class SearchViewModel: NSObject {
|
||||||
var disposeBag = Set<AnyCancellable>()
|
var disposeBag = Set<AnyCancellable>()
|
||||||
|
|
||||||
// input
|
// input
|
||||||
@ -51,41 +53,79 @@ final class SearchViewModel {
|
|||||||
|
|
||||||
init(context: AppContext) {
|
init(context: AppContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
|
super.init()
|
||||||
|
|
||||||
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Publishers.CombineLatest(
|
Publishers.CombineLatest(
|
||||||
searchText
|
searchText
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(),
|
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(),
|
||||||
searchScope)
|
searchScope
|
||||||
.flatMap { (text, scope) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in
|
)
|
||||||
let query = Mastodon.API.Search.Query(accountID: nil,
|
.filter { text, _ in
|
||||||
maxID: nil,
|
!text.isEmpty
|
||||||
minID: nil,
|
}
|
||||||
type: scope,
|
.flatMap { (text, scope) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in
|
||||||
excludeUnreviewed: nil,
|
|
||||||
q: text,
|
let query = Mastodon.API.Search.Query(accountID: nil,
|
||||||
resolve: nil,
|
maxID: nil,
|
||||||
limit: nil,
|
minID: nil,
|
||||||
offset: nil,
|
type: scope,
|
||||||
following: nil)
|
excludeUnreviewed: nil,
|
||||||
return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
q: text,
|
||||||
}
|
resolve: nil,
|
||||||
.sink { _ in
|
limit: nil,
|
||||||
} receiveValue: { [weak self] result in
|
offset: nil,
|
||||||
self?.searchResult.value = result.value
|
following: nil)
|
||||||
}
|
return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
|
||||||
.store(in: &disposeBag)
|
}
|
||||||
|
.sink { _ in
|
||||||
|
} receiveValue: { [weak self] result in
|
||||||
|
self?.searchResult.value = result.value
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
isSearching
|
isSearching
|
||||||
.sink { [weak self] isSearching in
|
.sink { [weak self] isSearching in
|
||||||
if !isSearching {
|
if !isSearching {
|
||||||
self?.searchResult.value = nil
|
self?.searchResult.value = nil
|
||||||
|
self?.searchText.value = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
Publishers.CombineLatest3(
|
||||||
|
isSearching,
|
||||||
|
searchText,
|
||||||
|
searchScope
|
||||||
|
)
|
||||||
|
.filter { isSearching, text, _ in
|
||||||
|
isSearching && text.isEmpty
|
||||||
|
}
|
||||||
|
.sink { [weak self] _, _, scope in
|
||||||
|
guard let self = self else { return }
|
||||||
|
guard let searchHistories = self.fetchSearchHistory() else { return }
|
||||||
|
guard let dataSource = self.searchResultDiffableDataSource else { return }
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
|
||||||
|
snapshot.appendSections([.mixed])
|
||||||
|
|
||||||
|
searchHistories.forEach { searchHistory in
|
||||||
|
let containsAccount = scope == Mastodon.API.Search.Scope.accounts.rawValue || scope == ""
|
||||||
|
let containsHashTag = scope == Mastodon.API.Search.Scope.hashTags.rawValue || scope == ""
|
||||||
|
if let mastodonUser = searchHistory.account, containsAccount {
|
||||||
|
let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID)
|
||||||
|
snapshot.appendItems([item], toSection: .mixed)
|
||||||
|
}
|
||||||
|
if let tag = searchHistory.hashTag, containsHashTag {
|
||||||
|
let item = SearchResultItem.hashTagObjectID(hashTagObjectID: tag.objectID)
|
||||||
|
snapshot.appendItems([item], toSection: .mixed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
requestRecommendHashTags()
|
requestRecommendHashTags()
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
@ -190,4 +230,67 @@ final class SearchViewModel {
|
|||||||
.store(in: &self.disposeBag)
|
.store(in: &self.disposeBag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func saveItemToCoreData(item: SearchResultItem) {
|
||||||
|
_ = context.managedObjectContext.performChanges { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
switch item {
|
||||||
|
case .account(let account):
|
||||||
|
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// load request mastodon user
|
||||||
|
let requestMastodonUser: MastodonUser? = {
|
||||||
|
let request = MastodonUser.sortedFetchRequest
|
||||||
|
request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, id: activeMastodonAuthenticationBox.userID)
|
||||||
|
request.fetchLimit = 1
|
||||||
|
request.returnsObjectsAsFaults = false
|
||||||
|
do {
|
||||||
|
return try self.context.managedObjectContext.fetch(request).first
|
||||||
|
} catch {
|
||||||
|
assertionFailure(error.localizedDescription)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.context.managedObjectContext, for: requestMastodonUser, in: activeMastodonAuthenticationBox.domain, entity: account, userCache: nil, networkDate: Date(), log: OSLog.api)
|
||||||
|
SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser)
|
||||||
|
|
||||||
|
case .hashTag(let tag):
|
||||||
|
let histories = tag.history?[0 ... 2].compactMap { history -> History in
|
||||||
|
History.insert(into: self.context.managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts))
|
||||||
|
}
|
||||||
|
let tagInCoreData = Tag.insert(into: self.context.managedObjectContext, property: Tag.Property(name: tag.name, url: tag.url, histories: histories))
|
||||||
|
SearchHistory.insert(into: self.context.managedObjectContext, hashTag: tagInCoreData)
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSearchHistory() -> [SearchHistory]? {
|
||||||
|
let searchHistory: [SearchHistory]? = {
|
||||||
|
let request = SearchHistory.sortedFetchRequest
|
||||||
|
request.predicate = nil
|
||||||
|
request.returnsObjectsAsFaults = false
|
||||||
|
do {
|
||||||
|
return try context.managedObjectContext.fetch(request)
|
||||||
|
} catch {
|
||||||
|
assertionFailure(error.localizedDescription)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
}()
|
||||||
|
return searchHistory
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSearchHistory() {
|
||||||
|
let result = fetchSearchHistory()
|
||||||
|
_ = context.managedObjectContext.performChanges { [weak self] in
|
||||||
|
result?.forEach { history in
|
||||||
|
self?.context.managedObjectContext.delete(history)
|
||||||
|
}
|
||||||
|
self?.isSearching.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,8 @@
|
|||||||
// Created by sxiaojian on 2021/4/2.
|
// Created by sxiaojian on 2021/4/2.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import CoreDataStack
|
||||||
import Foundation
|
import Foundation
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
import UIKit
|
import UIKit
|
||||||
@ -12,7 +14,7 @@ import UIKit
|
|||||||
final class SearchingTableViewCell: UITableViewCell {
|
final class SearchingTableViewCell: UITableViewCell {
|
||||||
let _imageView: UIImageView = {
|
let _imageView: UIImageView = {
|
||||||
let imageView = UIImageView()
|
let imageView = UIImageView()
|
||||||
imageView.tintColor = .black
|
imageView.tintColor = Asset.Colors.Label.primary.color
|
||||||
return imageView
|
return imageView
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@ -50,6 +52,7 @@ final class SearchingTableViewCell: UITableViewCell {
|
|||||||
|
|
||||||
extension SearchingTableViewCell {
|
extension SearchingTableViewCell {
|
||||||
private func configure() {
|
private func configure() {
|
||||||
|
backgroundColor = .clear
|
||||||
selectionStyle = .none
|
selectionStyle = .none
|
||||||
contentView.addSubview(_imageView)
|
contentView.addSubview(_imageView)
|
||||||
_imageView.pin(toSize: CGSize(width: 42, height: 42))
|
_imageView.pin(toSize: CGSize(width: 42, height: 42))
|
||||||
@ -75,6 +78,16 @@ extension SearchingTableViewCell {
|
|||||||
_subTitleLabel.text = account.acct
|
_subTitleLabel.text = account.acct
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func config(with account: MastodonUser) {
|
||||||
|
_imageView.af.setImage(
|
||||||
|
withURL: URL(string: account.avatar)!,
|
||||||
|
placeholderImage: UIImage.placeholder(color: .systemFill),
|
||||||
|
imageTransition: .crossDissolve(0.2)
|
||||||
|
)
|
||||||
|
_titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName
|
||||||
|
_subTitleLabel.text = account.acct
|
||||||
|
}
|
||||||
|
|
||||||
func config(with tag: Mastodon.Entity.Tag) {
|
func config(with tag: Mastodon.Entity.Tag) {
|
||||||
let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate)
|
let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate)
|
||||||
_imageView.image = image
|
_imageView.image = image
|
||||||
@ -88,6 +101,22 @@ extension SearchingTableViewCell {
|
|||||||
let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking))
|
let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking))
|
||||||
_subTitleLabel.text = string
|
_subTitleLabel.text = string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func config(with tag: Tag) {
|
||||||
|
let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate)
|
||||||
|
_imageView.image = image
|
||||||
|
_titleLabel.text = "# " + tag.name
|
||||||
|
guard let historys = tag.histories?.sorted(by: {
|
||||||
|
$0.createAt.compare($1.createAt) == .orderedAscending
|
||||||
|
}) else {
|
||||||
|
_subTitleLabel.text = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let recentHistory = historys[0 ... 2]
|
||||||
|
let peopleAreTalking = recentHistory.compactMap { Int($0.accounts) }.reduce(0, +)
|
||||||
|
let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking))
|
||||||
|
_subTitleLabel.text = string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(SwiftUI) && DEBUG
|
#if canImport(SwiftUI) && DEBUG
|
||||||
|
@ -18,7 +18,7 @@ class SearchRecommendCollectionHeader: UIView {
|
|||||||
|
|
||||||
let descriptionLabel: UILabel = {
|
let descriptionLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.textColor = Asset.Colors.lightSecondaryText.color
|
label.textColor = Asset.Colors.Label.secondary.color
|
||||||
label.font = .preferredFont(forTextStyle: .body)
|
label.font = .preferredFont(forTextStyle: .body)
|
||||||
label.numberOfLines = 0
|
label.numberOfLines = 0
|
||||||
label.lineBreakMode = .byWordWrapping
|
label.lineBreakMode = .byWordWrapping
|
||||||
|
Loading…
x
Reference in New Issue
Block a user