forked from zelo72/mastodon-ios
Merge pull request #77 from tootsuite/feature/publish-title-view
Update navigation title view to display publish progress
This commit is contained in:
commit
ff850301df
|
@ -18,6 +18,10 @@
|
|||
"discard_post_content": {
|
||||
"title": "Discard Publish",
|
||||
"message": "Confirm discard composed post content."
|
||||
},
|
||||
"publish_post_failure": {
|
||||
"title": "Publish Failure",
|
||||
"message": "Failed to publish the post.\nPlease check your internet connection."
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
|
@ -32,6 +36,7 @@
|
|||
"continue": "Continue",
|
||||
"cancel": "Cancel",
|
||||
"discard": "Discard",
|
||||
"try_again": "Try Again",
|
||||
"take_photo": "Take photo",
|
||||
"save_photo": "Save photo",
|
||||
"sign_in": "Sign In",
|
||||
|
|
|
@ -73,8 +73,8 @@
|
|||
2D7631B325C159F700929FB9 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631B225C159F700929FB9 /* Item.swift */; };
|
||||
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; };
|
||||
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; };
|
||||
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */; };
|
||||
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */; };
|
||||
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */; };
|
||||
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */; };
|
||||
2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D84350425FF858100EECE90 /* UIScrollView.swift */; };
|
||||
2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0125C7E4F2004F19B8 /* Mention.swift */; };
|
||||
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; };
|
||||
|
@ -246,6 +246,7 @@
|
|||
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; };
|
||||
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; };
|
||||
DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; };
|
||||
DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */; };
|
||||
DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; };
|
||||
DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; };
|
||||
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; };
|
||||
|
@ -370,8 +371,8 @@
|
|||
2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
|
||||
2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewControllerAppearance.swift; sourceTree = "<group>"; };
|
||||
2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = "<group>"; };
|
||||
2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarState.swift; sourceTree = "<group>"; };
|
||||
2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarView.swift; sourceTree = "<group>"; };
|
||||
2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleViewModel.swift; sourceTree = "<group>"; };
|
||||
2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleView.swift; sourceTree = "<group>"; };
|
||||
2D84350425FF858100EECE90 /* UIScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScrollView.swift; sourceTree = "<group>"; };
|
||||
2D927F0125C7E4F2004F19B8 /* Mention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = "<group>"; };
|
||||
2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
|
||||
|
@ -555,6 +556,7 @@
|
|||
DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = "<group>"; };
|
||||
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
|
||||
DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = "<group>"; };
|
||||
DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPublishService.swift; sourceTree = "<group>"; };
|
||||
DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = "<group>"; };
|
||||
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
|
||||
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; };
|
||||
|
@ -707,6 +709,7 @@
|
|||
2D38F1D325CD463600561493 /* HomeTimeline */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DB1F239626117C360057430E /* View */,
|
||||
2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */,
|
||||
2D38F1DE25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift */,
|
||||
2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */,
|
||||
|
@ -715,8 +718,6 @@
|
|||
2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */,
|
||||
2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */,
|
||||
2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */,
|
||||
2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */,
|
||||
2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */,
|
||||
);
|
||||
path = HomeTimeline;
|
||||
sourceTree = "<group>";
|
||||
|
@ -785,6 +786,7 @@
|
|||
2DA6054625F716A2006356F9 /* PlaybackState.swift */,
|
||||
5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */,
|
||||
DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */,
|
||||
DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */,
|
||||
);
|
||||
path = Service;
|
||||
sourceTree = "<group>";
|
||||
|
@ -970,6 +972,15 @@
|
|||
path = TableView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB1F239626117C360057430E /* View */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */,
|
||||
2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */,
|
||||
);
|
||||
path = View;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DB3D0FF725BAA68500EAA174 /* Supporting Files */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1832,12 +1843,12 @@
|
|||
2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */,
|
||||
2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */,
|
||||
DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */,
|
||||
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */,
|
||||
2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */,
|
||||
DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */,
|
||||
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
|
||||
2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */,
|
||||
DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */,
|
||||
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */,
|
||||
2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */,
|
||||
DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */,
|
||||
DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */,
|
||||
DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */,
|
||||
|
@ -1925,6 +1936,7 @@
|
|||
2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */,
|
||||
DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */,
|
||||
DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */,
|
||||
DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */,
|
||||
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */,
|
||||
0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */,
|
||||
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */,
|
||||
|
@ -2246,7 +2258,7 @@
|
|||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
|
@ -2254,7 +2266,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
MARKETING_VERSION = 0.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
@ -2273,7 +2285,7 @@
|
|||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets";
|
||||
DEVELOPMENT_TEAM = 7LFDZ96332;
|
||||
INFOPLIST_FILE = Mastodon/Info.plist;
|
||||
|
@ -2281,7 +2293,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
MARKETING_VERSION = 0.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
|
@ -44,6 +44,7 @@ internal enum Asset {
|
|||
internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background")
|
||||
internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background")
|
||||
internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background")
|
||||
internal static let success = ColorAsset(name: "Colors/Background/success")
|
||||
internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background")
|
||||
internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background")
|
||||
internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background")
|
||||
|
|
|
@ -25,6 +25,12 @@ internal enum L10n {
|
|||
/// Discard Publish
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Title")
|
||||
}
|
||||
internal enum PublishPostFailure {
|
||||
/// Failed to publish the post.\nPlease check your internet connection.
|
||||
internal static let message = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Message")
|
||||
/// Publish Failure
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Title")
|
||||
}
|
||||
internal enum ServerError {
|
||||
/// Server Error
|
||||
internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title")
|
||||
|
@ -76,6 +82,8 @@ internal enum L10n {
|
|||
internal static let signUp = L10n.tr("Localizable", "Common.Controls.Actions.SignUp")
|
||||
/// Take photo
|
||||
internal static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto")
|
||||
/// Try Again
|
||||
internal static let tryAgain = L10n.tr("Localizable", "Common.Controls.Actions.TryAgain")
|
||||
}
|
||||
internal enum Status {
|
||||
/// Tap to reveal that may be sensitive
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.604",
|
||||
"green" : "0.741",
|
||||
"red" : "0.475"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -2,6 +2,9 @@
|
|||
"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later.";
|
||||
"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content.";
|
||||
"Common.Alerts.DiscardPostContent.Title" = "Discard Publish";
|
||||
"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post.
|
||||
Please check your internet connection.";
|
||||
"Common.Alerts.PublishPostFailure.Title" = "Publish Failure";
|
||||
"Common.Alerts.ServerError.Title" = "Server Error";
|
||||
"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure";
|
||||
"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired";
|
||||
|
@ -23,6 +26,7 @@
|
|||
"Common.Controls.Actions.SignIn" = "Sign In";
|
||||
"Common.Controls.Actions.SignUp" = "Sign Up";
|
||||
"Common.Controls.Actions.TakePhoto" = "Take photo";
|
||||
"Common.Controls.Actions.TryAgain" = "Try Again";
|
||||
"Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive";
|
||||
"Common.Controls.Status.Poll.Closed" = "Closed";
|
||||
"Common.Controls.Status.Poll.TimeLeft" = "%@ left";
|
||||
|
|
|
@ -483,7 +483,7 @@ extension ComposeViewController {
|
|||
// TODO: handle error
|
||||
return
|
||||
}
|
||||
|
||||
context.statusPublishService.publish(composeViewModel: viewModel)
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ extension ComposeViewModel {
|
|||
|
||||
override func didEnter(from previousState: GKState?) {
|
||||
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
|
||||
viewModel?.publishStateMachinePublisher.value = self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -48,6 +49,8 @@ extension ComposeViewModel.PublishState {
|
|||
return
|
||||
}
|
||||
|
||||
viewModel.updatePublishDate()
|
||||
|
||||
let domain = mastodonAuthenticationBox.domain
|
||||
let attachmentServices = viewModel.attachmentServices.value
|
||||
let mediaIDs = attachmentServices.compactMap { attachmentService in
|
||||
|
@ -131,7 +134,13 @@ extension ComposeViewModel.PublishState {
|
|||
class Fail: ComposeViewModel.PublishState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
// allow discard publishing
|
||||
return stateClass == Publishing.self || stateClass == Finish.self
|
||||
return stateClass == Publishing.self || stateClass == Discard.self
|
||||
}
|
||||
}
|
||||
|
||||
class Discard: ComposeViewModel.PublishState {
|
||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -39,12 +39,15 @@ final class ComposeViewModel {
|
|||
PublishState.Initial(viewModel: self),
|
||||
PublishState.Publishing(viewModel: self),
|
||||
PublishState.Fail(viewModel: self),
|
||||
PublishState.Discard(viewModel: self),
|
||||
PublishState.Finish(viewModel: self),
|
||||
])
|
||||
stateMachine.enter(PublishState.Initial.self)
|
||||
return stateMachine
|
||||
}()
|
||||
|
||||
private(set) lazy var publishStateMachinePublisher = CurrentValueSubject<PublishState?, Never>(nil)
|
||||
private(set) var publishDate = Date() // update it when enter Publishing state
|
||||
|
||||
// UI & UX
|
||||
let title: CurrentValueSubject<String, Never>
|
||||
let shouldDismiss = CurrentValueSubject<Bool, Never>(true)
|
||||
|
@ -316,6 +319,10 @@ extension ComposeViewModel {
|
|||
let attribute = ComposeStatusItem.ComposePollOptionAttribute()
|
||||
pollOptionAttributes.value = pollOptionAttributes.value + [attribute]
|
||||
}
|
||||
|
||||
func updatePublishDate() {
|
||||
publishDate = Date()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MastodonAttachmentServiceDelegate
|
||||
|
|
|
@ -1,156 +0,0 @@
|
|||
//
|
||||
// HomeTimelineNavigationBarState.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/15.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
final class HomeTimelineNavigationBarState {
|
||||
static let errorCountMax: Int = 3
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
var errorCountDownDispose: AnyCancellable?
|
||||
var timerDispose: AnyCancellable?
|
||||
var networkErrorCountSubject = PassthroughSubject<Bool, Never>()
|
||||
|
||||
var newTopContent = CurrentValueSubject<Bool, Never>(false)
|
||||
var hasContentBeforeFetching: Bool = true
|
||||
|
||||
weak var viewController: HomeTimelineViewController?
|
||||
|
||||
let timestampUpdatePublisher = Timer.publish(every: NavigationBarProgressView.progressAnimationDuration, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.share()
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
init() {
|
||||
reCountdown()
|
||||
subscribeNewContent()
|
||||
addGesture()
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeTimelineNavigationBarState {
|
||||
func showOfflineInNavigationBar() {
|
||||
HomeTimelineNavigationBarView.progressView.removeFromSuperview()
|
||||
viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.offlineView
|
||||
}
|
||||
|
||||
func showNewPostsInNavigationBar() {
|
||||
HomeTimelineNavigationBarView.progressView.removeFromSuperview()
|
||||
viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.newPostsView
|
||||
}
|
||||
|
||||
func showPublishingNewPostInNavigationBar() {
|
||||
let progressView = HomeTimelineNavigationBarView.progressView
|
||||
if let navigationBar = viewController?.navigationBar(), progressView.superview == nil {
|
||||
navigationBar.addSubview(progressView)
|
||||
NSLayoutConstraint.activate([
|
||||
progressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor),
|
||||
progressView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor),
|
||||
progressView.trailingAnchor.constraint(equalTo: navigationBar.trailingAnchor),
|
||||
progressView.heightAnchor.constraint(equalToConstant: 3)
|
||||
])
|
||||
}
|
||||
progressView.layoutIfNeeded()
|
||||
progressView.progress = 0
|
||||
viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.publishingLabel
|
||||
|
||||
var times: Int = 0
|
||||
timerDispose = timestampUpdatePublisher
|
||||
.map { _ in
|
||||
times += 1
|
||||
return Double(times)
|
||||
}
|
||||
.scan(0) { value, count in
|
||||
value + 1 / pow(Double(2), count)
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { value in
|
||||
print(value)
|
||||
progressView.progress = CGFloat(value)
|
||||
}
|
||||
}
|
||||
|
||||
func showPublishedInNavigationBar() {
|
||||
timerDispose = nil
|
||||
HomeTimelineNavigationBarView.progressView.removeFromSuperview()
|
||||
viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.publishedView
|
||||
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) {
|
||||
self.showMastodonLogoInNavigationBar()
|
||||
}
|
||||
}
|
||||
|
||||
func showMastodonLogoInNavigationBar() {
|
||||
HomeTimelineNavigationBarView.progressView.removeFromSuperview()
|
||||
viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.mastodonLogoTitleView
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeTimelineNavigationBarState {
|
||||
func handleScrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
let contentOffsetY = scrollView.contentOffset.y
|
||||
let isShowingNewPostsNew = viewController?.navigationItem.titleView === HomeTimelineNavigationBarView.newPostsView
|
||||
if !isShowingNewPostsNew {
|
||||
return
|
||||
}
|
||||
let isTop = contentOffsetY < -scrollView.contentInset.top
|
||||
if isTop {
|
||||
newTopContent.value = false
|
||||
showMastodonLogoInNavigationBar()
|
||||
}
|
||||
}
|
||||
|
||||
func addGesture() {
|
||||
let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer
|
||||
tapGesture.addTarget(self, action: #selector(HomeTimelineNavigationBarState.newPostsNewDidPressed(_:)))
|
||||
HomeTimelineNavigationBarView.newPostsView.addGestureRecognizer(tapGesture)
|
||||
}
|
||||
|
||||
@objc func newPostsNewDidPressed(_ sender: UITapGestureRecognizer) {
|
||||
if newTopContent.value == true {
|
||||
viewController?.tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeTimelineNavigationBarState {
|
||||
func subscribeNewContent() {
|
||||
newTopContent
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] newContent in
|
||||
guard let self = self else { return }
|
||||
if self.hasContentBeforeFetching, newContent {
|
||||
self.showNewPostsInNavigationBar()
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
func reCountdown() {
|
||||
errorCountDownDispose = networkErrorCountSubject
|
||||
.scan(0) { value, _ in value + 1 }
|
||||
.sink(receiveValue: { [weak self] errorCount in
|
||||
guard let self = self else { return }
|
||||
if errorCount >= HomeTimelineNavigationBarState.errorCountMax {
|
||||
self.showOfflineInNavigationBar()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func receiveCompletion(completion: Subscribers.Completion<Error>) {
|
||||
switch completion {
|
||||
case .failure:
|
||||
networkErrorCountSubject.send(false)
|
||||
case .finished:
|
||||
reCountdown()
|
||||
let isShowingOfflineView = viewController?.navigationItem.titleView === HomeTimelineNavigationBarView.offlineView
|
||||
if isShowingOfflineView {
|
||||
showMastodonLogoInNavigationBar()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
//
|
||||
// HomeTimelineNavigationBarView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/15.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class HomeTimelineNavigationBarView {
|
||||
static let mastodonLogoTitleView: UIImageView = {
|
||||
let imageView = UIImageView(image: Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate))
|
||||
imageView.tintColor = Asset.Colors.Label.primary.color
|
||||
return imageView
|
||||
}()
|
||||
|
||||
static let offlineView: UIView = {
|
||||
let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.danger.color)
|
||||
let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.offline)
|
||||
HomeTimelineNavigationBarView.addLabelToView(label: label, view: view)
|
||||
return view
|
||||
}()
|
||||
|
||||
static let newPostsView: UIView = {
|
||||
let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.Button.normal.color)
|
||||
let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.newPosts)
|
||||
HomeTimelineNavigationBarView.addLabelToView(label: label, view: view)
|
||||
return view
|
||||
}()
|
||||
|
||||
static var publishedView: UIView = {
|
||||
let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.lightSuccessGreen.color)
|
||||
let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.published)
|
||||
HomeTimelineNavigationBarView.addLabelToView(label: label, view: view)
|
||||
return view
|
||||
}()
|
||||
|
||||
static var progressView: NavigationBarProgressView = {
|
||||
let view = NavigationBarProgressView()
|
||||
return view
|
||||
}()
|
||||
|
||||
static var publishingLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.textColor = .black
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold))
|
||||
label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing
|
||||
return label
|
||||
}()
|
||||
|
||||
static func addLabelToView(label: UILabel, view: UIView) {
|
||||
view.addSubview(label)
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||
view.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 16),
|
||||
label.topAnchor.constraint(equalTo: view.topAnchor, constant: 1),
|
||||
view.bottomAnchor.constraint(equalTo: label.bottomAnchor, constant: 1),
|
||||
view.heightAnchor.constraint(equalToConstant: 24),
|
||||
])
|
||||
}
|
||||
|
||||
static func backgroundViewWithColor(color: UIColor) -> UIView {
|
||||
let view = UIView()
|
||||
view.backgroundColor = color
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.layer.cornerRadius = 12
|
||||
view.clipsToBounds = true
|
||||
return view
|
||||
}
|
||||
|
||||
static func contentLabel(text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.textColor = .white
|
||||
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .bold))
|
||||
label.text = text
|
||||
return label
|
||||
}
|
||||
}
|
|
@ -23,6 +23,8 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency {
|
|||
var disposeBag = Set<AnyCancellable>()
|
||||
private(set) lazy var viewModel = HomeTimelineViewModel(context: context)
|
||||
|
||||
let titleView = HomeTimelineNavigationBarTitleView()
|
||||
|
||||
let settingBarButtonItem: UIBarButtonItem = {
|
||||
let barButtonItem = UIBarButtonItem()
|
||||
barButtonItem.tintColor = Asset.Colors.Label.highlight.color
|
||||
|
@ -49,6 +51,12 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency {
|
|||
return tableView
|
||||
}()
|
||||
|
||||
let publishProgressView: UIProgressView = {
|
||||
let progressView = UIProgressView(progressViewStyle: .bar)
|
||||
progressView.alpha = 0
|
||||
return progressView
|
||||
}()
|
||||
|
||||
let refreshControl = UIRefreshControl()
|
||||
|
||||
deinit {
|
||||
|
@ -64,8 +72,19 @@ extension HomeTimelineViewController {
|
|||
|
||||
title = L10n.Scene.HomeTimeline.title
|
||||
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
|
||||
navigationItem.titleView = HomeTimelineNavigationBarView.mastodonLogoTitleView
|
||||
navigationItem.leftBarButtonItem = settingBarButtonItem
|
||||
navigationItem.titleView = titleView
|
||||
titleView.delegate = self
|
||||
|
||||
viewModel.homeTimelineNavigationBarTitleViewModel.state
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
guard let self = self else { return }
|
||||
self.titleView.configure(state: state)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
|
||||
#if DEBUG
|
||||
// long press to trigger debug menu
|
||||
settingBarButtonItem.menu = debugMenu
|
||||
|
@ -95,9 +114,16 @@ extension HomeTimelineViewController {
|
|||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
publishProgressView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(publishProgressView)
|
||||
NSLayoutConstraint.activate([
|
||||
publishProgressView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
|
||||
publishProgressView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
])
|
||||
|
||||
viewModel.tableView = tableView
|
||||
viewModel.viewController = self
|
||||
viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self
|
||||
tableView.delegate = self
|
||||
tableView.prefetchDataSource = self
|
||||
|
@ -121,9 +147,35 @@ extension HomeTimelineViewController {
|
|||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
viewModel.homeTimelineNavigationBarTitleViewModel.publishingProgress
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] progress in
|
||||
guard let self = self else { return }
|
||||
guard progress > 0 else {
|
||||
let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut)
|
||||
dismissAnimator.addAnimations {
|
||||
self.publishProgressView.alpha = 0
|
||||
}
|
||||
dismissAnimator.addCompletion { _ in
|
||||
self.publishProgressView.setProgress(0, animated: false)
|
||||
}
|
||||
dismissAnimator.startAnimation()
|
||||
return
|
||||
}
|
||||
if self.publishProgressView.alpha == 0 {
|
||||
let progressAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeOut)
|
||||
progressAnimator.addAnimations {
|
||||
self.publishProgressView.alpha = 1
|
||||
}
|
||||
progressAnimator.startAnimation()
|
||||
}
|
||||
|
||||
self.publishProgressView.setProgress(progress, animated: true)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
|
@ -207,7 +259,7 @@ extension HomeTimelineViewController {
|
|||
extension HomeTimelineViewController {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
handleScrollViewDidScroll(scrollView)
|
||||
self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView)
|
||||
self.viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -221,8 +273,9 @@ extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer {
|
|||
// MARK: - UITableViewDelegate
|
||||
extension HomeTimelineViewController: UITableViewDelegate {
|
||||
|
||||
func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
return 200
|
||||
// TODO:
|
||||
// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
// guard let diffableDataSource = viewModel.diffableDataSource else { return 100 }
|
||||
// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 }
|
||||
//
|
||||
|
@ -232,7 +285,7 @@ extension HomeTimelineViewController: UITableViewDelegate {
|
|||
// // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription)
|
||||
//
|
||||
// return ceil(frame.height)
|
||||
// }
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
handleTableView(tableView, willDisplay: cell, forRowAt: indexPath)
|
||||
|
@ -364,3 +417,23 @@ extension HomeTimelineViewController: StatusTableViewCellDelegate {
|
|||
weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self }
|
||||
func parent() -> UIViewController { return self }
|
||||
}
|
||||
|
||||
// MARK: - HomeTimelineNavigationBarTitleViewDelegate
|
||||
extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate {
|
||||
func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) {
|
||||
switch titleView.state {
|
||||
case .newPostButton:
|
||||
guard let diffableDataSource = viewModel.diffableDataSource else { return }
|
||||
let indexPath = IndexPath(row: 0, section: 0)
|
||||
guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return }
|
||||
tableView.scrollToRow(at: indexPath, at: .top, animated: true)
|
||||
case .offlineButton:
|
||||
// TODO: retry
|
||||
break
|
||||
case .publishedButton:
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -73,7 +73,7 @@ extension HomeTimelineViewModel.LoadLatestState {
|
|||
stateMachine.enter(Fail.self)
|
||||
return
|
||||
}
|
||||
viewModel.homeTimelineNavigationBarState.hasContentBeforeFetching = !latestTootIDs.isEmpty
|
||||
|
||||
let end = CACurrentMediaTime()
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect toots id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start)
|
||||
|
||||
|
@ -81,7 +81,7 @@ extension HomeTimelineViewModel.LoadLatestState {
|
|||
viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { completion in
|
||||
viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion)
|
||||
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion)
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
// TODO: handle error
|
||||
|
@ -102,9 +102,10 @@ extension HomeTimelineViewModel.LoadLatestState {
|
|||
|
||||
if newToots.isEmpty {
|
||||
viewModel.isFetchingLatestTimeline.value = false
|
||||
viewModel.homeTimelineNavigationBarState.newTopContent.value = false
|
||||
} else {
|
||||
viewModel.homeTimelineNavigationBarState.newTopContent.value = true
|
||||
if !latestTootIDs.isEmpty {
|
||||
viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming()
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.disposeBag)
|
||||
|
|
|
@ -68,7 +68,7 @@ extension HomeTimelineViewModel.LoadMiddleState {
|
|||
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { completion in
|
||||
viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion)
|
||||
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion)
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
// TODO: handle error
|
||||
|
|
|
@ -58,7 +58,7 @@ extension HomeTimelineViewModel.LoadOldestState {
|
|||
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { completion in
|
||||
viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion)
|
||||
viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion)
|
||||
switch completion {
|
||||
case .failure(let error):
|
||||
os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
|
||||
|
|
|
@ -28,17 +28,11 @@ final class HomeTimelineViewModel: NSObject {
|
|||
let fetchedResultsController: NSFetchedResultsController<HomeTimelineIndex>
|
||||
let isFetchingLatestTimeline = CurrentValueSubject<Bool, Never>(false)
|
||||
let viewDidAppear = PassthroughSubject<Void, Never>()
|
||||
|
||||
let homeTimelineNavigationBarState = HomeTimelineNavigationBarState()
|
||||
let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel
|
||||
|
||||
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
|
||||
weak var tableView: UITableView?
|
||||
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
|
||||
weak var viewController: HomeTimelineViewController? {
|
||||
willSet(value) {
|
||||
self.homeTimelineNavigationBarState.viewController = value
|
||||
}
|
||||
}
|
||||
|
||||
// output
|
||||
// top loader
|
||||
|
@ -90,6 +84,7 @@ final class HomeTimelineViewModel: NSObject {
|
|||
|
||||
return controller
|
||||
}()
|
||||
self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context)
|
||||
super.init()
|
||||
|
||||
fetchedResultsController.delegate = self
|
||||
|
|
|
@ -0,0 +1,210 @@
|
|||
//
|
||||
// HomeTimelineNavigationBarTitleView.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/15.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import UIKit
|
||||
|
||||
protocol HomeTimelineNavigationBarTitleViewDelegate: class {
|
||||
func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton)
|
||||
}
|
||||
|
||||
final class HomeTimelineNavigationBarTitleView: UIView {
|
||||
|
||||
let containerView = UIStackView()
|
||||
|
||||
let imageView = UIImageView()
|
||||
let button = RoundedEdgesButton()
|
||||
let label = UILabel()
|
||||
|
||||
// input
|
||||
private var blockingState: HomeTimelineNavigationBarTitleViewModel.State?
|
||||
weak var delegate: HomeTimelineNavigationBarTitleViewDelegate?
|
||||
|
||||
// output
|
||||
private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logoImage
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
_init()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
_init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension HomeTimelineNavigationBarTitleView {
|
||||
private func _init() {
|
||||
containerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(containerView)
|
||||
NSLayoutConstraint.activate([
|
||||
containerView.topAnchor.constraint(equalTo: topAnchor),
|
||||
containerView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
containerView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
containerView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
|
||||
containerView.addArrangedSubview(imageView)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addArrangedSubview(button)
|
||||
NSLayoutConstraint.activate([
|
||||
button.heightAnchor.constraint(equalToConstant: 24).priority(.defaultHigh)
|
||||
])
|
||||
containerView.addArrangedSubview(label)
|
||||
|
||||
configure(state: .logoImage)
|
||||
button.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.buttonDidPressed(_:)), for: .touchUpInside)
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeTimelineNavigationBarTitleView {
|
||||
@objc private func buttonDidPressed(_ sender: UIButton) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
delegate?.homeTimelineNavigationBarTitleView(self, buttonDidPressed: sender)
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeTimelineNavigationBarTitleView {
|
||||
|
||||
func resetContainer() {
|
||||
imageView.isHidden = true
|
||||
button.isHidden = true
|
||||
label.isHidden = true
|
||||
}
|
||||
|
||||
func configure(state: HomeTimelineNavigationBarTitleViewModel.State) {
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: configure title view: %s", ((#file as NSString).lastPathComponent), #line, #function, state.rawValue)
|
||||
self.state = state
|
||||
|
||||
// check state block or not
|
||||
guard blockingState == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
resetContainer()
|
||||
|
||||
switch state {
|
||||
case .logoImage:
|
||||
imageView.tintColor = Asset.Colors.Label.primary.color
|
||||
imageView.image = Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate)
|
||||
imageView.contentMode = .center
|
||||
imageView.isHidden = false
|
||||
case .newPostButton:
|
||||
configureButton(
|
||||
title: L10n.Scene.HomeTimeline.NavigationBarState.newPosts,
|
||||
textColor: .white,
|
||||
backgroundColor: Asset.Colors.Button.normal.color
|
||||
)
|
||||
button.isHidden = false
|
||||
case .offlineButton:
|
||||
configureButton(
|
||||
title: L10n.Scene.HomeTimeline.NavigationBarState.offline,
|
||||
textColor: .white,
|
||||
backgroundColor: Asset.Colors.Background.danger.color
|
||||
)
|
||||
button.isHidden = false
|
||||
case .publishingPostLabel:
|
||||
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
label.textColor = Asset.Colors.Label.primary.color
|
||||
label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing
|
||||
label.textAlignment = .center
|
||||
label.isHidden = false
|
||||
case .publishedButton:
|
||||
blockingState = state
|
||||
configureButton(
|
||||
title: L10n.Scene.HomeTimeline.NavigationBarState.published,
|
||||
textColor: .white,
|
||||
backgroundColor: Asset.Colors.Background.success.color
|
||||
)
|
||||
button.isHidden = false
|
||||
|
||||
let presentDuration: TimeInterval = 0.33
|
||||
let scaleAnimator = UIViewPropertyAnimator(duration: presentDuration, timingParameters: UISpringTimingParameters())
|
||||
button.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
|
||||
scaleAnimator.addAnimations {
|
||||
self.button.transform = .identity
|
||||
}
|
||||
let alphaAnimator = UIViewPropertyAnimator(duration: presentDuration, curve: .easeInOut)
|
||||
button.alpha = 0.3
|
||||
alphaAnimator.addAnimations {
|
||||
self.button.alpha = 1
|
||||
}
|
||||
scaleAnimator.startAnimation()
|
||||
alphaAnimator.startAnimation()
|
||||
|
||||
let dismissDuration: TimeInterval = 3
|
||||
let dissolveAnimator = UIViewPropertyAnimator(duration: dismissDuration, curve: .easeInOut)
|
||||
dissolveAnimator.addAnimations({
|
||||
self.button.alpha = 0
|
||||
}, delayFactor: 0.9) // at 2.7s
|
||||
dissolveAnimator.addCompletion { _ in
|
||||
self.blockingState = nil
|
||||
self.configure(state: self.state)
|
||||
self.button.alpha = 1
|
||||
}
|
||||
dissolveAnimator.startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private func configureButton(title: String, textColor: UIColor, backgroundColor: UIColor) {
|
||||
button.setBackgroundImage(.placeholder(color: backgroundColor), for: .normal)
|
||||
button.setBackgroundImage(.placeholder(color: backgroundColor.withAlphaComponent(0.5)), for: .highlighted)
|
||||
button.setTitleColor(textColor, for: .normal)
|
||||
button.setTitleColor(textColor.withAlphaComponent(0.5), for: .highlighted)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.contentEdgeInsets = UIEdgeInsets(top: 1, left: 16, bottom: 1, right: 16)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#if canImport(SwiftUI) && DEBUG
|
||||
import SwiftUI
|
||||
|
||||
struct HomeTimelineNavigationBarTitleView_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
Group {
|
||||
UIViewPreview(width: 375) {
|
||||
let titleView = HomeTimelineNavigationBarTitleView()
|
||||
titleView.configure(state: .logoImage)
|
||||
return titleView
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 44))
|
||||
UIViewPreview(width: 150) {
|
||||
let titleView = HomeTimelineNavigationBarTitleView()
|
||||
titleView.configure(state: .newPostButton)
|
||||
return titleView
|
||||
}
|
||||
.previewLayout(.fixed(width: 150, height: 24))
|
||||
UIViewPreview(width: 120) {
|
||||
let titleView = HomeTimelineNavigationBarTitleView()
|
||||
titleView.configure(state: .offlineButton)
|
||||
return titleView
|
||||
}
|
||||
.previewLayout(.fixed(width: 120, height: 24))
|
||||
UIViewPreview(width: 375) {
|
||||
let titleView = HomeTimelineNavigationBarTitleView()
|
||||
titleView.configure(state: .publishingPostLabel)
|
||||
return titleView
|
||||
}
|
||||
.previewLayout(.fixed(width: 375, height: 44))
|
||||
UIViewPreview(width: 120) {
|
||||
let titleView = HomeTimelineNavigationBarTitleView()
|
||||
titleView.configure(state: .publishedButton)
|
||||
return titleView
|
||||
}
|
||||
.previewLayout(.fixed(width: 120, height: 24))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
//
|
||||
// HomeTimelineNavigationBarTitleViewModel.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by sxiaojian on 2021/3/15.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
final class HomeTimelineNavigationBarTitleViewModel {
|
||||
|
||||
static let offlineCounterLimit = 3
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
private(set) var publishingProgressSubscription: AnyCancellable?
|
||||
|
||||
// input
|
||||
let context: AppContext
|
||||
var networkErrorCount = CurrentValueSubject<Int, Never>(0)
|
||||
var networkErrorPublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
// output
|
||||
let state = CurrentValueSubject<State, Never>(.logoImage)
|
||||
let hasNewPosts = CurrentValueSubject<Bool, Never>(false)
|
||||
let isOffline = CurrentValueSubject<Bool, Never>(false)
|
||||
let isPublishingPost = CurrentValueSubject<Bool, Never>(false)
|
||||
let isPublished = CurrentValueSubject<Bool, Never>(false)
|
||||
let publishingProgress = PassthroughSubject<Float, Never>()
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
|
||||
networkErrorPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.networkErrorCount.value += self.networkErrorCount.value + 1
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
networkErrorCount
|
||||
.receive(on: DispatchQueue.main)
|
||||
.map { count in
|
||||
return count >= HomeTimelineNavigationBarTitleViewModel.offlineCounterLimit
|
||||
}
|
||||
.assign(to: \.value, on: isOffline)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
context.statusPublishService.latestPublishingComposeViewModel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] composeViewModel in
|
||||
guard let self = self else { return }
|
||||
guard let composeViewModel = composeViewModel,
|
||||
let state = composeViewModel.publishStateMachine.currentState else {
|
||||
self.isPublishingPost.value = false
|
||||
self.isPublished.value = false
|
||||
return
|
||||
}
|
||||
|
||||
self.isPublishingPost.value = state is ComposeViewModel.PublishState.Publishing || state is ComposeViewModel.PublishState.Fail
|
||||
self.isPublished.value = state is ComposeViewModel.PublishState.Finish
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
Publishers.CombineLatest4(
|
||||
hasNewPosts.eraseToAnyPublisher(),
|
||||
isOffline.eraseToAnyPublisher(),
|
||||
isPublishingPost.eraseToAnyPublisher(),
|
||||
isPublished.eraseToAnyPublisher()
|
||||
)
|
||||
.map { hasNewPosts, isOffline, isPublishingPost, isPublished -> State in
|
||||
guard !isPublished else { return .publishedButton }
|
||||
guard !isPublishingPost else { return .publishingPostLabel }
|
||||
guard !isOffline else { return .offlineButton }
|
||||
guard !hasNewPosts else { return .newPostButton }
|
||||
return .logoImage
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.value, on: state)
|
||||
.store(in: &disposeBag)
|
||||
|
||||
state
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] state in
|
||||
guard let self = self else { return }
|
||||
switch state {
|
||||
case .publishingPostLabel:
|
||||
self.setupPublishingProgress()
|
||||
default:
|
||||
self.suspendPublishingProgress()
|
||||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeTimelineNavigationBarTitleViewModel {
|
||||
// state order by priority from low to high
|
||||
enum State: String {
|
||||
case logoImage
|
||||
case newPostButton
|
||||
case offlineButton
|
||||
case publishingPostLabel
|
||||
case publishedButton
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - New post state
|
||||
extension HomeTimelineNavigationBarTitleViewModel {
|
||||
|
||||
func newPostsIncoming() {
|
||||
hasNewPosts.value = true
|
||||
}
|
||||
|
||||
private func resetNewPostState() {
|
||||
hasNewPosts.value = false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Offline state
|
||||
extension HomeTimelineNavigationBarTitleViewModel {
|
||||
|
||||
func resetOfflineCounterListener() {
|
||||
networkErrorCount.value = 0
|
||||
}
|
||||
|
||||
func receiveLoadingStateCompletion(_ completion: Subscribers.Completion<Error>) {
|
||||
switch completion {
|
||||
case .failure:
|
||||
networkErrorPublisher.send()
|
||||
case .finished:
|
||||
resetOfflineCounterListener()
|
||||
}
|
||||
}
|
||||
|
||||
func handleScrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard hasNewPosts.value else { return }
|
||||
|
||||
let contentOffsetY = scrollView.contentOffset.y
|
||||
let isScrollToTop = contentOffsetY < -scrollView.contentInset.top
|
||||
guard isScrollToTop else { return }
|
||||
resetNewPostState()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Publish post state
|
||||
extension HomeTimelineNavigationBarTitleViewModel {
|
||||
|
||||
func setupPublishingProgress() {
|
||||
let progressUpdatePublisher = Timer.publish(every: 0.016, on: .main, in: .common) // ~ 60FPS
|
||||
.autoconnect()
|
||||
.share()
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
publishingProgressSubscription = progressUpdatePublisher
|
||||
.map { _ in Float(0) }
|
||||
.scan(0.0) { progress, _ -> Float in
|
||||
return 0.95 * progress + 0.05 // progress + 0.05 * (1.0 - progress). ~ 1 sec to 0.95 (under 60FPS)
|
||||
}
|
||||
.subscribe(publishingProgress)
|
||||
}
|
||||
|
||||
func suspendPublishingProgress() {
|
||||
publishingProgressSubscription = nil
|
||||
publishingProgress.send(0)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -123,6 +123,32 @@ extension MainTabBarController {
|
|||
}
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
// handle post failure
|
||||
context.statusPublishService
|
||||
.latestPublishingComposeViewModel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] composeViewModel in
|
||||
guard let self = self else { return }
|
||||
guard let composeViewModel = composeViewModel else { return }
|
||||
guard let currentState = composeViewModel.publishStateMachine.currentState else { return }
|
||||
guard currentState is ComposeViewModel.PublishState.Fail else { return }
|
||||
|
||||
let alertController = UIAlertController(title: L10n.Common.Alerts.PublishPostFailure.title, message: L10n.Common.Alerts.PublishPostFailure.message, preferredStyle: .alert)
|
||||
let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self, weak composeViewModel] _ in
|
||||
guard let self = self else { return }
|
||||
guard let composeViewModel = composeViewModel else { return }
|
||||
self.context.statusPublishService.remove(composeViewModel: composeViewModel)
|
||||
}
|
||||
alertController.addAction(discardAction)
|
||||
let retryAction = UIAlertAction(title: L10n.Common.Controls.Actions.tryAgain, style: .default) { [weak composeViewModel] _ in
|
||||
guard let composeViewModel = composeViewModel else { return }
|
||||
composeViewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self)
|
||||
}
|
||||
alertController.addAction(retryAction)
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
.store(in: &disposeBag)
|
||||
|
||||
#if DEBUG
|
||||
// selectedIndex = 1
|
||||
|
|
|
@ -16,8 +16,8 @@ final class StatusPrefetchingService {
|
|||
|
||||
typealias TaskID = String
|
||||
|
||||
let workingQueue = DispatchQueue(label: "status-prefetching-service-working-queue")
|
||||
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.StatusPrefetchingService.working-queue")
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:]
|
||||
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
//
|
||||
// StatusPublishService.swift
|
||||
// Mastodon
|
||||
//
|
||||
// Created by MainasuK Cirno on 2021-3-26.
|
||||
//
|
||||
|
||||
import os.log
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreData
|
||||
import CoreDataStack
|
||||
import MastodonSDK
|
||||
|
||||
final class StatusPublishService {
|
||||
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.StatusPublishService.working-queue")
|
||||
|
||||
// input
|
||||
var viewModels = CurrentValueSubject<[ComposeViewModel], Never>([]) // use strong reference to retain the view models
|
||||
|
||||
// output
|
||||
let composeViewModelDidUpdatePublisher = PassthroughSubject<Void, Never>()
|
||||
let latestPublishingComposeViewModel = CurrentValueSubject<ComposeViewModel?, Never>(nil)
|
||||
|
||||
init() {
|
||||
Publishers.CombineLatest(
|
||||
viewModels.eraseToAnyPublisher(),
|
||||
composeViewModelDidUpdatePublisher.eraseToAnyPublisher()
|
||||
)
|
||||
.map { viewModels, _ in viewModels.last }
|
||||
.assign(to: \.value, on: latestPublishingComposeViewModel)
|
||||
.store(in: &disposeBag)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension StatusPublishService {
|
||||
|
||||
func publish(composeViewModel: ComposeViewModel) {
|
||||
workingQueue.sync {
|
||||
guard !self.viewModels.value.contains(where: { $0 === composeViewModel }) else { return }
|
||||
self.viewModels.value = self.viewModels.value + [composeViewModel]
|
||||
|
||||
composeViewModel.publishStateMachinePublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self, weak composeViewModel] state in
|
||||
guard let self = self else { return }
|
||||
guard let composeViewModel = composeViewModel else { return }
|
||||
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: composeViewModelDidUpdate", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
self.composeViewModelDidUpdatePublisher.send()
|
||||
|
||||
switch state {
|
||||
case is ComposeViewModel.PublishState.Finish:
|
||||
self.remove(composeViewModel: composeViewModel)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &composeViewModel.disposeBag) // cancel subscription when viewModel dealloc
|
||||
}
|
||||
}
|
||||
|
||||
func remove(composeViewModel: ComposeViewModel) {
|
||||
workingQueue.async {
|
||||
var viewModels = self.viewModels.value
|
||||
viewModels.removeAll(where: { $0 === composeViewModel })
|
||||
self.viewModels.value = viewModels
|
||||
|
||||
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: composeViewModel removed", ((#file as NSString).lastPathComponent), #line, #function)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -14,7 +14,7 @@ import os.log
|
|||
final class VideoPlaybackService {
|
||||
var disposeBag = Set<AnyCancellable>()
|
||||
|
||||
let workingQueue = DispatchQueue(label: "com.twidere.twiderex.video-playback-service.working-queue")
|
||||
let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.VideoPlaybackService.working-queue")
|
||||
private(set) var viewPlayerViewModelDict: [URL: VideoPlayerViewModel] = [:]
|
||||
|
||||
// only for video kind
|
||||
|
|
|
@ -27,6 +27,7 @@ class AppContext: ObservableObject {
|
|||
let audioPlaybackService = AudioPlaybackService()
|
||||
let videoPlaybackService = VideoPlaybackService()
|
||||
let statusPrefetchingService: StatusPrefetchingService
|
||||
let statusPublishService = StatusPublishService()
|
||||
|
||||
let documentStore: DocumentStore
|
||||
private var documentStoreSubscription: AnyCancellable!
|
||||
|
|
Loading…
Reference in New Issue