diff --git a/Package.swift b/Package.swift index 14e5880804888513d778f51ee860c843bd3e3060..5a8a8e1d8fdc7f2e8887e4caa5d72b89b2449201 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,6 @@ let package = Package( products: [ .library(name: "App", targets: ["App"]), .library(name: "HUD", targets: ["HUD"]), - .library(name: "Theme", targets: ["Theme"]), .library(name: "Shared", targets: ["Shared"]), .library(name: "Models", targets: ["Models"]), .library(name: "XXLogger", targets: ["XXLogger"]), @@ -28,7 +27,6 @@ let package = Package( .library(name: "CrashService", targets: ["CrashService"]), .library(name: "TermsFeature", targets: ["TermsFeature"]), .library(name: "Presentation", targets: ["Presentation"]), - .library(name: "ToastFeature", targets: ["ToastFeature"]), .library(name: "BackupFeature", targets: ["BackupFeature"]), .library(name: "LaunchFeature", targets: ["LaunchFeature"]), .library(name: "SearchFeature", targets: ["SearchFeature"]), @@ -96,12 +94,15 @@ let package = Package( ), .package( path: "../elixxir-dapps-sdk-swift" - // url: "https://git.xx.network/elixxir/elixxir-dapps-sdk-swift", - // branch: "development" + //url: "https://git.xx.network/elixxir/elixxir-dapps-sdk-swift", + //branch: "development" ), .package( path: "../xxm-cloud-providers" ), + .package( + path: "../Router-PoC/Navigation" + ), .package( url: "https://git.xx.network/elixxir/client-ios-db.git", .upToNextMajor(from: "1.1.0") @@ -139,7 +140,6 @@ let package = Package( .target(name: "MenuFeature"), .target(name: "PushFeature"), .target(name: "TermsFeature"), - .target(name: "ToastFeature"), .target(name: "CrashService"), .target(name: "BackupFeature"), .target(name: "SearchFeature"), @@ -154,6 +154,7 @@ let package = Package( .target(name: "ReportingFeature"), .target(name: "OnboardingFeature"), .target(name: "ContactListFeature"), + .product(name: "Navigation", package: "Navigation"), ] ), .testTarget( @@ -192,7 +193,6 @@ let package = Package( .target( name: "Permissions", dependencies: [ - .target(name: "Theme"), .target(name: "Shared"), .target(name: "DependencyInjection"), ] @@ -241,12 +241,6 @@ let package = Package( .target(name: "DependencyInjection"), ] ), - .target( - name: "ToastFeature", - dependencies: [ - .target(name: "Shared"), - ] - ), .target( name: "CrashService", dependencies: [ @@ -257,7 +251,6 @@ let package = Package( .target( name: "Countries", dependencies: [ - .target(name: "Theme"), .target(name: "Shared"), .target(name: "DependencyInjection"), ], @@ -265,21 +258,6 @@ let package = Package( .process("Resources"), ] ), - .target( - name: "Theme", - dependencies: [ - .target(name: "Defaults"), - .target(name: "DependencyInjection"), - ] - ), - .testTarget( - name: "ThemeTests", - dependencies: [ - .target(name: "Theme"), - .product(name: "Quick", package: "Quick"), - .product(name: "Nimble", package: "Nimble"), - ] - ), .target( name: "DrawerFeature", dependencies: [ @@ -291,7 +269,6 @@ let package = Package( .target( name: "HUD", dependencies: [ - .target(name: "Theme"), .target(name: "Shared"), .product(name: "SnapKit", package: "SnapKit"), ] @@ -319,7 +296,6 @@ let package = Package( .target( name: "Presentation", dependencies: [ - .target(name: "Theme"), .target(name: "Shared"), .product(name: "SnapKit", package: "SnapKit"), ] @@ -378,7 +354,6 @@ let package = Package( name: "ChatFeature", dependencies: [ .target(name: "HUD"), - .target(name: "Theme"), .target(name: "Shared"), .target(name: "Defaults"), .target(name: "Keychain"), @@ -434,7 +409,6 @@ let package = Package( name: "LaunchFeature", dependencies: [ .target(name: "HUD"), - .target(name: "Theme"), .target(name: "Shared"), .target(name: "Defaults"), .target(name: "PushFeature"), @@ -454,7 +428,6 @@ let package = Package( .target( name: "TermsFeature", dependencies: [ - .target(name: "Theme"), .target(name: "Shared"), .target(name: "Defaults"), .target(name: "Presentation"), @@ -463,9 +436,7 @@ let package = Package( .target( name: "RequestsFeature", dependencies: [ - .target(name: "Theme"), .target(name: "Shared"), - .target(name: "ToastFeature"), .target(name: "ContactFeature"), .target(name: "DependencyInjection"), .product(name: "DifferenceKit", package: "DifferenceKit"), @@ -484,7 +455,6 @@ let package = Package( name: "ProfileFeature", dependencies: [ .target(name: "HUD"), - .target(name: "Theme"), .target(name: "Shared"), .target(name: "Keychain"), .target(name: "Defaults"), @@ -514,7 +484,6 @@ let package = Package( .target( name: "ChatListFeature", dependencies: [ - .target(name: "Theme"), .target(name: "Shared"), .target(name: "Defaults"), .target(name: "MenuFeature"), @@ -566,7 +535,6 @@ let package = Package( .target( name: "MenuFeature", dependencies: [ - .target(name: "Theme"), .target(name: "Shared"), .target(name: "Defaults"), .target(name: "Presentation"), @@ -598,7 +566,6 @@ let package = Package( .target( name: "ScanFeature", dependencies: [ - .target(name: "Theme"), .target(name: "Shared"), .target(name: "Countries"), .target(name: "Permissions"), @@ -621,7 +588,6 @@ let package = Package( .target( name: "ContactListFeature", dependencies: [ - .target(name: "Theme"), .target(name: "Shared"), .target(name: "Presentation"), .target(name: "ContactFeature"), @@ -642,7 +608,6 @@ let package = Package( name: "SettingsFeature", dependencies: [ .target(name: "HUD"), - .target(name: "Theme"), .target(name: "Shared"), .target(name: "Defaults"), .target(name: "Keychain"), diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index c4d76fdeef07350ba48d3fb275c7101d0518926b..6ff2efd3a3207403d7bc1be03792a276cee9235e 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -1,12 +1,12 @@ import UIKit +import Navigation import BackgroundTasks -import Theme +import Shared import XXModels import XXLogger import Defaults import PushFeature -import ToastFeature import LaunchFeature import CrashReporting import DependencyInjection @@ -20,9 +20,9 @@ import CloudFilesICloud import CloudFilesDropbox public class AppDelegate: UIResponder, UIApplicationDelegate { - @Dependency private var pushRouter: PushRouter - @Dependency private var pushHandler: PushHandling - @Dependency private var crashReporter: CrashReporter + @Dependency var navigator: Navigator + @Dependency var pushHandler: PushHandling + @Dependency var crashReporter: CrashReporter @KeyObject(.hideAppList, defaultValue: false) var hideAppList: Bool @KeyObject(.recordingLogs, defaultValue: true) var recordingLogs: Bool @@ -39,32 +39,15 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { -#if DEBUG - DependencyRegistrator.registerForMock() -#else - DependencyRegistrator.registerForLive() -#endif - - if recordingLogs { - XXLogger.start() - } - - crashReporter.configure() - crashReporter.setEnabled(isCrashReportingEnabled) - UNUserNotificationCenter.current().delegate = self - - let window = Window() - let navController = UINavigationController(rootViewController: LaunchController()) - window.rootViewController = StatusBarViewController(ToastViewController(navController)) - window.backgroundColor = UIColor.white - window.makeKeyAndVisible() - self.window = window - - DependencyInjection.Container.shared.register( - PushRouter.live(navigationController: navController) - ) - + DependencyRegistrator.registerDependencies() + setupCloudFilesManagers() + setupCrashReporting() + setupLogging() + + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = RootViewController(UINavigationController(rootViewController: LaunchController())) + window?.makeKeyAndVisible() return true } @@ -172,21 +155,6 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { } } -func getUsernameFromInvitationDeepLink(_ url: URL) -> String? { - if let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - components.scheme == "https", - components.host == "elixxir.io", - components.path == "/connect", - let queryItem = components.queryItems?.first(where: { $0.name == "username" }), - let username = queryItem.value { - return username - } - - return nil -} - -// MARK: Notifications - extension AppDelegate: UNUserNotificationCenterDelegate { public func userNotificationCenter( _ center: UNUserNotificationCenter, @@ -194,7 +162,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { withCompletionHandler completionHandler: @escaping () -> Void ) { let userInfo = response.notification.request.content.userInfo - pushHandler.handleAction(pushRouter, userInfo, completionHandler) + //pushHandler.handleAction(pushRouter, userInfo, completionHandler) } public func application( @@ -212,3 +180,39 @@ extension AppDelegate: UNUserNotificationCenterDelegate { pushHandler.registerToken(deviceToken) } } + +extension AppDelegate { + private func setupCrashReporting() { + crashReporter.configure() + crashReporter.setEnabled(isCrashReportingEnabled) + } + + private func setupCloudFilesManagers() { + CloudFilesManager.all[.icloud] = .iCloud(fileName: "backup.xxm") + CloudFilesManager.all[.dropbox] = .dropbox(appKey: "ppx0de5f16p9aq2", path: "/backup/backup.xxm") + CloudFilesManager.all[.drive] = .drive( + apiKey: "AIzaSyCbI2yQ7pbuVSRvraqanjGcS9CDrjD7lNU", + clientId: "662236151640-herpu89qikpfs9m4kvbi9bs5fpdji5de.apps.googleusercontent.com", + fileName: "backup.xxm" + ) + } + + private func setupLogging() { + if recordingLogs { + XXLogger.start() + } + } +} + +func getUsernameFromInvitationDeepLink(_ url: URL) -> String? { + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme == "https", + components.host == "elixxir.io", + components.path == "/connect", + let queryItem = components.queryItems?.first(where: { $0.name == "username" }), + let username = queryItem.value { + return username + } + + return nil +} diff --git a/Sources/App/DependencyRegistrator.swift b/Sources/App/DependencyRegistrator.swift index 5fe201895d13401ece9994aab414f6ff5c0b7230..824b2ef6de0378d6cf41e14f566694469f6e9ca1 100644 --- a/Sources/App/DependencyRegistrator.swift +++ b/Sources/App/DependencyRegistrator.swift @@ -8,7 +8,6 @@ import MobileCoreServices // MARK: Isolated features import HUD -import Theme import Bindings import XXLogger import Keychain @@ -18,7 +17,6 @@ import Voxophone import Permissions import PushFeature import CrashService -import ToastFeature import CrashReporting import NetworkMonitor import VersionChecking @@ -44,11 +42,21 @@ import OnboardingFeature import ContactListFeature import XXClient +import Navigation import KeychainAccess +import Shared struct DependencyRegistrator { static private let container = DependencyInjection.Container.shared + static func registerDependencies() { + #if DEBUG + DependencyRegistrator.registerForMock() + #else + DependencyRegistrator.registerForLive() + #endif + } + // MARK: MOCK static func registerForMock() { @@ -103,9 +111,8 @@ struct DependencyRegistrator { // MARK: Isolated container.register(HUD()) - container.register(ThemeController() as ThemeControlling) container.register(ToastController()) - container.register(StatusBarController() as StatusBarStyleControlling) + container.register(StatusBarStylist()) // MARK: Coordinators diff --git a/Sources/ChatFeature/Controllers/GroupChatController.swift b/Sources/ChatFeature/Controllers/GroupChatController.swift index 6080ba62c24a8096e3ec4d4ecaee1fbf20fcb89b..5bd1504ec4f4a91b30cc2303f0ebcfd097756dee 100644 --- a/Sources/ChatFeature/Controllers/GroupChatController.swift +++ b/Sources/ChatFeature/Controllers/GroupChatController.swift @@ -1,6 +1,5 @@ import HUD import UIKit -import Theme import Models import Shared import Combine @@ -21,631 +20,631 @@ typealias OutgoingFailedGroupTextCell = CollectionCell<FlexibleSpace, StackMessa typealias OutgoingFailedGroupReplyCell = CollectionCell<FlexibleSpace, ReplyStackMessageView> public final class GroupChatController: UIViewController { - @Dependency private var hud: HUD - @Dependency private var database: Database - @Dependency private var coordinator: ChatCoordinating - @Dependency private var reportingStatus: ReportingStatus - @Dependency private var makeReportDrawer: MakeReportDrawer - @Dependency private var makeAppScreenshot: MakeAppScreenshot - @Dependency private var statusBarController: StatusBarStyleControlling - - private let members: MembersController - private var collectionView: UICollectionView! - lazy private var header = GroupHeaderView() - private let inputComponent: ChatInputView - - private var animator: ManualAnimator? - private let viewModel: GroupChatViewModel - private let layoutDelegate = LayoutDelegate() - private var cancellables = Set<AnyCancellable>() - private let chatLayout = CollectionViewChatLayout() - private var sections = [ArraySection<ChatSection, Message>]() - private var currentInterfaceActions = SetActor<Set<InterfaceActions>, ReactionTypes>() - - public override var canBecomeFirstResponder: Bool { true } - public override var inputAccessoryView: UIView? { inputComponent } - - public init(_ info: GroupInfo) { - let viewModel = GroupChatViewModel(info) - self.viewModel = viewModel - self.members = .init(with: info.members) - - self.inputComponent = ChatInputView(store: .init( - initialState: .init(canAddAttachments: false), - reducer: chatInputReducer, - environment: .init( - voxophone: try! DependencyInjection.Container.shared.resolve() as Voxophone, - sendAudio: { _ in }, - didTapCamera: {}, - didTapLibrary: {}, - sendText: { viewModel.send($0) }, - didTapAbortReply: { viewModel.abortReply() }, - didTapMicrophone: { false } - ) - )) - - super.init(nibName: nil, bundle: nil) - - let memberList = info.members.map { - Member( - title: ($0.nickname ?? $0.username) ?? "Fetching username...", - photo: $0.photo - ) - } - - header.setup(title: info.group.name, memberList: memberList) + @Dependency var hud: HUD + @Dependency var database: Database + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: ChatCoordinating + @Dependency var reportingStatus: ReportingStatus + @Dependency var makeReportDrawer: MakeReportDrawer + @Dependency var makeAppScreenshot: MakeAppScreenshot + + private let members: MembersController + private var collectionView: UICollectionView! + lazy private var header = GroupHeaderView() + private let inputComponent: ChatInputView + + private var animator: ManualAnimator? + private let viewModel: GroupChatViewModel + private let layoutDelegate = LayoutDelegate() + private var cancellables = Set<AnyCancellable>() + private let chatLayout = CollectionViewChatLayout() + private var sections = [ArraySection<ChatSection, Message>]() + private var currentInterfaceActions = SetActor<Set<InterfaceActions>, ReactionTypes>() + + public override var canBecomeFirstResponder: Bool { true } + public override var inputAccessoryView: UIView? { inputComponent } + + public init(_ info: GroupInfo) { + let viewModel = GroupChatViewModel(info) + self.viewModel = viewModel + self.members = .init(with: info.members) + + self.inputComponent = ChatInputView(store: .init( + initialState: .init(canAddAttachments: false), + reducer: chatInputReducer, + environment: .init( + voxophone: try! DependencyInjection.Container.shared.resolve() as Voxophone, + sendAudio: { _ in }, + didTapCamera: {}, + didTapLibrary: {}, + sendText: { viewModel.send($0) }, + didTapAbortReply: { viewModel.abortReply() }, + didTapMicrophone: { false } + ) + )) + + super.init(nibName: nil, bundle: nil) + + let memberList = info.members.map { + Member( + title: ($0.nickname ?? $0.username) ?? "Fetching username...", + photo: $0.photo + ) } - public required init?(coder: NSCoder) { nil } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - statusBarController.style.send(.darkContent) - navigationController?.navigationBar.customize( - backgroundColor: Asset.neutralWhite.color, - shadowColor: Asset.neutralDisabled.color - ) + header.setup(title: info.group.name, memberList: memberList) + } + + public required init?(coder: NSCoder) { nil } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar.customize( + backgroundColor: Asset.neutralWhite.color, + shadowColor: Asset.neutralDisabled.color + ) + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + collectionView.collectionViewLayout.invalidateLayout() + becomeFirstResponder() + } + + private var isFirstAppearance = true + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if isFirstAppearance { + isFirstAppearance = false + let insets = UIEdgeInsets( + top: 0, + left: 0, + bottom: inputComponent.bounds.height - view.safeAreaInsets.bottom, + right: 0 + ) + collectionView.contentInset = insets + collectionView.scrollIndicatorInsets = insets } - - public override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - collectionView.collectionViewLayout.invalidateLayout() - becomeFirstResponder() + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + viewModel.readAll() + } + + public override func viewDidLoad() { + super.viewDidLoad() + + setupNavigationBar() + setupCollectionView() + setupInputController() + setupBindings() + + KeyboardListener.shared.add(delegate: self) + } + + private func setupNavigationBar() { + let more = UIButton() + more.setImage(Asset.chatMore.image, for: .normal) + more.addTarget(self, action: #selector(didTapDots), for: .touchUpInside) + + navigationItem.titleView = header + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: more) + } + + private func setupCollectionView() { + chatLayout.configure(layoutDelegate) + collectionView = .init(on: view, with: chatLayout) + collectionView.register(IncomingGroupTextCell.self) + collectionView.register(OutgoingGroupTextCell.self) + collectionView.register(IncomingGroupReplyCell.self) + collectionView.register(OutgoingGroupReplyCell.self) + collectionView.register(OutgoingFailedGroupTextCell.self) + collectionView.register(OutgoingFailedGroupReplyCell.self) + collectionView.registerSectionHeader(SectionHeaderView.self) + collectionView.dataSource = self + collectionView.delegate = self + } + + private func setupInputController() { + inputComponent.setMaxHeight { [weak self] in + guard let self = self else { return 150 } + + let maxHeight = self.collectionView.frame.height + - self.collectionView.adjustedContentInset.top + - self.collectionView.adjustedContentInset.bottom + + self.inputComponent.bounds.height + + return maxHeight * 0.9 } - private var isFirstAppearance = true - - public override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - if isFirstAppearance { - isFirstAppearance = false - let insets = UIEdgeInsets( - top: 0, - left: 0, - bottom: inputComponent.bounds.height - view.safeAreaInsets.bottom, - right: 0 - ) - collectionView.contentInset = insets - collectionView.scrollIndicatorInsets = insets + viewModel.replyPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] senderTitle, messageText in + inputComponent.setupReply(message: messageText, sender: senderTitle) + } + .store(in: &cancellables) + } + + private func setupBindings() { + viewModel.routesPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + switch $0 { + case .waitingRound: + coordinator.toDrawer(makeWaitingRoundDrawer(), from: self) + case .webview(let urlString): + coordinator.toWebview(with: urlString, from: self) + } + }.store(in: &cancellables) + + viewModel.hudPublisher + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + viewModel.reportPopupPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] contact in + presentReportDrawer(contact) + }.store(in: &cancellables) + + viewModel.messages + .receive(on: DispatchQueue.main) + .sink { [unowned self] sections in + func process() { + let changeSet = StagedChangeset(source: self.sections, target: sections).flattenIfPossible() + collectionView.reload( + using: changeSet, + interrupt: { changeSet in + guard !self.sections.isEmpty else { return true } + return false + }, onInterruptedReload: { + guard let lastSection = self.sections.last else { return } + let positionSnapshot = ChatLayoutPositionSnapshot( + indexPath: IndexPath( + item: lastSection.elements.count - 1, + section: self.sections.count - 1 + ), + kind: .cell, + edge: .bottom + ) + + self.collectionView.reloadData() + self.chatLayout.restoreContentOffset(with: positionSnapshot) + }, + completion: nil, + setData: { self.sections = $0 } + ) } - } - - public override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - viewModel.readAll() - } - public override func viewDidLoad() { - super.viewDidLoad() + guard currentInterfaceActions.options.isEmpty else { + let reaction = SetActor<Set<InterfaceActions>, ReactionTypes>.Reaction( + type: .delayedUpdate, + action: .onEmpty, + executionType: .once, + actionBlock: { [weak self] in + guard let _ = self else { return } + process() + } + ) - setupNavigationBar() - setupCollectionView() - setupInputController() - setupBindings() + currentInterfaceActions.add(reaction: reaction) + return + } - KeyboardListener.shared.add(delegate: self) + process() + } + .store(in: &cancellables) + } + + @objc private func didTapDots() { + coordinator.toMembersList(members, from: self) + } + + private func presentReportDrawer(_ contact: Contact) { + var config = MakeReportDrawer.Config() + config.onReport = { [weak self] in + guard let self = self else { return } + let screenshot = try! self.makeAppScreenshot() + self.viewModel.report(contact: contact, screenshot: screenshot) { + self.collectionView.reloadData() + } } - - private func setupNavigationBar() { - let more = UIButton() - more.setImage(Asset.chatMore.image, for: .normal) - more.addTarget(self, action: #selector(didTapDots), for: .touchUpInside) - - navigationItem.titleView = header - navigationItem.rightBarButtonItem = UIBarButtonItem(customView: more) + let drawer = makeReportDrawer(config) + coordinator.toDrawer(drawer, from: self) + } + + private func makeWaitingRoundDrawer() -> UIViewController { + let text = DrawerText( + font: Fonts.Mulish.semiBold.font(size: 14.0), + text: Localized.Chat.RoundDrawer.title, + color: Asset.neutralWeak.color, + lineHeightMultiple: 1.35, + spacingAfter: 25 + ) + + let button = DrawerCapsuleButton(model: .init( + title: Localized.Chat.RoundDrawer.action, + style: .brandColored + )) + + let drawer = DrawerController(with: [text, button]) + + button.action + .receive(on: DispatchQueue.main) + .sink { [weak drawer] in + drawer?.dismiss(animated: true) + }.store(in: &drawer.cancellables) + + return drawer + } + + func scrollToBottom(completion: (() -> Void)? = nil) { + let contentOffsetAtBottom = CGPoint( + x: collectionView.contentOffset.x, + y: chatLayout.collectionViewContentSize.height + - collectionView.frame.height + collectionView.adjustedContentInset.bottom + ) + + guard contentOffsetAtBottom.y > collectionView.contentOffset.y else { completion?(); return } + + let initialOffset = collectionView.contentOffset.y + let delta = contentOffsetAtBottom.y - initialOffset + + if abs(delta) > chatLayout.visibleBounds.height { + animator = ManualAnimator() + animator?.animate(duration: TimeInterval(0.25), curve: .easeInOut) { [weak self] percentage in + guard let self = self else { return } + + self.collectionView.contentOffset = CGPoint(x: self.collectionView.contentOffset.x, y: initialOffset + (delta * percentage)) + if percentage == 1.0 { + self.animator = nil + let positionSnapshot = ChatLayoutPositionSnapshot(indexPath: IndexPath(item: 0, section: 0), kind: .footer, edge: .bottom) + self.chatLayout.restoreContentOffset(with: positionSnapshot) + self.currentInterfaceActions.options.remove(.scrollingToBottom) + completion?() + } + } + } else { + currentInterfaceActions.options.insert(.scrollingToBottom) + UIView.animate(withDuration: 0.25, animations: { + self.collectionView.setContentOffset(contentOffsetAtBottom, animated: true) + }, completion: { [weak self] _ in + self?.currentInterfaceActions.options.remove(.scrollingToBottom) + completion?() + }) } + } +} - private func setupCollectionView() { - chatLayout.configure(layoutDelegate) - collectionView = .init(on: view, with: chatLayout) - collectionView.register(IncomingGroupTextCell.self) - collectionView.register(OutgoingGroupTextCell.self) - collectionView.register(IncomingGroupReplyCell.self) - collectionView.register(OutgoingGroupReplyCell.self) - collectionView.register(OutgoingFailedGroupTextCell.self) - collectionView.register(OutgoingFailedGroupReplyCell.self) - collectionView.registerSectionHeader(SectionHeaderView.self) - collectionView.dataSource = self - collectionView.delegate = self +extension GroupChatController: UICollectionViewDataSource { + public func numberOfSections(in collectionView: UICollectionView) -> Int { + sections.count + } + + public func collectionView(_ collectionView: UICollectionView, + viewForSupplementaryElementOfKind kind: String, + at indexPath: IndexPath) -> UICollectionReusableView { + let sectionHeader: SectionHeaderView = collectionView.dequeueSupplementaryView(forIndexPath: indexPath) + sectionHeader.title.text = sections[indexPath.section].model.date.asDayOfMonth() + return sectionHeader + } + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + sections[section].elements.count + } + + public func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + + var item = sections[indexPath.section].elements[indexPath.item] + let canReply: () -> Bool = { + (item.status == .sent || item.status == .received) && item.networkId != nil } - private func setupInputController() { - inputComponent.setMaxHeight { [weak self] in - guard let self = self else { return 150 } - - let maxHeight = self.collectionView.frame.height - - self.collectionView.adjustedContentInset.top - - self.collectionView.adjustedContentInset.bottom - + self.inputComponent.bounds.height - - return maxHeight * 0.9 - } - - viewModel.replyPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] senderTitle, messageText in - inputComponent.setupReply(message: messageText, sender: senderTitle) - } - .store(in: &cancellables) + let performReply: () -> Void = { [weak self] in + self?.viewModel.didRequestReply(item) } - private func setupBindings() { - viewModel.routesPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - switch $0 { - case .waitingRound: - coordinator.toDrawer(makeWaitingRoundDrawer(), from: self) - case .webview(let urlString): - coordinator.toWebview(with: urlString, from: self) - } - }.store(in: &cancellables) - - viewModel.hudPublisher - .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - viewModel.reportPopupPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] contact in - presentReportDrawer(contact) - }.store(in: &cancellables) - - viewModel.messages - .receive(on: DispatchQueue.main) - .sink { [unowned self] sections in - func process() { - let changeSet = StagedChangeset(source: self.sections, target: sections).flattenIfPossible() - collectionView.reload( - using: changeSet, - interrupt: { changeSet in - guard !self.sections.isEmpty else { return true } - return false - }, onInterruptedReload: { - guard let lastSection = self.sections.last else { return } - let positionSnapshot = ChatLayoutPositionSnapshot( - indexPath: IndexPath( - item: lastSection.elements.count - 1, - section: self.sections.count - 1 - ), - kind: .cell, - edge: .bottom - ) - - self.collectionView.reloadData() - self.chatLayout.restoreContentOffset(with: positionSnapshot) - }, - completion: nil, - setData: { self.sections = $0 } - ) - } - - guard currentInterfaceActions.options.isEmpty else { - let reaction = SetActor<Set<InterfaceActions>, ReactionTypes>.Reaction( - type: .delayedUpdate, - action: .onEmpty, - executionType: .once, - actionBlock: { [weak self] in - guard let _ = self else { return } - process() - } - ) - - currentInterfaceActions.add(reaction: reaction) - return - } - - process() - } - .store(in: &cancellables) - } + let name: (Data) -> String = viewModel.getName(from:) + let showRound: (String?) -> Void = viewModel.showRoundFrom(_:) + let replyContent: (Data) -> (String, String) = viewModel.getReplyContent(for:) - @objc private func didTapDots() { - coordinator.toMembersList(members, from: self) - } + var isSenderBanned = false - private func presentReportDrawer(_ contact: Contact) { - var config = MakeReportDrawer.Config() - config.onReport = { [weak self] in - guard let self = self else { return } - let screenshot = try! self.makeAppScreenshot() - self.viewModel.report(contact: contact, screenshot: screenshot) { - self.collectionView.reloadData() - } - } - let drawer = makeReportDrawer(config) - coordinator.toDrawer(drawer, from: self) + if let sender = try? database.fetchContacts(.init(id: [item.senderId])).first { + isSenderBanned = sender.isBanned } - private func makeWaitingRoundDrawer() -> UIViewController { - let text = DrawerText( - font: Fonts.Mulish.semiBold.font(size: 14.0), - text: Localized.Chat.RoundDrawer.title, - color: Asset.neutralWeak.color, - lineHeightMultiple: 1.35, - spacingAfter: 25 + if item.status == .received { + guard isSenderBanned == false else { + item.text = "This user has been banned" + + let cell: IncomingGroupTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) + Bubbler.buildGroup( + bubble: cell.leftView, + with: item, + with: "Banned user" ) - let button = DrawerCapsuleButton(model: .init( - title: Localized.Chat.RoundDrawer.action, - style: .brandColored - )) + cell.canReply = false + cell.performReply = {} + cell.leftView.didTapShowRound = {} - let drawer = DrawerController(with: [text, button]) + return cell + } - button.action - .receive(on: DispatchQueue.main) - .sink { [weak drawer] in - drawer?.dismiss(animated: true) - }.store(in: &drawer.cancellables) + if let replyMessageId = item.replyMessageId { + let cell: IncomingGroupReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - return drawer - } + Bubbler.buildReplyGroup( + bubble: cell.leftView, + with: item, + reply: replyContent(replyMessageId), + sender: name(item.senderId) + ) - func scrollToBottom(completion: (() -> Void)? = nil) { - let contentOffsetAtBottom = CGPoint( - x: collectionView.contentOffset.x, - y: chatLayout.collectionViewContentSize.height - - collectionView.frame.height + collectionView.adjustedContentInset.bottom + cell.canReply = canReply() + cell.performReply = performReply + cell.leftView.didTapShowRound = { showRound(item.roundURL) } + + return cell + } else { + let cell: IncomingGroupTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) + Bubbler.buildGroup( + bubble: cell.leftView, + with: item, + with: name(item.senderId) ) - guard contentOffsetAtBottom.y > collectionView.contentOffset.y else { completion?(); return } + cell.canReply = canReply() + cell.performReply = performReply + cell.leftView.didTapShowRound = { showRound(item.roundURL) } + + return cell + } + } else if item.status == .sendingFailed { + if let replyMessageId = item.replyMessageId { + let cell: OutgoingFailedGroupReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) + + Bubbler.buildReplyGroup( + bubble: cell.rightView, + with: item, + reply: replyContent(replyMessageId), + sender: name(item.senderId) + ) - let initialOffset = collectionView.contentOffset.y - let delta = contentOffsetAtBottom.y - initialOffset + cell.canReply = canReply() + cell.performReply = performReply - if abs(delta) > chatLayout.visibleBounds.height { - animator = ManualAnimator() - animator?.animate(duration: TimeInterval(0.25), curve: .easeInOut) { [weak self] percentage in - guard let self = self else { return } + return cell + } else { + let cell: OutgoingFailedGroupTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - self.collectionView.contentOffset = CGPoint(x: self.collectionView.contentOffset.x, y: initialOffset + (delta * percentage)) - if percentage == 1.0 { - self.animator = nil - let positionSnapshot = ChatLayoutPositionSnapshot(indexPath: IndexPath(item: 0, section: 0), kind: .footer, edge: .bottom) - self.chatLayout.restoreContentOffset(with: positionSnapshot) - self.currentInterfaceActions.options.remove(.scrollingToBottom) - completion?() - } - } - } else { - currentInterfaceActions.options.insert(.scrollingToBottom) - UIView.animate(withDuration: 0.25, animations: { - self.collectionView.setContentOffset(contentOffsetAtBottom, animated: true) - }, completion: { [weak self] _ in - self?.currentInterfaceActions.options.remove(.scrollingToBottom) - completion?() - }) - } - } -} + Bubbler.buildGroup( + bubble: cell.rightView, + with: item, + with: name(item.senderId) + ) -extension GroupChatController: UICollectionViewDataSource { - public func numberOfSections(in collectionView: UICollectionView) -> Int { - sections.count - } + cell.canReply = canReply() + cell.performReply = performReply - public func collectionView(_ collectionView: UICollectionView, - viewForSupplementaryElementOfKind kind: String, - at indexPath: IndexPath) -> UICollectionReusableView { - let sectionHeader: SectionHeaderView = collectionView.dequeueSupplementaryView(forIndexPath: indexPath) - sectionHeader.title.text = sections[indexPath.section].model.date.asDayOfMonth() - return sectionHeader - } + return cell + } + } else if item.status == .sendingTimedOut { + if let replyMessageId = item.replyMessageId { + let cell: OutgoingFailedGroupReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - sections[section].elements.count - } + Bubbler.buildReplyGroup( + bubble: cell.rightView, + with: item, + reply: replyContent(replyMessageId), + sender: name(item.senderId) + ) - public func collectionView( - _ collectionView: UICollectionView, - cellForItemAt indexPath: IndexPath - ) -> UICollectionViewCell { + cell.canReply = false + cell.performReply = performReply - var item = sections[indexPath.section].elements[indexPath.item] - let canReply: () -> Bool = { - (item.status == .sent || item.status == .received) && item.networkId != nil - } + return cell + } else { + let cell: OutgoingFailedGroupTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - let performReply: () -> Void = { [weak self] in - self?.viewModel.didRequestReply(item) - } + Bubbler.buildGroup( + bubble: cell.rightView, + with: item, + with: name(item.senderId) + ) - let name: (Data) -> String = viewModel.getName(from:) - let showRound: (String?) -> Void = viewModel.showRoundFrom(_:) - let replyContent: (Data) -> (String, String) = viewModel.getReplyContent(for:) + cell.canReply = false + cell.performReply = performReply - var isSenderBanned = false + return cell + } + } else { + if let replyMessageId = item.replyMessageId { + let cell: OutgoingGroupReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - if let sender = try? database.fetchContacts(.init(id: [item.senderId])).first { - isSenderBanned = sender.isBanned - } + Bubbler.buildReplyGroup( + bubble: cell.rightView, + with: item, + reply: replyContent(replyMessageId), + sender: name(item.senderId) + ) - if item.status == .received { - guard isSenderBanned == false else { - item.text = "This user has been banned" + cell.canReply = canReply() + cell.performReply = performReply + cell.rightView.didTapShowRound = { showRound(item.roundURL) } - let cell: IncomingGroupTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - Bubbler.buildGroup( - bubble: cell.leftView, - with: item, - with: "Banned user" - ) + return cell + } else { + let cell: OutgoingGroupTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - cell.canReply = false - cell.performReply = {} - cell.leftView.didTapShowRound = {} + Bubbler.buildGroup( + bubble: cell.rightView, + with: item, + with: name(item.senderId) + ) - return cell - } + cell.canReply = canReply() + cell.performReply = performReply + cell.rightView.didTapShowRound = { showRound(item.roundURL) } - if let replyMessageId = item.replyMessageId { - let cell: IncomingGroupReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - - Bubbler.buildReplyGroup( - bubble: cell.leftView, - with: item, - reply: replyContent(replyMessageId), - sender: name(item.senderId) - ) - - cell.canReply = canReply() - cell.performReply = performReply - cell.leftView.didTapShowRound = { showRound(item.roundURL) } - - return cell - } else { - let cell: IncomingGroupTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - Bubbler.buildGroup( - bubble: cell.leftView, - with: item, - with: name(item.senderId) - ) - - cell.canReply = canReply() - cell.performReply = performReply - cell.leftView.didTapShowRound = { showRound(item.roundURL) } - - return cell - } - } else if item.status == .sendingFailed { - if let replyMessageId = item.replyMessageId { - let cell: OutgoingFailedGroupReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - - Bubbler.buildReplyGroup( - bubble: cell.rightView, - with: item, - reply: replyContent(replyMessageId), - sender: name(item.senderId) - ) - - cell.canReply = canReply() - cell.performReply = performReply - - return cell - } else { - let cell: OutgoingFailedGroupTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - - Bubbler.buildGroup( - bubble: cell.rightView, - with: item, - with: name(item.senderId) - ) - - cell.canReply = canReply() - cell.performReply = performReply - - return cell - } - } else if item.status == .sendingTimedOut { - if let replyMessageId = item.replyMessageId { - let cell: OutgoingFailedGroupReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - - Bubbler.buildReplyGroup( - bubble: cell.rightView, - with: item, - reply: replyContent(replyMessageId), - sender: name(item.senderId) - ) - - cell.canReply = false - cell.performReply = performReply - - return cell - } else { - let cell: OutgoingFailedGroupTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - - Bubbler.buildGroup( - bubble: cell.rightView, - with: item, - with: name(item.senderId) - ) - - cell.canReply = false - cell.performReply = performReply - - return cell - } - } else { - if let replyMessageId = item.replyMessageId { - let cell: OutgoingGroupReplyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - - Bubbler.buildReplyGroup( - bubble: cell.rightView, - with: item, - reply: replyContent(replyMessageId), - sender: name(item.senderId) - ) - - cell.canReply = canReply() - cell.performReply = performReply - cell.rightView.didTapShowRound = { showRound(item.roundURL) } - - return cell - } else { - let cell: OutgoingGroupTextCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - - Bubbler.buildGroup( - bubble: cell.rightView, - with: item, - with: name(item.senderId) - ) - - cell.canReply = canReply() - cell.performReply = performReply - cell.rightView.didTapShowRound = { showRound(item.roundURL) } - - return cell - } - } + return cell + } } + } } extension GroupChatController: KeyboardListenerDelegate { - fileprivate var isUserInitiatedScrolling: Bool { - return collectionView.isDragging || collectionView.isDecelerating + fileprivate var isUserInitiatedScrolling: Bool { + return collectionView.isDragging || collectionView.isDecelerating + } + + func keyboardWillChangeFrame(info: KeyboardInfo) { + let keyWindow = UIApplication.shared.windows.filter { $0.isKeyWindow }.first + + guard !currentInterfaceActions.options.contains(.changingFrameSize), + collectionView.contentInsetAdjustmentBehavior != .never, + let keyboardFrame = keyWindow?.convert(info.frameEnd, to: view), + collectionView.convert(collectionView.bounds, to: keyWindow).maxY > info.frameEnd.minY else { + return } - - func keyboardWillChangeFrame(info: KeyboardInfo) { - let keyWindow = UIApplication.shared.windows.filter { $0.isKeyWindow }.first - - guard !currentInterfaceActions.options.contains(.changingFrameSize), - collectionView.contentInsetAdjustmentBehavior != .never, - let keyboardFrame = keyWindow?.convert(info.frameEnd, to: view), - collectionView.convert(collectionView.bounds, to: keyWindow).maxY > info.frameEnd.minY else { - return - } - currentInterfaceActions.options.insert(.changingKeyboardFrame) - let newBottomInset = collectionView.frame.minY + collectionView.frame.size.height - keyboardFrame.minY - collectionView.safeAreaInsets.bottom - if newBottomInset > 0, - collectionView.contentInset.bottom != newBottomInset { - let positionSnapshot = chatLayout.getContentOffsetSnapshot(from: .bottom) - - currentInterfaceActions.options.insert(.changingContentInsets) - UIView.animate(withDuration: info.animationDuration, animations: { - self.collectionView.performBatchUpdates({ - self.collectionView.contentInset.bottom = newBottomInset - self.collectionView.verticalScrollIndicatorInsets.bottom = newBottomInset - }, completion: nil) - - if let positionSnapshot = positionSnapshot, !self.isUserInitiatedScrolling { - self.chatLayout.restoreContentOffset(with: positionSnapshot) - } - }, completion: { _ in - self.currentInterfaceActions.options.remove(.changingContentInsets) - }) + currentInterfaceActions.options.insert(.changingKeyboardFrame) + let newBottomInset = collectionView.frame.minY + collectionView.frame.size.height - keyboardFrame.minY - collectionView.safeAreaInsets.bottom + if newBottomInset > 0, + collectionView.contentInset.bottom != newBottomInset { + let positionSnapshot = chatLayout.getContentOffsetSnapshot(from: .bottom) + + currentInterfaceActions.options.insert(.changingContentInsets) + UIView.animate(withDuration: info.animationDuration, animations: { + self.collectionView.performBatchUpdates({ + self.collectionView.contentInset.bottom = newBottomInset + self.collectionView.verticalScrollIndicatorInsets.bottom = newBottomInset + }, completion: nil) + + if let positionSnapshot = positionSnapshot, !self.isUserInitiatedScrolling { + self.chatLayout.restoreContentOffset(with: positionSnapshot) } + }, completion: { _ in + self.currentInterfaceActions.options.remove(.changingContentInsets) + }) } + } - func keyboardDidChangeFrame(info: KeyboardInfo) { - guard currentInterfaceActions.options.contains(.changingKeyboardFrame) else { return } - currentInterfaceActions.options.remove(.changingKeyboardFrame) - } + func keyboardDidChangeFrame(info: KeyboardInfo) { + guard currentInterfaceActions.options.contains(.changingKeyboardFrame) else { return } + currentInterfaceActions.options.remove(.changingKeyboardFrame) + } } extension GroupChatController: UICollectionViewDelegate { - private func makeTargetedPreview(for configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - guard let identifier = configuration.identifier as? String, - let first = identifier.components(separatedBy: "|").first, - let last = identifier.components(separatedBy: "|").last, - let item = Int(first), let section = Int(last), - let cell = collectionView.cellForItem(at: IndexPath(item: item, section: section)) else { - return nil - } - - let parameters = UIPreviewParameters() - parameters.backgroundColor = .clear - - if sections[section].elements[item].status == .received { - var leftView: UIView! - - if let cell = cell as? IncomingGroupReplyCell { - leftView = cell.leftView - } else if let cell = cell as? IncomingGroupTextCell { - leftView = cell.leftView - } + private func makeTargetedPreview(for configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + guard let identifier = configuration.identifier as? String, + let first = identifier.components(separatedBy: "|").first, + let last = identifier.components(separatedBy: "|").last, + let item = Int(first), let section = Int(last), + let cell = collectionView.cellForItem(at: IndexPath(item: item, section: section)) else { + return nil + } - parameters.visiblePath = UIBezierPath(roundedRect: leftView.bounds, cornerRadius: 13) - return UITargetedPreview(view: leftView, parameters: parameters) - } + let parameters = UIPreviewParameters() + parameters.backgroundColor = .clear - var rightView: UIView! + if sections[section].elements[item].status == .received { + var leftView: UIView! - if let cell = cell as? OutgoingGroupTextCell { - rightView = cell.rightView - } else if let cell = cell as? OutgoingGroupReplyCell { - rightView = cell.rightView - } + if let cell = cell as? IncomingGroupReplyCell { + leftView = cell.leftView + } else if let cell = cell as? IncomingGroupTextCell { + leftView = cell.leftView + } - parameters.visiblePath = UIBezierPath(roundedRect: rightView.bounds, cornerRadius: 13) - return UITargetedPreview(view: rightView, parameters: parameters) + parameters.visiblePath = UIBezierPath(roundedRect: leftView.bounds, cornerRadius: 13) + return UITargetedPreview(view: leftView, parameters: parameters) } - public func collectionView( - _ collectionView: UICollectionView, - previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration - ) -> UITargetedPreview? { - makeTargetedPreview(for: configuration) - } + var rightView: UIView! - public func collectionView( - _ collectionView: UICollectionView, - previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration - ) -> UITargetedPreview? { - makeTargetedPreview(for: configuration) + if let cell = cell as? OutgoingGroupTextCell { + rightView = cell.rightView + } else if let cell = cell as? OutgoingGroupReplyCell { + rightView = cell.rightView } - public func collectionView( - _ collectionView: UICollectionView, - contextMenuConfigurationForItemAt indexPath: IndexPath, - point: CGPoint - ) -> UIContextMenuConfiguration? { - UIContextMenuConfiguration( - identifier: "\(indexPath.item)|\(indexPath.section)" as NSCopying, - previewProvider: nil - ) { [weak self] suggestedActions in - - guard let self = self else { return nil } - - let item = self.sections[indexPath.section].elements[indexPath.item] - - let copy = UIAction(title: Localized.Chat.BubbleMenu.copy, state: .off) { _ in - UIPasteboard.general.string = item.text - } - - let reply = UIAction(title: Localized.Chat.BubbleMenu.reply, state: .off) { [weak self] _ in - self?.viewModel.didRequestReply(item) - } - - let delete = UIAction(title: Localized.Chat.BubbleMenu.delete, state: .off) { [weak self] _ in - self?.viewModel.didRequestDelete([item]) - } - - let report = UIAction(title: Localized.Chat.BubbleMenu.report, state: .off) { [weak self] _ in - self?.viewModel.didRequestReport(item) - } - - let retry = UIAction(title: Localized.Chat.BubbleMenu.retry, state: .off) { [weak self] _ in - self?.viewModel.retry(item) - } - - var children = [UIAction]() - - if item.status == .sendingFailed { - children = [copy, retry, delete] - } else if item.status == .sending { - children = [copy] - } else { - children = [copy, reply, delete] - - if self.reportingStatus.isEnabled() { - children.append(report) - } - } - - return UIMenu(title: "", children: children) + parameters.visiblePath = UIBezierPath(roundedRect: rightView.bounds, cornerRadius: 13) + return UITargetedPreview(view: rightView, parameters: parameters) + } + + public func collectionView( + _ collectionView: UICollectionView, + previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration + ) -> UITargetedPreview? { + makeTargetedPreview(for: configuration) + } + + public func collectionView( + _ collectionView: UICollectionView, + previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration + ) -> UITargetedPreview? { + makeTargetedPreview(for: configuration) + } + + public func collectionView( + _ collectionView: UICollectionView, + contextMenuConfigurationForItemAt indexPath: IndexPath, + point: CGPoint + ) -> UIContextMenuConfiguration? { + UIContextMenuConfiguration( + identifier: "\(indexPath.item)|\(indexPath.section)" as NSCopying, + previewProvider: nil + ) { [weak self] suggestedActions in + + guard let self = self else { return nil } + + let item = self.sections[indexPath.section].elements[indexPath.item] + + let copy = UIAction(title: Localized.Chat.BubbleMenu.copy, state: .off) { _ in + UIPasteboard.general.string = item.text + } + + let reply = UIAction(title: Localized.Chat.BubbleMenu.reply, state: .off) { [weak self] _ in + self?.viewModel.didRequestReply(item) + } + + let delete = UIAction(title: Localized.Chat.BubbleMenu.delete, state: .off) { [weak self] _ in + self?.viewModel.didRequestDelete([item]) + } + + let report = UIAction(title: Localized.Chat.BubbleMenu.report, state: .off) { [weak self] _ in + self?.viewModel.didRequestReport(item) + } + + let retry = UIAction(title: Localized.Chat.BubbleMenu.retry, state: .off) { [weak self] _ in + self?.viewModel.retry(item) + } + + var children = [UIAction]() + + if item.status == .sendingFailed { + children = [copy, retry, delete] + } else if item.status == .sending { + children = [copy] + } else { + children = [copy, reply, delete] + + if self.reportingStatus.isEnabled() { + children.append(report) } + } + + return UIMenu(title: "", children: children) } + } } diff --git a/Sources/ChatFeature/Controllers/SingleChatController.swift b/Sources/ChatFeature/Controllers/SingleChatController.swift index 970539b33f9dc0ebf25c2fa1bad10ec97a6c20e4..5ad2e83d43be534ca156dcf58020818ef222ebf1 100644 --- a/Sources/ChatFeature/Controllers/SingleChatController.swift +++ b/Sources/ChatFeature/Controllers/SingleChatController.swift @@ -1,6 +1,5 @@ import HUD import UIKit -import Theme import Models import Shared import Combine @@ -17,691 +16,691 @@ import DependencyInjection import ScrollViewController extension FlexibleSpace: CollectionCellContent { - func prepareForReuse() {} + func prepareForReuse() {} } extension Message: Differentiable { - public var differenceIdentifier: Int64 { id! } + public var differenceIdentifier: Int64 { id! } } public final class SingleChatController: UIViewController { - @Dependency private var hud: HUD - @Dependency private var logger: XXLogger - @Dependency private var voxophone: Voxophone - @Dependency private var coordinator: ChatCoordinating - @Dependency private var reportingStatus: ReportingStatus - @Dependency private var makeReportDrawer: MakeReportDrawer - @Dependency private var makeAppScreenshot: MakeAppScreenshot - @Dependency private var statusBarController: StatusBarStyleControlling - - lazy private var infoView = UIControl() - lazy private var nameLabel = UILabel() - lazy private var avatarView = AvatarView() - - lazy private var moreButton = UIButton() - lazy private var screenView = ChatView() - lazy private var sheet = SheetController() - - private let inputComponent: ChatInputView - private var collectionView: UICollectionView! - - private var animator: ManualAnimator? - private let viewModel: SingleChatViewModel - private let layoutDelegate = LayoutDelegate() - private var cancellables = Set<AnyCancellable>() - private let chatLayout = CollectionViewChatLayout() - private var sections = [ArraySection<ChatSection, Message>]() - private var currentInterfaceActions: SetActor<Set<InterfaceActions>, ReactionTypes> = SetActor() - - var fileURL: URL? - - public override func loadView() { view = screenView } - public override var canBecomeFirstResponder: Bool { true } - public override var inputAccessoryView: UIView? { inputComponent } - - public init(_ contact: Contact) { - let viewModel = SingleChatViewModel(contact) - self.viewModel = viewModel - - self.inputComponent = ChatInputView(store: .init( - initialState: .init(canAddAttachments: true), - reducer: chatInputReducer, - environment: .init( - voxophone: try! DependencyInjection.Container.shared.resolve() as Voxophone, - sendAudio: { viewModel.didSendAudio(url: $0) }, - didTapCamera: { viewModel.didTest(permission: .camera) }, - didTapLibrary: { viewModel.didTest(permission: .library) }, - sendText: { viewModel.send($0) }, - didTapAbortReply: { viewModel.abortReply() }, - didTapMicrophone: { viewModel.didTest(permission: .microphone) } - ) - )) - - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { nil } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - statusBarController.style.send(.darkContent) - navigationController?.navigationBar.customize( - backgroundColor: Asset.neutralWhite.color, - shadowColor: Asset.neutralDisabled.color - ) - } - - public override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - collectionView.collectionViewLayout.invalidateLayout() - becomeFirstResponder() - viewModel.viewDidAppear() - } - - private var isFirstAppearance = true - - public override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - if isFirstAppearance { - isFirstAppearance = false - let insets = UIEdgeInsets( - top: 0, - left: 0, - bottom: inputComponent.bounds.height - view.safeAreaInsets.bottom, - right: 0 - ) - collectionView.contentInset = insets - collectionView.scrollIndicatorInsets = insets - } - } - - public override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - viewModel.readAll() - } - - public override func viewDidLoad() { - super.viewDidLoad() - - viewModel - .contactPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in setupNavigationBar(contact: $0) } - .store(in: &cancellables) - - setupCollectionView() - setupInputController() - setupBindings() - - KeyboardListener.shared.add(delegate: self) - screenView.bringSubviewToFront(screenView.snackBar) - } - - // MARK: Private - - private func setupCollectionView() { - chatLayout.configure(layoutDelegate) - collectionView = .init(on: screenView, with: chatLayout) - collectionView.delegate = self - collectionView.dataSource = self - - collectionView.register(OutgoingTextCell.self) - collectionView.register(IncomingTextCell.self) - collectionView.register(IncomingAudioCell.self) - collectionView.register(OutgoingAudioCell.self) - collectionView.register(IncomingImageCell.self) - collectionView.register(IncomingReplyCell.self) - collectionView.register(OutgoingImageCell.self) - collectionView.register(OutgoingReplyCell.self) - collectionView.register(OutgoingFailedTextCell.self) - collectionView.register(OutgoingFailedReplyCell.self) - - collectionView.registerSectionHeader(SectionHeaderView.self) - } - - private func setupNavigationBar(contact: Contact) { - screenView.set(name: contact.nickname ?? contact.username!) - avatarView.snp.makeConstraints { $0.width.height.equalTo(35) } - - let title = (contact.nickname ?? contact.username) ?? "" - avatarView.setupProfile(title: title, image: contact.photo, size: .small) - - nameLabel.text = title - nameLabel.textColor = Asset.neutralActive.color - nameLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) - - moreButton.setImage(Asset.chatMore.image, for: .normal) - moreButton.addTarget(self, action: #selector(didTapDots), for: .touchUpInside) - - infoView.addTarget(self, action: #selector(didTapInfo), for: .touchUpInside) - - infoView.addSubview(avatarView) - infoView.addSubview(nameLabel) - - avatarView.snp.makeConstraints { - $0.top.left.bottom.equalToSuperview() + @Dependency var hud: HUD + @Dependency var logger: XXLogger + @Dependency var voxophone: Voxophone + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: ChatCoordinating + @Dependency var reportingStatus: ReportingStatus + @Dependency var makeReportDrawer: MakeReportDrawer + @Dependency var makeAppScreenshot: MakeAppScreenshot + + lazy private var infoView = UIControl() + lazy private var nameLabel = UILabel() + lazy private var avatarView = AvatarView() + + lazy private var moreButton = UIButton() + lazy private var screenView = ChatView() + lazy private var sheet = SheetController() + + private let inputComponent: ChatInputView + private var collectionView: UICollectionView! + + private var animator: ManualAnimator? + private let viewModel: SingleChatViewModel + private let layoutDelegate = LayoutDelegate() + private var cancellables = Set<AnyCancellable>() + private let chatLayout = CollectionViewChatLayout() + private var sections = [ArraySection<ChatSection, Message>]() + private var currentInterfaceActions: SetActor<Set<InterfaceActions>, ReactionTypes> = SetActor() + + var fileURL: URL? + + public override func loadView() { view = screenView } + public override var canBecomeFirstResponder: Bool { true } + public override var inputAccessoryView: UIView? { inputComponent } + + public init(_ contact: Contact) { + let viewModel = SingleChatViewModel(contact) + self.viewModel = viewModel + + self.inputComponent = ChatInputView(store: .init( + initialState: .init(canAddAttachments: true), + reducer: chatInputReducer, + environment: .init( + voxophone: try! DependencyInjection.Container.shared.resolve() as Voxophone, + sendAudio: { viewModel.didSendAudio(url: $0) }, + didTapCamera: { viewModel.didTest(permission: .camera) }, + didTapLibrary: { viewModel.didTest(permission: .library) }, + sendText: { viewModel.send($0) }, + didTapAbortReply: { viewModel.abortReply() }, + didTapMicrophone: { viewModel.didTest(permission: .microphone) } + ) + )) + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar.customize( + backgroundColor: Asset.neutralWhite.color, + shadowColor: Asset.neutralDisabled.color + ) + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + collectionView.collectionViewLayout.invalidateLayout() + becomeFirstResponder() + viewModel.viewDidAppear() + } + + private var isFirstAppearance = true + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if isFirstAppearance { + isFirstAppearance = false + let insets = UIEdgeInsets( + top: 0, + left: 0, + bottom: inputComponent.bounds.height - view.safeAreaInsets.bottom, + right: 0 + ) + collectionView.contentInset = insets + collectionView.scrollIndicatorInsets = insets + } + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + viewModel.readAll() + } + + public override func viewDidLoad() { + super.viewDidLoad() + + viewModel + .contactPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in setupNavigationBar(contact: $0) } + .store(in: &cancellables) + + setupCollectionView() + setupInputController() + setupBindings() + + KeyboardListener.shared.add(delegate: self) + screenView.bringSubviewToFront(screenView.snackBar) + } + + // MARK: Private + + private func setupCollectionView() { + chatLayout.configure(layoutDelegate) + collectionView = .init(on: screenView, with: chatLayout) + collectionView.delegate = self + collectionView.dataSource = self + + collectionView.register(OutgoingTextCell.self) + collectionView.register(IncomingTextCell.self) + collectionView.register(IncomingAudioCell.self) + collectionView.register(OutgoingAudioCell.self) + collectionView.register(IncomingImageCell.self) + collectionView.register(IncomingReplyCell.self) + collectionView.register(OutgoingImageCell.self) + collectionView.register(OutgoingReplyCell.self) + collectionView.register(OutgoingFailedTextCell.self) + collectionView.register(OutgoingFailedReplyCell.self) + + collectionView.registerSectionHeader(SectionHeaderView.self) + } + + private func setupNavigationBar(contact: Contact) { + screenView.set(name: contact.nickname ?? contact.username!) + avatarView.snp.makeConstraints { $0.width.height.equalTo(35) } + + let title = (contact.nickname ?? contact.username) ?? "" + avatarView.setupProfile(title: title, image: contact.photo, size: .small) + + nameLabel.text = title + nameLabel.textColor = Asset.neutralActive.color + nameLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) + + moreButton.setImage(Asset.chatMore.image, for: .normal) + moreButton.addTarget(self, action: #selector(didTapDots), for: .touchUpInside) + + infoView.addTarget(self, action: #selector(didTapInfo), for: .touchUpInside) + + infoView.addSubview(avatarView) + infoView.addSubview(nameLabel) + + avatarView.snp.makeConstraints { + $0.top.left.bottom.equalToSuperview() + } + + nameLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.left.equalTo(avatarView.snp.right).offset(13) + $0.right.lessThanOrEqualToSuperview() + } + + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: moreButton) + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: infoView) + navigationItem.leftItemsSupplementBackButton = true + } + + private func setupInputController() { + inputComponent.setMaxHeight { [weak self] in + guard let self = self else { return 150 } + + let maxHeight = self.collectionView.frame.height + - self.collectionView.adjustedContentInset.top + - self.collectionView.adjustedContentInset.bottom + + self.inputComponent.bounds.height + + return maxHeight * 0.9 + } + + viewModel.replyPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] senderTitle, messageText in + inputComponent.setupReply(message: messageText, sender: senderTitle) + } + .store(in: &cancellables) + + viewModel.navigation + .receive(on: DispatchQueue.main) + .removeDuplicates() + .sink { [unowned self] in + switch $0 { + case .library: + coordinator.toLibrary(from: self) + case .camera: + coordinator.toCamera(from: self) + case .cameraPermission: + coordinator.toPermission(type: .camera, from: self) + case .microphonePermission: + coordinator.toPermission(type: .microphone, from: self) + case .libraryPermission: + coordinator.toPermission(type: .library, from: self) + case .webview(let urlString): + coordinator.toWebview(with: urlString, from: self) + case .waitingRound: + coordinator.toDrawer(makeWaitingRoundDrawer(), from: self) + case .none: + break } - nameLabel.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.left.equalTo(avatarView.snp.right).offset(13) - $0.right.lessThanOrEqualToSuperview() + viewModel.didNavigateSomewhere() + }.store(in: &cancellables) + } + + private func setupBindings() { + viewModel.hud + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + sheet.actionPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + switch $0 { + case .clear: + presentDeleteAllDrawer() + case .details: + coordinator.toContact(viewModel.contact, from: self) + case .report: + presentReportDrawer() } + }.store(in: &cancellables) - navigationItem.rightBarButtonItem = UIBarButtonItem(customView: moreButton) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: infoView) - navigationItem.leftItemsSupplementBackButton = true - } - - private func setupInputController() { - inputComponent.setMaxHeight { [weak self] in - guard let self = self else { return 150 } + viewModel + .shouldDisplayEmptyView + .removeDuplicates() + .sink { [unowned self] in + screenView.titleLabel.isHidden = !$0 - let maxHeight = self.collectionView.frame.height - - self.collectionView.adjustedContentInset.top - - self.collectionView.adjustedContentInset.bottom - + self.inputComponent.bounds.height - - return maxHeight * 0.9 + if $0 == true { + screenView.bringSubviewToFront(screenView.titleLabel) + } + }.store(in: &cancellables) + + viewModel.reportPopupPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + presentReportDrawer() + }.store(in: &cancellables) + + viewModel.isOnline + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak screenView] in screenView?.displayNetworkIssue(!$0) } + .store(in: &cancellables) + + viewModel.messages + .receive(on: DispatchQueue.main) + .sink { [unowned self] sections in + func process() { + let changeSet = StagedChangeset(source: self.sections, target: sections).flattenIfPossible() + collectionView.reload( + using: changeSet, + interrupt: { changeSet in + guard !self.sections.isEmpty else { return true } + return false + }, onInterruptedReload: { + guard let lastSection = self.sections.last else { return } + let positionSnapshot = ChatLayoutPositionSnapshot( + indexPath: IndexPath( + item: lastSection.elements.count - 1, + section: self.sections.count - 1 + ), + kind: .cell, + edge: .bottom + ) + + self.collectionView.reloadData() + self.chatLayout.restoreContentOffset(with: positionSnapshot) + }, + completion: nil, + setData: { self.sections = $0 } + ) } - viewModel.replyPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] senderTitle, messageText in - inputComponent.setupReply(message: messageText, sender: senderTitle) - } - .store(in: &cancellables) - - viewModel.navigation - .receive(on: DispatchQueue.main) - .removeDuplicates() - .sink { [unowned self] in - switch $0 { - case .library: - coordinator.toLibrary(from: self) - case .camera: - coordinator.toCamera(from: self) - case .cameraPermission: - coordinator.toPermission(type: .camera, from: self) - case .microphonePermission: - coordinator.toPermission(type: .microphone, from: self) - case .libraryPermission: - coordinator.toPermission(type: .library, from: self) - case .webview(let urlString): - coordinator.toWebview(with: urlString, from: self) - case .waitingRound: - coordinator.toDrawer(makeWaitingRoundDrawer(), from: self) - case .none: - break - } - - viewModel.didNavigateSomewhere() - }.store(in: &cancellables) - } - - private func setupBindings() { - viewModel.hud - .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - sheet.actionPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - switch $0 { - case .clear: - presentDeleteAllDrawer() - case .details: - coordinator.toContact(viewModel.contact, from: self) - case .report: - presentReportDrawer() - } - }.store(in: &cancellables) - - viewModel - .shouldDisplayEmptyView - .removeDuplicates() - .sink { [unowned self] in - screenView.titleLabel.isHidden = !$0 - - if $0 == true { - screenView.bringSubviewToFront(screenView.titleLabel) - } - }.store(in: &cancellables) - - viewModel.reportPopupPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - presentReportDrawer() - }.store(in: &cancellables) - - viewModel.isOnline - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak screenView] in screenView?.displayNetworkIssue(!$0) } - .store(in: &cancellables) - - viewModel.messages - .receive(on: DispatchQueue.main) - .sink { [unowned self] sections in - func process() { - let changeSet = StagedChangeset(source: self.sections, target: sections).flattenIfPossible() - collectionView.reload( - using: changeSet, - interrupt: { changeSet in - guard !self.sections.isEmpty else { return true } - return false - }, onInterruptedReload: { - guard let lastSection = self.sections.last else { return } - let positionSnapshot = ChatLayoutPositionSnapshot( - indexPath: IndexPath( - item: lastSection.elements.count - 1, - section: self.sections.count - 1 - ), - kind: .cell, - edge: .bottom - ) - - self.collectionView.reloadData() - self.chatLayout.restoreContentOffset(with: positionSnapshot) - }, - completion: nil, - setData: { self.sections = $0 } - ) - } - - guard currentInterfaceActions.options.isEmpty else { - let reaction = SetActor<Set<InterfaceActions>, ReactionTypes>.Reaction( - type: .delayedUpdate, - action: .onEmpty, - executionType: .once, - actionBlock: { [weak self] in - guard let _ = self else { return } - process() - } - ) - - currentInterfaceActions.add(reaction: reaction) - return - } - - process() + guard currentInterfaceActions.options.isEmpty else { + let reaction = SetActor<Set<InterfaceActions>, ReactionTypes>.Reaction( + type: .delayedUpdate, + action: .onEmpty, + executionType: .once, + actionBlock: { [weak self] in + guard let _ = self else { return } + process() } - .store(in: &cancellables) - } - - func scrollToBottom(completion: (() -> Void)? = nil) { - let contentOffsetAtBottom = CGPoint( - x: collectionView.contentOffset.x, - y: chatLayout.collectionViewContentSize.height - - collectionView.frame.height + collectionView.adjustedContentInset.bottom - ) - - guard contentOffsetAtBottom.y > collectionView.contentOffset.y else { completion?(); return } - - let initialOffset = collectionView.contentOffset.y - let delta = contentOffsetAtBottom.y - initialOffset - - if abs(delta) > chatLayout.visibleBounds.height { - animator = ManualAnimator() - animator?.animate(duration: TimeInterval(0.25), curve: .easeInOut) { [weak self] percentage in - guard let self = self else { return } + ) - self.collectionView.contentOffset = CGPoint(x: self.collectionView.contentOffset.x, y: initialOffset + (delta * percentage)) - if percentage == 1.0 { - self.animator = nil - let positionSnapshot = ChatLayoutPositionSnapshot(indexPath: IndexPath(item: 0, section: 0), kind: .footer, edge: .bottom) - self.chatLayout.restoreContentOffset(with: positionSnapshot) - self.currentInterfaceActions.options.remove(.scrollingToBottom) - completion?() - } - } - } else { - currentInterfaceActions.options.insert(.scrollingToBottom) - UIView.animate(withDuration: 0.25, animations: { - self.collectionView.setContentOffset(contentOffsetAtBottom, animated: true) - }, completion: { [weak self] _ in - self?.currentInterfaceActions.options.remove(.scrollingToBottom) - completion?() - }) + currentInterfaceActions.add(reaction: reaction) + return } - } - - private func makeWaitingRoundDrawer() -> UIViewController { - let text = DrawerText( - font: Fonts.Mulish.semiBold.font(size: 14.0), - text: Localized.Chat.RoundDrawer.title, - color: Asset.neutralWeak.color, - lineHeightMultiple: 1.35, - spacingAfter: 25 - ) - - let button = DrawerCapsuleButton(model: .init( - title: Localized.Chat.RoundDrawer.action, - style: .brandColored - )) - let drawer = DrawerController(with: [text, button]) - - button.action - .receive(on: DispatchQueue.main) - .sink { [unowned drawer] in drawer.dismiss(animated: true) } - .store(in: &drawer.cancellables) - - return drawer - } - - private func presentReportDrawer() { - var config = MakeReportDrawer.Config() - config.onReport = { [weak self] in - guard let self = self else { return } - let screenshot = try! self.makeAppScreenshot() - self.viewModel.report(screenshot: screenshot) { success in - guard success else { return } - self.navigationController?.popViewController(animated: true) - } + process() + } + .store(in: &cancellables) + } + + func scrollToBottom(completion: (() -> Void)? = nil) { + let contentOffsetAtBottom = CGPoint( + x: collectionView.contentOffset.x, + y: chatLayout.collectionViewContentSize.height + - collectionView.frame.height + collectionView.adjustedContentInset.bottom + ) + + guard contentOffsetAtBottom.y > collectionView.contentOffset.y else { completion?(); return } + + let initialOffset = collectionView.contentOffset.y + let delta = contentOffsetAtBottom.y - initialOffset + + if abs(delta) > chatLayout.visibleBounds.height { + animator = ManualAnimator() + animator?.animate(duration: TimeInterval(0.25), curve: .easeInOut) { [weak self] percentage in + guard let self = self else { return } + + self.collectionView.contentOffset = CGPoint(x: self.collectionView.contentOffset.x, y: initialOffset + (delta * percentage)) + if percentage == 1.0 { + self.animator = nil + let positionSnapshot = ChatLayoutPositionSnapshot(indexPath: IndexPath(item: 0, section: 0), kind: .footer, edge: .bottom) + self.chatLayout.restoreContentOffset(with: positionSnapshot) + self.currentInterfaceActions.options.remove(.scrollingToBottom) + completion?() } - let drawer = makeReportDrawer(config) - coordinator.toDrawer(drawer, from: self) - } + } + } else { + currentInterfaceActions.options.insert(.scrollingToBottom) + UIView.animate(withDuration: 0.25, animations: { + self.collectionView.setContentOffset(contentOffsetAtBottom, animated: true) + }, completion: { [weak self] _ in + self?.currentInterfaceActions.options.remove(.scrollingToBottom) + completion?() + }) + } + } + + private func makeWaitingRoundDrawer() -> UIViewController { + let text = DrawerText( + font: Fonts.Mulish.semiBold.font(size: 14.0), + text: Localized.Chat.RoundDrawer.title, + color: Asset.neutralWeak.color, + lineHeightMultiple: 1.35, + spacingAfter: 25 + ) + + let button = DrawerCapsuleButton(model: .init( + title: Localized.Chat.RoundDrawer.action, + style: .brandColored + )) + + let drawer = DrawerController(with: [text, button]) + + button.action + .receive(on: DispatchQueue.main) + .sink { [unowned drawer] in drawer.dismiss(animated: true) } + .store(in: &drawer.cancellables) + + return drawer + } + + private func presentReportDrawer() { + var config = MakeReportDrawer.Config() + config.onReport = { [weak self] in + guard let self = self else { return } + let screenshot = try! self.makeAppScreenshot() + self.viewModel.report(screenshot: screenshot) { success in + guard success else { return } + self.navigationController?.popViewController(animated: true) + } + } + let drawer = makeReportDrawer(config) + coordinator.toDrawer(drawer, from: self) + } + + private func presentDeleteAllDrawer() { + let clearButton = CapsuleButton() + clearButton.setStyle(.red) + clearButton.setTitle(Localized.Chat.Clear.action, for: .normal) + + let cancelButton = CapsuleButton() + cancelButton.setStyle(.seeThrough) + cancelButton.setTitle(Localized.Chat.Clear.cancel, for: .normal) + + let drawer = DrawerController(with: [ + DrawerImage( + image: Asset.drawerNegative.image + ), + DrawerText( + font: Fonts.Mulish.semiBold.font(size: 18.0), + text: Localized.Chat.Clear.title, + color: Asset.neutralActive.color + ), + DrawerText( + font: Fonts.Mulish.semiBold.font(size: 14.0), + text: Localized.Chat.Clear.subtitle, + color: Asset.neutralWeak.color, + lineHeightMultiple: 1.35, + spacingAfter: 25 + ), + DrawerStack( + spacing: 20.0, + views: [clearButton, cancelButton] + ) + ]) + + clearButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned drawer, weak self] in + drawer.dismiss(animated: true) { + self?.viewModel.didRequestDeleteAll() + } + } + .store(in: &drawer.cancellables) - private func presentDeleteAllDrawer() { - let clearButton = CapsuleButton() - clearButton.setStyle(.red) - clearButton.setTitle(Localized.Chat.Clear.action, for: .normal) - - let cancelButton = CapsuleButton() - cancelButton.setStyle(.seeThrough) - cancelButton.setTitle(Localized.Chat.Clear.cancel, for: .normal) - - let drawer = DrawerController(with: [ - DrawerImage( - image: Asset.drawerNegative.image - ), - DrawerText( - font: Fonts.Mulish.semiBold.font(size: 18.0), - text: Localized.Chat.Clear.title, - color: Asset.neutralActive.color - ), - DrawerText( - font: Fonts.Mulish.semiBold.font(size: 14.0), - text: Localized.Chat.Clear.subtitle, - color: Asset.neutralWeak.color, - lineHeightMultiple: 1.35, - spacingAfter: 25 - ), - DrawerStack( - spacing: 20.0, - views: [clearButton, cancelButton] - ) - ]) - - clearButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned drawer, weak self] in - drawer.dismiss(animated: true) { - self?.viewModel.didRequestDeleteAll() - } - } - .store(in: &drawer.cancellables) + cancelButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned drawer] in drawer.dismiss(animated: true) } + .store(in: &drawer.cancellables) - cancelButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned drawer] in drawer.dismiss(animated: true) } - .store(in: &drawer.cancellables) + coordinator.toDrawer(drawer, from: self) + } - coordinator.toDrawer(drawer, from: self) - } + private func previewItemAt(_ indexPath: IndexPath) { + let item = sections[indexPath.section].elements[indexPath.item] + guard let ftid = item.fileTransferId, + item.status != .receiving, + item.status != .receivingFailed else { return } - private func previewItemAt(_ indexPath: IndexPath) { - let item = sections[indexPath.section].elements[indexPath.item] - guard let ftid = item.fileTransferId, - item.status != .receiving, - item.status != .receivingFailed else { return } - - let ft = viewModel.getFileTransferWith(id: ftid) - fileURL = FileManager.url(for: "\(ft.name).\(ft.type)") - coordinator.toPreview(from: self) - } + let ft = viewModel.getFileTransferWith(id: ftid) + fileURL = FileManager.url(for: "\(ft.name).\(ft.type)") + coordinator.toPreview(from: self) + } - // MARK: Selectors + // MARK: Selectors - @objc private func didTapDots() { - coordinator.toMenuSheet(sheet, from: self) - } + @objc private func didTapDots() { + coordinator.toMenuSheet(sheet, from: self) + } - @objc private func didTapInfo() { - coordinator.toContact(viewModel.contact, from: self) - } + @objc private func didTapInfo() { + coordinator.toContact(viewModel.contact, from: self) + } } extension SingleChatController: UICollectionViewDataSource { - public func numberOfSections(in collectionView: UICollectionView) -> Int { - sections.count - } - - public func collectionView( - _ collectionView: UICollectionView, - viewForSupplementaryElementOfKind kind: String, - at indexPath: IndexPath - ) -> UICollectionReusableView { - let sectionHeader: SectionHeaderView = collectionView.dequeueSupplementaryView(forIndexPath: indexPath) - sectionHeader.title.text = sections[indexPath.section].model.date.asDayOfMonth() - return sectionHeader - } - - public func collectionView( - _ collectionView: UICollectionView, - numberOfItemsInSection section: Int - ) -> Int { - sections[section].elements.count - } - - public func collectionView( - _ collectionView: UICollectionView, - cellForItemAt indexPath: IndexPath - ) -> UICollectionViewCell { - - let showRound: (String?) -> Void = viewModel.showRoundFrom(_:) - let item = sections[indexPath.section].elements[indexPath.item] - let replyContent: (Data) -> (String, String) = viewModel.getReplyContent(for:) - let performReply: () -> Void = { [weak self] in self?.viewModel.didRequestReply(item) } - - let factory = CellFactory.combined(factories: [ - .incomingImage(transfer: viewModel.getFileTransferWith(id:)), - .outgoingImage(transfer: viewModel.getFileTransferWith(id:)), - .incomingAudio(voxophone: voxophone, transfer: viewModel.getFileTransferWith(id:)), - .outgoingAudio(voxophone: voxophone, transfer: viewModel.getFileTransferWith(id:)), - .incomingText(performReply: performReply, showRound: showRound), - .outgoingText(performReply: performReply, showRound: showRound), - .outgoingFailedText(performReply: performReply), - .incomingReply(performReply: performReply, replyContent: replyContent, showRound: showRound), - .outgoingReply(performReply: performReply, replyContent: replyContent, showRound: showRound), - .outgoingFailedReply(performReply: performReply, replyContent: replyContent) - ]) - - return factory(item: item, collectionView: collectionView, indexPath: indexPath) - } + public func numberOfSections(in collectionView: UICollectionView) -> Int { + sections.count + } + + public func collectionView( + _ collectionView: UICollectionView, + viewForSupplementaryElementOfKind kind: String, + at indexPath: IndexPath + ) -> UICollectionReusableView { + let sectionHeader: SectionHeaderView = collectionView.dequeueSupplementaryView(forIndexPath: indexPath) + sectionHeader.title.text = sections[indexPath.section].model.date.asDayOfMonth() + return sectionHeader + } + + public func collectionView( + _ collectionView: UICollectionView, + numberOfItemsInSection section: Int + ) -> Int { + sections[section].elements.count + } + + public func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + + let showRound: (String?) -> Void = viewModel.showRoundFrom(_:) + let item = sections[indexPath.section].elements[indexPath.item] + let replyContent: (Data) -> (String, String) = viewModel.getReplyContent(for:) + let performReply: () -> Void = { [weak self] in self?.viewModel.didRequestReply(item) } + + let factory = CellFactory.combined(factories: [ + .incomingImage(transfer: viewModel.getFileTransferWith(id:)), + .outgoingImage(transfer: viewModel.getFileTransferWith(id:)), + .incomingAudio(voxophone: voxophone, transfer: viewModel.getFileTransferWith(id:)), + .outgoingAudio(voxophone: voxophone, transfer: viewModel.getFileTransferWith(id:)), + .incomingText(performReply: performReply, showRound: showRound), + .outgoingText(performReply: performReply, showRound: showRound), + .outgoingFailedText(performReply: performReply), + .incomingReply(performReply: performReply, replyContent: replyContent, showRound: showRound), + .outgoingReply(performReply: performReply, replyContent: replyContent, showRound: showRound), + .outgoingFailedReply(performReply: performReply, replyContent: replyContent) + ]) + + return factory(item: item, collectionView: collectionView, indexPath: indexPath) + } } extension SingleChatController: KeyboardListenerDelegate { - fileprivate var isUserInitiatedScrolling: Bool { - collectionView.isDragging || collectionView.isDecelerating - } - - func keyboardWillChangeFrame(info: KeyboardInfo) { - let keyWindow = UIApplication.shared.windows.filter { $0.isKeyWindow }.first + fileprivate var isUserInitiatedScrolling: Bool { + collectionView.isDragging || collectionView.isDecelerating + } - guard let keyWindow = keyWindow else { - fatalError("[keyboardWillChangeFrame]: Couldn't get key window") - } + func keyboardWillChangeFrame(info: KeyboardInfo) { + let keyWindow = UIApplication.shared.windows.filter { $0.isKeyWindow }.first - let keyboardFrame = keyWindow.convert(info.frameEnd, to: view) - - guard !currentInterfaceActions.options.contains(.changingFrameSize), - collectionView.contentInsetAdjustmentBehavior != .never, - collectionView.convert(collectionView.bounds, to: keyWindow).maxY > info.frameEnd.minY else { return } - - currentInterfaceActions.options.insert(.changingKeyboardFrame) - let newBottomInset = collectionView.frame.minY + collectionView.frame.size.height - keyboardFrame.minY - collectionView.safeAreaInsets.bottom - if newBottomInset > 0, - collectionView.contentInset.bottom != newBottomInset { - let positionSnapshot = chatLayout.getContentOffsetSnapshot(from: .bottom) - - currentInterfaceActions.options.insert(.changingContentInsets) - UIView.animate(withDuration: info.animationDuration, animations: { - self.collectionView.performBatchUpdates({ - self.collectionView.contentInset.bottom = newBottomInset - self.collectionView.verticalScrollIndicatorInsets.bottom = newBottomInset - }, completion: nil) - - if let positionSnapshot = positionSnapshot, !self.isUserInitiatedScrolling { - self.chatLayout.restoreContentOffset(with: positionSnapshot) - } - }, completion: { _ in - self.currentInterfaceActions.options.remove(.changingContentInsets) - }) - } + guard let keyWindow = keyWindow else { + fatalError("[keyboardWillChangeFrame]: Couldn't get key window") } - func keyboardDidChangeFrame(info: KeyboardInfo) { - guard currentInterfaceActions.options.contains(.changingKeyboardFrame) else { return } - currentInterfaceActions.options.remove(.changingKeyboardFrame) - } -} + let keyboardFrame = keyWindow.convert(info.frameEnd, to: view) -extension SingleChatController: UICollectionViewDelegate { - private func makeTargetedPreview(for configuration: UIContextMenuConfiguration) -> UITargetedPreview? { - guard let identifier = configuration.identifier as? String, - let first = identifier.components(separatedBy: "|").first, - let last = identifier.components(separatedBy: "|").last, - let item = Int(first), let section = Int(last), - let cell = collectionView.cellForItem(at: IndexPath(item: item, section: section)) else { - return nil - } - - let parameters = UIPreviewParameters() - parameters.backgroundColor = .clear - - let status = sections[section].elements[item].status - - if status == .received || status == .receiving { - var leftView: UIView! - - if let cell = cell as? IncomingReplyCell { - leftView = cell.leftView - } else if let cell = cell as? IncomingAudioCell { - leftView = cell.leftView - } else if let cell = cell as? IncomingTextCell { - leftView = cell.leftView - } else if let cell = cell as? IncomingImageCell { - leftView = cell.leftView - } + guard !currentInterfaceActions.options.contains(.changingFrameSize), + collectionView.contentInsetAdjustmentBehavior != .never, + collectionView.convert(collectionView.bounds, to: keyWindow).maxY > info.frameEnd.minY else { return } - parameters.visiblePath = UIBezierPath(roundedRect: leftView.bounds, cornerRadius: 13) - return UITargetedPreview(view: leftView, parameters: parameters) - } + currentInterfaceActions.options.insert(.changingKeyboardFrame) + let newBottomInset = collectionView.frame.minY + collectionView.frame.size.height - keyboardFrame.minY - collectionView.safeAreaInsets.bottom + if newBottomInset > 0, + collectionView.contentInset.bottom != newBottomInset { + let positionSnapshot = chatLayout.getContentOffsetSnapshot(from: .bottom) - var rightView: UIView! - - if let cell = cell as? OutgoingTextCell { - rightView = cell.rightView - } else if let cell = cell as? OutgoingAudioCell { - rightView = cell.rightView - } else if let cell = cell as? OutgoingReplyCell { - rightView = cell.rightView - } else if let cell = cell as? OutgoingImageCell { - rightView = cell.rightView - } else if let cell = cell as? OutgoingFailedTextCell { - rightView = cell.rightView - } else if let cell = cell as? OutgoingFailedReplyCell { - rightView = cell.rightView - } + currentInterfaceActions.options.insert(.changingContentInsets) + UIView.animate(withDuration: info.animationDuration, animations: { + self.collectionView.performBatchUpdates({ + self.collectionView.contentInset.bottom = newBottomInset + self.collectionView.verticalScrollIndicatorInsets.bottom = newBottomInset + }, completion: nil) - parameters.visiblePath = UIBezierPath(roundedRect: rightView.bounds, cornerRadius: 13) - return UITargetedPreview(view: rightView, parameters: parameters) - } - - public func collectionView( - _ collectionView: UICollectionView, - previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration - ) -> UITargetedPreview? { - makeTargetedPreview(for: configuration) + if let positionSnapshot = positionSnapshot, !self.isUserInitiatedScrolling { + self.chatLayout.restoreContentOffset(with: positionSnapshot) + } + }, completion: { _ in + self.currentInterfaceActions.options.remove(.changingContentInsets) + }) } + } - public func collectionView( - _ collectionView: UICollectionView, - previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration - ) -> UITargetedPreview? { - makeTargetedPreview(for: configuration) - } + func keyboardDidChangeFrame(info: KeyboardInfo) { + guard currentInterfaceActions.options.contains(.changingKeyboardFrame) else { return } + currentInterfaceActions.options.remove(.changingKeyboardFrame) + } +} - public func collectionView( - _ collectionView: UICollectionView, - contextMenuConfigurationForItemAt indexPath: IndexPath, - point: CGPoint - ) -> UIContextMenuConfiguration? { - UIContextMenuConfiguration( - identifier: "\(indexPath.item)|\(indexPath.section)" as NSCopying, - previewProvider: nil - ) { [weak self] _ in - - guard let self = self else { return nil } - let item = self.sections[indexPath.section].elements[indexPath.item] - - var children = [ - ActionFactory.build(from: item, action: .copy, closure: self.viewModel.didRequestCopy(_:)), - ActionFactory.build(from: item, action: .retry, closure: self.viewModel.didRequestRetry(_:)), - ActionFactory.build(from: item, action: .reply, closure: self.viewModel.didRequestReply(_:)), - ActionFactory.build(from: item, action: .delete, closure: self.viewModel.didRequestDeleteSingle(_:)) - ] - - if self.reportingStatus.isEnabled() { - children.append( - ActionFactory.build(from: item, action: .report, closure: self.viewModel.didRequestReport(_:)) - ) - } +extension SingleChatController: UICollectionViewDelegate { + private func makeTargetedPreview(for configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + guard let identifier = configuration.identifier as? String, + let first = identifier.components(separatedBy: "|").first, + let last = identifier.components(separatedBy: "|").last, + let item = Int(first), let section = Int(last), + let cell = collectionView.cellForItem(at: IndexPath(item: item, section: section)) else { + return nil + } + + let parameters = UIPreviewParameters() + parameters.backgroundColor = .clear + + let status = sections[section].elements[item].status + + if status == .received || status == .receiving { + var leftView: UIView! + + if let cell = cell as? IncomingReplyCell { + leftView = cell.leftView + } else if let cell = cell as? IncomingAudioCell { + leftView = cell.leftView + } else if let cell = cell as? IncomingTextCell { + leftView = cell.leftView + } else if let cell = cell as? IncomingImageCell { + leftView = cell.leftView + } + + parameters.visiblePath = UIBezierPath(roundedRect: leftView.bounds, cornerRadius: 13) + return UITargetedPreview(view: leftView, parameters: parameters) + } + + var rightView: UIView! + + if let cell = cell as? OutgoingTextCell { + rightView = cell.rightView + } else if let cell = cell as? OutgoingAudioCell { + rightView = cell.rightView + } else if let cell = cell as? OutgoingReplyCell { + rightView = cell.rightView + } else if let cell = cell as? OutgoingImageCell { + rightView = cell.rightView + } else if let cell = cell as? OutgoingFailedTextCell { + rightView = cell.rightView + } else if let cell = cell as? OutgoingFailedReplyCell { + rightView = cell.rightView + } + + parameters.visiblePath = UIBezierPath(roundedRect: rightView.bounds, cornerRadius: 13) + return UITargetedPreview(view: rightView, parameters: parameters) + } + + public func collectionView( + _ collectionView: UICollectionView, + previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration + ) -> UITargetedPreview? { + makeTargetedPreview(for: configuration) + } + + public func collectionView( + _ collectionView: UICollectionView, + previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration + ) -> UITargetedPreview? { + makeTargetedPreview(for: configuration) + } + + public func collectionView( + _ collectionView: UICollectionView, + contextMenuConfigurationForItemAt indexPath: IndexPath, + point: CGPoint + ) -> UIContextMenuConfiguration? { + UIContextMenuConfiguration( + identifier: "\(indexPath.item)|\(indexPath.section)" as NSCopying, + previewProvider: nil + ) { [weak self] _ in + + guard let self = self else { return nil } + let item = self.sections[indexPath.section].elements[indexPath.item] + + var children = [ + ActionFactory.build(from: item, action: .copy, closure: self.viewModel.didRequestCopy(_:)), + ActionFactory.build(from: item, action: .retry, closure: self.viewModel.didRequestRetry(_:)), + ActionFactory.build(from: item, action: .reply, closure: self.viewModel.didRequestReply(_:)), + ActionFactory.build(from: item, action: .delete, closure: self.viewModel.didRequestDeleteSingle(_:)) + ] + + if self.reportingStatus.isEnabled() { + children.append( + ActionFactory.build(from: item, action: .report, closure: self.viewModel.didRequestReport(_:)) + ) + } - return UIMenu(title: "", children: children.compactMap { $0 }) - } + return UIMenu(title: "", children: children.compactMap { $0 }) } + } - public func collectionView( - _ collectionView: UICollectionView, - didSelectItemAt indexPath: IndexPath - ) { - previewItemAt(indexPath) - } + public func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath + ) { + previewItemAt(indexPath) + } } extension SingleChatController: UIImagePickerControllerDelegate { - public func imagePickerController( - _ picker: UIImagePickerController, - didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] - ) { - picker.delegate = nil - picker.dismiss(animated: true) - guard let image = info[.originalImage] as? UIImage else { return } - - DispatchQueue.global().async { [weak self] in - self?.viewModel.didSend(image: image) - } - } + public func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + picker.delegate = nil + picker.dismiss(animated: true) + guard let image = info[.originalImage] as? UIImage else { return } + + DispatchQueue.global().async { [weak self] in + self?.viewModel.didSend(image: image) + } + } } extension SingleChatController: UINavigationControllerDelegate {} extension SingleChatController: QLPreviewControllerDataSource { - public func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } + public func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } - public func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { - fileURL! as QLPreviewItem - } + public func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + fileURL! as QLPreviewItem + } } extension SingleChatController: QLPreviewControllerDelegate { - public func previewControllerDidDismiss(_ controller: QLPreviewController) { - fileURL = nil - } + public func previewControllerDidDismiss(_ controller: QLPreviewController) { + fileURL = nil + } } diff --git a/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift b/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift index 7e5bc8ea026b1f68378a3935594616a9a16ebee8..2265930fde064d351972af38bebd8c85ba9ab31e 100644 --- a/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift +++ b/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift @@ -6,7 +6,6 @@ import Combine import XXModels import Defaults import Foundation -import ToastFeature import DifferenceKit import ReportingFeature import DependencyInjection diff --git a/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift b/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift index d06d88f7eb5c2e6917a891dab8d062cb2723bc02..58757d2f8b83412fd9a7482de7d7275b98bb16d2 100644 --- a/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift +++ b/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift @@ -9,7 +9,6 @@ import XXClient import Defaults import Foundation import Permissions -import ToastFeature import DifferenceKit import ReportingFeature import DependencyInjection diff --git a/Sources/ChatListFeature/Controller/ChatListController.swift b/Sources/ChatListFeature/Controller/ChatListController.swift index 57e744a3b29d7ab15371624fe2ce0932e7f9d772..be41db769a23a3ef9e8215d2e240ffee932a7c33 100644 --- a/Sources/ChatListFeature/Controller/ChatListController.swift +++ b/Sources/ChatListFeature/Controller/ChatListController.swift @@ -1,5 +1,4 @@ import UIKit -import Theme import Models import Shared import Combine @@ -8,229 +7,229 @@ import MenuFeature import DependencyInjection public final class ChatListController: UIViewController { - @Dependency private var coordinator: ChatListCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling - - lazy private var screenView = ChatListView() - lazy private var topLeftView = ChatListTopLeftNavView() - lazy private var topRightView = ChatListTopRightNavView() - lazy private var tableController = ChatListTableController(viewModel) - lazy private var searchTableController = ChatSearchTableController(viewModel) - private var collectionDataSource: UICollectionViewDiffableDataSource<SectionId, Contact>! - - private let viewModel = ChatListViewModel() - private var cancellables = Set<AnyCancellable>() - private var drawerCancellables = Set<AnyCancellable>() - - private var isEditingSearch = false { - didSet { - screenView.listContainerView - .showRecentsCollection(isEditingSearch ? false : shouldBeShowingRecents) - } + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: ChatListCoordinating + + lazy private var screenView = ChatListView() + lazy private var topLeftView = ChatListTopLeftNavView() + lazy private var topRightView = ChatListTopRightNavView() + lazy private var tableController = ChatListTableController(viewModel) + lazy private var searchTableController = ChatSearchTableController(viewModel) + private var collectionDataSource: UICollectionViewDiffableDataSource<SectionId, Contact>! + + private let viewModel = ChatListViewModel() + private var cancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() + + private var isEditingSearch = false { + didSet { + screenView.listContainerView + .showRecentsCollection(isEditingSearch ? false : shouldBeShowingRecents) } + } - private var shouldBeShowingRecents = false { - didSet { - screenView.listContainerView - .showRecentsCollection(isEditingSearch ? false : shouldBeShowingRecents) - } + private var shouldBeShowingRecents = false { + didSet { + screenView.listContainerView + .showRecentsCollection(isEditingSearch ? false : shouldBeShowingRecents) } - - public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { - super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) - navigationItem.backButtonTitle = "" + } + + public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + navigationItem.backButtonTitle = "" + } + + required init?(coder: NSCoder) { nil } + + public override func loadView() { + view = screenView + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar.customize(backgroundColor: Asset.neutralWhite.color) + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupChatList() + setupBindings() + setupNavigationBar() + setupRecentContacts() + } + + private func setupNavigationBar() { + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: topLeftView) + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: topRightView) + + topRightView.actionPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + switch $0 { + case .didTapSearch: + coordinator.toSearch(from: self) + case .didTapNewGroup: + coordinator.toNewGroup(from: self) + } + }.store(in: &cancellables) + + viewModel.badgeCountPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in topLeftView.updateBadge($0) } + .store(in: &cancellables) + + topLeftView.actionPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toSideMenu(from: self) } + .store(in: &cancellables) + } + + private func setupChatList() { + addChild(tableController) + addChild(searchTableController) + + screenView.listContainerView.addSubview(tableController.view) + screenView.searchListContainerView.addSubview(searchTableController.view) + + tableController.view.snp.makeConstraints { + $0.top.equalTo(screenView.listContainerView.collectionContainerView.snp.bottom) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() } - required init?(coder: NSCoder) { nil } - - public override func loadView() { - view = screenView + searchTableController.view.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() } - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - statusBarController.style.send(.darkContent) - navigationController?.navigationBar.customize(backgroundColor: Asset.neutralWhite.color) + tableController.didMove(toParent: self) + searchTableController.didMove(toParent: self) + } + + private func setupRecentContacts() { + screenView + .listContainerView + .collectionView + .register(ChatListRecentContactCell.self) + + collectionDataSource = UICollectionViewDiffableDataSource<SectionId, Contact>( + collectionView: screenView.listContainerView.collectionView + ) { collectionView, indexPath, contact in + let cell: ChatListRecentContactCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) + let title = (contact.nickname ?? contact.username) ?? "" + cell.setup(title: title, image: contact.photo) + return cell } - public override func viewDidLoad() { - super.viewDidLoad() - setupChatList() - setupBindings() - setupNavigationBar() - setupRecentContacts() + screenView.listContainerView.collectionView.delegate = self + screenView.listContainerView.collectionView.dataSource = collectionDataSource + + viewModel.recentsPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + collectionDataSource.apply($0) + shouldBeShowingRecents = $0.numberOfItems > 0 + }.store(in: &cancellables) + } + + private func setupBindings() { + screenView.searchView + .rightPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toScan(from: self) } + .store(in: &cancellables) + + screenView.searchView + .textPublisher + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] query in + viewModel.updateSearch(query: query) + screenView.searchListContainerView.emptyView.updateSearched(content: query) + }.store(in: &cancellables) + + Publishers.CombineLatest( + viewModel.searchPublisher, + screenView.searchView.textPublisher.removeDuplicates() + ) + .receive(on: DispatchQueue.main) + .sink { [unowned self] items, query in + guard query.isEmpty == false else { + screenView.searchListContainerView.isHidden = true + screenView.listContainerView.isHidden = false + screenView.bringSubviewToFront(screenView.listContainerView) + return + } + + screenView.listContainerView.isHidden = true + screenView.searchListContainerView.isHidden = false + + guard items.numberOfItems > 0 else { + screenView.searchListContainerView.emptyView.isHidden = false + screenView.bringSubviewToFront(screenView.searchListContainerView) + screenView.searchListContainerView.bringSubviewToFront(screenView.searchListContainerView.emptyView) + return + } + + screenView.searchListContainerView.bringSubviewToFront(searchTableController.view) + screenView.searchListContainerView.emptyView.isHidden = true } - - private func setupNavigationBar() { - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: topLeftView) - navigationItem.rightBarButtonItem = UIBarButtonItem(customView: topRightView) - - topRightView.actionPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - switch $0 { - case .didTapSearch: - coordinator.toSearch(from: self) - case .didTapNewGroup: - coordinator.toNewGroup(from: self) - } - }.store(in: &cancellables) - - viewModel.badgeCountPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in topLeftView.updateBadge($0) } - .store(in: &cancellables) - - topLeftView.actionPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toSideMenu(from: self) } - .store(in: &cancellables) - } - - private func setupChatList() { - addChild(tableController) - addChild(searchTableController) - - screenView.listContainerView.addSubview(tableController.view) - screenView.searchListContainerView.addSubview(searchTableController.view) - - tableController.view.snp.makeConstraints { - $0.top.equalTo(screenView.listContainerView.collectionContainerView.snp.bottom) - $0.left.equalToSuperview() - $0.right.equalToSuperview() - $0.bottom.equalToSuperview() - } - - searchTableController.view.snp.makeConstraints { - $0.top.equalToSuperview() - $0.left.equalToSuperview() - $0.right.equalToSuperview() - $0.bottom.equalToSuperview() + .store(in: &cancellables) + + screenView.searchView + .isEditingPublisher + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in isEditingSearch = $0 } + .store(in: &cancellables) + + viewModel.chatsPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + guard $0.isEmpty == false else { + screenView.listContainerView.bringSubviewToFront(screenView.listContainerView.emptyView) + screenView.listContainerView.emptyView.isHidden = false + return } - tableController.didMove(toParent: self) - searchTableController.didMove(toParent: self) - } - - private func setupRecentContacts() { - screenView - .listContainerView - .collectionView - .register(ChatListRecentContactCell.self) - - collectionDataSource = UICollectionViewDiffableDataSource<SectionId, Contact>( - collectionView: screenView.listContainerView.collectionView - ) { collectionView, indexPath, contact in - let cell: ChatListRecentContactCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) - let title = (contact.nickname ?? contact.username) ?? "" - cell.setup(title: title, image: contact.photo) - return cell - } - - screenView.listContainerView.collectionView.delegate = self - screenView.listContainerView.collectionView.dataSource = collectionDataSource - - viewModel.recentsPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - collectionDataSource.apply($0) - shouldBeShowingRecents = $0.numberOfItems > 0 - }.store(in: &cancellables) - } - - private func setupBindings() { - screenView.searchView - .rightPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toScan(from: self) } - .store(in: &cancellables) - - screenView.searchView - .textPublisher - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] query in - viewModel.updateSearch(query: query) - screenView.searchListContainerView.emptyView.updateSearched(content: query) - }.store(in: &cancellables) - - Publishers.CombineLatest( - viewModel.searchPublisher, - screenView.searchView.textPublisher.removeDuplicates() - ) - .receive(on: DispatchQueue.main) - .sink { [unowned self] items, query in - guard query.isEmpty == false else { - screenView.searchListContainerView.isHidden = true - screenView.listContainerView.isHidden = false - screenView.bringSubviewToFront(screenView.listContainerView) - return - } - - screenView.listContainerView.isHidden = true - screenView.searchListContainerView.isHidden = false - - guard items.numberOfItems > 0 else { - screenView.searchListContainerView.emptyView.isHidden = false - screenView.bringSubviewToFront(screenView.searchListContainerView) - screenView.searchListContainerView.bringSubviewToFront(screenView.searchListContainerView.emptyView) - return - } - - screenView.searchListContainerView.bringSubviewToFront(searchTableController.view) - screenView.searchListContainerView.emptyView.isHidden = true - } - .store(in: &cancellables) - - screenView.searchView - .isEditingPublisher - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in isEditingSearch = $0 } - .store(in: &cancellables) - - viewModel.chatsPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - guard $0.isEmpty == false else { - screenView.listContainerView.bringSubviewToFront(screenView.listContainerView.emptyView) - screenView.listContainerView.emptyView.isHidden = false - return - } - - screenView.listContainerView.bringSubviewToFront(tableController.view) - screenView.listContainerView.emptyView.isHidden = true - } - .store(in: &cancellables) - - screenView.searchListContainerView - .emptyView.searchButton - .publisher(for: .touchUpInside) - .sink { [unowned self] in coordinator.toSearch(from: self) } - .store(in: &cancellables) - - screenView.listContainerView - .emptyView.contactsButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toContacts(from: self) } - .store(in: &cancellables) - - viewModel.isOnline - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak screenView] connected in screenView?.showConnectingBanner(!connected) } - .store(in: &cancellables) - } + screenView.listContainerView.bringSubviewToFront(tableController.view) + screenView.listContainerView.emptyView.isHidden = true + } + .store(in: &cancellables) + + screenView.searchListContainerView + .emptyView.searchButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in coordinator.toSearch(from: self) } + .store(in: &cancellables) + + screenView.listContainerView + .emptyView.contactsButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toContacts(from: self) } + .store(in: &cancellables) + + viewModel.isOnline + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak screenView] connected in screenView?.showConnectingBanner(!connected) } + .store(in: &cancellables) + } } extension ChatListController: UICollectionViewDelegate { - public func collectionView( - _ collectionView: UICollectionView, - didSelectItemAt indexPath: IndexPath - ) { - if let contact = collectionDataSource.itemIdentifier(for: indexPath) { - coordinator.toSingleChat(with: contact, from: self) - } + public func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath + ) { + if let contact = collectionDataSource.itemIdentifier(for: indexPath) { + coordinator.toSingleChat(with: contact, from: self) } + } } diff --git a/Sources/ContactFeature/Controllers/ContactController.swift b/Sources/ContactFeature/Controllers/ContactController.swift index 30dd3f5e48444176c274a0f6f18502b12e172c17..d9c7f68fc87b0b4970ea138aa63dbf08e1d660dd 100644 --- a/Sources/ContactFeature/Controllers/ContactController.swift +++ b/Sources/ContactFeature/Controllers/ContactController.swift @@ -1,6 +1,5 @@ import HUD import UIKit -import Theme import Shared import Models import Combine @@ -10,428 +9,428 @@ import DependencyInjection import ScrollViewController public final class ContactController: UIViewController { - @Dependency private var hud: HUD - @Dependency private var coordinator: ContactCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling - - lazy private var screenView = ContactView() - lazy private var scrollViewController = ScrollViewController() - - private let viewModel: ContactViewModel - private var cancellables = Set<AnyCancellable>() - private var drawerCancellables = Set<AnyCancellable>() - - public init(_ model: Contact) { - self.viewModel = ContactViewModel(model) - super.init(nibName: nil, bundle: nil) + @Dependency var hud: HUD + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: ContactCoordinating + + lazy private var screenView = ContactView() + lazy private var scrollViewController = ScrollViewController() + + private let viewModel: ContactViewModel + private var cancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() + + public init(_ model: Contact) { + self.viewModel = ContactViewModel(model) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + barStylist.styleSubject.send(.lightContent) + navigationController?.navigationBar + .customize( + backgroundColor: Asset.neutralBody.color, + tint: Asset.neutralWhite.color + ) + } + + public override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + screenView.updateTopOffset(-view.safeAreaInsets.top) + screenView.updateBottomOffset(view.safeAreaInsets.bottom) + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupScrollView() + setupBindings() + + screenView.didTapSend = { [weak self] in + guard let self = self else { return } + self.coordinator.toSingleChat(with: self.viewModel.contact, from: self) } - - required init?(coder: NSCoder) { nil } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationItem.backButtonTitle = "" - statusBarController.style.send(.lightContent) - navigationController?.navigationBar - .customize( - backgroundColor: Asset.neutralBody.color, - tint: Asset.neutralWhite.color - ) + screenView.didTapInfo = { [weak self] in + self?.presentInfo( + title: Localized.Contact.SendMessage.Info.title, + subtitle: Localized.Contact.SendMessage.Info.subtitle, + urlString: "https://links.xx.network/cmix" + ) } - public override func viewSafeAreaInsetsDidChange() { - super.viewSafeAreaInsetsDidChange() - screenView.updateTopOffset(-view.safeAreaInsets.top) - screenView.updateBottomOffset(view.safeAreaInsets.bottom) - } - - public override func viewDidLoad() { - super.viewDidLoad() - setupScrollView() - setupBindings() - - screenView.didTapSend = { [weak self] in - guard let self = self else { return } - self.coordinator.toSingleChat(with: self.viewModel.contact, from: self) + screenView.set(status: viewModel.contact.authStatus) + } + + private func setupScrollView() { + addChild(scrollViewController) + view.addSubview(scrollViewController.view) + scrollViewController.view.backgroundColor = Asset.neutralWhite.color + scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } + scrollViewController.didMove(toParent: self) + scrollViewController.contentView = screenView + scrollViewController.scrollView.bounces = false + } + + private func setupBindings() { + viewModel.hudPublisher + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + screenView.cardComponent.avatarView.editButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toPhotos(from: self) } + .store(in: &cancellables) + + viewModel.statePublisher + .map(\.photo) + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink { [unowned self] in screenView.cardComponent.image = $0 } + .store(in: &cancellables) + + viewModel.statePublisher + .map(\.title) + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink { [unowned self] in screenView.cardComponent.nameLabel.text = $0 } + .store(in: &cancellables) + + viewModel.popPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in navigationController?.popViewController(animated: true) } + .store(in: &cancellables) + + viewModel.popToRootPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in navigationController?.popToRootViewController(animated: true) } + .store(in: &cancellables) + + viewModel.successPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in screenView.updateToSuccess() } + .store(in: &cancellables) + + setupScannedBindings() + setupReceivedBindings() + setupConfirmedBindings() + setupInProgressBindings() + setupSuccessBindings() + } + + private func setupSuccessBindings() { + screenView.successView.keepAdding + .publisher(for: .touchUpInside) + .sink { [unowned self] in navigationController?.popViewController(animated: true) } + .store(in: &cancellables) + + screenView.successView.sentRequests + .publisher(for: .touchUpInside) + .sink { [unowned self] in coordinator.toRequests(from: self) } + .store(in: &cancellables) + + viewModel.statePublisher + .map(\.username) + .removeDuplicates() + .combineLatest( + viewModel.statePublisher.map(\.email).removeDuplicates(), + viewModel.statePublisher.map(\.phone).removeDuplicates() + ) + .sink { [unowned self] in + [Localized.Contact.username: $0.0, + Localized.Contact.email: $0.1, + Localized.Contact.phone: $0.2].forEach { pair in + guard let value = pair.value else { return } + + let attributeView = AttributeComponent() + attributeView.set( + title: pair.key, + value: value + ) + + screenView.successView.stack.addArrangedSubview(attributeView) } - screenView.didTapInfo = { [weak self] in - self?.presentInfo( - title: Localized.Contact.SendMessage.Info.title, - subtitle: Localized.Contact.SendMessage.Info.subtitle, - urlString: "https://links.xx.network/cmix" - ) + }.store(in: &cancellables) + } + + private func setupScannedBindings() { + screenView.scannedView.add + .publisher(for: .touchUpInside) + .sink { [unowned self] in + coordinator.toNickname( + from: self, + prefilled: (viewModel.contact.nickname ?? viewModel.contact.username) ?? "", + viewModel.didTapRequest(with:) + ) + }.store(in: &cancellables) + } + + private func setupReceivedBindings() { + screenView.receivedView.accept + .publisher(for: .touchUpInside) + .sink { [unowned self] in + coordinator.toNickname( + from: self, + prefilled: (viewModel.contact.nickname ?? viewModel.contact.username) ?? "", + viewModel.didTapAccept(_:) + ) + }.store(in: &cancellables) + + screenView.receivedView.reject + .publisher(for: .touchUpInside) + .sink { [weak viewModel] in viewModel?.didTapReject() } + .store(in: &cancellables) + } + + private func setupInProgressBindings() { + viewModel.statePublisher + .map(\.username) + .removeDuplicates() + .combineLatest( + viewModel.statePublisher.map(\.email).removeDuplicates(), + viewModel.statePublisher.map(\.phone).removeDuplicates() + ) + .sink { [unowned self] in + [Localized.Contact.username: $0.0, + Localized.Contact.email: $0.1, + Localized.Contact.phone: $0.2].forEach { pair in + guard let value = pair.value else { return } + + let attributeView = AttributeComponent() + attributeView.set( + title: pair.key, + value: value + ) + + screenView.inProgressView.stack.addArrangedSubview(attributeView) } - - screenView.set(status: viewModel.contact.authStatus) - } - - private func setupScrollView() { - addChild(scrollViewController) - view.addSubview(scrollViewController.view) - scrollViewController.view.backgroundColor = Asset.neutralWhite.color - scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } - scrollViewController.didMove(toParent: self) - scrollViewController.contentView = screenView - scrollViewController.scrollView.bounces = false - } - - private func setupBindings() { - viewModel.hudPublisher - .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - screenView.cardComponent.avatarView.editButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toPhotos(from: self) } - .store(in: &cancellables) - - viewModel.statePublisher - .map(\.photo) - .compactMap { $0 } - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.cardComponent.image = $0 } - .store(in: &cancellables) - - viewModel.statePublisher - .map(\.title) - .compactMap { $0 } - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.cardComponent.nameLabel.text = $0 } - .store(in: &cancellables) - - viewModel.popPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in navigationController?.popViewController(animated: true) } - .store(in: &cancellables) - - viewModel.popToRootPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in navigationController?.popToRootViewController(animated: true) } - .store(in: &cancellables) - - viewModel.successPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.updateToSuccess() } - .store(in: &cancellables) - - setupScannedBindings() - setupReceivedBindings() - setupConfirmedBindings() - setupInProgressBindings() - setupSuccessBindings() - } - - private func setupSuccessBindings() { - screenView.successView.keepAdding - .publisher(for: .touchUpInside) - .sink { [unowned self] in navigationController?.popViewController(animated: true) } - .store(in: &cancellables) - - screenView.successView.sentRequests - .publisher(for: .touchUpInside) - .sink { [unowned self] in coordinator.toRequests(from: self) } - .store(in: &cancellables) - - viewModel.statePublisher - .map(\.username) - .removeDuplicates() - .combineLatest( - viewModel.statePublisher.map(\.email).removeDuplicates(), - viewModel.statePublisher.map(\.phone).removeDuplicates() + }.store(in: &cancellables) + + screenView.inProgressView.feedback + .button.publisher(for: .touchUpInside) + .sink { [weak viewModel] in viewModel?.didTapResend() } + .store(in: &cancellables) + } + + private func setupConfirmedBindings() { + viewModel.statePublisher + .receive(on: DispatchQueue.main) + .map(\.nickname) + .removeDuplicates() + .combineLatest( + viewModel.statePublisher.map(\.username).removeDuplicates(), + viewModel.statePublisher.map(\.email).removeDuplicates(), + viewModel.statePublisher.map(\.phone).removeDuplicates() + ) + .sink { [unowned self] in + screenView.confirmedView.stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + let nicknameAttribute = AttributeComponent() + nicknameAttribute.set(title: Localized.Contact.nickname, value: $0.0, style: .requiredEditable) + screenView.confirmedView.stackView.insertArrangedSubview(nicknameAttribute, at: 0) + + nicknameAttribute.actionButton.publisher(for: .touchUpInside) + .sink { [unowned self] in + coordinator.toNickname( + from: self, + prefilled: (viewModel.contact.nickname ?? viewModel.contact.username) ?? "", + viewModel.didUpdateNickname(_:) ) - .sink { [unowned self] in - [Localized.Contact.username: $0.0, - Localized.Contact.email: $0.1, - Localized.Contact.phone: $0.2].forEach { pair in - guard let value = pair.value else { return } - - let attributeView = AttributeComponent() - attributeView.set( - title: pair.key, - value: value - ) - - screenView.successView.stack.addArrangedSubview(attributeView) - } - }.store(in: &cancellables) - } - - private func setupScannedBindings() { - screenView.scannedView.add - .publisher(for: .touchUpInside) - .sink { [unowned self] in - coordinator.toNickname( - from: self, - prefilled: (viewModel.contact.nickname ?? viewModel.contact.username) ?? "", - viewModel.didTapRequest(with:) - ) - }.store(in: &cancellables) - } - - private func setupReceivedBindings() { - screenView.receivedView.accept - .publisher(for: .touchUpInside) - .sink { [unowned self] in - coordinator.toNickname( - from: self, - prefilled: (viewModel.contact.nickname ?? viewModel.contact.username) ?? "", - viewModel.didTapAccept(_:) - ) - }.store(in: &cancellables) - - screenView.receivedView.reject - .publisher(for: .touchUpInside) - .sink { [weak viewModel] in viewModel?.didTapReject() } - .store(in: &cancellables) - } + } + .store(in: &cancellables) + + let usernameAttribute = AttributeComponent() + usernameAttribute.set(title: Localized.Contact.username, value: $0.1) + screenView.confirmedView.stackView.addArrangedSubview(usernameAttribute) + + let emailAttribute = AttributeComponent() + emailAttribute.set(title: Localized.Contact.email, value: $0.2) + screenView.confirmedView.stackView.addArrangedSubview(emailAttribute) + + let phoneAttribute = AttributeComponent() + phoneAttribute.set(title: Localized.Contact.phone, value: $0.3) + screenView.confirmedView.stackView.addArrangedSubview(phoneAttribute) + + let deleteButton = RowButton() + deleteButton.setup( + title: Localized.Contact.Delete.Info.title, + icon: Asset.settingsDelete.image, + style: .delete, + separator: false + ) - private func setupInProgressBindings() { - viewModel.statePublisher - .map(\.username) - .removeDuplicates() - .combineLatest( - viewModel.statePublisher.map(\.email).removeDuplicates(), - viewModel.statePublisher.map(\.phone).removeDuplicates() - ) - .sink { [unowned self] in - [Localized.Contact.username: $0.0, - Localized.Contact.email: $0.1, - Localized.Contact.phone: $0.2].forEach { pair in - guard let value = pair.value else { return } - - let attributeView = AttributeComponent() - attributeView.set( - title: pair.key, - value: value - ) - - screenView.inProgressView.stack.addArrangedSubview(attributeView) - } - }.store(in: &cancellables) - - screenView.inProgressView.feedback - .button.publisher(for: .touchUpInside) - .sink { [weak viewModel] in viewModel?.didTapResend() } - .store(in: &cancellables) - } + screenView.confirmedView.stackView.addArrangedSubview(deleteButton) + + deleteButton.publisher(for: .touchUpInside) + .sink { [unowned self] in presentDeleteInfo() } + .store(in: &cancellables) + }.store(in: &cancellables) + + screenView.confirmedView.clearButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in presentClearDrawer() } + .store(in: &cancellables) + } + + private func presentClearDrawer() { + let clearButton = CapsuleButton() + clearButton.setStyle(.red) + clearButton.setTitle(Localized.Contact.Clear.action, for: .normal) + + let cancelButton = CapsuleButton() + cancelButton.setStyle(.seeThrough) + cancelButton.setTitle(Localized.Contact.Clear.cancel, for: .normal) + + let drawer = DrawerController(with: [ + DrawerImage( + image: Asset.drawerNegative.image + ), + DrawerText( + font: Fonts.Mulish.semiBold.font(size: 18.0), + text: Localized.Contact.Clear.title, + color: Asset.neutralActive.color + ), + DrawerText( + font: Fonts.Mulish.semiBold.font(size: 14.0), + text: Localized.Contact.Clear.subtitle, + color: Asset.neutralWeak.color, + lineHeightMultiple: 1.35, + spacingAfter: 25 + ), + DrawerStack( + spacing: 20.0, + views: [clearButton, cancelButton] + ) + ]) + + clearButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + self.viewModel.didTapClear() + } + }.store(in: &drawerCancellables) - private func setupConfirmedBindings() { - viewModel.statePublisher - .receive(on: DispatchQueue.main) - .map(\.nickname) - .removeDuplicates() - .combineLatest( - viewModel.statePublisher.map(\.username).removeDuplicates(), - viewModel.statePublisher.map(\.email).removeDuplicates(), - viewModel.statePublisher.map(\.phone).removeDuplicates() - ) - .sink { [unowned self] in - screenView.confirmedView.stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } - - let nicknameAttribute = AttributeComponent() - nicknameAttribute.set(title: Localized.Contact.nickname, value: $0.0, style: .requiredEditable) - screenView.confirmedView.stackView.insertArrangedSubview(nicknameAttribute, at: 0) - - nicknameAttribute.actionButton.publisher(for: .touchUpInside) - .sink { [unowned self] in - coordinator.toNickname( - from: self, - prefilled: (viewModel.contact.nickname ?? viewModel.contact.username) ?? "", - viewModel.didUpdateNickname(_:) - ) - } - .store(in: &cancellables) - - let usernameAttribute = AttributeComponent() - usernameAttribute.set(title: Localized.Contact.username, value: $0.1) - screenView.confirmedView.stackView.addArrangedSubview(usernameAttribute) - - let emailAttribute = AttributeComponent() - emailAttribute.set(title: Localized.Contact.email, value: $0.2) - screenView.confirmedView.stackView.addArrangedSubview(emailAttribute) - - let phoneAttribute = AttributeComponent() - phoneAttribute.set(title: Localized.Contact.phone, value: $0.3) - screenView.confirmedView.stackView.addArrangedSubview(phoneAttribute) - - let deleteButton = RowButton() - deleteButton.setup( - title: Localized.Contact.Delete.Info.title, - icon: Asset.settingsDelete.image, - style: .delete, - separator: false - ) - - screenView.confirmedView.stackView.addArrangedSubview(deleteButton) - - deleteButton.publisher(for: .touchUpInside) - .sink { [unowned self] in presentDeleteInfo() } - .store(in: &cancellables) - }.store(in: &cancellables) - - screenView.confirmedView.clearButton - .publisher(for: .touchUpInside) - .sink { [unowned self] in presentClearDrawer() } - .store(in: &cancellables) - } + cancelButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + self?.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) - private func presentClearDrawer() { - let clearButton = CapsuleButton() - clearButton.setStyle(.red) - clearButton.setTitle(Localized.Contact.Clear.action, for: .normal) - - let cancelButton = CapsuleButton() - cancelButton.setStyle(.seeThrough) - cancelButton.setTitle(Localized.Contact.Clear.cancel, for: .normal) - - let drawer = DrawerController(with: [ - DrawerImage( - image: Asset.drawerNegative.image - ), - DrawerText( - font: Fonts.Mulish.semiBold.font(size: 18.0), - text: Localized.Contact.Clear.title, - color: Asset.neutralActive.color - ), - DrawerText( - font: Fonts.Mulish.semiBold.font(size: 14.0), - text: Localized.Contact.Clear.subtitle, - color: Asset.neutralWeak.color, - lineHeightMultiple: 1.35, - spacingAfter: 25 - ), - DrawerStack( - spacing: 20.0, - views: [clearButton, cancelButton] - ) - ]) - - clearButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - self.viewModel.didTapClear() - } - }.store(in: &drawerCancellables) - - cancelButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - self?.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } + coordinator.toDrawer(drawer, from: self) + } } extension ContactController: UIImagePickerControllerDelegate { - public func imagePickerController( - _ picker: UIImagePickerController, - didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any] - ) { - var image: UIImage? - - if let originalImage = info[.originalImage] as? UIImage { - image = originalImage - } - - if let croppedImage = info[.editedImage] as? UIImage { - image = croppedImage - } + public func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any] + ) { + var image: UIImage? + + if let originalImage = info[.originalImage] as? UIImage { + image = originalImage + } - guard let image = image else { - picker.dismiss(animated: true) - return - } + if let croppedImage = info[.editedImage] as? UIImage { + image = croppedImage + } - picker.dismiss(animated: true) - viewModel.didChoosePhoto(image) + guard let image = image else { + picker.dismiss(animated: true) + return } + + picker.dismiss(animated: true) + viewModel.didChoosePhoto(image) + } } extension ContactController: UINavigationControllerDelegate {} extension ContactController { - private func presentInfo( - title: String, - subtitle: String, - urlString: String = "" - ) { - let actionButton = CapsuleButton() - actionButton.set( - style: .seeThrough, - title: Localized.Settings.InfoDrawer.action - ) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerLinkText( - text: subtitle, - urlString: urlString, - spacingAfter: 37 - ), - DrawerStack(views: [ - actionButton, - FlexibleSpace() - ]) - ]) - - actionButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } + private func presentInfo( + title: String, + subtitle: String, + urlString: String = "" + ) { + let actionButton = CapsuleButton() + actionButton.set( + style: .seeThrough, + title: Localized.Settings.InfoDrawer.action + ) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerLinkText( + text: subtitle, + urlString: urlString, + spacingAfter: 37 + ), + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) + ]) + + actionButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) + + coordinator.toDrawer(drawer, from: self) + } + + private func presentDeleteInfo() { + let actionButton = DrawerCapsuleButton(model: .init( + title: Localized.Contact.Delete.Info.title, + style: .red + )) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: Localized.Contact.Delete.Drawer.title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerText( + text: Localized.Contact.Delete.Drawer.description(viewModel.contact.username ?? ""), + spacingAfter: 37, + customAttributes: [.font: Fonts.Mulish.bold.font(size: 16.0)] + ), + actionButton + ]) + + actionButton.action + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + self.viewModel.didTapDelete() + } + }.store(in: &drawerCancellables) - private func presentDeleteInfo() { - let actionButton = DrawerCapsuleButton(model: .init( - title: Localized.Contact.Delete.Info.title, - style: .red - )) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: Localized.Contact.Delete.Drawer.title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerText( - text: Localized.Contact.Delete.Drawer.description(viewModel.contact.username ?? ""), - spacingAfter: 37, - customAttributes: [.font: Fonts.Mulish.bold.font(size: 16.0)] - ), - actionButton - ]) - - actionButton.action - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - self.viewModel.didTapDelete() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } + coordinator.toDrawer(drawer, from: self) + } } diff --git a/Sources/ContactListFeature/Controllers/ContactListController.swift b/Sources/ContactListFeature/Controllers/ContactListController.swift index 1e09b9377139e9428ddb539c6ace95164f26bb1c..d7f9094b833da623928d8c197ddd468239cbe091 100644 --- a/Sources/ContactListFeature/Controllers/ContactListController.swift +++ b/Sources/ContactListFeature/Controllers/ContactListController.swift @@ -1,135 +1,134 @@ import UIKit -import Theme import Shared import Combine import DependencyInjection public final class ContactListController: UIViewController { - @Dependency private var coordinator: ContactListCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling - - lazy private var screenView = ContactListView() - lazy private var tableController = ContactListTableController(viewModel) - - private let viewModel = ContactListViewModel() - private var cancellables = Set<AnyCancellable>() - - public override func loadView() { - view = screenView - } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - statusBarController.style.send(.darkContent) - navigationController?.navigationBar.customize(backgroundColor: Asset.neutralWhite.color) - } - - public override func viewDidLoad() { - super.viewDidLoad() - setupNavigationBar() - setupTableView() - setupBindings() + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: ContactListCoordinating + + lazy private var screenView = ContactListView() + lazy private var tableController = ContactListTableController(viewModel) + + private let viewModel = ContactListViewModel() + private var cancellables = Set<AnyCancellable>() + + public override func loadView() { + view = screenView + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar.customize(backgroundColor: Asset.neutralWhite.color) + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + setupTableView() + setupBindings() + } + + private func setupNavigationBar() { + navigationItem.backButtonTitle = " " + + let titleLabel = UILabel() + titleLabel.text = Localized.ContactList.title + titleLabel.textColor = Asset.neutralActive.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) + + let menuButton = UIButton() + menuButton.tintColor = Asset.neutralDark.color + menuButton.setImage(Asset.chatListMenu.image, for: .normal) + menuButton.addTarget(self, action: #selector(didTapMenu), for: .touchUpInside) + menuButton.snp.makeConstraints { $0.width.equalTo(50) } + + navigationItem.leftBarButtonItem = UIBarButtonItem( + customView: UIStackView(arrangedSubviews: [menuButton, titleLabel]) + ) + + let search = UIButton() + search.tintColor = Asset.neutralActive.color + search.setImage(Asset.contactListSearch.image, for: .normal) + search.addTarget(self, action: #selector(didTapSearch), for: .touchUpInside) + search.accessibilityIdentifier = Localized.Accessibility.ContactList.search + + let scanButton = UIButton() + scanButton.setImage(Asset.sharedScan.image, for: .normal) + scanButton.addTarget(self, action: #selector(didTapScan), for: .touchUpInside) + + let rightStack = UIStackView() + rightStack.spacing = 15 + rightStack.addArrangedSubview(scanButton) + rightStack.addArrangedSubview(search) + + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: rightStack) + + search.snp.makeConstraints { $0.width.equalTo(40) } + } + + private func setupTableView() { + addChild(tableController) + screenView.addSubview(tableController.view) + + tableController.view.snp.makeConstraints { make in + make.top.equalTo(screenView.topStackView.snp.bottom) + make.left.bottom.right.equalToSuperview() } - private func setupNavigationBar() { - navigationItem.backButtonTitle = " " - - let titleLabel = UILabel() - titleLabel.text = Localized.ContactList.title - titleLabel.textColor = Asset.neutralActive.color - titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) - - let menuButton = UIButton() - menuButton.tintColor = Asset.neutralDark.color - menuButton.setImage(Asset.chatListMenu.image, for: .normal) - menuButton.addTarget(self, action: #selector(didTapMenu), for: .touchUpInside) - menuButton.snp.makeConstraints { $0.width.equalTo(50) } - - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [menuButton, titleLabel]) - ) - - let search = UIButton() - search.tintColor = Asset.neutralActive.color - search.setImage(Asset.contactListSearch.image, for: .normal) - search.addTarget(self, action: #selector(didTapSearch), for: .touchUpInside) - search.accessibilityIdentifier = Localized.Accessibility.ContactList.search - - let scanButton = UIButton() - scanButton.setImage(Asset.sharedScan.image, for: .normal) - scanButton.addTarget(self, action: #selector(didTapScan), for: .touchUpInside) - - let rightStack = UIStackView() - rightStack.spacing = 15 - rightStack.addArrangedSubview(scanButton) - rightStack.addArrangedSubview(search) - - navigationItem.rightBarButtonItem = UIBarButtonItem(customView: rightStack) - - search.snp.makeConstraints { $0.width.equalTo(40) } - } - - private func setupTableView() { - addChild(tableController) - screenView.addSubview(tableController.view) - - tableController.view.snp.makeConstraints { make in - make.top.equalTo(screenView.topStackView.snp.bottom) - make.left.bottom.right.equalToSuperview() + tableController.didMove(toParent: self) + } + + private func setupBindings() { + tableController.didTap + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toSingleChat(with: $0, from: self) } + .store(in: &cancellables) + + screenView.requestsButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toRequests(from: self) } + .store(in: &cancellables) + + screenView.newGroupButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toNewGroup(from: self) } + .store(in: &cancellables) + + screenView.searchButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toSearch(from: self) } + .store(in: &cancellables) + + viewModel.requestCount + .receive(on: DispatchQueue.main) + .sink { [weak screenView] in screenView?.requestsButton.updateNotification($0) } + .store(in: &cancellables) + + viewModel.contacts + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + screenView.stackView.isHidden = !$0.isEmpty + + if $0.isEmpty { + screenView.bringSubviewToFront(screenView.stackView) } + }.store(in: &cancellables) + } - tableController.didMove(toParent: self) - } - - private func setupBindings() { - tableController.didTap - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toSingleChat(with: $0, from: self) } - .store(in: &cancellables) - - screenView.requestsButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toRequests(from: self) } - .store(in: &cancellables) - - screenView.newGroupButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toNewGroup(from: self) } - .store(in: &cancellables) - - screenView.searchButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toSearch(from: self) } - .store(in: &cancellables) - - viewModel.requestCount - .receive(on: DispatchQueue.main) - .sink { [weak screenView] in screenView?.requestsButton.updateNotification($0) } - .store(in: &cancellables) - - viewModel.contacts - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - screenView.stackView.isHidden = !$0.isEmpty - - if $0.isEmpty { - screenView.bringSubviewToFront(screenView.stackView) - } - }.store(in: &cancellables) - } - - @objc private func didTapSearch() { - coordinator.toSearch(from: self) - } + @objc private func didTapSearch() { + coordinator.toSearch(from: self) + } - @objc private func didTapScan() { - coordinator.toScan(from: self) - } + @objc private func didTapScan() { + coordinator.toScan(from: self) + } - @objc private func didTapMenu() { - coordinator.toSideMenu(from: self) - } + @objc private func didTapMenu() { + coordinator.toSideMenu(from: self) + } } diff --git a/Sources/Countries/CountryListController.swift b/Sources/Countries/CountryListController.swift index a11c0e5724696bc172f58cfdc7b4333288639b7f..633633f1a921be0183cd00effe2f3e73080105b6 100644 --- a/Sources/Countries/CountryListController.swift +++ b/Sources/Countries/CountryListController.swift @@ -1,93 +1,92 @@ import os -import Theme import UIKit import Shared import Combine import DependencyInjection public final class CountryListController: UIViewController { - @Dependency private var statusBarController: StatusBarStyleControlling - - lazy private var screenView = CountryListView() - - private var didChoose: ((Country) -> Void)! - private let viewModel = CountryListViewModel() - private var cancellables = Set<AnyCancellable>() - private var dataSource: UITableViewDiffableDataSource<SectionId, Country>! - - public init(_ didChoose: @escaping (Country) -> Void) { - self.didChoose = didChoose - super.init(nibName: nil, bundle: nil) + @Dependency var barStylist: StatusBarStylist + + lazy private var screenView = CountryListView() + + private var didChoose: ((Country) -> Void)! + private let viewModel = CountryListViewModel() + private var cancellables = Set<AnyCancellable>() + private var dataSource: UITableViewDiffableDataSource<SectionId, Country>! + + public init(_ didChoose: @escaping (Country) -> Void) { + self.didChoose = didChoose + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + barStylist.styleSubject.send(.darkContent) + + navigationController?.navigationBar.customize( + backgroundColor: Asset.neutralWhite.color, + shadowColor: Asset.neutralDisabled.color + ) + } + + public override func loadView() { + view = screenView + } + + public override func viewDidLoad() { + super.viewDidLoad() + screenView.tableView.register(CountryListCell.self) + setupNavigationBar() + setupBindings() + + viewModel.fetchCountryList() + } + + private func setupNavigationBar() { + let title = UILabel() + title.text = Localized.Countries.title + title.textColor = Asset.neutralActive.color + title.font = Fonts.Mulish.semiBold.font(size: 18.0) + + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: title) + navigationItem.leftItemsSupplementBackButton = true + } + + private func setupBindings() { + viewModel.countries + .receive(on: DispatchQueue.main) + .sink { [unowned self] in dataSource.apply($0, animatingDifferences: false) } + .store(in: &cancellables) + + dataSource = UITableViewDiffableDataSource<SectionId, Country>( + tableView: screenView.tableView + ) { tableView, indexPath, country in + let cell: CountryListCell = tableView.dequeueReusableCell(forIndexPath: indexPath) + cell.flagLabel.text = country.flag + cell.nameLabel.text = country.name + cell.prefixLabel.text = country.prefix + return cell } - required init?(coder: NSCoder) { nil } + screenView.searchComponent + .textPublisher + .removeDuplicates() + .sink { [unowned self] in viewModel.didSearchFor($0) } + .store(in: &cancellables) - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationItem.backButtonTitle = "" - statusBarController.style.send(.darkContent) + screenView.tableView.delegate = self + screenView.tableView.dataSource = dataSource + } - navigationController?.navigationBar.customize( - backgroundColor: Asset.neutralWhite.color, - shadowColor: Asset.neutralDisabled.color - ) - } - - public override func loadView() { - view = screenView - } - - public override func viewDidLoad() { - super.viewDidLoad() - screenView.tableView.register(CountryListCell.self) - setupNavigationBar() - setupBindings() - - viewModel.fetchCountryList() - } - - private func setupNavigationBar() { - let title = UILabel() - title.text = Localized.Countries.title - title.textColor = Asset.neutralActive.color - title.font = Fonts.Mulish.semiBold.font(size: 18.0) - - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: title) - navigationItem.leftItemsSupplementBackButton = true - } - - private func setupBindings() { - viewModel.countries - .receive(on: DispatchQueue.main) - .sink { [unowned self] in dataSource.apply($0, animatingDifferences: false) } - .store(in: &cancellables) - - dataSource = UITableViewDiffableDataSource<SectionId, Country>( - tableView: screenView.tableView - ) { tableView, indexPath, country in - let cell: CountryListCell = tableView.dequeueReusableCell(forIndexPath: indexPath) - cell.flagLabel.text = country.flag - cell.nameLabel.text = country.name - cell.prefixLabel.text = country.prefix - return cell - } - - screenView.searchComponent - .textPublisher - .removeDuplicates() - .sink { [unowned self] in viewModel.didSearchFor($0) } - .store(in: &cancellables) - - screenView.tableView.delegate = self - screenView.tableView.dataSource = dataSource - } - - public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if let country = dataSource.itemIdentifier(for: indexPath) { - didChoose(country) - navigationController?.popViewController(animated: true) - } + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if let country = dataSource.itemIdentifier(for: indexPath) { + didChoose(country) + navigationController?.popViewController(animated: true) } + } } extension CountryListController: UITableViewDelegate {} diff --git a/Sources/Defaults/KeyObject.swift b/Sources/Defaults/KeyObject.swift index 0ade4e83639f54a5181b4292936a2d9dee049f60..c864b9f9c9adcd70a62f5f8891fc8b71f9644a07 100644 --- a/Sources/Defaults/KeyObject.swift +++ b/Sources/Defaults/KeyObject.swift @@ -20,7 +20,6 @@ public enum Key: String { // MARK: General - case theme case acceptedTerms // MARK: Requests diff --git a/Sources/HUD/HUD.swift b/Sources/HUD/HUD.swift index 207f3473c9173a3a64c8636a8373d734d207807b..8850d8a2ee7a2e7be7fa7e20f36d0f8070973cb4 100644 --- a/Sources/HUD/HUD.swift +++ b/Sources/HUD/HUD.swift @@ -1,195 +1,194 @@ import UIKit -import Theme import Shared import Combine import SnapKit private enum Constants { - static let title = Localized.Hud.Error.title - static let action = Localized.Hud.Error.action + static let title = Localized.Hud.Error.title + static let action = Localized.Hud.Error.action } public enum HUDStatus: Equatable { - case none - case on - case onTitle(String) - case onAction(String) - case error(HUDError) - - var isPresented: Bool { - switch self { - case .none: - return false - case .on, .error, .onTitle, .onAction: - return true - } + case none + case on + case onTitle(String) + case onAction(String) + case error(HUDError) + + var isPresented: Bool { + switch self { + case .none: + return false + case .on, .error, .onTitle, .onAction: + return true } + } } public struct HUDError: Equatable { - var title: String - var content: String - var buttonTitle: String - var dismissable: Bool - - public init( - content: String, - title: String? = nil, - buttonTitle: String? = nil, - dismissable: Bool = true - ) { - self.content = content - self.title = title ?? Constants.title - self.buttonTitle = buttonTitle ?? Constants.action - self.dismissable = dismissable - } - - public init(with error: Error) { - self.title = Constants.title - self.buttonTitle = Constants.action - self.content = error.localizedDescription - self.dismissable = true - } + var title: String + var content: String + var buttonTitle: String + var dismissable: Bool + + public init( + content: String, + title: String? = nil, + buttonTitle: String? = nil, + dismissable: Bool = true + ) { + self.content = content + self.title = title ?? Constants.title + self.buttonTitle = buttonTitle ?? Constants.action + self.dismissable = dismissable + } + + public init(with error: Error) { + self.title = Constants.title + self.buttonTitle = Constants.action + self.content = error.localizedDescription + self.dismissable = true + } } public final class HUD { - private(set) var window: UIWindow? - private(set) var errorView: ErrorView? - private(set) var titleLabel: UILabel? - private(set) var animation: DotAnimation? - public var actionButton: CapsuleButton? - private var cancellables = Set<AnyCancellable>() - - private var status: HUDStatus = .none { - didSet { - if oldValue.isPresented == true && status.isPresented == true { - self.errorView = nil - self.animation = nil - self.window = nil - self.actionButton = nil - self.titleLabel = nil - - switch status { - case .on: - animation = DotAnimation() - - case .onTitle(let text): - animation = DotAnimation() - titleLabel = UILabel() - titleLabel!.text = text - - case .onAction(let title): - animation = DotAnimation() - actionButton = CapsuleButton() - actionButton!.set(style: .seeThroughWhite, title: title) - - case .error(let error): - errorView = ErrorView(with: error) - case .none: - break - } - - showWindow() - } - - if oldValue.isPresented == false && status.isPresented == true { - switch status { - case .on: - animation = DotAnimation() - - case .onTitle(let text): - animation = DotAnimation() - titleLabel = UILabel() - titleLabel!.text = text - - case .onAction(let title): - animation = DotAnimation() - actionButton = CapsuleButton() - actionButton!.set(style: .seeThroughWhite, title: title) - - case .error(let error): - errorView = ErrorView(with: error) - case .none: - break - } - - showWindow() - } - - if oldValue.isPresented == true && status.isPresented == false { - hideWindow() - } + private(set) var window: UIWindow? + private(set) var errorView: ErrorView? + private(set) var titleLabel: UILabel? + private(set) var animation: DotAnimation? + public var actionButton: CapsuleButton? + private var cancellables = Set<AnyCancellable>() + + private var status: HUDStatus = .none { + didSet { + if oldValue.isPresented == true && status.isPresented == true { + self.errorView = nil + self.animation = nil + self.window = nil + self.actionButton = nil + self.titleLabel = nil + + switch status { + case .on: + animation = DotAnimation() + + case .onTitle(let text): + animation = DotAnimation() + titleLabel = UILabel() + titleLabel!.text = text + + case .onAction(let title): + animation = DotAnimation() + actionButton = CapsuleButton() + actionButton!.set(style: .seeThroughWhite, title: title) + + case .error(let error): + errorView = ErrorView(with: error) + case .none: + break } - } - public init() {} + showWindow() + } - public func update(with status: HUDStatus) { - self.status = status - } + if oldValue.isPresented == false && status.isPresented == true { + switch status { + case .on: + animation = DotAnimation() - private func showWindow() { - window = Window() - window?.backgroundColor = UIColor.black.withAlphaComponent(0.8) - window?.rootViewController = StatusBarViewController(nil) + case .onTitle(let text): + animation = DotAnimation() + titleLabel = UILabel() + titleLabel!.text = text - if let animation = animation { - window?.addSubview(animation) - animation.setColor(.white) - animation.snp.makeConstraints { $0.center.equalToSuperview() } - } + case .onAction(let title): + animation = DotAnimation() + actionButton = CapsuleButton() + actionButton!.set(style: .seeThroughWhite, title: title) - if let titleLabel = titleLabel { - window?.addSubview(titleLabel) - titleLabel.textAlignment = .center - titleLabel.numberOfLines = 0 - titleLabel.snp.makeConstraints { make in - make.left.equalToSuperview().offset(18) - make.center.equalToSuperview().offset(50) - make.right.equalToSuperview().offset(-18) - } + case .error(let error): + errorView = ErrorView(with: error) + case .none: + break } - if let actionButton = actionButton { - window?.addSubview(actionButton) - actionButton.snp.makeConstraints { - $0.left.equalToSuperview().offset(18) - $0.right.equalToSuperview().offset(-18) - $0.bottom.equalToSuperview().offset(-50) - } - } + showWindow() + } - if let errorView = errorView { - window?.addSubview(errorView) - errorView.snp.makeConstraints { make in - make.left.equalToSuperview().offset(18) - make.center.equalToSuperview() - make.right.equalToSuperview().offset(-18) - } - - errorView.button - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in hideWindow() } - .store(in: &cancellables) - } + if oldValue.isPresented == true && status.isPresented == false { + hideWindow() + } + } + } + + public init() {} - window?.alpha = 0.0 - window?.makeKeyAndVisible() + public func update(with status: HUDStatus) { + self.status = status + } - UIView.animate(withDuration: 0.3) { self.window?.alpha = 1.0 } + private func showWindow() { + window = UIWindow(frame: UIScreen.main.bounds) + window?.backgroundColor = UIColor.black.withAlphaComponent(0.8) + window?.rootViewController = RootViewController(nil) + + if let animation = animation { + window?.addSubview(animation) + animation.setColor(.white) + animation.snp.makeConstraints { $0.center.equalToSuperview() } } - private func hideWindow() { - UIView.animate(withDuration: 0.3) { - self.window?.alpha = 0.0 - } completion: { _ in - self.cancellables.removeAll() - self.errorView = nil - self.animation = nil - self.actionButton = nil - self.titleLabel = nil - self.window = nil - } + if let titleLabel = titleLabel { + window?.addSubview(titleLabel) + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 0 + titleLabel.snp.makeConstraints { make in + make.left.equalToSuperview().offset(18) + make.center.equalToSuperview().offset(50) + make.right.equalToSuperview().offset(-18) + } + } + + if let actionButton = actionButton { + window?.addSubview(actionButton) + actionButton.snp.makeConstraints { + $0.left.equalToSuperview().offset(18) + $0.right.equalToSuperview().offset(-18) + $0.bottom.equalToSuperview().offset(-50) + } + } + + if let errorView = errorView { + window?.addSubview(errorView) + errorView.snp.makeConstraints { make in + make.left.equalToSuperview().offset(18) + make.center.equalToSuperview() + make.right.equalToSuperview().offset(-18) + } + + errorView.button + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in hideWindow() } + .store(in: &cancellables) + } + + window?.alpha = 0.0 + window?.makeKeyAndVisible() + + UIView.animate(withDuration: 0.3) { self.window?.alpha = 1.0 } + } + + private func hideWindow() { + UIView.animate(withDuration: 0.3) { + self.window?.alpha = 0.0 + } completion: { _ in + self.cancellables.removeAll() + self.errorView = nil + self.animation = nil + self.actionButton = nil + self.titleLabel = nil + self.window = nil } + } } diff --git a/Sources/LaunchFeature/LaunchController.swift b/Sources/LaunchFeature/LaunchController.swift index 651da26c6449cc120fdc33c640aaac51cb07154c..fe5d10cbead95e6ca6433a600325cbceca1f13d7 100644 --- a/Sources/LaunchFeature/LaunchController.swift +++ b/Sources/LaunchFeature/LaunchController.swift @@ -7,156 +7,155 @@ import PushFeature import DependencyInjection public final class LaunchController: UIViewController { - @Dependency private var hud: HUD - @Dependency private var coordinator: LaunchCoordinating - - @KeyObject(.acceptedTerms, defaultValue: false) var didAcceptTerms: Bool - - lazy private var screenView = LaunchView() - - private let blocker = UpdateBlocker() - private let viewModel = LaunchViewModel() - public var pendingPushRoute: PushRouter.Route? - private var cancellables = Set<AnyCancellable>() - - public override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - viewModel.viewDidAppear() - } - - public override func loadView() { - view = screenView - } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationController? - .navigationBar - .customize(translucent: true) + @Dependency var hud: HUD + @Dependency var coordinator: LaunchCoordinating + + @KeyObject(.acceptedTerms, defaultValue: false) var didAcceptTerms: Bool + + lazy private var screenView = LaunchView() + + private let viewModel = LaunchViewModel() + public var pendingPushRoute: PushRouter.Route? + private var cancellables = Set<AnyCancellable>() + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewModel.viewDidAppear() + } + + public override func loadView() { + view = screenView + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController? + .navigationBar + .customize(translucent: true) + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + screenView.setupGradient() + } + + public override func viewDidLoad() { + super.viewDidLoad() + + viewModel.hudPublisher + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + viewModel.routePublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + switch $0 { + case .chats: + guard didAcceptTerms == true else { + coordinator.toTerms(from: self) + return + } + + if let pushRoute = pendingPushRoute { + switch pushRoute { + case .requests: + coordinator.toRequests(from: self) + + case .search(username: let username): + coordinator.toSearch(searching: username, from: self) + + case .groupChat(id: let groupId): + if let groupInfo = viewModel.getGroupInfoWith(groupId: groupId) { + coordinator.toGroupChat(with: groupInfo, from: self) + return + } + coordinator.toChats(from: self) + + case .contactChat(id: let userId): + if let contact = viewModel.getContactWith(userId: userId) { + coordinator.toSingleChat(with: contact, from: self) + return + } + coordinator.toChats(from: self) + } + + return + } + + coordinator.toChats(from: self) + + case .onboarding: + coordinator.toOnboarding(from: self) + + case .update(let model): + offerUpdate(model: model) + } + }.store(in: &cancellables) + } + + private func offerUpdate(model: Update) { + let drawerView = UIView() + drawerView.backgroundColor = Asset.neutralSecondary.color + drawerView.layer.cornerRadius = 5 + + let vStack = UIStackView() + vStack.axis = .vertical + vStack.spacing = 10 + drawerView.addSubview(vStack) + + vStack.snp.makeConstraints { + $0.top.equalToSuperview().offset(18) + $0.left.equalToSuperview().offset(18) + $0.right.equalToSuperview().offset(-18) + $0.bottom.equalToSuperview().offset(-18) } - public override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - screenView.setupGradient() + let title = UILabel() + title.text = "App Update" + title.textAlignment = .center + title.textColor = Asset.neutralDark.color + + let body = UILabel() + body.numberOfLines = 0 + body.textAlignment = .center + body.textColor = Asset.neutralDark.color + + let update = CapsuleButton() + update.publisher(for: .touchUpInside) + .sink { UIApplication.shared.open(.init(string: model.urlString)!, options: [:]) } + .store(in: &cancellables) + + vStack.addArrangedSubview(title) + vStack.addArrangedSubview(body) + vStack.addArrangedSubview(update) + + body.text = model.content + update.set( + style: model.actionStyle, + title: model.positiveActionTitle + ) + + if let negativeTitle = model.negativeActionTitle { + let negativeButton = CapsuleButton() + negativeButton.set(style: .simplestColoredRed, title: negativeTitle) + + negativeButton.publisher(for: .touchUpInside) + .sink { [unowned self] in + blocker.hideWindow() + viewModel.continueWithInitialization() + }.store(in: &cancellables) + + vStack.addArrangedSubview(negativeButton) } - public override func viewDidLoad() { - super.viewDidLoad() - - viewModel.hudPublisher - .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - viewModel.routePublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - switch $0 { - case .chats: - guard didAcceptTerms == true else { - coordinator.toTerms(from: self) - return - } - - if let pushRoute = pendingPushRoute { - switch pushRoute { - case .requests: - coordinator.toRequests(from: self) - - case .search(username: let username): - coordinator.toSearch(searching: username, from: self) - - case .groupChat(id: let groupId): - if let groupInfo = viewModel.getGroupInfoWith(groupId: groupId) { - coordinator.toGroupChat(with: groupInfo, from: self) - return - } - coordinator.toChats(from: self) - - case .contactChat(id: let userId): - if let contact = viewModel.getContactWith(userId: userId) { - coordinator.toSingleChat(with: contact, from: self) - return - } - coordinator.toChats(from: self) - } - - return - } - - coordinator.toChats(from: self) - - case .onboarding: - coordinator.toOnboarding(from: self) - - case .update(let model): - offerUpdate(model: model) - } - }.store(in: &cancellables) + blocker.window?.addSubview(drawerView) + drawerView.snp.makeConstraints { + $0.left.equalToSuperview().offset(18) + $0.center.equalToSuperview() + $0.right.equalToSuperview().offset(-18) } - private func offerUpdate(model: Update) { - let drawerView = UIView() - drawerView.backgroundColor = Asset.neutralSecondary.color - drawerView.layer.cornerRadius = 5 - - let vStack = UIStackView() - vStack.axis = .vertical - vStack.spacing = 10 - drawerView.addSubview(vStack) - - vStack.snp.makeConstraints { - $0.top.equalToSuperview().offset(18) - $0.left.equalToSuperview().offset(18) - $0.right.equalToSuperview().offset(-18) - $0.bottom.equalToSuperview().offset(-18) - } - - let title = UILabel() - title.text = "App Update" - title.textAlignment = .center - title.textColor = Asset.neutralDark.color - - let body = UILabel() - body.numberOfLines = 0 - body.textAlignment = .center - body.textColor = Asset.neutralDark.color - - let update = CapsuleButton() - update.publisher(for: .touchUpInside) - .sink { UIApplication.shared.open(.init(string: model.urlString)!, options: [:]) } - .store(in: &cancellables) - - vStack.addArrangedSubview(title) - vStack.addArrangedSubview(body) - vStack.addArrangedSubview(update) - - body.text = model.content - update.set( - style: model.actionStyle, - title: model.positiveActionTitle - ) - - if let negativeTitle = model.negativeActionTitle { - let negativeButton = CapsuleButton() - negativeButton.set(style: .simplestColoredRed, title: negativeTitle) - - negativeButton.publisher(for: .touchUpInside) - .sink { [unowned self] in - blocker.hideWindow() - viewModel.continueWithInitialization() - }.store(in: &cancellables) - - vStack.addArrangedSubview(negativeButton) - } - - blocker.window?.addSubview(drawerView) - drawerView.snp.makeConstraints { - $0.left.equalToSuperview().offset(18) - $0.center.equalToSuperview() - $0.right.equalToSuperview().offset(-18) - } - - blocker.showWindow() - } + blocker.showWindow() + } } diff --git a/Sources/LaunchFeature/LaunchViewModel.swift b/Sources/LaunchFeature/LaunchViewModel.swift index 66ba1a1e0323cb8145fa9f623f147733b2dd4294..8eda0596d6817bb246d86620f22c7190a18ab908 100644 --- a/Sources/LaunchFeature/LaunchViewModel.swift +++ b/Sources/LaunchFeature/LaunchViewModel.swift @@ -8,7 +8,6 @@ import XXLogger import Keychain import Foundation import Permissions -import ToastFeature import BackupFeature import VersionChecking import ReportingFeature diff --git a/Sources/LaunchFeature/UpdateBlocker.swift b/Sources/LaunchFeature/UpdateBlocker.swift deleted file mode 100644 index 571c2a527a33e8411c320cbc7b25fdec6145d399..0000000000000000000000000000000000000000 --- a/Sources/LaunchFeature/UpdateBlocker.swift +++ /dev/null @@ -1,24 +0,0 @@ -import UIKit -import Theme -import Shared - -final class UpdateBlocker { - private(set) var window: Window? = Window() - - func showWindow() { - window?.backgroundColor = UIColor.black.withAlphaComponent(0.5) - window?.rootViewController = StatusBarViewController(nil) - window?.alpha = 0.0 - window?.makeKeyAndVisible() - - UIView.animate(withDuration: 0.3) { self.window?.alpha = 1.0 } - } - - func hideWindow() { - UIView.animate(withDuration: 0.3) { - self.window?.alpha = 0.0 - } completion: { _ in - self.window = nil - } - } -} diff --git a/Sources/MenuFeature/Controllers/MenuController.swift b/Sources/MenuFeature/Controllers/MenuController.swift index 93f1fcf4a328da1f73f077b8f0f8806ceee0c7f5..49b86d3a90686a2b460d0256a8ff040376486672 100644 --- a/Sources/MenuFeature/Controllers/MenuController.swift +++ b/Sources/MenuFeature/Controllers/MenuController.swift @@ -1,4 +1,3 @@ -import Theme import UIKit import Shared import Combine @@ -6,230 +5,230 @@ import DrawerFeature import DependencyInjection public enum MenuItem { - case join - case scan - case chats - case share - case profile - case contacts - case requests - case settings - case dashboard + case join + case scan + case chats + case share + case profile + case contacts + case requests + case settings + case dashboard } public final class MenuController: UIViewController { - @Dependency private var coordinator: MenuCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling - - lazy private var screenView = MenuView() - - private let previousItem: MenuItem - private let viewModel = MenuViewModel() - private let previousController: UIViewController - private var cancellables = Set<AnyCancellable>() - private var drawerCancellables = Set<AnyCancellable>() - - public init( - _ previousItem: MenuItem, - _ previousController: UIViewController - ) { - self.previousItem = previousItem - self.previousController = previousController - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { nil } - - public override func loadView() { - view = screenView - } - - public override func viewDidLoad() { - super.viewDidLoad() - - screenView.headerView.set( - username: viewModel.username, - image: viewModel.avatar - ) - - screenView.select(item: previousItem) - screenView.xxdkVersionLabel.text = "XXDK \(viewModel.xxdk)" - screenView.buildLabel.text = Localized.Menu.build(viewModel.build) - screenView.versionLabel.text = Localized.Menu.version(viewModel.version) - setupBindings() - } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - statusBarController.style.send(.lightContent) - } - - public override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - statusBarController.style.send(.darkContent) - } - - private func setupBindings() { - screenView.headerView.scanButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - dismiss(animated: true) { [weak self] in - guard let self = self, self.previousItem != .scan else { return } - self.coordinator.toFlow(.scan, from: self.previousController) - } - }.store(in: &cancellables) - - screenView.headerView.nameButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - dismiss(animated: true) { [weak self] in - guard let self = self, self.previousItem != .profile else { return } - self.coordinator.toFlow(.profile, from: self.previousController) - } - }.store(in: &cancellables) - - screenView.scanButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - dismiss(animated: true) { [weak self] in - guard let self = self, self.previousItem != .scan else { return } - self.coordinator.toFlow(.scan, from: self.previousController) - } - }.store(in: &cancellables) - - screenView.chatsButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - dismiss(animated: true) { [weak self] in - guard let self = self, self.previousItem != .chats else { return } - self.coordinator.toFlow(.chats, from: self.previousController) - } - }.store(in: &cancellables) - - screenView.contactsButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - dismiss(animated: true) { [weak self] in - guard let self = self, self.previousItem != .contacts else { return } - self.coordinator.toFlow(.contacts, from: self.previousController) - } - }.store(in: &cancellables) - - screenView.settingsButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - dismiss(animated: true) { [weak self] in - guard let self = self, self.previousItem != .settings else { return } - self.coordinator.toFlow(.settings, from: self.previousController) - } - }.store(in: &cancellables) - - screenView.dashboardButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - dismiss(animated: true) { [weak self] in - guard let self = self, self.previousItem != .dashboard else { return } - self.presentDrawer( - title: Localized.ChatList.Dashboard.title, - subtitle: Localized.ChatList.Dashboard.subtitle, - actionTitle: Localized.ChatList.Dashboard.open) { - guard let url = URL(string: "https://dashboard.xx.network") else { return } - UIApplication.shared.open(url, options: [:]) - } - } - }.store(in: &cancellables) - - screenView.requestsButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - dismiss(animated: true) { [weak self] in - guard let self = self, self.previousItem != .requests else { return } - self.coordinator.toFlow(.requests, from: self.previousController) - } - }.store(in: &cancellables) - - screenView.joinButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - dismiss(animated: true) { [weak self] in - guard let self = self, self.previousItem != .join else { return } - self.presentDrawer( - title: Localized.ChatList.Join.title, - subtitle: Localized.ChatList.Join.subtitle, - actionTitle: Localized.ChatList.Dashboard.open) { - guard let url = URL(string: "https://xx.network") else { return } - UIApplication.shared.open(url, options: [:]) - } - } - }.store(in: &cancellables) - - screenView.shareButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - dismiss(animated: true) { [weak self] in - guard let self = self, self.previousItem != .share else { return } - self.coordinator.toActivityController( - with: [Localized.Menu.shareContent(self.viewModel.referralDeeplink)], - from: self.previousController - ) - } - }.store(in: &cancellables) - - viewModel.requestCount - .receive(on: DispatchQueue.main) - .sink { [weak screenView] in screenView?.requestsButton.updateNotification($0) } - .store(in: &cancellables) - } - - private func presentDrawer( - title: String, - subtitle: String, - actionTitle: String, - action: @escaping () -> Void - ) { - let actionButton = DrawerCapsuleButton(model: .init( - title: actionTitle, - style: .red - )) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerText( - font: Fonts.Mulish.regular.font(size: 16.0), - text: subtitle, - color: Asset.neutralBody.color, - alignment: .left, - lineHeightMultiple: 1.1, - spacingAfter: 39 - ), - actionButton - ]) - - actionButton.action.receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - action() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: previousController) - } + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: MenuCoordinating + + lazy private var screenView = MenuView() + + private let previousItem: MenuItem + private let viewModel = MenuViewModel() + private let previousController: UIViewController + private var cancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() + + public init( + _ previousItem: MenuItem, + _ previousController: UIViewController + ) { + self.previousItem = previousItem + self.previousController = previousController + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + + public override func loadView() { + view = screenView + } + + public override func viewDidLoad() { + super.viewDidLoad() + + screenView.headerView.set( + username: viewModel.username, + image: viewModel.avatar + ) + + screenView.select(item: previousItem) + screenView.xxdkVersionLabel.text = "XXDK \(viewModel.xxdk)" + screenView.buildLabel.text = Localized.Menu.build(viewModel.build) + screenView.versionLabel.text = Localized.Menu.version(viewModel.version) + setupBindings() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + barStylist.styleSubject.send(.lightContent) + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + barStylist.styleSubject.send(.darkContent) + } + + private func setupBindings() { + screenView.headerView.scanButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + dismiss(animated: true) { [weak self] in + guard let self = self, self.previousItem != .scan else { return } + self.coordinator.toFlow(.scan, from: self.previousController) + } + }.store(in: &cancellables) + + screenView.headerView.nameButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + dismiss(animated: true) { [weak self] in + guard let self = self, self.previousItem != .profile else { return } + self.coordinator.toFlow(.profile, from: self.previousController) + } + }.store(in: &cancellables) + + screenView.scanButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + dismiss(animated: true) { [weak self] in + guard let self = self, self.previousItem != .scan else { return } + self.coordinator.toFlow(.scan, from: self.previousController) + } + }.store(in: &cancellables) + + screenView.chatsButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + dismiss(animated: true) { [weak self] in + guard let self = self, self.previousItem != .chats else { return } + self.coordinator.toFlow(.chats, from: self.previousController) + } + }.store(in: &cancellables) + + screenView.contactsButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + dismiss(animated: true) { [weak self] in + guard let self = self, self.previousItem != .contacts else { return } + self.coordinator.toFlow(.contacts, from: self.previousController) + } + }.store(in: &cancellables) + + screenView.settingsButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + dismiss(animated: true) { [weak self] in + guard let self = self, self.previousItem != .settings else { return } + self.coordinator.toFlow(.settings, from: self.previousController) + } + }.store(in: &cancellables) + + screenView.dashboardButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + dismiss(animated: true) { [weak self] in + guard let self = self, self.previousItem != .dashboard else { return } + self.presentDrawer( + title: Localized.ChatList.Dashboard.title, + subtitle: Localized.ChatList.Dashboard.subtitle, + actionTitle: Localized.ChatList.Dashboard.open) { + guard let url = URL(string: "https://dashboard.xx.network") else { return } + UIApplication.shared.open(url, options: [:]) + } + } + }.store(in: &cancellables) + + screenView.requestsButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + dismiss(animated: true) { [weak self] in + guard let self = self, self.previousItem != .requests else { return } + self.coordinator.toFlow(.requests, from: self.previousController) + } + }.store(in: &cancellables) + + screenView.joinButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + dismiss(animated: true) { [weak self] in + guard let self = self, self.previousItem != .join else { return } + self.presentDrawer( + title: Localized.ChatList.Join.title, + subtitle: Localized.ChatList.Join.subtitle, + actionTitle: Localized.ChatList.Dashboard.open) { + guard let url = URL(string: "https://xx.network") else { return } + UIApplication.shared.open(url, options: [:]) + } + } + }.store(in: &cancellables) + + screenView.shareButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + dismiss(animated: true) { [weak self] in + guard let self = self, self.previousItem != .share else { return } + self.coordinator.toActivityController( + with: [Localized.Menu.shareContent(self.viewModel.referralDeeplink)], + from: self.previousController + ) + } + }.store(in: &cancellables) + + viewModel.requestCount + .receive(on: DispatchQueue.main) + .sink { [weak screenView] in screenView?.requestsButton.updateNotification($0) } + .store(in: &cancellables) + } + + private func presentDrawer( + title: String, + subtitle: String, + actionTitle: String, + action: @escaping () -> Void + ) { + let actionButton = DrawerCapsuleButton(model: .init( + title: actionTitle, + style: .red + )) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: subtitle, + color: Asset.neutralBody.color, + alignment: .left, + lineHeightMultiple: 1.1, + spacingAfter: 39 + ), + actionButton + ]) + + actionButton.action.receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + action() + } + }.store(in: &drawerCancellables) + + coordinator.toDrawer(drawer, from: previousController) + } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift b/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift index 578a27dc5bdedfcd58ab9dbecfd1338c07bad324..b45a26fcc8e08e24c1232444434b632914edda51 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift @@ -1,152 +1,151 @@ import HUD -import DrawerFeature -import Theme import UIKit import Shared +import Models import Combine +import DrawerFeature import DependencyInjection import ScrollViewController -import Models public final class OnboardingEmailConfirmationController: UIViewController { - @Dependency private var hud: HUD - @Dependency private var coordinator: OnboardingCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling - - lazy private var screenView = OnboardingEmailConfirmationView() - lazy private var scrollViewController = ScrollViewController() - - private var cancellables = Set<AnyCancellable>() - private let completion: (UIViewController) -> Void - private var drawerCancellables = Set<AnyCancellable>() - private let viewModel: OnboardingEmailConfirmationViewModel - - public init( - _ confirmation: AttributeConfirmation, - _ completion: @escaping (UIViewController) -> Void - ) { - self.completion = completion - self.viewModel = OnboardingEmailConfirmationViewModel(confirmation) - super.init(nibName: nil, bundle: nil) + @Dependency var hud: HUD + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: OnboardingCoordinating + + lazy private var screenView = OnboardingEmailConfirmationView() + lazy private var scrollViewController = ScrollViewController() + + private var cancellables = Set<AnyCancellable>() + private let completion: (UIViewController) -> Void + private var drawerCancellables = Set<AnyCancellable>() + private let viewModel: OnboardingEmailConfirmationViewModel + + public init( + _ confirmation: AttributeConfirmation, + _ completion: @escaping (UIViewController) -> Void + ) { + self.completion = completion + self.viewModel = OnboardingEmailConfirmationViewModel(confirmation) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar.customize(translucent: true) + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupScrollView() + setupBindings() + + screenView.setupSubtitle( + Localized.Onboarding.EmailConfirmation.subtitle(viewModel.confirmation.content) + ) + + screenView.didTapInfo = { [weak self] in + self?.presentInfo( + title: Localized.Onboarding.EmailConfirmation.Info.title, + subtitle: Localized.Onboarding.EmailConfirmation.Info.subtitle + ) } - - required init?(coder: NSCoder) { nil } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationItem.backButtonTitle = "" - statusBarController.style.send(.darkContent) - navigationController?.navigationBar.customize(translucent: true) - } - - public override func viewDidLoad() { - super.viewDidLoad() - setupScrollView() - setupBindings() - - screenView.setupSubtitle( - Localized.Onboarding.EmailConfirmation.subtitle(viewModel.confirmation.content) - ) - - screenView.didTapInfo = { [weak self] in - self?.presentInfo( - title: Localized.Onboarding.EmailConfirmation.Info.title, - subtitle: Localized.Onboarding.EmailConfirmation.Info.subtitle - ) + } + + private func setupScrollView() { + addChild(scrollViewController) + view.addSubview(scrollViewController.view) + scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } + scrollViewController.didMove(toParent: self) + scrollViewController.contentView = screenView + scrollViewController.scrollView.backgroundColor = Asset.neutralWhite.color + } + + private func setupBindings() { + viewModel.hud.receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + screenView.inputField.textPublisher + .sink { [unowned self] in viewModel.didInput($0) } + .store(in: &cancellables) + + viewModel.state + .map(\.status) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in screenView.update(status: $0) } + .store(in: &cancellables) + + screenView.nextButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in viewModel.didTapNext() } + .store(in: &cancellables) + + viewModel.completionPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] _ in completion(self) } + .store(in: &cancellables) + + screenView.resendButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in viewModel.didTapResend() } + .store(in: &cancellables) + + viewModel.state + .map(\.resendDebouncer) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + screenView.resendButton.isEnabled = $0 == 0 + + if $0 == 0 { + screenView.resendButton.setTitle(Localized.Profile.Code.resend(""), for: .normal) + } else { + screenView.resendButton.setTitle(Localized.Profile.Code.resend("(\($0))"), for: .disabled) } - } - - private func setupScrollView() { - addChild(scrollViewController) - view.addSubview(scrollViewController.view) - scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } - scrollViewController.didMove(toParent: self) - scrollViewController.contentView = screenView - scrollViewController.scrollView.backgroundColor = Asset.neutralWhite.color - } - - private func setupBindings() { - viewModel.hud.receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - screenView.inputField.textPublisher - .sink { [unowned self] in viewModel.didInput($0) } - .store(in: &cancellables) - - viewModel.state - .map(\.status) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.update(status: $0) } - .store(in: &cancellables) - - screenView.nextButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in viewModel.didTapNext() } - .store(in: &cancellables) - - viewModel.completionPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] _ in completion(self) } - .store(in: &cancellables) - - screenView.resendButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in viewModel.didTapResend() } - .store(in: &cancellables) - - viewModel.state - .map(\.resendDebouncer) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - screenView.resendButton.isEnabled = $0 == 0 - - if $0 == 0 { - screenView.resendButton.setTitle(Localized.Profile.Code.resend(""), for: .normal) - } else { - screenView.resendButton.setTitle(Localized.Profile.Code.resend("(\($0))"), for: .disabled) - } - }.store(in: &cancellables) - } + }.store(in: &cancellables) + } + + private func presentInfo(title: String, subtitle: String) { + let actionButton = CapsuleButton() + actionButton.set(style: .seeThrough, title: Localized.Settings.InfoDrawer.action) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: subtitle, + color: Asset.neutralBody.color, + alignment: .left, + lineHeightMultiple: 1.1, + spacingAfter: 37 + ), + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) + ]) + + actionButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) - private func presentInfo(title: String, subtitle: String) { - let actionButton = CapsuleButton() - actionButton.set(style: .seeThrough, title: Localized.Settings.InfoDrawer.action) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerText( - font: Fonts.Mulish.regular.font(size: 16.0), - text: subtitle, - color: Asset.neutralBody.color, - alignment: .left, - lineHeightMultiple: 1.1, - spacingAfter: 37 - ), - DrawerStack(views: [ - actionButton, - FlexibleSpace() - ]) - ]) - - actionButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } + coordinator.toDrawer(drawer, from: self) + } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingEmailController.swift b/Sources/OnboardingFeature/Controllers/OnboardingEmailController.swift index 4ff185e789c7d3761e5be4d6aeabdcc8ef272b0b..b3ff6046b302a2ccb6eb1bc8d263fffe16030d17 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingEmailController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingEmailController.swift @@ -1,140 +1,139 @@ import HUD -import DrawerFeature -import Theme import UIKit import Shared import Combine +import DrawerFeature import DependencyInjection import ScrollViewController public final class OnboardingEmailController: UIViewController { - @Dependency private var hud: HUD - @Dependency private var coordinator: OnboardingCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling - - lazy private var screenView = OnboardingEmailView() - lazy private var scrollViewController = ScrollViewController() - - private var cancellables = Set<AnyCancellable>() - private let viewModel = OnboardingEmailViewModel() - private var drawerCancellables = Set<AnyCancellable>() - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - statusBarController.style.send(.darkContent) - navigationController?.navigationBar.customize(translucent: true) + @Dependency var hud: HUD + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: OnboardingCoordinating + + lazy private var screenView = OnboardingEmailView() + lazy private var scrollViewController = ScrollViewController() + + private var cancellables = Set<AnyCancellable>() + private let viewModel = OnboardingEmailViewModel() + private var drawerCancellables = Set<AnyCancellable>() + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar.customize(translucent: true) + } + + public override func viewDidLoad() { + super.viewDidLoad() + navigationItem.backButtonTitle = " " + + setupScrollView() + setupBindings() + + screenView.didTapInfo = { [weak self] in + self?.presentInfo( + title: Localized.Onboarding.Email.Info.title, + subtitle: Localized.Onboarding.Email.Info.subtitle, + urlString: "https://links.xx.network/ud" + ) } - - public override func viewDidLoad() { - super.viewDidLoad() - navigationItem.backButtonTitle = " " - - setupScrollView() - setupBindings() - - screenView.didTapInfo = { [weak self] in - self?.presentInfo( - title: Localized.Onboarding.Email.Info.title, - subtitle: Localized.Onboarding.Email.Info.subtitle, - urlString: "https://links.xx.network/ud" - ) + } + + private func setupScrollView() { + addChild(scrollViewController) + view.addSubview(scrollViewController.view) + scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } + scrollViewController.didMove(toParent: self) + scrollViewController.contentView = screenView + scrollViewController.scrollView.backgroundColor = Asset.neutralWhite.color + } + + private func setupBindings() { + viewModel.hud.receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + screenView.inputField.textPublisher + .sink { [unowned self] in viewModel.didInput($0) } + .store(in: &cancellables) + + screenView.inputField.returnPublisher + .sink { [unowned self] in screenView.inputField.endEditing(true) } + .store(in: &cancellables) + + viewModel.state + .map(\.confirmation) + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [unowned self] in + viewModel.clearUp() + coordinator.toEmailConfirmation(with: $0, from: self) { controller in + let successModel = OnboardingSuccessModel( + title: Localized.Onboarding.Success.Email.title, + subtitle: nil, + nextController: self.coordinator.toPhone(from:) + ) + + self.coordinator.toSuccess(with: successModel, from: controller) } - } - - private func setupScrollView() { - addChild(scrollViewController) - view.addSubview(scrollViewController.view) - scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } - scrollViewController.didMove(toParent: self) - scrollViewController.contentView = screenView - scrollViewController.scrollView.backgroundColor = Asset.neutralWhite.color - } - - private func setupBindings() { - viewModel.hud.receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - screenView.inputField.textPublisher - .sink { [unowned self] in viewModel.didInput($0) } - .store(in: &cancellables) - - screenView.inputField.returnPublisher - .sink { [unowned self] in screenView.inputField.endEditing(true) } - .store(in: &cancellables) - - viewModel.state - .map(\.confirmation) - .receive(on: DispatchQueue.main) - .compactMap { $0 } - .sink { [unowned self] in - viewModel.clearUp() - coordinator.toEmailConfirmation(with: $0, from: self) { controller in - let successModel = OnboardingSuccessModel( - title: Localized.Onboarding.Success.Email.title, - subtitle: nil, - nextController: self.coordinator.toPhone(from:) - ) - - self.coordinator.toSuccess(with: successModel, from: controller) - } - }.store(in: &cancellables) - - viewModel.state - .map(\.status) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.update(status: $0) } - .store(in: &cancellables) - - screenView.nextButton.publisher(for: .touchUpInside) - .sink { [unowned self] in viewModel.didTapNext() } - .store(in: &cancellables) - - screenView.skipButton.publisher(for: .touchUpInside) - .sink { [unowned self] in coordinator.toPhone(from: self) } - .store(in: &cancellables) - } + }.store(in: &cancellables) + + viewModel.state + .map(\.status) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in screenView.update(status: $0) } + .store(in: &cancellables) + + screenView.nextButton.publisher(for: .touchUpInside) + .sink { [unowned self] in viewModel.didTapNext() } + .store(in: &cancellables) + + screenView.skipButton.publisher(for: .touchUpInside) + .sink { [unowned self] in coordinator.toPhone(from: self) } + .store(in: &cancellables) + } + + private func presentInfo( + title: String, + subtitle: String, + urlString: String = "" + ) { + let actionButton = CapsuleButton() + actionButton.set( + style: .seeThrough, + title: Localized.Settings.InfoDrawer.action + ) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerLinkText( + text: subtitle, + urlString: urlString, + spacingAfter: 37 + ), + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) + ]) + + actionButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) - private func presentInfo( - title: String, - subtitle: String, - urlString: String = "" - ) { - let actionButton = CapsuleButton() - actionButton.set( - style: .seeThrough, - title: Localized.Settings.InfoDrawer.action - ) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerLinkText( - text: subtitle, - urlString: urlString, - spacingAfter: 37 - ), - DrawerStack(views: [ - actionButton, - FlexibleSpace() - ]) - ]) - - actionButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } + coordinator.toDrawer(drawer, from: self) + } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift b/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift index 6207a5a7c9b19b1a5184557eb45cd07bd8aeb21b..1fb93ad4f023b0e18fa8c1a52b8ffd3c30d759cc 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift @@ -1,152 +1,151 @@ import HUD -import DrawerFeature -import Theme import UIKit import Shared +import Models import Combine +import DrawerFeature import DependencyInjection import ScrollViewController -import Models public final class OnboardingPhoneConfirmationController: UIViewController { - @Dependency private var hud: HUD - @Dependency private var coordinator: OnboardingCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling - - lazy private var screenView = OnboardingPhoneConfirmationView() - lazy private var scrollViewController = ScrollViewController() - - private var cancellables = Set<AnyCancellable>() - private let completion: (UIViewController) -> Void - private var drawerCancellables = Set<AnyCancellable>() - private let viewModel: OnboardingPhoneConfirmationViewModel - - public init( - _ confirmation: AttributeConfirmation, - _ completion: @escaping (UIViewController) -> Void - ) { - self.completion = completion - self.viewModel = OnboardingPhoneConfirmationViewModel(confirmation) - super.init(nibName: nil, bundle: nil) + @Dependency var hud: HUD + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: OnboardingCoordinating + + lazy private var screenView = OnboardingPhoneConfirmationView() + lazy private var scrollViewController = ScrollViewController() + + private var cancellables = Set<AnyCancellable>() + private let completion: (UIViewController) -> Void + private var drawerCancellables = Set<AnyCancellable>() + private let viewModel: OnboardingPhoneConfirmationViewModel + + public init( + _ confirmation: AttributeConfirmation, + _ completion: @escaping (UIViewController) -> Void + ) { + self.completion = completion + self.viewModel = OnboardingPhoneConfirmationViewModel(confirmation) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar.customize(translucent: true) + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupScrollView() + setupBindings() + + screenView.setupSubtitle( + Localized.Onboarding.PhoneConfirmation.subtitle(viewModel.confirmation.content) + ) + + screenView.didTapInfo = { [weak self] in + self?.presentInfo( + title: Localized.Onboarding.PhoneConfirmation.Info.title, + subtitle: Localized.Onboarding.PhoneConfirmation.Info.subtitle + ) } - - required init?(coder: NSCoder) { nil } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationItem.backButtonTitle = "" - statusBarController.style.send(.darkContent) - navigationController?.navigationBar.customize(translucent: true) - } - - public override func viewDidLoad() { - super.viewDidLoad() - setupScrollView() - setupBindings() - - screenView.setupSubtitle( - Localized.Onboarding.PhoneConfirmation.subtitle(viewModel.confirmation.content) - ) - - screenView.didTapInfo = { [weak self] in - self?.presentInfo( - title: Localized.Onboarding.PhoneConfirmation.Info.title, - subtitle: Localized.Onboarding.PhoneConfirmation.Info.subtitle - ) + } + + private func setupScrollView() { + addChild(scrollViewController) + view.addSubview(scrollViewController.view) + scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } + scrollViewController.didMove(toParent: self) + scrollViewController.contentView = screenView + scrollViewController.scrollView.backgroundColor = Asset.neutralWhite.color + } + + private func setupBindings() { + viewModel.hud.receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + screenView.inputField.textPublisher + .sink { [unowned self] in viewModel.didInput($0) } + .store(in: &cancellables) + + viewModel.state + .map(\.status) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in screenView.update(status: $0) } + .store(in: &cancellables) + + screenView.nextButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in viewModel.didTapNext() } + .store(in: &cancellables) + + viewModel.completionPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] _ in completion(self) } + .store(in: &cancellables) + + screenView.resendButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in viewModel.didTapResend() } + .store(in: &cancellables) + + viewModel.state + .map(\.resendDebouncer) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + screenView.resendButton.isEnabled = $0 == 0 + + if $0 == 0 { + screenView.resendButton.setTitle(Localized.Profile.Code.resend(""), for: .normal) + } else { + screenView.resendButton.setTitle(Localized.Profile.Code.resend("(\($0))"), for: .disabled) } - } - - private func setupScrollView() { - addChild(scrollViewController) - view.addSubview(scrollViewController.view) - scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } - scrollViewController.didMove(toParent: self) - scrollViewController.contentView = screenView - scrollViewController.scrollView.backgroundColor = Asset.neutralWhite.color - } - - private func setupBindings() { - viewModel.hud.receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - screenView.inputField.textPublisher - .sink { [unowned self] in viewModel.didInput($0) } - .store(in: &cancellables) - - viewModel.state - .map(\.status) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.update(status: $0) } - .store(in: &cancellables) - - screenView.nextButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in viewModel.didTapNext() } - .store(in: &cancellables) - - viewModel.completionPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] _ in completion(self) } - .store(in: &cancellables) - - screenView.resendButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in viewModel.didTapResend() } - .store(in: &cancellables) - - viewModel.state - .map(\.resendDebouncer) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - screenView.resendButton.isEnabled = $0 == 0 - - if $0 == 0 { - screenView.resendButton.setTitle(Localized.Profile.Code.resend(""), for: .normal) - } else { - screenView.resendButton.setTitle(Localized.Profile.Code.resend("(\($0))"), for: .disabled) - } - }.store(in: &cancellables) - } + }.store(in: &cancellables) + } + + private func presentInfo(title: String, subtitle: String) { + let actionButton = CapsuleButton() + actionButton.set(style: .seeThrough, title: Localized.Settings.InfoDrawer.action) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: subtitle, + color: Asset.neutralBody.color, + alignment: .left, + lineHeightMultiple: 1.1, + spacingAfter: 37 + ), + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) + ]) + + actionButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) - private func presentInfo(title: String, subtitle: String) { - let actionButton = CapsuleButton() - actionButton.set(style: .seeThrough, title: Localized.Settings.InfoDrawer.action) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerText( - font: Fonts.Mulish.regular.font(size: 16.0), - text: subtitle, - color: Asset.neutralBody.color, - alignment: .left, - lineHeightMultiple: 1.1, - spacingAfter: 37 - ), - DrawerStack(views: [ - actionButton, - FlexibleSpace() - ]) - ]) - - actionButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } + coordinator.toDrawer(drawer, from: self) + } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingPhoneController.swift b/Sources/OnboardingFeature/Controllers/OnboardingPhoneController.swift index 4686825bbeba8c9e599602f7c62453c3f23cffee..e25481b405aabf394245fde4598a44d4a548cd3c 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingPhoneController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingPhoneController.swift @@ -1,153 +1,152 @@ import HUD -import DrawerFeature -import Theme import UIKit import Shared import Combine +import DrawerFeature import DependencyInjection import ScrollViewController public final class OnboardingPhoneController: UIViewController { - @Dependency private var hud: HUD - @Dependency private var coordinator: OnboardingCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling - - lazy private var screenView = OnboardingPhoneView() - lazy private var scrollViewController = ScrollViewController() - - private var cancellables = Set<AnyCancellable>() - private let viewModel = OnboardingPhoneViewModel() - private var drawerCancellables = Set<AnyCancellable>() - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - statusBarController.style.send(.darkContent) - navigationController?.navigationBar.customize(translucent: true) + @Dependency var hud: HUD + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: OnboardingCoordinating + + lazy private var screenView = OnboardingPhoneView() + lazy private var scrollViewController = ScrollViewController() + + private var cancellables = Set<AnyCancellable>() + private let viewModel = OnboardingPhoneViewModel() + private var drawerCancellables = Set<AnyCancellable>() + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar.customize(translucent: true) + } + + public override func viewDidLoad() { + super.viewDidLoad() + navigationItem.backButtonTitle = " " + + setupScrollView() + setupBindings() + + screenView.didTapInfo = { [weak self] in + self?.presentInfo( + title: Localized.Onboarding.Phone.Info.title, + subtitle: Localized.Onboarding.Phone.Info.subtitle, + urlString: "https://links.xx.network/ud" + ) } - - public override func viewDidLoad() { - super.viewDidLoad() - navigationItem.backButtonTitle = " " - - setupScrollView() - setupBindings() - - screenView.didTapInfo = { [weak self] in - self?.presentInfo( - title: Localized.Onboarding.Phone.Info.title, - subtitle: Localized.Onboarding.Phone.Info.subtitle, - urlString: "https://links.xx.network/ud" - ) + } + + private func setupScrollView() { + addChild(scrollViewController) + view.addSubview(scrollViewController.view) + scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } + scrollViewController.didMove(toParent: self) + scrollViewController.contentView = screenView + scrollViewController.scrollView.backgroundColor = Asset.neutralWhite.color + } + + private func setupBindings() { + viewModel.hud + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + screenView.inputField.textPublisher + .sink { [unowned self] in viewModel.didInput($0) } + .store(in: &cancellables) + + screenView.inputField.returnPublisher + .sink { [unowned self] in screenView.inputField.endEditing(true) } + .store(in: &cancellables) + + viewModel.state.map(\.status) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in screenView.update(status: $0) } + .store(in: &cancellables) + + screenView.nextButton.publisher(for: .touchUpInside) + .sink { [unowned self] in viewModel.didTapNext() } + .store(in: &cancellables) + + screenView.skipButton.publisher(for: .touchUpInside) + .sink { [unowned self] in coordinator.toChats(from: self) } + .store(in: &cancellables) + + screenView.inputField.codePublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + coordinator.toCountries(from: self) { self.viewModel.didChooseCountry($0) } + }.store(in: &cancellables) + + viewModel.state.map(\.confirmation) + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [unowned self] in + viewModel.clearUp() + coordinator.toPhoneConfirmation(with: $0, from: self) { controller in + let successModel = OnboardingSuccessModel( + title: Localized.Onboarding.Success.Phone.title, + subtitle: nil, + nextController: self.coordinator.toChats(from:) + ) + + self.coordinator.toSuccess(with: successModel, from: controller) } - } - - private func setupScrollView() { - addChild(scrollViewController) - view.addSubview(scrollViewController.view) - scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } - scrollViewController.didMove(toParent: self) - scrollViewController.contentView = screenView - scrollViewController.scrollView.backgroundColor = Asset.neutralWhite.color - } - - private func setupBindings() { - viewModel.hud - .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - screenView.inputField.textPublisher - .sink { [unowned self] in viewModel.didInput($0) } - .store(in: &cancellables) - - screenView.inputField.returnPublisher - .sink { [unowned self] in screenView.inputField.endEditing(true) } - .store(in: &cancellables) - - viewModel.state.map(\.status) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.update(status: $0) } - .store(in: &cancellables) - - screenView.nextButton.publisher(for: .touchUpInside) - .sink { [unowned self] in viewModel.didTapNext() } - .store(in: &cancellables) - - screenView.skipButton.publisher(for: .touchUpInside) - .sink { [unowned self] in coordinator.toChats(from: self) } - .store(in: &cancellables) - - screenView.inputField.codePublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - coordinator.toCountries(from: self) { self.viewModel.didChooseCountry($0) } - }.store(in: &cancellables) - - viewModel.state.map(\.confirmation) - .receive(on: DispatchQueue.main) - .compactMap { $0 } - .sink { [unowned self] in - viewModel.clearUp() - coordinator.toPhoneConfirmation(with: $0, from: self) { controller in - let successModel = OnboardingSuccessModel( - title: Localized.Onboarding.Success.Phone.title, - subtitle: nil, - nextController: self.coordinator.toChats(from:) - ) - - self.coordinator.toSuccess(with: successModel, from: controller) - } - }.store(in: &cancellables) - - viewModel.state.map(\.country) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - screenView.inputField.set(prefix: $0.prefixWithFlag) - screenView.inputField.update(placeholder: $0.example) - }.store(in: &cancellables) - } + }.store(in: &cancellables) + + viewModel.state.map(\.country) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + screenView.inputField.set(prefix: $0.prefixWithFlag) + screenView.inputField.update(placeholder: $0.example) + }.store(in: &cancellables) + } + + private func presentInfo( + title: String, + subtitle: String, + urlString: String = "" + ) { + let actionButton = CapsuleButton() + actionButton.set( + style: .seeThrough, + title: Localized.Settings.InfoDrawer.action + ) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerLinkText( + text: subtitle, + urlString: urlString, + spacingAfter: 37 + ), + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) + ]) + + actionButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) - private func presentInfo( - title: String, - subtitle: String, - urlString: String = "" - ) { - let actionButton = CapsuleButton() - actionButton.set( - style: .seeThrough, - title: Localized.Settings.InfoDrawer.action - ) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerLinkText( - text: subtitle, - urlString: urlString, - spacingAfter: 37 - ), - DrawerStack(views: [ - actionButton, - FlexibleSpace() - ]) - ]) - - actionButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } + coordinator.toDrawer(drawer, from: self) + } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingStartController.swift b/Sources/OnboardingFeature/Controllers/OnboardingStartController.swift index 3fce498d995b7bbabaf62517313b1705c9996b1b..a902a8d2e68656aa4b920a55e8c11c0012abc87b 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingStartController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingStartController.swift @@ -1,6 +1,5 @@ import HUD import UIKit -import Theme import Shared import Combine import DependencyInjection diff --git a/Sources/OnboardingFeature/Controllers/OnboardingSuccessController.swift b/Sources/OnboardingFeature/Controllers/OnboardingSuccessController.swift index a260afa9a7c69369f0bf9a9510c851ebe2e20313..d072efad04cb9446f229614025c1adc515db9f2f 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingSuccessController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingSuccessController.swift @@ -1,5 +1,4 @@ import UIKit -import Theme import Models import Shared import Combine diff --git a/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift b/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift index fd349efb4f4acfba8e6538e5892a1bf3025d5617..2b139fad43b2f00f23a2834289952673efbbfb0b 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift @@ -1,5 +1,4 @@ import HUD -import Theme import UIKit import Shared import Combine @@ -8,131 +7,131 @@ import DependencyInjection import ScrollViewController public final class OnboardingUsernameController: UIViewController { - @Dependency private var hud: HUD - @Dependency private var coordinator: OnboardingCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling - - lazy private var screenView = OnboardingUsernameView() - lazy private var scrollViewController = ScrollViewController() - - private var cancellables = Set<AnyCancellable>() - private let viewModel = OnboardingUsernameViewModel() - private var drawerCancellables = Set<AnyCancellable>() - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationItem.backButtonTitle = "" - statusBarController.style.send(.darkContent) - navigationController?.navigationBar.customize(translucent: true) + @Dependency var hud: HUD + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: OnboardingCoordinating + + lazy private var screenView = OnboardingUsernameView() + lazy private var scrollViewController = ScrollViewController() + + private var cancellables = Set<AnyCancellable>() + private let viewModel = OnboardingUsernameViewModel() + private var drawerCancellables = Set<AnyCancellable>() + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar.customize(translucent: true) + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupScrollView() + setupBindings() + + screenView.didTapInfo = { [weak self] in + self?.presentInfo( + title: Localized.Onboarding.Username.Info.title, + subtitle: Localized.Onboarding.Username.Info.subtitle, + urlString: "https://links.xx.network/ud" + ) } - - public override func viewDidLoad() { - super.viewDidLoad() - setupScrollView() - setupBindings() - - screenView.didTapInfo = { [weak self] in - self?.presentInfo( - title: Localized.Onboarding.Username.Info.title, - subtitle: Localized.Onboarding.Username.Info.subtitle, - urlString: "https://links.xx.network/ud" - ) + } + + private func setupScrollView() { + scrollViewController.scrollView.backgroundColor = .white + + addChild(scrollViewController) + view.addSubview(scrollViewController.view) + scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } + scrollViewController.didMove(toParent: self) + scrollViewController.contentView = screenView + } + + private func setupBindings() { + viewModel.hud + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + screenView.inputField.textPublisher + .removeDuplicates() + .compactMap { $0 } + .sink { [unowned self] in viewModel.didInput($0) } + .store(in: &cancellables) + + screenView.restoreView.restoreButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toRestoreList(from: self) } + .store(in: &cancellables) + + screenView.inputField.returnPublisher + .sink { [unowned self] in + if screenView.nextButton.isEnabled { + viewModel.didTapRegister() + } else { + screenView.inputField.endEditing(true) } - } - - private func setupScrollView() { - scrollViewController.scrollView.backgroundColor = .white - - addChild(scrollViewController) - view.addSubview(scrollViewController.view) - scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } - scrollViewController.didMove(toParent: self) - scrollViewController.contentView = screenView - } - - private func setupBindings() { - viewModel.hud - .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - screenView.inputField.textPublisher - .removeDuplicates() - .compactMap { $0 } - .sink { [unowned self] in viewModel.didInput($0) } - .store(in: &cancellables) - - screenView.restoreView.restoreButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toRestoreList(from: self) } - .store(in: &cancellables) - - screenView.inputField.returnPublisher - .sink { [unowned self] in - if screenView.nextButton.isEnabled { - viewModel.didTapRegister() - } else { - screenView.inputField.endEditing(true) - } - }.store(in: &cancellables) - - screenView.nextButton.publisher(for: .touchUpInside) - .sink { [unowned self] in viewModel.didTapRegister() } - .store(in: &cancellables) - - viewModel.greenPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toWelcome(from: self) } - .store(in: &cancellables) - - viewModel.state - .map(\.status) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.update(status: $0) } - .store(in: &cancellables) - } + }.store(in: &cancellables) + + screenView.nextButton.publisher(for: .touchUpInside) + .sink { [unowned self] in viewModel.didTapRegister() } + .store(in: &cancellables) + + viewModel.greenPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toWelcome(from: self) } + .store(in: &cancellables) + + viewModel.state + .map(\.status) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in screenView.update(status: $0) } + .store(in: &cancellables) + } + + private func presentInfo( + title: String, + subtitle: String, + urlString: String = "" + ) { + let actionButton = CapsuleButton() + actionButton.set( + style: .seeThrough, + title: Localized.Settings.InfoDrawer.action + ) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerLinkText( + text: subtitle, + urlString: urlString, + spacingAfter: 37 + ), + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) + ]) + + actionButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) - private func presentInfo( - title: String, - subtitle: String, - urlString: String = "" - ) { - let actionButton = CapsuleButton() - actionButton.set( - style: .seeThrough, - title: Localized.Settings.InfoDrawer.action - ) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerLinkText( - text: subtitle, - urlString: urlString, - spacingAfter: 37 - ), - DrawerStack(views: [ - actionButton, - FlexibleSpace() - ]) - ]) - - actionButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } + coordinator.toDrawer(drawer, from: self) + } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingWelcomeController.swift b/Sources/OnboardingFeature/Controllers/OnboardingWelcomeController.swift index 6def35a43a53695ff916c62761f73f3091966af8..9bed33d2acdb20c9e684c1614583bff9f8c129f6 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingWelcomeController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingWelcomeController.swift @@ -1,4 +1,3 @@ -import Theme import UIKit import Shared import Combine @@ -7,89 +6,90 @@ import DrawerFeature import DependencyInjection public final class OnboardingWelcomeController: UIViewController { - @KeyObject(.username, defaultValue: "") var username: String - @Dependency private var coordinator: OnboardingCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling + @KeyObject(.username, defaultValue: "") var username: String - lazy private var screenView = OnboardingWelcomeView() + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: OnboardingCoordinating - private var cancellables = Set<AnyCancellable>() - private var drawerCancellables = Set<AnyCancellable>() + lazy private var screenView = OnboardingWelcomeView() - public override func loadView() { - view = screenView - } + private var cancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - statusBarController.style.send(.darkContent) - navigationController?.navigationBar.customize(translucent: true) - } + public override func loadView() { + view = screenView + } - public override func viewDidLoad() { - super.viewDidLoad() - setupBindings() + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar.customize(translucent: true) + } - screenView.setupTitle(Localized.Onboarding.Welcome.title(username)) + public override func viewDidLoad() { + super.viewDidLoad() + setupBindings() - screenView.didTapInfo = { [weak self] in - self?.presentInfo( - title: Localized.Onboarding.Welcome.Info.title, - subtitle: Localized.Onboarding.Welcome.Info.subtitle, - urlString: "https://links.xx.network/ud" - ) - } + screenView.setupTitle(Localized.Onboarding.Welcome.title(username)) + + screenView.didTapInfo = { [weak self] in + self?.presentInfo( + title: Localized.Onboarding.Welcome.Info.title, + subtitle: Localized.Onboarding.Welcome.Info.subtitle, + urlString: "https://links.xx.network/ud" + ) } + } - private func setupBindings() { - screenView.continueButton.publisher(for: .touchUpInside) - .sink { [unowned self] in coordinator.toEmail(from: self) } - .store(in: &cancellables) + private func setupBindings() { + screenView.continueButton.publisher(for: .touchUpInside) + .sink { [unowned self] in coordinator.toEmail(from: self) } + .store(in: &cancellables) - screenView.skipButton.publisher(for: .touchUpInside) - .sink { [unowned self] in coordinator.toChats(from: self) } - .store(in: &cancellables) - } + screenView.skipButton.publisher(for: .touchUpInside) + .sink { [unowned self] in coordinator.toChats(from: self) } + .store(in: &cancellables) + } - private func presentInfo( - title: String, - subtitle: String, - urlString: String = "" - ) { - let actionButton = CapsuleButton() - actionButton.set( - style: .seeThrough, - title: Localized.Settings.InfoDrawer.action - ) + private func presentInfo( + title: String, + subtitle: String, + urlString: String = "" + ) { + let actionButton = CapsuleButton() + actionButton.set( + style: .seeThrough, + title: Localized.Settings.InfoDrawer.action + ) - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerLinkText( - text: subtitle, - urlString: urlString, - spacingAfter: 37 - ), - DrawerStack(views: [ - actionButton, - FlexibleSpace() - ]) - ]) + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerLinkText( + text: subtitle, + urlString: urlString, + spacingAfter: 37 + ), + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) + ]) - actionButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) + actionButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) - coordinator.toDrawer(drawer, from: self) - } + coordinator.toDrawer(drawer, from: self) + } } diff --git a/Sources/Permissions/RequestPermissionController.swift b/Sources/Permissions/RequestPermissionController.swift index d892ab60e8722633e8d7c2930eb7ec97b4049d8c..6aa33347a74af25cd0abba5e1a30211bda19ac84 100644 --- a/Sources/Permissions/RequestPermissionController.swift +++ b/Sources/Permissions/RequestPermissionController.swift @@ -1,100 +1,99 @@ import UIKit -import Theme import Shared import Combine import DependencyInjection public enum PermissionType { - case camera - case library - case microphone + case camera + case library + case microphone } public final class RequestPermissionController: UIViewController { - @Dependency private var permissions: PermissionHandling - @Dependency private var statusBarController: StatusBarStyleControlling + @Dependency var barStylist: StatusBarStylist + @Dependency var permissions: PermissionHandling - lazy private var screenView = RequestPermissionView() + lazy private var screenView = RequestPermissionView() - private var type: PermissionType! - private var cancellables = Set<AnyCancellable>() + private var type: PermissionType! + private var cancellables = Set<AnyCancellable>() - public override func loadView() { - view = screenView - } + public override func loadView() { + view = screenView + } - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationItem.backButtonTitle = "" - statusBarController.style.send(.darkContent) - navigationController?.navigationBar.customize(backgroundColor: Asset.neutralWhite.color) - } + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar.customize(backgroundColor: Asset.neutralWhite.color) + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupBindings() + } + + public func setup(type: PermissionType) { + self.type = type - public override func viewDidLoad() { - super.viewDidLoad() - setupBindings() + switch type { + case .camera: + screenView.setup( + title: Localized.Chat.Actions.Permission.Camera.title, + subtitle: Localized.Chat.Actions.Permission.Camera.subtitle, + image: Asset.permissionCamera.image + ) + case .library: + screenView.setup( + title: Localized.Chat.Actions.Permission.Library.title, + subtitle: Localized.Chat.Actions.Permission.Library.subtitle, + image: Asset.permissionLibrary.image + ) + case .microphone: + screenView.setup( + title: Localized.Chat.Actions.Permission.Microphone.title, + subtitle: Localized.Chat.Actions.Permission.Microphone.subtitle, + image: Asset.permissionMicrophone.image + ) } + } - public func setup(type: PermissionType) { - self.type = type + private func setupBindings() { + screenView.notNowButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.navigationController?.popViewController(animated: true) + }.store(in: &cancellables) + screenView.continueButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in switch type { case .camera: - screenView.setup( - title: Localized.Chat.Actions.Permission.Camera.title, - subtitle: Localized.Chat.Actions.Permission.Camera.subtitle, - image: Asset.permissionCamera.image - ) + permissions.requestCamera { [weak self] _ in + DispatchQueue.main.async { + self?.navigationController?.popViewController(animated: true) + } + } case .library: - screenView.setup( - title: Localized.Chat.Actions.Permission.Library.title, - subtitle: Localized.Chat.Actions.Permission.Library.subtitle, - image: Asset.permissionLibrary.image - ) + permissions.requestPhotos { [weak self] _ in + DispatchQueue.main.async { + self?.navigationController?.popViewController(animated: true) + } + } case .microphone: - screenView.setup( - title: Localized.Chat.Actions.Permission.Microphone.title, - subtitle: Localized.Chat.Actions.Permission.Microphone.subtitle, - image: Asset.permissionMicrophone.image - ) + permissions.requestMicrophone { [weak self] _ in + DispatchQueue.main.async { + self?.navigationController?.popViewController(animated: true) + } + } + case .none: + break } - } - - private func setupBindings() { - screenView.notNowButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [weak self] in - self?.navigationController?.popViewController(animated: true) - }.store(in: &cancellables) - - screenView.continueButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - switch type { - case .camera: - permissions.requestCamera { [weak self] _ in - DispatchQueue.main.async { - self?.navigationController?.popViewController(animated: true) - } - } - case .library: - permissions.requestPhotos { [weak self] _ in - DispatchQueue.main.async { - self?.navigationController?.popViewController(animated: true) - } - } - case .microphone: - permissions.requestMicrophone { [weak self] _ in - DispatchQueue.main.async { - self?.navigationController?.popViewController(animated: true) - } - } - case .none: - break - } - }.store(in: &cancellables) - } + }.store(in: &cancellables) + } } diff --git a/Sources/Presentation/Presenting.swift b/Sources/Presentation/Presenting.swift index 1baf98d11fe94e90217c41b39c081175cd00bcb9..7de9932742a7f81f90e0dab7ba1f958d36e86757 100644 --- a/Sources/Presentation/Presenting.swift +++ b/Sources/Presentation/Presenting.swift @@ -1,5 +1,5 @@ import UIKit -import Theme +import Shared public protocol Presenting { func present(_ target: UIViewController..., from parent: UIViewController) @@ -24,7 +24,7 @@ public struct ModalPresenter: Presenting { public init() {} public func present(_ target: UIViewController..., from parent: UIViewController) { - let statusBarVC = StatusBarViewController(target.first!) + let statusBarVC = RootViewController(target.first!) statusBarVC.modalPresentationStyle = .fullScreen parent.present(statusBarVC, animated: true) } diff --git a/Sources/ProfileFeature/Controllers/ProfileController.swift b/Sources/ProfileFeature/Controllers/ProfileController.swift index 750c5342b31ddad18660f94aa784f3b22ef520b3..f6b04009f92f47c91d586ff848b55e835f0e837b 100644 --- a/Sources/ProfileFeature/Controllers/ProfileController.swift +++ b/Sources/ProfileFeature/Controllers/ProfileController.swift @@ -1,225 +1,224 @@ import HUD -import DrawerFeature import UIKit -import Theme import Shared import Combine +import DrawerFeature import DependencyInjection public final class ProfileController: UIViewController { - @Dependency private var hud: HUD - @Dependency private var coordinator: ProfileCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling - - lazy private var screenView = ProfileView() - - private let viewModel = ProfileViewModel() - private var cancellables = Set<AnyCancellable>() - private var drawerCancellables = Set<AnyCancellable>() - - public override func loadView() { - view = screenView - } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - statusBarController.style.send(.lightContent) - navigationController?.navigationBar - .customize(backgroundColor: Asset.neutralBody.color) - viewModel.refresh() - } - - public override func viewDidLoad() { - super.viewDidLoad() - screenView.cardComponent.nameLabel.text = viewModel.username! - setupNavigationBar() - setupBindings() - } - - private func setupNavigationBar() { - navigationItem.backButtonTitle = "" - - let menuButton = UIButton() - menuButton.tintColor = Asset.neutralWhite.color - menuButton.setImage(Asset.chatListMenu.image, for: .normal) - menuButton.addTarget(self, action: #selector(didTapMenu), for: .touchUpInside) - menuButton.snp.makeConstraints { $0.width.equalTo(50) } - - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: menuButton) - } - - private func setupBindings() { - viewModel.hud - .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - screenView.emailView.actionButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - if screenView.emailView.currentValue != nil { - presentDrawer( - title: Localized.Profile.Delete.title( - Localized.Profile.Email.title.capitalized - ), - subtitle: Localized.Profile.Delete.subtitle( - Localized.Profile.Email.title.lowercased(), Localized.Profile.Email.title.lowercased() - ), - actionTitle: Localized.Profile.Delete.action( - Localized.Profile.Email.title - )) { - self.viewModel.didTapDelete(isEmail: true) - } - } else { - coordinator.toEmail(from: self) - } - }.store(in: &cancellables) - - screenView.phoneView.actionButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - if screenView.phoneView.currentValue != nil { - presentDrawer( - title: Localized.Profile.Delete.title( - Localized.Profile.Phone.title.capitalized - ), - subtitle: Localized.Profile.Delete.subtitle( - Localized.Profile.Phone.title.lowercased(), Localized.Profile.Phone.title.lowercased() - ), - actionTitle: Localized.Profile.Delete.action( - Localized.Profile.Phone.title - )) { - self.viewModel.didTapDelete(isEmail: false) - } - } else { - coordinator.toPhone(from: self) - } - }.store(in: &cancellables) - - screenView.cardComponent.avatarView.editButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in viewModel.didRequestLibraryAccess() } - .store(in: &cancellables) - - viewModel.navigation - .receive(on: DispatchQueue.main) - .removeDuplicates() - .sink { [unowned self] in - switch $0 { - case .library: - presentDrawer( - title: Localized.Profile.Photo.title, - subtitle: Localized.Profile.Photo.subtitle, - actionTitle: Localized.Profile.Photo.continue) { - self.coordinator.toPhotos(from: self) - } - case .libraryPermission: - coordinator.toPermission(type: .library, from: self) - case .none: - break - } - - viewModel.didNavigateSomewhere() - }.store(in: &cancellables) - - viewModel.state - .map(\.email) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.emailView.set(value: $0) } - .store(in: &cancellables) - - viewModel.state - .map(\.phone) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.phoneView.set(value: $0) } - .store(in: &cancellables) - - viewModel.state - .map(\.photo) - .compactMap { $0 } - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.cardComponent.image = $0 } - .store(in: &cancellables) - } - - private func presentDrawer( - title: String, - subtitle: String, - actionTitle: String, - action: @escaping () -> Void - ) { - let actionButton = DrawerCapsuleButton(model: .init( - title: actionTitle, - style: .red - )) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 + @Dependency var hud: HUD + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: ProfileCoordinating + + lazy private var screenView = ProfileView() + + private let viewModel = ProfileViewModel() + private var cancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() + + public override func loadView() { + view = screenView + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + barStylist.styleSubject.send(.lightContent) + navigationController?.navigationBar + .customize(backgroundColor: Asset.neutralBody.color) + viewModel.refresh() + } + + public override func viewDidLoad() { + super.viewDidLoad() + screenView.cardComponent.nameLabel.text = viewModel.username! + setupNavigationBar() + setupBindings() + } + + private func setupNavigationBar() { + navigationItem.backButtonTitle = "" + + let menuButton = UIButton() + menuButton.tintColor = Asset.neutralWhite.color + menuButton.setImage(Asset.chatListMenu.image, for: .normal) + menuButton.addTarget(self, action: #selector(didTapMenu), for: .touchUpInside) + menuButton.snp.makeConstraints { $0.width.equalTo(50) } + + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: menuButton) + } + + private func setupBindings() { + viewModel.hud + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + screenView.emailView.actionButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + if screenView.emailView.currentValue != nil { + presentDrawer( + title: Localized.Profile.Delete.title( + Localized.Profile.Email.title.capitalized ), - DrawerText( - font: Fonts.Mulish.regular.font(size: 16.0), - text: subtitle, - color: Asset.neutralBody.color, - alignment: .left, - lineHeightMultiple: 1.1, - spacingAfter: 37 + subtitle: Localized.Profile.Delete.subtitle( + Localized.Profile.Email.title.lowercased(), Localized.Profile.Email.title.lowercased() ), - actionButton - ]) - - actionButton.action - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() + actionTitle: Localized.Profile.Delete.action( + Localized.Profile.Email.title + )) { + self.viewModel.didTapDelete(isEmail: true) + } + } else { + coordinator.toEmail(from: self) + } + }.store(in: &cancellables) + + screenView.phoneView.actionButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + if screenView.phoneView.currentValue != nil { + presentDrawer( + title: Localized.Profile.Delete.title( + Localized.Profile.Phone.title.capitalized + ), + subtitle: Localized.Profile.Delete.subtitle( + Localized.Profile.Phone.title.lowercased(), Localized.Profile.Phone.title.lowercased() + ), + actionTitle: Localized.Profile.Delete.action( + Localized.Profile.Phone.title + )) { + self.viewModel.didTapDelete(isEmail: false) + } + } else { + coordinator.toPhone(from: self) + } + }.store(in: &cancellables) + + screenView.cardComponent.avatarView.editButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in viewModel.didRequestLibraryAccess() } + .store(in: &cancellables) + + viewModel.navigation + .receive(on: DispatchQueue.main) + .removeDuplicates() + .sink { [unowned self] in + switch $0 { + case .library: + presentDrawer( + title: Localized.Profile.Photo.title, + subtitle: Localized.Profile.Photo.subtitle, + actionTitle: Localized.Profile.Photo.continue) { + self.coordinator.toPhotos(from: self) + } + case .libraryPermission: + coordinator.toPermission(type: .library, from: self) + case .none: + break + } - action() - } - }.store(in: &drawerCancellables) + viewModel.didNavigateSomewhere() + }.store(in: &cancellables) + + viewModel.state + .map(\.email) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in screenView.emailView.set(value: $0) } + .store(in: &cancellables) + + viewModel.state + .map(\.phone) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in screenView.phoneView.set(value: $0) } + .store(in: &cancellables) + + viewModel.state + .map(\.photo) + .compactMap { $0 } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in screenView.cardComponent.image = $0 } + .store(in: &cancellables) + } + + private func presentDrawer( + title: String, + subtitle: String, + actionTitle: String, + action: @escaping () -> Void + ) { + let actionButton = DrawerCapsuleButton(model: .init( + title: actionTitle, + style: .red + )) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: subtitle, + color: Asset.neutralBody.color, + alignment: .left, + lineHeightMultiple: 1.1, + spacingAfter: 37 + ), + actionButton + ]) + + actionButton.action + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + + action() + } + }.store(in: &drawerCancellables) - coordinator.toDrawer(drawer, from: self) - } + coordinator.toDrawer(drawer, from: self) + } - @objc private func didTapMenu() { - coordinator.toSideMenu(from: self) - } + @objc private func didTapMenu() { + coordinator.toSideMenu(from: self) + } } extension ProfileController: UIImagePickerControllerDelegate { - public func imagePickerController( - _ picker: UIImagePickerController, - didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any] - ) { - var image: UIImage? - - if let originalImage = info[.originalImage] as? UIImage { - image = originalImage - } - - if let croppedImage = info[.editedImage] as? UIImage { - image = croppedImage - } + public func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any] + ) { + var image: UIImage? + + if let originalImage = info[.originalImage] as? UIImage { + image = originalImage + } - guard let image = image else { - picker.dismiss(animated: true) - return - } + if let croppedImage = info[.editedImage] as? UIImage { + image = croppedImage + } - picker.dismiss(animated: true) - viewModel.didChoosePhoto(image) + guard let image = image else { + picker.dismiss(animated: true) + return } + + picker.dismiss(animated: true) + viewModel.didChoosePhoto(image) + } } extension ProfileController: UINavigationControllerDelegate {} diff --git a/Sources/ProfileFeature/Controllers/ProfileEmailController.swift b/Sources/ProfileFeature/Controllers/ProfileEmailController.swift index 799cbae557abf3808f8f297fadfff08ffb9d8bb3..f06b1f30d4b756ac5b9403753aa8e6876ba0e0fa 100644 --- a/Sources/ProfileFeature/Controllers/ProfileEmailController.swift +++ b/Sources/ProfileFeature/Controllers/ProfileEmailController.swift @@ -2,83 +2,82 @@ import HUD import UIKit import Shared import Combine -import Theme import DependencyInjection import ScrollViewController public final class ProfileEmailController: UIViewController { - @Dependency private var hud: HUD - @Dependency private var coordinator: ProfileCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling + @Dependency var hud: HUD + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: ProfileCoordinating - lazy private var screenView = ProfileEmailView() - lazy private var scrollViewController = ScrollViewController() + lazy private var screenView = ProfileEmailView() + lazy private var scrollViewController = ScrollViewController() - private let viewModel = ProfileEmailViewModel() - private var cancellables = Set<AnyCancellable>() + private let viewModel = ProfileEmailViewModel() + private var cancellables = Set<AnyCancellable>() - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationItem.backButtonTitle = "" - statusBarController.style.send(.darkContent) - navigationController?.navigationBar - .customize(backgroundColor: Asset.neutralWhite.color) - } + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar + .customize(backgroundColor: Asset.neutralWhite.color) + } - public override func viewDidLoad() { - super.viewDidLoad() - setupScrollView() - setupBindings() - } + public override func viewDidLoad() { + super.viewDidLoad() + setupScrollView() + setupBindings() + } - private func setupScrollView() { - addChild(scrollViewController) - view.addSubview(scrollViewController.view) - scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } - scrollViewController.didMove(toParent: self) - scrollViewController.contentView = screenView - scrollViewController.scrollView.backgroundColor = Asset.neutralWhite.color - } + private func setupScrollView() { + addChild(scrollViewController) + view.addSubview(scrollViewController.view) + scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } + scrollViewController.didMove(toParent: self) + scrollViewController.contentView = screenView + scrollViewController.scrollView.backgroundColor = Asset.neutralWhite.color + } - private func setupBindings() { - viewModel.hud - .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) + private func setupBindings() { + viewModel.hud + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) - screenView.inputField.textPublisher - .sink { [unowned self] in viewModel.didInput($0) } - .store(in: &cancellables) + screenView.inputField.textPublisher + .sink { [unowned self] in viewModel.didInput($0) } + .store(in: &cancellables) - screenView.inputField.returnPublisher - .sink { [unowned self] in screenView.inputField.endEditing(true) } - .store(in: &cancellables) + screenView.inputField.returnPublisher + .sink { [unowned self] in screenView.inputField.endEditing(true) } + .store(in: &cancellables) - viewModel.state - .map(\.confirmation) - .receive(on: DispatchQueue.main) - .compactMap { $0 } - .sink { [unowned self] in - viewModel.clearUp() - coordinator.toCode(with: $0, from: self) { _, _ in - if let viewControllers = self.navigationController?.viewControllers { - self.navigationController?.popToViewController( - viewControllers[viewControllers.count - 3], - animated: true - ) - } - } - } - .store(in: &cancellables) + viewModel.state + .map(\.confirmation) + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [unowned self] in + viewModel.clearUp() + coordinator.toCode(with: $0, from: self) { _, _ in + if let viewControllers = self.navigationController?.viewControllers { + self.navigationController?.popToViewController( + viewControllers[viewControllers.count - 3], + animated: true + ) + } + } + } + .store(in: &cancellables) - viewModel.state.map(\.status) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.update(status: $0) } - .store(in: &cancellables) + viewModel.state.map(\.status) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in screenView.update(status: $0) } + .store(in: &cancellables) - screenView.saveButton.publisher(for: .touchUpInside) - .sink { [unowned self] in viewModel.didTapNext() } - .store(in: &cancellables) - } + screenView.saveButton.publisher(for: .touchUpInside) + .sink { [unowned self] in viewModel.didTapNext() } + .store(in: &cancellables) + } } diff --git a/Sources/ProfileFeature/Controllers/ProfilePhoneController.swift b/Sources/ProfileFeature/Controllers/ProfilePhoneController.swift index 34fbd10bf1cabcb14ae5b93474d0d3b8b835b369..0b1264850c2049efbbffc11fe8f33b09d88028d8 100644 --- a/Sources/ProfileFeature/Controllers/ProfilePhoneController.swift +++ b/Sources/ProfileFeature/Controllers/ProfilePhoneController.swift @@ -2,99 +2,98 @@ import HUD import UIKit import Shared import Combine -import Theme import DependencyInjection import ScrollViewController public final class ProfilePhoneController: UIViewController { - @Dependency private var hud: HUD - @Dependency private var coordinator: ProfileCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling + @Dependency var hud: HUD + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: ProfileCoordinating - lazy private var screenView = ProfilePhoneView() - lazy private var scrollViewController = ScrollViewController() + lazy private var screenView = ProfilePhoneView() + lazy private var scrollViewController = ScrollViewController() - private let viewModel = ProfilePhoneViewModel() - private var cancellables = Set<AnyCancellable>() + private let viewModel = ProfilePhoneViewModel() + private var cancellables = Set<AnyCancellable>() - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationItem.backButtonTitle = "" - statusBarController.style.send(.darkContent) - navigationController?.navigationBar - .customize(backgroundColor: Asset.neutralWhite.color) - } + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar + .customize(backgroundColor: Asset.neutralWhite.color) + } - public override func viewDidLoad() { - super.viewDidLoad() - setupScrollView() - setupBindings() - } + public override func viewDidLoad() { + super.viewDidLoad() + setupScrollView() + setupBindings() + } - private func setupScrollView() { - addChild(scrollViewController) - view.addSubview(scrollViewController.view) - scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } - scrollViewController.didMove(toParent: self) - scrollViewController.contentView = screenView - scrollViewController.scrollView.backgroundColor = Asset.neutralWhite.color - } + private func setupScrollView() { + addChild(scrollViewController) + view.addSubview(scrollViewController.view) + scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } + scrollViewController.didMove(toParent: self) + scrollViewController.contentView = screenView + scrollViewController.scrollView.backgroundColor = Asset.neutralWhite.color + } - private func setupBindings() { - viewModel.hud - .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) + private func setupBindings() { + viewModel.hud + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) - screenView.inputField.textPublisher - .sink { [unowned self] in viewModel.didInput($0) } - .store(in: &cancellables) + screenView.inputField.textPublisher + .sink { [unowned self] in viewModel.didInput($0) } + .store(in: &cancellables) - screenView.inputField.returnPublisher - .sink { [unowned self] in screenView.inputField.endEditing(true) } - .store(in: &cancellables) + screenView.inputField.returnPublisher + .sink { [unowned self] in screenView.inputField.endEditing(true) } + .store(in: &cancellables) - screenView.inputField.codePublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - coordinator.toCountries(from: self) { self.viewModel.didChooseCountry($0) } - }.store(in: &cancellables) + screenView.inputField.codePublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + coordinator.toCountries(from: self) { self.viewModel.didChooseCountry($0) } + }.store(in: &cancellables) - viewModel.state - .map(\.confirmation) - .receive(on: DispatchQueue.main) - .compactMap { $0 } - .sink { [unowned self] in - viewModel.clearUp() - coordinator.toCode(with: $0, from: self) { _, _ in - if let viewControllers = self.navigationController?.viewControllers { - self.navigationController?.popToViewController( - viewControllers[viewControllers.count - 3], - animated: true - ) - } - } - }.store(in: &cancellables) + viewModel.state + .map(\.confirmation) + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [unowned self] in + viewModel.clearUp() + coordinator.toCode(with: $0, from: self) { _, _ in + if let viewControllers = self.navigationController?.viewControllers { + self.navigationController?.popToViewController( + viewControllers[viewControllers.count - 3], + animated: true + ) + } + } + }.store(in: &cancellables) - viewModel.state - .map(\.country) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - screenView.inputField.set(prefix: $0.prefixWithFlag) - screenView.inputField.update(placeholder: $0.example) - } - .store(in: &cancellables) + viewModel.state + .map(\.country) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + screenView.inputField.set(prefix: $0.prefixWithFlag) + screenView.inputField.update(placeholder: $0.example) + } + .store(in: &cancellables) - viewModel.state - .map(\.status) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.update(status: $0) } - .store(in: &cancellables) + viewModel.state + .map(\.status) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in screenView.update(status: $0) } + .store(in: &cancellables) - screenView.saveButton.publisher(for: .touchUpInside) - .sink { [unowned self] in viewModel.didTapNext() } - .store(in: &cancellables) - } + screenView.saveButton.publisher(for: .touchUpInside) + .sink { [unowned self] in viewModel.didTapNext() } + .store(in: &cancellables) + } } diff --git a/Sources/RequestsFeature/Controllers/RequestsContainerController.swift b/Sources/RequestsFeature/Controllers/RequestsContainerController.swift index f3f7b3937059b0b30185e139b79b597f338061ca..ef723bcb358bbbd0d532c1138a0053be75334883 100644 --- a/Sources/RequestsFeature/Controllers/RequestsContainerController.swift +++ b/Sources/RequestsFeature/Controllers/RequestsContainerController.swift @@ -1,118 +1,117 @@ import UIKit -import Theme import Shared import Combine import ContactFeature import DependencyInjection public final class RequestsContainerController: UIViewController { - @Dependency private var coordinator: RequestsCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: RequestsCoordinating - lazy private var screenView = RequestsContainerView() - private var cancellables = Set<AnyCancellable>() + lazy private var screenView = RequestsContainerView() + private var cancellables = Set<AnyCancellable>() - public override func loadView() { - view = screenView - screenView.scrollView.delegate = self + public override func loadView() { + view = screenView + screenView.scrollView.delegate = self - addChild(screenView.sentController) - addChild(screenView.failedController) - addChild(screenView.receivedController) + addChild(screenView.sentController) + addChild(screenView.failedController) + addChild(screenView.receivedController) - screenView.sentController.didMove(toParent: self) - screenView.failedController.didMove(toParent: self) - screenView.receivedController.didMove(toParent: self) + screenView.sentController.didMove(toParent: self) + screenView.failedController.didMove(toParent: self) + screenView.receivedController.didMove(toParent: self) - screenView.bringSubviewToFront(screenView.segmentedControl) - } + screenView.bringSubviewToFront(screenView.segmentedControl) + } - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - statusBarController.style.send(.darkContent) + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + barStylist.styleSubject.send(.darkContent) - navigationController?.navigationBar - .customize(backgroundColor: Asset.neutralWhite.color) - } + navigationController?.navigationBar + .customize(backgroundColor: Asset.neutralWhite.color) + } - public override func viewDidLoad() { - super.viewDidLoad() - setupNavigationBar() - setupBindings() + public override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + setupBindings() - if let stack = navigationController?.viewControllers, stack.count > 1 { - if stack[stack.count - 2].isKind(of: ContactController.self) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - guard let self = self else { return } + if let stack = navigationController?.viewControllers, stack.count > 1 { + if stack[stack.count - 2].isKind(of: ContactController.self) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self = self else { return } - let point = CGPoint(x: self.screenView.frame.width, y: 0.0) - self.screenView.scrollView.setContentOffset(point, animated: true) - } - } + let point = CGPoint(x: self.screenView.frame.width, y: 0.0) + self.screenView.scrollView.setContentOffset(point, animated: true) } + } } - - private func setupNavigationBar() { - navigationItem.backButtonTitle = "" - - let titleLabel = UILabel() - titleLabel.text = Localized.Requests.title - titleLabel.textColor = Asset.neutralActive.color - titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) - - let menuButton = UIButton() - menuButton.tintColor = Asset.neutralDark.color - menuButton.setImage(Asset.chatListMenu.image, for: .normal) - menuButton.addTarget(self, action: #selector(didTapMenu), for: .touchUpInside) - menuButton.snp.makeConstraints { $0.width.equalTo(50) } - - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [menuButton, titleLabel]) - ) - } - - private func setupBindings() { - screenView - .sentController - .connectionsPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toSearch(from: self) } - .store(in: &cancellables) - - screenView - .segmentedControl - .receivedRequestsButton - .publisher(for: .touchUpInside) - .sink { [unowned self] _ in - screenView.scrollView.setContentOffset(.zero, animated: true) - }.store(in: &cancellables) - - screenView - .segmentedControl - .sentRequestsButton - .publisher(for: .touchUpInside) - .sink { [unowned self] _ in - let point = CGPoint(x: screenView.frame.width, y: 0.0) - screenView.scrollView.setContentOffset(point, animated: true) - }.store(in: &cancellables) - - screenView - .segmentedControl - .failedRequestsButton - .publisher(for: .touchUpInside) - .sink { [unowned self] _ in - let point = CGPoint(x: screenView.frame.width * 2.0, y: 0.0) - screenView.scrollView.setContentOffset(point, animated: true) - }.store(in: &cancellables) - } - - @objc private func didTapMenu() { - coordinator.toSideMenu(from: self) - } + } + + private func setupNavigationBar() { + navigationItem.backButtonTitle = "" + + let titleLabel = UILabel() + titleLabel.text = Localized.Requests.title + titleLabel.textColor = Asset.neutralActive.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) + + let menuButton = UIButton() + menuButton.tintColor = Asset.neutralDark.color + menuButton.setImage(Asset.chatListMenu.image, for: .normal) + menuButton.addTarget(self, action: #selector(didTapMenu), for: .touchUpInside) + menuButton.snp.makeConstraints { $0.width.equalTo(50) } + + navigationItem.leftBarButtonItem = UIBarButtonItem( + customView: UIStackView(arrangedSubviews: [menuButton, titleLabel]) + ) + } + + private func setupBindings() { + screenView + .sentController + .connectionsPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toSearch(from: self) } + .store(in: &cancellables) + + screenView + .segmentedControl + .receivedRequestsButton + .publisher(for: .touchUpInside) + .sink { [unowned self] _ in + screenView.scrollView.setContentOffset(.zero, animated: true) + }.store(in: &cancellables) + + screenView + .segmentedControl + .sentRequestsButton + .publisher(for: .touchUpInside) + .sink { [unowned self] _ in + let point = CGPoint(x: screenView.frame.width, y: 0.0) + screenView.scrollView.setContentOffset(point, animated: true) + }.store(in: &cancellables) + + screenView + .segmentedControl + .failedRequestsButton + .publisher(for: .touchUpInside) + .sink { [unowned self] _ in + let point = CGPoint(x: screenView.frame.width * 2.0, y: 0.0) + screenView.scrollView.setContentOffset(point, animated: true) + }.store(in: &cancellables) + } + + @objc private func didTapMenu() { + coordinator.toSideMenu(from: self) + } } extension RequestsContainerController: UIScrollViewDelegate { - public func scrollViewDidScroll(_ scrollView: UIScrollView) { - screenView.segmentedControl.updateSwipePercentage(scrollView.contentOffset.x / view.frame.width) - } + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + screenView.segmentedControl.updateSwipePercentage(scrollView.contentOffset.x / view.frame.width) + } } diff --git a/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift b/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift index 0a30d05fb32b5ec4d3eb54b1fdeb9ced674a17ac..c5a9118966df113885bae7de0d6d4538470d4bff 100644 --- a/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift +++ b/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift @@ -5,7 +5,6 @@ import Shared import Combine import XXModels import Countries -import ToastFeature import DrawerFeature import DependencyInjection diff --git a/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift index e23720943c3e900c1c322c2d717a4884a97c6d27..86dd95914ac5871a15497b22529901f39bfe4e49 100644 --- a/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift +++ b/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift @@ -7,7 +7,6 @@ import Defaults import XXModels import Defaults import XXClient -import ToastFeature import ReportingFeature import CombineSchedulers import DependencyInjection diff --git a/Sources/RestoreFeature/Controllers/RestoreSuccessController.swift b/Sources/RestoreFeature/Controllers/RestoreSuccessController.swift index fd4f97899558664da35f86da6c1ebfaa8438b0d8..4f6f1d9b9a0befadf8ea5060e556cf890207e64c 100644 --- a/Sources/RestoreFeature/Controllers/RestoreSuccessController.swift +++ b/Sources/RestoreFeature/Controllers/RestoreSuccessController.swift @@ -1,11 +1,11 @@ import UIKit -import Theme +import Shared import Combine import DependencyInjection public final class RestoreSuccessController: UIViewController { + @Dependency var barStylist: StatusBarStylist @Dependency var coordinator: RestoreCoordinating - @Dependency var statusBarController: StatusBarStyleControlling lazy private var screenView = RestoreSuccessView() private var cancellables = Set<AnyCancellable>() @@ -16,7 +16,7 @@ public final class RestoreSuccessController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - statusBarController.style.send(.darkContent) + barStylist.styleSubject.send(.darkContent) navigationController?.navigationBar.customize(translucent: true) } diff --git a/Sources/ScanFeature/Controllers/ScanContainerController.swift b/Sources/ScanFeature/Controllers/ScanContainerController.swift index 41df6a8705056d9f68ef82366b890d6e07f4140f..9105e1329ddbbeab058bc0497100dfb7945db2f0 100644 --- a/Sources/ScanFeature/Controllers/ScanContainerController.swift +++ b/Sources/ScanFeature/Controllers/ScanContainerController.swift @@ -1,190 +1,189 @@ import UIKit -import Theme import Shared import Combine import DrawerFeature import DependencyInjection public final class ScanContainerController: UIViewController { - @Dependency private var coordinator: ScanCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling - - lazy private var screenView = ScanContainerView() - - private let scanController = ScanController() - private var cancellables = Set<AnyCancellable>() - private var drawerCancellables = Set<AnyCancellable>() - private let displayController = ScanDisplayController() - private let pageController = UIPageViewController( - transitionStyle: .scroll, - navigationOrientation: .horizontal - ) - - public override func loadView() { - view = screenView - - addChild(pageController) - screenView.addSubview(pageController.view) - pageController.view.snp.makeConstraints { - $0.top.equalTo(screenView.stackView.snp.bottom) - $0.left.equalToSuperview() - $0.right.equalToSuperview() - $0.bottom.equalTo(screenView) - } - - pageController.delegate = self - pageController.dataSource = self - pageController.didMove(toParent: self) - pageController.setViewControllers([scanController], direction: .forward, animated: true) - screenView.bringSubviewToFront(screenView.stackView) + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: ScanCoordinating + + lazy private var screenView = ScanContainerView() + + private let scanController = ScanController() + private var cancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() + private let displayController = ScanDisplayController() + private let pageController = UIPageViewController( + transitionStyle: .scroll, + navigationOrientation: .horizontal + ) + + public override func loadView() { + view = screenView + + addChild(pageController) + screenView.addSubview(pageController.view) + pageController.view.snp.makeConstraints { + $0.top.equalTo(screenView.stackView.snp.bottom) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalTo(screenView) } - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - statusBarController.style.send(.lightContent) - navigationController?.navigationBar.customize(translucent: true) + pageController.delegate = self + pageController.dataSource = self + pageController.didMove(toParent: self) + pageController.setViewControllers([scanController], direction: .forward, animated: true) + screenView.bringSubviewToFront(screenView.stackView) + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + barStylist.styleSubject.send(.lightContent) + navigationController?.navigationBar.customize(translucent: true) + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + setupBindings() + + displayController.didTapInfo = { [weak self] in + self?.presentInfo( + title: Localized.Scan.Info.title, + subtitle: Localized.Scan.Info.subtitle + ) } - public override func viewDidLoad() { - super.viewDidLoad() - setupNavigationBar() - setupBindings() - - displayController.didTapInfo = { [weak self] in - self?.presentInfo( - title: Localized.Scan.Info.title, - subtitle: Localized.Scan.Info.subtitle - ) - } - - displayController.didTapAddEmail = { [weak self] in - guard let self = self else { return } - self.coordinator.toEmail(from: self) - } + displayController.didTapAddEmail = { [weak self] in + guard let self = self else { return } + self.coordinator.toEmail(from: self) + } - displayController.didTapAddPhone = { [weak self] in - guard let self = self else { return } - self.coordinator.toPhone(from: self) - } + displayController.didTapAddPhone = { [weak self] in + guard let self = self else { return } + self.coordinator.toPhone(from: self) } + } - private func setupNavigationBar() { - navigationItem.backButtonTitle = "" + private func setupNavigationBar() { + navigationItem.backButtonTitle = "" - let titleLabel = UILabel() - titleLabel.text = "QR Code" - titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) - titleLabel.textColor = Asset.neutralWhite.color + let titleLabel = UILabel() + titleLabel.text = "QR Code" + titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) + titleLabel.textColor = Asset.neutralWhite.color - let menuButton = UIButton() - menuButton.tintColor = Asset.neutralWhite.color - menuButton.setImage(Asset.chatListMenu.image, for: .normal) - menuButton.addTarget(self, action: #selector(didTapMenu), for: .touchUpInside) - menuButton.snp.makeConstraints { $0.width.equalTo(50) } + let menuButton = UIButton() + menuButton.tintColor = Asset.neutralWhite.color + menuButton.setImage(Asset.chatListMenu.image, for: .normal) + menuButton.addTarget(self, action: #selector(didTapMenu), for: .touchUpInside) + menuButton.snp.makeConstraints { $0.width.equalTo(50) } - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [menuButton, titleLabel]) - ) - } - - private func setupBindings() { - screenView.leftButton - .publisher(for: .touchUpInside) - .sink { [unowned self] _ in - screenView.leftButton.set(selected: true) - screenView.rightButton.set(selected: false) - pageController.setViewControllers([scanController], direction: .reverse, animated: true, completion: nil) - }.store(in: &cancellables) - - screenView.rightButton - .publisher(for: .touchUpInside) - .sink { [unowned self] _ in - screenView.leftButton.set(selected: false) - screenView.rightButton.set(selected: true) - pageController.setViewControllers([displayController], direction: .forward, animated: true, completion: nil) - }.store(in: &cancellables) - } + navigationItem.leftBarButtonItem = UIBarButtonItem( + customView: UIStackView(arrangedSubviews: [menuButton, titleLabel]) + ) + } + + private func setupBindings() { + screenView.leftButton + .publisher(for: .touchUpInside) + .sink { [unowned self] _ in + screenView.leftButton.set(selected: true) + screenView.rightButton.set(selected: false) + pageController.setViewControllers([scanController], direction: .reverse, animated: true, completion: nil) + }.store(in: &cancellables) + + screenView.rightButton + .publisher(for: .touchUpInside) + .sink { [unowned self] _ in + screenView.leftButton.set(selected: false) + screenView.rightButton.set(selected: true) + pageController.setViewControllers([displayController], direction: .forward, animated: true, completion: nil) + }.store(in: &cancellables) + } + + @objc private func didTapMenu() { + coordinator.toSideMenu(from: self) + } + + private func presentInfo(title: String, subtitle: String) { + let actionButton = CapsuleButton() + actionButton.set( + style: .seeThrough, + title: Localized.Settings.InfoDrawer.action + ) - @objc private func didTapMenu() { - coordinator.toSideMenu(from: self) - } + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: subtitle, + color: Asset.neutralBody.color, + alignment: .left, + lineHeightMultiple: 1.1, + spacingAfter: 37 + ), + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) + ]) + + actionButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) - private func presentInfo(title: String, subtitle: String) { - let actionButton = CapsuleButton() - actionButton.set( - style: .seeThrough, - title: Localized.Settings.InfoDrawer.action - ) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerText( - font: Fonts.Mulish.regular.font(size: 16.0), - text: subtitle, - color: Asset.neutralBody.color, - alignment: .left, - lineHeightMultiple: 1.1, - spacingAfter: 37 - ), - DrawerStack(views: [ - actionButton, - FlexibleSpace() - ]) - ]) - - actionButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } + coordinator.toDrawer(drawer, from: self) + } } extension ScanContainerController: UIPageViewControllerDataSource { - public func pageViewController( - _ pageViewController: UIPageViewController, - viewControllerAfter viewController: UIViewController - ) -> UIViewController? { - guard viewController != displayController else { return nil } - return displayController - } - - public func pageViewController( - _ pageViewController: UIPageViewController, - viewControllerBefore viewController: UIViewController - ) -> UIViewController? { - guard viewController != scanController else { return nil } - return scanController - } + public func pageViewController( + _ pageViewController: UIPageViewController, + viewControllerAfter viewController: UIViewController + ) -> UIViewController? { + guard viewController != displayController else { return nil } + return displayController + } + + public func pageViewController( + _ pageViewController: UIPageViewController, + viewControllerBefore viewController: UIViewController + ) -> UIViewController? { + guard viewController != scanController else { return nil } + return scanController + } } extension ScanContainerController: UIPageViewControllerDelegate { - public func pageViewController( - _ pageViewController: UIPageViewController, - didFinishAnimating finished: Bool, - previousViewControllers: [UIViewController], - transitionCompleted completed: Bool - ) { - guard finished, completed else { return } - - if previousViewControllers.contains(scanController) { - screenView.leftButton.set(selected: false) - screenView.rightButton.set(selected: true) - } else { - screenView.leftButton.set(selected: true) - screenView.rightButton.set(selected: false) - } + public func pageViewController( + _ pageViewController: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool + ) { + guard finished, completed else { return } + + if previousViewControllers.contains(scanController) { + screenView.leftButton.set(selected: false) + screenView.rightButton.set(selected: true) + } else { + screenView.leftButton.set(selected: true) + screenView.rightButton.set(selected: false) } + } } diff --git a/Sources/SearchFeature/Controllers/SearchContainerController.swift b/Sources/SearchFeature/Controllers/SearchContainerController.swift index 15cb91be299d187b5dffb2f82bb68bb1c9f19d80..69665f8c946e0887c5ff5ba2d186944bcd9728d4 100644 --- a/Sources/SearchFeature/Controllers/SearchContainerController.swift +++ b/Sources/SearchFeature/Controllers/SearchContainerController.swift @@ -1,5 +1,4 @@ import UIKit -import Theme import Shared import Combine import XXModels @@ -7,182 +6,182 @@ import DrawerFeature import DependencyInjection public final class SearchContainerController: UIViewController { - @Dependency var coordinator: SearchCoordinating - @Dependency var statusBarController: StatusBarStyleControlling - - lazy private var screenView = SearchContainerView() - - private var contentOffset: CGPoint? - private var cancellables = Set<AnyCancellable>() - private let leftController: SearchLeftController - private let viewModel = SearchContainerViewModel() - private let rightController = SearchRightController() - private var drawerCancellables = Set<AnyCancellable>() - - public init(_ invitation: String? = nil) { - self.leftController = .init(invitation) - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { nil } - - public override func loadView() { - view = screenView - embedControllers() - } - - public func startSearchingFor(_ string: String) { - leftController.viewModel.invitation = string - leftController.viewModel.viewDidAppear() + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: SearchCoordinating + + lazy private var screenView = SearchContainerView() + + private var contentOffset: CGPoint? + private var cancellables = Set<AnyCancellable>() + private let leftController: SearchLeftController + private let viewModel = SearchContainerViewModel() + private let rightController = SearchRightController() + private var drawerCancellables = Set<AnyCancellable>() + + public init(_ invitation: String? = nil) { + self.leftController = .init(invitation) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + + public override func loadView() { + view = screenView + embedControllers() + } + + public func startSearchingFor(_ string: String) { + leftController.viewModel.invitation = string + leftController.viewModel.viewDidAppear() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar.customize( + backgroundColor: Asset.neutralWhite.color + ) + + if let contentOffset = self.contentOffset { + screenView.scrollView.setContentOffset(contentOffset, animated: true) } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationItem.backButtonTitle = "" - statusBarController.style.send(.darkContent) - navigationController?.navigationBar.customize( - backgroundColor: Asset.neutralWhite.color - ) - - if let contentOffset = self.contentOffset { - screenView.scrollView.setContentOffset(contentOffset, animated: true) + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + contentOffset = screenView.scrollView.contentOffset + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewModel.didAppear() + rightController.viewModel.viewWillAppear() + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + setupBindings() + } + + private func setupNavigationBar() { + let title = UILabel() + title.text = Localized.Ud.title + title.textColor = Asset.neutralActive.color + title.font = Fonts.Mulish.semiBold.font(size: 18.0) + + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: title) + navigationItem.leftItemsSupplementBackButton = true + } + + private func setupBindings() { + screenView.segmentedControl + .actionPublisher + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + if $0 == .qr { + let point = CGPoint(x: screenView.frame.width, y: 0.0) + screenView.scrollView.setContentOffset(point, animated: true) + leftController.endEditing() + } else { + screenView.scrollView.setContentOffset(.zero, animated: true) + leftController.viewModel.didSelectItem($0) } + }.store(in: &cancellables) + + viewModel.coverTrafficPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in presentCoverTrafficDrawer() } + .store(in: &cancellables) + } + + private func embedControllers() { + addChild(leftController) + addChild(rightController) + + screenView.scrollView.addSubview(leftController.view) + screenView.scrollView.addSubview(rightController.view) + + leftController.view.snp.makeConstraints { + $0.top.equalTo(screenView.segmentedControl.snp.bottom) + $0.width.equalTo(screenView) + $0.bottom.equalTo(screenView) + $0.left.equalToSuperview() + $0.right.equalTo(rightController.view.snp.left) } - public override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - contentOffset = screenView.scrollView.contentOffset - } - - public override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - viewModel.didAppear() - rightController.viewModel.viewWillAppear() - } - - public override func viewDidLoad() { - super.viewDidLoad() - setupNavigationBar() - setupBindings() + rightController.view.snp.makeConstraints { + $0.top.equalTo(screenView.segmentedControl.snp.bottom) + $0.width.equalTo(screenView) + $0.bottom.equalTo(screenView) } - private func setupNavigationBar() { - let title = UILabel() - title.text = Localized.Ud.title - title.textColor = Asset.neutralActive.color - title.font = Fonts.Mulish.semiBold.font(size: 18.0) - - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: title) - navigationItem.leftItemsSupplementBackButton = true - } - - private func setupBindings() { - screenView.segmentedControl - .actionPublisher - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - if $0 == .qr { - let point = CGPoint(x: screenView.frame.width, y: 0.0) - screenView.scrollView.setContentOffset(point, animated: true) - leftController.endEditing() - } else { - screenView.scrollView.setContentOffset(.zero, animated: true) - leftController.viewModel.didSelectItem($0) - } - }.store(in: &cancellables) - - viewModel.coverTrafficPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in presentCoverTrafficDrawer() } - .store(in: &cancellables) - } - - private func embedControllers() { - addChild(leftController) - addChild(rightController) - - screenView.scrollView.addSubview(leftController.view) - screenView.scrollView.addSubview(rightController.view) + leftController.didMove(toParent: self) + rightController.didMove(toParent: self) + } +} - leftController.view.snp.makeConstraints { - $0.top.equalTo(screenView.segmentedControl.snp.bottom) - $0.width.equalTo(screenView) - $0.bottom.equalTo(screenView) - $0.left.equalToSuperview() - $0.right.equalTo(rightController.view.snp.left) +extension SearchContainerController { + private func presentCoverTrafficDrawer() { + let enableButton = CapsuleButton() + enableButton.set( + style: .brandColored, + title: Localized.ChatList.Traffic.positive + ) + + let dismissButton = CapsuleButton() + dismissButton.set( + style: .seeThrough, + title: Localized.ChatList.Traffic.negative + ) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: Localized.ChatList.Traffic.title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: Localized.ChatList.Traffic.subtitle, + color: Asset.neutralBody.color, + alignment: .left, + lineHeightMultiple: 1.1, + spacingAfter: 39 + ), + DrawerStack( + axis: .horizontal, + spacing: 20, + distribution: .fillEqually, + views: [enableButton, dismissButton] + ) + ]) + + enableButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + self.viewModel.didEnableCoverTraffic() } - - rightController.view.snp.makeConstraints { - $0.top.equalTo(screenView.segmentedControl.snp.bottom) - $0.width.equalTo(screenView) - $0.bottom.equalTo(screenView) + }.store(in: &drawerCancellables) + + dismissButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() } + }.store(in: &drawerCancellables) - leftController.didMove(toParent: self) - rightController.didMove(toParent: self) - } -} - -extension SearchContainerController { - private func presentCoverTrafficDrawer() { - let enableButton = CapsuleButton() - enableButton.set( - style: .brandColored, - title: Localized.ChatList.Traffic.positive - ) - - let dismissButton = CapsuleButton() - dismissButton.set( - style: .seeThrough, - title: Localized.ChatList.Traffic.negative - ) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: Localized.ChatList.Traffic.title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerText( - font: Fonts.Mulish.regular.font(size: 16.0), - text: Localized.ChatList.Traffic.subtitle, - color: Asset.neutralBody.color, - alignment: .left, - lineHeightMultiple: 1.1, - spacingAfter: 39 - ), - DrawerStack( - axis: .horizontal, - spacing: 20, - distribution: .fillEqually, - views: [enableButton, dismissButton] - ) - ]) - - enableButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - self.viewModel.didEnableCoverTraffic() - } - }.store(in: &drawerCancellables) - - dismissButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } + coordinator.toDrawer(drawer, from: self) + } } diff --git a/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift b/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift index 67b8d649a9875a94d8da43aade89be448e4262e3..e7a0921a149c6cb95f288a68912fe970cc4d93e6 100644 --- a/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift +++ b/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift @@ -9,7 +9,6 @@ import XXClient import Defaults import Countries import CustomDump -import ToastFeature import NetworkMonitor import ReportingFeature import CombineSchedulers diff --git a/Sources/SettingsFeature/Controllers/SettingsController.swift b/Sources/SettingsFeature/Controllers/SettingsController.swift index e58a347096e53ddefc3014517c4f11c946a06429..af68cf87bf8803e6282b23bc9340aa25404c63cd 100644 --- a/Sources/SettingsFeature/Controllers/SettingsController.swift +++ b/Sources/SettingsFeature/Controllers/SettingsController.swift @@ -1,300 +1,299 @@ import HUD -import DrawerFeature import UIKit -import Theme import Shared import Combine +import DrawerFeature import DependencyInjection import ScrollViewController public final class SettingsController: UIViewController { - @Dependency private var hud: HUD - @Dependency private var coordinator: SettingsCoordinating - @Dependency private var statusBarController: StatusBarStyleControlling - - lazy private var scrollViewController = ScrollViewController() - lazy private var screenView = SettingsView { - switch $0 { - case .icognitoKeyboard: - self.presentInfo( - title: Localized.Settings.InfoDrawer.Icognito.title, - subtitle: Localized.Settings.InfoDrawer.Icognito.subtitle - ) - case .biometrics: - self.presentInfo( - title: Localized.Settings.InfoDrawer.Biometrics.title, - subtitle: Localized.Settings.InfoDrawer.Biometrics.subtitle - ) - case .notifications: - self.presentInfo( - title: Localized.Settings.InfoDrawer.Notifications.title, - subtitle: Localized.Settings.InfoDrawer.Notifications.subtitle, - urlString: "https://links.xx.network/denseids" - ) - - case .dummyTraffic: - self.presentInfo( - title: Localized.Settings.InfoDrawer.Traffic.title, - subtitle: Localized.Settings.InfoDrawer.Traffic.subtitle, - urlString: "https://links.xx.network/covertraffic" - ) - } - } - - private let viewModel = SettingsViewModel() - private var cancellables = Set<AnyCancellable>() - private var drawerCancellables = Set<AnyCancellable>() - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - statusBarController.style.send(.darkContent) - navigationController?.navigationBar - .customize(backgroundColor: Asset.neutralWhite.color) - } - - public override func viewDidLoad() { - super.viewDidLoad() - - setupNavigationBar() - setupScrollView() - setupBindings() - - viewModel.loadCachedSettings() - } - - private func setupNavigationBar() { - navigationItem.backButtonTitle = "" - - let titleLabel = UILabel() - titleLabel.text = Localized.Settings.title - titleLabel.textColor = Asset.neutralActive.color - titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) - - let menuButton = UIButton() - menuButton.tintColor = Asset.neutralDark.color - menuButton.setImage(Asset.chatListMenu.image, for: .normal) - menuButton.addTarget(self, action: #selector(didTapMenu), for: .touchUpInside) - menuButton.snp.makeConstraints { $0.width.equalTo(50) } - - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [menuButton, titleLabel]) - ) - } - - private func setupScrollView() { - scrollViewController.view.backgroundColor = Asset.neutralWhite.color - - addChild(scrollViewController) - view.addSubview(scrollViewController.view) - - scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } - scrollViewController.didMove(toParent: self) - scrollViewController.contentView = screenView + @Dependency var hud: HUD + @Dependency var barStylist: StatusBarStylist + @Dependency var coordinator: SettingsCoordinating + + lazy private var scrollViewController = ScrollViewController() + lazy private var screenView = SettingsView { + switch $0 { + case .icognitoKeyboard: + self.presentInfo( + title: Localized.Settings.InfoDrawer.Icognito.title, + subtitle: Localized.Settings.InfoDrawer.Icognito.subtitle + ) + case .biometrics: + self.presentInfo( + title: Localized.Settings.InfoDrawer.Biometrics.title, + subtitle: Localized.Settings.InfoDrawer.Biometrics.subtitle + ) + case .notifications: + self.presentInfo( + title: Localized.Settings.InfoDrawer.Notifications.title, + subtitle: Localized.Settings.InfoDrawer.Notifications.subtitle, + urlString: "https://links.xx.network/denseids" + ) + + case .dummyTraffic: + self.presentInfo( + title: Localized.Settings.InfoDrawer.Traffic.title, + subtitle: Localized.Settings.InfoDrawer.Traffic.subtitle, + urlString: "https://links.xx.network/covertraffic" + ) } + } + + private let viewModel = SettingsViewModel() + private var cancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + barStylist.styleSubject.send(.darkContent) + navigationController?.navigationBar + .customize(backgroundColor: Asset.neutralWhite.color) + } + + public override func viewDidLoad() { + super.viewDidLoad() + + setupNavigationBar() + setupScrollView() + setupBindings() + + viewModel.loadCachedSettings() + } + + private func setupNavigationBar() { + navigationItem.backButtonTitle = "" + + let titleLabel = UILabel() + titleLabel.text = Localized.Settings.title + titleLabel.textColor = Asset.neutralActive.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) + + let menuButton = UIButton() + menuButton.tintColor = Asset.neutralDark.color + menuButton.setImage(Asset.chatListMenu.image, for: .normal) + menuButton.addTarget(self, action: #selector(didTapMenu), for: .touchUpInside) + menuButton.snp.makeConstraints { $0.width.equalTo(50) } + + navigationItem.leftBarButtonItem = UIBarButtonItem( + customView: UIStackView(arrangedSubviews: [menuButton, titleLabel]) + ) + } + + private func setupScrollView() { + scrollViewController.view.backgroundColor = Asset.neutralWhite.color + + addChild(scrollViewController) + view.addSubview(scrollViewController.view) + + scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } + scrollViewController.didMove(toParent: self) + scrollViewController.contentView = screenView + } + + private func setupBindings() { + viewModel.hud + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + screenView.inAppNotifications.switcherView + .publisher(for: .valueChanged) + .sink { [weak viewModel] in viewModel?.didToggleInAppNotifications() } + .store(in: &cancellables) + + screenView.dummyTraffic.switcherView + .publisher(for: .valueChanged) + .sink { [weak viewModel] in viewModel?.didToggleDummyTraffic() } + .store(in: &cancellables) + + screenView.remoteNotifications.switcherView + .publisher(for: .valueChanged) + .sink { [weak viewModel] in viewModel?.didTogglePushNotifications() } + .store(in: &cancellables) + + screenView.hideActiveApp.switcherView + .publisher(for: .valueChanged) + .sink { [weak viewModel] in viewModel?.didToggleHideActiveApps() } + .store(in: &cancellables) + + screenView.icognitoKeyboard.switcherView + .publisher(for: .valueChanged) + .sink { [weak viewModel] in viewModel?.didToggleIcognitoKeyboard() } + .store(in: &cancellables) + + screenView.biometrics.switcherView + .publisher(for: .valueChanged) + .sink { [weak viewModel] in viewModel?.didToggleBiometrics() } + .store(in: &cancellables) + + screenView.privacyPolicyButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + presentDrawer( + title: Localized.Settings.Drawer.title(Localized.Settings.privacyPolicy), + subtitle: Localized.Settings.Drawer.subtitle(Localized.Settings.privacyPolicy), + actionTitle: Localized.ChatList.Dashboard.open) { + guard let url = URL(string: "https://elixxir.io/privategrity-corporation-privacy-policy/") else { return } + UIApplication.shared.open(url, options: [:]) + } + }.store(in: &cancellables) + + screenView.disclosuresButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + presentDrawer( + title: Localized.Settings.Drawer.title(Localized.Settings.disclosures), + subtitle: Localized.Settings.Drawer.subtitle(Localized.Settings.disclosures), + actionTitle: Localized.ChatList.Dashboard.open) { + guard let url = URL(string: "https://elixxir.io/privategrity-corporation-terms-of-use/") else { return } + UIApplication.shared.open(url, options: [:]) + } + }.store(in: &cancellables) + + screenView.deleteButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toDelete(from: self) } + .store(in: &cancellables) + + screenView.accountBackupButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toBackup(from: self) } + .store(in: &cancellables) + + screenView.advancedButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toAdvanced(from: self) } + .store(in: &cancellables) + + viewModel.state + .map(\.isBiometricsPossible) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak screenView] in screenView?.biometrics.switcherView.isEnabled = $0 } + .store(in: &cancellables) + + viewModel.state + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] state in + screenView.biometrics.switcherView.setOn(state.isBiometricsEnabled, animated: true) + screenView.hideActiveApp.switcherView.setOn(state.isHideActiveApps, animated: true) + screenView.icognitoKeyboard.switcherView.setOn(state.isIcognitoKeyboard, animated: true) + screenView.inAppNotifications.switcherView.setOn(state.isInAppNotification, animated: true) + screenView.remoteNotifications.switcherView.setOn(state.isPushNotification, animated: true) + screenView.dummyTraffic.switcherView.setOn(state.isDummyTrafficOn, animated: true) + }.store(in: &cancellables) + } + + private func presentDrawer( + title: String, + subtitle: String, + actionTitle: String, + action: @escaping () -> Void + ) { + let actionButton = CapsuleButton() + actionButton.setStyle(.red) + actionButton.setTitle(actionTitle, for: .normal) + + let cancelButton = CapsuleButton() + cancelButton.setStyle(.seeThrough) + cancelButton.setTitle(Localized.ChatList.Dashboard.cancel, for: .normal) + + let drawer = DrawerController(with: [ + DrawerImage( + image: Asset.drawerNegative.image + ), + DrawerText( + font: Fonts.Mulish.semiBold.font(size: 18.0), + text: title, + color: Asset.neutralActive.color + ), + DrawerText( + font: Fonts.Mulish.semiBold.font(size: 14.0), + text: subtitle, + color: Asset.neutralWeak.color, + lineHeightMultiple: 1.35, + spacingAfter: 25 + ), + DrawerStack( + spacing: 20.0, + views: [actionButton, cancelButton] + ) + ]) + + actionButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + + action() + } + }.store(in: &drawerCancellables) - private func setupBindings() { - viewModel.hud - .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - screenView.inAppNotifications.switcherView - .publisher(for: .valueChanged) - .sink { [weak viewModel] in viewModel?.didToggleInAppNotifications() } - .store(in: &cancellables) - - screenView.dummyTraffic.switcherView - .publisher(for: .valueChanged) - .sink { [weak viewModel] in viewModel?.didToggleDummyTraffic() } - .store(in: &cancellables) - - screenView.remoteNotifications.switcherView - .publisher(for: .valueChanged) - .sink { [weak viewModel] in viewModel?.didTogglePushNotifications() } - .store(in: &cancellables) - - screenView.hideActiveApp.switcherView - .publisher(for: .valueChanged) - .sink { [weak viewModel] in viewModel?.didToggleHideActiveApps() } - .store(in: &cancellables) - - screenView.icognitoKeyboard.switcherView - .publisher(for: .valueChanged) - .sink { [weak viewModel] in viewModel?.didToggleIcognitoKeyboard() } - .store(in: &cancellables) - - screenView.biometrics.switcherView - .publisher(for: .valueChanged) - .sink { [weak viewModel] in viewModel?.didToggleBiometrics() } - .store(in: &cancellables) - - screenView.privacyPolicyButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - presentDrawer( - title: Localized.Settings.Drawer.title(Localized.Settings.privacyPolicy), - subtitle: Localized.Settings.Drawer.subtitle(Localized.Settings.privacyPolicy), - actionTitle: Localized.ChatList.Dashboard.open) { - guard let url = URL(string: "https://elixxir.io/privategrity-corporation-privacy-policy/") else { return } - UIApplication.shared.open(url, options: [:]) - } - }.store(in: &cancellables) - - screenView.disclosuresButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - presentDrawer( - title: Localized.Settings.Drawer.title(Localized.Settings.disclosures), - subtitle: Localized.Settings.Drawer.subtitle(Localized.Settings.disclosures), - actionTitle: Localized.ChatList.Dashboard.open) { - guard let url = URL(string: "https://elixxir.io/privategrity-corporation-terms-of-use/") else { return } - UIApplication.shared.open(url, options: [:]) - } - }.store(in: &cancellables) - - screenView.deleteButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toDelete(from: self) } - .store(in: &cancellables) - - screenView.accountBackupButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toBackup(from: self) } - .store(in: &cancellables) - - screenView.advancedButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toAdvanced(from: self) } - .store(in: &cancellables) - - viewModel.state - .map(\.isBiometricsPossible) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak screenView] in screenView?.biometrics.switcherView.isEnabled = $0 } - .store(in: &cancellables) - - viewModel.state - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] state in - screenView.biometrics.switcherView.setOn(state.isBiometricsEnabled, animated: true) - screenView.hideActiveApp.switcherView.setOn(state.isHideActiveApps, animated: true) - screenView.icognitoKeyboard.switcherView.setOn(state.isIcognitoKeyboard, animated: true) - screenView.inAppNotifications.switcherView.setOn(state.isInAppNotification, animated: true) - screenView.remoteNotifications.switcherView.setOn(state.isPushNotification, animated: true) - screenView.dummyTraffic.switcherView.setOn(state.isDummyTrafficOn, animated: true) - }.store(in: &cancellables) - } + cancelButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + self?.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) - private func presentDrawer( - title: String, - subtitle: String, - actionTitle: String, - action: @escaping () -> Void - ) { - let actionButton = CapsuleButton() - actionButton.setStyle(.red) - actionButton.setTitle(actionTitle, for: .normal) - - let cancelButton = CapsuleButton() - cancelButton.setStyle(.seeThrough) - cancelButton.setTitle(Localized.ChatList.Dashboard.cancel, for: .normal) - - let drawer = DrawerController(with: [ - DrawerImage( - image: Asset.drawerNegative.image - ), - DrawerText( - font: Fonts.Mulish.semiBold.font(size: 18.0), - text: title, - color: Asset.neutralActive.color - ), - DrawerText( - font: Fonts.Mulish.semiBold.font(size: 14.0), - text: subtitle, - color: Asset.neutralWeak.color, - lineHeightMultiple: 1.35, - spacingAfter: 25 - ), - DrawerStack( - spacing: 20.0, - views: [actionButton, cancelButton] - ) - ]) - - actionButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - - action() - } - }.store(in: &drawerCancellables) - - cancelButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - self?.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } + coordinator.toDrawer(drawer, from: self) + } - @objc private func didTapMenu() { - coordinator.toSideMenu(from: self) - } + @objc private func didTapMenu() { + coordinator.toSideMenu(from: self) + } } extension SettingsController { - private func presentInfo( - title: String, - subtitle: String, - urlString: String = "" - ) { - let actionButton = CapsuleButton() - actionButton.set( - style: .seeThrough, - title: Localized.Settings.InfoDrawer.action - ) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerLinkText( - text: subtitle, - urlString: urlString, - spacingAfter: 37 - ), - DrawerStack(views: [ - actionButton, - FlexibleSpace() - ]) - ]) - - actionButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } + private func presentInfo( + title: String, + subtitle: String, + urlString: String = "" + ) { + let actionButton = CapsuleButton() + actionButton.set( + style: .seeThrough, + title: Localized.Settings.InfoDrawer.action + ) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerLinkText( + text: subtitle, + urlString: urlString, + spacingAfter: 37 + ), + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) + ]) + + actionButton.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) + + coordinator.toDrawer(drawer, from: self) + } } diff --git a/Sources/Shared/Controllers/RootViewController.swift b/Sources/Shared/Controllers/RootViewController.swift new file mode 100644 index 0000000000000000000000000000000000000000..3e1fcac17b6a17905d8450bf5a5d264326e9a990 --- /dev/null +++ b/Sources/Shared/Controllers/RootViewController.swift @@ -0,0 +1,146 @@ +import UIKit +import Combine +import DependencyInjection + +public final class RootViewController: UIViewController { + @Dependency var barStylist: StatusBarStylist + @Dependency var toastDispatcher: ToastController + + var toastTimer: Timer? + let content: UIViewController? + let toastTopPadding: CGFloat = 10 + var cancellables = Set<AnyCancellable>() + var topToastConstraint: NSLayoutConstraint? + + public init(_ content: UIViewController?) { + self.content = content + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + + public override var preferredStatusBarStyle: UIStatusBarStyle { + barStylist.styleSubject.value + } + + public override func viewDidLoad() { + super.viewDidLoad() + + if let content { + addChild(content) + view.addSubview(content.view) + content.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + content.view.frame = view.bounds + content.didMove(toParent: self) + } else { + view.isUserInteractionEnabled = false + } + + barStylist + .styleSubject + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + UIView.animate(withDuration: 0.2) { + self?.setNeedsStatusBarAppearanceUpdate() + } + }.store(in: &cancellables) + + toastDispatcher.currentToast + .receive(on: DispatchQueue.main) + .sink { [unowned self] model in + let toastView = ToastView(model: model) + add(toastView: toastView) + present(toastView: toastView) + }.store(in: &cancellables) + } + + @objc private func didPanToast(_ sender: UIPanGestureRecognizer) { + guard let toastView = sender.view else { return } + + switch sender.state { + case .began, .changed: + toastTimer?.invalidate() + let padding = toastTopPadding + min(0, sender.translation(in: view).y) + topToastConstraint?.constant = padding + + case .cancelled, .ended, .failed: + let halfFrameHeight = -0.5 * toastView.frame.height + let verticalTranslation = sender.translation(in: toastView).y + let didSwipeAboveHalf = verticalTranslation < halfFrameHeight + + if didSwipeAboveHalf { + dismiss(toastView: toastView) + } else { + present(toastView: toastView) + } + + case .possible: + break + @unknown default: + break + } + } + + private func dismiss(toastView: UIView) { + toastView.isUserInteractionEnabled = false + topToastConstraint?.constant = -(toastView.frame.height + view.safeAreaLayoutGuide.layoutFrame.minY) + + topToastConstraint = nil + UIView.animate(withDuration: 0.25) { + self.view.setNeedsLayout() + self.view.layoutIfNeeded() + } completion: { _ in + toastView.isUserInteractionEnabled = true + toastView.removeFromSuperview() + self.toastDispatcher.dismissCurrentToast() + } + } + + private func add(toastView: UIView) { + let gestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(didPanToast(_:))) + toastView.addGestureRecognizer(gestureRecognizer) + + toastView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(toastView) + + NSLayoutConstraint.activate([ + toastView.heightAnchor.constraint(equalToConstant: 78), + toastView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor, constant: 20), + toastView.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor, constant: -20) + ]) + + topToastConstraint = toastView.topAnchor.constraint( + equalTo: view.safeAreaLayoutGuide.topAnchor, + constant: -(toastView.frame.height + view.safeAreaLayoutGuide.layoutFrame.height) + ) + + topToastConstraint?.isActive = true + + view.setNeedsLayout() + view.layoutIfNeeded() + } + + private func present(toastView: UIView) { + toastView.isUserInteractionEnabled = false + topToastConstraint?.constant = toastTopPadding + + UIView.animate( + withDuration: 0.5, + delay: 0, + usingSpringWithDamping: 1, + initialSpringVelocity: 0.5, + options: .curveEaseInOut + ) { + self.view.setNeedsLayout() + self.view.layoutIfNeeded() + } completion: { _ in + toastView.isUserInteractionEnabled = true + + self.toastTimer?.invalidate() + self.toastTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in + guard let self = self else { return } + self.dismiss(toastView: toastView) + } + } + } +} diff --git a/Sources/Shared/Controllers/StatusBarStyling.swift b/Sources/Shared/Controllers/StatusBarStyling.swift new file mode 100644 index 0000000000000000000000000000000000000000..07941553057aa151e0f9e7b5dd5eee84ed580eed --- /dev/null +++ b/Sources/Shared/Controllers/StatusBarStyling.swift @@ -0,0 +1,7 @@ +import UIKit +import Combine + +public struct StatusBarStylist { + public init() {} + public let styleSubject = CurrentValueSubject<UIStatusBarStyle, Never>(.lightContent) +} diff --git a/Sources/ToastFeature/ToastController.swift b/Sources/Shared/Controllers/ToastController.swift similarity index 100% rename from Sources/ToastFeature/ToastController.swift rename to Sources/Shared/Controllers/ToastController.swift diff --git a/Sources/ToastFeature/ToastModel.swift b/Sources/Shared/Controllers/ToastModel.swift similarity index 98% rename from Sources/ToastFeature/ToastModel.swift rename to Sources/Shared/Controllers/ToastModel.swift index 06dd2a03fde9743309246e6438cdb9ca37383efc..001539d4dc907791e154a6c66ff8bf1b56e0c8c2 100644 --- a/Sources/ToastFeature/ToastModel.swift +++ b/Sources/Shared/Controllers/ToastModel.swift @@ -1,5 +1,4 @@ import UIKit -import Shared public struct ToastModel { let id: UUID diff --git a/Sources/Shared/Views/ToastView.swift b/Sources/Shared/Views/ToastView.swift new file mode 100644 index 0000000000000000000000000000000000000000..bf1254429a07344f7ba7c20b9dee19e13ebeff03 --- /dev/null +++ b/Sources/Shared/Views/ToastView.swift @@ -0,0 +1,77 @@ +import UIKit +import Combine + +final class ToastView: UIView { + let titleLabel = UILabel() + let subtitleLabel = UILabel() + let leftImageView = UIImageView() + let rightButton = UIButton() + let verticalStackView = UIStackView() + let horizontalStackView = UIStackView() + var cancellables = Set<AnyCancellable>() + + init(model: ToastModel) { + super.init(frame: .zero) + backgroundColor = model.color + layer.cornerRadius = 18.0 + + titleLabel.textColor = .white + subtitleLabel.textColor = .white + leftImageView.contentMode = .center + + titleLabel.numberOfLines = 0 + subtitleLabel.numberOfLines = 0 + titleLabel.font = Fonts.Mulish.semiBold.font(size: 16.0) + subtitleLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) + + leftImageView.image = Asset.sharedSuccess.image + leftImageView.setContentHuggingPriority(.required, for: .horizontal) + + rightButton.titleLabel?.numberOfLines = 0 + rightButton.titleLabel?.textAlignment = .center + rightButton.titleLabel?.font = Fonts.Mulish.bold.font(size: 12.0) + + verticalStackView.axis = .vertical + verticalStackView.distribution = .fill + verticalStackView.addArrangedSubview(titleLabel) + verticalStackView.addArrangedSubview(subtitleLabel) + + horizontalStackView.spacing = 12 + horizontalStackView.addArrangedSubview(leftImageView) + horizontalStackView.addArrangedSubview(verticalStackView) + horizontalStackView.addArrangedSubview(rightButton) + + addSubview(horizontalStackView) + + horizontalStackView.snp.makeConstraints { + $0.top.equalToSuperview().offset(17) + $0.left.equalToSuperview().offset(20) + $0.right.equalToSuperview().offset(-20) + $0.bottom.equalToSuperview().offset(-17) + } + + titleLabel.text = model.title + leftImageView.image = model.leftImage + + if let subtitle = model.subtitle { + subtitleLabel.text = subtitle + subtitleLabel.numberOfLines = 0 + } else { + subtitleLabel.isHidden = true + } + + if let buttonTitle = model.buttonTitle { + rightButton.setTitle(buttonTitle, for: .normal) + rightButton.setContentHuggingPriority(.required, for: .horizontal) + } else { + rightButton.isHidden = true + } + + rightButton + .publisher(for: .touchUpInside) + .sink { model.onTapClosure?() } + .store(in: &cancellables) + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/TermsFeature/TermsConditionsController.swift b/Sources/TermsFeature/TermsConditionsController.swift index d458ffe114d7bf80c9d2ba4b3ffe347c2e8ada48..08a6081f497f40ba68303c86fba00f8cedd4a705 100644 --- a/Sources/TermsFeature/TermsConditionsController.swift +++ b/Sources/TermsFeature/TermsConditionsController.swift @@ -1,5 +1,4 @@ import UIKit -import Theme import WebKit import Shared import Combine @@ -7,80 +6,79 @@ import Defaults import DependencyInjection public final class TermsConditionsController: UIViewController { - @Dependency var coordinator: TermsCoordinator - @Dependency var statusBarController: StatusBarStyleControlling + @Dependency var coordinator: TermsCoordinator - @KeyObject(.username, defaultValue: nil) var username: String? - @KeyObject(.acceptedTerms, defaultValue: false) var didAcceptTerms: Bool + @KeyObject(.username, defaultValue: nil) var username: String? + @KeyObject(.acceptedTerms, defaultValue: false) var didAcceptTerms: Bool - lazy private var screenView = TermsConditionsView() + lazy private var screenView = TermsConditionsView() - private var cancellables = Set<AnyCancellable>() + private var cancellables = Set<AnyCancellable>() - public override func loadView() { - view = screenView - } + public override func loadView() { + view = screenView + } - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationItem.backButtonTitle = "" - navigationController?.navigationBar.customize( - translucent: true, - tint: Asset.neutralWhite.color - ) - } + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + navigationController?.navigationBar.customize( + translucent: true, + tint: Asset.neutralWhite.color + ) + } - public override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() - let gradient = CAGradientLayer() - gradient.colors = [ - UIColor(red: 122/255, green: 235/255, blue: 239/255, alpha: 1).cgColor, - UIColor(red: 56/255, green: 204/255, blue: 232/255, alpha: 1).cgColor, - UIColor(red: 63/255, green: 186/255, blue: 253/255, alpha: 1).cgColor, - UIColor(red: 98/255, green: 163/255, blue: 255/255, alpha: 1).cgColor - ] + let gradient = CAGradientLayer() + gradient.colors = [ + UIColor(red: 122/255, green: 235/255, blue: 239/255, alpha: 1).cgColor, + UIColor(red: 56/255, green: 204/255, blue: 232/255, alpha: 1).cgColor, + UIColor(red: 63/255, green: 186/255, blue: 253/255, alpha: 1).cgColor, + UIColor(red: 98/255, green: 163/255, blue: 255/255, alpha: 1).cgColor + ] - gradient.startPoint = CGPoint(x: 0, y: 0) - gradient.endPoint = CGPoint(x: 1, y: 1) + gradient.startPoint = CGPoint(x: 0, y: 0) + gradient.endPoint = CGPoint(x: 1, y: 1) - gradient.frame = screenView.bounds - screenView.layer.insertSublayer(gradient, at: 0) - } + gradient.frame = screenView.bounds + screenView.layer.insertSublayer(gradient, at: 0) + } - public override func viewDidLoad() { - super.viewDidLoad() + public override func viewDidLoad() { + super.viewDidLoad() - screenView.radioComponent - .radioButton - .publisher(for: .touchUpInside) - .sink { [unowned self] in - screenView.radioComponent.isEnabled.toggle() - screenView.nextButton.isEnabled = screenView.radioComponent.isEnabled - UIImpactFeedbackGenerator(style: .heavy).impactOccurred() - }.store(in: &cancellables) + screenView.radioComponent + .radioButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in + screenView.radioComponent.isEnabled.toggle() + screenView.nextButton.isEnabled = screenView.radioComponent.isEnabled + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + }.store(in: &cancellables) - screenView.nextButton - .publisher(for: .touchUpInside) - .sink { [unowned self] in - didAcceptTerms = true + screenView.nextButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in + didAcceptTerms = true - if let _ = username { - coordinator.presentChatList(self) - } else { - coordinator.presentUsername(self) - } - }.store(in: &cancellables) + if let _ = username { + coordinator.presentChatList(self) + } else { + coordinator.presentUsername(self) + } + }.store(in: &cancellables) - screenView.showTermsButton - .publisher(for: .touchUpInside) - .sink { [unowned self] _ in - let webView = WKWebView() - let webController = UIViewController() - webController.view.addSubview(webView) - webView.snp.makeConstraints { $0.edges.equalToSuperview() } - webView.load(URLRequest(url: URL(string: "https://elixxir.io/eula")!)) - present(webController, animated: true) - }.store(in: &cancellables) - } + screenView.showTermsButton + .publisher(for: .touchUpInside) + .sink { [unowned self] _ in + let webView = WKWebView() + let webController = UIViewController() + webController.view.addSubview(webView) + webView.snp.makeConstraints { $0.edges.equalToSuperview() } + webView.load(URLRequest(url: URL(string: "https://elixxir.io/eula")!)) + present(webController, animated: true) + }.store(in: &cancellables) + } } diff --git a/Sources/Theme/StatusBarViewController.swift b/Sources/Theme/StatusBarViewController.swift deleted file mode 100644 index d5dd7b000aa81a0180a517e69dc347016fa7a74c..0000000000000000000000000000000000000000 --- a/Sources/Theme/StatusBarViewController.swift +++ /dev/null @@ -1,59 +0,0 @@ -import UIKit -import Combine -import DependencyInjection - -public protocol StatusBarStyleControlling { - var style: CurrentValueSubject<UIStatusBarStyle, Never> { get } -} - -public struct StatusBarController: StatusBarStyleControlling { - public init() {} - - public let style = CurrentValueSubject<UIStatusBarStyle, Never>(.lightContent) -} - -public final class StatusBarViewController: UIViewController { - private let content: UIViewController? - private var cancellables = Set<AnyCancellable>() - - @Dependency private var statusBarController: StatusBarStyleControlling - - public init(_ content: UIViewController?) { - self.content = content - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { nil } - - public override var preferredStatusBarStyle: UIStatusBarStyle { - statusBarController.style.value - } - - public override func loadView() { - let view = UIView() - view.backgroundColor = .clear - self.view = view - } - - public override func viewDidLoad() { - super.viewDidLoad() - - if let content = content { - addChild(content) - view.addSubview(content.view) - content.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - content.view.frame = view.bounds - content.didMove(toParent: self) - } else { - view.isUserInteractionEnabled = false - } - - statusBarController.style - .receive(on: DispatchQueue.main) - .sink { [weak self] style in - UIView.animate(withDuration: 0.2) { - self?.setNeedsStatusBarAppearanceUpdate() - } - }.store(in: &cancellables) - } -} diff --git a/Sources/Theme/ThemeController.swift b/Sources/Theme/ThemeController.swift deleted file mode 100644 index 255f1063f39a94670b4a9e4e7bbbe81e54956a73..0000000000000000000000000000000000000000 --- a/Sources/Theme/ThemeController.swift +++ /dev/null @@ -1,41 +0,0 @@ -import UIKit -import Combine -import Defaults - -public enum Theme: Int { - case system = 0 - case dark - - public var userInterfaceStyle: UIUserInterfaceStyle { - switch self { - case .system: - return .unspecified - case .dark: - return .dark - } - } -} - -public protocol ThemeControlling { - var theme: CurrentValueSubject<Theme, Never> { get } -} - -public final class ThemeController: ThemeControlling { - // MARK: Stored - - @KeyObject(.theme, defaultValue: 0) var storedTheme: Int - - // MARK: Properties - - private var cancellables = Set<AnyCancellable>() - public let theme = CurrentValueSubject<Theme, Never>(.system) - - // MARK: Lifecycle - - public init() { - theme.send(Theme(rawValue: storedTheme) ?? .system) - - theme.sink { [unowned self] in storedTheme = $0.rawValue } - .store(in: &cancellables) - } -} diff --git a/Sources/Theme/Window.swift b/Sources/Theme/Window.swift deleted file mode 100644 index 8afb304005d85a8657b2f6bb2b1b5dba7a039546..0000000000000000000000000000000000000000 --- a/Sources/Theme/Window.swift +++ /dev/null @@ -1,19 +0,0 @@ -import UIKit -import Combine -import DependencyInjection - -public final class Window: UIWindow { - @Dependency private var themeController: ThemeControlling - - private var cancellables = Set<AnyCancellable>() - - public init() { - super.init(frame: UIScreen.main.bounds) - - themeController.theme - .sink { [unowned self] in overrideUserInterfaceStyle = $0.userInterfaceStyle } - .store(in: &cancellables) - } - - required init?(coder: NSCoder) { nil } -} diff --git a/Sources/ToastFeature/ToastView.swift b/Sources/ToastFeature/ToastView.swift deleted file mode 100644 index c5c96561df06b942bd640a30cdf308bed014a80f..0000000000000000000000000000000000000000 --- a/Sources/ToastFeature/ToastView.swift +++ /dev/null @@ -1,78 +0,0 @@ -import UIKit -import Shared -import Combine - -final class ToastView: UIView { - private let titleLabel = UILabel() - private let subtitleLabel = UILabel() - private let leftImageView = UIImageView() - private let rightButton = UIButton() - private let verticalStackView = UIStackView() - private let horizontalStackView = UIStackView() - private var cancellables = Set<AnyCancellable>() - - init(model: ToastModel) { - super.init(frame: .zero) - backgroundColor = model.color - layer.cornerRadius = 18.0 - - titleLabel.textColor = .white - subtitleLabel.textColor = .white - leftImageView.contentMode = .center - - titleLabel.numberOfLines = 0 - subtitleLabel.numberOfLines = 0 - titleLabel.font = Fonts.Mulish.semiBold.font(size: 16.0) - subtitleLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) - - leftImageView.image = Asset.sharedSuccess.image - leftImageView.setContentHuggingPriority(.required, for: .horizontal) - - rightButton.titleLabel?.numberOfLines = 0 - rightButton.titleLabel?.textAlignment = .center - rightButton.titleLabel?.font = Fonts.Mulish.bold.font(size: 12.0) - - verticalStackView.axis = .vertical - verticalStackView.distribution = .fill - verticalStackView.addArrangedSubview(titleLabel) - verticalStackView.addArrangedSubview(subtitleLabel) - - horizontalStackView.spacing = 12 - horizontalStackView.addArrangedSubview(leftImageView) - horizontalStackView.addArrangedSubview(verticalStackView) - horizontalStackView.addArrangedSubview(rightButton) - - addSubview(horizontalStackView) - - horizontalStackView.snp.makeConstraints { - $0.top.equalToSuperview().offset(17) - $0.left.equalToSuperview().offset(20) - $0.right.equalToSuperview().offset(-20) - $0.bottom.equalToSuperview().offset(-17) - } - - titleLabel.text = model.title - leftImageView.image = model.leftImage - - if let subtitle = model.subtitle { - subtitleLabel.text = subtitle - subtitleLabel.numberOfLines = 0 - } else { - subtitleLabel.isHidden = true - } - - if let buttonTitle = model.buttonTitle { - rightButton.setTitle(buttonTitle, for: .normal) - rightButton.setContentHuggingPriority(.required, for: .horizontal) - } else { - rightButton.isHidden = true - } - - rightButton - .publisher(for: .touchUpInside) - .sink { model.onTapClosure?() } - .store(in: &cancellables) - } - - required init?(coder: NSCoder) { nil } -} diff --git a/Sources/ToastFeature/ToastViewController.swift b/Sources/ToastFeature/ToastViewController.swift deleted file mode 100644 index 35c68a531da1d7197d3ed709472c07b452d463c6..0000000000000000000000000000000000000000 --- a/Sources/ToastFeature/ToastViewController.swift +++ /dev/null @@ -1,134 +0,0 @@ -import UIKit -import Combine -import DependencyInjection - -public final class ToastViewController: UIViewController { - @Dependency private var controller: ToastController - - private var timer: Timer? - private let content: UIViewController - private let toastTopPadding: CGFloat = 10 - private var cancellables = Set<AnyCancellable>() - private var topToastConstraint: NSLayoutConstraint? - - public init(_ content: UIViewController) { - self.content = content - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { nil } - - public override func loadView() { - let view = UIView() - view.backgroundColor = .clear - self.view = view - } - - override public func viewDidLoad() { - super.viewDidLoad() - - addChild(content) - view.addSubview(content.view) - content.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - content.view.frame = view.bounds - content.didMove(toParent: self) - - controller.currentToast - .receive(on: DispatchQueue.main) - .sink { [unowned self] model in - let toastView = ToastView(model: model) - add(toastView: toastView) - present(toastView: toastView) - }.store(in: &cancellables) - } - - @objc private func didPanToast(_ sender: UIPanGestureRecognizer) { - guard let toastView = sender.view else { return } - - switch sender.state { - case .began, .changed: - timer?.invalidate() - let padding = toastTopPadding + min(0, sender.translation(in: view).y) - topToastConstraint?.constant = padding - - case .cancelled, .ended, .failed: - let halfFrameHeight = -0.5 * toastView.frame.height - let verticalTranslation = sender.translation(in: toastView).y - let didSwipeAboveHalf = verticalTranslation < halfFrameHeight - - if didSwipeAboveHalf { - dismiss(toastView: toastView) - } else { - present(toastView: toastView) - } - - case .possible: - break - @unknown default: - break - } - } - - private func dismiss(toastView: UIView) { - toastView.isUserInteractionEnabled = false - topToastConstraint?.constant = -(toastView.frame.height + view.safeAreaLayoutGuide.layoutFrame.minY) - - topToastConstraint = nil - UIView.animate(withDuration: 0.25) { - self.view.setNeedsLayout() - self.view.layoutIfNeeded() - } completion: { _ in - toastView.isUserInteractionEnabled = true - toastView.removeFromSuperview() - self.controller.dismissCurrentToast() - } - } - - private func add(toastView: UIView) { - let gestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(didPanToast(_:))) - toastView.addGestureRecognizer(gestureRecognizer) - - toastView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(toastView) - - NSLayoutConstraint.activate([ - toastView.heightAnchor.constraint(equalToConstant: 78), - toastView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor, constant: 20), - toastView.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor, constant: -20) - ]) - - topToastConstraint = toastView.topAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.topAnchor, - constant: -(toastView.frame.height + view.safeAreaLayoutGuide.layoutFrame.height) - ) - - topToastConstraint?.isActive = true - - view.setNeedsLayout() - view.layoutIfNeeded() - } - - private func present(toastView: UIView) { - toastView.isUserInteractionEnabled = false - topToastConstraint?.constant = toastTopPadding - - UIView.animate( - withDuration: 0.5, - delay: 0, - usingSpringWithDamping: 1, - initialSpringVelocity: 0.5, - options: .curveEaseInOut - ) { - self.view.setNeedsLayout() - self.view.layoutIfNeeded() - } completion: { _ in - toastView.isUserInteractionEnabled = true - - self.timer?.invalidate() - self.timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in - guard let self = self else { return } - self.dismiss(toastView: toastView) - } - } - } -} diff --git a/Tests/OnboardingFeatureTests/Coordinator/OnboardingCoordinatorSpec.swift b/Tests/OnboardingFeatureTests/Coordinator/OnboardingCoordinatorSpec.swift index 2ffb72ee0e6560a6162eace8dcbd0fa788a87197..fdf5e58c4ca17d0ec192af873f89383b86d3d44d 100644 --- a/Tests/OnboardingFeatureTests/Coordinator/OnboardingCoordinatorSpec.swift +++ b/Tests/OnboardingFeatureTests/Coordinator/OnboardingCoordinatorSpec.swift @@ -1,6 +1,5 @@ import UIKit import Quick -import Theme import Nimble import Combine import TestHelpers diff --git a/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved b/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved index b1975fc1c84bad46fd59eaa07c5716d0682d0931..659e607ccb07a4ee550069a01dc5b919f4c25419 100644 --- a/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -275,6 +275,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/darrarski/Shout.git", "state" : { + "branch" : "master", "revision" : "df5a662293f0ac15eeb4f2fd3ffd0c07b73d0de0" } },