From b13f4b89a82e8a07ea7e191bf6f316d151a26cfd Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Tue, 22 Sep 2020 18:00:56 -0700 Subject: [PATCH] Refactoring --- .../CollectionItemKind+Extensions.swift | 14 +++ Metatext.xcodeproj/project.pbxproj | 32 +++++-- .../Services/AccountListService.swift | 26 +++++ .../Services/AccountService.swift | 24 +++++ ...r.swift => CollectionViewController.swift} | 84 ++++++++-------- .../ViewModels/AccountListViewModel.swift | 7 ++ .../ViewModels/AccountStatusesViewModel.swift | 2 +- .../Sources/ViewModels/AccountViewModel.swift | 24 +++++ .../ViewModels/CollectionViewModel.swift | 17 ++++ .../ViewModels/Entities/CollectionItem.swift | 13 +++ .../ViewModels/Entities/NavigationEvent.swift | 9 ++ .../ViewModels/StatusListViewModel.swift | 96 ++++++++++++------- .../Sources/ViewModels/StatusViewModel.swift | 5 + Views/AccountContentConfiguration.swift | 18 ++++ Views/AccountListCell.swift | 23 +++++ Views/AccountView.swift | 64 +++++++++++++ Views/CollectionView.swift | 26 +++++ Views/Status/StatusView.swift | 9 +- Views/StatusListView.swift | 26 ----- Views/TabNavigationView.swift | 2 +- 20 files changed, 410 insertions(+), 111 deletions(-) create mode 100644 Extensions/CollectionItemKind+Extensions.swift create mode 100644 ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift create mode 100644 ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift rename View Controllers/{StatusListViewController.swift => CollectionViewController.swift} (73%) create mode 100644 ViewModels/Sources/ViewModels/AccountListViewModel.swift create mode 100644 ViewModels/Sources/ViewModels/AccountViewModel.swift create mode 100644 ViewModels/Sources/ViewModels/CollectionViewModel.swift create mode 100644 ViewModels/Sources/ViewModels/Entities/CollectionItem.swift create mode 100644 ViewModels/Sources/ViewModels/Entities/NavigationEvent.swift create mode 100644 Views/AccountContentConfiguration.swift create mode 100644 Views/AccountListCell.swift create mode 100644 Views/AccountView.swift create mode 100644 Views/CollectionView.swift delete mode 100644 Views/StatusListView.swift diff --git a/Extensions/CollectionItemKind+Extensions.swift b/Extensions/CollectionItemKind+Extensions.swift new file mode 100644 index 0000000..ebb98cd --- /dev/null +++ b/Extensions/CollectionItemKind+Extensions.swift @@ -0,0 +1,14 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import ViewModels + +extension CollectionItem.Kind { + var cellClass: AnyClass { + switch self { + case .status: + return StatusListCell.self + case .account: + return AccountListCell.self + } + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 656f7a6..10ba16f 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -30,14 +30,13 @@ D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42224F76169001EBDBB /* IdentitiesView.swift */; }; D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */; }; D0C7D49924F7616A001EBDBB /* AddIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42424F76169001EBDBB /* AddIdentityView.swift */; }; - D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42524F76169001EBDBB /* StatusListView.swift */; }; + D0C7D49A24F7616A001EBDBB /* CollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42524F76169001EBDBB /* CollectionView.swift */; }; D0C7D49B24F7616A001EBDBB /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42624F76169001EBDBB /* PreferencesView.swift */; }; D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42724F76169001EBDBB /* RootView.swift */; }; D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */; }; D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */; }; D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */; }; D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */; }; - D0C7D4A524F7616A001EBDBB /* StatusListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D43124F76169001EBDBB /* StatusListViewController.swift */; }; D0C7D4C224F7616A001EBDBB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D0C7D45224F76169001EBDBB /* Assets.xcassets */; }; D0C7D4C324F7616A001EBDBB /* MetatextApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D45424F76169001EBDBB /* MetatextApp.swift */; }; D0C7D4C424F7616A001EBDBB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D45524F76169001EBDBB /* AppDelegate.swift */; }; @@ -51,6 +50,11 @@ D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0E2C1D024FD97F000854680 /* ViewModels */; }; D0E5361C24E3EB4D00FB1CE1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */; }; D0E5362024E3EB4D00FB1CE1 /* Notification Service Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + D0F0B10E251A868200942152 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B10D251A868200942152 /* AccountView.swift */; }; + D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */; }; + D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B125251A90F400942152 /* AccountListCell.swift */; }; + D0F0B12E251A97E400942152 /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B12D251A97E400942152 /* CollectionViewController.swift */; }; + D0F0B136251AA12700942152 /* CollectionItemKind+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B135251AA12700942152 /* CollectionItemKind+Extensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -116,14 +120,13 @@ D0C7D42224F76169001EBDBB /* IdentitiesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentitiesView.swift; sourceTree = ""; }; D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomEmojiText.swift; sourceTree = ""; }; D0C7D42424F76169001EBDBB /* AddIdentityView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddIdentityView.swift; sourceTree = ""; }; - D0C7D42524F76169001EBDBB /* StatusListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusListView.swift; sourceTree = ""; }; + D0C7D42524F76169001EBDBB /* CollectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionView.swift; sourceTree = ""; }; D0C7D42624F76169001EBDBB /* PreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; D0C7D42724F76169001EBDBB /* RootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; D0C7D42824F76169001EBDBB /* PostingReadingPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostingReadingPreferencesView.swift; sourceTree = ""; }; D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecondaryNavigationView.swift; sourceTree = ""; }; D0C7D42D24F76169001EBDBB /* NotificationTypesPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationTypesPreferencesView.swift; sourceTree = ""; }; D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabNavigationView.swift; sourceTree = ""; }; - D0C7D43124F76169001EBDBB /* StatusListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusListViewController.swift; sourceTree = ""; }; D0C7D45224F76169001EBDBB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D0C7D45424F76169001EBDBB /* MetatextApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MetatextApp.swift; sourceTree = ""; }; D0C7D45524F76169001EBDBB /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -141,6 +144,11 @@ D0E5361B24E3EB4D00FB1CE1 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; D0E5361D24E3EB4D00FB1CE1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D0E5362824E4A06B00FB1CE1 /* Notification Service Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Notification Service Extension.entitlements"; sourceTree = ""; }; + D0F0B10D251A868200942152 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = ""; }; + D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountContentConfiguration.swift; sourceTree = ""; }; + D0F0B125251A90F400942152 /* AccountListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListCell.swift; sourceTree = ""; }; + D0F0B12D251A97E400942152 /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = ""; }; + D0F0B135251AA12700942152 /* CollectionItemKind+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionItemKind+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -257,9 +265,13 @@ D0C7D42024F76169001EBDBB /* Views */ = { isa = PBXGroup; children = ( + D0F0B112251A86A000942152 /* AccountContentConfiguration.swift */, D01EF22325182B1F00650C6B /* AccountHeaderView.swift */, + D0F0B125251A90F400942152 /* AccountListCell.swift */, + D0F0B10D251A868200942152 /* AccountView.swift */, D0C7D42424F76169001EBDBB /* AddIdentityView.swift */, D01F41E024F8885900D55A2D /* Attachments */, + D0C7D42524F76169001EBDBB /* CollectionView.swift */, D0C7D42324F76169001EBDBB /* CustomEmojiText.swift */, D0BEB21024FA2A90001B0F04 /* EditFilterView.swift */, D0BEB20424FA1107001B0F04 /* FiltersView.swift */, @@ -274,7 +286,6 @@ D02E1F94250B13210071AD56 /* SafariView.swift */, D0C7D42924F76169001EBDBB /* SecondaryNavigationView.swift */, D0625E55250F086B00502611 /* Status */, - D0C7D42524F76169001EBDBB /* StatusListView.swift */, D0C7D42E24F76169001EBDBB /* TabNavigationView.swift */, D01F41D624F880C400D55A2D /* TouchFallthroughTextView.swift */, ); @@ -284,7 +295,7 @@ D0C7D43024F76169001EBDBB /* View Controllers */ = { isa = PBXGroup; children = ( - D0C7D43124F76169001EBDBB /* StatusListViewController.swift */, + D0F0B12D251A97E400942152 /* CollectionViewController.swift */, ); path = "View Controllers"; sourceTree = ""; @@ -311,6 +322,7 @@ isa = PBXGroup; children = ( D0B5FE9A251583DB00478838 /* AccountStatusCollection+Extensions.swift */, + D0F0B135251AA12700942152 /* CollectionItemKind+Extensions.swift */, D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */, D0C7D46B24F76169001EBDBB /* NSMutableAttributedString+Extensions.swift */, D0C7D46A24F76169001EBDBB /* String+Extensions.swift */, @@ -502,12 +514,15 @@ D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */, D02E1F95250B13210071AD56 /* SafariView.swift in Sources */, D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */, + D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */, D0B32F50250B373600311912 /* RegistrationView.swift in Sources */, + D0F0B136251AA12700942152 /* CollectionItemKind+Extensions.swift in Sources */, D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */, D0BEB1F324F8EE8C001B0F04 /* AttachmentView.swift in Sources */, - D0C7D49A24F7616A001EBDBB /* StatusListView.swift in Sources */, + D0C7D49A24F7616A001EBDBB /* CollectionView.swift in Sources */, + D0F0B12E251A97E400942152 /* CollectionViewController.swift in Sources */, + D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */, D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, - D0C7D4A524F7616A001EBDBB /* StatusListViewController.swift in Sources */, D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */, D0625E5F250F0CFF00502611 /* StatusView.swift in Sources */, D0625E59250F092900502611 /* StatusListCell.swift in Sources */, @@ -519,6 +534,7 @@ D0C7D4A224F7616A001EBDBB /* NotificationTypesPreferencesView.swift in Sources */, D0BEB1F724F9A84B001B0F04 /* LoadingTableFooterView.swift in Sources */, D0C7D4D924F7616A001EBDBB /* KingfisherOptionsInfo+Extensions.swift in Sources */, + D0F0B10E251A868200942152 /* AccountView.swift in Sources */, D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */, D0C7D49724F7616A001EBDBB /* IdentitiesView.swift in Sources */, D01EF22425182B1F00650C6B /* AccountHeaderView.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift new file mode 100644 index 0000000..ddff731 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Services/AccountListService.swift @@ -0,0 +1,26 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import DB +import Foundation +import Mastodon +import MastodonAPI + +public struct AccountListService { + public let accountSections: AnyPublisher<[[Account]], Error> + public let paginates: Bool + + private let mastodonAPIClient: MastodonAPIClient + private let contentDatabase: ContentDatabase + private let requestClosure: (_ maxID: String?, _ minID: String?) -> AnyPublisher +} + +extension AccountListService { + +} + +public extension AccountListService { + func request(maxID: String?, minID: String?) -> AnyPublisher { + requestClosure(maxID, minID) + } +} diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift new file mode 100644 index 0000000..12c826f --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Services/AccountService.swift @@ -0,0 +1,24 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import DB +import Foundation +import Mastodon +import MastodonAPI + +public struct AccountService { + public let account: Account + public let urlService: URLService + private let mastodonAPIClient: MastodonAPIClient + private let contentDatabase: ContentDatabase + + init(account: Account, mastodonAPIClient: MastodonAPIClient, contentDatabase: ContentDatabase) { + self.account = account + self.urlService = URLService( + status: nil, + mastodonAPIClient: mastodonAPIClient, + contentDatabase: contentDatabase) + self.mastodonAPIClient = mastodonAPIClient + self.contentDatabase = contentDatabase + } +} diff --git a/View Controllers/StatusListViewController.swift b/View Controllers/CollectionViewController.swift similarity index 73% rename from View Controllers/StatusListViewController.swift rename to View Controllers/CollectionViewController.swift index 9780903..c981b3a 100644 --- a/View Controllers/StatusListViewController.swift +++ b/View Controllers/CollectionViewController.swift @@ -5,30 +5,36 @@ import SafariServices import SwiftUI import ViewModels -class StatusListViewController: UITableViewController { - private let viewModel: StatusListViewModel +class CollectionViewController: UITableViewController { + private let viewModel: CollectionViewModel private let loadingTableFooterView = LoadingTableFooterView() private var cancellables = Set() - private var cellHeightCaches = [CGFloat: [String: CGFloat]]() + private var cellHeightCaches = [CGFloat: [CollectionItem: CGFloat]]() private let dataSourceQueue = - DispatchQueue(label: "com.metabolist.metatext.status-list.data-source-queue") + DispatchQueue(label: "com.metabolist.metatext.collection.data-source-queue") - private lazy var dataSource: UITableViewDiffableDataSource = { - UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, statusID in - guard - let self = self, - let cell = tableView.dequeueReusableCell( - withIdentifier: String(describing: StatusListCell.self), - for: indexPath) as? StatusListCell - else { return nil } + private lazy var dataSource: UITableViewDiffableDataSource = { + UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in + guard let self = self, let cellViewModel = self.viewModel.viewModel(item: item) else { return nil } - cell.viewModel = self.viewModel.statusViewModel(id: statusID) + let cell = tableView.dequeueReusableCell( + withIdentifier: String(describing: item.kind.cellClass), + for: indexPath) + + switch (cell, cellViewModel) { + case (let statusListCell as StatusListCell, let statusViewModel as StatusViewModel): + statusListCell.viewModel = statusViewModel + case (let accountListCell as AccountListCell, let accountViewModel as AccountViewModel): + accountListCell.viewModel = accountViewModel + default: + return nil + } return cell } }() - init(viewModel: StatusListViewModel) { + init(viewModel: CollectionViewModel) { self.viewModel = viewModel super.init(style: .plain) @@ -42,33 +48,35 @@ class StatusListViewController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() - tableView.register(StatusListCell.self, forCellReuseIdentifier: String(describing: StatusListCell.self)) + for kind in CollectionItem.Kind.allCases { + tableView.register(kind.cellClass, forCellReuseIdentifier: String(describing: kind.cellClass)) + } tableView.dataSource = dataSource tableView.prefetchDataSource = self tableView.cellLayoutMarginsFollowReadableWidth = true tableView.tableFooterView = UIView() - navigationItem.title = viewModel.title +// navigationItem.title = viewModel.title - viewModel.$statusIDs - .sink { [weak self] in self?.update(statusIDs: $0) } + viewModel.collectionItems + .sink { [weak self] in self?.update(items: $0) } .store(in: &cancellables) - viewModel.events.sink { [weak self] in + viewModel.navigationEvents.sink { [weak self] in guard let self = self else { return } switch $0 { case let .share(url): self.share(url: url) - case let .statusListNavigation(statusListViewModel): - self.show(StatusListViewController(viewModel: statusListViewModel), sender: self) + case let .collectionNavigation(collectionViewModel): + self.show(CollectionViewController(viewModel: collectionViewModel), sender: self) case let .urlNavigation(url): self.present(SFSafariViewController(url: url), animated: true) } } .store(in: &cancellables) - viewModel.$loading + viewModel.loading .receive(on: RunLoop.main) .sink { [weak self] in guard let self = self else { return } @@ -97,7 +105,7 @@ class StatusListViewController: UITableViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - viewModel.request() + viewModel.request(maxID: nil, minID: nil) } override func tableView(_ tableView: UITableView, @@ -105,7 +113,7 @@ class StatusListViewController: UITableViewController { forRowAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } - var heightCache = cellHeightCaches[tableView.frame.width] ?? [String: CGFloat]() + var heightCache = cellHeightCaches[tableView.frame.width] ?? [CollectionItem: CGFloat]() heightCache[item] = cell.frame.height cellHeightCaches[tableView.frame.width] = heightCache @@ -118,15 +126,15 @@ class StatusListViewController: UITableViewController { } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { - guard let id = dataSource.itemIdentifier(for: indexPath) else { return true } + guard let item = dataSource.itemIdentifier(for: indexPath) else { return true } - return id != viewModel.contextParentID + return viewModel.canSelect(item: item) } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let id = dataSource.itemIdentifier(for: indexPath) else { return } + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } - show(StatusListViewController(viewModel: viewModel.contextViewModel(id: id)), sender: self) + viewModel.itemSelected(item) } override func viewDidLayoutSubviews() { @@ -136,27 +144,27 @@ class StatusListViewController: UITableViewController { } } -extension StatusListViewController: UITableViewDataSourcePrefetching { +extension CollectionViewController: UITableViewDataSourcePrefetching { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { guard viewModel.paginates, let indexPath = indexPaths.last, indexPath.section == dataSource.numberOfSections(in: tableView) - 1, indexPath.row == dataSource.tableView(tableView, numberOfRowsInSection: indexPath.section) - 1, - let maxID = dataSource.itemIdentifier(for: indexPath) + let maxID = dataSource.itemIdentifier(for: indexPath)?.id else { return } - viewModel.request(maxID: maxID) + viewModel.request(maxID: maxID, minID: nil) } } -private extension StatusListViewController { - func update(statusIDs: [[String]]) { +private extension CollectionViewController { + func update(items: [[CollectionItem]]) { var offsetFromNavigationBar: CGFloat? if - let id = viewModel.maintainScrollPositionOfStatusID, - let indexPath = dataSource.indexPath(for: id), + let item = viewModel.maintainScrollPositionOfItem, + let indexPath = dataSource.indexPath(for: item), let navigationBar = navigationController?.navigationBar { let navigationBarMaxY = tableView.convert(navigationBar.bounds, from: navigationBar).maxY offsetFromNavigationBar = tableView.rectForRow(at: indexPath).origin.y - navigationBarMaxY @@ -165,10 +173,10 @@ private extension StatusListViewController { dataSourceQueue.async { [weak self] in guard let self = self else { return } - self.dataSource.apply(statusIDs.snapshot(), animatingDifferences: false) { + self.dataSource.apply(items.snapshot(), animatingDifferences: false) { if - let id = self.viewModel.maintainScrollPositionOfStatusID, - let indexPath = self.dataSource.indexPath(for: id) { + let item = self.viewModel.maintainScrollPositionOfItem, + let indexPath = self.dataSource.indexPath(for: item) { self.tableView.scrollToRow(at: indexPath, at: .top, animated: false) if let offsetFromNavigationBar = offsetFromNavigationBar { diff --git a/ViewModels/Sources/ViewModels/AccountListViewModel.swift b/ViewModels/Sources/ViewModels/AccountListViewModel.swift new file mode 100644 index 0000000..0da31a7 --- /dev/null +++ b/ViewModels/Sources/ViewModels/AccountListViewModel.swift @@ -0,0 +1,7 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +public class AccountListViewModel: ObservableObject { + +} diff --git a/ViewModels/Sources/ViewModels/AccountStatusesViewModel.swift b/ViewModels/Sources/ViewModels/AccountStatusesViewModel.swift index d629ca4..e370bf9 100644 --- a/ViewModels/Sources/ViewModels/AccountStatusesViewModel.swift +++ b/ViewModels/Sources/ViewModels/AccountStatusesViewModel.swift @@ -39,7 +39,7 @@ public class AccountStatusesViewModel: StatusListViewModel { } override func isPinned(status: Status) -> Bool { - collection == .statuses && statusIDs.first?.contains(status.id) ?? false + collection == .statuses && items.first?.contains(CollectionItem(id: status.id, kind: .status)) ?? false } } diff --git a/ViewModels/Sources/ViewModels/AccountViewModel.swift b/ViewModels/Sources/ViewModels/AccountViewModel.swift new file mode 100644 index 0000000..2f8974c --- /dev/null +++ b/ViewModels/Sources/ViewModels/AccountViewModel.swift @@ -0,0 +1,24 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation +import Mastodon +import ServiceLayer + +public class AccountViewModel: ObservableObject { + private let accountService: AccountService + + init(accountService: AccountService) { + self.accountService = accountService + } +} + +public extension AccountViewModel { + var avatarURL: URL { + accountService.account.avatar + } + + var note: NSAttributedString { + accountService.account.note.attributed + } +} diff --git a/ViewModels/Sources/ViewModels/CollectionViewModel.swift b/ViewModels/Sources/ViewModels/CollectionViewModel.swift new file mode 100644 index 0000000..bb838d1 --- /dev/null +++ b/ViewModels/Sources/ViewModels/CollectionViewModel.swift @@ -0,0 +1,17 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation + +public protocol CollectionViewModel { + var collectionItems: AnyPublisher<[[CollectionItem]], Never> { get } + var alertItems: AnyPublisher { get } + var loading: AnyPublisher { get } + var navigationEvents: AnyPublisher { get } + var paginates: Bool { get } + var maintainScrollPositionOfItem: CollectionItem? { get } + func request(maxID: String?, minID: String?) + func itemSelected(_ item: CollectionItem) + func canSelect(item: CollectionItem) -> Bool + func viewModel(item: CollectionItem) -> Any? +} diff --git a/ViewModels/Sources/ViewModels/Entities/CollectionItem.swift b/ViewModels/Sources/ViewModels/Entities/CollectionItem.swift new file mode 100644 index 0000000..d22f8b8 --- /dev/null +++ b/ViewModels/Sources/ViewModels/Entities/CollectionItem.swift @@ -0,0 +1,13 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +public struct CollectionItem: Hashable { + public let id: String + public let kind: Kind +} + +public extension CollectionItem { + enum Kind: Hashable, CaseIterable { + case status + case account + } +} diff --git a/ViewModels/Sources/ViewModels/Entities/NavigationEvent.swift b/ViewModels/Sources/ViewModels/Entities/NavigationEvent.swift new file mode 100644 index 0000000..0a84c2a --- /dev/null +++ b/ViewModels/Sources/ViewModels/Entities/NavigationEvent.swift @@ -0,0 +1,9 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +public enum NavigationEvent { + case collectionNavigation(CollectionViewModel) + case urlNavigation(URL) + case share(URL) +} diff --git a/ViewModels/Sources/ViewModels/StatusListViewModel.swift b/ViewModels/Sources/ViewModels/StatusListViewModel.swift index b742f4d..bd83eae 100644 --- a/ViewModels/Sources/ViewModels/StatusListViewModel.swift +++ b/ViewModels/Sources/ViewModels/StatusListViewModel.swift @@ -6,21 +6,22 @@ import Mastodon import ServiceLayer public class StatusListViewModel: ObservableObject { - @Published public private(set) var statusIDs = [[String]]() + @Published public private(set) var items = [[CollectionItem]]() @Published public var alertItem: AlertItem? - @Published public private(set) var loading = false - public let events: AnyPublisher - public private(set) var maintainScrollPositionOfStatusID: String? + public let navigationEvents: AnyPublisher + public private(set) var maintainScrollPositionOfItem: CollectionItem? private var statuses = [String: Status]() + private var flatStatusIDs = [String]() private let statusListService: StatusListService private var statusViewModelCache = [Status: (StatusViewModel, AnyCancellable)]() - private let eventsSubject = PassthroughSubject() + private let navigationEventsSubject = PassthroughSubject() + private let loadingSubject = PassthroughSubject() private var cancellables = Set() init(statusListService: StatusListService) { self.statusListService = statusListService - events = eventsSubject.eraseToAnyPublisher() + navigationEvents = navigationEventsSubject.eraseToAnyPublisher() statusListService.statusSections .combineLatest(statusListService.filters.map { $0.regularExpression() }) @@ -29,11 +30,12 @@ public class StatusListViewModel: ObservableObject { self?.determineIfScrollPositionShouldBeMaintained(newStatusSections: $0) self?.cleanViewModelCache(newStatusSections: $0) self?.statuses = Dictionary(uniqueKeysWithValues: Set($0.reduce([], +)).map { ($0.id, $0) }) + self?.flatStatusIDs = $0.reduce([], +).map(\.id) }) .receive(on: DispatchQueue.main) .assignErrorsToAlertItem(to: \.alertItem, on: self) - .map { $0.map { $0.map(\.id) } } - .assign(to: &$statusIDs) + .map { $0.map { $0.map { CollectionItem(id: $0.id, kind: .status) } } } + .assign(to: &$items) } public func request(maxID: String? = nil, minID: String? = nil) { @@ -41,8 +43,8 @@ public class StatusListViewModel: ObservableObject { .receive(on: DispatchQueue.main) .assignErrorsToAlertItem(to: \.alertItem, on: self) .handleEvents( - receiveSubscription: { [weak self] _ in self?.loading = true }, - receiveCompletion: { [weak self] _ in self?.loading = false }) + receiveSubscription: { [weak self] _ in self?.loadingSubject.send(true) }, + receiveCompletion: { [weak self] _ in self?.loadingSubject.send(false) }) .sink { _ in } .store(in: &cancellables) } @@ -50,11 +52,44 @@ public class StatusListViewModel: ObservableObject { func isPinned(status: Status) -> Bool { false } } -public extension StatusListViewModel { - enum Event { - case statusListNavigation(StatusListViewModel) - case urlNavigation(URL) - case share(URL) +extension StatusListViewModel: CollectionViewModel { + public var collectionItems: AnyPublisher<[[CollectionItem]], Never> { $items.eraseToAnyPublisher() } + + public var alertItems: AnyPublisher { $alertItem.compactMap { $0 }.eraseToAnyPublisher() } + + public var loading: AnyPublisher { + loadingSubject.eraseToAnyPublisher() + } + + public func itemSelected(_ item: CollectionItem) { + switch item.kind { + case .status: + let displayStatusID = statuses[item.id]?.displayStatus.id ?? item.id + + navigationEventsSubject.send( + .collectionNavigation( + StatusListViewModel( + statusListService: statusListService.contextService(statusID: displayStatusID)))) + default: + break + } + } + + public func canSelect(item: CollectionItem) -> Bool { + if case .status = item.kind, item.id == statusListService.contextParentID { + return false + } + + return true + } + + public func viewModel(item: CollectionItem) -> Any? { + switch item.kind { + case .status: + return statusViewModel(id: item.id) + default: + return nil + } } } @@ -80,9 +115,9 @@ public extension StatusListViewModel { .assignErrorsToAlertItem(to: \.alertItem, on: self) .sink { [weak self] in guard let self = self, - let event = self.event(statusEvent: $0) + let event = self.navigationEvent(statusEvent: $0) else { return } - self.eventsSubject.send(event) + self.navigationEventsSubject.send(event) }) } @@ -93,12 +128,6 @@ public extension StatusListViewModel { return statusViewModel } - - func contextViewModel(id: String) -> StatusListViewModel { - let displayStatusID = statuses[id]?.displayStatus.id ?? id - - return StatusListViewModel(statusListService: statusListService.contextService(statusID: displayStatusID)) - } } private extension StatusListViewModel { @@ -110,7 +139,7 @@ private extension StatusListViewModel { } } - func event(statusEvent: StatusViewModel.Event) -> Event? { + func navigationEvent(statusEvent: StatusViewModel.Event) -> NavigationEvent? { switch statusEvent { case .ignorableOutput: return nil @@ -119,30 +148,31 @@ private extension StatusListViewModel { case let .url(url): return .urlNavigation(url) case let .accountID(id): - return .statusListNavigation( + return .collectionNavigation( AccountStatusesViewModel(accountStatusesService: statusListService.service(accountID: id))) case let .statusID(id): - return .statusListNavigation( + return .collectionNavigation( StatusListViewModel( statusListService: statusListService.contextService(statusID: id))) case let .tag(tag): - return .statusListNavigation( + return .collectionNavigation( StatusListViewModel( statusListService: statusListService.service(timeline: Timeline.tag(tag)))) } + case let .accountListNavigation(accountListViewModel): +// return .collectionNavigation(accountListViewModel) + return nil case let .share(url): return .share(url) } } func determineIfScrollPositionShouldBeMaintained(newStatusSections: [[Status]]) { - maintainScrollPositionOfStatusID = nil // clear old value - - let flatStatusIDs = statusIDs.reduce([], +) + maintainScrollPositionOfItem = nil // clear old value // Maintain scroll position of parent after initial load of context if let contextParentID = contextParentID, flatStatusIDs == [contextParentID] || flatStatusIDs == [] { - maintainScrollPositionOfStatusID = contextParentID + maintainScrollPositionOfItem = CollectionItem(id: contextParentID, kind: .status) } } @@ -153,8 +183,6 @@ private extension StatusListViewModel { } func isReplyInContext(status: Status) -> Bool { - let flatStatusIDs = statusIDs.reduce([], +) - guard let index = flatStatusIDs.firstIndex(where: { $0 == status.id }), index > 0 @@ -166,8 +194,6 @@ private extension StatusListViewModel { } func hasReplyFollowing(status: Status) -> Bool { - let flatStatusIDs = statusIDs.reduce([], +) - guard let index = flatStatusIDs.firstIndex(where: { $0 == status.id }), flatStatusIDs.count > index + 1, diff --git a/ViewModels/Sources/ViewModels/StatusViewModel.swift b/ViewModels/Sources/ViewModels/StatusViewModel.swift index 1e68abb..9593975 100644 --- a/ViewModels/Sources/ViewModels/StatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/StatusViewModel.swift @@ -53,6 +53,7 @@ public extension StatusViewModel { enum Event { case ignorableOutput case navigation(URLItem) + case accountListNavigation(AccountListViewModel) case share(URL) } } @@ -130,6 +131,10 @@ public extension StatusViewModel { .eraseToAnyPublisher()) } + func favoritedBySelected() { + + } + func toggleFavorited() { eventsSubject.send(statusService.toggleFavorited().map { _ in Event.ignorableOutput }.eraseToAnyPublisher()) } diff --git a/Views/AccountContentConfiguration.swift b/Views/AccountContentConfiguration.swift new file mode 100644 index 0000000..59020f9 --- /dev/null +++ b/Views/AccountContentConfiguration.swift @@ -0,0 +1,18 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +struct AccountContentConfiguration { + let viewModel: AccountViewModel +} + +extension AccountContentConfiguration: UIContentConfiguration { + func makeContentView() -> UIView & UIContentView { + AccountView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> AccountContentConfiguration { + self + } +} diff --git a/Views/AccountListCell.swift b/Views/AccountListCell.swift new file mode 100644 index 0000000..68b4357 --- /dev/null +++ b/Views/AccountListCell.swift @@ -0,0 +1,23 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +class AccountListCell: UITableViewCell { + var viewModel: AccountViewModel? + + override func updateConfiguration(using state: UICellConfigurationState) { + guard let viewModel = viewModel else { return } + + contentConfiguration = AccountContentConfiguration(viewModel: viewModel).updated(for: state) + } + + override func layoutSubviews() { + super.layoutSubviews() + + let isPhoneIdiom = UIDevice.current.userInterfaceIdiom == .phone + + separatorInset.right = isPhoneIdiom ? 0 : layoutMargins.right + separatorInset.left = isPhoneIdiom ? 0 : layoutMargins.left + } +} diff --git a/Views/AccountView.swift b/Views/AccountView.swift new file mode 100644 index 0000000..05d6d4c --- /dev/null +++ b/Views/AccountView.swift @@ -0,0 +1,64 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Kingfisher +import UIKit + +class AccountView: UIView { + let avatarImageView = AnimatedImageView() + let noteTextView = TouchFallthroughTextView() + + private var accountConfiguration: AccountContentConfiguration + + init(configuration: AccountContentConfiguration) { + self.accountConfiguration = configuration + + super.init(frame: .zero) + + initialSetup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension AccountView: UIContentView { + var configuration: UIContentConfiguration { + get { accountConfiguration } + set { + guard let accountConfiguration = newValue as? AccountContentConfiguration else { return } + + self.accountConfiguration = accountConfiguration + + avatarImageView.kf.cancelDownloadTask() + applyAccountConfiguration() + } + } +} + +private extension AccountView { + func initialSetup() { + let baseStackView = UIStackView() + + addSubview(baseStackView) + baseStackView.translatesAutoresizingMaskIntoConstraints = false + baseStackView.addArrangedSubview(avatarImageView) + baseStackView.addArrangedSubview(noteTextView) + noteTextView.isScrollEnabled = false + + NSLayoutConstraint.activate([ + baseStackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor), + baseStackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + baseStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + baseStackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor) + ]) + + applyAccountConfiguration() + } + + func applyAccountConfiguration() { + avatarImageView.kf.setImage(with: accountConfiguration.viewModel.avatarURL) + noteTextView.attributedText = accountConfiguration.viewModel.note + } +} diff --git a/Views/CollectionView.swift b/Views/CollectionView.swift new file mode 100644 index 0000000..f9b98c5 --- /dev/null +++ b/Views/CollectionView.swift @@ -0,0 +1,26 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI +import ViewModels + +struct CollectionView: UIViewControllerRepresentable { + let viewModel: CollectionViewModel + + func makeUIViewController(context: Context) -> CollectionViewController { + CollectionViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: CollectionViewController, context: Context) { + + } +} + +#if DEBUG +import PreviewViewModels + +struct StatusListView_Previews: PreviewProvider { + static var previews: some View { + CollectionView(viewModel: NavigationViewModel(identification: .preview).viewModel(timeline: .home)) + } +} +#endif diff --git a/Views/Status/StatusView.swift b/Views/Status/StatusView.swift index c43999e..30c41b6 100644 --- a/Views/Status/StatusView.swift +++ b/Views/Status/StatusView.swift @@ -148,15 +148,20 @@ private extension StatusView { let accountAction = UIAction { [weak self] _ in self?.statusConfiguration.viewModel.accountSelected() } avatarButton.addAction(accountAction, for: .touchUpInside) + contextParentAvatarButton.addAction(accountAction, for: .touchUpInside) let favoriteAction = UIAction { [weak self] _ in self?.statusConfiguration.viewModel.toggleFavorited() } favoriteButton.addAction(favoriteAction, for: .touchUpInside) contextParentFavoriteButton.addAction(favoriteAction, for: .touchUpInside) - let shareAction = UIAction { [weak self] _ in self?.statusConfiguration.viewModel.shareStatus() } + shareButton.addAction( + UIAction { [weak self] _ in self?.statusConfiguration.viewModel.shareStatus() }, + for: .touchUpInside) - shareButton.addAction(shareAction, for: .touchUpInside) + contextParentFavoritedByButton.addAction( + UIAction { [weak self] _ in self?.statusConfiguration.viewModel.favoritedBySelected() }, + for: .touchUpInside) applyStatusConfiguration() } diff --git a/Views/StatusListView.swift b/Views/StatusListView.swift deleted file mode 100644 index cc49972..0000000 --- a/Views/StatusListView.swift +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import SwiftUI -import ViewModels - -struct StatusListView: UIViewControllerRepresentable { - let viewModel: StatusListViewModel - - func makeUIViewController(context: Context) -> StatusListViewController { - StatusListViewController(viewModel: viewModel) - } - - func updateUIViewController(_ uiViewController: StatusListViewController, context: Context) { - - } -} - -#if DEBUG -import PreviewViewModels - -struct StatusListView_Previews: PreviewProvider { - static var previews: some View { - StatusListView(viewModel: NavigationViewModel(identification: .preview).viewModel(timeline: .home)) - } -} -#endif diff --git a/Views/TabNavigationView.swift b/Views/TabNavigationView.swift index b1fb300..67e1b41 100644 --- a/Views/TabNavigationView.swift +++ b/Views/TabNavigationView.swift @@ -61,7 +61,7 @@ private extension TabNavigationView { func view(tab: NavigationViewModel.Tab) -> some View { switch tab { case .timelines: - StatusListView(viewModel: viewModel.viewModel(timeline: viewModel.timeline)) + CollectionView(viewModel: viewModel.viewModel(timeline: viewModel.timeline)) .id(viewModel.timeline.id) .edgesIgnoringSafeArea(.all) .navigationTitle(viewModel.timeline.title)