diff --git a/App/client-ios.xcodeproj/project.pbxproj b/App/client-ios.xcodeproj/project.pbxproj index 0876fa9598cb2d915930270dfc19ba8e7e1c5ecb..3fa6e6adb9c619d26155f6b6a66c375031a734b4 100644 --- a/App/client-ios.xcodeproj/project.pbxproj +++ b/App/client-ios.xcodeproj/project.pbxproj @@ -448,7 +448,7 @@ CODE_SIGN_ENTITLEMENTS = "client-ios/Resources/client-ios.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 58; + CURRENT_PROJECT_VERSION = 67; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = S6JDM2WW29; ENABLE_BITCODE = NO; @@ -463,7 +463,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.8; + MARKETING_VERSION = 1.0.9; OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = xx.messenger.mock; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -487,7 +487,7 @@ CODE_SIGN_ENTITLEMENTS = "client-ios/Resources/client-ios.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 58; + CURRENT_PROJECT_VERSION = 67; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = S6JDM2WW29; ENABLE_BITCODE = NO; @@ -503,7 +503,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.8; + MARKETING_VERSION = 1.0.9; OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = xx.messenger; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -522,7 +522,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationExtension/NotificationExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 58; + CURRENT_PROJECT_VERSION = 67; DEVELOPMENT_TEAM = S6JDM2WW29; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -536,7 +536,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.8; + MARKETING_VERSION = 1.0.9; PRODUCT_BUNDLE_IDENTIFIER = xx.messenger.mock.notifications; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -553,7 +553,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationExtension/NotificationExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 58; + CURRENT_PROJECT_VERSION = 67; DEVELOPMENT_TEAM = S6JDM2WW29; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -567,7 +567,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.8; + MARKETING_VERSION = 1.0.9; PRODUCT_BUNDLE_IDENTIFIER = xx.messenger.notifications; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Package.swift b/Package.swift index cf6ac5fc3323dffb1360e4b5863725f2d5751b31..ce66cf78fb427b5813a1fc9878c8bea961f9d885 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,6 @@ let package = Package( .library(name: "App", targets: ["App"]), .library(name: "HUD", targets: ["HUD"]), .library(name: "Theme", targets: ["Theme"]), - .library(name: "Popup", targets: ["Popup"]), .library(name: "Shared", targets: ["Shared"]), .library(name: "Models", targets: ["Models"]), .library(name: "XXLogger", targets: ["XXLogger"]), @@ -33,6 +32,7 @@ let package = Package( .library(name: "BackupFeature", targets: ["BackupFeature"]), .library(name: "iCloudFeature", targets: ["iCloudFeature"]), .library(name: "SearchFeature", targets: ["SearchFeature"]), + .library(name: "DrawerFeature", targets: ["DrawerFeature"]), .library(name: "RestoreFeature", targets: ["RestoreFeature"]), .library(name: "CrashReporting", targets: ["CrashReporting"]), .library(name: "ProfileFeature", targets: ["ProfileFeature"]), @@ -152,6 +152,7 @@ let package = Package( "ScanFeature", "ChatFeature", "MenuFeature", + "ToastFeature", "CrashService", "BackupFeature", "SearchFeature", @@ -163,6 +164,7 @@ let package = Package( "CrashReporting", "ChatListFeature", "SettingsFeature", + "RequestsFeature", "PushNotifications", "OnboardingFeature", "GoogleDriveFeature", @@ -253,6 +255,15 @@ let package = Package( ] ), + // MARK: - ToastFeature + + .target( + name: "ToastFeature", + dependencies: [ + "Shared" + ] + ), + // MARK: - CrashService .target( @@ -330,12 +341,13 @@ let package = Package( ] ), - // MARK: - Popup + // MARK: - DrawerFeature .target( - name: "Popup", + name: "DrawerFeature", dependencies: [ "Shared", + "InputField", .product( name: "ScrollViewController", package: "ScrollViewController" @@ -500,15 +512,15 @@ let package = Package( dependencies: [ "HUD", "Theme", - "Popup", "Shared", "Defaults", "Keychain", + "Voxophone", "Integration", - "ChatInputFeature", "Permissions", - "Voxophone", "Presentation", + "DrawerFeature", + "ChatInputFeature", "DependencyInjection", .product( name: "DifferenceKit", @@ -548,6 +560,7 @@ let package = Package( "Theme", "Shared", "Integration", + "ToastFeature", "ContactFeature", "DependencyInjection", .product( @@ -564,15 +577,16 @@ let package = Package( dependencies: [ "HUD", "Theme", - "Popup", "Shared", "Keychain", "Defaults", "Countries", "InputField", + "MenuFeature", "Permissions", "Integration", "Presentation", + "DrawerFeature", "DependencyInjection", .product( name: "ScrollViewController", @@ -612,7 +626,6 @@ let package = Package( name: "OnboardingFeature", dependencies: [ "HUD", - "Popup", "Shared", "Defaults", "Keychain", @@ -621,6 +634,7 @@ let package = Package( "Permissions", "Integration", "Presentation", + "DrawerFeature", "VersionChecking", "PushNotifications", "DependencyInjection", @@ -640,12 +654,12 @@ let package = Package( .target( name: "MenuFeature", dependencies: [ + "Theme", + "Shared", "Defaults", + "Integration", "Presentation", - "ProfileFeature", - "RequestsFeature", - "SettingsFeature", - "ContactListFeature" + "DependencyInjection" ] ), @@ -711,14 +725,15 @@ let package = Package( dependencies: [ "HUD", "Theme", - "Popup", "Shared", "Defaults", "Keychain", "InputField", "Permissions", + "MenuFeature", "Integration", "Presentation", + "DrawerFeature", "PushNotifications", "DependencyInjection", .product( diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 927bcf4d17dcb4b3825b36374390634effc168b3..b6319b9cef9b381e616ed1221d51c6c7c7fb33b7 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -6,6 +6,7 @@ import Theme import XXLogger import Defaults import Integration +import ToastFeature import SwiftyDropbox import CrashReporting import PushNotifications @@ -51,8 +52,13 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { UNUserNotificationCenter.current().delegate = self - let rootScreen = StatusBarViewController( - UINavigationController(rootViewController: OnboardingLaunchController()) + let rootScreen = + StatusBarViewController( + ToastViewController( + UINavigationController( + rootViewController: OnboardingLaunchController() + ) + ) ) window = Window() diff --git a/Sources/App/DependencyRegistrator.swift b/Sources/App/DependencyRegistrator.swift index aa07ecd90afcaa69874c49b578a1e43b247032b0..5d87fd000b19903e982cd03e4ba4c9bfa742ab2a 100644 --- a/Sources/App/DependencyRegistrator.swift +++ b/Sources/App/DependencyRegistrator.swift @@ -18,6 +18,7 @@ import Voxophone import Integration import Permissions import CrashService +import ToastFeature import iCloudFeature import CrashReporting import NetworkMonitor @@ -101,13 +102,25 @@ struct DependencyRegistrator { container.register(HUD() as HUDType) container.register(ThemeController() as ThemeControlling) + container.register(ToastController()) container.register(StatusBarController() as StatusBarStyleControlling) // MARK: Coordinators - container.register(BackupCoordinator( - passphraseFactory: BackupPassphraseController.init(_:_:) - ) as BackupCoordinating) + container.register( + BackupCoordinator( + passphraseFactory: BackupPassphraseController.init(_:_:) + ) as BackupCoordinating) + + container.register( + MenuCoordinator( + scanFactory: ScanContainerController.init, + chatsFactory: ChatListController.init, + profileFactory: ProfileController.init, + settingsFactory: SettingsController.init, + contactsFactory: ContactListController.init, + requestsFactory: RequestsContainerController.init + ) as MenuCoordinating) container.register( SearchCoordinator( @@ -121,6 +134,7 @@ struct DependencyRegistrator { phoneFactory: ProfilePhoneController.init, imagePickerFactory: UIImagePickerController.init, permissionFactory: RequestPermissionController.init, + sideMenuFactory: MenuController.init(_:_:), countriesFactory: CountryListController.init(_:), codeFactory: ProfileCodeController.init(_:_:) ) as ProfileCoordinating) @@ -129,7 +143,8 @@ struct DependencyRegistrator { SettingsCoordinator( backupFactory: BackupController.init, advancedFactory: SettingsAdvancedController.init, - accountDeleteFactory: AccountDeleteController.init + accountDeleteFactory: AccountDeleteController.init, + sideMenuFactory: MenuController.init(_:_:) ) as SettingsCoordinating) container.register( @@ -155,21 +170,24 @@ struct DependencyRegistrator { requestsFactory: RequestsContainerController.init, singleChatFactory: SingleChatController.init(_:), imagePickerFactory: UIImagePickerController.init, - nicknameFactory: NickameController.init(_:_:) + nicknameFactory: NicknameController.init(_:_:) ) as ContactCoordinating) container.register( RequestsCoordinator( searchFactory: SearchController.init, - verifyingFactory: VerifyingController.init, contactFactory: ContactController.init(_:), - nicknameFactory: NickameController.init(_:_:) + singleChatFactory: SingleChatController.init(_:), + groupChatFactory: GroupChatController.init(_:), + sideMenuFactory: MenuController.init(_:_:), + nicknameFactory: NicknameController.init(_:_:) ) as RequestsCoordinating) container.register( OnboardingCoordinator( emailFactory: OnboardingEmailController.init, phoneFactory: OnboardingPhoneController.init, + searchFactory: SearchController.init, welcomeFactory: OnboardingWelcomeController.init, chatListFactory: ChatListController.init, startFactory: OnboardingStartController.init(_:), @@ -188,28 +206,28 @@ struct DependencyRegistrator { newGroupFactory: CreateGroupController.init, requestsFactory: RequestsContainerController.init, contactFactory: ContactController.init(_:), + singleChatFactory: SingleChatController.init(_:), groupChatFactory: GroupChatController.init(_:), - groupPopupFactory: CreatePopupController.init(_:_:) + sideMenuFactory: MenuController.init(_:_:), + groupDrawerFactory: CreateDrawerController.init(_:_:) ) as ContactListCoordinating) container.register( ScanCoordinator( contactsFactory: ContactListController.init, requestsFactory: RequestsContainerController.init, - contactFactory: ContactController.init(_:) + contactFactory: ContactController.init(_:), + sideMenuFactory: MenuController.init(_:_:) ) as ScanCoordinating) container.register( ChatListCoordinator( scanFactory: ScanContainerController.init, searchFactory: SearchController.init, - profileFactory: ProfileController.init, - settingsFactory: SettingsController.init, contactsFactory: ContactListController.init, - requestsFactory: RequestsContainerController.init, singleChatFactory: SingleChatController.init(_:), - sideMenuFactory: MenuController.init(_:), - groupChatFactory: GroupChatController.init(_:) + groupChatFactory: GroupChatController.init(_:), + sideMenuFactory: MenuController.init(_:_:) ) as ChatListCoordinating) } } diff --git a/Sources/BackupFeature/Controllers/BackupConfigController.swift b/Sources/BackupFeature/Controllers/BackupConfigController.swift index 04b1c56b48c0363d899fd93e316b0e97af5bdd74..c61bd74d6c971c544087d7020db72ce16d11e763 100644 --- a/Sources/BackupFeature/Controllers/BackupConfigController.swift +++ b/Sources/BackupFeature/Controllers/BackupConfigController.swift @@ -1,8 +1,8 @@ import UIKit -import Popup import Models import Shared import Combine +import DrawerFeature import DependencyInjection final class BackupConfigController: UIViewController { @@ -12,7 +12,7 @@ final class BackupConfigController: UIViewController { private let viewModel: BackupConfigViewModel private var cancellables = Set<AnyCancellable>() - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() private var wifiOnly = false private var manualBackups = false @@ -82,12 +82,12 @@ final class BackupConfigController: UIViewController { screenView.frequencyDetailView .publisher(for: .touchUpInside) - .sink { [unowned self] in presentFrequencyPopup(manual: manualBackups) } + .sink { [unowned self] in presentFrequencyDrawer(manual: manualBackups) } .store(in: &cancellables) screenView.infrastructureDetailView .publisher(for: .touchUpInside) - .sink { [unowned self] in presentInfrastructurePopup(wifiOnly: wifiOnly) } + .sink { [unowned self] in presentInfrastructureDrawer(wifiOnly: wifiOnly) } .store(in: &cancellables) screenView.googleDriveButton @@ -193,16 +193,25 @@ final class BackupConfigController: UIViewController { } } - private func presentInfrastructurePopup(wifiOnly: Bool) { - let cancelButton = CapsuleButton() - cancelButton.setStyle(.seeThrough) - cancelButton.setTitle(Localized.ChatList.Dashboard.cancel, for: .normal) - - let wifiOnlyButton = PopupRadioButton(title: "Wi-Fi Only", isSelected: wifiOnly) - let wifiAndCellularButton = PopupRadioButton(title: "Wi-Fi and Cellular", isSelected: !wifiOnly) - - let popup = BottomPopup(with: [ - PopupLabel( + private func presentInfrastructureDrawer(wifiOnly: Bool) { + let cancelButton = DrawerCapsuleButton(model: .init( + title: Localized.ChatList.Dashboard.cancel, + style: .seeThrough + )) + + let wifiOnlyButton = DrawerRadio( + title: "Wi-Fi Only", + isSelected: wifiOnly + ) + + let wifiAndCellularButton = DrawerRadio( + title: "Wi-Fi and Cellular", + isSelected: !wifiOnly, + spacingAfter: 40 + ) + + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.extraBold.font(size: 28.0), text: Localized.Backup.Config.infrastructure, color: Asset.neutralActive.color, @@ -211,49 +220,57 @@ final class BackupConfigController: UIViewController { ), wifiOnlyButton, wifiAndCellularButton, - PopupEmptyView(height: 20.0), - PopupStackView(spacing: 20.0, views: [cancelButton]) + cancelButton ]) wifiOnlyButton.action .sink { [unowned self] in viewModel.didChooseWifiOnly(true) - popup.dismiss(animated: true) { [weak self] in - self?.popupCancellables.removeAll() + drawer.dismiss(animated: true) { [weak self] in + self?.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) wifiAndCellularButton.action .sink { [unowned self] in viewModel.didChooseWifiOnly(false) - popup.dismiss(animated: true) { [weak self] in - self?.popupCancellables.removeAll() + drawer.dismiss(animated: true) { [weak self] in + self?.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - cancelButton.publisher(for: .touchUpInside) + cancelButton.action .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in - self?.popupCancellables.removeAll() + drawer.dismiss(animated: true) { [weak self] in + self?.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } - private func presentFrequencyPopup(manual: Bool) { - let cancelButton = CapsuleButton() - cancelButton.setStyle(.seeThrough) - cancelButton.setTitle(Localized.ChatList.Dashboard.cancel, for: .normal) - - let manualButton = PopupRadioButton(title: "Manual", isSelected: manual) - let automaticButton = PopupRadioButton(title: "Automatic", isSelected: !manual) - - let popup = BottomPopup(with: [ - PopupLabel( + private func presentFrequencyDrawer(manual: Bool) { + let cancelButton = DrawerCapsuleButton(model: .init( + title: Localized.ChatList.Dashboard.cancel, + style: .seeThrough + )) + + let manualButton = DrawerRadio( + title: "Manual", + isSelected: manual + ) + + let automaticButton = DrawerRadio( + title: "Automatic", + isSelected: !manual, + spacingAfter: 40 + ) + + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.extraBold.font(size: 28.0), text: Localized.Backup.Config.frequency(serviceName), color: Asset.neutralActive.color, @@ -262,36 +279,35 @@ final class BackupConfigController: UIViewController { ), manualButton, automaticButton, - PopupEmptyView(height: 20.0), - PopupStackView(spacing: 20.0, views: [cancelButton]) + cancelButton ]) manualButton.action .sink { [unowned self] in viewModel.didChooseAutomatic(false) - popup.dismiss(animated: true) { [weak self] in - self?.popupCancellables.removeAll() + drawer.dismiss(animated: true) { [weak self] in + self?.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) automaticButton.action .sink { [unowned self] in viewModel.didChooseAutomatic(true) - popup.dismiss(animated: true) { [weak self] in - self?.popupCancellables.removeAll() + drawer.dismiss(animated: true) { [weak self] in + self?.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - cancelButton.publisher(for: .touchUpInside) + cancelButton.action .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in - self?.popupCancellables.removeAll() + drawer.dismiss(animated: true) { [weak self] in + self?.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } } diff --git a/Sources/BackupFeature/Coordinator/BackupCoordinator.swift b/Sources/BackupFeature/Coordinator/BackupCoordinator.swift index 14598be27d56881b5ac2b7d44960b08da4359d38..9dbd110e659f066dd25d5bda897ebccc14047748 100644 --- a/Sources/BackupFeature/Coordinator/BackupCoordinator.swift +++ b/Sources/BackupFeature/Coordinator/BackupCoordinator.swift @@ -3,7 +3,7 @@ import Shared import Presentation public protocol BackupCoordinating { - func toPopup( + func toDrawer( _: UIViewController, from: UIViewController ) @@ -34,7 +34,7 @@ public struct BackupCoordinator: BackupCoordinating { } public extension BackupCoordinator { - func toPopup( + func toDrawer( _ screen: UIViewController, from parent: UIViewController ) { diff --git a/Sources/ChatFeature/Controllers/GroupChatController.swift b/Sources/ChatFeature/Controllers/GroupChatController.swift index 8447c9bc1b840bc0097aa48733a5450ff23e4a67..174bb5fa7e2f39ca9d793b81a195150e68fdd407 100644 --- a/Sources/ChatFeature/Controllers/GroupChatController.swift +++ b/Sources/ChatFeature/Controllers/GroupChatController.swift @@ -57,7 +57,7 @@ public final class GroupChatController: UIViewController { super.init(nibName: nil, bundle: nil) - header.setup(title: info.group.name, members: info.members) + header.setup(title: info.group.name, memberList: info.members.map { ($0.username, $0.photo) }) } public required init?(coder: NSCoder) { nil } @@ -490,19 +490,19 @@ extension GroupChatController: UICollectionViewDelegate { let item = self.sections[indexPath.section].elements[indexPath.item] - let copy = UIAction(title: Localized.Chat.BubbleMenu.copy, state: .on) { _ in + let copy = UIAction(title: Localized.Chat.BubbleMenu.copy, state: .off) { _ in UIPasteboard.general.string = item.payload.text } - let reply = UIAction(title: Localized.Chat.BubbleMenu.reply, state: .on) { [weak self] _ in + 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: .on) { [weak self] _ in + let delete = UIAction(title: Localized.Chat.BubbleMenu.delete, state: .off) { [weak self] _ in self?.viewModel.didRequestDelete([item]) } - let retry = UIAction(title: Localized.Chat.BubbleMenu.retry, state: .on) { [weak self] _ in + let retry = UIAction(title: Localized.Chat.BubbleMenu.retry, state: .off) { [weak self] _ in self?.viewModel.retry(item) } diff --git a/Sources/ChatFeature/Controllers/MembersController.swift b/Sources/ChatFeature/Controllers/MembersController.swift index e2103dad29d0196f6c2f382b52a8fc3e72f8b748..488ad42517153bfa8fcb492dad11ced22ebf5a6e 100644 --- a/Sources/ChatFeature/Controllers/MembersController.swift +++ b/Sources/ChatFeature/Controllers/MembersController.swift @@ -34,7 +34,7 @@ final class MembersController: UIViewController { for member in members { let memberView = MemberView() memberView.titleLabel.text = member.username - memberView.avatarView.set(username: member.username, image: member.photo) + memberView.avatarView.setupProfile(title: member.username, image: member.photo, size: .small) stackView.addArrangedSubview(memberView) } } diff --git a/Sources/ChatFeature/Controllers/SingleChatController.swift b/Sources/ChatFeature/Controllers/SingleChatController.swift index e2b04d846f2c811a98f63449c712616266622dd7..41526deb8afa5d814546dbcf6dfb23dcc3b18ad3 100644 --- a/Sources/ChatFeature/Controllers/SingleChatController.swift +++ b/Sources/ChatFeature/Controllers/SingleChatController.swift @@ -1,5 +1,5 @@ import HUD -import Popup +import DrawerFeature import UIKit import Theme import Models @@ -25,10 +25,13 @@ public final class SingleChatController: UIViewController { @Dependency private var coordinator: ChatCoordinating @Dependency private var statusBarController: StatusBarStyleControlling - lazy private var name = UILabel() - lazy private var more = UIButton() - lazy private var back = UIButton.back() - lazy private var avatar = AvatarView() + + lazy private var infoView = UIControl() + lazy private var nameLabel = UILabel() + lazy private var avatarView = AvatarView() + + lazy private var moreButton = UIButton() + lazy private var backButton = UIButton.back() lazy private var screenView = ChatView() lazy private var sheet = SheetController() @@ -40,7 +43,7 @@ public final class SingleChatController: UIViewController { private let viewModel: SingleChatViewModel private let layoutDelegate = LayoutDelegate() private var cancellables = Set<AnyCancellable>() - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() private var sections = [ArraySection<ChatSection, ChatItem>]() private var currentInterfaceActions: SetActor<Set<InterfaceActions>, ReactionTypes> = SetActor() @@ -151,32 +154,39 @@ public final class SingleChatController: UIViewController { private func setupNavigationBar(contact: Contact) { screenView.set(name: contact.nickname ?? contact.username) - avatar.snp.makeConstraints { $0.width.height.equalTo(35) } + avatarView.snp.makeConstraints { $0.width.height.equalTo(35) } + avatarView.setupProfile(title: contact.nickname ?? contact.username, image: contact.photo, size: .small) - avatar.set( - cornerRadius: 10, - username: contact.nickname ?? contact.username, - image: contact.photo - ) + nameLabel.text = contact.nickname ?? contact.username + nameLabel.textColor = Asset.neutralActive.color + nameLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) + + backButton.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) + + moreButton.setImage(Asset.chatMore.image, for: .normal) + moreButton.addTarget(self, action: #selector(didTapDots), for: .touchUpInside) - name.text = contact.nickname ?? contact.username - name.textColor = Asset.neutralActive.color - name.font = Fonts.Mulish.semiBold.font(size: 18.0) + infoView.addTarget(self, action: #selector(didTapInfo), for: .touchUpInside) - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) + infoView.addSubview(avatarView) + infoView.addSubview(nameLabel) - more.setImage(Asset.chatMore.image, for: .normal) - more.addTarget(self, action: #selector(didTapDots), for: .touchUpInside) + avatarView.snp.makeConstraints { + $0.top.left.bottom.equalToSuperview() + } - let stack = UIStackView() - stack.addArrangedSubview(back) - stack.addArrangedSubview(avatar) - stack.addArrangedSubview(name) + nameLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.left.equalTo(avatarView.snp.right).offset(13) + $0.right.lessThanOrEqualToSuperview() + } - stack.setCustomSpacing(13, after: avatar) + let stackView = UIStackView() + stackView.addArrangedSubview(backButton) + stackView.addArrangedSubview(infoView) - navigationItem.rightBarButtonItem = UIBarButtonItem(customView: more) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: stack) + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: moreButton) + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: stackView) } private func setupInputController() { @@ -232,7 +242,7 @@ public final class SingleChatController: UIViewController { .sink { [unowned self] in switch $0 { case .clear: - presentDeleteAllPopup() + presentDeleteAllDrawer() case .details: coordinator.toContact(viewModel.contact, from: self) } @@ -341,57 +351,56 @@ public final class SingleChatController: UIViewController { } } - private func presentDeleteAllPopup() { - let clear = CapsuleButton() - clear.setStyle(.red) - clear.setTitle(Localized.Chat.Clear.action, for: .normal) + private func presentDeleteAllDrawer() { + let clearButton = CapsuleButton() + clearButton.setStyle(.red) + clearButton.setTitle(Localized.Chat.Clear.action, for: .normal) - let cancel = CapsuleButton() - cancel.setStyle(.seeThrough) - cancel.setTitle(Localized.Chat.Clear.cancel, for: .normal) + let cancelButton = CapsuleButton() + cancelButton.setStyle(.seeThrough) + cancelButton.setTitle(Localized.Chat.Clear.cancel, for: .normal) - let popup = BottomPopup(with: [ - PopupImage(image: Asset.popupNegative.image), - PopupLabel( + 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 ), - PopupLabel( + DrawerText( font: Fonts.Mulish.semiBold.font(size: 14.0), text: Localized.Chat.Clear.subtitle, color: Asset.neutralWeak.color, lineHeightMultiple: 1.35, spacingAfter: 25 ), - PopupStackView( + DrawerStack( spacing: 20.0, - views: [ - clear, - cancel - ] + views: [clearButton, cancelButton] ) ]) - clear.publisher(for: .touchUpInside) + clearButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() self.viewModel.didRequestDeleteAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - cancel.publisher(for: .touchUpInside) + cancelButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in - self?.popupCancellables.removeAll() + drawer.dismiss(animated: true) { [weak self] in + self?.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } private func previewItemAt(_ indexPath: IndexPath) { @@ -407,6 +416,10 @@ public final class SingleChatController: UIViewController { coordinator.toMenuSheet(sheet, from: self) } + @objc private func didTapInfo() { + coordinator.toContact(viewModel.contact, from: self) + } + @objc private func didTapBack() { navigationController?.popViewController(animated: true) } diff --git a/Sources/ChatFeature/Coordinator/ChatCoordinator.swift b/Sources/ChatFeature/Coordinator/ChatCoordinator.swift index ce1f35c8e71789508d88cca0294a03a934801804..3c04dc1ed0513f95e3fef74248df0fb5edac6185 100644 --- a/Sources/ChatFeature/Coordinator/ChatCoordinator.swift +++ b/Sources/ChatFeature/Coordinator/ChatCoordinator.swift @@ -12,7 +12,7 @@ public protocol ChatCoordinating { func toRetrySheet(from: UIViewController) func toContact(_: Contact, from: UIViewController) func toWebview(with: String, from: UIViewController) - func toPopup(_: UIViewController, from: UIViewController) + func toDrawer(_: UIViewController, from: UIViewController) func toMenuSheet(_: UIViewController, from: UIViewController) func toPermission(type: PermissionType, from: UIViewController) func toMembersList(_: UIViewController, from: UIViewController) @@ -95,8 +95,8 @@ public extension ChatCoordinator { bottomPresenter.present(screen, from: parent) } - func toPopup(_ popup: UIViewController, from parent: UIViewController) { - bottomPresenter.present(popup, from: parent) + func toDrawer(_ drawer: UIViewController, from parent: UIViewController) { + bottomPresenter.present(drawer, from: parent) } func toMenuSheet(_ screen: UIViewController, from parent: UIViewController) { diff --git a/Sources/ChatFeature/Helpers/CellConfigurator.swift b/Sources/ChatFeature/Helpers/CellConfigurator.swift index 51ca55a7fff0777b46740fcf100f9756157e6280..75ac2ce6d22af0a05d643286d9dc6bad24c7b4a2 100644 --- a/Sources/ChatFeature/Helpers/CellConfigurator.swift +++ b/Sources/ChatFeature/Helpers/CellConfigurator.swift @@ -438,8 +438,8 @@ struct ActionFactory { return UIAction( title: action.title, - state: .on, - handler: { _ in closure(item)} + state: .off, + handler: { _ in closure(item) } ) } } diff --git a/Sources/ChatFeature/Views/GroupHeaderView.swift b/Sources/ChatFeature/Views/GroupHeaderView.swift index 86927545e0f8a94206ea191f5c929bd65dcfff67..d85a49abe97265a8b0ba5bd1dec227b45e97c9ec 100644 --- a/Sources/ChatFeature/Views/GroupHeaderView.swift +++ b/Sources/ChatFeature/Views/GroupHeaderView.swift @@ -1,63 +1,54 @@ import UIKit import Shared -import Models final class GroupHeaderView: UIView { - let container = UIView() - let title = UILabel() - let stack = UIStackView() + let titleLabel = UILabel() + let containerView = UIView() + let stackView = UIStackView() init() { super.init(frame: .zero) - setup() - } - required init?(coder: NSCoder) { nil } + stackView.spacing = -8 + titleLabel.textColor = Asset.neutralActive.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 15.0) - func setup(title: String, members: [GroupMember]) { - self.title.text = title + containerView.addSubview(titleLabel) + containerView.addSubview(stackView) + addSubview(containerView) - for member in members { - let avatar = AvatarView() - avatar.set( - cornerRadius: 25/2.0, - fontSize: 10.0, - username: member.username, - image: member.photo - ) + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview() + $0.centerX.equalToSuperview() + $0.left.greaterThanOrEqualToSuperview() + $0.right.lessThanOrEqualToSuperview() + } - avatar.layer.borderWidth = 3 - avatar.layer.borderColor = UIColor.white.cgColor + stackView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom) + $0.centerX.equalToSuperview() + $0.left.greaterThanOrEqualToSuperview() + $0.right.lessThanOrEqualToSuperview() + $0.bottom.equalToSuperview() + } - avatar.snp.makeConstraints { $0.width.height.equalTo(25.0) } - stack.addArrangedSubview(avatar) + containerView.snp.makeConstraints { + $0.edges.equalToSuperview() } } - private func setup() { - title.textColor = Asset.neutralActive.color - title.font = Fonts.Mulish.semiBold.font(size: 15.0) - - container.addSubview(title) - container.addSubview(stack) - stack.spacing = -8 + required init?(coder: NSCoder) { nil } - title.snp.makeConstraints { make in - make.top.equalToSuperview() - make.centerX.equalToSuperview() - make.left.greaterThanOrEqualToSuperview() - make.right.lessThanOrEqualToSuperview() - } + func setup(title: String, memberList: [(String, Data?)]) { + titleLabel.text = title - stack.snp.makeConstraints { make in - make.top.equalTo(title.snp.bottom) - make.centerX.equalToSuperview() - make.left.greaterThanOrEqualToSuperview() - make.right.lessThanOrEqualToSuperview() - make.bottom.equalToSuperview() + for member in memberList { + let avatarView = AvatarView() + avatarView.layer.borderWidth = 3 + avatarView.layer.borderColor = UIColor.white.cgColor + avatarView.setupProfile(title: member.0, image: member.1, size: .small) + avatarView.snp.makeConstraints { $0.width.height.equalTo(25.0) } + stackView.addArrangedSubview(avatarView) } - - addSubview(container) - container.snp.makeConstraints { $0.edges.equalToSuperview() } } } diff --git a/Sources/ChatListFeature/Controller/ChatListController.swift b/Sources/ChatListFeature/Controller/ChatListController.swift index 9974b0cfb7e55d8e4b293ad06ab4a30fe277bdba..20dce02844eff56a20f51c9951fa5c42e20fdbe6 100644 --- a/Sources/ChatListFeature/Controller/ChatListController.swift +++ b/Sources/ChatListFeature/Controller/ChatListController.swift @@ -1,5 +1,5 @@ import HUD -import Popup +import DrawerFeature import UIKit import Theme import Shared @@ -25,7 +25,7 @@ public final class ChatListController: UIViewController { private var shouldPresentMenu = false private let viewModel = ChatListViewModel() private var cancellables = Set<AnyCancellable>() - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() public override var canBecomeFirstResponder: Bool { true } @@ -37,6 +37,12 @@ public final class ChatListController: UIViewController { return shouldPresentMenu ? menuView : nil } + public init() { + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + public override func loadView() { view = screenView @@ -61,11 +67,6 @@ public final class ChatListController: UIViewController { navigationController?.navigationBar.customize(backgroundColor: Asset.neutralWhite.color) } - public override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - viewModel.viewDidAppear() - } - public override func viewDidLoad() { super.viewDidLoad() setupNavigationBar() @@ -143,30 +144,18 @@ public final class ChatListController: UIViewController { .sink { [unowned self] in coordinator.toScan(from: self) } .store(in: &cancellables) - viewModel.askDummyTrafficPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - presentPopup( - title: Localized.ChatList.Traffic.title, - subtitle: Localized.ChatList.Traffic.subtitle, - actionTitle: Localized.ChatList.Traffic.positive, - cancelTitle: Localized.ChatList.Traffic.negative, - action: { [weak self] in self?.viewModel.didEnableDummyTraffic() } - ) - }.store(in: &cancellables) - tableController.deletePublisher .receive(on: DispatchQueue.main) .sink { [unowned self] ip in if viewModel.isGroup(indexPath: ip) { - presentPopup( + presentDrawer( title: Localized.ChatList.DeleteGroup.title, subtitle: Localized.ChatList.DeleteGroup.subtitle, actionTitle: Localized.ChatList.DeleteGroup.action) { [weak self] in self?.viewModel.deleteAndLeaveGroupFrom(indexPath: ip) } } else { - presentPopup( + presentDrawer( title: Localized.ChatList.Delete.title, subtitle: Localized.ChatList.Delete.subtitle, actionTitle: Localized.ChatList.Delete.delete) { [weak self] in @@ -227,53 +216,26 @@ public final class ChatListController: UIViewController { coordinator.toSideMenu(from: self) } - public func didSelect(item: MenuItem) { - switch item { - case .scan: - coordinator.toScan(from: self) - case .profile: - coordinator.toProfile(from: self) - case .contacts: - coordinator.toContacts(from: self) - case .settings: - coordinator.toSettings(from: self) - case .requests: - coordinator.toRequests(from: self) - case .join: - presentPopup( - 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: [:]) - } - case .dashboard: - presentPopup( - 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: [:]) - } - } - } - - private func presentPopup( + private func presentDrawer( title: String, subtitle: String, actionTitle: String, action: @escaping () -> Void ) { - let actionButton = PopupCapsuleButton(model: .init(title: actionTitle, style: .red)) - let popup = BottomPopup(with: [ - PopupLabel( + 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 ), - PopupLabel( + DrawerText( font: Fonts.Mulish.regular.font(size: 16.0), text: subtitle, color: Asset.neutralBody.color, @@ -286,77 +248,13 @@ public final class ChatListController: UIViewController { actionButton.action.receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() action() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } - - private func presentPopup( - title: String, - subtitle: String, - actionTitle: String, - cancelTitle: String, - action: @escaping () -> Void - ) { - let actionButton = CapsuleButton() - actionButton.set(style: .brandColored, title: actionTitle) - - let cancelButton = CapsuleButton() - cancelButton.set(style: .seeThrough, title: cancelTitle) - - let popup = BottomPopup(with: [ - PopupLabel( - font: Fonts.Mulish.bold.font(size: 26.0), - text: title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - PopupLabel( - font: Fonts.Mulish.regular.font(size: 16.0), - text: subtitle, - color: Asset.neutralBody.color, - alignment: .left, - lineHeightMultiple: 1.1, - spacingAfter: 39 - ), - PopupStackView( - axis: .horizontal, - spacing: 20, - distribution: .fillEqually, - views: [actionButton, cancelButton] - ) - ]) - - actionButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - popup.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.popupCancellables.removeAll() - action() - } - }.store(in: &popupCancellables) - - cancelButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - popup.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.popupCancellables.removeAll() - } - }.store(in: &popupCancellables) - - coordinator.toPopup(popup, from: self) - } - } - -extension ChatListController: MenuDelegate {} diff --git a/Sources/ChatListFeature/Controller/ChatListTableController.swift b/Sources/ChatListFeature/Controller/ChatListTableController.swift index 6f5e522f757b92b22f025a813e9b5c4877017a18..af7f3c55d81242a6173947f5dee2d8bc96341b7d 100644 --- a/Sources/ChatListFeature/Controller/ChatListTableController.swift +++ b/Sources/ChatListFeature/Controller/ChatListTableController.swift @@ -89,61 +89,43 @@ final class ChatListTableController: UITableViewController { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath, ofType: ChatListCell.self) let chatInfo = rows[indexPath.row] - var name: String! - if let contact = chatInfo.contact { - name = contact.nickname ?? contact.username + let name = contact.nickname ?? contact.username + cell.titleLabel.text = name + cell.avatarView.setupProfile(title: name, image: chatInfo.contact?.photo, size: .large) } else { - name = chatInfo.groupInfo!.group.name + cell.titleLabel.text = chatInfo.groupInfo!.group.name + cell.avatarView.setupGroup(size: .large) } - cell.title.text = name - cell.avatar.set( - cornerRadius: 16, - username: name, - image: chatInfo.contact?.photo - ) - cell.didLongPress = { [weak longPressRelay] in longPressRelay?.send() } if let latestGroupMessage = chatInfo.groupInfo?.lastMessage { - cell.title.alpha = 1.0 - cell.avatar.alpha = 1.0 + cell.titleLabel.alpha = 1.0 + cell.avatarView.alpha = 1.0 cell.date = Date.fromTimestamp(latestGroupMessage.timestamp) - cell.preview.text = latestGroupMessage.payload.text - cell.unread.backgroundColor = latestGroupMessage.unread ? Asset.brandPrimary.color : .clear + cell.previewLabel.text = latestGroupMessage.payload.text + cell.unreadView.backgroundColor = latestGroupMessage.unread ? Asset.brandPrimary.color : .clear } if let latestE2EMessage = chatInfo.latestE2EMessage { - cell.title.alpha = 1.0 - cell.avatar.alpha = 1.0 + cell.titleLabel.alpha = 1.0 + cell.avatarView.alpha = 1.0 cell.date = Date.fromTimestamp(latestE2EMessage.timestamp) - cell.preview.text = latestE2EMessage.payload.text - cell.unread.backgroundColor = latestE2EMessage.unread ? Asset.brandPrimary.color : .clear + cell.previewLabel.text = latestE2EMessage.payload.text + cell.unreadView.backgroundColor = latestE2EMessage.unread ? Asset.brandPrimary.color : .clear } return cell } - override func tableView( - _ tableView: UITableView, - numberOfRowsInSection section: Int - ) -> Int { rows.count } - - // MARK: UITableViewDelegate - - override func tableView( - _ tableView: UITableView, - heightForRowAt indexPath: IndexPath - ) -> CGFloat { 72 } + override func tableView(_: UITableView, numberOfRowsInSection: Int) -> Int { rows.count } - override func tableView( - _ tableView: UITableView, - trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath - ) -> UISwipeActionsConfiguration? { + override func tableView(_: UITableView, heightForRowAt: IndexPath) -> CGFloat { 72 } + override func tableView(_: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let delete = UIContextualAction(style: .normal, title: nil) { [weak self] _, _, complete in self?.deleteRelay.send(indexPath) complete(true) @@ -171,7 +153,7 @@ final class ChatListTableController: UITableViewController { } } - override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { + override func tableView(_: UITableView, didDeselectRowAt: IndexPath) { numberOfSelectedRows -= 1 } } diff --git a/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift b/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift index f1235d08ac6038666523b98ebe2c48ea6822d4be..75958ab2f3d02726bc2af8464d5b9f8956d73d1c 100644 --- a/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift +++ b/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift @@ -10,14 +10,11 @@ public typealias ChatListSheetClosure = (ChatListSheetController.Action) -> Void public protocol ChatListCoordinating { func toScan(from: UIViewController) func toSearch(from: UIViewController) - func toProfile(from: UIViewController) - func toSettings(from: UIViewController) func toContacts(from: UIViewController) - func toRequests(from: UIViewController) + func toSideMenu(from: UIViewController) func toSingleChat(with: Contact, from: UIViewController) - func toPopup(_: UIViewController, from: UIViewController) + func toDrawer(_: UIViewController, from: UIViewController) func toGroupChat(with: GroupChatInfo, from: UIViewController) - func toSideMenu<T: UIViewController>(from: T) where T: MenuDelegate } public struct ChatListCoordinator: ChatListCoordinating { @@ -28,31 +25,22 @@ public struct ChatListCoordinator: ChatListCoordinating { var scanFactory: () -> UIViewController var searchFactory: () -> UIViewController - var profileFactory: () -> UIViewController - var settingsFactory: () -> UIViewController var contactsFactory: () -> UIViewController - var requestsFactory: () -> UIViewController var singleChatFactory: (Contact) -> UIViewController - var sideMenuFactory: (MenuDelegate) -> UIViewController var groupChatFactory: (GroupChatInfo) -> UIViewController + var sideMenuFactory: (MenuItem, UIViewController) -> UIViewController public init( scanFactory: @escaping () -> UIViewController, searchFactory: @escaping () -> UIViewController, - profileFactory: @escaping () -> UIViewController, - settingsFactory: @escaping () -> UIViewController, contactsFactory: @escaping () -> UIViewController, - requestsFactory: @escaping () -> UIViewController, singleChatFactory: @escaping (Contact) -> UIViewController, - sideMenuFactory: @escaping (MenuDelegate) -> UIViewController, - groupChatFactory: @escaping (GroupChatInfo) -> UIViewController + groupChatFactory: @escaping (GroupChatInfo) -> UIViewController, + sideMenuFactory: @escaping (MenuItem, UIViewController) -> UIViewController ) { self.scanFactory = scanFactory self.searchFactory = searchFactory - self.profileFactory = profileFactory - self.settingsFactory = settingsFactory self.contactsFactory = contactsFactory - self.requestsFactory = requestsFactory self.sideMenuFactory = sideMenuFactory self.groupChatFactory = groupChatFactory self.singleChatFactory = singleChatFactory @@ -70,26 +58,11 @@ public extension ChatListCoordinator { pushPresenter.present(screen, from: parent) } - func toProfile(from parent: UIViewController) { - let screen = profileFactory() - pushPresenter.present(screen, from: parent) - } - func toContacts(from parent: UIViewController) { let screen = contactsFactory() pushPresenter.present(screen, from: parent) } - func toSettings(from parent: UIViewController) { - let screen = settingsFactory() - pushPresenter.present(screen, from: parent) - } - - func toRequests(from parent: UIViewController) { - let screen = requestsFactory() - pushPresenter.present(screen, from: parent) - } - func toSingleChat(with contact: Contact, from parent: UIViewController) { let screen = singleChatFactory(contact) pushPresenter.present(screen, from: parent) @@ -100,12 +73,12 @@ public extension ChatListCoordinator { pushPresenter.present(screen, from: parent) } - func toSideMenu<T: UIViewController>(from parent: T) where T: MenuDelegate { - let screen = sideMenuFactory(parent) + func toSideMenu(from parent: UIViewController) { + let screen = sideMenuFactory(.chats, parent) sidePresenter.present(screen, from: parent) } - func toPopup(_ popup: UIViewController, from parent: UIViewController) { - bottomPresenter.present(popup, from: parent) + func toDrawer(_ drawer: UIViewController, from parent: UIViewController) { + bottomPresenter.present(drawer, from: parent) } } diff --git a/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift b/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift index 2b704f532b05b78f52fa39a05d90a6c80c5e91a6..a78407c0f6225acef3e5efafb8a389c6bb85b69a 100644 --- a/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift +++ b/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift @@ -1,12 +1,10 @@ import HUD -import UIKit import Shared -import Combine import Models +import Combine import Defaults import Foundation import Integration -import PushNotifications import DependencyInjection protocol ChatListViewModelType { @@ -24,12 +22,8 @@ protocol ChatListViewModelType { final class ChatListViewModel: ChatListViewModelType { @Dependency private var session: SessionType - @Dependency private var pushHandler: PushHandling @KeyObject(.username, defaultValue: "") var myUsername: String - @KeyObject(.dummyTrafficOn, defaultValue: false) var isDummyTrafficOn: Bool - @KeyObject(.pushNotifications, defaultValue: false) private var pushNotifications - @KeyObject(.askedDummyTrafficOnce, defaultValue: false) var askedDummyTraffic: Bool let editState = EditStateHandler() let chatsRelay = CurrentValueSubject<[GenericChatInfo], Never>([]) @@ -39,9 +33,6 @@ final class ChatListViewModel: ChatListViewModelType { var hud: AnyPublisher<HUDStatus, Never> { hudRelay.eraseToAnyPublisher() } private let hudRelay = CurrentValueSubject<HUDStatus, Never>(.none) - var askDummyTrafficPublisher: AnyPublisher<Void, Never> { askDummyTrafficSubject.eraseToAnyPublisher() } - private let askDummyTrafficSubject = PassthroughSubject<Void, Never>() - var badgeCount: AnyPublisher<Int, Never> { Publishers.CombineLatest( session.contacts(.received), @@ -162,38 +153,6 @@ final class ChatListViewModel: ChatListViewModelType { .store(in: &cancellables) } - func viewDidAppear() { - verifyDummyTraffic() - verifyNotifications() - } - - private func verifyDummyTraffic() { - guard askedDummyTraffic == false else { return } - askedDummyTraffic = true - askDummyTrafficSubject.send() - } - - private func verifyNotifications() { - guard pushNotifications == false else { return } - - pushHandler.didRequestAuthorization { [weak self] result in - guard let self = self else { return } - - switch result { - case .success(let granted): - if granted { - DispatchQueue.main.async { - UIApplication.shared.registerForRemoteNotifications() - } - } - - self.pushNotifications = granted - case .failure: - self.pushNotifications = false - } - } - } - func isGroup(indexPath: IndexPath) -> Bool { chatsRelay.value[indexPath.row].contact == nil } @@ -228,9 +187,4 @@ final class ChatListViewModel: ChatListViewModelType { groups.forEach(session.deleteAll(from:)) contacts.forEach(session.deleteAll(from:)) } - - func didEnableDummyTraffic() { - isDummyTrafficOn = true - session.setDummyTraffic(status: true) - } } diff --git a/Sources/ChatListFeature/Views/ChatListCell.swift b/Sources/ChatListFeature/Views/ChatListCell.swift index 076d448912af4c7b17c5eeb9e0789f6b2899b3fc..fe59df03888602a5b06ccdc64dece68eb5e026f1 100644 --- a/Sources/ChatListFeature/Views/ChatListCell.swift +++ b/Sources/ChatListFeature/Views/ChatListCell.swift @@ -2,89 +2,34 @@ import UIKit import Shared final class ChatListCell: UITableViewCell { - // MARK: UI - - let title = UILabel() - let unread = UIView() - let preview = UILabel() + let titleLabel = UILabel() + let unreadView = UIView() + let previewLabel = UILabel() let dateLabel = UILabel() - let avatar = AvatarView() + let avatarView = AvatarView() let coloringView = UIView() - // MARK: Properties - private var timer: Timer? var date: Date? { - didSet { updateTimeAgoLabel() } + didSet { + updateTimeAgoLabel() + } } - deinit { timer?.invalidate() } + deinit { + timer?.invalidate() + } var didLongPress: EmptyClosure? private let longPressGesture = UILongPressGestureRecognizer(target: nil, action: nil) - // MARK: Lifecycle - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) longPressGesture.addTarget(self, action: #selector(longAction)) addGestureRecognizer(longPressGesture) - setup() - } - - required init?(coder: NSCoder) { nil } - - override func prepareForReuse() { - super.prepareForReuse() - - date = nil - title.text = nil - preview.text = nil - avatar.prepareForReuse() - } - - override func willTransition(to state: UITableViewCell.StateMask) { - super.willTransition(to: state) - - UIView.transition(with: self.coloringView, duration: 0.4, options: .transitionCrossDissolve) { - let isEditing = state == .showingEditControl - self.coloringView.backgroundColor = isEditing ? - Asset.neutralSecondary.color : Asset.neutralWhite.color - } - - UIView.transition(with: self.dateLabel, duration: 0.4, options: .transitionCrossDissolve) { - let isEditing = state == .showingEditControl - self.dateLabel.alpha = isEditing ? 0.0 : 1.0 - } - - UIView.transition(with: self.avatar, duration: 0.1, options: .transitionCrossDissolve) { - let isEditing = state == .showingEditControl - - self.avatar.snp.updateConstraints { make in - make.left.equalToSuperview().offset(isEditing ? 16 : 28) - } - } - } - - // MARK: Public - - func setup( - title: String, - photo: Data? - ) { - self.title.text = title - self.avatar.set( - cornerRadius: 16, - username: title, - image: photo - ) - } - // MARK: Private - - private func setup() { backgroundColor = .clear selectedBackgroundView = UIView() multipleSelectionBackgroundView = UIView() @@ -93,82 +38,109 @@ final class ChatListCell: UITableViewCell { self?.updateTimeAgoLabel() } - preview.numberOfLines = 2 - unread.layer.cornerRadius = 8 - avatar.layer.cornerRadius = 21 + previewLabel.numberOfLines = 2 + unreadView.layer.cornerRadius = 8 + avatarView.layer.cornerRadius = 21 dateLabel.textAlignment = .right - avatar.layer.masksToBounds = true + avatarView.layer.masksToBounds = true dateLabel.setContentHuggingPriority(.init(rawValue: 251), for: .vertical) dateLabel.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) dateLabel.setContentCompressionResistancePriority(.init(rawValue: 751), for: .vertical) dateLabel.setContentCompressionResistancePriority(.init(rawValue: 751), for: .horizontal) - unread.backgroundColor = .clear + unreadView.backgroundColor = .clear backgroundColor = Asset.neutralWhite.color - title.textColor = Asset.neutralActive.color - preview.textColor = Asset.neutralDisabled.color + titleLabel.textColor = Asset.neutralActive.color + previewLabel.textColor = Asset.neutralDisabled.color dateLabel.textColor = Asset.neutralWeak.color - title.font = Fonts.Mulish.semiBold.font(size: 14.0) - preview.font = Fonts.Mulish.regular.font(size: 12.0) + titleLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) + previewLabel.font = Fonts.Mulish.regular.font(size: 12.0) dateLabel.font = Fonts.Mulish.regular.font(size: 10.0) insertSubview(coloringView, belowSubview: contentView) - contentView.addSubview(title) - contentView.addSubview(unread) - contentView.addSubview(avatar) - contentView.addSubview(preview) + contentView.addSubview(titleLabel) + contentView.addSubview(unreadView) + contentView.addSubview(avatarView) + contentView.addSubview(previewLabel) contentView.addSubview(dateLabel) - setupConstraints() - } + coloringView.snp.makeConstraints { + $0.top.equalToSuperview().offset(6) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview().offset(-6) + } - private func updateTimeAgoLabel() { - guard let date = date else { return } - dateLabel.text = date.asRelativeFromNow() - } + avatarView.snp.makeConstraints { + $0.top.equalTo(coloringView).offset(6) + $0.left.equalToSuperview().offset(28) + $0.width.height.equalTo(48) + $0.bottom.equalTo(coloringView).offset(-6) + } - private func setupConstraints() { - coloringView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(6) - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.equalToSuperview().offset(-6) + titleLabel.snp.makeConstraints { + $0.top.equalTo(coloringView).offset(4) + $0.left.equalTo(avatarView.snp.right).offset(16) + $0.right.lessThanOrEqualTo(dateLabel.snp.left).offset(-10) } - avatar.snp.makeConstraints { make in - make.top.equalTo(coloringView).offset(6) - make.left.equalToSuperview().offset(28) - make.width.height.equalTo(48) - make.bottom.equalTo(coloringView).offset(-6) + dateLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel) + $0.right.equalToSuperview().offset(-24) } - title.snp.makeConstraints { make in - make.top.equalTo(coloringView).offset(4) - make.left.equalTo(avatar.snp.right).offset(16) - make.right.lessThanOrEqualTo(dateLabel.snp.left).offset(-10) + previewLabel.snp.makeConstraints { + $0.left.equalTo(titleLabel) + $0.top.equalTo(titleLabel.snp.bottom).offset(3) + $0.right.lessThanOrEqualTo(unreadView.snp.left).offset(-3) } - dateLabel.snp.makeConstraints { make in - make.top.equalTo(title) - make.right.equalToSuperview().offset(-24) + unreadView.snp.makeConstraints { + $0.right.equalTo(dateLabel) + $0.centerY.equalTo(previewLabel) + $0.width.height.equalTo(20) } + } - preview.snp.makeConstraints { make in - make.left.equalTo(title) - make.top.equalTo(title.snp.bottom).offset(3) - make.right.lessThanOrEqualTo(unread.snp.left).offset(-3) + required init?(coder: NSCoder) { nil } + + override func prepareForReuse() { + super.prepareForReuse() + date = nil + titleLabel.text = nil + previewLabel.text = nil + avatarView.prepareForReuse() + } + + override func willTransition(to state: UITableViewCell.StateMask) { + super.willTransition(to: state) + + UIView.transition(with: coloringView, duration: 0.4, options: .transitionCrossDissolve) { + let isEditing = state == .showingEditControl + self.coloringView.backgroundColor = isEditing ? + Asset.neutralSecondary.color : Asset.neutralWhite.color + } + + UIView.transition(with: dateLabel, duration: 0.4, options: .transitionCrossDissolve) { + let isEditing = state == .showingEditControl + self.dateLabel.alpha = isEditing ? 0.0 : 1.0 } - unread.snp.makeConstraints { make in - make.right.equalTo(dateLabel) - make.centerY.equalTo(preview) - make.width.height.equalTo(20) + UIView.transition(with: avatarView, duration: 0.1, options: .transitionCrossDissolve) { + let isEditing = state == .showingEditControl + + self.avatarView.snp.updateConstraints { + $0.left.equalToSuperview().offset(isEditing ? 16 : 28) + } } } - // MARK: Selectors + private func updateTimeAgoLabel() { + guard let date = date else { return } + dateLabel.text = date.asRelativeFromNow() + } @objc private func longAction(_ sender: UILongPressGestureRecognizer) { if sender.state == .began { diff --git a/Sources/ContactFeature/Controllers/ContactController.swift b/Sources/ContactFeature/Controllers/ContactController.swift index 5b6abe47459e937c6cf52b992a1e5cf01882dcfb..f7c33bb8ee34b21cd3582f019cd8ac54b90979b1 100644 --- a/Sources/ContactFeature/Controllers/ContactController.swift +++ b/Sources/ContactFeature/Controllers/ContactController.swift @@ -1,5 +1,5 @@ import HUD -import Popup +import DrawerFeature import UIKit import Theme import Shared @@ -18,7 +18,7 @@ public final class ContactController: UIViewController { private let viewModel: ContactViewModel private var cancellables = Set<AnyCancellable>() - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() public init(_ model: Contact) { self.viewModel = ContactViewModel(model) @@ -277,11 +277,11 @@ public final class ContactController: UIViewController { screenView.confirmedView.clearButton .publisher(for: .touchUpInside) - .sink { [unowned self] in presentClearPopup() } + .sink { [unowned self] in presentClearDrawer() } .store(in: &cancellables) } - private func presentClearPopup() { + private func presentClearDrawer() { let clearButton = CapsuleButton() clearButton.setStyle(.red) clearButton.setTitle(Localized.Contact.Clear.action, for: .normal) @@ -290,48 +290,47 @@ public final class ContactController: UIViewController { cancelButton.setStyle(.seeThrough) cancelButton.setTitle(Localized.Contact.Clear.cancel, for: .normal) - let popup = BottomPopup(with: [ - PopupImage(image: Asset.popupNegative.image), - PopupLabel( + 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 ), - PopupLabel( + DrawerText( font: Fonts.Mulish.semiBold.font(size: 14.0), text: Localized.Contact.Clear.subtitle, color: Asset.neutralWeak.color, lineHeightMultiple: 1.35, spacingAfter: 25 ), - PopupStackView( + DrawerStack( spacing: 20.0, - views: [ - clearButton, - cancelButton - ] + views: [clearButton, cancelButton] ) ]) clearButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() self.viewModel.didTapClear() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) cancelButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in - self?.popupCancellables.removeAll() + drawer.dismiss(animated: true) { [weak self] in + self?.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } @objc private func didTapBack() { @@ -375,69 +374,71 @@ extension ContactController { let actionButton = CapsuleButton() actionButton.set( style: .seeThrough, - title: Localized.Settings.InfoPopUp.action + title: Localized.Settings.InfoDrawer.action ) - let popup = BottomPopup(with: [ - PopupLabel( + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), text: title, color: Asset.neutralActive.color, alignment: .left, spacingAfter: 19 ), - PopupLinkText( + DrawerLinkText( text: subtitle, urlString: urlString, spacingAfter: 37 ), - PopupStackView(views: [actionButton, FlexibleSpace()]) + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) ]) actionButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } private func presentDeleteInfo() { - let actionButton = CapsuleButton() - actionButton.set( - style: .red, - title: "Delete Connection" - ) + let actionButton = DrawerCapsuleButton(model: .init( + title: "Delete Connection", + style: .red + )) - let popup = BottomPopup(with: [ - PopupLabel( + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), text: "Delete Connection?", color: Asset.neutralActive.color, alignment: .left, spacingAfter: 19 ), - PopupLabelAttributed( + DrawerText( text: "This is a silent deletion, \(viewModel.contact.username) will not know you deleted them. This action will remove all information on your phone about this user, including your communications. You #cannot undo this step, and cannot re-add them unless they delete you as a connection as well.#", spacingAfter: 37 ), - PopupStackView(views: [actionButton]) + actionButton ]) - actionButton.publisher(for: .touchUpInside) + actionButton.action .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() self.viewModel.didTapDelete() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } } diff --git a/Sources/ContactFeature/Controllers/NickameController.swift b/Sources/ContactFeature/Controllers/NicknameController.swift similarity index 81% rename from Sources/ContactFeature/Controllers/NickameController.swift rename to Sources/ContactFeature/Controllers/NicknameController.swift index c3f00b42f9a05a2fd289d7a241787f7df9c95daf..709a66998b46c59b402adbbaedb8098be57c8ae3 100644 --- a/Sources/ContactFeature/Controllers/NickameController.swift +++ b/Sources/ContactFeature/Controllers/NicknameController.swift @@ -4,8 +4,8 @@ import Combine import InputField import ScrollViewController -public final class NickameController: UIViewController { - lazy private var screenView = NickameView() +public final class NicknameController: UIViewController { + lazy private var screenView = NicknameView() private let prefilled: String private let completion: StringClosure @@ -25,11 +25,11 @@ public final class NickameController: UIViewController { let view = UIView() view.addSubview(screenView) - screenView.snp.makeConstraints { make in - make.top.equalToSuperview() - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.equalToSuperview().offset(0) + screenView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview().offset(0) } self.view = view @@ -40,7 +40,7 @@ public final class NickameController: UIViewController { setupKeyboard() setupBindings() - screenView.input.update(content: prefilled) + screenView.inputField.update(content: prefilled) viewModel.didInput(prefilled) } @@ -76,13 +76,11 @@ public final class NickameController: UIViewController { completion($0) }.store(in: &cancellables) - screenView.input - .textPublisher + screenView.inputField.textPublisher .sink { [weak viewModel] in viewModel?.didInput($0) } .store(in: &cancellables) - screenView.save - .publisher(for: .touchUpInside) + screenView.saveButton.publisher(for: .touchUpInside) .sink { [weak viewModel] in viewModel?.didTapSave() } .store(in: &cancellables) } diff --git a/Sources/ContactFeature/Coordinator/ContactCoordinator.swift b/Sources/ContactFeature/Coordinator/ContactCoordinator.swift index a9ceab13dd5e0614b37bc3e8d05223d4ff9a09bd..2578bf8cb09a4a58f866dabb4135db243e9c43d4 100644 --- a/Sources/ContactFeature/Coordinator/ContactCoordinator.swift +++ b/Sources/ContactFeature/Coordinator/ContactCoordinator.swift @@ -8,7 +8,7 @@ public protocol ContactCoordinating: AnyObject { func toPhotos(from: UIViewController) func toRequests(from: UIViewController) func toSingleChat(with: Contact, from: UIViewController) - func toPopup(_: UIViewController, from: UIViewController) + func toDrawer(_: UIViewController, from: UIViewController) func toNickname(from: UIViewController, prefilled: String, _: @escaping StringClosure) } @@ -58,8 +58,8 @@ public extension ContactCoordinator { pushPresenter.present(screen, from: parent) } - func toPopup(_ popup: UIViewController, from parent: UIViewController) { - bottomPresenter.present(popup, from: parent) + func toDrawer(_ drawer: UIViewController, from parent: UIViewController) { + bottomPresenter.present(drawer, from: parent) } func toSingleChat(with contact: Contact, from parent: UIViewController) { diff --git a/Sources/ContactFeature/Views/NickameView.swift b/Sources/ContactFeature/Views/NickameView.swift deleted file mode 100644 index e6eaaf46bea3db16667d3f8cbdc6652113f626c9..0000000000000000000000000000000000000000 --- a/Sources/ContactFeature/Views/NickameView.swift +++ /dev/null @@ -1,70 +0,0 @@ -import UIKit -import Shared -import InputField - -final class NickameView: UIView { - let title = UILabel() - let icon = UIImageView() - let input = InputField() - let stack = UIStackView() - let save = CapsuleButton() - - init() { - super.init(frame: .zero) - setup() - } - - required init?(coder: NSCoder) { nil } - - func update(status: InputField.ValidationStatus) { - input.update(status: status) - - switch status { - case .valid: - save.isEnabled = true - case .invalid, .unknown: - save.isEnabled = false - } - } - - private func setup() { - layer.cornerRadius = 40 - backgroundColor = Asset.neutralWhite.color - layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - - icon.contentMode = .center - title.textColor = Asset.neutralDark.color - icon.image = Asset.personPlaceholder.image - - input.setup( - style: .regular, - title: Localized.Contact.Nickname.input, - placeholder: "Jim Morrison", - leftView: .image(Asset.personGray.image), - subtitleColor: Asset.neutralDisabled.color - ) - - title.text = Localized.Contact.Nickname.title - title.textAlignment = .center - title.font = Fonts.Mulish.semiBold.font(size: 18.0) - - save.setStyle(.brandColored) - save.setTitle(Localized.Contact.Nickname.save, for: .normal) - - stack.spacing = 20 - stack.axis = .vertical - stack.addArrangedSubview(icon) - stack.addArrangedSubview(title) - stack.addArrangedSubview(input) - stack.addArrangedSubview(save) - - addSubview(stack) - - stack.snp.makeConstraints { make in - make.top.equalToSuperview().offset(32) - make.left.equalToSuperview().offset(30) - make.right.equalToSuperview().offset(-30) - make.bottom.equalToSuperview().offset(-40) - } - } -} diff --git a/Sources/ContactFeature/Views/NicknameView.swift b/Sources/ContactFeature/Views/NicknameView.swift new file mode 100644 index 0000000000000000000000000000000000000000..731bdd70a67f940d16e570b7f25b78cd8f1e8e1e --- /dev/null +++ b/Sources/ContactFeature/Views/NicknameView.swift @@ -0,0 +1,67 @@ +import UIKit +import Shared +import InputField + +final class NicknameView: UIView { + let titleLabel = UILabel() + let imageView = UIImageView() + let inputField = InputField() + let stackView = UIStackView() + let saveButton = CapsuleButton() + + init() { + super.init(frame: .zero) + + layer.cornerRadius = 40 + backgroundColor = Asset.neutralWhite.color + layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + + imageView.contentMode = .center + titleLabel.textColor = Asset.neutralDark.color + imageView.image = Asset.personPlaceholder.image + + inputField.setup( + style: .regular, + title: Localized.Contact.Nickname.input, + placeholder: "Jim Morrison", + leftView: .image(Asset.personGray.image), + subtitleColor: Asset.neutralDisabled.color + ) + + titleLabel.text = Localized.Contact.Nickname.title + titleLabel.textAlignment = .center + titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) + + saveButton.setStyle(.brandColored) + saveButton.setTitle(Localized.Contact.Nickname.save, for: .normal) + + stackView.spacing = 20 + stackView.axis = .vertical + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(inputField) + stackView.addArrangedSubview(saveButton) + + addSubview(stackView) + + stackView.snp.makeConstraints { + $0.top.equalToSuperview().offset(32) + $0.left.equalToSuperview().offset(30) + $0.right.equalToSuperview().offset(-30) + $0.bottom.equalToSuperview().offset(-40) + } + } + + required init?(coder: NSCoder) { nil } + + func update(status: InputField.ValidationStatus) { + inputField.update(status: status) + + switch status { + case .valid: + saveButton.isEnabled = true + case .invalid, .unknown: + saveButton.isEnabled = false + } + } +} diff --git a/Sources/ContactListFeature/Controllers/ContactListController.swift b/Sources/ContactListFeature/Controllers/ContactListController.swift index 50d71253a3339fe892f890f3826c0fdcc911c1a7..1e09b9377139e9428ddb539c6ace95164f26bb1c 100644 --- a/Sources/ContactListFeature/Controllers/ContactListController.swift +++ b/Sources/ContactListFeature/Controllers/ContactListController.swift @@ -34,16 +34,19 @@ public final class ContactListController: UIViewController { private func setupNavigationBar() { navigationItem.backButtonTitle = " " - let title = UILabel() - title.text = Localized.ContactList.title - title.textColor = Asset.neutralActive.color - title.font = Fonts.Mulish.semiBold.font(size: 18.0) + let titleLabel = UILabel() + titleLabel.text = Localized.ContactList.title + titleLabel.textColor = Asset.neutralActive.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) + 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: [back, title]) + customView: UIStackView(arrangedSubviews: [menuButton, titleLabel]) ) let search = UIButton() @@ -81,7 +84,7 @@ public final class ContactListController: UIViewController { private func setupBindings() { tableController.didTap .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toContact($0, from: self) } + .sink { [unowned self] in coordinator.toSingleChat(with: $0, from: self) } .store(in: &cancellables) screenView.requestsButton @@ -96,18 +99,6 @@ public final class ContactListController: UIViewController { .sink { [unowned self] in coordinator.toNewGroup(from: self) } .store(in: &cancellables) - screenView.searchView - .rightPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toScan(from: self) } - .store(in: &cancellables) - - screenView.searchView - .textPublisher - .removeDuplicates() - .sink { [unowned self] in tableController.filter($0) } - .store(in: &cancellables) - screenView.searchButton .publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) @@ -138,7 +129,7 @@ public final class ContactListController: UIViewController { coordinator.toScan(from: self) } - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) + @objc private func didTapMenu() { + coordinator.toSideMenu(from: self) } } diff --git a/Sources/ContactListFeature/Controllers/ContactListTableController.swift b/Sources/ContactListFeature/Controllers/ContactListTableController.swift index 1304342c2760ef8b362d2e3e6cd8662ba56e54d3..5ae07f2fa985d5a35ec2d296a63096ab4d3f50e9 100644 --- a/Sources/ContactListFeature/Controllers/ContactListTableController.swift +++ b/Sources/ContactListFeature/Controllers/ContactListTableController.swift @@ -2,10 +2,13 @@ import UIKit import Shared import Combine import Models -import DifferenceKit final class ContactListTableController: UITableViewController { - private var contacts = [Contact]() + private var collation = UILocalizedIndexedCollation.current() + private var sections: [[Contact]] = [] { + didSet { self.tableView.reloadData() } + } + private let viewModel: ContactListViewModel private var cancellables = Set<AnyCancellable>() private let tapRelay = PassthroughSubject<Contact, Never>() @@ -24,61 +27,55 @@ final class ContactListTableController: UITableViewController { required init?(coder: NSCoder) { nil } - func filter(_ text: String) { - viewModel.filter(text) - } - private func setupTableView() { tableView.separatorStyle = .none tableView.register(SmallAvatarAndTitleCell.self) tableView.backgroundColor = Asset.neutralWhite.color + tableView.sectionIndexColor = Asset.neutralDark.color tableView.contentInset = UIEdgeInsets(top: -20, left: 0, bottom: 0, right: 0) - viewModel - .contacts + viewModel.contacts .receive(on: DispatchQueue.main) .sink { [unowned self] in - guard !self.contacts.isEmpty else { - self.contacts = $0 - tableView.reloadData() - return - } - - self.tableView.reload( - using: StagedChangeset(source: self.contacts, target: $0), - deleteSectionsAnimation: .none, - insertSectionsAnimation: .none, - reloadSectionsAnimation: .none, - deleteRowsAnimation: .none, - insertRowsAnimation: .none, - reloadRowsAnimation: .none - ) { [unowned self] in - self.contacts = $0 - } + let results = IndexedListCollator().sectioned(items: $0) + self.collation = results.collation + self.sections = results.sections }.store(in: &cancellables) } - override func tableView(_ tableView: UITableView, - cellForRowAt indexPath: IndexPath) -> UITableViewCell { + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell: SmallAvatarAndTitleCell = tableView.dequeueReusableCell(forIndexPath: indexPath) - cell.title.text = contacts[indexPath.row].nickname ?? contacts[indexPath.row].username - - cell.avatar.set( - cornerRadius: 10, - username: contacts[indexPath.row].nickname ?? contacts[indexPath.row].username, - image: contacts[indexPath.row].photo - ) - + let contact = sections[indexPath.section][indexPath.row] + cell.titleLabel.text = contact.nickname ?? contact.username + cell.avatarView.setupProfile(title: contact.nickname ?? contact.username, image: contact.photo, size: .medium) return cell } - override func tableView(_: UITableView, numberOfRowsInSection: Int) -> Int { contacts.count } + override func numberOfSections(in: UITableView) -> Int { + sections.count + } + + override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { + sections[section].count + } override func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { - tapRelay.send(contacts[indexPath.row]) + tapRelay.send(sections[indexPath.section][indexPath.row]) + } + + override func sectionIndexTitles(for: UITableView) -> [String]? { + collation.sectionIndexTitles + } + + override func tableView(_: UITableView, titleForHeaderInSection section: Int) -> String? { + collation.sectionTitles[section] + } + + override func tableView(_: UITableView, sectionForSectionIndexTitle: String, at index: Int) -> Int { + collation.section(forSectionIndexTitle: index) } - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + override func tableView(_: UITableView, heightForRowAt: IndexPath) -> CGFloat { 64 } } diff --git a/Sources/ContactListFeature/Controllers/CreatePopupController.swift b/Sources/ContactListFeature/Controllers/CreateDrawerController.swift similarity index 67% rename from Sources/ContactListFeature/Controllers/CreatePopupController.swift rename to Sources/ContactListFeature/Controllers/CreateDrawerController.swift index 40d6f3acf58dd13972337a64c6f66e6e2136b523..543a9722869b0bf6cbc1b66579f235cfa88daf26 100644 --- a/Sources/ContactListFeature/Controllers/CreatePopupController.swift +++ b/Sources/ContactListFeature/Controllers/CreateDrawerController.swift @@ -2,11 +2,11 @@ import UIKit import Shared import Combine -public final class CreatePopupController: UIViewController { - lazy private var screenView = CreatePopupView() +public final class CreateDrawerController: UIViewController { + lazy private var screenView = CreateDrawerView() private let selectedCount: Int - private let viewModel = CreatePopupViewModel() + private let viewModel = CreateDrawerViewModel() private let completion: (String, String?) -> Void private var cancellables = Set<AnyCancellable>() @@ -22,11 +22,11 @@ public final class CreatePopupController: UIViewController { let view = UIView() view.addSubview(screenView) - screenView.snp.makeConstraints { make in - make.top.equalToSuperview() - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.equalToSuperview().offset(0) + screenView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview().offset(0) } self.view = view @@ -34,18 +34,21 @@ public final class CreatePopupController: UIViewController { public override func viewDidLoad() { super.viewDidLoad() - screenView.set(count: selectedCount) { print("teste") } + screenView.set(count: selectedCount) { + // TODO: âš ï¸ + } + setupBindings() } private func setupBindings() { - viewModel.state + viewModel.statePublisher .map(\.status) .receive(on: DispatchQueue.main) .sink { [weak screenView] in screenView?.update(status: $0) } .store(in: &cancellables) - viewModel.done + viewModel.donePublisher .receive(on: DispatchQueue.main) .sink { [unowned self] in dismiss(animated: true) @@ -68,6 +71,16 @@ public final class CreatePopupController: UIViewController { .sink { [weak viewModel] in viewModel?.didOtherInput($0) } .store(in: &cancellables) + screenView.inputField + .returnPublisher + .sink { [unowned self] in screenView.inputField.endEditing(true) } + .store(in: &cancellables) + + screenView.otherInputField + .returnPublisher + .sink { [unowned self] in screenView.otherInputField.endEditing(true) } + .store(in: &cancellables) + screenView.createButton .publisher(for: .touchUpInside) .sink { [weak viewModel] in viewModel?.didTapCreate() } diff --git a/Sources/ContactListFeature/Controllers/CreateGroupController.swift b/Sources/ContactListFeature/Controllers/CreateGroupController.swift index 07872d38f9c9f165e84f68453587c2ebeecc6661..f307813c7f8e3dd959294979f9dfd26183dbccf2 100644 --- a/Sources/ContactListFeature/Controllers/CreateGroupController.swift +++ b/Sources/ContactListFeature/Controllers/CreateGroupController.swift @@ -83,12 +83,8 @@ public final class CreateGroupController: UIViewController { tableView: screenView.tableView ) { [weak self] tableView, indexPath, contact in let cell = tableView.dequeueReusableCell(forIndexPath: indexPath, ofType: SmallAvatarAndTitleCell.self) - cell.title.text = contact.nickname ?? contact.username - cell.avatar.set( - cornerRadius: 10, - username: contact.nickname ?? contact.username, - image: contact.photo - ) + cell.titleLabel.text = contact.nickname ?? contact.username + cell.avatarView.setupProfile(title: contact.nickname ?? contact.username, image: contact.photo, size: .medium) if let selectedElements = self?.selectedElements, selectedElements.contains(contact) { tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none) @@ -159,7 +155,7 @@ public final class CreateGroupController: UIViewController { .publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { [unowned self] in - coordinator.toGroupPopup( + coordinator.toGroupDrawer( with: count + 1, from: self, { (name, welcome) in viewModel.create(name: name, welcome: welcome, members: selectedElements) diff --git a/Sources/ContactListFeature/Coordinator/ContactListCoordinator.swift b/Sources/ContactListFeature/Coordinator/ContactListCoordinator.swift index 99440fc5e403a2363639c7bd200309d0f194efc5..6afe76e1aae847c8080d1b5c80795b5884a2c40d 100644 --- a/Sources/ContactListFeature/Coordinator/ContactListCoordinator.swift +++ b/Sources/ContactListFeature/Coordinator/ContactListCoordinator.swift @@ -1,6 +1,7 @@ import UIKit import Shared import Models +import MenuFeature import ChatFeature import Presentation import ContactFeature @@ -11,24 +12,28 @@ public protocol ContactListCoordinating { func toSearch(from: UIViewController) func toRequests(from: UIViewController) func toNewGroup(from: UIViewController) + func toSideMenu(from: UIViewController) func toContact(_: Contact, from: UIViewController) + func toSingleChat(with: Contact, from: UIViewController) func toGroupChat(with: GroupChatInfo, from: UIViewController) - func toGroupPopup(with: Int, from: UIViewController, _: @escaping (String, String?) -> Void) + func toGroupDrawer(with: Int, from: UIViewController, _: @escaping (String, String?) -> Void) } public struct ContactListCoordinator: ContactListCoordinating { var pushPresenter: Presenting = PushPresenter() + var sidePresenter: Presenting = SideMenuPresenter() var bottomPresenter: Presenting = BottomPresenter() var fullscreenPresenter: Presenting = FullscreenPresenter() - var replacePresenter: Presenting = ReplacePresenter(mode: .replaceLast) var scanFactory: () -> UIViewController var searchFactory: () -> UIViewController var newGroupFactory: () -> UIViewController var requestsFactory: () -> UIViewController var contactFactory: (Contact) -> UIViewController + var singleChatFactory: (Contact) -> UIViewController var groupChatFactory: (GroupChatInfo) -> UIViewController - var groupPopupFactory: (Int, @escaping (String, String?) -> Void) -> UIViewController + var sideMenuFactory: (MenuItem, UIViewController) -> UIViewController + var groupDrawerFactory: (Int, @escaping (String, String?) -> Void) -> UIViewController public init( scanFactory: @escaping () -> UIViewController, @@ -36,29 +41,41 @@ public struct ContactListCoordinator: ContactListCoordinating { newGroupFactory: @escaping () -> UIViewController, requestsFactory: @escaping () -> UIViewController, contactFactory: @escaping (Contact) -> UIViewController, + singleChatFactory: @escaping (Contact) -> UIViewController, groupChatFactory: @escaping (GroupChatInfo) -> UIViewController, - groupPopupFactory: @escaping (Int, @escaping (String, String?) -> Void) -> UIViewController + sideMenuFactory: @escaping (MenuItem, UIViewController) -> UIViewController, + groupDrawerFactory: @escaping (Int, @escaping (String, String?) -> Void) -> UIViewController ) { self.scanFactory = scanFactory self.searchFactory = searchFactory + self.contactFactory = contactFactory self.newGroupFactory = newGroupFactory self.requestsFactory = requestsFactory - self.contactFactory = contactFactory + self.sideMenuFactory = sideMenuFactory self.groupChatFactory = groupChatFactory - self.groupPopupFactory = groupPopupFactory + self.singleChatFactory = singleChatFactory + self.groupDrawerFactory = groupDrawerFactory } } public extension ContactListCoordinator { - func toGroupPopup( + func toGroupDrawer( with count: Int, from parent: UIViewController, _ completion: @escaping (String, String?) -> Void ) { - let screen = ScrollViewController.embedding(groupPopupFactory(count, completion)) + let screen = ScrollViewController.embedding(groupDrawerFactory(count, completion)) fullscreenPresenter.present(screen, from: parent) } + func toSingleChat( + with contact: Contact, + from parent: UIViewController + ) { + let screen = singleChatFactory(contact) + pushPresenter.present(screen, from: parent) + } + func toScan(from parent: UIViewController) { let screen = scanFactory() pushPresenter.present(screen, from: parent) @@ -86,7 +103,12 @@ public extension ContactListCoordinator { func toGroupChat(with info: GroupChatInfo, from parent: UIViewController) { let screen = groupChatFactory(info) - replacePresenter.present(screen, from: parent) + pushPresenter.present(screen, from: parent) + } + + func toSideMenu(from parent: UIViewController) { + let screen = sideMenuFactory(.contacts, parent) + sidePresenter.present(screen, from: parent) } } diff --git a/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift b/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift index 0e46ee2a3c79e9e8b46236244fed23236318d4f5..c1d5d1f83114feaef55a898c9bce85623a20f156 100644 --- a/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift +++ b/Sources/ContactListFeature/ViewModels/ContactListViewModel.swift @@ -7,46 +7,27 @@ final class ContactListViewModel { @Dependency private var session: SessionType var contacts: AnyPublisher<[Contact], Never> { - contactsRelay.eraseToAnyPublisher() + session.contacts(.friends).eraseToAnyPublisher() } var requestCount: AnyPublisher<Int, Never> { Publishers.CombineLatest( session.contacts(.received), session.groups(.pending) - ).map { $0.0.count + $0.1.count } - .eraseToAnyPublisher() - } - - private var cancellables = Set<AnyCancellable>() - private let contactsRelay = CurrentValueSubject<[Contact], Never>([]) - private let searchQueryRelay = CurrentValueSubject<String, Never>("") - - init() { - Publishers.CombineLatest( - session.contacts(.friends), - searchQueryRelay - ) - .map { contacts, query -> [Contact] in - guard !query.isEmpty else { return contacts } - - return contacts.filter { - let containsUsername = $0.username.lowercased().contains(query.lowercased()) + ).map { (contacts, groups) in + let contactRequests = contacts.filter { + $0.status == .verified || + $0.status == .confirming || + $0.status == .confirmationFailed || + $0.status == .verificationFailed || + $0.status == .verificationInProgress + } - if let nickname = $0.nickname { - let containsNickname = nickname.lowercased().contains(query.lowercased()) - return containsNickname || containsUsername - } else { - return containsUsername - } - } + let groupRequests = groups.filter { + $0.status == .pending } - .map { $0.sorted(by: { ($0.nickname ?? $0.username) < ($1.nickname ?? $1.username) })} - .sink { [unowned self] in contactsRelay.send($0) } - .store(in: &cancellables) - } - func filter(_ text: String) { - searchQueryRelay.send(text) + return contactRequests.count + groupRequests.count + }.eraseToAnyPublisher() } } diff --git a/Sources/ContactListFeature/ViewModels/CreateDrawerViewModel.swift b/Sources/ContactListFeature/ViewModels/CreateDrawerViewModel.swift new file mode 100644 index 0000000000000000000000000000000000000000..7369fe9d06625d5e59aa7666d184ea0ae4fc90dc --- /dev/null +++ b/Sources/ContactListFeature/ViewModels/CreateDrawerViewModel.swift @@ -0,0 +1,53 @@ +import Shared +import Combine +import InputField + +struct CreateDrawerViewState { + var welcome: String? + var groupName: String = "" + var status: InputField.ValidationStatus = .unknown(nil) +} + +final class CreateDrawerViewModel { + var statePublisher: AnyPublisher<CreateDrawerViewState, Never> { + stateSubject.eraseToAnyPublisher() + } + + var donePublisher: AnyPublisher<(String, String?), Never> { + doneSubject.eraseToAnyPublisher() + } + + private let doneSubject = PassthroughSubject<(String, String?), Never>() + private let stateSubject = CurrentValueSubject<CreateDrawerViewState, Never>(.init()) + + func didInput(_ string: String) { + stateSubject.value.groupName = string + validate() + } + + func didOtherInput(_ string: String) { + stateSubject.value.welcome = string + } + + func didTapCreate() { + let name = stateSubject.value.groupName.trimmingCharacters(in: .whitespacesAndNewlines) + let welcome = stateSubject.value.welcome + doneSubject.send((name, welcome)) + } + + private func validate() { + let value = stateSubject.value.groupName.trimmingCharacters(in: .whitespacesAndNewlines) + + guard value.count >= 4 else { + stateSubject.value.status = .invalid(Localized.CreateGroup.Drawer.minimum) + return + } + + guard value.count < 32 else { + stateSubject.value.status = .invalid(Localized.CreateGroup.Drawer.maximum) + return + } + + stateSubject.value.status = .valid(nil) + } +} diff --git a/Sources/ContactListFeature/ViewModels/CreatePopupViewModel.swift b/Sources/ContactListFeature/ViewModels/CreatePopupViewModel.swift deleted file mode 100644 index ab53b53a4e407f6fa90069abd8b81871eb1834a9..0000000000000000000000000000000000000000 --- a/Sources/ContactListFeature/ViewModels/CreatePopupViewModel.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Shared -import Combine -import InputField - -struct CreatePopupViewState { - var welcome: String? - var groupName: String = "" - var status: InputField.ValidationStatus = .unknown(nil) -} - -final class CreatePopupViewModel { - // MARK: Properties - - var state: AnyPublisher<CreatePopupViewState, Never> { - stateRelay.eraseToAnyPublisher() - } - - var done: AnyPublisher<(String, String?), Never> { - doneRelay.eraseToAnyPublisher() - } - - private let doneRelay = PassthroughSubject<(String, String?), Never>() - private let stateRelay = CurrentValueSubject<CreatePopupViewState, Never>(.init()) - - // MARK: Public - - func didInput(_ string: String) { - stateRelay.value.groupName = string - validate() - } - - func didOtherInput(_ string: String) { - stateRelay.value.welcome = string - } - - func didTapCreate() { - let name = stateRelay.value.groupName.trimmingCharacters(in: .whitespacesAndNewlines) - let welcome = stateRelay.value.welcome - doneRelay.send((name, welcome)) - } - - // MARK: Private - - private func validate() { - let value = stateRelay.value.groupName.trimmingCharacters(in: .whitespacesAndNewlines) - - guard value.count > 4 else { - stateRelay.value.status = .invalid(Localized.CreateGroup.Popup.minimum) - return - } - - guard value.count < 32 else { - stateRelay.value.status = .invalid(Localized.CreateGroup.Popup.maximum) - return - } - - stateRelay.value.status = .valid(nil) - } -} diff --git a/Sources/ContactListFeature/Views/ContactListView.swift b/Sources/ContactListFeature/Views/ContactListView.swift index 136e82e63ec5e5d5364c1c0b28c0a0736df86135..e7a52a717346d43bb2551eac86839d796a9c78c1 100644 --- a/Sources/ContactListFeature/Views/ContactListView.swift +++ b/Sources/ContactListFeature/Views/ContactListView.swift @@ -8,7 +8,6 @@ final class ContactListView: UIView { let stackView = UIStackView() let emptyTitleLabel = UILabel() let searchButton = CapsuleButton() - let searchView = SearchComponent() init() { super.init(frame: .zero) @@ -41,11 +40,6 @@ final class ContactListView: UIView { searchButton.setStyle(.brandColored) searchButton.setTitle(Localized.ContactList.Empty.action, for: .normal) - searchView.set( - placeholder: "Search connections", - imageAtRight: UIImage.color(.clear) - ) - stackView.spacing = 24 stackView.axis = .vertical stackView.alignment = .center @@ -56,7 +50,6 @@ final class ContactListView: UIView { topStackView.addArrangedSubview(newGroupButton) topStackView.addArrangedSubview(requestsButton) - addSubview(searchView) addSubview(topStackView) addSubview(stackView) @@ -64,14 +57,8 @@ final class ContactListView: UIView { } private func setupConstraints() { - searchView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(20) - make.left.equalToSuperview().offset(20) - make.right.equalToSuperview().offset(-20) - } - topStackView.snp.makeConstraints { make in - make.top.equalTo(searchView.snp.bottom).offset(6) + make.top.equalToSuperview().offset(20) make.left.equalToSuperview() make.right.equalToSuperview() } diff --git a/Sources/ContactListFeature/Views/CreatePopupView.swift b/Sources/ContactListFeature/Views/CreateDrawerView.swift similarity index 71% rename from Sources/ContactListFeature/Views/CreatePopupView.swift rename to Sources/ContactListFeature/Views/CreateDrawerView.swift index b3a22bd189be54e3d49760688ef4e7bb789aa2b8..618ce7c94b4ed11109294b82bda47c8e17d98820 100644 --- a/Sources/ContactListFeature/Views/CreatePopupView.swift +++ b/Sources/ContactListFeature/Views/CreateDrawerView.swift @@ -2,7 +2,7 @@ import UIKit import Shared import InputField -final class CreatePopupView: UIView { +final class CreateDrawerView: UIView { let titleLabel = UILabel() let subtitleView = TextWithInfoView() let inputField = InputField() @@ -21,37 +21,37 @@ final class CreatePopupView: UIView { layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] titleLabel.textAlignment = .left - titleLabel.text = Localized.CreateGroup.Popup.title + titleLabel.text = Localized.CreateGroup.Drawer.title titleLabel.font = Fonts.Mulish.bold.font(size: 26.0) titleLabel.textColor = Asset.neutralActive.color inputField.setup( style: .regular, - title: Localized.CreateGroup.Popup.input, - placeholder: Localized.CreateGroup.Popup.placeholder, + title: Localized.CreateGroup.Drawer.input, + placeholder: Localized.CreateGroup.Drawer.placeholder, leftView: .image(Asset.personGray.image), - accessibility: Localized.Accessibility.CreateGroup.Popup.input, + accessibility: Localized.Accessibility.CreateGroup.Drawer.input, subtitleColor: Asset.neutralDisabled.color ) otherInputField.setup( style: .regular, - title: Localized.CreateGroup.Popup.otherInput, - placeholder: Localized.CreateGroup.Popup.otherPlaceholder, + title: Localized.CreateGroup.Drawer.otherInput, + placeholder: Localized.CreateGroup.Drawer.otherPlaceholder, leftView: .image(Asset.balloon.image), - accessibility: Localized.Accessibility.CreateGroup.Popup.otherInput, + accessibility: Localized.Accessibility.CreateGroup.Drawer.otherInput, subtitleColor: Asset.neutralDisabled.color ) createButton.set( style: .brandColored, - title: Localized.CreateGroup.Popup.action, - accessibility: Localized.Accessibility.CreateGroup.Popup.create + title: Localized.CreateGroup.Drawer.action, + accessibility: Localized.Accessibility.CreateGroup.Drawer.create ) cancelButton.set( style: .seeThrough, - title: Localized.CreateGroup.Popup.cancel + title: Localized.CreateGroup.Drawer.cancel ) stackView.spacing = 20 @@ -65,11 +65,11 @@ final class CreatePopupView: UIView { addSubview(stackView) - stackView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(60) - make.left.equalToSuperview().offset(50) - make.right.equalToSuperview().offset(-50) - make.bottom.equalToSuperview().offset(-70) + stackView.snp.makeConstraints { + $0.top.equalToSuperview().offset(60) + $0.left.equalToSuperview().offset(50) + $0.right.equalToSuperview().offset(-50) + $0.bottom.equalToSuperview().offset(-70) } } @@ -83,7 +83,7 @@ final class CreatePopupView: UIView { paragraphStyle.lineHeightMultiple = 1.1 subtitleView.setup( - text: Localized.CreateGroup.Popup.subtitle("\(count)"), + text: Localized.CreateGroup.Drawer.subtitle("\(count)"), attributes: [ .paragraphStyle: paragraphStyle, .foregroundColor: Asset.neutralBody.color, diff --git a/Sources/ContactListFeature/Views/CreateGroupCollectionCell.swift b/Sources/ContactListFeature/Views/CreateGroupCollectionCell.swift index 45019cac3601eb3a61e53b3e57c0f20c16df4dc8..1717365300ab0ae52380cc47e6073f8118684957 100644 --- a/Sources/ContactListFeature/Views/CreateGroupCollectionCell.swift +++ b/Sources/ContactListFeature/Views/CreateGroupCollectionCell.swift @@ -3,90 +3,78 @@ import Shared import Combine final class CreateGroupCollectionCell: UICollectionViewCell { - // MARK: UI - - let title = UILabel() - let remove = UIButton() + let titleLabel = UILabel() + let removeButton = UIButton() let upperView = UIView() - let avatar = AvatarView() - var didTapRemove: (() -> Void)? + let avatarView = AvatarView() + var didTapRemove: (() -> Void)? var cancellables = Set<AnyCancellable>() - // MARK: Lifecycle - override init(frame: CGRect) { super.init(frame: frame) - setup() + + titleLabel.numberOfLines = 2 + titleLabel.lineBreakMode = .byWordWrapping + titleLabel.textAlignment = .center + titleLabel.textColor = Asset.neutralDark.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) + + removeButton.layer.cornerRadius = 9 + removeButton.backgroundColor = Asset.accentDanger.color + removeButton.setImage(Asset.contactListAvatarRemove.image, for: .normal) + + upperView.addSubview(avatarView) + contentView.addSubview(titleLabel) + contentView.addSubview(upperView) + contentView.addSubview(removeButton) + + upperView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.right.equalToSuperview() + } + + avatarView.snp.makeConstraints { + $0.width.equalTo(48) + $0.height.equalTo(48) + $0.top.equalToSuperview().offset(4) + $0.left.equalToSuperview().offset(4) + $0.right.equalToSuperview().offset(-4) + $0.bottom.equalToSuperview().offset(-4) + } + + removeButton.snp.makeConstraints { + $0.centerY.equalTo(avatarView.snp.top).offset(5) + $0.centerX.equalTo(avatarView.snp.right).offset(-5) + $0.width.equalTo(18) + $0.height.equalTo(18) + } + + titleLabel.snp.makeConstraints { + $0.top.equalTo(upperView.snp.bottom) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + } } required init?(coder: NSCoder) { nil } override func prepareForReuse() { super.prepareForReuse() - title.text = nil - avatar.prepareForReuse() + titleLabel.text = nil + avatarView.prepareForReuse() cancellables.removeAll() } - // MARK: Public - func setup(title: String, image: Data?) { - self.title.text = title - self.avatar.set(username: title, image: image) - + titleLabel.text = title + avatarView.setupProfile(title: title, image: image, size: .large) cancellables.removeAll() - remove.publisher(for: .touchUpInside) + removeButton.publisher(for: .touchUpInside) .sink { [unowned self] in didTapRemove?() } .store(in: &cancellables) } - - // MARK: Private - - private func setup() { - title.numberOfLines = 2 - title.lineBreakMode = .byWordWrapping - title.textAlignment = .center - title.textColor = Asset.neutralDark.color - title.font = Fonts.Mulish.semiBold.font(size: 14.0) - - remove.layer.cornerRadius = 9 - remove.backgroundColor = Asset.accentDanger.color - remove.setImage(Asset.contactListAvatarRemove.image, for: .normal) - - upperView.addSubview(avatar) - contentView.addSubview(title) - contentView.addSubview(upperView) - contentView.addSubview(remove) - - upperView.snp.makeConstraints { make in - make.top.equalToSuperview() - make.left.equalToSuperview() - make.right.equalToSuperview() - } - - avatar.snp.makeConstraints { make in - make.width.equalTo(48) - make.height.equalTo(48) - make.top.equalToSuperview().offset(4) - make.left.equalToSuperview().offset(4) - make.right.equalToSuperview().offset(-4) - make.bottom.equalToSuperview().offset(-4) - } - - remove.snp.makeConstraints { make in - make.centerY.equalTo(avatar.snp.top).offset(5) - make.centerX.equalTo(avatar.snp.right).offset(-5) - make.width.equalTo(18) - make.height.equalTo(18) - } - - title.snp.makeConstraints { make in - make.top.equalTo(upperView.snp.bottom) - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.equalToSuperview() - } - } } diff --git a/Sources/Countries/Country.swift b/Sources/Countries/Country.swift index 1a6f14f27b460a690db9f7c1903e1d164b245b98..acb0bae1d468c7cf3abba10f20a59e7c8d803949 100644 --- a/Sources/Countries/Country.swift +++ b/Sources/Countries/Country.swift @@ -1,8 +1,6 @@ import os import Foundation -private let logger = Logger(subsystem: "logs_xxmessenger", category: "Country.swift") - public struct Country { public var name: String public var code: String @@ -13,8 +11,6 @@ public struct Country { public var prefixWithFlag: String { "\(flag) \(prefix)" } public static func fromMyPhone() -> Self { - logger.trace("fromMyPhone()") - let all = all() guard let country = all.filter({ $0.code == Locale.current.regionCode }).first else { @@ -25,12 +21,9 @@ public struct Country { } public static func all() -> [Self] { - logger.trace("all()") - guard let url = Bundle.module.url(forResource: "country_codes", withExtension: "json"), let data = try? Data(contentsOf: url), let countries = try? JSONDecoder().decode([Country].self, from: data) else { - logger.error("Can't handle country codes json") fatalError("Can't handle country codes json") } @@ -38,9 +31,7 @@ public struct Country { } public static func findFrom(_ number: String) -> Self { - logger.trace("findFrom: \(number, privacy: .public)()") - - return all().first { country in + all().first { country in let start = number.index(number.startIndex, offsetBy: number.count - 2) let end = number.index(start, offsetBy: number.count - (number.count - 2)) diff --git a/Sources/Countries/CountryListCell.swift b/Sources/Countries/CountryListCell.swift index d55713df783aeab470fcec56d0eb4f89225cfd60..b3b650e5ae8daae80cc8b026fc3d242bac1b9920 100644 --- a/Sources/Countries/CountryListCell.swift +++ b/Sources/Countries/CountryListCell.swift @@ -2,65 +2,61 @@ import UIKit import Shared final class CountryListCell: UITableViewCell { - let name = UILabel() - let flag = UILabel() - let prefix = UILabel() - let separator = UIView() + let nameLabel = UILabel() + let flagLabel = UILabel() + let prefixLabel = UILabel() + let separatorView = UIView() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none - setup() - } - - required init?(coder: NSCoder) { nil } - - override func prepareForReuse() { - super.prepareForReuse() - - name.text = nil - flag.text = nil - prefix.text = nil - } - - private func setup() { backgroundColor = Asset.neutralWhite.color - name.textColor = Asset.neutralDark.color - prefix.textColor = Asset.neutralWeak.color - name.font = Fonts.Mulish.semiBold.font(size: 14.0) - prefix.font = Fonts.Mulish.semiBold.font(size: 14.0) + nameLabel.textColor = Asset.neutralDark.color + prefixLabel.textColor = Asset.neutralWeak.color + nameLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) + prefixLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) - separator.backgroundColor = Asset.brandBackground.color - prefix.setContentCompressionResistancePriority(.required, for: .horizontal) + separatorView.backgroundColor = Asset.brandBackground.color + prefixLabel.setContentCompressionResistancePriority(.required, for: .horizontal) - contentView.addSubview(name) - contentView.addSubview(flag) - contentView.addSubview(prefix) - contentView.addSubview(separator) + contentView.addSubview(nameLabel) + contentView.addSubview(flagLabel) + contentView.addSubview(prefixLabel) + contentView.addSubview(separatorView) - flag.snp.makeConstraints { make in - make.left.top.equalToSuperview().inset(18) - make.bottom.equalToSuperview().offset(-16) + flagLabel.snp.makeConstraints { + $0.left.top.equalToSuperview().inset(18) + $0.bottom.equalToSuperview().offset(-16) } - name.snp.makeConstraints { make in - make.left.equalToSuperview().offset(55) - make.centerY.equalToSuperview() - make.right.lessThanOrEqualTo(prefix.snp.left).offset(-10) + nameLabel.snp.makeConstraints { + $0.left.equalToSuperview().offset(55) + $0.centerY.equalToSuperview() + $0.right.lessThanOrEqualTo(prefixLabel.snp.left).offset(-10) } - prefix.snp.makeConstraints { make in - make.right.equalToSuperview().offset(-18) - make.centerY.equalToSuperview() + prefixLabel.snp.makeConstraints { + $0.right.equalToSuperview().offset(-18) + $0.centerY.equalToSuperview() } - separator.snp.makeConstraints { make in - make.bottom.equalToSuperview() - make.left.equalToSuperview().offset(20) - make.right.equalToSuperview().offset(-20) - make.height.equalTo(1) + separatorView.snp.makeConstraints { + $0.bottom.equalToSuperview() + $0.left.equalToSuperview().offset(20) + $0.right.equalToSuperview().offset(-20) + $0.height.equalTo(1) } } + + required init?(coder: NSCoder) { nil } + + override func prepareForReuse() { + super.prepareForReuse() + + nameLabel.text = nil + flagLabel.text = nil + prefixLabel.text = nil + } } diff --git a/Sources/Countries/CountryListController.swift b/Sources/Countries/CountryListController.swift index c0a8341b12754f055b27da96c01649395d581d3c..ab7ca357c0d9f074d73c38c1616cbba81d9ed9c1 100644 --- a/Sources/Countries/CountryListController.swift +++ b/Sources/Countries/CountryListController.swift @@ -5,8 +5,6 @@ import Shared import Combine import DependencyInjection -private let logger = Logger(subsystem: "logs_xxmessenger", category: "Countries.CountryListController.swift") - public final class CountryListController: UIViewController { @Dependency private var statusBarController: StatusBarStyleControlling @@ -25,8 +23,6 @@ public final class CountryListController: UIViewController { required init?(coder: NSCoder) { nil } public override func viewWillAppear(_ animated: Bool) { - logger.log("viewWillAppear()") - super.viewWillAppear(animated) statusBarController.style.send(.darkContent) @@ -37,13 +33,10 @@ public final class CountryListController: UIViewController { } public override func loadView() { - logger.log("loadView()") view = screenView } public override func viewDidLoad() { - logger.log("viewDidLoad()") - super.viewDidLoad() screenView.tableView.register(CountryListCell.self) setupNavigationBar() @@ -53,8 +46,6 @@ public final class CountryListController: UIViewController { } private func setupNavigationBar() { - logger.log("setupNavigationBar()") - navigationItem.backButtonTitle = " " let title = UILabel() @@ -71,8 +62,6 @@ public final class CountryListController: UIViewController { } private func setupBindings() { - logger.log("setupBindings()") - viewModel.countries .receive(on: DispatchQueue.main) .sink { [unowned self] in dataSource.apply($0, animatingDifferences: false) } @@ -82,9 +71,9 @@ public final class CountryListController: UIViewController { tableView: screenView.tableView ) { tableView, indexPath, country in let cell: CountryListCell = tableView.dequeueReusableCell(forIndexPath: indexPath) - cell.flag.text = country.flag - cell.name.text = country.name - cell.prefix.text = country.prefix + cell.flagLabel.text = country.flag + cell.nameLabel.text = country.name + cell.prefixLabel.text = country.prefix return cell } @@ -99,13 +88,10 @@ public final class CountryListController: UIViewController { } @objc private func didTapBack() { - logger.log("didTapBack()") navigationController?.popViewController(animated: true) } public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - logger.log("tableView(didSelectRowAt indexPath.row: \(indexPath.row, privacy: .public)()") - if let country = dataSource.itemIdentifier(for: indexPath) { didChoose(country) navigationController?.popViewController(animated: true) diff --git a/Sources/Countries/Resources/country_codes.json b/Sources/Countries/Resources/country_codes.json index 992ee9a60584e656efbb4005c9121e1f4869f651..a71422a682a6dbf9fb3060047e658164a7c8593b 100644 --- a/Sources/Countries/Resources/country_codes.json +++ b/Sources/Countries/Resources/country_codes.json @@ -435,7 +435,7 @@ "name": "Cyprus", "code": "CY", "flag": "🇨🇾", - "prefix": "+537", + "prefix": "+357", "example": "96 123456", "regex": "9[4-79]\\d{6}" }, diff --git a/Sources/Database/DB+Contact.swift b/Sources/Database/DB+Contact.swift index 3d483d2b92a364a3636b3f3e114910c17b6fceb4..e8771ab9a3d359a0a4b51ea5d7d2b5d928dd4548 100644 --- a/Sources/Database/DB+Contact.swift +++ b/Sources/Database/DB+Contact.swift @@ -21,6 +21,8 @@ extension Contact: Persistable { public static func query(_ request: Request) -> QueryInterfaceRequest<Contact> { switch request { + case .all: + return Contact.all() case .verificationInProgress: return Contact.filter(Column.status == Contact.Status.verificationInProgress.rawValue) case .failed: @@ -35,6 +37,7 @@ extension Contact: Persistable { ) case .received: return Contact.filter( + Column.status == Contact.Status.hidden.rawValue || Column.status == Contact.Status.verified.rawValue || Column.status == Contact.Status.verificationFailed.rawValue || Column.status == Contact.Status.verificationInProgress.rawValue diff --git a/Sources/Database/DB+Group.swift b/Sources/Database/DB+Group.swift index 8a37fb74f0742e04bad1077f8ff4dd209fe0934f..dc78a33efae1c30fd484d7fd2c99e3e996134ec2 100644 --- a/Sources/Database/DB+Group.swift +++ b/Sources/Database/DB+Group.swift @@ -9,8 +9,10 @@ extension Group: Persistable { case name case leader case groupId - case accepted + case status case serialize + case createdAt + case accepted // Deprecated } public mutating func didInsert(with rowID: Int64, for column: String?) { @@ -22,9 +24,12 @@ extension Group: Persistable { case .withGroupId(let id): return Group.filter(Column.groupId == id) case .accepted: - return Group.filter(Column.accepted == true) + return Group.filter(Column.status == Group.Status.participating.rawValue) case .pending: - return Group.filter(Column.accepted == false) + return Group.filter( + Column.status == Group.Status.pending.rawValue || + Column.status == Group.Status.hidden.rawValue + ) } } } diff --git a/Sources/Database/DB+GroupChatInfo.swift b/Sources/Database/DB+GroupChatInfo.swift index 9c65c4f96b7ef619a68f879a3fd57dcbfed4ed23..233b89525031ef534d1df7b84b8d59ac664086c6 100644 --- a/Sources/Database/DB+GroupChatInfo.swift +++ b/Sources/Database/DB+GroupChatInfo.swift @@ -19,7 +19,7 @@ extension GroupChatInfo: Requestable { switch request { case .accepted: return Group - .filter(Group.Column.accepted == true) + .filter(Group.Column.status == Group.Status.participating.rawValue) .with(lastMessageCTE) .including(optional: lastMessage) .including(all: Group.members.forKey("members")) diff --git a/Sources/Database/DB+GroupMember.swift b/Sources/Database/DB+GroupMember.swift index ca075936a10fad70eda84ee8283bffd8bb238eb6..2153704e4d14c37b9a4e122ca77299b76bd655a6 100644 --- a/Sources/Database/DB+GroupMember.swift +++ b/Sources/Database/DB+GroupMember.swift @@ -17,10 +17,16 @@ extension GroupMember: Persistable { public static func query(_ request: Request) -> QueryInterfaceRequest<GroupMember> { switch request { + case .all: + return GroupMember.all() case let .withUserId(userId): return GroupMember.filter(Column.userId == userId) + case .fromGroup(let groupId): + return GroupMember.filter(Column.groupId == groupId) case .strangers: - return GroupMember.filter(Column.status == GroupMember.Status.pendingUsername.rawValue) + return GroupMember.filter( + Column.status == GroupMember.Status.pendingUsername.rawValue + ) } } } diff --git a/Sources/Database/DatabaseManager.swift b/Sources/Database/DatabaseManager.swift index b60caaf7344bc080f250469e03552ad5faf44ca2..db9d5565c113dc3c3d62e78d7a54dcf2b69dc983 100644 --- a/Sources/Database/DatabaseManager.swift +++ b/Sources/Database/DatabaseManager.swift @@ -40,7 +40,6 @@ public extension DatabaseManager { public final class GRDBDatabaseManager { var databaseQueue: DatabaseQueue! - var databaseMigrator: DatabaseMigrator! public init() {} } @@ -114,6 +113,8 @@ extension GRDBDatabaseManager: DatabaseManager { } public func setup() throws { + var migrator = DatabaseMigrator() + let path = NSSearchPathForDirectoriesInDomains( .documentDirectory, .userDomainMask, true )[0] @@ -124,7 +125,7 @@ extension GRDBDatabaseManager: DatabaseManager { .protectionKey : FileProtectionType.completeUntilFirstUserAuthentication ], ofItemAtPath: path) - try databaseQueue.write { db in + migrator.registerMigration("v1") { db in try db.create(table: Contact.databaseTableName, ifNotExists: true) { table in table.autoIncrementedPrimaryKey(Contact.Column.id.rawValue, onConflict: .replace) table.column(Contact.Column.photo.rawValue, .blob) @@ -197,5 +198,45 @@ extension GRDBDatabaseManager: DatabaseManager { table.column(FileTransfer.Column.isIncoming.rawValue, .boolean).notNull() } } + + migrator.registerMigration("v1: Updating contact/group requests UI") { db in + try db.create(table: "temp_\(Group.databaseTableName)") { table in + table.autoIncrementedPrimaryKey(Group.Column.id.rawValue, onConflict: .replace) + table.column(Group.Column.groupId.rawValue, .blob).unique() + table.column(Group.Column.name.rawValue, .text).notNull() + table.column(Group.Column.leader.rawValue, .blob).notNull() + table.column(Group.Column.serialize.rawValue, .blob).notNull() + table.column(Group.Column.status.rawValue, .integer).notNull() + table.column(Group.Column.createdAt.rawValue, .datetime).notNull() + } + + let oldRows = try Row.fetchCursor(db, sql: "SELECT * FROM \(Group.databaseTableName)") + while let row = try oldRows.next() { + let status: Group.Status + + if row["accepted"] == true { + status = .participating + } else { + status = .pending + } + + try db.execute( + sql: "INSERT INTO temp_\(Group.databaseTableName) (id, groupId, name, leader, serialize, status, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)", + arguments: + [row["id"], + row["groupId"], + row["name"], + row["leader"], + row["serialize"], + status.rawValue, + Date() + ]) + } + + try db.drop(table: Group.databaseTableName) + try db.rename(table: "temp_\(Group.databaseTableName)", to: Group.databaseTableName) + } + + try migrator.migrate(databaseQueue) } } diff --git a/Sources/Defaults/KeyObject.swift b/Sources/Defaults/KeyObject.swift index 385c4992b1f35e76d9232822884d3570f125e51a..7757f7ed4fa550c478736b76cd7c1036ef4fde34 100644 --- a/Sources/Defaults/KeyObject.swift +++ b/Sources/Defaults/KeyObject.swift @@ -22,6 +22,10 @@ public enum Key: String { case theme + // MARK: Requests + + case isShowingHiddenRequests + // MARK: Backup case backupSettings diff --git a/Sources/DrawerFeature/DrawerController.swift b/Sources/DrawerFeature/DrawerController.swift new file mode 100644 index 0000000000000000000000000000000000000000..d2eba3624041cd025d10e26362c6c9b8c6806f4f --- /dev/null +++ b/Sources/DrawerFeature/DrawerController.swift @@ -0,0 +1,26 @@ +import UIKit + +public final class DrawerController: UIViewController { + lazy private var screenView = DrawerView() + + private let content: [DrawerItem] + + public init(with content: [DrawerItem]) { + self.content = content + super.init(nibName: nil, bundle: nil) + + let views = content.map { $0.makeView() } + views.forEach { screenView.stackView.addArrangedSubview($0) } + + content.enumerated().forEach { item in + guard let spacing = item.element.spacingAfter else { return } + screenView.stackView.setCustomSpacing(spacing, after: views[item.offset]) + } + } + + required init?(coder: NSCoder) { nil } + + public override func loadView() { + view = screenView + } +} diff --git a/Sources/Popup/PopupStackItems.swift b/Sources/DrawerFeature/DrawerItem.swift similarity index 65% rename from Sources/Popup/PopupStackItems.swift rename to Sources/DrawerFeature/DrawerItem.swift index f5137f8dd0a3abae6ec4e8e7fe62a54aeef9613d..44ef57064cf6c84345b4bb24d3661e5c24d2274c 100644 --- a/Sources/Popup/PopupStackItems.swift +++ b/Sources/DrawerFeature/DrawerItem.swift @@ -1,11 +1,11 @@ import UIKit -public protocol PopupStackItem { +public protocol DrawerItem { var spacingAfter: CGFloat? { get } func makeView() -> UIView } -public extension PopupStackItem { +public extension DrawerItem { var spacingAfter: CGFloat? { nil } } diff --git a/Sources/DrawerFeature/DrawerView.swift b/Sources/DrawerFeature/DrawerView.swift new file mode 100644 index 0000000000000000000000000000000000000000..e03282d5306ef66399d5ee0e96915f38d3e895de --- /dev/null +++ b/Sources/DrawerFeature/DrawerView.swift @@ -0,0 +1,26 @@ +import UIKit +import Shared + +final class DrawerView: UIView { + let stackView = UIStackView() + + init() { + super.init(frame: .zero) + + layer.cornerRadius = 40 + backgroundColor = Asset.neutralWhite.color + layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + + stackView.axis = .vertical + addSubview(stackView) + + stackView.snp.makeConstraints { + $0.top.equalToSuperview().offset(40) + $0.left.equalToSuperview().offset(50) + $0.right.equalToSuperview().offset(-50) + $0.bottom.equalToSuperview().offset(-50) + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/Popup/StackItems/PopupCapsuleButton.swift b/Sources/DrawerFeature/Items/DrawerCapsuleButton.swift similarity index 72% rename from Sources/Popup/StackItems/PopupCapsuleButton.swift rename to Sources/DrawerFeature/Items/DrawerCapsuleButton.swift index 64bfcda468756cb97a7d34bf94e0b8b997534050..1fe694c1945d3171d8b4a7102cc58cb48e6683d7 100644 --- a/Sources/Popup/StackItems/PopupCapsuleButton.swift +++ b/Sources/DrawerFeature/Items/DrawerCapsuleButton.swift @@ -2,19 +2,18 @@ import UIKit import Shared import Combine -public final class PopupCapsuleButton: PopupStackItem { - let model: CapsuleButtonModel +public final class DrawerCapsuleButton: DrawerItem { + public var action: AnyPublisher<Void, Never> { + actionSubject.eraseToAnyPublisher() + } - public var spacingAfter: CGFloat? = 0 + private let model: CapsuleButtonModel private var cancellables = Set<AnyCancellable>() private let actionSubject = PassthroughSubject<Void, Never>() - public var action: AnyPublisher<Void, Never> { actionSubject.eraseToAnyPublisher() } + public var spacingAfter: CGFloat? = 0 - public init( - model: CapsuleButtonModel, - spacingAfter: CGFloat? = 10 - ) { + public init(model: CapsuleButtonModel, spacingAfter: CGFloat = 10) { self.model = model self.spacingAfter = spacingAfter } diff --git a/Sources/Popup/StackItems/PopupImage.swift b/Sources/DrawerFeature/Items/DrawerImage.swift similarity index 51% rename from Sources/Popup/StackItems/PopupImage.swift rename to Sources/DrawerFeature/Items/DrawerImage.swift index 5ca1d3041e73973620bce1a8fd545388cacdbc2d..04a5974a7ff56672631ebab1f4eb231eb2647195 100644 --- a/Sources/Popup/StackItems/PopupImage.swift +++ b/Sources/DrawerFeature/Items/DrawerImage.swift @@ -1,24 +1,21 @@ import UIKit -public struct PopupImage: PopupStackItem { - // MARK: Properties +public struct DrawerImage: DrawerItem { + private let image: UIImage + private let contentMode: UIView.ContentMode - let image: UIImage - let contentMode: UIView.ContentMode - public var spacingAfter: CGFloat? = 16 - - // MARK: Lifecycle + public var spacingAfter: CGFloat? = 0 public init( image: UIImage, - contentMode: UIView.ContentMode = .center + contentMode: UIView.ContentMode = .center, + spacingAfter: CGFloat = 10 ) { self.image = image self.contentMode = contentMode + self.spacingAfter = spacingAfter } - // MARK: Builder - public func makeView() -> UIView { let imageView = UIImageView(image: image) imageView.contentMode = contentMode diff --git a/Sources/DrawerFeature/Items/DrawerInput.swift b/Sources/DrawerFeature/Items/DrawerInput.swift new file mode 100644 index 0000000000000000000000000000000000000000..07afa87c8e4573b3990784b8851e743a2da9c003 --- /dev/null +++ b/Sources/DrawerFeature/Items/DrawerInput.swift @@ -0,0 +1,91 @@ +import UIKit +import Shared +import Combine +import InputField + +public struct DrawerInputValidator { + let wrongIcon: InputField.RightView + let correctIcon: InputField.RightView + let shouldAcceptPlaceholder: Bool + + public init( + wrongIcon: InputField.RightView, + correctIcon: InputField.RightView, + shouldAcceptPlaceholder: Bool + ) { + self.wrongIcon = wrongIcon + self.correctIcon = correctIcon + self.shouldAcceptPlaceholder = shouldAcceptPlaceholder + } +} + +public final class DrawerInput: DrawerItem { + public var inputPublisher: AnyPublisher<String, Never> { + inputSubject.eraseToAnyPublisher() + } + + public var validationPublisher: AnyPublisher<Bool, Never> { + validationSubject.eraseToAnyPublisher() + } + + private let placeholder: String + private let validator: DrawerInputValidator? + private var cancellables = Set<AnyCancellable>() + private let inputSubject = PassthroughSubject<String, Never>() + private let validationSubject = CurrentValueSubject<Bool, Never>(true) + + public var spacingAfter: CGFloat? = 0 + + public init( + placeholder: String, + validator: DrawerInputValidator? = nil, + spacingAfter: CGFloat = 10 + ) { + self.validator = validator + self.placeholder = placeholder + self.spacingAfter = spacingAfter + } + + public func makeView() -> UIView { + let view = InputField() + view.setup(style: .regular, placeholder: placeholder) + + if let validator = validator { + if validator.shouldAcceptPlaceholder { + view.set(rightView: validator.correctIcon) + } + } + + func validate(string: String, using validator: DrawerInputValidator) { + if string.isEmpty && validator.shouldAcceptPlaceholder { + view.set(rightView: validator.correctIcon) + validationSubject.send(true) + return + } + + if string.isEmpty && !validator.shouldAcceptPlaceholder { + view.set(rightView: validator.wrongIcon) + validationSubject.send(false) + return + } + + if string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + view.set(rightView: validator.wrongIcon) + validationSubject.send(false) + return + } + } + + view.textPublisher + .sink { [weak self] in + if let validator = self?.validator { + validate(string: $0, using: validator) + } + + self?.inputSubject.send($0) + } + .store(in: &cancellables) + + return view + } +} diff --git a/Sources/DrawerFeature/Items/DrawerLinkText.swift b/Sources/DrawerFeature/Items/DrawerLinkText.swift new file mode 100644 index 0000000000000000000000000000000000000000..428acbaa89e295fb8f95f4bc3a131f0955e571fc --- /dev/null +++ b/Sources/DrawerFeature/Items/DrawerLinkText.swift @@ -0,0 +1,63 @@ +import UIKit +import Shared + +public final class DrawerLinkText: NSObject, DrawerItem { + let text: String + let urlString: String + + public var spacingAfter: CGFloat? = 0 + + public init( + text: String, + urlString: String, + spacingAfter: CGFloat = 10 + ) { + self.text = text + self.urlString = urlString + self.spacingAfter = spacingAfter + } + + public func makeView() -> UIView { + let textView = UnselectableTextView() + textView.delegate = self + textView.isEditable = false + textView.isSelectable = true + textView.isScrollEnabled = false + textView.backgroundColor = .clear + textView.isUserInteractionEnabled = true + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .left + paragraphStyle.lineHeightMultiple = 1.1 + + let attrString = NSMutableAttributedString(string: text) + attrString.addAttributes([ + .paragraphStyle: paragraphStyle, + .foregroundColor: Asset.neutralDark.color, + .font: Fonts.Mulish.regular.font(size: 16.0) as Any + ]) + + if let url = URL(string: urlString) { + attrString.addAttribute(name: .link, value: url, betweenCharacters: "#") + + textView.linkTextAttributes = [ + .paragraphStyle: paragraphStyle, + .foregroundColor: Asset.brandPrimary.color, + .font: Fonts.Mulish.regular.font(size: 16.0) as Any + ] + } + + textView.attributedText = attrString + + return textView + } + + public func textView( + _: UITextView, + shouldInteractWith: URL, + in: NSRange, + interaction: UITextItemInteraction + ) -> Bool { true } +} + +extension DrawerLinkText: UITextViewDelegate {} diff --git a/Sources/DrawerFeature/Items/DrawerLoadingRetry.swift b/Sources/DrawerFeature/Items/DrawerLoadingRetry.swift new file mode 100644 index 0000000000000000000000000000000000000000..dcf46a4c812ef1698972c1f5337c1963279a7122 --- /dev/null +++ b/Sources/DrawerFeature/Items/DrawerLoadingRetry.swift @@ -0,0 +1,57 @@ +import UIKit +import Shared +import Combine + +public final class DrawerLoadingRetry: DrawerItem { + public var retryPublisher: AnyPublisher<Void, Never> { + retrySubject.eraseToAnyPublisher() + } + + private let view = UIView() + private let retryButton = UIButton() + private let stackView = UIStackView() + private var cancellables = Set<AnyCancellable>() + private let activityIndicator = UIActivityIndicatorView() + private let retrySubject = PassthroughSubject<Void, Never>() + + public var spacingAfter: CGFloat? = 0 + + public init(spacingAfter: CGFloat? = 10) { + self.spacingAfter = spacingAfter + self.activityIndicator.style = .large + self.activityIndicator.hidesWhenStopped = true + } + + public func startSpinning() { + activityIndicator.startAnimating() + retryButton.isHidden = true + } + + public func stopSpinning(withRetry retry: Bool) { + guard retry else { view.isHidden = true; return } + + retryButton.isHidden = false + activityIndicator.stopAnimating() + retryButton.setTitle("Retry", for: .normal) + retryButton.setTitleColor(.red, for: .normal) + + retryButton.titleLabel?.numberOfLines = 0 + retryButton.titleLabel?.textAlignment = .center + retryButton.titleLabel?.font = Fonts.Mulish.bold.font(size: 16.0) + } + + public func makeView() -> UIView { + stackView.axis = .vertical + stackView.addArrangedSubview(activityIndicator) + stackView.addArrangedSubview(retryButton) + + retryButton + .publisher(for: .touchUpInside) + .sink { [weak retrySubject] in retrySubject?.send() } + .store(in: &cancellables) + + view.addSubview(stackView) + stackView.snp.makeConstraints { $0.edges.equalToSuperview() } + return view + } +} diff --git a/Sources/DrawerFeature/Items/DrawerRadio.swift b/Sources/DrawerFeature/Items/DrawerRadio.swift new file mode 100644 index 0000000000000000000000000000000000000000..de3b764fe403ef3f2c6d80c0e1f91d57ac905382 --- /dev/null +++ b/Sources/DrawerFeature/Items/DrawerRadio.swift @@ -0,0 +1,79 @@ +import UIKit +import Shared +import Combine + +public final class DrawerRadio: DrawerItem { + private let title: String + private let isSelected: Bool + private var cancellables = Set<AnyCancellable>() + private let actionSubject = PassthroughSubject<Void, Never>() + + public var spacingAfter: CGFloat? = 0 + public var action: AnyPublisher<Void, Never> { actionSubject.eraseToAnyPublisher() } + + public init( + title: String, + isSelected: Bool, + spacingAfter: CGFloat = 10 + ) { + self.title = title + self.isSelected = isSelected + self.spacingAfter = spacingAfter + } + + public func makeView() -> UIView { + cancellables.removeAll() + + let radioView = UIView() + let titleLabel = UILabel() + let radioInnerView = UIView() + + let view = UIControl() + view.addSubview(titleLabel) + view.addSubview(radioView) + radioView.addSubview(radioInnerView) + + titleLabel.text = title + titleLabel.textColor = Asset.neutralDark.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) + + radioView.layer.cornerRadius = 11.0 + radioInnerView.layer.cornerRadius = 3 + radioView.isUserInteractionEnabled = false + + if isSelected { + radioView.layer.borderWidth = 0.0 + radioView.backgroundColor = Asset.brandLight.color + radioView.layer.borderColor = Asset.brandLight.color.cgColor + radioInnerView.backgroundColor = Asset.neutralWhite.color + } else { + radioView.layer.borderWidth = 1.0 + radioView.backgroundColor = Asset.neutralSecondary.color + radioView.layer.borderColor = Asset.neutralLine.color.cgColor + radioInnerView.backgroundColor = .clear + } + + titleLabel.snp.makeConstraints { + $0.left.equalToSuperview().offset(42) + $0.centerY.equalToSuperview() + } + + radioView.snp.makeConstraints { + $0.right.equalTo(titleLabel.snp.left).offset(-12) + $0.width.height.equalTo(20) + $0.centerY.equalToSuperview() + $0.bottom.equalToSuperview().offset(-5) + } + + radioInnerView.snp.makeConstraints { + $0.width.height.equalTo(6) + $0.center.equalToSuperview() + } + + view.publisher(for: .touchUpInside) + .sink { [weak self] in self?.actionSubject.send() } + .store(in: &cancellables) + + return view + } +} diff --git a/Sources/DrawerFeature/Items/DrawerStack.swift b/Sources/DrawerFeature/Items/DrawerStack.swift new file mode 100644 index 0000000000000000000000000000000000000000..6e6cf5d629717baffa0b52ea6703219422526647 --- /dev/null +++ b/Sources/DrawerFeature/Items/DrawerStack.swift @@ -0,0 +1,34 @@ +import UIKit +import Shared + +public final class DrawerStack: DrawerItem { + private let views: [UIView] + private let spacing: CGFloat + private let axis: NSLayoutConstraint.Axis + private let distribution: UIStackView.Distribution + + public var spacingAfter: CGFloat? = 0 + + public init( + axis: NSLayoutConstraint.Axis = .horizontal, + spacing: CGFloat = 10, + spacingAfter: CGFloat = 10, + distribution: UIStackView.Distribution = .fillEqually, + views: [UIView] + ) { + self.axis = axis + self.views = views + self.spacing = spacing + self.distribution = distribution + self.spacingAfter = spacingAfter + } + + public func makeView() -> UIView { + let stackView = UIStackView() + stackView.axis = axis + stackView.spacing = spacing + stackView.distribution = distribution + stackView.addArrangedSubviews(views) + return stackView + } +} diff --git a/Sources/DrawerFeature/Items/DrawerSwitch.swift b/Sources/DrawerFeature/Items/DrawerSwitch.swift new file mode 100644 index 0000000000000000000000000000000000000000..449261487789f2685bc43b861ce313b8cf8aaa88 --- /dev/null +++ b/Sources/DrawerFeature/Items/DrawerSwitch.swift @@ -0,0 +1,80 @@ +import UIKit +import Shared +import Combine + +public final class DrawerSwitch: DrawerItem { + public var isOnPublisher: AnyPublisher<Bool, Never> { + isOnSubject.eraseToAnyPublisher() + } + + private let title: String + private let content: String + private let isEnabled: Bool + private let isInitiallyOn: Bool + private var cancellables = Set<AnyCancellable>() + private let isOnSubject: CurrentValueSubject<Bool, Never> + + public var spacingAfter: CGFloat? = 0 + + public init( + title: String, + content: String, + isEnabled: Bool = true, + spacingAfter: CGFloat = 10, + isInitiallyOn: Bool = false + ) { + self.title = title + self.content = content + self.isEnabled = isEnabled + self.spacingAfter = spacingAfter + self.isInitiallyOn = isInitiallyOn + self.isOnSubject = .init(isInitiallyOn) + } + + public func makeView() -> UIView { + let view = UIView() + let titleLabel = UILabel() + let contentLabel = UILabel() + let switcherView = UISwitch() + + titleLabel.text = title + contentLabel.text = content + + switcherView.isOn = isInitiallyOn + switcherView.isEnabled = isEnabled + switcherView.onTintColor = Asset.brandPrimary.color + + titleLabel.textColor = Asset.neutralWeak.color + contentLabel.textColor = Asset.neutralActive.color + + titleLabel.font = Fonts.Mulish.bold.font(size: 12.0) + contentLabel.font = Fonts.Mulish.regular.font(size: 16.0) + + view.addSubview(titleLabel) + view.addSubview(contentLabel) + view.addSubview(switcherView) + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + } + + contentLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(5) + $0.left.equalToSuperview() + $0.bottom.equalToSuperview() + } + + switcherView.snp.makeConstraints { + $0.right.equalToSuperview() + $0.centerY.equalToSuperview() + } + + switcherView.publisher(for: .valueChanged) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in isOnSubject.send(switcherView.isOn) } + .store(in: &cancellables) + + return view + } +} diff --git a/Sources/DrawerFeature/Items/DrawerTable.swift b/Sources/DrawerFeature/Items/DrawerTable.swift new file mode 100644 index 0000000000000000000000000000000000000000..bf1b8d4432e2220bcdac8372558bfbdccaf91f66 --- /dev/null +++ b/Sources/DrawerFeature/Items/DrawerTable.swift @@ -0,0 +1,143 @@ +import UIKit +import Shared +import SnapKit + +enum DrawerTableSection { + case main +} + +public final class DrawerTable: DrawerItem { + private let view = UIView() + private let tableView = UITableView() + private var heightConstraint: Constraint? + private let dataSource: UITableViewDiffableDataSource<DrawerTableSection, DrawerTableCellModel> + + public var spacingAfter: CGFloat? = 0 + + public init(spacingAfter: CGFloat? = 10) { + self.dataSource = .init( + tableView: tableView, + cellProvider: { tableView, indexPath, model in + let cell: DrawerTableCell = tableView.dequeueReusableCell(forIndexPath: indexPath) + + cell.titleLabel.text = model.title + cell.avatarView.setupProfile( + title: model.title, + image: model.image, + size: .medium + ) + + if model.isCreator { + cell.subtitleLabel.text = "Creator" + cell.subtitleLabel.isHidden = false + cell.subtitleLabel.textColor = Asset.accentSafe.color + } else if !model.isConnection { + cell.subtitleLabel.text = "Not a connection" + cell.subtitleLabel.isHidden = false + cell.subtitleLabel.textColor = Asset.neutralSecondaryAlternative.color + } else { + cell.subtitleLabel.isHidden = true + } + + return cell + }) + + self.spacingAfter = spacingAfter + } + + public func makeView() -> UIView { + tableView.register(DrawerTableCell.self) + tableView.dataSource = dataSource + tableView.separatorStyle = .none + + view.addSubview(tableView) + + tableView.snp.makeConstraints { + $0.edges.equalToSuperview() + heightConstraint = $0.height.equalTo(1).priority(.low).constraint + } + + return view + } + + public func update(models: [DrawerTableCellModel]) { + let cellHeight = 56 + self.heightConstraint?.update(offset: cellHeight * models.count) + + var snapshot = NSDiffableDataSourceSnapshot<DrawerTableSection, DrawerTableCellModel>() + snapshot.appendSections([.main]) + snapshot.appendItems(models, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false) { [self] in + tableView.isScrollEnabled = tableView.contentSize.height > tableView.frame.height + } + } +} + +public struct DrawerTableCellModel: Hashable { + let title: String + let image: Data? + let isCreator: Bool + let isConnection: Bool + + public init( + title: String, + image: Data? = nil, + isCreator: Bool = false, + isConnection: Bool = true + ) { + self.title = title + self.image = image + self.isCreator = isCreator + self.isConnection = isConnection + } +} + +final class DrawerTableCell: UITableViewCell { + let titleLabel = UILabel() + let subtitleLabel = UILabel() + let avatarView = AvatarView() + let stackView = UIStackView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + selectionStyle = .none + backgroundColor = Asset.neutralWhite.color + + titleLabel.font = Fonts.Mulish.semiBold.font(size: 16.0) + subtitleLabel.font = Fonts.Mulish.regular.font(size: 14.0) + titleLabel.textColor = Asset.neutralActive.color + + stackView.axis = .vertical + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(subtitleLabel) + + contentView.addSubview(avatarView) + contentView.addSubview(stackView) + + avatarView.snp.makeConstraints { + $0.width.equalTo(36) + $0.height.equalTo(36) + $0.top.equalToSuperview().offset(10) + $0.left.equalToSuperview() + $0.bottom.equalToSuperview().offset(-10) + } + + stackView.snp.makeConstraints { + $0.left.equalTo(avatarView.snp.right).offset(15) + $0.top.equalTo(avatarView) + $0.bottom.equalTo(avatarView) + $0.right.equalToSuperview() + } + } + + required init?(coder: NSCoder) { nil } + + override func prepareForReuse() { + super.prepareForReuse() + + titleLabel.text = nil + subtitleLabel.text = nil + avatarView.prepareForReuse() + } +} diff --git a/Sources/DrawerFeature/Items/DrawerText.swift b/Sources/DrawerFeature/Items/DrawerText.swift new file mode 100644 index 0000000000000000000000000000000000000000..8cfeaffa7487d0fe644b7538fd245648ef4cd21c --- /dev/null +++ b/Sources/DrawerFeature/Items/DrawerText.swift @@ -0,0 +1,70 @@ +import UIKit +import Shared + +public final class DrawerText: DrawerItem { + private let font: UIFont + private let text: String + private let color: UIColor + private let leftImage: UIImage? + private let alignment: NSTextAlignment + private let lineHeightMultiple: CGFloat + private let customAttributes: [NSAttributedString.Key: Any]? + private let stackView = UIStackView() + + public var spacingAfter: CGFloat? = 0 + + public init( + font: UIFont = Fonts.Mulish.regular.font(size: 16.0), + text: String, + color: UIColor = Asset.neutralActive.color, + alignment: NSTextAlignment = .left, + lineHeightMultiple: CGFloat = 1.1, + spacingAfter: CGFloat = 10, + customAttributes: [NSAttributedString.Key: Any]? = nil, + leftImage: UIImage? = nil + ) { + self.font = font + self.text = text + self.color = color + self.leftImage = leftImage + self.alignment = alignment + self.spacingAfter = spacingAfter + self.customAttributes = customAttributes + self.lineHeightMultiple = lineHeightMultiple + } + + public func makeView() -> UIView { + let label = UILabel() + label.numberOfLines = 0 + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = alignment + paragraphStyle.lineHeightMultiple = lineHeightMultiple + + let attrString = NSMutableAttributedString(string: text) + attrString.addAttributes([ + .paragraphStyle: paragraphStyle, + .foregroundColor: color, + .font: font as Any + ]) + + if let customAttributes = customAttributes { + attrString.addAttributes( + attributes: customAttributes, + betweenCharacters: "#" + ) + } + + label.attributedText = attrString + + if let image = leftImage { + let imageView = UIImageView() + imageView.image = image + stackView.addArrangedSubview(imageView) + } + + stackView.addArrangedSubview(label) + stackView.spacing = 5 + return stackView + } +} diff --git a/Sources/InputField/InputField.swift b/Sources/InputField/InputField.swift index 0eeaf47e4700d59bc7500e231a92e04c0af83fab..4174ff0a78abc8f3f597e3d7298a92f93f2a3fa8 100644 --- a/Sources/InputField/InputField.swift +++ b/Sources/InputField/InputField.swift @@ -183,7 +183,7 @@ public final class InputField: UIView { } } - private func set(rightView: RightView?) { + public func set(rightView: RightView?) { switch rightView { case.image(let image): field.rightView = UIImageView(image: image) diff --git a/Sources/InputField/Validator.swift b/Sources/InputField/Validator.swift index 0a9f30779bfa6115b91f65ade241a9c021495442..042916f36061b3b111e3d49cd1deb9e6079c2ed3 100644 --- a/Sources/InputField/Validator.swift +++ b/Sources/InputField/Validator.swift @@ -58,7 +58,7 @@ public extension Validator where T == String { return .failure("") } - let regex = try? NSRegularExpression(pattern: "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$") + let regex = try? NSRegularExpression(pattern: "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d!@#$%^&*]{8,}$") guard let regex = regex, regex.firstMatch(in: passphrase, options: [], range: passphrase.fullRange()) != nil else { return .failure("") diff --git a/Sources/Integration/Implementations/Bindings.swift b/Sources/Integration/Implementations/Bindings.swift index e9766771e889f1df7725c7b55295a9ac9a113249..c0ab7acfdef259bc1438b4a74de19c93bb1e18c1 100644 --- a/Sources/Integration/Implementations/Bindings.swift +++ b/Sources/Integration/Implementations/Bindings.swift @@ -80,15 +80,6 @@ extension BindingsClient: BindingsInterface { } guard let compressed = compressed else { - let extensiveLog = - """ - #### Important: No error was written but CompressJpeg() returned nothing. - - Params: - -- imageData = \(image.base64EncodedString()) - - Error description: \(error!.localizedDescription) - """ - log(string: extensiveLog, type: .error) - log(string: "An image compression failed without any specific error", type: .error) completion(.failure(NSError.create("Image compression failed without error"))) return } @@ -123,67 +114,25 @@ extension BindingsClient: BindingsInterface { } public func meMarshalled(_ username: String, email: String?, phone: String?) -> Data { - guard let user = getUser(), let contact = user.getContact(), let factList = contact.getFactList() else { - let extensiveLog = - """ - #### Important: It was impossible to get the fact list of my own contact. - - getUser was nil? = \(getUser() == nil) - - getContact was nil? = \(getUser()?.getContact() == nil) - """ - log(string: extensiveLog, type: .error) - dumpThreads() - fatalError(extensiveLog) - } + guard let user = getUser(), + let contact = user.getContact(), + let factList = contact.getFactList() else { fatalError() } - do { - try factList.add(username, factType: FactType.username.rawValue) - } catch { - log(string: error.localizedDescription, type: .error) - dumpThreads() - fatalError() - } + try! factList.add(username, factType: FactType.username.rawValue) if let email = email { - do { - try factList.add(email, factType: FactType.email.rawValue) - } catch { - log(string: error.localizedDescription, type: .error) - dumpThreads() - fatalError() - } + try! factList.add(email, factType: FactType.email.rawValue) } if let phone = phone { - do { - try factList.add(phone, factType: FactType.phone.rawValue) - } catch { - log(string: error.localizedDescription, type: .error) - dumpThreads() - fatalError() - } + try! factList.add(phone, factType: FactType.phone.rawValue) } - do { - return try contact.marshal() - } catch { - log(string: error.localizedDescription, type: .error) - dumpThreads() - fatalError() - } + return try! contact.marshal() } public var receptionId: Data { - guard let user = getUser(), let recId = user.getReceptionID() else { - let extendedLog = - """ - #### Important: It was impossible to get my own reception id. - - getUser was nil? = \(getUser() == nil) - """ - log(string: extendedLog, type: .error) - dumpThreads() - fatalError(extendedLog) - } - + guard let user = getUser(), let recId = user.getReceptionID() else { fatalError() } return recId } @@ -550,19 +499,19 @@ extension BindingsClient: BindingsInterface { /// Alternate udb -// guard let certPath = Bundle.module.path(forResource: "ud.elixxir.io", ofType: "crt") else { -// fatalError("Couldn't retrieve cert.") -// } -// -// guard let contactFilePath = Bundle.module.path(forResource: "udContact-test", ofType: "bin") else { -// fatalError("Couldn't retrieve cert.") -// } -// -// try! udb!.setAlternative( -// "18.198.117.203:11420".data(using: .utf8), -// cert: try! Data(contentsOf: URL(fileURLWithPath: certPath)), -// contactFile: try! Data(contentsOf: URL(fileURLWithPath: contactFilePath)) -// ) + guard let certPath = Bundle.module.path(forResource: "ud.elixxir.io", ofType: "crt") else { + fatalError("Couldn't retrieve cert.") + } + + guard let contactFilePath = Bundle.module.path(forResource: "udContact-test", ofType: "bin") else { + fatalError("Couldn't retrieve cert.") + } + + try! udb!.setAlternative( + "18.198.117.203:11420".data(using: .utf8), + cert: try! Data(contentsOf: URL(fileURLWithPath: certPath)), + contactFile: try! Data(contentsOf: URL(fileURLWithPath: contactFilePath)) + ) guard let error = error else { return udb! } throw error.friendly() @@ -576,19 +525,19 @@ extension BindingsClient: BindingsInterface { /// Alternate udb -// guard let certPath = Bundle.module.path(forResource: "ud.elixxir.io", ofType: "crt") else { -// fatalError("Couldn't retrieve cert.") -// } -// -// guard let contactFilePath = Bundle.module.path(forResource: "udContact-test", ofType: "bin") else { -// fatalError("Couldn't retrieve cert.") -// } -// -// try! udb!.setAlternative( -// "18.198.117.203:11420".data(using: .utf8), -// cert: try! Data(contentsOf: URL(fileURLWithPath: certPath)), -// contactFile: try! Data(contentsOf: URL(fileURLWithPath: contactFilePath)) -// ) + guard let certPath = Bundle.module.path(forResource: "ud.elixxir.io", ofType: "crt") else { + fatalError("Couldn't retrieve cert.") + } + + guard let contactFilePath = Bundle.module.path(forResource: "udContact-test", ofType: "bin") else { + fatalError("Couldn't retrieve cert.") + } + + try! udb!.setAlternative( + "18.198.117.203:11420".data(using: .utf8), + cert: try! Data(contentsOf: URL(fileURLWithPath: certPath)), + contactFile: try! Data(contentsOf: URL(fileURLWithPath: contactFilePath)) + ) guard let error = error else { return udb! } throw error.friendly() diff --git a/Sources/Integration/Implementations/GroupManager.swift b/Sources/Integration/Implementations/GroupManager.swift index 95a23565b943da9c5b184df28672adf874d0dafc..c51522bea25f9065f485b37ce4a87effc6fcd33d 100644 --- a/Sources/Integration/Implementations/GroupManager.swift +++ b/Sources/Integration/Implementations/GroupManager.swift @@ -72,7 +72,8 @@ extension BindingsGroupChat: GroupManagerInterface { leader: me, name: name, groupId: group.getID()!, - accepted: true, + status: .participating, + createdAt: Date(), serialize: group.serialize()! ))) return diff --git a/Sources/Integration/Listeners.swift b/Sources/Integration/Listeners.swift index 3372cb26b7d27d06c51582ccfb6fd514286d316e..f820cb04f57dfa4dfcc383a24081943c0e9b246a 100644 --- a/Sources/Integration/Listeners.swift +++ b/Sources/Integration/Listeners.swift @@ -135,7 +135,8 @@ public extension BindingsClient { leader: members.first!, name: String(data: name, encoding: .utf8)!, groupId: id, - accepted: false, + status: .pending, + createdAt: Date(), serialize: serialize ), members, welcomeMessage) } diff --git a/Sources/Integration/Mocks/BindingsMock.swift b/Sources/Integration/Mocks/BindingsMock.swift index 3ab8c5e3405500028f1f996200c6133fc8cdcc87..1235f0409f78f70e7c68f1bcd83b62c07b19f458 100644 --- a/Sources/Integration/Mocks/BindingsMock.swift +++ b/Sources/Integration/Mocks/BindingsMock.swift @@ -5,6 +5,7 @@ import Foundation public final class BindingsMock: BindingsInterface { private var cancellables = Set<AnyCancellable>() private let requestsSubject = PassthroughSubject<Contact, Never>() + private let groupRequestsSubject = PassthroughSubject<Group, Never>() private let confirmationsSubject = PassthroughSubject<Contact, Never>() public var hasRunningTasks: Bool { @@ -87,11 +88,11 @@ public final class BindingsMock: BindingsInterface { public func initializeBackup( passphrase: String, callback: @escaping (Data) -> Void - ) -> BackupInterface { fatalError() } + ) -> BackupInterface { BindingsBackupMock() } public func resumeBackup( callback: @escaping (Data) -> Void - ) -> BackupInterface { fatalError() } + ) -> BackupInterface { BindingsBackupMock() } public func listenNetworkUpdates(_: @escaping (Bool) -> Void) {} @@ -112,7 +113,10 @@ public final class BindingsMock: BindingsInterface { return } + self?.requestsSubject.send(.carlRequested) self?.requestsSubject.send(.angelinaRequested) + self?.requestsSubject.send(.elonRequested) + self?.groupRequestsSubject.send(.mockGroup) DispatchQueue.global().asyncAfter(deadline: .now() + 1) { [weak self] in self?.confirmationsSubject.send(.georgeDiscovered) @@ -154,10 +158,14 @@ public final class BindingsMock: BindingsInterface { } public func listenGroupRequests( - _: @escaping (Group, [Data], String?) -> Void, + _ groupRequests: @escaping (Group, [Data], String?) -> Void, groupMessages: @escaping (GroupMessage) -> Void ) throws -> GroupManagerInterface? { - GroupManagerMock() + groupRequestsSubject + .sink { groupRequests($0, [], nil) } + .store(in: &cancellables) + + return GroupManagerMock() } public func restore( @@ -174,6 +182,17 @@ public final class BindingsMock: BindingsInterface { } } +extension Group { + static let mockGroup = Group( + leader: "mockGroupLeader".data(using: .utf8)!, + name: "Bruno's birthday 6/1", + groupId: "mockGroup".data(using: .utf8)!, + status: .pending, + createdAt: Date.distantPast, + serialize: "mockGroup".data(using: .utf8)! + ) +} + extension Contact { static func mock(_ count: Int = 1) -> [Contact] { var mocks = [Contact]() @@ -198,15 +217,39 @@ extension Contact { static let angelinaRequested = Contact( photo: nil, userId: "angelinajolie".data(using: .utf8)!, - email: "angelina@xx.io", - phone: "81982022255BR", - status: .verified, + email: nil, + phone: nil, + status: .verificationInProgress, marshaled: "angelinajolie".data(using: .utf8)!, username: "angelinajolie", - nickname: "Angelina Jolie", + nickname: "Angelica Jolie", createdAt: Date() ) + static let carlRequested = Contact( + photo: nil, + userId: "carlsagan".data(using: .utf8)!, + email: "carl@jpl.nasa", + phone: "81982022244BR", + status: .verified, + marshaled: "carlsagan".data(using: .utf8)!, + username: "carlsagan", + nickname: "Carl Sagan", + createdAt: Date.distantPast + ) + + static let elonRequested = Contact( + photo: nil, + userId: "elonmusk".data(using: .utf8)!, + email: "elon@tesla.com", + phone: nil, + status: .verified, + marshaled: "elonmusk".data(using: .utf8)!, + username: "elonmusk", + nickname: "Elon Musk", + createdAt: Date.distantPast + ) + static let georgeDiscovered = Contact( photo: nil, userId: "georgebenson74".data(using: .utf8)!, @@ -234,3 +277,17 @@ public struct MockDummyManager: DummyTrafficManaging { print("Dummy manager status set to \(status)") } } + +public struct BindingsBackupMock: BackupInterface { + public func stop() throws { + // TODO + } + + public func addJson(_: String?) { + // TODO + } + + public func isBackupRunning() -> Bool { + return true + } +} diff --git a/Sources/Integration/Session/Session+Chat.swift b/Sources/Integration/Session/Session+Chat.swift index 2c8047f3f0ef7d295012f37934fe2c3295390f26..5bdf71c60b733813df42fadfa5290e011b54e8d0 100644 --- a/Sources/Integration/Session/Session+Chat.swift +++ b/Sources/Integration/Session/Session+Chat.swift @@ -259,7 +259,7 @@ extension Session { var message = Message( sender: transfer.contact, receiver: client.bindings.meMarshalled, - payload: .init(text: "Sent you a \(transfer.fileType)", reply: nil, attachment: attachment), + payload: .init(text: "Sent you a \(fileExtension.writtenExtended)", reply: nil, attachment: attachment), unread: true, timestamp: Date.asTimestamp, uniqueId: nil, diff --git a/Sources/Integration/Session/Session+Contacts.swift b/Sources/Integration/Session/Session+Contacts.swift index 3a8b20a3f35d4cd211e7f01612a3d14e18976f3d..1c6690b55619b4c63a12f5025765923844fedeeb 100644 --- a/Sources/Integration/Session/Session+Contacts.swift +++ b/Sources/Integration/Session/Session+Contacts.swift @@ -1,5 +1,6 @@ import Retry import Models +import Shared import Database import Foundation @@ -149,7 +150,11 @@ extension Session { contactToOperate.status = .requesting - let myself = client.bindings.meMarshalled(username!, email: nil, phone: nil) + let myself = client.bindings.meMarshalled( + username!, + email: isSharingEmail ? email : nil, + phone: isSharingPhone ? phone : nil + ) client.bindings.add(contactToOperate.marshaled, from: myself) { [weak self, contactToOperate] in guard let self = self, var contactToOperate = contactToOperate else { return } @@ -169,6 +174,12 @@ extension Session { contactToOperate = try self.dbManager.save(contactToOperate) log(string: "Failed when adding \(title):\n\(error.localizedDescription)", type: .error) + + self.toastController.enqueueToast(model: .init( + title: Localized.Requests.Failed.toast(contactToOperate.nickname ?? contact.username), + color: Asset.accentDanger.color, + leftImage: Asset.requestFailedToaster.image + )) } } catch { log(string: "Error adding \(title):\n\(error.localizedDescription)", type: .error) diff --git a/Sources/Integration/Session/Session+Group.swift b/Sources/Integration/Session/Session+Group.swift index 115bd669c39af44a9a17e63a3e0a49313787bfca..78670c9b0b9083dfa09ee555101a2479a153809f 100644 --- a/Sources/Integration/Session/Session+Group.swift +++ b/Sources/Integration/Session/Session+Group.swift @@ -9,8 +9,8 @@ extension Session { try manager.join(group.serialize) var group = group - group.accepted = true - scanStrangers() + group.status = .participating + scanStrangers {} try dbManager.save(group) } @@ -63,7 +63,7 @@ extension Session { userId: stranger.element, groupId: group.groupId, status: .pendingUsername, - username: "Unknown user nº \(stranger.offset)", + username: "Fetching username...", photo: nil )) } @@ -76,7 +76,7 @@ extension Session { DeviceFeedback.shake(.notification) } - scanStrangers() + scanStrangers {} return members } } @@ -158,11 +158,18 @@ extension Session { } } - private func scanStrangers() { + public func scanStrangers(_ completion: @escaping () -> Void) { DispatchQueue.global().async { [weak self] in guard let self = self, let ud = self.client.userDiscovery else { return } - guard let strangers: [GroupMember] = try? self.dbManager.fetch(.strangers) else { return } + guard let strangers: [GroupMember] = try? self.dbManager.fetch(.strangers) else { + DispatchQueue.main.async { + completion() + } + + return + } + let ids = strangers.map { $0.userId } var updatedStrangers: [GroupMember] = [] @@ -173,6 +180,7 @@ extension Session { strangers.forEach { stranger in if let found = contacts.first(where: { contact in contact.userId == stranger.userId }) { var updatedStranger = stranger + updatedStranger.status = .usernameSet updatedStranger.username = found.username updatedStrangers.append(updatedStranger) } @@ -188,10 +196,12 @@ extension Session { } log(string: "Scanned unknown group members", type: .info) + completion() } case .failure(let error): DispatchQueue.main.async { log(string: error.localizedDescription, type: .error) + completion() } } } diff --git a/Sources/Integration/Session/Session.swift b/Sources/Integration/Session/Session.swift index b674246c17f7bfed38a3e3173a1583ef9e7f7c8a..7e0f40d9ba28d6ffac4c05f37f6aa0cbe5d6b16a 100644 --- a/Sources/Integration/Session/Session.swift +++ b/Sources/Integration/Session/Session.swift @@ -10,6 +10,7 @@ import NetworkMonitor import DependencyInjection import os.log +import ToastFeature let logHandler = OSLog(subsystem: "xx.network", category: "Performance debugging") @@ -47,6 +48,7 @@ public final class Session: SessionType { @KeyObject(.inappnotifications, defaultValue: true) var inappnotifications: Bool @Dependency var backupService: BackupService + @Dependency var toastController: ToastController @Dependency var networkMonitor: NetworkMonitoring public let client: Client @@ -74,8 +76,18 @@ public final class Session: SessionType { networkMonitor.statusPublisher.map { $0 == .available }.eraseToAnyPublisher() } - lazy public var groups: (Group.Request) -> AnyPublisher<[Group], Never> = { - self.dbManager.publisher(fetch: Group.self, $0).catch { _ in Just([]) }.eraseToAnyPublisher() + public func groups(_ request: Group.Request) -> AnyPublisher<[Group], Never> { + self.dbManager + .publisher(fetch: Group.self, request) + .catch { _ in Just([]) } + .eraseToAnyPublisher() + } + + public func groupMembers(_ request: GroupMember.Request) -> AnyPublisher<[GroupMember], Never> { + self.dbManager + .publisher(fetch: GroupMember.self, request) + .catch { _ in Just([]) } + .eraseToAnyPublisher() } lazy public var contacts: (Contact.Request) -> AnyPublisher<[Contact], Never> = { @@ -218,6 +230,18 @@ public final class Session: SessionType { inappnotifications = true } + public func hideRequestOf(group: Group) { + var group = group + group.status = .hidden + _ = try? dbManager.save(group) + } + + public func hideRequestOf(contact: Contact) { + var contact = contact + contact.status = .hidden + _ = try? dbManager.save(contact) + } + public func forceFailMessages() { if let pendingE2E: [Message] = try? dbManager.fetch(.sending) { pendingE2E.forEach { @@ -381,6 +405,12 @@ public final class Session: SessionType { if var contact: Contact = try? dbManager.fetch(.withUserId($0.userId)).first { contact.status = .friend _ = try? dbManager.save(contact) + + toastController.enqueueToast(model: .init( + title: contact.nickname ?? contact.username, + subtitle: Localized.Requests.Confirmations.toaster, + leftImage: Asset.sharedSuccess.image + )) } }.store(in: &cancellables) } diff --git a/Sources/Integration/Session/SessionType.swift b/Sources/Integration/Session/SessionType.swift index 930724e7d0cfb949ee1e5ca2d64eb4212b22af89..a1e2c7331472bbbe6d2c64d9a2daeeeaa7bbd64d 100644 --- a/Sources/Integration/Session/SessionType.swift +++ b/Sources/Integration/Session/SessionType.swift @@ -14,7 +14,9 @@ public protocol SessionType { var singleMessages: (Contact) -> AnyPublisher<[Message], Never> { get } var singleChats: (SingleChatInfo.Request) -> AnyPublisher<[SingleChatInfo], Never> { get } - var groups: (Group.Request) -> AnyPublisher<[Group], Never> { get } + func groupMembers(_: GroupMember.Request) -> AnyPublisher<[GroupMember], Never> + + func groups(_: Group.Request) -> AnyPublisher<[Group], Never> var groupMessages: (Group) -> AnyPublisher<[GroupMessage], Never> { get } var groupChats: (GroupChatInfo.Request) -> AnyPublisher<[GroupChatInfo], Never> { get } @@ -23,6 +25,10 @@ public protocol SessionType { func forceFailMessages() + func hideRequestOf(group: Group) + + func hideRequestOf(contact: Contact) + func send(imageData: Data, to: Contact, completion: @escaping (Result<Void, Error>) -> Void) func verify(contact: Contact) @@ -73,6 +79,7 @@ public protocol SessionType { func deleteContact(_: Contact) throws func retryRequest(_: Contact) throws + func scanStrangers(_: @escaping () -> Void) // Groups diff --git a/Sources/Integration/XXNetwork.swift b/Sources/Integration/XXNetwork.swift index 5761631d28d8b39cb22aafa00f89dbb53f946f2e..34d656ec2d432f54ecac1693cbf8e27d05c9db30 100644 --- a/Sources/Integration/XXNetwork.swift +++ b/Sources/Integration/XXNetwork.swift @@ -80,7 +80,6 @@ extension XXNetwork: XXNetworking { data: Data, ndf: String ) throws -> (Client, Data?) { - var error: NSError? let password = B.secret(32)! diff --git a/Sources/MenuFeature/Controllers/MenuController.swift b/Sources/MenuFeature/Controllers/MenuController.swift index 11187a999ba362258a65a3091b96f806f394183a..a960075a03c8d2a1f337d4d16581a650317420eb 100644 --- a/Sources/MenuFeature/Controllers/MenuController.swift +++ b/Sources/MenuFeature/Controllers/MenuController.swift @@ -2,33 +2,38 @@ import Theme import UIKit import Shared import Combine +import DrawerFeature import DependencyInjection public enum MenuItem { + case join case scan + case chats + case profile case contacts case requests - case profile case settings case dashboard - case join -} - -public protocol MenuDelegate: AnyObject { - func didSelect(item: MenuItem) } public final class MenuController: UIViewController { + @Dependency private var coordinator: MenuCoordinating @Dependency private var statusBarController: StatusBarStyleControlling lazy private var screenView = MenuView() - weak var delegate: MenuDelegate? + private let previousItem: MenuItem private let viewModel = MenuViewModel() + private let previousController: UIViewController private var cancellables = Set<AnyCancellable>() - - public init(_ delegate: MenuDelegate) { - self.delegate = delegate + private var drawerCancellables = Set<AnyCancellable>() + + public init( + _ previousItem: MenuItem, + _ previousController: UIViewController + ) { + self.previousItem = previousItem + self.previousController = previousController super.init(nibName: nil, bundle: nil) } @@ -68,16 +73,18 @@ public final class MenuController: UIViewController { .receive(on: DispatchQueue.main) .sink { [unowned self] in dismiss(animated: true) { [weak self] in - self?.delegate?.didSelect(item: .scan) + guard let self = self, self.previousItem != .scan else { return } + self.coordinator.toFlow(.scan, from: self.previousController) } }.store(in: &cancellables) - screenView.profileButton + screenView.headerView.nameButton .publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { [unowned self] in dismiss(animated: true) { [weak self] in - self?.delegate?.didSelect(item: .profile) + guard let self = self, self.previousItem != .profile else { return } + self.coordinator.toFlow(.profile, from: self.previousController) } }.store(in: &cancellables) @@ -86,22 +93,28 @@ public final class MenuController: UIViewController { .receive(on: DispatchQueue.main) .sink { [unowned self] in dismiss(animated: true) { [weak self] in - self?.delegate?.didSelect(item: .scan) + 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) } - .store(in: &cancellables) + .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 - self?.delegate?.didSelect(item: .contacts) + guard let self = self, self.previousItem != .contacts else { return } + self.coordinator.toFlow(.contacts, from: self.previousController) } }.store(in: &cancellables) @@ -110,7 +123,8 @@ public final class MenuController: UIViewController { .receive(on: DispatchQueue.main) .sink { [unowned self] in dismiss(animated: true) { [weak self] in - self?.delegate?.didSelect(item: .settings) + guard let self = self, self.previousItem != .settings else { return } + self.coordinator.toFlow(.settings, from: self.previousController) } }.store(in: &cancellables) @@ -119,25 +133,40 @@ public final class MenuController: UIViewController { .receive(on: DispatchQueue.main) .sink { [unowned self] in dismiss(animated: true) { [weak self] in - self?.delegate?.didSelect(item: .dashboard) + 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.joinButton + screenView.requestsButton .publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { [unowned self] in dismiss(animated: true) { [weak self] in - self?.delegate?.didSelect(item: .join) + guard let self = self, self.previousItem != .requests else { return } + self.coordinator.toFlow(.requests, from: self.previousController) } }.store(in: &cancellables) - screenView.requestsButton + screenView.joinButton .publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { [unowned self] in dismiss(animated: true) { [weak self] in - self?.delegate?.didSelect(item: .requests) + 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) @@ -146,4 +175,46 @@ public final class MenuController: UIViewController { .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/MenuFeature/Coordinator/MenuCoordinator.swift b/Sources/MenuFeature/Coordinator/MenuCoordinator.swift new file mode 100644 index 0000000000000000000000000000000000000000..fba5c8ea4f8bfb1aa155dcf4ada8fba1a58c3f39 --- /dev/null +++ b/Sources/MenuFeature/Coordinator/MenuCoordinator.swift @@ -0,0 +1,64 @@ +import UIKit +import Presentation + +public protocol MenuCoordinating { + func toFlow(_ item: MenuItem, from: UIViewController) + func toDrawer(_: UIViewController, from: UIViewController) +} + +public struct MenuCoordinator: MenuCoordinating { + var bottomPresenter: Presenting = BottomPresenter() + var replacePresenter: Presenting = ReplacePresenter() + + var scanFactory: () -> UIViewController + var chatsFactory: () -> UIViewController + var profileFactory: () -> UIViewController + var settingsFactory: () -> UIViewController + var contactsFactory: () -> UIViewController + var requestsFactory: () -> UIViewController + + public init( + scanFactory: @escaping () -> UIViewController, + chatsFactory: @escaping () -> UIViewController, + profileFactory: @escaping () -> UIViewController, + settingsFactory: @escaping () -> UIViewController, + contactsFactory: @escaping () -> UIViewController, + requestsFactory: @escaping () -> UIViewController + ) { + self.scanFactory = scanFactory + self.chatsFactory = chatsFactory + self.profileFactory = profileFactory + self.settingsFactory = settingsFactory + self.contactsFactory = contactsFactory + self.requestsFactory = requestsFactory + } +} + +public extension MenuCoordinator { + func toDrawer(_ drawer: UIViewController, from parent: UIViewController) { + bottomPresenter.present(drawer, from: parent) + } + + func toFlow(_ item: MenuItem, from parent: UIViewController) { + let controller: UIViewController + + switch item { + case .scan: + controller = scanFactory() + case .chats: + controller = chatsFactory() + case .profile: + controller = profileFactory() + case .contacts: + controller = contactsFactory() + case .requests: + controller = requestsFactory() + case .settings: + controller = settingsFactory() + default: + fatalError() + } + + replacePresenter.present(controller, from: parent) + } +} diff --git a/Sources/MenuFeature/ViewModels/MenuViewModel.swift b/Sources/MenuFeature/ViewModels/MenuViewModel.swift index a1a10b500d65fb3f109fb69cee40711876b1163c..92eb785136c7cc4ba6ff6fb30fc597d93d6a3ac7 100644 --- a/Sources/MenuFeature/ViewModels/MenuViewModel.swift +++ b/Sources/MenuFeature/ViewModels/MenuViewModel.swift @@ -14,8 +14,21 @@ final class MenuViewModel { Publishers.CombineLatest( session.contacts(.received), session.groups(.pending) - ).map { $0.0.count + $0.1.count } - .eraseToAnyPublisher() + ).map { (contacts, groups) in + let contactRequests = contacts.filter { + $0.status == .verified || + $0.status == .confirming || + $0.status == .confirmationFailed || + $0.status == .verificationFailed || + $0.status == .verificationInProgress + } + + let groupRequests = groups.filter { + $0.status == .pending + } + + return contactRequests.count + groupRequests.count + }.eraseToAnyPublisher() } var xxdk: String { diff --git a/Sources/MenuFeature/Views/MenuHeaderView.swift b/Sources/MenuFeature/Views/MenuHeaderView.swift index 0fd07e6635ef8fba6761ca332e54d21568d97320..2f925646b3b03c3aec498ccb67578a1b4f40fb89 100644 --- a/Sources/MenuFeature/Views/MenuHeaderView.swift +++ b/Sources/MenuFeature/Views/MenuHeaderView.swift @@ -2,7 +2,7 @@ import UIKit import Shared final class MenuHeaderView: UIView { - let nameLabel = UILabel() + let nameButton = UIButton() let scanButton = UIButton() let stackView = UIStackView() let avatarView = AvatarView() @@ -16,14 +16,14 @@ final class MenuHeaderView: UIView { helloLabel.textColor = Asset.neutralWeak.color helloLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) - nameLabel.textColor = Asset.neutralLine.color - nameLabel.font = Fonts.Mulish.bold.font(size: 18.0) + nameButton.titleLabel?.font = Fonts.Mulish.bold.font(size: 18.0) + nameButton.setTitleColor(Asset.neutralLine.color, for: .normal) let spacingView = UIView() verticalStackView.axis = .vertical verticalStackView.addArrangedSubview(spacingView) verticalStackView.addArrangedSubview(helloLabel) - verticalStackView.addArrangedSubview(nameLabel.pinning(at: .top(0))) + verticalStackView.addArrangedSubview(nameButton.pinning(at: .left(0))) verticalStackView.setCustomSpacing(15, after: spacingView) verticalStackView.setCustomSpacing(5, after: helloLabel) @@ -47,7 +47,7 @@ final class MenuHeaderView: UIView { required init?(coder: NSCoder) { nil } func set(username: String, image: Data? = nil) { - nameLabel.text = username - avatarView.set(username: username, image: image) + nameButton.setTitle(username, for: .normal) + avatarView.setupProfile(title: username, image: image, size: .large) } } diff --git a/Sources/MenuFeature/Views/MenuView.swift b/Sources/MenuFeature/Views/MenuView.swift index 7a72252ec90011db6a9ac31d08843639d85de7b5..48bee02edc7f7e3e22edfc75281395e968c0c973 100644 --- a/Sources/MenuFeature/Views/MenuView.swift +++ b/Sources/MenuFeature/Views/MenuView.swift @@ -6,7 +6,6 @@ final class MenuView: UIView { let stackView = UIStackView() let scanButton = MenuSectionButton() let chatsButton = MenuSectionButton() - let profileButton = MenuSectionButton() let contactsButton = MenuSectionButton() let requestsButton = MenuSectionButton() let settingsButton = MenuSectionButton() @@ -35,7 +34,6 @@ final class MenuView: UIView { scanButton.set(title: Localized.Menu.scan, image: Asset.menuScan.image) requestsButton.set(title: Localized.Menu.requests, image: Asset.menuRequests.image) - profileButton.set(title: Localized.Menu.profile, image: Asset.menuProfile.image) contactsButton.set(title: Localized.Menu.contacts, image: Asset.menuContacts.image) settingsButton.set(title: Localized.Menu.settings, image: Asset.menuSettings.image) dashboardButton.set(title: Localized.Menu.dashboard, image: Asset.menuDashboard.image) @@ -44,7 +42,6 @@ final class MenuView: UIView { stackView.addArrangedSubview(chatsButton) stackView.addArrangedSubview(contactsButton) stackView.addArrangedSubview(requestsButton) - stackView.addArrangedSubview(profileButton) stackView.addArrangedSubview(scanButton) stackView.addArrangedSubview(settingsButton) stackView.addArrangedSubview(dashboardButton) @@ -92,7 +89,6 @@ final class MenuView: UIView { scanButton.accessibilityIdentifier = Localized.Accessibility.Menu.scan chatsButton.accessibilityIdentifier = Localized.Accessibility.Menu.chats headerView.accessibilityIdentifier = Localized.Accessibility.Menu.header - profileButton.accessibilityIdentifier = Localized.Accessibility.Menu.profile contactsButton.accessibilityIdentifier = Localized.Accessibility.Menu.contacts requestsButton.accessibilityIdentifier = Localized.Accessibility.Menu.requests settingsButton.accessibilityIdentifier = Localized.Accessibility.Menu.settings diff --git a/Sources/Models/Attachment.swift b/Sources/Models/Attachment.swift index a4e531addace992e3fbbd07c19fba5e238e910b3..92eb3c09dcf4a7846bf5e265ec5a5080342a69b1 100644 --- a/Sources/Models/Attachment.swift +++ b/Sources/Models/Attachment.swift @@ -18,6 +18,15 @@ public struct Attachment: Codable, Equatable, Hashable { return "m4a" } } + + public var writtenExtended: String { + switch self { + case .image: + return "image" + case .audio: + return "voice message" + } + } } public let data: Data? diff --git a/Sources/Models/Contact.swift b/Sources/Models/Contact.swift index 051057e63d91b57ef49d741f766541b1a720b978..f80b0ae1d135c1bcf913efe8cc667a4e7d178aed 100644 --- a/Sources/Models/Contact.swift +++ b/Sources/Models/Contact.swift @@ -1,8 +1,50 @@ -import Foundation +import UIKit import DifferenceKit +public protocol IndexableItem { + var indexedOn: NSString { get } +} + +public class IndexedListCollator<Item: IndexableItem> { + private final class CollationWrapper: NSObject { + let value: Any + @objc let indexedOn: NSString + + init(value: Any, indexedOn: NSString) { + self.value = value + self.indexedOn = indexedOn + } + + func unwrappedValue<UnwrappedType>() -> UnwrappedType { + return value as! UnwrappedType + } + } + + public init() {} + + public func sectioned(items: [Item]) -> (sections: [[Item]], collation: UILocalizedIndexedCollation) { + let collation = UILocalizedIndexedCollation.current() + let selector = #selector(getter: CollationWrapper.indexedOn) + + let wrappedItems = items.map { item in + CollationWrapper(value: item, indexedOn: item.indexedOn) + } + + let sortedObjects = collation.sortedArray(from: wrappedItems, collationStringSelector: selector) as! [CollationWrapper] + + var sections = collation.sectionIndexTitles.map { _ in [Item]() } + sortedObjects.forEach { item in + let sectionNumber = collation.section(for: item, collationStringSelector: selector) + sections[sectionNumber].append(item.unwrappedValue()) + } + + return (sections: sections.filter { !$0.isEmpty }, collation: collation) + } +} + public struct Contact: Codable, Hashable, Equatable { public enum Request { + case all case failed case friends case received @@ -24,6 +66,7 @@ public struct Contact: Codable, Hashable, Equatable { case requestFailed case confirming case confirmationFailed + case hidden } public var id: Int64? @@ -65,3 +108,12 @@ public struct Contact: Codable, Hashable, Equatable { } extension Contact: Differentiable {} +extension Contact: IndexableItem { + public var indexedOn: NSString { + guard let nickname = nickname else { + return "\(username.first!)" as NSString + } + + return "\(nickname.first!)" as NSString + } +} diff --git a/Sources/Models/Group.swift b/Sources/Models/Group.swift index 8a724cf68c93f1b71e8256c91ea0674ceaf677eb..feda834cb266a933e2470e3d6ab5910b22d4588b 100644 --- a/Sources/Models/Group.swift +++ b/Sources/Models/Group.swift @@ -1,6 +1,14 @@ import Foundation +import KeychainAccess public struct Group: Codable, Equatable, Hashable { + public enum Status: Int64, Codable { + case hidden + case pending + case deleting + case participating + } + public enum Request { case pending case accepted @@ -11,21 +19,24 @@ public struct Group: Codable, Equatable, Hashable { public var name: String public var leader: Data public var groupId: Data - public var accepted: Bool + public var status: Status public var serialize: Data + public var createdAt: Date public static var databaseTableName: String { "groups" } public init( leader: Data, name: String, groupId: Data, - accepted: Bool, + status: Status, + createdAt: Date, serialize: Data ) { self.name = name self.leader = leader + self.status = status self.groupId = groupId - self.accepted = accepted + self.createdAt = createdAt self.serialize = serialize } } diff --git a/Sources/Models/GroupMember.swift b/Sources/Models/GroupMember.swift index c0f55d3eac21ec63482cd4150e55d3e773e84aa4..25c619f3280d9aa2891ab7b205e6639884783840 100644 --- a/Sources/Models/GroupMember.swift +++ b/Sources/Models/GroupMember.swift @@ -2,7 +2,9 @@ import Foundation public struct GroupMember { public enum Request { + case all case strangers + case fromGroup(Data) case withUserId(Data) } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift b/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift index 3ab0b1633f363bb0f3ba0743357fc79fa83a0f2b..75b7d0ffb081bec57a2c8cf06d0fa6915fd07092 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift @@ -1,5 +1,5 @@ import HUD -import Popup +import DrawerFeature import Theme import UIKit import Shared @@ -18,7 +18,7 @@ public final class OnboardingEmailConfirmationController: UIViewController { private var cancellables = Set<AnyCancellable>() private let completion: (UIViewController) -> Void - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() private let viewModel: OnboardingEmailConfirmationViewModel public init( @@ -122,17 +122,17 @@ public final class OnboardingEmailConfirmationController: UIViewController { private func presentInfo(title: String, subtitle: String) { let actionButton = CapsuleButton() - actionButton.set(style: .seeThrough, title: Localized.Settings.InfoPopUp.action) + actionButton.set(style: .seeThrough, title: Localized.Settings.InfoDrawer.action) - let popup = BottomPopup(with: [ - PopupLabel( + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), text: title, color: Asset.neutralActive.color, alignment: .left, spacingAfter: 19 ), - PopupLabel( + DrawerText( font: Fonts.Mulish.regular.font(size: 16.0), text: subtitle, color: Asset.neutralBody.color, @@ -140,19 +140,22 @@ public final class OnboardingEmailConfirmationController: UIViewController { lineHeightMultiple: 1.1, spacingAfter: 37 ), - PopupStackView(views: [actionButton, FlexibleSpace()]) + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) ]) actionButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } @objc private func didTapBack() { diff --git a/Sources/OnboardingFeature/Controllers/OnboardingEmailController.swift b/Sources/OnboardingFeature/Controllers/OnboardingEmailController.swift index 9997793da6a7803b491ff4a3b0abc57814432a62..68b10159a32ab4e5167d10ba98d993f453fdf24d 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingEmailController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingEmailController.swift @@ -1,5 +1,5 @@ import HUD -import Popup +import DrawerFeature import Theme import UIKit import Shared @@ -17,7 +17,7 @@ public final class OnboardingEmailController: UIViewController { private var cancellables = Set<AnyCancellable>() private let viewModel = OnboardingEmailViewModel() - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -102,33 +102,39 @@ public final class OnboardingEmailController: UIViewController { urlString: String = "" ) { let actionButton = CapsuleButton() - actionButton.set(style: .seeThrough, title: Localized.Settings.InfoPopUp.action) + actionButton.set( + style: .seeThrough, + title: Localized.Settings.InfoDrawer.action + ) - let popup = BottomPopup(with: [ - PopupLabel( + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), text: title, color: Asset.neutralActive.color, alignment: .left, spacingAfter: 19 ), - PopupLinkText( + DrawerLinkText( text: subtitle, urlString: urlString, spacingAfter: 37 ), - PopupStackView(views: [actionButton, FlexibleSpace()]) + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) ]) actionButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingLaunchController.swift b/Sources/OnboardingFeature/Controllers/OnboardingLaunchController.swift index 7c384baaee94b9f88bb2402d9f9bccf3f14e459a..f7e3087e4bc2425f901623b088d6b414bde1d65f 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingLaunchController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingLaunchController.swift @@ -76,20 +76,20 @@ public final class OnboardingLaunchController: UIViewController { viewModel.updatePublisher .receive(on: DispatchQueue.main) .sink { [unowned self] updateModel in - let popupView = UIView() - popupView.backgroundColor = Asset.neutralSecondary.color - popupView.layer.cornerRadius = 5 + let drawerView = UIView() + drawerView.backgroundColor = Asset.neutralSecondary.color + drawerView.layer.cornerRadius = 5 let vStack = UIStackView() vStack.axis = .vertical vStack.spacing = 10 - popupView.addSubview(vStack) + drawerView.addSubview(vStack) - vStack.snp.makeConstraints { make in - make.top.equalToSuperview().offset(18) - make.left.equalToSuperview().offset(18) - make.right.equalToSuperview().offset(-18) - make.bottom.equalToSuperview().offset(-18) + 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() @@ -130,11 +130,11 @@ public final class OnboardingLaunchController: UIViewController { vStack.addArrangedSubview(notNow) } - blocker.window?.addSubview(popupView) - popupView.snp.makeConstraints { make in - make.left.equalToSuperview().offset(18) - make.center.equalToSuperview() - make.right.equalToSuperview().offset(-18) + blocker.window?.addSubview(drawerView) + drawerView.snp.makeConstraints { + $0.left.equalToSuperview().offset(18) + $0.center.equalToSuperview() + $0.right.equalToSuperview().offset(-18) } blocker.showWindow() diff --git a/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift b/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift index c92ee37dd8379cc1bd4fbfb61a790fa35788dd86..dbef5be43b8314ffcc71a2481bb4bcc0d67a5e6d 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift @@ -1,5 +1,5 @@ import HUD -import Popup +import DrawerFeature import Theme import UIKit import Shared @@ -18,7 +18,7 @@ public final class OnboardingPhoneConfirmationController: UIViewController { private var cancellables = Set<AnyCancellable>() private let completion: (UIViewController) -> Void - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() private let viewModel: OnboardingPhoneConfirmationViewModel public init( @@ -122,17 +122,17 @@ public final class OnboardingPhoneConfirmationController: UIViewController { private func presentInfo(title: String, subtitle: String) { let actionButton = CapsuleButton() - actionButton.set(style: .seeThrough, title: Localized.Settings.InfoPopUp.action) + actionButton.set(style: .seeThrough, title: Localized.Settings.InfoDrawer.action) - let popup = BottomPopup(with: [ - PopupLabel( + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), text: title, color: Asset.neutralActive.color, alignment: .left, spacingAfter: 19 ), - PopupLabel( + DrawerText( font: Fonts.Mulish.regular.font(size: 16.0), text: subtitle, color: Asset.neutralBody.color, @@ -140,19 +140,22 @@ public final class OnboardingPhoneConfirmationController: UIViewController { lineHeightMultiple: 1.1, spacingAfter: 37 ), - PopupStackView(views: [actionButton, FlexibleSpace()]) + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) ]) actionButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } @objc private func didTapBack() { diff --git a/Sources/OnboardingFeature/Controllers/OnboardingPhoneController.swift b/Sources/OnboardingFeature/Controllers/OnboardingPhoneController.swift index 5fde1a32e78599330fb50a28f558933e13803726..b935ae07434e90d94818a2ea400202924323ea32 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingPhoneController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingPhoneController.swift @@ -1,5 +1,5 @@ import HUD -import Popup +import DrawerFeature import Theme import UIKit import Shared @@ -17,7 +17,7 @@ public final class OnboardingPhoneController: UIViewController { private var cancellables = Set<AnyCancellable>() private let viewModel = OnboardingPhoneViewModel() - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -115,33 +115,39 @@ public final class OnboardingPhoneController: UIViewController { urlString: String = "" ) { let actionButton = CapsuleButton() - actionButton.set(style: .seeThrough, title: Localized.Settings.InfoPopUp.action) + actionButton.set( + style: .seeThrough, + title: Localized.Settings.InfoDrawer.action + ) - let popup = BottomPopup(with: [ - PopupLabel( + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), text: title, color: Asset.neutralActive.color, alignment: .left, spacingAfter: 19 ), - PopupLinkText( + DrawerLinkText( text: subtitle, urlString: urlString, spacingAfter: 37 ), - PopupStackView(views: [actionButton, FlexibleSpace()]) + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) ]) actionButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift b/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift index 635cb70388ba061b96c07e0f97553186abad843c..19fc945b63b2495f5823af11fb21de205f538d85 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift @@ -1,5 +1,5 @@ import HUD -import Popup +import DrawerFeature import Theme import UIKit import Shared @@ -18,7 +18,7 @@ public final class OnboardingUsernameController: UIViewController { private let ndf: String private var cancellables = Set<AnyCancellable>() private let viewModel: OnboardingUsernameViewModel! - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -110,34 +110,37 @@ public final class OnboardingUsernameController: UIViewController { let actionButton = CapsuleButton() actionButton.set( style: .seeThrough, - title: Localized.Settings.InfoPopUp.action + title: Localized.Settings.InfoDrawer.action ) - let popup = BottomPopup(with: [ - PopupLabel( + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), text: title, color: Asset.neutralActive.color, alignment: .left, spacingAfter: 19 ), - PopupLinkText( + DrawerLinkText( text: subtitle, urlString: urlString, spacingAfter: 37 ), - PopupStackView(views: [actionButton, FlexibleSpace()]) + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) ]) actionButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingWelcomeController.swift b/Sources/OnboardingFeature/Controllers/OnboardingWelcomeController.swift index 5efe0d7e0aa23e6c228ddb6c1c7443187155b0b6..33455d6627930f945bbadc2ddb19d53a047d5dc8 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingWelcomeController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingWelcomeController.swift @@ -1,4 +1,4 @@ -import Popup +import DrawerFeature import Theme import UIKit import Shared @@ -14,7 +14,7 @@ public final class OnboardingWelcomeController: UIViewController { lazy private var screenView = OnboardingWelcomeView() private var cancellables = Set<AnyCancellable>() - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() public override func loadView() { view = screenView @@ -59,34 +59,37 @@ public final class OnboardingWelcomeController: UIViewController { let actionButton = CapsuleButton() actionButton.set( style: .seeThrough, - title: Localized.Settings.InfoPopUp.action + title: Localized.Settings.InfoDrawer.action ) - let popup = BottomPopup(with: [ - PopupLabel( + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), text: title, color: Asset.neutralActive.color, alignment: .left, spacingAfter: 19 ), - PopupLinkText( + DrawerLinkText( text: subtitle, urlString: urlString, spacingAfter: 37 ), - PopupStackView(views: [actionButton, FlexibleSpace()]) + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) ]) actionButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } } diff --git a/Sources/OnboardingFeature/Coordinator/OnboardingCoordinator.swift b/Sources/OnboardingFeature/Coordinator/OnboardingCoordinator.swift index 0d12a9e0f63c9bbeda543d51a84634ddc133d063..da4249e349d52195df01de3ff897822b6399bb25 100644 --- a/Sources/OnboardingFeature/Coordinator/OnboardingCoordinator.swift +++ b/Sources/OnboardingFeature/Coordinator/OnboardingCoordinator.swift @@ -13,10 +13,9 @@ public protocol OnboardingCoordinating { func toWelcome(from: UIViewController) func toStart(with: String, from: UIViewController) func toUsername(with: String, from: UIViewController) - func toPopup(_: UIViewController, from: UIViewController) - - func toSuccess(with: OnboardingSuccessModel, from: UIViewController) func toRestoreList(with: String, from: UIViewController) + func toDrawer(_: UIViewController, from: UIViewController) + func toSuccess(with: OnboardingSuccessModel, from: UIViewController) func toEmailConfirmation( with: AttributeConfirmation, @@ -43,6 +42,7 @@ public struct OnboardingCoordinator: OnboardingCoordinating { var emailFactory: () -> UIViewController var phoneFactory: () -> UIViewController + var searchFactory: () -> UIViewController var welcomeFactory: () -> UIViewController var chatListFactory: () -> UIViewController var startFactory: (String) -> UIViewController @@ -56,6 +56,7 @@ public struct OnboardingCoordinator: OnboardingCoordinating { public init( emailFactory: @escaping () -> UIViewController, phoneFactory: @escaping () -> UIViewController, + searchFactory: @escaping () -> UIViewController, welcomeFactory: @escaping () -> UIViewController, chatListFactory: @escaping () -> UIViewController, startFactory: @escaping (String) -> UIViewController, @@ -69,12 +70,13 @@ public struct OnboardingCoordinator: OnboardingCoordinating { self.emailFactory = emailFactory self.phoneFactory = phoneFactory self.startFactory = startFactory + self.searchFactory = searchFactory self.welcomeFactory = welcomeFactory + self.successFactory = successFactory self.usernameFactory = usernameFactory self.chatListFactory = chatListFactory - self.restoreListFactory = restoreListFactory - self.successFactory = successFactory self.countriesFactory = countriesFactory + self.restoreListFactory = restoreListFactory self.phoneConfirmationFactory = phoneConfirmationFactory self.emailConfirmationFactory = emailConfirmationFactory } @@ -91,11 +93,6 @@ public extension OnboardingCoordinator { replacePresenter.present(screen, from: parent) } - func toChats(from parent: UIViewController) { - let screen = chatListFactory() - replacePresenter.present(screen, from: parent) - } - func toWelcome(from parent: UIViewController) { let screen = welcomeFactory() replacePresenter.present(screen, from: parent) @@ -121,8 +118,14 @@ public extension OnboardingCoordinator { replacePresenter.present(screen, from: parent) } - func toPopup(_ popup: UIViewController, from parent: UIViewController) { - bottomPresenter.present(popup, from: parent) + func toDrawer(_ drawer: UIViewController, from parent: UIViewController) { + bottomPresenter.present(drawer, from: parent) + } + + func toChats(from parent: UIViewController) { + let searchScreen = searchFactory() + let chatListScreen = chatListFactory() + replacePresenter.present(chatListScreen, searchScreen, from: parent) } func toCountries(from parent: UIViewController, _ onChoose: @escaping (Country) -> Void) { diff --git a/Sources/OnboardingFeature/ViewModels/OnboardingLaunchViewModel.swift b/Sources/OnboardingFeature/ViewModels/OnboardingLaunchViewModel.swift index 589977fbea9101172f02371f907786e0f9947786..bfc03a0cf4f265157da60da3704e587c9253d755 100644 --- a/Sources/OnboardingFeature/ViewModels/OnboardingLaunchViewModel.swift +++ b/Sources/OnboardingFeature/ViewModels/OnboardingLaunchViewModel.swift @@ -10,7 +10,7 @@ import CombineSchedulers import DependencyInjection import DropboxFeature -struct UpdatePopupModel { +struct UpdateDrawerModel { let body: String let updateTitle: String let updateStyle: CapsuleButtonStyle @@ -19,20 +19,14 @@ struct UpdatePopupModel { } final class OnboardingLaunchViewModel { - // MARK: Stored - @KeyObject(.username, defaultValue: nil) var username: String? @KeyObject(.biometrics, defaultValue: false) var isBiometricsEnabled: Bool - // MARK: Injected - @Dependency private var network: XXNetworking @Dependency private var versioning: VersionChecker @Dependency private var permissions: PermissionHandling @Dependency private var dropboxService: DropboxInterface - // MARK: Properties - var getSession: (String) throws -> SessionType = Session.init var backgroundScheduler: AnySchedulerOf<DispatchQueue> = DispatchQueue.global().eraseToAnyScheduler() @@ -45,8 +39,8 @@ final class OnboardingLaunchViewModel { var hud: AnyPublisher<HUDStatus, Never> { hudRelay.eraseToAnyPublisher() } private let hudRelay = CurrentValueSubject<HUDStatus, Never>(.none) - var updatePublisher: AnyPublisher<UpdatePopupModel, Never> { updateRelay.eraseToAnyPublisher() } - private let updateRelay = PassthroughSubject<UpdatePopupModel, Never>() + var updatePublisher: AnyPublisher<UpdateDrawerModel, Never> { updateRelay.eraseToAnyPublisher() } + private let updateRelay = PassthroughSubject<UpdateDrawerModel, Never>() private var cancellables = Set<AnyCancellable>() diff --git a/Sources/Popup/PopupController.swift b/Sources/Popup/PopupController.swift deleted file mode 100644 index 513944fd124d18e68cfa6e3a954fa884ce64a5fd..0000000000000000000000000000000000000000 --- a/Sources/Popup/PopupController.swift +++ /dev/null @@ -1,111 +0,0 @@ -import UIKit -import Shared -import Combine -import ScrollViewController - -public enum PopupInputType: Equatable { - case email - case emailCode - case emailSuccess(String) - case phone - case phoneCode - case phoneSuccess(String) - case done -} - -public final class Popup: UIViewController { - lazy private var screenView = PopupView() - lazy private var containerView = UIView() - lazy private var scrollViewController = ScrollViewController() - - private let content: [PopupStackItem] - - public init(with content: [PopupStackItem]) { - self.content = content - super.init(nibName: nil, bundle: nil) - - let views = content.map { $0.makeView() } - - views.forEach { screenView.stack.addArrangedSubview($0) } - - content.enumerated().forEach { item in - guard let spacing = item.element.spacingAfter else { return } - screenView.stack.setCustomSpacing(spacing, after: views[item.offset]) - } - } - - public required init?(coder: NSCoder) { nil } - - public override func viewDidLoad() { - super.viewDidLoad() - - scrollViewController.view.backgroundColor = .clear - - addChild(scrollViewController) - view.addSubview(scrollViewController.view) - - scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } - scrollViewController.didMove(toParent: self) - containerView.addSubview(screenView) - - screenView.snp.makeConstraints { make in - make.top.greaterThanOrEqualToSuperview().offset(50) - make.left.equalToSuperview().offset(20) - make.right.equalToSuperview().offset(-20) - make.centerY.equalToSuperview() - make.bottom.lessThanOrEqualToSuperview().offset(-50) - } - - scrollViewController.contentView = containerView - } -} - -public final class BottomPopup: UIViewController { - lazy private var screenView = BottomPopupView() - - private let content: [PopupStackItem] - - public init(with content: [PopupStackItem]) { - self.content = content - super.init(nibName: nil, bundle: nil) - - let views = content.map { $0.makeView() } - - views.forEach { screenView.stack.addArrangedSubview($0) } - - content.enumerated().forEach { item in - guard let spacing = item.element.spacingAfter else { return } - screenView.stack.setCustomSpacing(spacing, after: views[item.offset]) - } - } - - required init?(coder: NSCoder) { nil } - - public override func loadView() { - view = screenView - } -} - -final class BottomPopupView: UIView { - let stack = UIStackView() - - init() { - super.init(frame: .zero) - - layer.cornerRadius = 40 - backgroundColor = Asset.neutralWhite.color - layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - - stack.axis = .vertical - addSubview(stack) - - stack.snp.makeConstraints { make in - make.top.equalToSuperview().offset(60) - make.left.equalToSuperview().offset(50) - make.right.equalToSuperview().offset(-50) - make.bottom.equalToSuperview().offset(-70) - } - } - - required init?(coder: NSCoder) { nil } -} diff --git a/Sources/Popup/PopupView.swift b/Sources/Popup/PopupView.swift deleted file mode 100644 index b306ab96f20e747acd3dcedcf7f9d18c427426c2..0000000000000000000000000000000000000000 --- a/Sources/Popup/PopupView.swift +++ /dev/null @@ -1,30 +0,0 @@ -import UIKit -import Shared - -final class PopupView: UIView { - // MARK: UI - - let stack = UIStackView() - - // MARK: Lifecycle - - init() { - super.init(frame: .zero) - - stack.axis = .vertical - layer.cornerRadius = 6 - layer.masksToBounds = true - backgroundColor = Asset.neutralWhite.color - - addSubview(stack) - - stack.snp.makeConstraints { make in - make.top.equalToSuperview().offset(40) - make.left.equalToSuperview().offset(30) - make.right.equalToSuperview().offset(-30) - make.bottom.equalToSuperview().offset(-20) - } - } - - required init?(coder: NSCoder) { nil } -} diff --git a/Sources/Popup/StackItems/PopupButton.swift b/Sources/Popup/StackItems/PopupButton.swift deleted file mode 100644 index 0715ceef3985ff1f11fd524996bfb00338dbfbd9..0000000000000000000000000000000000000000 --- a/Sources/Popup/StackItems/PopupButton.swift +++ /dev/null @@ -1,124 +0,0 @@ -import UIKit -import Shared -import Combine - -public final class PopupButton: PopupStackItem { - let font: UIFont? - let title: String? - let color: UIColor? - let image: UIImage? - let accessibility: String? - let pinPoint: UIView.PinningPosition? - - public var spacingAfter: CGFloat? = 10 - private var cancellables = Set<AnyCancellable>() - private let actionSubject = PassthroughSubject<Void, Never>() - - public var action: AnyPublisher<Void, Never> { actionSubject.eraseToAnyPublisher() } - - public init( - title: String? = nil, - font: UIFont? = Fonts.Mulish.regular.font(size: 12.0), - color: UIColor? = Asset.neutralBody.color, - image: UIImage? = nil, - embedding: UIView.PinningPosition? = nil, - accessibility: String? = nil - ) { - self.title = title - self.font = font - self.color = color - self.image = image - self.pinPoint = embedding - self.accessibility = accessibility - } - - public func makeView() -> UIView { - cancellables.removeAll() - - let view = UIButton() - view.titleLabel?.font = font - view.setTitle(title, for: .normal) - view.setTitleColor(color, for: .normal) - view.setImage(image, for: .normal) - view.accessibilityIdentifier = accessibility - - view.publisher(for: .touchUpInside) - .sink { [weak self] in self?.actionSubject.send() } - .store(in: &cancellables) - - if let point = pinPoint { - return view.pinning(at: point) - } else { - return view - } - } -} - -public final class PopupRadioButton: PopupStackItem { - let radioView = UIView() - let titleLabel = UILabel() - let radioInnerView = UIView() - - public var spacingAfter: CGFloat? = 10 - private var cancellables = Set<AnyCancellable>() - private let actionSubject = PassthroughSubject<Void, Never>() - - public var action: AnyPublisher<Void, Never> { actionSubject.eraseToAnyPublisher() } - - public init( - title: String, - isSelected: Bool - ) { - titleLabel.text = title - titleLabel.textColor = Asset.neutralDark.color - titleLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) - - radioView.layer.cornerRadius = 11.0 - radioInnerView.layer.cornerRadius = 3 - radioView.isUserInteractionEnabled = false - - if isSelected { - radioView.layer.borderWidth = 0.0 - radioView.backgroundColor = Asset.brandLight.color - radioView.layer.borderColor = Asset.brandLight.color.cgColor - radioInnerView.backgroundColor = Asset.neutralWhite.color - } else { - radioView.layer.borderWidth = 1.0 - radioView.backgroundColor = Asset.neutralSecondary.color - radioView.layer.borderColor = Asset.neutralLine.color.cgColor - radioInnerView.backgroundColor = .clear - } - } - - public func makeView() -> UIView { - cancellables.removeAll() - - let view = UIControl() - view.addSubview(titleLabel) - view.addSubview(radioView) - radioView.addSubview(radioInnerView) - - titleLabel.snp.makeConstraints { make in - make.left.equalToSuperview().offset(42) - make.centerY.equalToSuperview() - } - - radioView.snp.makeConstraints { make in - make.right.equalTo(titleLabel.snp.left).offset(-12) - make.width.height.equalTo(20) - make.centerY.equalToSuperview() - make.bottom.equalToSuperview().offset(-5) - } - - radioInnerView.snp.makeConstraints { make in - make.width.height.equalTo(6) - make.center.equalToSuperview() - } - - view.publisher(for: .touchUpInside) - .sink { [weak self] in self?.actionSubject.send() } - .store(in: &cancellables) - - return view - } -} diff --git a/Sources/Popup/StackItems/PopupEmptyView.swift b/Sources/Popup/StackItems/PopupEmptyView.swift deleted file mode 100644 index bc32b728c6a5cc3e9bc723465f8487036786aafa..0000000000000000000000000000000000000000 --- a/Sources/Popup/StackItems/PopupEmptyView.swift +++ /dev/null @@ -1,16 +0,0 @@ -import UIKit - -public final class PopupEmptyView: PopupStackItem { - private var height: CGFloat - - public init(height: CGFloat) { - self.height = height - } - - public func makeView() -> UIView { - let view = UIView() - view.snp.makeConstraints { $0.height.equalTo(height) } - - return view - } -} diff --git a/Sources/Popup/StackItems/PopupLabel.swift b/Sources/Popup/StackItems/PopupLabel.swift deleted file mode 100644 index 8ed0d9d502b1d0fb133d5ed325db8f592c7dc4f5..0000000000000000000000000000000000000000 --- a/Sources/Popup/StackItems/PopupLabel.swift +++ /dev/null @@ -1,177 +0,0 @@ -import UIKit -import Shared - -public final class PopupLabel: PopupStackItem { - let font: UIFont - let text: String - let color: UIColor - let alignment: NSTextAlignment - let lineSpacing: CGFloat? - let lineHeightMultiple: CGFloat? - - public var spacingAfter: CGFloat? = 0 - - public init( - font: UIFont, - text: String, - color: UIColor = Asset.neutralDark.color, - alignment: NSTextAlignment = .center, - lineSpacing: CGFloat? = nil, - lineHeightMultiple: CGFloat? = nil, - spacingAfter: CGFloat = 10 - ) { - self.font = font - self.text = text - self.color = color - self.alignment = alignment - self.lineSpacing = lineSpacing - self.spacingAfter = spacingAfter - self.lineHeightMultiple = lineHeightMultiple - } - - public func makeView() -> UIView { - let label = UILabel() - label.numberOfLines = 0 - - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.alignment = alignment - - if let spacing = lineSpacing { - paragraphStyle.lineSpacing = spacing - } - - if let lineHeightMultiple = lineHeightMultiple { - paragraphStyle.lineHeightMultiple = lineHeightMultiple - } - - label.attributedText = NSAttributedString( - string: text, - attributes: [ - .font: font as Any, - .foregroundColor: color, - .paragraphStyle: paragraphStyle - ] - ) - - return label - } -} - -public final class PopupLinkText: NSObject, PopupStackItem { - let text: String - let urlString: String - - public var spacingAfter: CGFloat? = 0 - - public init( - text: String, - urlString: String, - spacingAfter: CGFloat = 10 - ) { - self.text = text - self.urlString = urlString - self.spacingAfter = spacingAfter - } - - public func makeView() -> UIView { - let textView = UnselectableTextView() - textView.delegate = self - textView.isEditable = false - textView.isSelectable = true - textView.isScrollEnabled = false - textView.backgroundColor = .clear - textView.isUserInteractionEnabled = true - - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.alignment = .left - paragraphStyle.lineHeightMultiple = 1.1 - - let attrString = NSMutableAttributedString(string: text) - attrString.addAttributes([ - .paragraphStyle: paragraphStyle, - .foregroundColor: Asset.neutralDark.color, - .font: Fonts.Mulish.regular.font(size: 16.0) as Any - ]) - - if let url = URL(string: urlString) { - attrString.addAttribute(name: .link, value: url, betweenCharacters: "#") - - textView.linkTextAttributes = [ - .paragraphStyle: paragraphStyle, - .foregroundColor: Asset.brandPrimary.color, - .font: Fonts.Mulish.regular.font(size: 16.0) as Any - ] - } - - textView.attributedText = attrString - - return textView - } - - public func textView( - _: UITextView, - shouldInteractWith: URL, - in: NSRange, - interaction: UITextItemInteraction - ) -> Bool { true } -} - -extension PopupLinkText: UITextViewDelegate {} - -public final class UnselectableTextView: UITextView { - public override var selectedTextRange: UITextRange? { - get { return nil } - set {} - } - - public override func point( - inside point: CGPoint, - with event: UIEvent? - ) -> Bool { - guard let pos = closestPosition(to: point) else { return false } - guard let range = tokenizer.rangeEnclosingPosition(pos, with: .character, inDirection: .layout(.left)) else { return false } - - let startIndex = offset(from: beginningOfDocument, to: range.start) - return attributedText.attribute(.link, at: startIndex, effectiveRange: nil) != nil - } -} - -public final class PopupLabelAttributed: PopupStackItem { - let text: String - - public var spacingAfter: CGFloat? = 0 - - public init( - text: String, - spacingAfter: CGFloat = 10 - ) { - self.text = text - self.spacingAfter = spacingAfter - } - - public func makeView() -> UIView { - let label = UILabel() - label.numberOfLines = 0 - - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.alignment = .left - paragraphStyle.lineHeightMultiple = 1.1 - - let attrString = NSMutableAttributedString(string: text) - attrString.addAttributes([ - .paragraphStyle: paragraphStyle, - .foregroundColor: Asset.neutralDark.color, - .font: Fonts.Mulish.regular.font(size: 16.0) as Any - ]) - - attrString.addAttribute( - name: .font, - value: Fonts.Mulish.bold.font(size: 16.0) as Any, - betweenCharacters: "#" - ) - - label.attributedText = attrString - - return label - } -} diff --git a/Sources/Popup/StackItems/PopupStackView.swift b/Sources/Popup/StackItems/PopupStackView.swift deleted file mode 100644 index a705e8e284ee4a50514270d0c910204b1defa572..0000000000000000000000000000000000000000 --- a/Sources/Popup/StackItems/PopupStackView.swift +++ /dev/null @@ -1,32 +0,0 @@ -import UIKit -import Shared - -public final class PopupStackView: PopupStackItem { - let views: [UIView] - let spacing: CGFloat - let axis: NSLayoutConstraint.Axis - let distribution: UIStackView.Distribution - - public var spacingAfter: CGFloat? = 10 - - public init( - axis: NSLayoutConstraint.Axis = .horizontal, - spacing: CGFloat = 10, - distribution: UIStackView.Distribution = .fillEqually, - views: [UIView] - ) { - self.axis = axis - self.views = views - self.spacing = spacing - self.distribution = distribution - } - - public func makeView() -> UIView { - let stack = UIStackView() - stack.axis = axis - stack.spacing = spacing - stack.distribution = distribution - stack.addArrangedSubviews(views) - return stack - } -} diff --git a/Sources/Presentation/BottomPresenter.swift b/Sources/Presentation/BottomPresenter.swift index 6488ce1562f6bc317d41a75c47a5902e96b24527..d4d5778b09e00ffaedb3357b1d621f6017da339a 100644 --- a/Sources/Presentation/BottomPresenter.swift +++ b/Sources/Presentation/BottomPresenter.swift @@ -3,11 +3,14 @@ import UIKit public final class BottomPresenter: NSObject, Presenting { private var transition: BottomTransition? - public func present(_ viewController: UIViewController, from parent: UIViewController) { - viewController.modalPresentationStyle = .overFullScreen - viewController.transitioningDelegate = self - - parent.present(viewController, animated: true) + public func present(_ viewControllers: UIViewController..., from parent: UIViewController) { + guard let screen = viewControllers.first else { + fatalError("Tried to present empty list of view controllers") + } + + screen.modalPresentationStyle = .overFullScreen + screen.transitioningDelegate = self + parent.present(screen, animated: true) } } diff --git a/Sources/Presentation/BottomTransition.swift b/Sources/Presentation/BottomTransition.swift index cd3d08c00d8cf5d86c752dea1af1884648904d87..28f41a490b2607e813ecd9123b373b673e579da2 100644 --- a/Sources/Presentation/BottomTransition.swift +++ b/Sources/Presentation/BottomTransition.swift @@ -62,7 +62,11 @@ final class BottomTransition: NSObject, UIViewControllerAnimatedTransitioning { presentedConstraints = [ presentedView.leftAnchor.constraint(equalTo: context.containerView.leftAnchor), presentedView.rightAnchor.constraint(equalTo: context.containerView.rightAnchor), - presentedView.bottomAnchor.constraint(equalTo: context.containerView.bottomAnchor) + presentedView.bottomAnchor.constraint(equalTo: context.containerView.bottomAnchor), + presentedView.topAnchor.constraint( + greaterThanOrEqualTo: context.containerView.safeAreaLayoutGuide.topAnchor, + constant: 60 + ) ] dismissedConstraints = [ diff --git a/Sources/Presentation/CenterPresenter.swift b/Sources/Presentation/CenterPresenter.swift index dee4ffaa80730ce03d7a0052986327da7f8d1906..99277dc5708680bcd74482edc5cc88d542072381 100644 --- a/Sources/Presentation/CenterPresenter.swift +++ b/Sources/Presentation/CenterPresenter.swift @@ -5,11 +5,14 @@ public protocol CenterPresenterNonDismissingTarget: UIViewController {} public final class CenterPresenter: NSObject, Presenting { private var transition: CenterTransition? - public func present(_ viewController: UIViewController, from parent: UIViewController) { - viewController.modalPresentationStyle = .overFullScreen - viewController.transitioningDelegate = self + public func present(_ viewControllers: UIViewController..., from parent: UIViewController) { + guard let screen = viewControllers.first else { + fatalError("Tried to present empty list of view controllers") + } - parent.present(viewController, animated: true) + screen.modalPresentationStyle = .overFullScreen + screen.transitioningDelegate = self + parent.present(screen, animated: true) } } diff --git a/Sources/Presentation/FadePresenter.swift b/Sources/Presentation/FadePresenter.swift index c592b3afc09bb22da5f0e21b8dde9cdb0fa1c6c2..d4d27c85ec5af65355d97487b06fb39f4c4fa2e3 100644 --- a/Sources/Presentation/FadePresenter.swift +++ b/Sources/Presentation/FadePresenter.swift @@ -3,11 +3,14 @@ import UIKit public final class FadePresenter: NSObject, Presenting { private var transition: FadeTransition? - public func present(_ target: UIViewController, from parent: UIViewController) { - target.modalPresentationStyle = .overFullScreen - target.transitioningDelegate = self + public func present(_ viewControllers: UIViewController..., from parent: UIViewController) { + guard let screen = viewControllers.first else { + fatalError("Tried to present empty list of view controllers") + } - parent.present(target, animated: true) + screen.modalPresentationStyle = .overFullScreen + screen.transitioningDelegate = self + parent.present(screen, animated: true) } } diff --git a/Sources/Presentation/FullscreenPresenter.swift b/Sources/Presentation/FullscreenPresenter.swift index 39701b2db5372d842128a31f14ddf120880aea02..207c77e66aabce11e6682b32701fc6a2e483befc 100644 --- a/Sources/Presentation/FullscreenPresenter.swift +++ b/Sources/Presentation/FullscreenPresenter.swift @@ -3,11 +3,14 @@ import UIKit public final class FullscreenPresenter: NSObject, Presenting { private var transition: FullscreenTransition? - public func present(_ viewController: UIViewController, from parent: UIViewController) { - viewController.modalPresentationStyle = .overFullScreen - viewController.transitioningDelegate = self + public func present(_ viewControllers: UIViewController..., from parent: UIViewController) { + guard let screen = viewControllers.first else { + fatalError("Tried to present empty list of view controllers") + } - parent.present(viewController, animated: true) + screen.modalPresentationStyle = .overFullScreen + screen.transitioningDelegate = self + parent.present(screen, animated: true) } } diff --git a/Sources/Presentation/Presenting.swift b/Sources/Presentation/Presenting.swift index 60a698bb2ccf626c2f795389546e393764c517ce..1baf98d11fe94e90217c41b39c081175cd00bcb9 100644 --- a/Sources/Presentation/Presenting.swift +++ b/Sources/Presentation/Presenting.swift @@ -2,7 +2,7 @@ import UIKit import Theme public protocol Presenting { - func present(_ target: UIViewController, from parent: UIViewController) + func present(_ target: UIViewController..., from parent: UIViewController) func dismiss(from parent: UIViewController) } @@ -15,16 +15,16 @@ public extension Presenting { public struct PushPresenter: Presenting { public init() {} - public func present(_ target: UIViewController, from parent: UIViewController) { - parent.navigationController?.pushViewController(target, animated: true) + public func present(_ target: UIViewController..., from parent: UIViewController) { + parent.navigationController?.pushViewController(target.first!, animated: true) } } public struct ModalPresenter: Presenting { public init() {} - public func present(_ target: UIViewController, from parent: UIViewController) { - let statusBarVC = StatusBarViewController(target) + public func present(_ target: UIViewController..., from parent: UIViewController) { + let statusBarVC = StatusBarViewController(target.first!) statusBarVC.modalPresentationStyle = .fullScreen parent.present(statusBarVC, animated: true) } @@ -43,12 +43,12 @@ public struct ReplacePresenter: Presenting { self.mode = mode } - public func present(_ target: UIViewController, from parent: UIViewController) { + public func present(_ target: UIViewController..., from parent: UIViewController) { guard let navigationController = parent.navigationController else { return } switch mode { case .replaceAll: - navigationController.setViewControllers([target], animated: true) + navigationController.setViewControllers(target, animated: true) case .replaceBackwards(let OlderInStack): if let oldScreen = navigationController.viewControllers.filter({ $0.isKind(of: OlderInStack.self) }).first, @@ -61,20 +61,20 @@ public struct ReplacePresenter: Presenting { if let coordinator = navigationController.transitionCoordinator { coordinator.animate(alongsideTransition: nil) { _ in - navigationController.setViewControllers(viewControllersBefore + [target] , animated: true) + navigationController.setViewControllers(viewControllersBefore + target , animated: true) } } else { - navigationController.setViewControllers(viewControllersBefore + [target] , animated: true) + navigationController.setViewControllers(viewControllersBefore + target , animated: true) } } else { - navigationController.pushViewController(target, animated: true) + navigationController.pushViewController(target.first!, animated: true) } case .replaceLast: let viewControllersBefore = navigationController.viewControllers.dropLast() func replace() { - navigationController.setViewControllers(viewControllersBefore + [target] , animated: true) + navigationController.setViewControllers(viewControllersBefore + target , animated: true) } if let coordinator = navigationController.transitionCoordinator { @@ -91,10 +91,10 @@ public struct ReplacePresenter: Presenting { public struct PopReplacePresenter: Presenting { public init() {} - public func present(_ target: UIViewController, from parent: UIViewController) { + public func present(_ target: UIViewController..., from parent: UIViewController) { if let lastViewController = parent.navigationController?.viewControllers.last { - parent.navigationController?.setViewControllers([target, lastViewController], animated: false) - parent.navigationController?.setViewControllers([target], animated: true) + parent.navigationController?.setViewControllers([target.first!, lastViewController], animated: false) + parent.navigationController?.setViewControllers([target.first!], animated: true) } } } diff --git a/Sources/Presentation/SideMenuPresenter.swift b/Sources/Presentation/SideMenuPresenter.swift index 2069049c6ddc0600ac4e9d659979fb6a8b45602c..5d9036dee7ef729973eeef8cf4f5d56e7b3d0bab 100644 --- a/Sources/Presentation/SideMenuPresenter.swift +++ b/Sources/Presentation/SideMenuPresenter.swift @@ -19,11 +19,14 @@ public final class SideMenuPresenter: NSObject, // MARK: Presenting - public func present(_ target: UIViewController, - from parent: UIViewController) { - target.modalPresentationStyle = .overFullScreen - target.transitioningDelegate = self - parent.present(target, animated: true) + public func present(_ viewControllers: UIViewController..., from parent: UIViewController) { + guard let screen = viewControllers.first else { + fatalError("Tried to present empty list of view controllers") + } + + screen.modalPresentationStyle = .overFullScreen + screen.transitioningDelegate = self + parent.present(screen, animated: true) } // MARK: UIViewControllerTransitioningDelegate diff --git a/Sources/ProfileFeature/Controllers/ProfileController.swift b/Sources/ProfileFeature/Controllers/ProfileController.swift index 55c9712f29f3eddc1dfc650dea99ee5a84b58523..62a2c90ee9bfb56674f8601bb1007d62c5f3323d 100644 --- a/Sources/ProfileFeature/Controllers/ProfileController.swift +++ b/Sources/ProfileFeature/Controllers/ProfileController.swift @@ -1,5 +1,5 @@ import HUD -import Popup +import DrawerFeature import UIKit import Theme import Shared @@ -15,7 +15,7 @@ public final class ProfileController: UIViewController { private let viewModel = ProfileViewModel() private var cancellables = Set<AnyCancellable>() - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() public override func loadView() { view = screenView @@ -39,9 +39,13 @@ public final class ProfileController: UIViewController { private func setupNavigationBar() { navigationItem.backButtonTitle = "" - let back = UIButton.back(color: Asset.neutralWhite.color) - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: back) + 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() { @@ -55,7 +59,7 @@ public final class ProfileController: UIViewController { .receive(on: DispatchQueue.main) .sink { [unowned self] in if screenView.emailView.currentValue != nil { - presentPopup( + presentDrawer( title: Localized.Profile.Delete.title( Localized.Profile.Email.title.capitalized ), @@ -77,7 +81,7 @@ public final class ProfileController: UIViewController { .receive(on: DispatchQueue.main) .sink { [unowned self] in if screenView.phoneView.currentValue != nil { - presentPopup( + presentDrawer( title: Localized.Profile.Delete.title( Localized.Profile.Phone.title.capitalized ), @@ -106,7 +110,7 @@ public final class ProfileController: UIViewController { .sink { [unowned self] in switch $0 { case .library: - presentPopup( + presentDrawer( title: Localized.Profile.Photo.title, subtitle: Localized.Profile.Photo.subtitle, actionTitle: Localized.Profile.Photo.continue) { @@ -144,22 +148,26 @@ public final class ProfileController: UIViewController { .store(in: &cancellables) } - private func presentPopup( + private func presentDrawer( title: String, subtitle: String, actionTitle: String, action: @escaping () -> Void ) { - let actionButton = PopupCapsuleButton(model: .init(title: actionTitle, style: .red)) - let popup = BottomPopup(with: [ - PopupLabel( + 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 ), - PopupLabel( + DrawerText( font: Fonts.Mulish.regular.font(size: 16.0), text: subtitle, color: Asset.neutralBody.color, @@ -173,19 +181,19 @@ public final class ProfileController: UIViewController { actionButton.action .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() action() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) + @objc private func didTapMenu() { + coordinator.toSideMenu(from: self) } } diff --git a/Sources/ProfileFeature/Coordinator/ProfileCoordinator.swift b/Sources/ProfileFeature/Coordinator/ProfileCoordinator.swift index d3eee0e16d403bb07d590a950c3fe50dba3f1bf5..947937b403a4f29cee289e590908589a66c57754 100644 --- a/Sources/ProfileFeature/Coordinator/ProfileCoordinator.swift +++ b/Sources/ProfileFeature/Coordinator/ProfileCoordinator.swift @@ -2,14 +2,16 @@ import UIKit import Shared import Models import Countries -import Presentation import Permissions +import MenuFeature +import Presentation public protocol ProfileCoordinating { func toEmail(from: UIViewController) func toPhone(from: UIViewController) func toPhotos(from: UIViewController) - func toPopup(_: UIViewController, from: UIViewController) + func toSideMenu(from: UIViewController) + func toDrawer(_: UIViewController, from: UIViewController) func toPermission(type: PermissionType, from: UIViewController) func toCode( @@ -27,12 +29,14 @@ public protocol ProfileCoordinating { public struct ProfileCoordinator: ProfileCoordinating { var pushPresenter: Presenting = PushPresenter() var modalPresenter: Presenting = ModalPresenter() + var sidePresenter: Presenting = SideMenuPresenter() var bottomPresenter: Presenting = BottomPresenter() var emailFactory: () -> UIViewController var phoneFactory: () -> UIViewController var imagePickerFactory: () -> UIImagePickerController var permissionFactory: () -> RequestPermissionController + var sideMenuFactory: (MenuItem, UIViewController) -> UIViewController var countriesFactory: (@escaping (Country) -> Void) -> UIViewController var codeFactory: (AttributeConfirmation, @escaping ControllerClosure) -> UIViewController @@ -41,12 +45,14 @@ public struct ProfileCoordinator: ProfileCoordinating { phoneFactory: @escaping () -> UIViewController, imagePickerFactory: @escaping () -> UIImagePickerController, permissionFactory: @escaping () -> RequestPermissionController, // âš ï¸ + sideMenuFactory: @escaping (MenuItem, UIViewController) -> UIViewController, countriesFactory: @escaping (@escaping (Country) -> Void) -> UIViewController, codeFactory: @escaping (AttributeConfirmation, @escaping ControllerClosure) -> UIViewController ) { self.codeFactory = codeFactory self.emailFactory = emailFactory self.phoneFactory = phoneFactory + self.sideMenuFactory = sideMenuFactory self.countriesFactory = countriesFactory self.permissionFactory = permissionFactory self.imagePickerFactory = imagePickerFactory @@ -79,8 +85,8 @@ public extension ProfileCoordinator { pushPresenter.present(screen, from: parent) } - func toPopup(_ popup: UIViewController, from parent: UIViewController) { - bottomPresenter.present(popup, from: parent) + func toDrawer(_ drawer: UIViewController, from parent: UIViewController) { + bottomPresenter.present(drawer, from: parent) } func toCountries(from parent: UIViewController, _ onChoose: @escaping (Country) -> Void) { @@ -94,4 +100,9 @@ public extension ProfileCoordinator { screen.allowsEditing = true modalPresenter.present(screen, from: parent) } + + func toSideMenu(from parent: UIViewController) { + let screen = sideMenuFactory(.profile, parent) + sidePresenter.present(screen, from: parent) + } } diff --git a/Sources/RequestsFeature/Controllers/RequestsContainerController.swift b/Sources/RequestsFeature/Controllers/RequestsContainerController.swift index 277932299955e0d628ba4dd6008cae614cbe1385..f3f7b3937059b0b30185e139b79b597f338061ca 100644 --- a/Sources/RequestsFeature/Controllers/RequestsContainerController.swift +++ b/Sources/RequestsFeature/Controllers/RequestsContainerController.swift @@ -1,4 +1,3 @@ -import HUD import UIKit import Theme import Shared @@ -7,35 +6,23 @@ import ContactFeature import DependencyInjection public final class RequestsContainerController: UIViewController { - // MARK: UI - - lazy private var screenView = RequestsContainerView() - - // MARK: Injected - - @Dependency private var hud: HUDType @Dependency private var coordinator: RequestsCoordinating @Dependency private var statusBarController: StatusBarStyleControlling - // MARK: Properties - + lazy private var screenView = RequestsContainerView() private var cancellables = Set<AnyCancellable>() - private let viewModel = RequestsContainerViewModel() - - // MARK: Lifecycle public override func loadView() { view = screenView - screenView.scrollView.delegate = self - addChild(screenView.sent) - addChild(screenView.failed) - addChild(screenView.received) + addChild(screenView.sentController) + addChild(screenView.failedController) + addChild(screenView.receivedController) - screenView.sent.didMove(toParent: self) - screenView.failed.didMove(toParent: self) - screenView.received.didMove(toParent: self) + screenView.sentController.didMove(toParent: self) + screenView.failedController.didMove(toParent: self) + screenView.receivedController.didMove(toParent: self) screenView.bringSubviewToFront(screenView.segmentedControl) } @@ -48,10 +35,6 @@ public final class RequestsContainerController: UIViewController { .customize(backgroundColor: Asset.neutralWhite.color) } - public override func viewDidAppear(_ animated: Bool) { - super.viewWillAppear(animated) - } - public override func viewDidLoad() { super.viewDidLoad() setupNavigationBar() @@ -64,112 +47,72 @@ public final class RequestsContainerController: UIViewController { let point = CGPoint(x: self.screenView.frame.width, y: 0.0) self.screenView.scrollView.setContentOffset(point, animated: true) - self.screenView.segmentedControl.didChooseFilter(.sent) - } } } } - // MARK: Private - private func setupNavigationBar() { navigationItem.backButtonTitle = "" - let title = UILabel() - title.text = Localized.Requests.title - title.textColor = Asset.neutralActive.color - title.font = Fonts.Mulish.semiBold.font(size: 18.0) + let titleLabel = UILabel() + titleLabel.text = Localized.Requests.title + titleLabel.textColor = Asset.neutralActive.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) + 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: [back, title]) + customView: UIStackView(arrangedSubviews: [menuButton, titleLabel]) ) } private func setupBindings() { - viewModel.hud - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - screenView.received - .hudPublisher - .sink { [weak viewModel] in viewModel?.didReceive(hud: $0) } - .store(in: &cancellables) - - screenView.sent - .hudPublisher - .sink { [weak viewModel] in viewModel?.didReceive(hud: $0) } - .store(in: &cancellables) - - screenView.failed - .hudPublisher - .sink { [weak viewModel] in viewModel?.didReceive(hud: $0) } - .store(in: &cancellables) - - screenView.received.verifyingPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toVerifying(from: self) } - .store(in: &cancellables) - - screenView.sent.tapPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toContact($0, from: self) } - .store(in: &cancellables) - - screenView.failed.didTap - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toContact($0, from: self) } - .store(in: &cancellables) - - screenView.sent.emptyTapPublisher + screenView + .sentController + .connectionsPublisher .receive(on: DispatchQueue.main) .sink { [unowned self] in coordinator.toSearch(from: self) } .store(in: &cancellables) screenView - .segmentedControl.received + .segmentedControl + .receivedRequestsButton .publisher(for: .touchUpInside) .sink { [unowned self] _ in screenView.scrollView.setContentOffset(.zero, animated: true) - screenView.segmentedControl.didChooseFilter(.received) }.store(in: &cancellables) screenView - .segmentedControl.sent + .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) - screenView.segmentedControl.didChooseFilter(.sent) }.store(in: &cancellables) screenView - .segmentedControl.failed + .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) - screenView.segmentedControl.didChooseFilter(.failed) }.store(in: &cancellables) } - // MARK: ObjC - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) + @objc private func didTapMenu() { + coordinator.toSideMenu(from: self) } +} - // MARK: UIScrollViewDelegate - +extension RequestsContainerController: UIScrollViewDelegate { public func scrollViewDidScroll(_ scrollView: UIScrollView) { - let percentage = scrollView.contentOffset.x / view.frame.width - screenView.segmentedControl.updateLeftConstraint(percentage) + screenView.segmentedControl.updateSwipePercentage(scrollView.contentOffset.x / view.frame.width) } } - -extension RequestsContainerController: UIScrollViewDelegate {} diff --git a/Sources/RequestsFeature/Controllers/RequestsFailedController.swift b/Sources/RequestsFeature/Controllers/RequestsFailedController.swift index 33661f089f16030e15969fddd82ef9a7a82aa35d..c166da6b2c97b01f38e273b2a1a91ccbed475055 100644 --- a/Sources/RequestsFeature/Controllers/RequestsFailedController.swift +++ b/Sources/RequestsFeature/Controllers/RequestsFailedController.swift @@ -2,105 +2,47 @@ import HUD import UIKit import Shared import Combine -import Models -import DifferenceKit +import DependencyInjection -final class RequestsFailedController: UITableViewController { - // MARK: Properties +final class RequestsFailedController: UIViewController { + @Dependency private var hud: HUDType - var hudPublisher: AnyPublisher<HUDStatus, Never> { - hudRelay.eraseToAnyPublisher() - } - - var didTap: AnyPublisher<Contact, Never> { - tapRelay.eraseToAnyPublisher() - } - - private let tapRelay = PassthroughSubject<Contact, Never>() - private let hudRelay = PassthroughSubject<HUDStatus, Never>() - - private var items = [Contact]() + lazy private var screenView = RequestsFailedView() private var cancellables = Set<AnyCancellable>() private let viewModel = RequestsFailedViewModel() + private var dataSource: UICollectionViewDiffableDataSource<Section, Request>? - // MARK: Lifecycle + override func loadView() { + view = screenView + } override func viewDidLoad() { super.viewDidLoad() - setupTableView() - setupBindings() - } - - // MARK: Private - - private func setupTableView() { - tableView.separatorStyle = .none - tableView.register(RequestFailedCell.self) - tableView.backgroundColor = Asset.neutralWhite.color - } - private func setupBindings() { - viewModel.items + screenView.collectionView.register(RequestCell.self) + dataSource = UICollectionViewDiffableDataSource<Section, Request>( + collectionView: screenView.collectionView + ) { collectionView, indexPath, request in + + let cell: RequestCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) + cell.setupFor(requestFailed: request) + cell.didTapStateButton = { [weak self] in + guard let self = self else { return } + self.viewModel.didTapStateButtonFor(request: request) + } + return cell + } + + viewModel.itemsPublisher .receive(on: DispatchQueue.main) .sink { [unowned self] in - let changeSet = StagedChangeset(source: self.items, target: $0) - - self.tableView.reload( - using: changeSet, - deleteSectionsAnimation: .none, - insertSectionsAnimation: .none, - reloadSectionsAnimation: .none, - deleteRowsAnimation: .none, - insertRowsAnimation: .none, - reloadRowsAnimation: .none - ) { [unowned self] in - self.items = $0 - } + dataSource?.apply($0, animatingDifferences: false) + screenView.collectionView.isHidden = $0.numberOfItems == 0 }.store(in: &cancellables) - viewModel.hud - .sink { [weak hudRelay] in hudRelay?.send($0) } + viewModel.hudPublisher + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } .store(in: &cancellables) } - - // MARK: UITableViewDataSource - - override func tableView(_ tableView: UITableView, - cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: RequestFailedCell = tableView.dequeueReusableCell(forIndexPath: indexPath) - let contact = items[indexPath.row] - - cell.setup( - username: contact.username, - nickname: contact.nickname, - createdAt: contact.createdAt, - photo: contact.photo - ) - - cell.button - .publisher(for: .touchUpInside) - .sink { [unowned self] in viewModel.didTapRetry(contact) } - .store(in: &cell.cancellables) - - return cell - } - - // MARK: UITableViewDelegate - - override func tableView( - _ tableView: UITableView, - didSelectRowAt indexPath: IndexPath - ) { - tapRelay.send(items[indexPath.row]) - } - - override func tableView( - _ tableView: UITableView, - numberOfRowsInSection section: Int - ) -> Int { items.count } - - override func tableView( - _ tableView: UITableView, - heightForRowAt indexPath: IndexPath - ) -> CGFloat { 72 } } diff --git a/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift b/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift index 62ddfb180d405bc97b47704915e9843718280be9..c92d5cb26e82317750b6b039bad65c563c1f7eea 100644 --- a/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift +++ b/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift @@ -1,117 +1,571 @@ import HUD import UIKit +import Models import Shared import Combine +import Countries +import ToastFeature +import DrawerFeature import DependencyInjection -final class RequestsReceivedController: UITableViewController { +final class RequestsReceivedController: UIViewController { + @Dependency private var hud: HUDType + @Dependency private var toaster: ToastController @Dependency private var coordinator: RequestsCoordinating - lazy private(set) var emptyView = RequestReceivedEmptyView() - - var hudPublisher: AnyPublisher<HUDStatus, Never> { hudRelay.eraseToAnyPublisher() } - var tapPublisher: AnyPublisher<RequestReceived, Never> { tapRelay.eraseToAnyPublisher() } - var verifyingPublisher: AnyPublisher<Void, Never> { verifyingRelay.eraseToAnyPublisher() } - + lazy private var screenView = RequestsReceivedView() private var cancellables = Set<AnyCancellable>() private let viewModel = RequestsReceivedViewModel() - private var dataSource: UITableViewDiffableDataSource<SectionId, RequestReceived>! + private var drawerCancellables = Set<AnyCancellable>() + private var dataSource: UICollectionViewDiffableDataSource<Section, RequestReceived>? - private let verifyingRelay = PassthroughSubject<Void, Never>() - private let hudRelay = PassthroughSubject<HUDStatus, Never>() - private let tapRelay = PassthroughSubject<RequestReceived, Never>() + override func loadView() { + view = screenView + } override func viewDidLoad() { super.viewDidLoad() - tableView.separatorStyle = .none - tableView.register(RequestReceivedCell.self) - tableView.backgroundColor = Asset.neutralWhite.color - tableView.contentInset = .init(top: 15, left: 0, bottom: 0, right: 0) - setupBindings() - } + screenView.collectionView.delegate = self + screenView.collectionView.register(RequestCell.self) + screenView.collectionView.register(RequestReceivedEmptyCell.self) + screenView.collectionView.registerSectionHeader(RequestsBlankSectionHeader.self) + screenView.collectionView.registerSectionHeader(RequestsHiddenSectionHeader.self) - private func setupBindings() { - viewModel.hud - .sink { [weak hudRelay] in hudRelay?.send($0) } - .store(in: &cancellables) + dataSource = UICollectionViewDiffableDataSource<Section, RequestReceived>( + collectionView: screenView.collectionView + ) { collectionView, indexPath, requestReceived in + guard let request = requestReceived.request else { + let cell: RequestReceivedEmptyCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) + return cell + } - dataSource = UITableViewDiffableDataSource<SectionId, RequestReceived>( - tableView: tableView - ) { tableView, indexPath, request in - let cell: RequestReceivedCell = tableView.dequeueReusableCell(forIndexPath: indexPath) + let cell: RequestCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) + cell.setupFor(requestReceived: requestReceived, isHidden: indexPath.section == 1) + cell.didTapStateButton = { [weak self] in + guard let self = self else { return } + self.viewModel.didTapStateButtonFor(request: request) + } - let isGroup = request.group != nil - let possibleContactTitle = request.contact?.nickname ?? request.contact?.username + return cell + } - let title = isGroup ? request.group!.name : possibleContactTitle - let createdAt = request.contact?.createdAt ?? Date() - var actionsHidden: Bool + dataSource?.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in + let reuseIdentifier: String - if isGroup { - actionsHidden = false + if indexPath.section == Section.appearing.rawValue { + reuseIdentifier = String(describing: RequestsBlankSectionHeader.self) } else { - actionsHidden = request.contact!.status != .verified + reuseIdentifier = String(describing: RequestsHiddenSectionHeader.self) } - cell.setup( - name: title ?? "", - createdAt: createdAt, - photo: request.contact?.photo, - actionsHidden: actionsHidden, - verificationFailed: request.contact?.status == .verificationFailed + let cell = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: reuseIdentifier, + for: indexPath ) - cell.didTapAccept = { [weak self] in - guard let self = self else { return } + if let cell = cell as? RequestsHiddenSectionHeader, let self = self { + cell.switcherView.setOn(self.viewModel.isShowingHiddenRequests, animated: true) + + cell.switcherView + .publisher(for: .valueChanged) + .sink { self.viewModel.didToggleHiddenRequestsSwitcher() } + .store(in: &cell.cancellables) + } + + return cell + } + + viewModel.hudPublisher + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + viewModel.verifyingPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in presentVerifyingDrawer() } + .store(in: &cancellables) + + viewModel.itemsPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in dataSource?.apply($0, animatingDifferences: true) } + .store(in: &cancellables) + + viewModel.contactConfirmationPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in presentSingleRequestSuccessDrawer(forContact: $0) } + .store(in: &cancellables) + + viewModel.groupConfirmationPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in presentGroupRequestSuccessDrawer(forGroup: $0) } + .store(in: &cancellables) + } +} + +extension RequestsReceivedController: UICollectionViewDelegate { + func collectionView(_: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let request = dataSource?.itemIdentifier(for: indexPath)?.request else { return } + + switch request { + case .group(let group): + guard group.status == .pending || group.status == .hidden else { return } + presentGroupRequestDrawer(forGroup: group) + case .contact(let contact): + guard contact.status == .verified || contact.status == .hidden else { return } + presentSingleRequestDrawer(forContact: contact) + } + } +} + +// MARK: - Group Request Success Drawer + +extension RequestsReceivedController { + func presentGroupRequestSuccessDrawer(forGroup group: Group) { + drawerCancellables.removeAll() + + var items: [DrawerItem] = [] + + let drawerTitle = DrawerText( + font: Fonts.Mulish.bold.font(size: 12.0), + text: Localized.Requests.Drawer.Group.Success.title, + color: Asset.accentSuccess.color, + spacingAfter: 20, + leftImage: Asset.requestAccepted.image + ) + + let drawerNickname = DrawerText( + font: Fonts.Mulish.extraBold.font(size: 26.0), + text: group.name, + color: Asset.neutralDark.color, + spacingAfter: 20 + ) + + let drawerSubtitle = DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: Localized.Requests.Drawer.Group.Success.subtitle, + color: Asset.neutralDark.color, + spacingAfter: 20 + ) + + items.append(contentsOf: [ + drawerTitle, + drawerNickname, + drawerSubtitle + ]) + + let drawerSendButton = DrawerCapsuleButton( + model: .init( + title: Localized.Requests.Drawer.Group.Success.send, + style: .brandColored + ), spacingAfter: 5 + ) + + let drawerLaterButton = DrawerCapsuleButton( + model: .init( + title: Localized.Requests.Drawer.Group.Success.later, + style: .simplestColoredBrand + ) + ) + + items.append(contentsOf: [ + drawerSendButton, + drawerLaterButton + ]) + + let drawer = DrawerController(with: items) + + drawerSendButton.action + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + drawer.dismiss(animated: true) { + let chatInfo = self.viewModel.groupChatWith(group: group) + self.coordinator.toGroupChat(with: chatInfo, from: self) + } + }.store(in: &drawerCancellables) + + drawerLaterButton.action + .sink { drawer.dismiss(animated: true) } + .store(in: &drawerCancellables) + + coordinator.toDrawer(drawer, from: self) + } +} + +// MARK: - Single Request Success Drawer + +extension RequestsReceivedController { + func presentSingleRequestSuccessDrawer(forContact contact: Contact) { + drawerCancellables.removeAll() + + var items: [DrawerItem] = [] + + let drawerTitle = DrawerText( + font: Fonts.Mulish.bold.font(size: 12.0), + text: Localized.Requests.Drawer.Single.Success.title, + color: Asset.accentSuccess.color, + spacingAfter: 20, + leftImage: Asset.requestAccepted.image + ) + + let drawerNickname = DrawerText( + font: Fonts.Mulish.extraBold.font(size: 26.0), + text: contact.nickname ?? contact.username, + color: Asset.neutralDark.color, + spacingAfter: 20 + ) + + let drawerSubtitle = DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: Localized.Requests.Drawer.Single.Success.subtitle, + color: Asset.neutralDark.color, + spacingAfter: 20 + ) + + items.append(contentsOf: [ + drawerTitle, + drawerNickname, + drawerSubtitle + ]) + + let drawerSendButton = DrawerCapsuleButton( + model: .init( + title: Localized.Requests.Drawer.Single.Success.send, + style: .brandColored + ), spacingAfter: 5 + ) + + let drawerLaterButton = DrawerCapsuleButton( + model: .init( + title: Localized.Requests.Drawer.Single.Success.later, + style: .simplestColoredBrand + ) + ) + + items.append(contentsOf: [ + drawerSendButton, + drawerLaterButton + ]) + + let drawer = DrawerController(with: items) + + drawerSendButton.action + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + drawer.dismiss(animated: true) { + self.coordinator.toSingleChat(with: contact, from: self) + } + }.store(in: &drawerCancellables) + + drawerLaterButton.action + .receive(on: DispatchQueue.main) + .sink { drawer.dismiss(animated: true) } + .store(in: &drawerCancellables) + + coordinator.toDrawer(drawer, from: self) + } +} + +// MARK: - Group Request Drawer + +extension RequestsReceivedController { + func presentGroupRequestDrawer(forGroup group: Group) { + drawerCancellables.removeAll() + + var items: [DrawerItem] = [] - guard let group = request.group else { - self.coordinator.toNickname(from: self, prefilled: possibleContactTitle ?? "") { - var contact = request.contact! - contact.nickname = $0 - self.viewModel.didAccept(contact) + let drawerTitle = DrawerText( + font: Fonts.Mulish.bold.font(size: 12.0), + text: Localized.Requests.Drawer.Group.title, + spacingAfter: 20 + ) + + let drawerGroupName = DrawerText( + font: Fonts.Mulish.extraBold.font(size: 26.0), + text: group.name, + color: Asset.neutralDark.color, + spacingAfter: 25 + ) + + items.append(contentsOf: [ + drawerTitle, + drawerGroupName + ]) + + let drawerLoading = DrawerLoadingRetry() + drawerLoading.startSpinning() + + items.append(drawerLoading) + + let drawerTable = DrawerTable(spacingAfter: 23) + + drawerLoading.retryPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + drawerLoading.startSpinning() + + viewModel.fetchMembers(group) { [weak self] in + guard let _ = self else { return } + + switch $0 { + case .success(let models): + DispatchQueue.main.async { + drawerTable.update(models: models) + drawerLoading.stopSpinning(withRetry: false) + } + case .failure: + drawerLoading.stopSpinning(withRetry: true) } - return } + }.store(in: &drawerCancellables) - self.viewModel.didAccept(group) - } + viewModel.fetchMembers(group) { [weak self] in + guard let _ = self else { return } - cell.didTapReject = { [weak self] in - guard let self = self else { return } - self.viewModel.didTapReject(request) + switch $0 { + case .success(let models): + DispatchQueue.main.async { + drawerTable.update(models: models) + drawerLoading.stopSpinning(withRetry: false) + } + case .failure: + drawerLoading.stopSpinning(withRetry: true) } + } + + items.append(drawerTable) - cell.didTapVerification = { [weak self] in - guard let self = self, let contact = request.contact else { return } + let drawerAcceptButton = DrawerCapsuleButton( + model: .init( + title: Localized.Requests.Drawer.Group.accept, + style: .brandColored + ), spacingAfter: 5 + ) - if contact.status == .verificationInProgress { - self.verifyingRelay.send() - } else if contact.status == .verificationFailed { - self.viewModel.didTapVerification(contact) + let drawerHideButton = DrawerCapsuleButton( + model: .init( + title: Localized.Requests.Drawer.Group.hide, + style: .simplestColoredBrand + ), spacingAfter: 5 + ) + + items.append(contentsOf: [drawerAcceptButton, drawerHideButton]) + + let drawer = DrawerController(with: items) + + drawerAcceptButton.action + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + drawer.dismiss(animated: true) { + self.viewModel.didRequestAccept(group: group) } } + .store(in: &drawerCancellables) - return cell - } - - viewModel.requests + drawerHideButton.action .receive(on: DispatchQueue.main) .sink { [unowned self] in - emptyView.isHidden = !$0.itemIdentifiers.isEmpty - dataSource.apply($0, animatingDifferences: false) - }.store(in: &cancellables) + drawer.dismiss(animated: true) { + self.viewModel.didRequestHide(group: group) + } + } + .store(in: &drawerCancellables) + + coordinator.toDrawerBottom(drawer, from: self) } +} + +// MARK: - Single Request Drawer + +extension RequestsReceivedController { + func presentSingleRequestDrawer(forContact contact: Contact) { + drawerCancellables.removeAll() + + var items: [DrawerItem] = [] - // MARK: UITableViewDelegate + let drawerTitle = DrawerText( + font: Fonts.Mulish.bold.font(size: 12.0), + text: Localized.Requests.Drawer.Single.title, + spacingAfter: 20 + ) + + let drawerUsername = DrawerText( + font: Fonts.Mulish.extraBold.font(size: 26.0), + text: contact.username, + color: Asset.neutralDark.color, + spacingAfter: 25 + ) + + items.append(contentsOf: [ + drawerTitle, + drawerUsername + ]) + + let drawerEmailTitle = DrawerText( + font: Fonts.Mulish.bold.font(size: 12.0), + text: Localized.Requests.Drawer.Single.email, + color: Asset.neutralWeak.color, + spacingAfter: 5 + ) + + if let email = contact.email { + let drawerEmailContent = DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: email, + spacingAfter: 25 + ) + + items.append(contentsOf: [ + drawerEmailTitle, + drawerEmailContent + ]) + } + + let drawerPhoneTitle = DrawerText( + font: Fonts.Mulish.bold.font(size: 12.0), + text: Localized.Requests.Drawer.Single.phone, + color: Asset.neutralWeak.color, + spacingAfter: 5 + ) + + if let phone = contact.phone { + let drawerPhoneContent = DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: "\(Country.findFrom(phone).prefix) \(phone.dropLast(2))", + spacingAfter: 30 + ) - override func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { - if let request = dataSource.itemIdentifier(for: indexPath) { - tapRelay.send(request) + items.append(contentsOf: [ + drawerPhoneTitle, + drawerPhoneContent + ]) } + + let drawerNicknameTitle = DrawerText( + font: Fonts.Mulish.bold.font(size: 16.0), + text: Localized.Requests.Drawer.Single.nickname, + color: Asset.neutralDark.color, + spacingAfter: 21 + ) + + items.append(drawerNicknameTitle) + + let drawerNicknameInput = DrawerInput( + placeholder: contact.username, + validator: .init( + wrongIcon: .image(Asset.sharedError.image), + correctIcon: .image(Asset.sharedSuccess.image), + shouldAcceptPlaceholder: true + ), + spacingAfter: 29 + ) + + items.append(drawerNicknameInput) + + let drawerAcceptButton = DrawerCapsuleButton( + model: .init( + title: Localized.Requests.Drawer.Single.accept, + style: .brandColored + ), spacingAfter: 5 + ) + + let drawerHideButton = DrawerCapsuleButton( + model: .init( + title: Localized.Requests.Drawer.Single.hide, + style: .simplestColoredBrand + ), spacingAfter: 5 + ) + + items.append(contentsOf: [drawerAcceptButton, drawerHideButton]) + + let drawer = DrawerController(with: items) + + var nickname: String? + var allowsSave = true + + drawerNicknameInput.validationPublisher + .receive(on: DispatchQueue.main) + .sink { allowsSave = $0 } + .store(in: &drawerCancellables) + + drawerNicknameInput.inputPublisher + .receive(on: DispatchQueue.main) + .sink { + guard !$0.isEmpty else { + nickname = contact.username + return + } + + nickname = $0 + } + .store(in: &drawerCancellables) + + drawerAcceptButton.action + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + guard allowsSave else { return } + + drawer.dismiss(animated: true) { + self.viewModel.didRequestAccept(contact: contact, nickname: nickname) + } + } + .store(in: &drawerCancellables) + + drawerHideButton.action + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + drawer.dismiss(animated: true) { + self.viewModel.didRequestHide(contact: contact) + } + } + .store(in: &drawerCancellables) + + coordinator.toDrawer(drawer, from: self) } +} + +// MARK: - Verifying Drawer + +extension RequestsReceivedController { + func presentVerifyingDrawer() { + drawerCancellables.removeAll() + + var items: [DrawerItem] = [] + + let drawerTitle = DrawerText( + font: Fonts.Mulish.extraBold.font(size: 26.0), + text: Localized.Requests.Received.Verifying.title, + spacingAfter: 20 + ) + + let drawerSubtitle = DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: Localized.Requests.Received.Verifying.subtitle, + spacingAfter: 40 + ) + + items.append(contentsOf: [ + drawerTitle, + drawerSubtitle + ]) + + let drawerDoneButton = DrawerCapsuleButton( + model: .init( + title: Localized.Requests.Received.Verifying.action, + style: .brandColored + ), spacingAfter: 5 + ) + + items.append(drawerDoneButton) + + let drawer = DrawerController(with: items) + + drawerDoneButton.action + .receive(on: DispatchQueue.main) + .sink { drawer.dismiss(animated: true) } + .store(in: &drawerCancellables) - override func tableView(_: UITableView, heightForRowAt: IndexPath) -> CGFloat { - 72 + coordinator.toDrawer(drawer, from: self) } } diff --git a/Sources/RequestsFeature/Controllers/RequestsSentController.swift b/Sources/RequestsFeature/Controllers/RequestsSentController.swift index 5e5a7e6f65a4dc4c14808cbdee3f6e93d8c2a882..36c245f4476fdd8795a6c0c38612f86c1ec6953e 100644 --- a/Sources/RequestsFeature/Controllers/RequestsSentController.swift +++ b/Sources/RequestsFeature/Controllers/RequestsSentController.swift @@ -1,134 +1,59 @@ import HUD import UIKit -import Models import Shared import Combine -import DifferenceKit +import DependencyInjection -final class RequestsSentController: UITableViewController { - lazy private(set) var emptyView = UIView() +final class RequestsSentController: UIViewController { + @Dependency private var hud: HUDType - var tapPublisher: AnyPublisher<Contact, Never> { - tapRelay.eraseToAnyPublisher() + var connectionsPublisher: AnyPublisher<Void, Never> { + connectionSubject.eraseToAnyPublisher() } - var hudPublisher: AnyPublisher<HUDStatus, Never> { - hudRelay.eraseToAnyPublisher() - } - - var emptyTapPublisher: AnyPublisher<Void, Never> { - emptyTapRelay.eraseToAnyPublisher() - } - - private var items = [Contact]() + lazy private var screenView = RequestsSentView() private let viewModel = RequestsSentViewModel() private var cancellables = Set<AnyCancellable>() - private let tapRelay = PassthroughSubject<Contact, Never>() - private let emptyTapRelay = PassthroughSubject<Void, Never>() - private let hudRelay = PassthroughSubject<HUDStatus, Never>() + private let tapSubject = PassthroughSubject<Request, Never>() + private let connectionSubject = PassthroughSubject<Void, Never>() + private var dataSource: UICollectionViewDiffableDataSource<Section, RequestSent>? - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - emptyView.frame = view.frame + override func loadView() { + view = screenView } override func viewDidLoad() { super.viewDidLoad() - setupTableView() - setupEmptyState() - setupBindings() - } - - private func setupTableView() { - tableView.separatorStyle = .none - tableView.register(RequestSentCell.self) - tableView.backgroundColor = Asset.neutralWhite.color - tableView.contentInset = .init(top: 15, left: 0, bottom: 0, right: 0) - } - - private func setupEmptyState() { - let icon = UIImageView() - icon.contentMode = .center - icon.image = Asset.requestsReceivedPlaceholder.image - - let button = CapsuleButton() - button.setStyle(.brandColored) - button.setTitle(Localized.Requests.Sent.action, for: .normal) - - button.publisher(for: .touchUpInside) - .sink { [weak emptyTapRelay] in emptyTapRelay?.send() } - .store(in: &cancellables) - - let stack = UIStackView() - stack.spacing = 24 - stack.axis = .vertical - stack.alignment = .center - stack.addArrangedSubview(icon) - stack.addArrangedSubview(button) - - emptyView.addSubview(stack) - stack.snp.makeConstraints { make in - make.centerY.equalToSuperview().multipliedBy(0.8) - make.left.equalToSuperview().offset(24) - make.right.equalToSuperview().offset(-24) + screenView.collectionView.register(RequestCell.self) + dataSource = UICollectionViewDiffableDataSource<Section, RequestSent>( + collectionView: screenView.collectionView + ) { collectionView, indexPath, requestSent in + + let cell: RequestCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) + cell.setupFor(requestSent: requestSent) + cell.didTapStateButton = { [weak self] in + guard let self = self else { return } + self.viewModel.didTapStateButtonFor(request: requestSent) + } + return cell } - } - private func setupBindings() { - viewModel.items + viewModel.itemsPublisher .receive(on: DispatchQueue.main) .sink { [unowned self] in - let changeSet = StagedChangeset(source: self.items, target: $0) - - self.tableView.reload( - using: changeSet, - deleteSectionsAnimation: .none, - insertSectionsAnimation: .none, - reloadSectionsAnimation: .none, - deleteRowsAnimation: .none, - insertRowsAnimation: .none, - reloadRowsAnimation: .none - ) { [unowned self] in - self.items = $0 - } + dataSource?.apply($0, animatingDifferences: false) + screenView.collectionView.isHidden = $0.numberOfItems == 0 }.store(in: &cancellables) - viewModel.items + viewModel.hudPublisher .receive(on: DispatchQueue.main) - .sink { [unowned self] in emptyView.isHidden = !$0.isEmpty } - .store(in: &cancellables) - - viewModel.hud - .sink { [weak hudRelay] in hudRelay?.send($0) } + .sink { [hud] in hud.update(with: $0) } .store(in: &cancellables) - } - - override func tableView(_ tableView: UITableView, - cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: RequestSentCell = tableView.dequeueReusableCell(forIndexPath: indexPath) - let contact = items[indexPath.row] - - cell.setup( - username: contact.username, - nickname: contact.nickname, - createdAt: contact.createdAt, - photo: contact.photo - ) - cell.button + screenView.connectionsButton .publisher(for: .touchUpInside) - .sink { [unowned self] in viewModel.didTapResend(contact) } - .store(in: &cell.cancellables) - - return cell - } - - override func tableView(_: UITableView, numberOfRowsInSection: Int) -> Int { - items.count - } - - override func tableView(_: UITableView, heightForRowAt: IndexPath) -> CGFloat { - 56 + .sink { [unowned self] in connectionSubject.send() } + .store(in: &cancellables) } } diff --git a/Sources/RequestsFeature/Controllers/VerifyingController.swift b/Sources/RequestsFeature/Controllers/VerifyingController.swift deleted file mode 100644 index 65d20075b80e55aa309d86fa9e59f22524e52cef..0000000000000000000000000000000000000000 --- a/Sources/RequestsFeature/Controllers/VerifyingController.swift +++ /dev/null @@ -1,20 +0,0 @@ -import UIKit -import Combine - -public final class VerifyingController: UIViewController { - lazy private var screenView = VerifyingView() - - private var cancellables = Set<AnyCancellable>() - - public override func loadView() { - view = screenView - } - - public override func viewDidLoad() { - super.viewDidLoad() - screenView.action.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in dismiss(animated: true) } - .store(in: &cancellables) - } -} diff --git a/Sources/RequestsFeature/Coordinator/RequestsCoordinator.swift b/Sources/RequestsFeature/Coordinator/RequestsCoordinator.swift index 2ef069e020f5f36c25b51699596dd89966181cf0..4d28c6bf7fc7c8f482a8b69809b679ec9b447816 100644 --- a/Sources/RequestsFeature/Coordinator/RequestsCoordinator.swift +++ b/Sources/RequestsFeature/Coordinator/RequestsCoordinator.swift @@ -1,39 +1,84 @@ import UIKit import Shared import Models +import MenuFeature import Presentation import ContactFeature +import ScrollViewController public protocol RequestsCoordinating { func toSearch(from: UIViewController) - func toVerifying(from: UIViewController) + func toSideMenu(from: UIViewController) func toContact(_: Contact, from: UIViewController) + func toSingleChat(with: Contact, from: UIViewController) + func toDrawer(_: UIViewController, from: UIViewController) + func toGroupChat(with: GroupChatInfo, from: UIViewController) + func toDrawerBottom(_: UIViewController, from: UIViewController) func toNickname(from: UIViewController, prefilled: String, _: @escaping StringClosure) } public struct RequestsCoordinator: RequestsCoordinating { var pushPresenter: Presenting = PushPresenter() + var sidePresenter: Presenting = SideMenuPresenter() var bottomPresenter: Presenting = BottomPresenter() + var fullscreenPresenter: Presenting = FullscreenPresenter() var searchFactory: () -> UIViewController - var verifyingFactory: () -> UIViewController var contactFactory: (Contact) -> UIViewController + var singleChatFactory: (Contact) -> UIViewController + var groupChatFactory: (GroupChatInfo) -> UIViewController + var sideMenuFactory: (MenuItem, UIViewController) -> UIViewController var nicknameFactory: (String, @escaping StringClosure) -> UIViewController public init( searchFactory: @escaping () -> UIViewController, - verifyingFactory: @escaping () -> UIViewController, contactFactory: @escaping (Contact) -> UIViewController, + singleChatFactory: @escaping (Contact) -> UIViewController, + groupChatFactory: @escaping (GroupChatInfo) -> UIViewController, + sideMenuFactory: @escaping (MenuItem, UIViewController) -> UIViewController, nicknameFactory: @escaping (String, @escaping StringClosure) -> UIViewController ) { self.searchFactory = searchFactory self.contactFactory = contactFactory self.nicknameFactory = nicknameFactory - self.verifyingFactory = verifyingFactory + self.sideMenuFactory = sideMenuFactory + self.groupChatFactory = groupChatFactory + self.singleChatFactory = singleChatFactory } } public extension RequestsCoordinator { + func toSingleChat( + with contact: Contact, + from parent: UIViewController + ) { + let screen = singleChatFactory(contact) + pushPresenter.present(screen, from: parent) + } + + func toGroupChat( + with info: GroupChatInfo, + from parent: UIViewController + ) { + let screen = groupChatFactory(info) + pushPresenter.present(screen, from: parent) + } + + func toDrawer( + _ drawer: UIViewController, + from parent: UIViewController + ) { + let target = ScrollViewController.embedding(drawer) + fullscreenPresenter.present(target, from: parent) + } + + func toDrawerBottom( + _ drawer: UIViewController, + from parent: UIViewController + ) { + bottomPresenter.present(drawer, from: parent) + } + func toSearch(from parent: UIViewController) { let screen = searchFactory() pushPresenter.present(screen, from: parent) @@ -48,13 +93,27 @@ public extension RequestsCoordinator { bottomPresenter.present(screen, from: parent) } - func toVerifying(from parent: UIViewController) { - let screen = verifyingFactory() - bottomPresenter.present(screen, from: parent) - } - func toContact(_ contact: Contact, from parent: UIViewController) { let screen = contactFactory(contact) pushPresenter.present(screen, from: parent) } + + func toSideMenu(from parent: UIViewController) { + let screen = sideMenuFactory(.requests, parent) + sidePresenter.present(screen, from: parent) + } +} + +extension ScrollViewController { + static func embedding(_ viewController: UIViewController) -> ScrollViewController { + let scrollViewController = ScrollViewController() + scrollViewController.addChild(viewController) + scrollViewController.contentView = viewController.view + scrollViewController.wrapperView.handlesTouchesOutsideContent = false + scrollViewController.wrapperView.alignContentToBottom = true + scrollViewController.scrollView.bounces = false + + viewController.didMove(toParent: scrollViewController) + return scrollViewController + } } diff --git a/Sources/RequestsFeature/Models/Request.swift b/Sources/RequestsFeature/Models/Request.swift new file mode 100644 index 0000000000000000000000000000000000000000..2bf872c5a02815db8be3eb0adf92790328bf0082 --- /dev/null +++ b/Sources/RequestsFeature/Models/Request.swift @@ -0,0 +1,68 @@ +import Models +import Foundation + +enum Section: Int { + case appearing = 0 + case hidden +} + +enum Request: Hashable, Equatable { + case group(Group) + case contact(Contact) + + var status: RequestStatus { + switch self { + case .group: + return .verified + case .contact(let contact): + return contact.status.toRequestStatus() + } + } + + var id: Data { + switch self { + case .group(let group): + return group.groupId + case .contact(let contact): + return contact.userId + } + } +} + +enum RequestStatus { + case verified + case verifying + case requested + case requesting + case confirming + case failedToVerify + case failedToConfirm + case failedToRequest +} + +extension Contact.Status { + func toRequestStatus() -> RequestStatus { + switch self { + case .friend, .stranger: + fatalError() + case .verified: + return .verified + case .requested: + return .requested + case .verificationInProgress: + return .verifying + case .requesting: + return .requesting + case .confirming: + return .confirming + case .requestFailed: + return .failedToRequest + case .verificationFailed: + return .failedToVerify + case .confirmationFailed: + return .failedToConfirm + case .hidden: + return .verified + } + } +} diff --git a/Sources/RequestsFeature/ViewModels/RequestsContainerViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsContainerViewModel.swift deleted file mode 100644 index 2efe99210965dc6ea3d905a817b52375fe556327..0000000000000000000000000000000000000000 --- a/Sources/RequestsFeature/ViewModels/RequestsContainerViewModel.swift +++ /dev/null @@ -1,18 +0,0 @@ -import HUD -import Combine - -final class RequestsContainerViewModel { - // MARK: Properties - - var hud: AnyPublisher<HUDStatus, Never> { - hudRelay.eraseToAnyPublisher() - } - - private let hudRelay = PassthroughSubject<HUDStatus, Never>() - - // MARK: Public - - func didReceive(hud: HUDStatus) { - hudRelay.send(hud) - } -} diff --git a/Sources/RequestsFeature/ViewModels/RequestsFailedViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsFailedViewModel.swift index f86a34ad238b2715cb74acd454e67cdee7704ffd..f67882bb3fca1237ac3f138b549d262755e7fafe 100644 --- a/Sources/RequestsFeature/ViewModels/RequestsFailedViewModel.swift +++ b/Sources/RequestsFeature/ViewModels/RequestsFailedViewModel.swift @@ -1,53 +1,51 @@ import HUD -import Combine +import UIKit import Models +import Combine import Integration -import DifferenceKit import CombineSchedulers import DependencyInjection final class RequestsFailedViewModel { - // MARK: Injected - @Dependency private var session: SessionType - // MARK: Properties - - var items: AnyPublisher<[Contact], Never> { - relay.eraseToAnyPublisher() + var hudPublisher: AnyPublisher<HUDStatus, Never> { + hudSubject.eraseToAnyPublisher() } - var hud: AnyPublisher<HUDStatus, Never> { - hudRelay.eraseToAnyPublisher() + var itemsPublisher: AnyPublisher<NSDiffableDataSourceSnapshot<Section, Request>, Never> { + itemsSubject.eraseToAnyPublisher() } private var cancellables = Set<AnyCancellable>() - private let relay = CurrentValueSubject<[Contact], Never>([]) - private let hudRelay = CurrentValueSubject<HUDStatus, Never>(.none) + private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) + private let itemsSubject = CurrentValueSubject<NSDiffableDataSourceSnapshot<Section, Request>, Never>(.init()) var backgroundScheduler: AnySchedulerOf<DispatchQueue> = DispatchQueue.global().eraseToAnyScheduler() - // MARK: Lifecycle - init() { session.contacts(.failed) - .sink { [unowned self] in relay.send($0) } + .map { data -> NSDiffableDataSourceSnapshot<Section, Request> in + var snapshot = NSDiffableDataSourceSnapshot<Section, Request>() + snapshot.appendSections([.appearing]) + snapshot.appendItems(data.map { Request.contact($0) }, toSection: .appearing) + return snapshot + }.sink { [unowned self] in itemsSubject.send($0) } .store(in: &cancellables) } - // MARK: Public - - func didTapRetry(_ contact: Contact) { - hudRelay.send(.on(nil)) + func didTapStateButtonFor(request: Request) { + guard case let .contact(contact) = request, request.status == .failedToRequest else { return } + hudSubject.send(.on(nil)) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } do { - try self.session.add(contact) - self.hudRelay.send(.none) + try self.session.retryRequest(contact) + self.hudSubject.send(.none) } catch { - self.hudRelay.send(.error(.init(with: error))) + self.hudSubject.send(.error(.init(with: error))) } } } diff --git a/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift index 43616b7dcb62ae636154defcf9125a3661d6a52f..35c2589da673e10cbc6dac35eec8771ead6a309b 100644 --- a/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift +++ b/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift @@ -3,95 +3,227 @@ import UIKit import Models import Shared import Combine +import Defaults +import DrawerFeature import Integration import CombineSchedulers import DependencyInjection +struct RequestReceived: Hashable, Equatable { + var request: Request? + var isHidden: Bool + var leader: String? +} + final class RequestsReceivedViewModel { @Dependency private var session: SessionType - var hud: AnyPublisher<HUDStatus, Never> { hudRelay.eraseToAnyPublisher() } - var requests: AnyPublisher<NSDiffableDataSourceSnapshot<SectionId, RequestReceived>, Never> { - requestsRelay.eraseToAnyPublisher() + @KeyObject(.isShowingHiddenRequests, defaultValue: false) var isShowingHiddenRequests: Bool + + var hudPublisher: AnyPublisher<HUDStatus, Never> { + hudSubject.eraseToAnyPublisher() + } + + var verifyingPublisher: AnyPublisher<Void, Never> { + verifyingSubject.eraseToAnyPublisher() + } + + var itemsPublisher: AnyPublisher<NSDiffableDataSourceSnapshot<Section, RequestReceived>, Never> { + itemsSubject.eraseToAnyPublisher() + } + + var groupConfirmationPublisher: AnyPublisher<Group, Never> { + groupConfirmationSubject.eraseToAnyPublisher() + } + + var contactConfirmationPublisher: AnyPublisher<Contact, Never> { + contactConfirmationSubject.eraseToAnyPublisher() } private var cancellables = Set<AnyCancellable>() - private let hudRelay = CurrentValueSubject<HUDStatus, Never>(.none) - private let requestsRelay = CurrentValueSubject<NSDiffableDataSourceSnapshot<SectionId, RequestReceived>, Never>(.init()) + private let updateSubject = CurrentValueSubject<Void, Never>(()) + private let verifyingSubject = PassthroughSubject<Void, Never>() + private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) + private let groupConfirmationSubject = PassthroughSubject<Group, Never>() + private let contactConfirmationSubject = PassthroughSubject<Contact, Never>() + private let itemsSubject = CurrentValueSubject<NSDiffableDataSourceSnapshot<Section, RequestReceived>, Never>(.init()) + private var groupChats = [GroupChatInfo]() var backgroundScheduler: AnySchedulerOf<DispatchQueue> = DispatchQueue.global().eraseToAnyScheduler() - // MARK: Lifecycle - init() { - Publishers.CombineLatest(session.groups(.pending), session.contacts(.received)) - .map { data -> NSDiffableDataSourceSnapshot<SectionId, RequestReceived> in - var snapshot = NSDiffableDataSourceSnapshot<SectionId, RequestReceived>() - let section = SectionId() - snapshot.appendSections([section]) + Publishers.CombineLatest4( + session.groups(.pending), + session.contacts(.all), + session.groupMembers(.all), + updateSubject.eraseToAnyPublisher() + ) + .subscribe(on: DispatchQueue.main) + .receive(on: DispatchQueue.global()) + .map { [unowned self] data -> NSDiffableDataSourceSnapshot<Section, RequestReceived> in + let contactRequests = data.1.filter { + $0.status == .hidden || + $0.status == .verified || + $0.status == .verificationFailed || + $0.status == .verificationInProgress + } + + var snapshot = NSDiffableDataSourceSnapshot<Section, RequestReceived>() + snapshot.appendSections([.appearing, .hidden]) + + let requests = data.0.map(Request.group) + contactRequests.map(Request.contact) + let receivedRequests = requests.map { request -> RequestReceived in + switch request { + case let .group(group): + var leaderTitle = "" + + if let leader = data.1.first(where: { $0.userId == group.leader }) { + leaderTitle = leader.nickname ?? leader.username + } else if let leader = data.2.first(where: { $0.userId == group.leader }) { + leaderTitle = leader.username + } + + return RequestReceived( + request: request, + isHidden: group.status == .hidden, + leader: leaderTitle + ) + case let .contact(contact): + return RequestReceived( + request: request, + isHidden: contact.status == .hidden, + leader: nil + ) + } + } - let groups = data.0.map { RequestReceived(id: $0.groupId, group: $0, contact: nil) } - let contacts = data.1.map { RequestReceived(id: $0.userId, group: nil, contact: $0) } + if self.isShowingHiddenRequests { + snapshot.appendItems(receivedRequests.filter(\.isHidden), toSection: .hidden) + } - snapshot.appendItems(groups + contacts, toSection: section) + guard receivedRequests.filter({ $0.isHidden == false }).count > 0 else { + snapshot.appendItems([RequestReceived(isHidden: false)], toSection: .appearing) return snapshot - }.sink( - receiveCompletion: { _ in }, - receiveValue: { [unowned self] in requestsRelay.send($0) } - ).store(in: &cancellables) - } + } - // MARK: Public + snapshot.appendItems(receivedRequests.filter { $0.isHidden == false }, toSection: .appearing) + return snapshot + }.sink( + receiveCompletion: { _ in }, + receiveValue: { [unowned self] in itemsSubject.send($0) } + ).store(in: &cancellables) - func didAccept(_ group: Group) { - hudRelay.send(.on(nil)) + session.groupChats(.accepted) + .sink { [unowned self] in groupChats = $0 } + .store(in: &cancellables) + } - backgroundScheduler.schedule { [weak self] in - guard let self = self else { return } + func didToggleHiddenRequestsSwitcher() { + isShowingHiddenRequests.toggle() + updateSubject.send() + } - do { - try self.session.join(group: group) - self.hudRelay.send(.none) - } catch { - self.hudRelay.send(.error(.init(with: error))) + func didTapStateButtonFor(request: Request) { + guard case let .contact(contact) = request else { return } + + if request.status == .failedToVerify { + backgroundScheduler.schedule { [weak self] in + self?.session.verify(contact: contact) } + } else if request.status == .verifying { + verifyingSubject.send() } } - func didAccept(_ contact: Contact) { - hudRelay.send(.on(nil)) + func didRequestHide(group: Group) { + session.hideRequestOf(group: group) + } - backgroundScheduler.schedule { [weak self] in - guard let self = self else { return } + func didRequestAccept(group: Group) { + hudSubject.send(.on(nil)) + backgroundScheduler.schedule { [weak self] in do { - try self.session.confirm(contact) - self.hudRelay.send(.none) + try self?.session.join(group: group) + self?.hudSubject.send(.none) + self?.groupConfirmationSubject.send(group) } catch { - self.hudRelay.send(.error(.init(with: error))) + self?.hudSubject.send(.error(.init(with: error))) } } } - func didTapVerification(_ contact: Contact) { - session.verify(contact: contact) - } + func fetchMembers( + _ group: Group, + _ completion: @escaping (Result<[DrawerTableCellModel], Error>) -> Void + ) { + session.scanStrangers { [weak self] in + guard let self = self else { return } - func didTapReject(_ request: RequestReceived) { - guard let contact = request.contact else { - session.delete(request.group!, isRequest: true) - return + Publishers.CombineLatest( + self.session.contacts(.all), + self.session.groupMembers(.fromGroup(group.groupId)) + ) + .sink { (allContacts, groupMembers) in + + guard !groupMembers.map(\.status).contains(.pendingUsername) else { + completion(.failure(NSError.create(""))) // Some members are still pending username lookup... + return + } + + // Now that all members are set with their usernames lets find our friends: + // + let contactsAlsoMembers = allContacts.filter { groupMembers.map(\.userId).contains($0.userId) } + let membersNonContacts = groupMembers.filter { !contactsAlsoMembers.map(\.userId).contains($0.userId) } + + var models = [DrawerTableCellModel]() + + contactsAlsoMembers.forEach { + models.append(.init( + title: $0.nickname ?? $0.username, + image: $0.photo, + isCreator: $0.userId == group.leader, + isConnection: true + )) + } + + membersNonContacts.forEach { + models.append(.init( + title: $0.username, + image: nil, + isCreator: $0.userId == group.leader, + isConnection: false + )) + } + + completion(.success(models)) + }.store(in: &self.cancellables) } + } - session.delete(contact, isRequest: true) + func didRequestHide(contact: Contact) { + session.hideRequestOf(contact: contact) } -} -struct RequestReceived { - var id: Data - var group: Group? - var contact: Contact? -} + func didRequestAccept(contact: Contact, nickname: String? = nil) { + hudSubject.send(.on(nil)) + + var contact = contact + contact.nickname = nickname ?? contact.username -extension RequestReceived: Hashable {} -extension RequestReceived: Equatable {} + backgroundScheduler.schedule { [weak self] in + do { + try self?.session.confirm(contact) + self?.hudSubject.send(.none) + self?.contactConfirmationSubject.send(contact) + } catch { + self?.hudSubject.send(.error(.init(with: error))) + } + } + } + + func groupChatWith(group: Group) -> GroupChatInfo { + guard let info = groupChats.first(where: { $0.group.groupId == group.groupId }) else { fatalError() } + return info + } +} diff --git a/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift index 0ab05705e83d77a177c5f13a0fff821ef0bd6c71..ebead6be165ce126a66b729321eac1fe8135fbbb 100644 --- a/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift +++ b/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift @@ -1,52 +1,80 @@ import HUD -import Combine +import UIKit import Models +import Shared +import Combine import Integration -import DifferenceKit -import DependencyInjection +import ToastFeature import CombineSchedulers +import DependencyInjection -final class RequestsSentViewModel { - // MARK: Injected +struct RequestSent: Hashable, Equatable { + var request: Request + var isResent: Bool = false +} +final class RequestsSentViewModel { @Dependency private var session: SessionType + @Dependency private var toastController: ToastController - // MARK: Properties - - var items: AnyPublisher<[Contact], Never> { - relay.eraseToAnyPublisher() + var hudPublisher: AnyPublisher<HUDStatus, Never> { + hudSubject.eraseToAnyPublisher() } - var hud: AnyPublisher<HUDStatus, Never> { - hudRelay.eraseToAnyPublisher() + var itemsPublisher: AnyPublisher<NSDiffableDataSourceSnapshot<Section, RequestSent>, Never> { + itemsSubject.eraseToAnyPublisher() } - private var cancellables = Set<AnyCancellable>() - private let relay = CurrentValueSubject<[Contact], Never>([]) - private let hudRelay = CurrentValueSubject<HUDStatus, Never>(.none) + private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) + private let itemsSubject = CurrentValueSubject<NSDiffableDataSourceSnapshot<Section, RequestSent>, Never>(.init()) var backgroundScheduler: AnySchedulerOf<DispatchQueue> = DispatchQueue.global().eraseToAnyScheduler() - // MARK: Lifecycle - init() { session.contacts(.requested) - .sink { [unowned self] in relay.send($0) } + .removeDuplicates() + .map { data -> NSDiffableDataSourceSnapshot<Section, RequestSent> in + var snapshot = NSDiffableDataSourceSnapshot<Section, RequestSent>() + snapshot.appendSections([.appearing]) + snapshot.appendItems(data.map { RequestSent(request: .contact($0)) }, toSection: .appearing) + return snapshot + }.sink { [unowned self] in itemsSubject.send($0) } .store(in: &cancellables) } - func didTapResend(_ contact: Contact) { - hudRelay.send(.on(nil)) + func didTapStateButtonFor(request item: RequestSent) { + guard case let .contact(contact) = item.request, item.request.status == .requested else { return } + hudSubject.send(.on(nil)) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } do { try self.session.retryRequest(contact) - self.hudRelay.send(.none) + self.hudSubject.send(.none) + + var item = item + var allRequests = self.itemsSubject.value.itemIdentifiers + + if let indexOfRequest = allRequests.firstIndex(of: item) { + allRequests.remove(at: indexOfRequest) + } + + item.isResent = true + allRequests.append(item) + + self.toastController.enqueueToast(model: .init( + title: Localized.Requests.Sent.Toast.resent(contact.nickname ?? contact.username), + leftImage: Asset.requestSentToaster.image + )) + + var snapshot = NSDiffableDataSourceSnapshot<Section, RequestSent>() + snapshot.appendSections([.appearing]) + snapshot.appendItems(allRequests, toSection: .appearing) + self.itemsSubject.send(snapshot) } catch { - self.hudRelay.send(.error(.init(with: error))) + self.hudSubject.send(.error(.init(with: error))) } } } diff --git a/Sources/RequestsFeature/Views/RequestCell.swift b/Sources/RequestsFeature/Views/RequestCell.swift new file mode 100644 index 0000000000000000000000000000000000000000..5a6bd6db7f9845d54745521bc9f49fd2c8dc753e --- /dev/null +++ b/Sources/RequestsFeature/Views/RequestCell.swift @@ -0,0 +1,259 @@ +import UIKit +import Shared +import Combine +import Countries + +final class RequestCell: UICollectionViewCell { + let titleLabel = UILabel() + let leaderLabel = UILabel() + let emailLabel = UILabel() + let phoneLabel = UILabel() + let dateLabel = UILabel() + let stackView = UIStackView() + let avatarView = AvatarView() + let stateButton = RequestCellButton() + + var cancellables = Set<AnyCancellable>() + var didTapStateButton: (() -> Void)! + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = Asset.neutralWhite.color + + titleLabel.textColor = Asset.neutralActive.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) + + emailLabel.font = Fonts.Mulish.regular.font(size: 14.0) + phoneLabel.font = Fonts.Mulish.regular.font(size: 14.0) + leaderLabel.font = Fonts.Mulish.regular.font(size: 14.0) + + emailLabel.textColor = Asset.neutralSecondaryAlternative.color + phoneLabel.textColor = Asset.neutralSecondaryAlternative.color + leaderLabel.textColor = Asset.neutralSecondaryAlternative.color + + dateLabel.font = Fonts.Mulish.regular.font(size: 10.0) + dateLabel.textColor = Asset.neutralWeak.color + + stackView.axis = .vertical + stackView.spacing = 4 + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(leaderLabel) + stackView.addArrangedSubview(emailLabel) + stackView.addArrangedSubview(phoneLabel) + stackView.addArrangedSubview(dateLabel) + + contentView.addSubview(avatarView) + contentView.addSubview(stateButton) + contentView.addSubview(stackView) + + avatarView.snp.makeConstraints { + $0.top.equalToSuperview().offset(15) + $0.width.height.equalTo(36) + $0.left.equalToSuperview().offset(27) + $0.bottom.lessThanOrEqualToSuperview().offset(-15) + } + + stackView.snp.makeConstraints { + $0.top.equalTo(avatarView).offset(-5) + $0.left.equalTo(avatarView.snp.right).offset(20) + $0.right.lessThanOrEqualTo(stateButton.snp.left).offset(-20) + $0.bottom.lessThanOrEqualToSuperview().offset(-15) + } + + stateButton.snp.makeConstraints { + $0.centerY.equalTo(stackView) + $0.right.equalToSuperview().offset(-24) + } + } + + required init?(coder: NSCoder) { nil } + + override func prepareForReuse() { + super.prepareForReuse() + titleLabel.text = nil + dateLabel.text = nil + phoneLabel.text = nil + emailLabel.text = nil + leaderLabel.text = nil + avatarView.prepareForReuse() + cancellables.removeAll() + } + + func setupFor(requestSent: RequestSent) { + cancellables.removeAll() + guard case .contact(let contact) = requestSent.request else { fatalError("A sent request -must- be of type contact") } + + var phone: String? + if let contactPhone = contact.phone { + phone = "\(Country.findFrom(contactPhone).prefix) \(contactPhone.dropLast(2))" + } + + setupContact( + title: contact.nickname ?? contact.username, + photo: contact.photo, + phone: phone, + email: contact.email, + createdAt: contact.createdAt, + backgroundColor: Asset.brandPrimary.color + ) + + var buttonTitle: String? = nil + var buttonImage: UIImage? = nil + var buttonTitleColor: UIColor? = nil + + if requestSent.isResent { + buttonTitle = Localized.Requests.Cell.resent + buttonImage = Asset.requestsResent.image + buttonTitleColor = Asset.neutralWeak.color + } else { + buttonTitle = Localized.Requests.Cell.requested + buttonImage = Asset.requestsResend.image + buttonTitleColor = Asset.brandPrimary.color + } + + setupStateButton( + image: buttonImage, + title: buttonTitle, + color: buttonTitleColor + ) + } + + func setupFor(requestFailed request: Request) { + cancellables.removeAll() + guard case .contact(let contact) = request else { fatalError("A failed request -must- be of type contact") } + + var phone: String? + if let contactPhone = contact.phone { + phone = "\(Country.findFrom(contactPhone).prefix) \(contactPhone.dropLast(2))" + } + + setupContact( + title: contact.nickname ?? contact.username, + photo: contact.photo, + phone: phone, + email: contact.email, + createdAt: contact.createdAt, + backgroundColor: Asset.brandPrimary.color + ) + + setupStateButton( + image: Asset.requestsResend.image, + title: Localized.Requests.Cell.failedRequest, + color: Asset.brandPrimary.color + ) + } + + func setupFor(requestReceived: RequestReceived, isHidden: Bool = false) { + cancellables.removeAll() + guard let request = requestReceived.request else { return } + let color = isHidden ? Asset.neutralDisabled.color : Asset.brandPrimary.color + + switch request { + case .group(let group): + setupGroup( + name: group.name, + createdAt: group.createdAt, + leader: requestReceived.leader, + backgroundColor: color + ) + + case .contact(let contact): + + var phone: String? + if let contactPhone = contact.phone { + phone = "\(Country.findFrom(contactPhone).prefix) \(contactPhone.dropLast(2))" + } + + setupContact( + title: contact.nickname ?? contact.username, + photo: contact.photo, + phone: phone, + email: contact.email, + createdAt: contact.createdAt, + backgroundColor: color + ) + + var buttonTitle: String? = nil + var buttonImage: UIImage? = nil + var buttonTitleColor: UIColor? = nil + + switch request.status { + case .verified, .confirming, .failedToConfirm: + break // TODO: These statuses don't need UI + + case .verifying: + buttonTitle = Localized.Requests.Cell.verifying + buttonTitleColor = Asset.neutralWeak.color + + case .failedToVerify: + buttonTitle = Localized.Requests.Cell.failedVerification + buttonImage = Asset.requestsVerificationFailed.image + buttonTitleColor = Asset.accentDanger.color + + case .requesting, .requested, .failedToRequest: + fatalError("A receivedRequest can never have the statuses: .requesting, .requested or .failedToRequest") + } + + setupStateButton( + image: buttonImage, + title: buttonTitle, + color: buttonTitleColor + ) + } + } + + private func setupContact( + title: String, + photo: Data?, + phone: String?, + email: String?, + createdAt: Date, + backgroundColor: UIColor + ) { + titleLabel.text = title + phoneLabel.text = phone + emailLabel.text = email + dateLabel.text = createdAt.asRelativeFromNow() + avatarView.setupProfile(title: title, image: photo, size: .small) + + leaderLabel.isHidden = true + phoneLabel.isHidden = phone == nil + emailLabel.isHidden = email == nil + avatarView.backgroundColor = backgroundColor + } + + private func setupGroup( + name: String, + createdAt: Date, + leader: String?, + backgroundColor: UIColor + ) { + titleLabel.text = name + stateButton.imageView.image = nil + stateButton.titleLabel.text = nil + avatarView.setupGroup(size: .small) + dateLabel.text = createdAt.asRelativeFromNow() + + leaderLabel.text = leader + leaderLabel.isHidden = false + phoneLabel.isHidden = true + emailLabel.isHidden = true + avatarView.backgroundColor = backgroundColor + } + + private func setupStateButton( + image: UIImage?, + title: String?, + color: UIColor? + ) { + stateButton.imageView.image = image + stateButton.titleLabel.text = title + stateButton.titleLabel.textColor = color + + stateButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in didTapStateButton() } + .store(in: &cancellables) + } +} + diff --git a/Sources/RequestsFeature/Views/RequestCellButton.swift b/Sources/RequestsFeature/Views/RequestCellButton.swift new file mode 100644 index 0000000000000000000000000000000000000000..22332912310da3912439621b425f614b1123a32d --- /dev/null +++ b/Sources/RequestsFeature/Views/RequestCellButton.swift @@ -0,0 +1,35 @@ +import UIKit +import Shared + +final class RequestCellButton: UIControl { + let titleLabel = UILabel() + let imageView = UIImageView() + + init() { + super.init(frame: .zero) + titleLabel.numberOfLines = 0 + titleLabel.textAlignment = .right + titleLabel.font = Fonts.Mulish.semiBold.font(size: 13.0) + + addSubview(imageView) + addSubview(titleLabel) + + imageView.snp.makeConstraints { + $0.top.greaterThanOrEqualToSuperview() + $0.left.equalToSuperview() + $0.centerY.equalToSuperview() + $0.bottom.lessThanOrEqualToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.top.greaterThanOrEqualToSuperview() + $0.left.equalTo(imageView.snp.right).offset(5) + $0.centerY.equalToSuperview() + $0.right.equalToSuperview() + $0.width.equalTo(60) + $0.bottom.lessThanOrEqualToSuperview() + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/RequestsFeature/Views/RequestFailedCell.swift b/Sources/RequestsFeature/Views/RequestFailedCell.swift deleted file mode 100644 index 85d2bffc25fb0762eaad9a4eae8ed3d4918f983d..0000000000000000000000000000000000000000 --- a/Sources/RequestsFeature/Views/RequestFailedCell.swift +++ /dev/null @@ -1,110 +0,0 @@ -import UIKit -import Shared -import Combine - -final class RequestFailedCell: UITableViewCell { - // MARK: UI - - let title = UILabel() - let button = UIButton() - let subtitle = UILabel() - let separator = UIView() - let avatar = AvatarView() - - var cancellables = Set<AnyCancellable>() - - // MARK: Lifecycle - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - setup() - } - - required init?(coder: NSCoder) { nil } - - override func prepareForReuse() { - super.prepareForReuse() - title.text = nil - subtitle.text = nil - avatar.prepareForReuse() - cancellables.removeAll() - } - - // MARK: Public - - func setup( - username: String, - nickname: String?, - createdAt: Date, - photo: Data? - ) { - title.text = nickname ?? username - subtitle.text = createdAt.asRelativeFromNow() - - avatar.set( - cornerRadius: 8, - username: nickname ?? username, - image: photo - ) - } - - // MARK: Private - - private func setup() { - selectionStyle = .none - backgroundColor = Asset.neutralWhite.color - - avatar.layer.cornerRadius = 8 - - title.textColor = Asset.accentDanger.color - title.font = Fonts.Mulish.semiBold.font(size: 14.0) - separator.backgroundColor = Asset.neutralLine.color - - button.setTitle("Tap to resend", for: .normal) - button.setTitleColor(Asset.brandPrimary.color, for: .normal) - button.titleLabel?.font = Fonts.Mulish.semiBold.font(size: 14.0) - - subtitle.font = Fonts.Mulish.regular.font(size: 10.0) - subtitle.textColor = Asset.neutralWeak.color - - contentView.addSubview(title) - contentView.addSubview(avatar) - contentView.addSubview(button) - contentView.addSubview(subtitle) - contentView.addSubview(separator) - - setupConstraints() - } - - private func setupConstraints() { - avatar.snp.makeConstraints { make in - make.width.height.equalTo(28) - make.left.equalToSuperview().offset(25) - make.centerY.equalTo(title) - } - - title.snp.makeConstraints { make in - make.bottom.equalTo(button.snp.centerY) - make.left.equalTo(avatar.snp.right).offset(10) - make.right.lessThanOrEqualTo(subtitle.snp.left).offset(-20) - } - - button.snp.makeConstraints { make in - make.right.equalToSuperview().offset(-25) - make.bottom.equalToSuperview().offset(-8) - } - - subtitle.snp.makeConstraints { make in - make.right.equalToSuperview().offset(-25) - make.bottom.equalTo(button.snp.top).offset(-3) - } - - separator.snp.makeConstraints { make in - make.height.equalTo(1) - make.top.equalTo(title.snp.bottom).offset(16) - make.left.equalToSuperview().offset(25) - make.right.equalToSuperview() - make.bottom.equalToSuperview() - } - } -} diff --git a/Sources/RequestsFeature/Views/RequestReceivedCell.swift b/Sources/RequestsFeature/Views/RequestReceivedCell.swift deleted file mode 100644 index 24567e50eea9b2fb4d7046248c7b9965e24d8fd9..0000000000000000000000000000000000000000 --- a/Sources/RequestsFeature/Views/RequestReceivedCell.swift +++ /dev/null @@ -1,152 +0,0 @@ -import UIKit -import Shared -import Combine - -final class RequestReceivedCell: UITableViewCell { - // MARK: UI - - let title = UILabel() - let subtitle = UILabel() - let separator = UIView() - let avatar = AvatarView() - let accept = UIButton() - let reject = UIButton() - let stack = UIStackView() - let verification = UIButton() - - // MARK: Properties - - var didTapAccept: (() -> Void)? - var didTapReject: (() -> Void)? - var didTapVerification: (() -> Void)? - var cancellables = Set<AnyCancellable>() - - // MARK: Lifecycle - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - setup() - } - - required init?(coder: NSCoder) { nil } - - override func prepareForReuse() { - super.prepareForReuse() - title.text = nil - subtitle.text = nil - avatar.prepareForReuse() - cancellables.removeAll() - } - - // MARK: Public - - func setup(name: String, createdAt: Date, photo: Data?, actionsHidden: Bool, verificationFailed: Bool) { - cancellables.removeAll() - - title.text = name - subtitle.text = createdAt.asRelativeFromNow() - avatar.set(cornerRadius: 8, username: name, image: photo) - - accept.publisher(for: .touchUpInside) - .sink { [unowned self] in didTapAccept?() } - .store(in: &cancellables) - - reject.publisher(for: .touchUpInside) - .sink { [unowned self] in didTapReject?() } - .store(in: &cancellables) - - verification.publisher(for: .touchUpInside) - .sink { [unowned self] in didTapVerification?() } - .store(in: &cancellables) - - stack.isHidden = actionsHidden - verification.isHidden = !actionsHidden - - if verificationFailed { - verification.setAttributedTitle(.init( - string: "Failed to verify", - attributes: [ - .underlineColor: Asset.accentDanger.color, - .foregroundColor: Asset.accentDanger.color, - .underlineStyle: NSUnderlineStyle.single.rawValue - ]), for: .normal - ) - } else { - verification.setAttributedTitle(.init( - string: "Verifying...", - attributes: [ - .underlineColor: Asset.neutralDark.color, - .foregroundColor: Asset.neutralDark.color, - .underlineStyle: NSUnderlineStyle.single.rawValue - ]), for: .normal - ) - } - } - - // MARK: Private - - private func setup() { - selectionStyle = .none - backgroundColor = Asset.neutralWhite.color - - accept.setImage(Asset.requestsAccept.image, for: .normal) - reject.setImage(Asset.requestsReject.image, for: .normal) - - title.textColor = Asset.neutralActive.color - title.font = Fonts.Mulish.semiBold.font(size: 14.0) - separator.backgroundColor = Asset.neutralLine.color - - subtitle.font = Fonts.Mulish.regular.font(size: 10.0) - subtitle.textColor = Asset.neutralWeak.color - - stack.spacing = 16 - stack.distribution = .fillEqually - stack.addArrangedSubview(accept) - stack.addArrangedSubview(reject) - - contentView.addSubview(title) - contentView.addSubview(stack) - contentView.addSubview(avatar) - contentView.addSubview(subtitle) - contentView.addSubview(separator) - contentView.addSubview(verification) - - setupConstraints() - } - - private func setupConstraints() { - avatar.snp.makeConstraints { make in - make.width.height.equalTo(28) - make.left.equalToSuperview().offset(25) - make.centerY.equalToSuperview() - } - - title.snp.makeConstraints { make in - make.top.equalTo(avatar).offset(-5) - make.left.equalTo(avatar.snp.right).offset(10) - make.right.lessThanOrEqualTo(stack.snp.left).offset(-20) - } - - subtitle.snp.makeConstraints { make in - make.top.equalTo(title.snp.bottom).offset(5) - make.left.equalTo(title) - } - - stack.snp.makeConstraints { make in - make.right.equalToSuperview().offset(-24) - make.centerY.equalToSuperview() - } - - separator.snp.makeConstraints { make in - make.left.equalToSuperview().offset(24) - make.right.equalToSuperview().offset(-24) - make.bottom.equalToSuperview() - make.height.equalTo(1) - } - - verification.snp.makeConstraints { make in - make.right.equalToSuperview().offset(-24) - make.centerY.equalToSuperview() - } - } -} diff --git a/Sources/RequestsFeature/Views/RequestReceivedEmptyCell.swift b/Sources/RequestsFeature/Views/RequestReceivedEmptyCell.swift new file mode 100644 index 0000000000000000000000000000000000000000..bf706b39c5eded757bcb02eeac4c0092c9f409e1 --- /dev/null +++ b/Sources/RequestsFeature/Views/RequestReceivedEmptyCell.swift @@ -0,0 +1,34 @@ +import UIKit +import Shared + +final class RequestReceivedEmptyCell: UICollectionViewCell { + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + titleLabel.textAlignment = .center + titleLabel.textColor = Asset.neutralWeak.color + titleLabel.font = Fonts.Mulish.regular.font(size: 14.0) + titleLabel.text = Localized.Requests.Received.placeholder + + contentView.addSubview(titleLabel) + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(50) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview().offset(-50) + } + } + + required init?(coder: NSCoder) { nil } + + override func prepareForReuse() { + super.prepareForReuse() + titleLabel.text = nil + } + + func setup(title: String) { + titleLabel.text = title + } +} diff --git a/Sources/RequestsFeature/Views/RequestReceivedEmptyView.swift b/Sources/RequestsFeature/Views/RequestReceivedEmptyView.swift deleted file mode 100644 index a274789b7a838fa50400079cfad0ab25377cd974..0000000000000000000000000000000000000000 --- a/Sources/RequestsFeature/Views/RequestReceivedEmptyView.swift +++ /dev/null @@ -1,47 +0,0 @@ -import UIKit -import Shared - -final class RequestReceivedEmptyView: UIView { - let label = UILabel() - let icon = UIImageView() - let stack = UIStackView() - - init() { - super.init(frame: .zero) - setup() - } - - required init?(coder: NSCoder) { nil } - - private func setup() { - icon.contentMode = .center - icon.image = Asset.requestsReceivedPlaceholder.image - - let paragraph = NSMutableParagraphStyle() - paragraph.lineHeightMultiple = 1.2 - paragraph.alignment = .center - - label.numberOfLines = 0 - label.attributedText = NSAttributedString( - string: Localized.Requests.Received.placeholder, - attributes: [ - .paragraphStyle: paragraph, - .foregroundColor: Asset.neutralActive.color, - .font: Fonts.Mulish.bold.font(size: 24.0) - ] - ) - - stack.axis = .vertical - stack.spacing = 24 - stack.addArrangedSubview(icon) - stack.addArrangedSubview(label) - - addSubview(stack) - - stack.snp.makeConstraints { make in - make.centerY.equalToSuperview().multipliedBy(0.8) - make.left.equalToSuperview().offset(24) - make.right.equalToSuperview().offset(-24) - } - } -} diff --git a/Sources/RequestsFeature/Views/RequestSegmentedButton.swift b/Sources/RequestsFeature/Views/RequestSegmentedButton.swift new file mode 100644 index 0000000000000000000000000000000000000000..22bd81e2da7387b54c70f93f8c8528cb4d0dc031 --- /dev/null +++ b/Sources/RequestsFeature/Views/RequestSegmentedButton.swift @@ -0,0 +1,32 @@ +import UIKit +import Shared + +final class RequestSegmentedButton: UIControl { + let titleLabel = UILabel() + let imageView = UIImageView() + + init() { + super.init(frame: .zero) + + titleLabel.textAlignment = .center + titleLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) + imageView.setContentCompressionResistancePriority(.required, for: .vertical) + titleLabel.setContentCompressionResistancePriority(.required, for: .vertical) + + addSubview(titleLabel) + addSubview(imageView) + + imageView.snp.makeConstraints { + $0.top.equalToSuperview().offset(7.5) + $0.centerX.equalTo(titleLabel) + } + + titleLabel.snp.makeConstraints { + $0.top.equalTo(imageView.snp.bottom).offset(2) + $0.centerX.equalToSuperview() + $0.bottom.equalToSuperview().offset(-7.5) + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/RequestsFeature/Views/RequestSentCell.swift b/Sources/RequestsFeature/Views/RequestSentCell.swift deleted file mode 100644 index a534c9a03ca3ad81efb8129135e1104888a88dbc..0000000000000000000000000000000000000000 --- a/Sources/RequestsFeature/Views/RequestSentCell.swift +++ /dev/null @@ -1,108 +0,0 @@ -import UIKit -import Shared -import Combine - -final class RequestSentCell: UITableViewCell { - // MARK: UI - - let title = UILabel() - let button = UIButton() - let subtitle = UILabel() - let separator = UIView() - let avatar = AvatarView() - - var cancellables = Set<AnyCancellable>() - - // MARK: Lifecycle - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - setup() - } - - required init?(coder: NSCoder) { nil } - - override func prepareForReuse() { - super.prepareForReuse() - title.text = nil - subtitle.text = nil - avatar.prepareForReuse() - cancellables.removeAll() - } - - // MARK: Public - - func setup( - username: String, - nickname: String?, - createdAt: Date, - photo: Data? - ) { - title.text = nickname ?? username - subtitle.text = createdAt.asRelativeFromNow() - - avatar.set( - cornerRadius: 8, - username: nickname ?? username, - image: photo - ) - } - - // MARK: Private - - private func setup() { - selectionStyle = .none - backgroundColor = Asset.neutralWhite.color - - title.textColor = Asset.neutralActive.color - title.font = Fonts.Mulish.semiBold.font(size: 14.0) - separator.backgroundColor = Asset.neutralLine.color - - button.setTitle("Resend", for: .normal) - button.setTitleColor(Asset.brandPrimary.color, for: .normal) - button.titleLabel?.font = Fonts.Mulish.semiBold.font(size: 14.0) - - subtitle.font = Fonts.Mulish.regular.font(size: 10.0) - subtitle.textColor = Asset.neutralWeak.color - - contentView.addSubview(title) - contentView.addSubview(avatar) - contentView.addSubview(button) - contentView.addSubview(subtitle) - contentView.addSubview(separator) - - setupConstraints() - } - - private func setupConstraints() { - avatar.snp.makeConstraints { make in - make.width.height.equalTo(28) - make.left.equalToSuperview().offset(25) - make.centerY.equalTo(title) - } - - title.snp.makeConstraints { make in - make.bottom.equalTo(button.snp.centerY) - make.left.equalTo(avatar.snp.right).offset(10) - make.right.lessThanOrEqualTo(subtitle.snp.left).offset(-20) - } - - button.snp.makeConstraints { make in - make.right.equalToSuperview().offset(-25) - make.bottom.equalToSuperview().offset(-12) - } - - subtitle.snp.makeConstraints { make in - make.right.equalToSuperview().offset(-25) - make.bottom.equalTo(button.snp.top).offset(-5) - } - - separator.snp.makeConstraints { make in - make.height.equalTo(1) - make.top.equalTo(title.snp.bottom).offset(16) - make.left.equalToSuperview().offset(25) - make.right.equalToSuperview() - make.bottom.equalToSuperview() - } - } -} diff --git a/Sources/RequestsFeature/Views/RequestsContainerView.swift b/Sources/RequestsFeature/Views/RequestsContainerView.swift index b9e713d8ddb2e41366f8ca9ab72908a49d6ee9e7..80cd844a803dd38d4cce568d5d077bb0b2e1a66d 100644 --- a/Sources/RequestsFeature/Views/RequestsContainerView.swift +++ b/Sources/RequestsFeature/Views/RequestsContainerView.swift @@ -2,76 +2,57 @@ import UIKit import Shared final class RequestsContainerView: UIView { - // MARK: UI - let scrollView = UIScrollView() - let sent = RequestsSentController() - let failed = RequestsFailedController() - let received = RequestsReceivedController() + let sentController = RequestsSentController() + let failedController = RequestsFailedController() + let receivedController = RequestsReceivedController() let segmentedControl = RequestsSegmentedControl() - // MARK: Lifecycle - init() { super.init(frame: .zero) - setup() - } - - required init?(coder: NSCoder) { nil } - - // MARK: Private - - private func setup() { scrollView.bounces = false scrollView.isScrollEnabled = false - backgroundColor = Asset.neutralActive.color + backgroundColor = Asset.neutralWhite.color + scrollView.addSubview(sentController.view) + scrollView.addSubview(failedController.view) + scrollView.addSubview(receivedController.view) addSubview(segmentedControl) addSubview(scrollView) - scrollView.addSubview(sent.view) - scrollView.addSubview(failed.view) - scrollView.addSubview(received.view) - - scrollView.addSubview(sent.emptyView) - scrollView.addSubview(received.emptyView) - - sent.emptyView.snp.makeConstraints { $0.edges.equalTo(sent.view) } - received.emptyView.snp.makeConstraints { $0.edges.equalTo(received.view) } - - setupConstraints() - } - - private func setupConstraints() { - scrollView.snp.makeConstraints { $0.edges.equalToSuperview() } + scrollView.snp.makeConstraints { + $0.edges.equalToSuperview() + } - segmentedControl.snp.makeConstraints { make in - make.top.equalTo(safeAreaLayoutGuide) - make.left.equalToSuperview() - make.right.equalToSuperview() - make.height.equalTo(50) + segmentedControl.snp.makeConstraints { + $0.top.equalTo(safeAreaLayoutGuide).offset(10) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.height.equalTo(60) } - received.view.snp.makeConstraints { make in - make.top.equalTo(segmentedControl.snp.bottom) - make.left.equalToSuperview() - make.right.equalTo(sent.view.snp.left) - make.bottom.equalTo(self) - make.width.equalTo(self) + receivedController.view.snp.makeConstraints { + $0.top.equalTo(segmentedControl.snp.bottom) + $0.left.equalToSuperview() + $0.right.equalTo(sentController.view.snp.left) + $0.bottom.equalTo(self) + $0.width.equalTo(self) } - sent.view.snp.makeConstraints { make in - make.top.equalTo(segmentedControl.snp.bottom) - make.right.equalTo(failed.view.snp.left) - make.bottom.equalTo(self) - make.width.equalTo(self) + sentController.view.snp.makeConstraints { + $0.top.equalTo(segmentedControl.snp.bottom) + $0.right.equalTo(failedController.view.snp.left) + $0.bottom.equalTo(self) + $0.width.equalTo(self) } - failed.view.snp.makeConstraints { make in - make.top.equalTo(segmentedControl.snp.bottom) - make.right.equalToSuperview() - make.bottom.equalTo(self) - make.width.equalTo(self) + failedController.view.snp.makeConstraints { + $0.top.equalTo(segmentedControl.snp.bottom) + $0.right.equalToSuperview() + $0.bottom.equalTo(self) + $0.width.equalTo(self) } } + + required init?(coder: NSCoder) { nil } } diff --git a/Sources/RequestsFeature/Views/RequestsFailedView.swift b/Sources/RequestsFeature/Views/RequestsFailedView.swift new file mode 100644 index 0000000000000000000000000000000000000000..76f4adadd7e15d27781e944bd957427629c74379 --- /dev/null +++ b/Sources/RequestsFeature/Views/RequestsFailedView.swift @@ -0,0 +1,40 @@ +import UIKit +import Shared + +final class RequestsFailedView: UIView { + let titleLabel = UILabel() + + lazy var collectionView: UICollectionView = { + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.backgroundColor = Asset.neutralWhite.color + config.showsSeparators = false + let layout = UICollectionViewCompositionalLayout.list(using: config) + let collectionView = UICollectionView(frame: bounds, collectionViewLayout: layout) + collectionView.contentInset = .init(top: 15, left: 0, bottom: 0, right: 0) + return collectionView + }() + + init() { + super.init(frame: .zero) + + titleLabel.textAlignment = .center + titleLabel.text = Localized.Requests.Failed.empty + titleLabel.textColor = Asset.neutralWeak.color + titleLabel.font = Fonts.Mulish.regular.font(size: 14.0) + + addSubview(titleLabel) + addSubview(collectionView) + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(48.5) + $0.left.equalToSuperview().offset(24) + $0.right.equalToSuperview().offset(-24) + } + + collectionView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/RequestsFeature/Views/RequestsHiddenSectionHeader.swift b/Sources/RequestsFeature/Views/RequestsHiddenSectionHeader.swift new file mode 100644 index 0000000000000000000000000000000000000000..f6fab012ee3b934ae1adbd31dd54c10857fe9e7d --- /dev/null +++ b/Sources/RequestsFeature/Views/RequestsHiddenSectionHeader.swift @@ -0,0 +1,64 @@ +import UIKit +import Shared +import Combine + +final class RequestsHiddenSectionHeader: UICollectionReusableView { + let titleLabel = UILabel() + let separatorView = UIView() + let switcherView = UISwitch() + var cancellables = Set<AnyCancellable>() + + override func prepareForReuse() { + super.prepareForReuse() + cancellables.removeAll() + } + + override init(frame: CGRect) { + super.init(frame: frame) + + titleLabel.text = Localized.Requests.Received.hidden + titleLabel.textColor = Asset.neutralActive.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 16.0) + separatorView.backgroundColor = Asset.neutralLine.color + switcherView.onTintColor = Asset.brandPrimary.color + + addSubview(titleLabel) + addSubview(switcherView) + addSubview(separatorView) + + separatorView.snp.makeConstraints { + $0.height.equalTo(1) + $0.top.equalToSuperview().offset(10) + $0.left.equalToSuperview().offset(24) + $0.right.equalToSuperview().offset(-24) + } + + titleLabel.snp.makeConstraints { + $0.top.equalTo(separatorView.snp.bottom).offset(30) + $0.left.equalToSuperview().offset(24) + $0.bottom.equalToSuperview().offset(-20) + } + + switcherView.snp.makeConstraints { + $0.centerY.equalTo(titleLabel) + $0.right.equalToSuperview().offset(-24) + } + } + + required init?(coder: NSCoder) { nil } +} + +final class RequestsBlankSectionHeader: UICollectionReusableView { + private let view = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(view) + view.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.height.equalTo(1) + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/RequestsFeature/Views/RequestsReceivedView.swift b/Sources/RequestsFeature/Views/RequestsReceivedView.swift new file mode 100644 index 0000000000000000000000000000000000000000..d4df66fea6dae47ee46dac61451f45a05fa2b4e7 --- /dev/null +++ b/Sources/RequestsFeature/Views/RequestsReceivedView.swift @@ -0,0 +1,52 @@ +import UIKit +import Shared + +final class RequestsReceivedView: UIView { + lazy var collectionView: UICollectionView = { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(1) + ) + + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(1) + ) + + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 5 + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10) + + let headerFooterSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(44) + ) + + let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerFooterSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ) + + section.boundarySupplementaryItems = [sectionHeader] + let layout = UICollectionViewCompositionalLayout(section: section) + + let collectionView = UICollectionView(frame: bounds, collectionViewLayout: layout) + collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + collectionView.backgroundColor = Asset.neutralWhite.color + collectionView.contentInset = .init(top: 15, left: 0, bottom: 0, right: 0) + return collectionView + }() + + init() { + super.init(frame: .zero) + addSubview(collectionView) + } + + required init?(coder: NSCoder) { nil } +} + diff --git a/Sources/RequestsFeature/Views/RequestsSegmentedControl.swift b/Sources/RequestsFeature/Views/RequestsSegmentedControl.swift index d0f3fa1b246d65f5cbafc627b0e30cbc1da76b6a..0bdea739207c2b2a73cba676d40df85eac85d946 100644 --- a/Sources/RequestsFeature/Views/RequestsSegmentedControl.swift +++ b/Sources/RequestsFeature/Views/RequestsSegmentedControl.swift @@ -3,124 +3,92 @@ import Shared import SnapKit final class RequestsSegmentedControl: UIView { - enum Filter { - case received - case sent - case failed - } - - // MARK: UI - - let track = UIView() - let trackIndicator = UIView() - var leftConstraint: Constraint? - let received = UIButton() - let sent = UIButton() - let failed = UIButton() - let stack = UIStackView() - - // MARK: Lifecycle + private let trackView = UIView() + private let stackView = UIStackView() + private var leftConstraint: Constraint? + private let trackIndicatorView = UIView() + private(set) var sentRequestsButton = RequestSegmentedButton() + private(set) var failedRequestsButton = RequestSegmentedButton() + private(set) var receivedRequestsButton = RequestSegmentedButton() init() { super.init(frame: .zero) - setup() - } - - required init?(coder: NSCoder) { nil } - - // MARK: Public - - func didChooseFilter(_ filter: Filter) { - switch filter { - case .received: - sent.setTitleColor(Asset.neutralWeak.color, for: .normal) - failed.setTitleColor(Asset.neutralWeak.color, for: .normal) - received.setTitleColor(Asset.brandPrimary.color, for: .normal) - - sent.titleLabel?.font = Fonts.Mulish.regular.font(size: 14.0) - failed.titleLabel?.font = Fonts.Mulish.regular.font(size: 14.0) - received.titleLabel?.font = Fonts.Mulish.semiBold.font(size: 14.0) - - case .sent: - sent.setTitleColor(Asset.brandPrimary.color, for: .normal) - failed.setTitleColor(Asset.neutralWeak.color, for: .normal) - received.setTitleColor(Asset.neutralWeak.color, for: .normal) - - sent.titleLabel?.font = Fonts.Mulish.semiBold.font(size: 14.0) - failed.titleLabel?.font = Fonts.Mulish.regular.font(size: 14.0) - received.titleLabel?.font = Fonts.Mulish.regular.font(size: 14.0) + trackView.backgroundColor = Asset.neutralLine.color + trackIndicatorView.backgroundColor = Asset.brandPrimary.color + + sentRequestsButton.titleLabel.text = Localized.Requests.Sent.title + failedRequestsButton.titleLabel.text = Localized.Requests.Failed.title + receivedRequestsButton.titleLabel.text = Localized.Requests.Received.title + + sentRequestsButton.titleLabel.textColor = Asset.neutralDisabled.color + failedRequestsButton.titleLabel.textColor = Asset.neutralDisabled.color + receivedRequestsButton.titleLabel.textColor = Asset.brandPrimary.color + + sentRequestsButton.imageView.tintColor = Asset.neutralDisabled.color + failedRequestsButton.imageView.tintColor = Asset.neutralDisabled.color + receivedRequestsButton.imageView.tintColor = Asset.brandPrimary.color + + sentRequestsButton.imageView.image = Asset.requestsTabSent.image + failedRequestsButton.imageView.image = Asset.requestsTabFailed.image + receivedRequestsButton.imageView.image = Asset.requestsTabReceived.image + + stackView.addArrangedSubview(receivedRequestsButton) + stackView.addArrangedSubview(sentRequestsButton) + stackView.addArrangedSubview(failedRequestsButton) + stackView.distribution = .fillEqually + stackView.backgroundColor = Asset.neutralWhite.color + + addSubview(stackView) + addSubview(trackView) + trackView.addSubview(trackIndicatorView) + + stackView.snp.makeConstraints { + $0.edges.equalToSuperview() + } - case .failed: - sent.setTitleColor(Asset.neutralWeak.color, for: .normal) - failed.setTitleColor(Asset.brandPrimary.color, for: .normal) - received.setTitleColor(Asset.neutralWeak.color, for: .normal) + trackView.snp.makeConstraints { + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + $0.height.equalTo(2) + } - sent.titleLabel?.font = Fonts.Mulish.regular.font(size: 14.0) - failed.titleLabel?.font = Fonts.Mulish.semiBold.font(size: 14.0) - received.titleLabel?.font = Fonts.Mulish.regular.font(size: 14.0) + trackIndicatorView.snp.makeConstraints { + $0.top.equalToSuperview() + leftConstraint = $0.left.equalToSuperview().constraint + $0.width.equalToSuperview().dividedBy(3) + $0.bottom.equalToSuperview() } - } - func updateLeftConstraint(_ percentage: CGFloat) { - leftConstraint?.update(offset: percentage * (bounds.width / 3)) + sentRequestsButton.accessibilityIdentifier = Localized.Accessibility.Requests.Sent.tab + failedRequestsButton.accessibilityIdentifier = Localized.Accessibility.Requests.Failed.tab + receivedRequestsButton.accessibilityIdentifier = Localized.Accessibility.Requests.Received.tab } - // MARK: Private - - private func setup() { - sent.setTitle(Localized.Requests.Sent.title, for: .normal) - failed.setTitle(Localized.Requests.Failed.title, for: .normal) - received.setTitle(Localized.Requests.Received.title, for: .normal) - - sent.setTitleColor(Asset.neutralWeak.color, for: .normal) - failed.setTitleColor(Asset.neutralWeak.color, for: .normal) - received.setTitleColor(Asset.brandPrimary.color, for: .normal) - - sent.titleLabel?.font = Fonts.Mulish.regular.font(size: 14.0) - failed.titleLabel?.font = Fonts.Mulish.regular.font(size: 14.0) - received.titleLabel?.font = Fonts.Mulish.semiBold.font(size: 14.0) - - track.backgroundColor = Asset.neutralLine.color - trackIndicator.backgroundColor = Asset.brandPrimary.color - - stack.addArrangedSubview(received) - stack.addArrangedSubview(sent) - stack.addArrangedSubview(failed) + required init?(coder: NSCoder) { nil } - stack.distribution = .fillEqually - stack.backgroundColor = Asset.neutralWhite.color + func updateSwipePercentage(_ percentageScrolled: CGFloat) { + let amountOfTabs = 3.0 + let tabWidth = bounds.width / amountOfTabs + let leftOffset = percentageScrolled * tabWidth - addSubview(stack) - addSubview(track) - track.addSubview(trackIndicator) + leftConstraint?.update(offset: leftOffset) - setupConstraints() + let receivedPercentage = percentageScrolled > 1 ? 1 : percentageScrolled + let failedPercentage = percentageScrolled <= 1 ? 0 : percentageScrolled - 1 + let sentPercentage = percentageScrolled > 1 ? 1 - (percentageScrolled-1) : percentageScrolled - sent.accessibilityIdentifier = Localized.Accessibility.Requests.Sent.tab - failed.accessibilityIdentifier = Localized.Accessibility.Requests.Failed.tab - received.accessibilityIdentifier = Localized.Accessibility.Requests.Received.tab - } + let sentColor = UIColor.fade(from: Asset.neutralDisabled.color, to: Asset.brandPrimary.color, pcent: sentPercentage) + let failedColor = UIColor.fade(from: Asset.neutralDisabled.color, to: Asset.brandPrimary.color, pcent: failedPercentage) + let receivedColor = UIColor.fade(from: Asset.brandPrimary.color, to: Asset.neutralDisabled.color, pcent: receivedPercentage) - private func setupConstraints() { - stack.snp.makeConstraints { make in - make.top.equalToSuperview() - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.equalToSuperview() - } + sentRequestsButton.imageView.tintColor = sentColor + sentRequestsButton.titleLabel.textColor = sentColor - track.snp.makeConstraints { make in - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.equalToSuperview() - make.height.equalTo(1) - } + failedRequestsButton.imageView.tintColor = failedColor + failedRequestsButton.titleLabel.textColor = failedColor - trackIndicator.snp.makeConstraints { make in - make.top.equalToSuperview().offset(-1) - leftConstraint = make.left.equalToSuperview().constraint - make.bottom.equalToSuperview() - make.width.equalTo(track).multipliedBy(0.3) - } + receivedRequestsButton.imageView.tintColor = receivedColor + receivedRequestsButton.titleLabel.textColor = receivedColor } } diff --git a/Sources/RequestsFeature/Views/RequestsSentView.swift b/Sources/RequestsFeature/Views/RequestsSentView.swift new file mode 100644 index 0000000000000000000000000000000000000000..9150c06bcb745e56036ff809568a4a4fbd2f351e --- /dev/null +++ b/Sources/RequestsFeature/Views/RequestsSentView.swift @@ -0,0 +1,53 @@ +import UIKit +import Shared + +final class RequestsSentView: UIView { + let titleLabel = UILabel() + let connectionsButton = CapsuleButton() + + lazy var collectionView: UICollectionView = { + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.backgroundColor = Asset.neutralWhite.color + config.showsSeparators = false + let layout = UICollectionViewCompositionalLayout.list(using: config) + let collectionView = UICollectionView(frame: bounds, collectionViewLayout: layout) + collectionView.contentInset = .init(top: 15, left: 0, bottom: 0, right: 0) + return collectionView + }() + + init() { + super.init(frame: .zero) + + titleLabel.textAlignment = .center + titleLabel.text = Localized.Requests.Sent.empty + titleLabel.textColor = Asset.neutralWeak.color + titleLabel.font = Fonts.Mulish.regular.font(size: 14.0) + + connectionsButton.set( + style: .brandColored, + title: Localized.Requests.Sent.action + ) + + addSubview(titleLabel) + addSubview(connectionsButton) + addSubview(collectionView) + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(48.5) + $0.left.equalToSuperview().offset(24) + $0.right.equalToSuperview().offset(-24) + } + + connectionsButton.snp.makeConstraints { + $0.left.equalToSuperview().offset(24) + $0.right.equalToSuperview().offset(-24) + $0.bottom.equalTo(safeAreaLayoutGuide).offset(-16) + } + + collectionView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/RequestsFeature/Views/VerifyingView.swift b/Sources/RequestsFeature/Views/VerifyingView.swift deleted file mode 100644 index 2c98af410e7ebd5053c5547919c9b3bbd77a1de1..0000000000000000000000000000000000000000 --- a/Sources/RequestsFeature/Views/VerifyingView.swift +++ /dev/null @@ -1,55 +0,0 @@ -import UIKit -import Shared - -final class VerifyingView: UIView { - let title = UILabel() - let subtitle = UILabel() - let icon = UIImageView() - let stack = UIStackView() - let action = CapsuleButton() - - init() { - super.init(frame: .zero) - setup() - } - - required init?(coder: NSCoder) { nil } - - private func setup() { - layer.cornerRadius = 15 - backgroundColor = Asset.neutralWhite.color - layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - - subtitle.numberOfLines = 0 - icon.contentMode = .center - icon.image = Asset.popupNegative.image - title.textColor = Asset.neutralDark.color - subtitle.textColor = Asset.neutralWeak.color - - title.textAlignment = .center - subtitle.textAlignment = .center - title.text = "Verifying" - subtitle.text = "We are working on verifying the request to make sure it is not a spam. Please check again shortly." - title.font = Fonts.Mulish.semiBold.font(size: 18.0) - subtitle.font = Fonts.Mulish.semiBold.font(size: 14.0) - - action.setStyle(.brandColored) - action.setTitle("OK", for: .normal) - - stack.spacing = 20 - stack.axis = .vertical - stack.addArrangedSubview(icon) - stack.addArrangedSubview(title) - stack.addArrangedSubview(subtitle) - stack.addArrangedSubview(action) - - addSubview(stack) - - stack.snp.makeConstraints { make in - make.top.equalToSuperview().offset(32) - make.left.equalToSuperview().offset(30) - make.right.equalToSuperview().offset(-30) - make.bottom.equalToSuperview().offset(-40) - } - } -} diff --git a/Sources/RestoreFeature/Controllers/RestoreController.swift b/Sources/RestoreFeature/Controllers/RestoreController.swift index e51c2273383f7e49a08b1be4c7ba3f371501e5e1..a77cc3a3ac5bb90858ae32f0f2c3b833b4e3edef 100644 --- a/Sources/RestoreFeature/Controllers/RestoreController.swift +++ b/Sources/RestoreFeature/Controllers/RestoreController.swift @@ -1,7 +1,7 @@ import UIKit import Models import Shared -import Popup +import DrawerFeature import Combine import DependencyInjection @@ -12,7 +12,7 @@ public final class RestoreController: UIViewController { private let viewModel: RestoreViewModel private var cancellables = Set<AnyCancellable>() - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() public init(_ ndf: String, _ settings: RestoreSettings) { viewModel = .init(ndf: ndf, settings: settings) @@ -94,36 +94,35 @@ public final class RestoreController: UIViewController { extension RestoreController { private func presentWarning() { - let actionButton = CapsuleButton() - actionButton.set( - style: .brandColored, - title: Localized.Restore.Warning.action - ) + let actionButton = DrawerCapsuleButton(model: .init( + title: Localized.Restore.Warning.action, + style: .brandColored + )) - let popup = BottomPopup(with: [ - PopupLabel( + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), text: Localized.Restore.Warning.title, color: Asset.neutralActive.color, alignment: .left, spacingAfter: 19 ), - PopupLabelAttributed( + DrawerText( text: Localized.Restore.Warning.subtitle, spacingAfter: 37 ), - PopupStackView(views: [actionButton]) + actionButton ]) - actionButton.publisher(for: .touchUpInside) + actionButton.action .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } } diff --git a/Sources/RestoreFeature/Controllers/RestoreListController.swift b/Sources/RestoreFeature/Controllers/RestoreListController.swift index c4e31d9daf904c9f2ddd59fe225aec49beb63e58..43767aed6f40e139a002be9ee31dc96efb9b6b1f 100644 --- a/Sources/RestoreFeature/Controllers/RestoreListController.swift +++ b/Sources/RestoreFeature/Controllers/RestoreListController.swift @@ -1,5 +1,5 @@ import HUD -import Popup +import DrawerFeature import Shared import UIKit import Combine @@ -14,7 +14,7 @@ public final class RestoreListController: UIViewController { private let ndf: String private let viewModel = RestoreListViewModel() private var cancellables = Set<AnyCancellable>() - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() public override func loadView() { view = screenView @@ -89,36 +89,33 @@ public final class RestoreListController: UIViewController { extension RestoreListController { private func presentWarning() { - let actionButton = CapsuleButton() - actionButton.set( - style: .brandColored, - title: Localized.Restore.Warning.action - ) + let actionButton = DrawerCapsuleButton(model: .init( + title: Localized.Restore.Warning.action, + style: .brandColored + )) - let popup = BottomPopup(with: [ - PopupLabel( + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), text: Localized.Restore.Warning.title, - color: Asset.neutralActive.color, - alignment: .left, spacingAfter: 19 ), - PopupLabelAttributed( + DrawerText( text: Localized.Restore.Warning.subtitle, spacingAfter: 37 ), - PopupStackView(views: [actionButton]) + actionButton ]) - actionButton.publisher(for: .touchUpInside) + actionButton.action .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } } diff --git a/Sources/RestoreFeature/Coordinator/RestoreCoordinator.swift b/Sources/RestoreFeature/Coordinator/RestoreCoordinator.swift index 6876e8ceff5bbc7268bc13c71561f1664575fb66..2183035bca4a57ee9fd4435b2656fed7123de814 100644 --- a/Sources/RestoreFeature/Coordinator/RestoreCoordinator.swift +++ b/Sources/RestoreFeature/Coordinator/RestoreCoordinator.swift @@ -6,7 +6,7 @@ import Presentation public protocol RestoreCoordinating { func toChats(from: UIViewController) func toSuccess(from: UIViewController) - func toPopup(_: UIViewController, from: UIViewController) + func toDrawer(_: UIViewController, from: UIViewController) func toPassphrase(from: UIViewController, _: @escaping StringClosure) func toRestore(using: String, with: RestoreSettings, from: UIViewController) } @@ -54,8 +54,8 @@ public extension RestoreCoordinator { replacePresenter.present(screen, from: parent) } - func toPopup(_ popup: UIViewController, from parent: UIViewController) { - bottomPresenter.present(popup, from: parent) + func toDrawer(_ drawer: UIViewController, from parent: UIViewController) { + bottomPresenter.present(drawer, from: parent) } func toPassphrase( diff --git a/Sources/ScanFeature/Controllers/ScanContainerController.swift b/Sources/ScanFeature/Controllers/ScanContainerController.swift index cd64aee603a91dbcb94c9fd7f5fe999728daae02..f02b4ef89d5f757a4bfe19dd033ee3ba0616695a 100644 --- a/Sources/ScanFeature/Controllers/ScanContainerController.swift +++ b/Sources/ScanFeature/Controllers/ScanContainerController.swift @@ -1,5 +1,5 @@ import UIKit -import Popup +import DrawerFeature import Theme import Shared import Combine @@ -12,7 +12,7 @@ public final class ScanContainerController: UIViewController { lazy private var screenView = ScanContainerView() private var cancellables = Set<AnyCancellable>() - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() public override func loadView() { view = screenView @@ -47,16 +47,20 @@ public final class ScanContainerController: UIViewController { private func setupNavigationBar() { navigationItem.backButtonTitle = "" - let titleLabel = UILabel() + let titleLabel = UILabel() titleLabel.text = "QR Code" titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) titleLabel.textColor = Asset.neutralWhite.color - let back = UIButton.back(color: Asset.neutralWhite.color) - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) + 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: [back, titleLabel]) + customView: UIStackView(arrangedSubviews: [menuButton, titleLabel]) ) } @@ -74,8 +78,8 @@ public final class ScanContainerController: UIViewController { }.store(in: &cancellables) } - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) + @objc private func didTapMenu() { + coordinator.toSideMenu(from: self) } public func scrollViewDidScroll(_ scrollView: UIScrollView) { @@ -90,18 +94,18 @@ public final class ScanContainerController: UIViewController { let actionButton = CapsuleButton() actionButton.set( style: .seeThrough, - title: Localized.Settings.InfoPopUp.action + title: Localized.Settings.InfoDrawer.action ) - let popup = BottomPopup(with: [ - PopupLabel( + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), text: title, color: Asset.neutralActive.color, alignment: .left, spacingAfter: 19 ), - PopupLabel( + DrawerText( font: Fonts.Mulish.regular.font(size: 16.0), text: subtitle, color: Asset.neutralBody.color, @@ -109,19 +113,22 @@ public final class ScanContainerController: UIViewController { lineHeightMultiple: 1.1, spacingAfter: 37 ), - PopupStackView(views: [actionButton, FlexibleSpace()]) + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) ]) actionButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } } diff --git a/Sources/ScanFeature/Coordinator/ScanCoordinator.swift b/Sources/ScanFeature/Coordinator/ScanCoordinator.swift index 782d5b62980f165f27aa813be6294e6cdac221e3..86ed4bc1f15f158c27cff79fc121f6ec6761b8e8 100644 --- a/Sources/ScanFeature/Coordinator/ScanCoordinator.swift +++ b/Sources/ScanFeature/Coordinator/ScanCoordinator.swift @@ -1,52 +1,63 @@ import UIKit import Models +import MenuFeature import Presentation import ContactFeature public protocol ScanCoordinating { func toContacts(from: UIViewController) func toRequests(from: UIViewController) + func toSideMenu(from: UIViewController) func toContact(_: Contact, from: UIViewController) - func toPopup(_: UIViewController, from: UIViewController) + func toDrawer(_: UIViewController, from: UIViewController) } -public struct ScanCoordinator { +public struct ScanCoordinator: ScanCoordinating { var pushPresenter: Presenting = PushPresenter() + var sidePresenter: Presenting = SideMenuPresenter() var bottomPresenter: Presenting = BottomPresenter() var replacePresenter: Presenting = ReplacePresenter(mode: .replaceLast) var contactsFactory: () -> UIViewController var requestsFactory: () -> UIViewController var contactFactory: (Contact) -> UIViewController + var sideMenuFactory: (MenuItem, UIViewController) -> UIViewController public init( contactsFactory: @escaping () -> UIViewController, requestsFactory: @escaping () -> UIViewController, - contactFactory: @escaping (Contact) -> UIViewController + contactFactory: @escaping (Contact) -> UIViewController, + sideMenuFactory: @escaping (MenuItem, UIViewController) -> UIViewController ) { self.contactFactory = contactFactory self.contactsFactory = contactsFactory self.requestsFactory = requestsFactory + self.sideMenuFactory = sideMenuFactory } } -extension ScanCoordinator: ScanCoordinating { - public func toRequests(from parent: UIViewController) { +public extension ScanCoordinator { + func toRequests(from parent: UIViewController) { let screen = requestsFactory() replacePresenter.present(screen, from: parent) } - public func toContacts(from parent: UIViewController) { + func toContacts(from parent: UIViewController) { let screen = contactsFactory() replacePresenter.present(screen, from: parent) } - public func toContact(_ contact: Contact, from parent: UIViewController) { + func toContact(_ contact: Contact, from parent: UIViewController) { let screen = contactFactory(contact) pushPresenter.present(screen, from: parent) } - public func toPopup(_ popup: UIViewController, from parent: UIViewController) { - bottomPresenter.present(popup, from: parent) + public func toDrawer(_ drawer: UIViewController, from parent: UIViewController) { + bottomPresenter.present(drawer, from: parent) + } + + func toSideMenu(from parent: UIViewController) { + let screen = sideMenuFactory(.scan, parent) + sidePresenter.present(screen, from: parent) } } diff --git a/Sources/ScanFeature/Views/ScanView.swift b/Sources/ScanFeature/Views/ScanView.swift index 18713f5c51e29ad72dfa04015b10c936f0790eb8..a21ceb854bea263ae0912541b4aff23a42b29b78 100644 --- a/Sources/ScanFeature/Views/ScanView.swift +++ b/Sources/ScanFeature/Views/ScanView.swift @@ -63,7 +63,7 @@ final class ScanView: UIView { animationView.isHidden = true actionButton.isHidden = true iconImageView.isHidden = false - iconImageView.image = Asset.scanSuccess.image + iconImageView.image = Asset.sharedSuccess.image text = Localized.Scan.Status.success overlay.updateCornerColor(Asset.accentSuccess.color) diff --git a/Sources/SearchFeature/Controllers/SearchController.swift b/Sources/SearchFeature/Controllers/SearchController.swift index 3172c62ac3e7853f79e960732c4d4815bb149a49..e192abf4d0005e266d2e3f609067dd52278cb16f 100644 --- a/Sources/SearchFeature/Controllers/SearchController.swift +++ b/Sources/SearchFeature/Controllers/SearchController.swift @@ -5,9 +5,17 @@ import Shared import Combine import DependencyInjection import ScrollViewController -import Popup +import DrawerFeature +import Models +import Defaults +import Countries public final class SearchController: UIViewController { + @KeyObject(.email, defaultValue: nil) var email: String? + @KeyObject(.phone, defaultValue: nil) var phone: String? + @KeyObject(.sharingEmail, defaultValue: false) var isSharingEmail: Bool + @KeyObject(.sharingPhone, defaultValue: false) var isSharingPhone: Bool + @Dependency private var hud: HUDType @Dependency private var coordinator: SearchCoordinating @Dependency private var statusBarController: StatusBarStyleControlling @@ -17,40 +25,43 @@ public final class SearchController: UIViewController { let actionButton = CapsuleButton() actionButton.set( style: .seeThrough, - title: Localized.ContactSearch.Placeholder.Popup.action + title: Localized.ContactSearch.Placeholder.Drawer.action ) - let popup = BottomPopup(with: [ - PopupLabel( + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), - text: Localized.ContactSearch.Placeholder.Popup.title, + text: Localized.ContactSearch.Placeholder.Drawer.title, color: Asset.neutralActive.color, alignment: .left, spacingAfter: 19 ), - PopupLinkText( - text: Localized.ContactSearch.Placeholder.Popup.subtitle, + DrawerLinkText( + text: Localized.ContactSearch.Placeholder.Drawer.subtitle, urlString: "https://links.xx.network/adrp", spacingAfter: 37 ), - PopupStackView(views: [actionButton, FlexibleSpace()]) + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) ]) actionButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() } - }.store(in: &self.popupCancellables) + }.store(in: &self.drawerCancellables) - self.coordinator.toPopup(popup, from: self) + self.coordinator.toDrawer(drawer, from: self) } private let viewModel = SearchViewModel() private var cancellables = Set<AnyCancellable>() - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() public override func loadView() { view = screenView @@ -66,6 +77,11 @@ public final class SearchController: UIViewController { ) } + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewModel.didAppear() + } + public override func viewDidLoad() { super.viewDidLoad() setupNavigationBar() @@ -78,9 +94,9 @@ public final class SearchController: UIViewController { addChild(tableController) screenView.addSubview(tableController.view) - tableController.view.snp.makeConstraints { make in - make.top.equalTo(screenView.stack.snp.bottom).offset(20) - make.left.bottom.right.equalToSuperview() + tableController.view.snp.makeConstraints { + $0.top.equalTo(screenView.stack.snp.bottom).offset(20) + $0.left.bottom.right.equalToSuperview() } tableController.didMove(toParent: self) @@ -92,25 +108,35 @@ public final class SearchController: UIViewController { private func setupNavigationBar() { navigationItem.backButtonTitle = " " - let title = UILabel() - title.text = Localized.ContactSearch.title - title.textColor = Asset.neutralActive.color - title.font = Fonts.Mulish.semiBold.font(size: 18.0) + let titleLabel = UILabel() + titleLabel.text = Localized.ContactSearch.title + titleLabel.textColor = Asset.neutralActive.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) + let backButton = UIButton.back() + backButton.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [back, title]) + customView: UIStackView(arrangedSubviews: [backButton, titleLabel]) ) } private func setupBindings() { - viewModel.hud + viewModel.successPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in presentSucessDrawerFor(contact: $0) } + .store(in: &cancellables) + + viewModel.hudPublisher .receive(on: DispatchQueue.main) .sink { [hud] in hud.update(with: $0) } .store(in: &cancellables) + viewModel.coverTrafficPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in presentCoverTrafficDrawer() } + .store(in: &cancellables) + viewModel .itemsRelay .removeDuplicates() @@ -124,7 +150,7 @@ public final class SearchController: UIViewController { .sink { [unowned self] in screenView.placeholder.isHidden = !$0 } .store(in: &cancellables) - viewModel.state + viewModel.statePublisher .map(\.country) .removeDuplicates() .receive(on: DispatchQueue.main) @@ -163,8 +189,11 @@ public final class SearchController: UIViewController { .phoneInput .codePublisher .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toCountries(from: self) { viewModel.didChooseCountry($0) }} - .store(in: &cancellables) + .sink { [unowned self] in + coordinator.toCountries(from: self) { + self.viewModel.didChooseCountry($0) + } + }.store(in: &cancellables) } private func setupFilterBindings() { @@ -183,13 +212,13 @@ public final class SearchController: UIViewController { .sink { [unowned self] _ in viewModel.didSelect(filter: .email) } .store(in: &cancellables) - viewModel.state + viewModel.statePublisher .map(\.selectedFilter) .removeDuplicates() .sink { [unowned self] in screenView.alternateFieldsOver(filter: $0) } .store(in: &cancellables) - viewModel.state + viewModel.statePublisher .map(\.selectedFilter) .removeDuplicates() .dropFirst() @@ -201,10 +230,268 @@ public final class SearchController: UIViewController { navigationController?.popViewController(animated: true) } - public func tableView(_ tableView: UITableView, - didSelectRowAt indexPath: IndexPath) { - coordinator.toContact(viewModel.itemsRelay.value[indexPath.row], from: self) + public func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { + let contact = viewModel.itemsRelay.value[indexPath.row] + + guard contact.status == .stranger else { + coordinator.toContact(contact, from: self) + return + } + + presentRequestDrawer(forContact: contact) } } extension SearchController: UITableViewDelegate {} + +// MARK: - Contact Request Drawer + +extension SearchController { + private func presentRequestDrawer(forContact contact: Contact) { + var items: [DrawerItem] = [] + + let drawerTitle = DrawerText( + font: Fonts.Mulish.extraBold.font(size: 26.0), + text: Localized.ContactSearch.RequestDrawer.title, + color: Asset.neutralDark.color, + spacingAfter: 20 + ) + + var subtitleFragment = "Share your information with #\(contact.username)" + + if let email = contact.email { + subtitleFragment.append(contentsOf: " (\(email))#") + } else if let phone = contact.phone { + subtitleFragment.append(contentsOf: " (\(Country.findFrom(phone).prefix) \(phone.dropLast(2)))#") + } else { + subtitleFragment.append(contentsOf: "#") + } + + subtitleFragment.append(contentsOf: " so they know its you.") + + let drawerSubtitle = DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: subtitleFragment, + color: Asset.neutralDark.color, + spacingAfter: 31.5, + customAttributes: [ + .font: Fonts.Mulish.regular.font(size: 16.0) as Any, + .foregroundColor: Asset.brandPrimary.color + ] + ) + + items.append(contentsOf: [ + drawerTitle, + drawerSubtitle + ]) + + if let email = email { + let drawerEmail = DrawerSwitch( + title: Localized.ContactSearch.RequestDrawer.email, + content: email, + spacingAfter: phone != nil ? 23 : 31, + isInitiallyOn: isSharingEmail + ) + + items.append(drawerEmail) + + drawerEmail.isOnPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.isSharingEmail = $0 } + .store(in: &drawerCancellables) + } + + if let phone = phone { + let drawerPhone = DrawerSwitch( + title: Localized.ContactSearch.RequestDrawer.phone, + content: "\(Country.findFrom(phone).prefix) \(phone.dropLast(2))", + spacingAfter: 31, + isInitiallyOn: isSharingPhone + ) + + items.append(drawerPhone) + + drawerPhone.isOnPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.isSharingPhone = $0 } + .store(in: &drawerCancellables) + } + + let drawerSendButton = DrawerCapsuleButton( + model: .init( + title: Localized.ContactSearch.RequestDrawer.send, + style: .brandColored + ), spacingAfter: 5 + ) + + let drawerCancelButton = DrawerCapsuleButton( + model: .init( + title: Localized.ContactSearch.RequestDrawer.cancel, + style: .simplestColoredBrand + ), spacingAfter: 5 + ) + + items.append(contentsOf: [drawerSendButton, drawerCancelButton]) + let drawer = DrawerController(with: items) + + drawerSendButton.action + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + drawer.dismiss(animated: true) { + self.viewModel.didTapRequest(contact: contact) + } + }.store(in: &drawerCancellables) + + drawerCancelButton.action + .receive(on: DispatchQueue.main) + .sink { drawer.dismiss(animated: true) } + .store(in: &drawerCancellables) + + coordinator.toDrawer(drawer, from: self) + } +} + +// MARK: - Cover Traffic Drawer + +extension SearchController { + 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) + } +} + +extension SearchController { + private func presentSucessDrawerFor(contact: Contact) { + var items: [DrawerItem] = [] + + let drawerTitle = DrawerText( + font: Fonts.Mulish.extraBold.font(size: 26.0), + text: Localized.ContactSearch.NicknameDrawer.title, + color: Asset.neutralDark.color, + spacingAfter: 20 + ) + + let drawerSubtitle = DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: Localized.ContactSearch.NicknameDrawer.subtitle, + color: Asset.neutralDark.color, + spacingAfter: 20 + ) + + items.append(contentsOf: [ + drawerTitle, + drawerSubtitle + ]) + + let drawerNicknameInput = DrawerInput( + placeholder: contact.username, + validator: .init( + wrongIcon: .image(Asset.sharedError.image), + correctIcon: .image(Asset.sharedSuccess.image), + shouldAcceptPlaceholder: true + ), + spacingAfter: 29 + ) + + items.append(drawerNicknameInput) + + let drawerSaveButton = DrawerCapsuleButton( + model: .init( + title: Localized.ContactSearch.NicknameDrawer.save, + style: .brandColored + ), spacingAfter: 5 + ) + + items.append(drawerSaveButton) + + let drawer = DrawerController(with: items) + var nickname: String? + var allowsSave = true + + drawerNicknameInput.validationPublisher + .receive(on: DispatchQueue.main) + .sink { allowsSave = $0 } + .store(in: &drawerCancellables) + + drawerNicknameInput.inputPublisher + .receive(on: DispatchQueue.main) + .sink { + guard !$0.isEmpty else { + nickname = contact.username + return + } + + nickname = $0 + } + .store(in: &drawerCancellables) + + drawerSaveButton.action + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + guard allowsSave else { return } + + drawer.dismiss(animated: true) { + self.viewModel.didSet(nickname: nickname ?? contact.username, for: contact) + } + } + .store(in: &drawerCancellables) + + coordinator.toNicknameDrawer(drawer, from: self) + } +} diff --git a/Sources/SearchFeature/Controllers/SearchTableController.swift b/Sources/SearchFeature/Controllers/SearchTableController.swift index 186a7be0c201583e49424d091cad48f129120681..625ab603defabb7d9c86b8bead13119198091e2c 100644 --- a/Sources/SearchFeature/Controllers/SearchTableController.swift +++ b/Sources/SearchFeature/Controllers/SearchTableController.swift @@ -49,12 +49,7 @@ final class SearchTableController: UITableViewController { let cell = tableView.dequeueReusableCell(forIndexPath: indexPath, ofType: SearchCell.self) cell.title.text = dataSource[indexPath.row].username cell.subtitle.text = dataSource[indexPath.row].username - - cell.avatar.set( - username: dataSource[indexPath.row].username, - image: nil - ) - + cell.avatar.setupProfile(title: dataSource[indexPath.row].username, image: nil, size: .large) return cell } diff --git a/Sources/SearchFeature/Coordinator/SearchCoordinator.swift b/Sources/SearchFeature/Coordinator/SearchCoordinator.swift index 0bb1448c964c26e399979a4a47a2f8ab98f5dec8..c9a831c2c6be45b36e23d7329ad26b282f9b539e 100644 --- a/Sources/SearchFeature/Coordinator/SearchCoordinator.swift +++ b/Sources/SearchFeature/Coordinator/SearchCoordinator.swift @@ -2,16 +2,19 @@ import UIKit import Models import Countries import Presentation +import ScrollViewController public protocol SearchCoordinating { func toContact(_: Contact, from: UIViewController) - func toPopup(_: UIViewController, from: UIViewController) + func toDrawer(_: UIViewController, from: UIViewController) + func toNicknameDrawer(_: UIViewController, from: UIViewController) func toCountries(from: UIViewController, _: @escaping (Country) -> Void) } public struct SearchCoordinator { var pushPresenter: Presenting = PushPresenter() var bottomPresenter: Presenting = BottomPresenter() + var fullscreenPresenter: Presenting = FullscreenPresenter() var contactFactory: (Contact) -> UIViewController var countriesFactory: (@escaping (Country) -> Void) -> UIViewController @@ -31,12 +34,31 @@ extension SearchCoordinator: SearchCoordinating { pushPresenter.present(screen, from: parent) } - public func toPopup(_ popup: UIViewController, from parent: UIViewController) { - bottomPresenter.present(popup, from: parent) + public func toDrawer(_ drawer: UIViewController, from parent: UIViewController) { + bottomPresenter.present(drawer, from: parent) } public func toCountries(from parent: UIViewController, _ onChoose: @escaping (Country) -> Void) { let screen = countriesFactory(onChoose) pushPresenter.present(screen, from: parent) } + + public func toNicknameDrawer(_ target: UIViewController, from parent: UIViewController) { + let screen = ScrollViewController.embedding(target) + fullscreenPresenter.present(screen, from: parent) + } +} + +extension ScrollViewController { + static func embedding(_ viewController: UIViewController) -> ScrollViewController { + let scrollViewController = ScrollViewController() + scrollViewController.addChild(viewController) + scrollViewController.contentView = viewController.view + scrollViewController.wrapperView.handlesTouchesOutsideContent = false + scrollViewController.wrapperView.alignContentToBottom = true + scrollViewController.scrollView.bounces = false + + viewController.didMove(toParent: scrollViewController) + return scrollViewController + } } diff --git a/Sources/SearchFeature/ViewModels/SearchViewModel.swift b/Sources/SearchFeature/ViewModels/SearchViewModel.swift index 99305371659316ed457663191e616e8bb36d61a2..f750fc38c472379547b721f1ac148139b77cc619 100644 --- a/Sources/SearchFeature/ViewModels/SearchViewModel.swift +++ b/Sources/SearchFeature/ViewModels/SearchViewModel.swift @@ -1,9 +1,12 @@ import HUD -import Combine +import UIKit import Models +import Combine +import Defaults import Countries import Foundation import Integration +import PushNotifications import CombineSchedulers import DependencyInjection @@ -32,64 +35,158 @@ struct SearchViewState: Equatable { } final class SearchViewModel { + @KeyObject(.dummyTrafficOn, defaultValue: false) var isCoverTrafficEnabled: Bool + @KeyObject(.pushNotifications, defaultValue: false) private var pushNotifications + @KeyObject(.askedDummyTrafficOnce, defaultValue: false) var offeredCoverTraffic: Bool + @Dependency private var session: SessionType + @Dependency private var pushHandler: PushHandling - let itemsRelay = CurrentValueSubject<[Contact], Never>([]) - private let hudRelay = CurrentValueSubject<HUDStatus, Never>(.none) - private let placeholderRelay = CurrentValueSubject<Bool, Never>(true) - private let stateRelay = CurrentValueSubject<SearchViewState, Never>(.init()) + var hudPublisher: AnyPublisher<HUDStatus, Never> { + hudSubject.eraseToAnyPublisher() + } + + var placeholderPublisher: AnyPublisher<Bool, Never> { + placeholderSubject.eraseToAnyPublisher() + } + + var coverTrafficPublisher: AnyPublisher<Void, Never> { + coverTrafficSubject.eraseToAnyPublisher() + } + + var statePublisher: AnyPublisher<SearchViewState, Never> { + stateSubject.eraseToAnyPublisher() + } + + var successPublisher: AnyPublisher<Contact, Never> { + successSubject.eraseToAnyPublisher() + } - var hud: AnyPublisher<HUDStatus, Never> { hudRelay.eraseToAnyPublisher() } - var state: AnyPublisher<SearchViewState, Never> { stateRelay.eraseToAnyPublisher() } - var placeholderPublisher: AnyPublisher<Bool, Never> { placeholderRelay.eraseToAnyPublisher() } - var backgroundScheduler: AnySchedulerOf<DispatchQueue> = DispatchQueue.global().eraseToAnyScheduler() + var backgroundScheduler: AnySchedulerOf<DispatchQueue> + = DispatchQueue.global().eraseToAnyScheduler() + + let itemsRelay = CurrentValueSubject<[Contact], Never>([]) + private let successSubject = PassthroughSubject<Contact, Never>() + private let coverTrafficSubject = PassthroughSubject<Void, Never>() + private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) + private let placeholderSubject = CurrentValueSubject<Bool, Never>(true) + private let stateSubject = CurrentValueSubject<SearchViewState, Never>(.init()) + + func didAppear() { + verifyCoverTraffic() + verifyNotifications() + } func didSelect(filter: SelectedFilter) { - stateRelay.value.selectedFilter = filter + stateSubject.value.selectedFilter = filter } func didInput(_ string: String) { - stateRelay.value.input = string.trimmingCharacters(in: .whitespacesAndNewlines) + stateSubject.value.input = string.trimmingCharacters(in: .whitespacesAndNewlines) } func didInputPhone(_ string: String) { - stateRelay.value.phoneInput = string.trimmingCharacters(in: .whitespacesAndNewlines) + stateSubject.value.phoneInput = string.trimmingCharacters(in: .whitespacesAndNewlines) } func didChooseCountry(_ country: Country) { - stateRelay.value.country = country + stateSubject.value.country = country + } + + func didEnableCoverTraffic() { + isCoverTrafficEnabled = true + session.setDummyTraffic(status: true) } func didTapSearch() { - hudRelay.send(.on(nil)) + hudSubject.send(.on(nil)) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } do { - var content = self.stateRelay.value.selectedFilter.prefix + var content = self.stateSubject.value.selectedFilter.prefix - if self.stateRelay.value.selectedFilter == .phone { - content += self.stateRelay.value.phoneInput + self.stateRelay.value.country.code + if self.stateSubject.value.selectedFilter == .phone { + content += self.stateSubject.value.phoneInput + self.stateSubject.value.country.code } else { - content += self.stateRelay.value.input + content += self.stateSubject.value.input } try self.session.search(fact: content) { result in - self.placeholderRelay.send(false) + self.placeholderSubject.send(false) switch result { case .success(let searched): - self.hudRelay.send(.none) + self.hudSubject.send(.none) self.itemsRelay.send([searched]) case .failure(let error): - self.hudRelay.send(.error(.init(with: error))) + self.hudSubject.send(.error(.init(with: error))) self.itemsRelay.send([]) } } } catch { - self.hudRelay.send(.error(.init(with: error))) + self.hudSubject.send(.error(.init(with: error))) } } } + + private func verifyCoverTraffic() { + guard offeredCoverTraffic == false else { + return + } + + offeredCoverTraffic = true + coverTrafficSubject.send() + } + + private func verifyNotifications() { + guard pushNotifications == false else { return } + + pushHandler.didRequestAuthorization { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let granted): + if granted { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } + + self.pushNotifications = granted + case .failure: + self.pushNotifications = false + } + } + } + + func didSet(nickname: String, for contact: Contact) { + var contact = contact + contact.nickname = nickname + + backgroundScheduler.schedule { [weak self] in + guard let self = self else { return } + self.session.update(contact) + } + } + + func didTapRequest(contact: Contact) { + hudSubject.send(.on(nil)) + var contact = contact + contact.nickname = contact.username + + backgroundScheduler.schedule { [weak self] in + guard let self = self else { return } + + do { + try self.session.add(contact) + self.hudSubject.send(.none) + self.successSubject.send(contact) + } catch { + self.hudSubject.send(.error(.init(with: error))) + } + } + + } } diff --git a/Sources/SettingsFeature/Controllers/AccountDeleteController.swift b/Sources/SettingsFeature/Controllers/AccountDeleteController.swift index 24f7ba5dea09f3f2cca6389607eaff6c840d8a96..7eae36405a3bf673064d10fd743b648d99d0fc28 100644 --- a/Sources/SettingsFeature/Controllers/AccountDeleteController.swift +++ b/Sources/SettingsFeature/Controllers/AccountDeleteController.swift @@ -1,6 +1,6 @@ import HUD import UIKit -import Popup +import DrawerFeature import Shared import Combine import Defaults @@ -18,7 +18,7 @@ public final class AccountDeleteController: UIViewController { private let viewModel = AccountDeleteViewModel() private var cancellables = Set<AnyCancellable>() - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -92,18 +92,18 @@ public final class AccountDeleteController: UIViewController { let actionButton = CapsuleButton() actionButton.set( style: .seeThrough, - title: Localized.Settings.InfoPopUp.action + title: Localized.Settings.InfoDrawer.action ) - let popup = BottomPopup(with: [ - PopupLabel( + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), text: title, color: Asset.neutralActive.color, alignment: .left, spacingAfter: 19 ), - PopupLabel( + DrawerText( font: Fonts.Mulish.regular.font(size: 16.0), text: subtitle, color: Asset.neutralBody.color, @@ -111,18 +111,21 @@ public final class AccountDeleteController: UIViewController { lineHeightMultiple: 1.1, spacingAfter: 37 ), - PopupStackView(views: [actionButton, FlexibleSpace()]) + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) ]) actionButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } } diff --git a/Sources/SettingsFeature/Controllers/SettingsController.swift b/Sources/SettingsFeature/Controllers/SettingsController.swift index fcf0aa2525e00479333eaaad5bb7e99c43c14ffb..e12674906cde540f35bbff5741f3ec5e816b4507 100644 --- a/Sources/SettingsFeature/Controllers/SettingsController.swift +++ b/Sources/SettingsFeature/Controllers/SettingsController.swift @@ -1,5 +1,5 @@ import HUD -import Popup +import DrawerFeature import UIKit import Theme import Shared @@ -17,25 +17,25 @@ public final class SettingsController: UIViewController { switch $0 { case .icognitoKeyboard: self.presentInfo( - title: Localized.Settings.InfoPopUp.Icognito.title, - subtitle: Localized.Settings.InfoPopUp.Icognito.subtitle + title: Localized.Settings.InfoDrawer.Icognito.title, + subtitle: Localized.Settings.InfoDrawer.Icognito.subtitle ) case .biometrics: self.presentInfo( - title: Localized.Settings.InfoPopUp.Biometrics.title, - subtitle: Localized.Settings.InfoPopUp.Biometrics.subtitle + title: Localized.Settings.InfoDrawer.Biometrics.title, + subtitle: Localized.Settings.InfoDrawer.Biometrics.subtitle ) case .notifications: self.presentInfo( - title: Localized.Settings.InfoPopUp.Notifications.title, - subtitle: Localized.Settings.InfoPopUp.Notifications.subtitle, + 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.InfoPopUp.Traffic.title, - subtitle: Localized.Settings.InfoPopUp.Traffic.subtitle, + title: Localized.Settings.InfoDrawer.Traffic.title, + subtitle: Localized.Settings.InfoDrawer.Traffic.subtitle, urlString: "https://links.xx.network/covertraffic" ) } @@ -43,7 +43,7 @@ public final class SettingsController: UIViewController { private let viewModel = SettingsViewModel() private var cancellables = Set<AnyCancellable>() - private var popupCancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -65,16 +65,19 @@ public final class SettingsController: UIViewController { private func setupNavigationBar() { navigationItem.backButtonTitle = "" - let title = UILabel() - title.text = Localized.Settings.title - title.textColor = Asset.neutralActive.color - title.font = Fonts.Mulish.semiBold.font(size: 18.0) + let titleLabel = UILabel() + titleLabel.text = Localized.Settings.title + titleLabel.textColor = Asset.neutralActive.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) - let back = UIButton.back() - back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) + 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: [back, title]) + customView: UIStackView(arrangedSubviews: [menuButton, titleLabel]) ) } @@ -129,9 +132,9 @@ public final class SettingsController: UIViewController { .publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { [unowned self] in - presentPopup( - title: Localized.Settings.Popup.title(Localized.Settings.privacyPolicy), - subtitle: Localized.Settings.Popup.subtitle(Localized.Settings.privacyPolicy), + 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://xx.network/privategrity-corporation-privacy-policy") else { return } UIApplication.shared.open(url, options: [:]) @@ -142,9 +145,9 @@ public final class SettingsController: UIViewController { .publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { [unowned self] in - presentPopup( - title: Localized.Settings.Popup.title(Localized.Settings.disclosures), - subtitle: Localized.Settings.Popup.subtitle(Localized.Settings.disclosures), + 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://xx.network/privategrity-corporation-terms-of-use") else { return } UIApplication.shared.open(url, options: [:]) @@ -189,7 +192,7 @@ public final class SettingsController: UIViewController { }.store(in: &cancellables) } - private func presentPopup( + private func presentDrawer( title: String, subtitle: String, actionTitle: String, @@ -203,53 +206,52 @@ public final class SettingsController: UIViewController { cancelButton.setStyle(.seeThrough) cancelButton.setTitle(Localized.ChatList.Dashboard.cancel, for: .normal) - let popup = BottomPopup(with: [ - PopupImage(image: Asset.popupNegative.image), - PopupLabel( + let drawer = DrawerController(with: [ + DrawerImage( + image: Asset.drawerNegative.image + ), + DrawerText( font: Fonts.Mulish.semiBold.font(size: 18.0), text: title, color: Asset.neutralActive.color ), - PopupLabel( + DrawerText( font: Fonts.Mulish.semiBold.font(size: 14.0), text: subtitle, color: Asset.neutralWeak.color, lineHeightMultiple: 1.35, spacingAfter: 25 ), - PopupStackView( + DrawerStack( spacing: 20.0, - views: [ - actionButton, - cancelButton - ] + views: [actionButton, cancelButton] ) ]) actionButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() action() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) cancelButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in - self?.popupCancellables.removeAll() + drawer.dismiss(animated: true) { [weak self] in + self?.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) + @objc private func didTapMenu() { + coordinator.toSideMenu(from: self) } } @@ -262,34 +264,37 @@ extension SettingsController { let actionButton = CapsuleButton() actionButton.set( style: .seeThrough, - title: Localized.Settings.InfoPopUp.action + title: Localized.Settings.InfoDrawer.action ) - let popup = BottomPopup(with: [ - PopupLabel( + let drawer = DrawerController(with: [ + DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), text: title, color: Asset.neutralActive.color, alignment: .left, spacingAfter: 19 ), - PopupLinkText( + DrawerLinkText( text: subtitle, urlString: urlString, spacingAfter: 37 ), - PopupStackView(views: [actionButton, FlexibleSpace()]) + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) ]) actionButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) .sink { - popup.dismiss(animated: true) { [weak self] in + drawer.dismiss(animated: true) { [weak self] in guard let self = self else { return } - self.popupCancellables.removeAll() + self.drawerCancellables.removeAll() } - }.store(in: &popupCancellables) + }.store(in: &drawerCancellables) - coordinator.toPopup(popup, from: self) + coordinator.toDrawer(drawer, from: self) } } diff --git a/Sources/SettingsFeature/Coordinator/SettingsCoordinator.swift b/Sources/SettingsFeature/Coordinator/SettingsCoordinator.swift index 6a3bf13774762095d79cacd7ac0e9e264871fc6f..73de549152915e86ff93f911c5e28626dff74d00 100644 --- a/Sources/SettingsFeature/Coordinator/SettingsCoordinator.swift +++ b/Sources/SettingsFeature/Coordinator/SettingsCoordinator.swift @@ -1,36 +1,41 @@ import UIKit import Shared +import MenuFeature import Presentation public protocol SettingsCoordinating { func toBackup(from: UIViewController) func toDelete(from: UIViewController) func toAdvanced(from: UIViewController) - func toPopup(_: UIViewController, from: UIViewController) + func toSideMenu(from: UIViewController) + func toDrawer(_: UIViewController, from: UIViewController) func toActivityController(with: [Any], from: UIViewController) } public struct SettingsCoordinator: SettingsCoordinating { - public init( - backupFactory: @escaping () -> UIViewController, - advancedFactory: @escaping () -> UIViewController, - accountDeleteFactory: @escaping () -> UIViewController - ) { - self.backupFactory = backupFactory - self.advancedFactory = advancedFactory - self.accountDeleteFactory = accountDeleteFactory - } - var pushPresenter: Presenting = PushPresenter() var modalPresenter: Presenting = ModalPresenter() + var sidePresenter: Presenting = SideMenuPresenter() var bottomPresenter: Presenting = BottomPresenter() var backupFactory: () -> UIViewController var advancedFactory: () -> UIViewController var accountDeleteFactory: () -> UIViewController - + var sideMenuFactory: (MenuItem, UIViewController) -> UIViewController var activityControllerFactory: ([Any]) -> UIViewController - = { UIActivityViewController(activityItems: $0, applicationActivities: nil) } + = { UIActivityViewController(activityItems: $0, applicationActivities: nil) } + + public init( + backupFactory: @escaping () -> UIViewController, + advancedFactory: @escaping () -> UIViewController, + accountDeleteFactory: @escaping () -> UIViewController, + sideMenuFactory: @escaping (MenuItem, UIViewController) -> UIViewController + ) { + self.backupFactory = backupFactory + self.advancedFactory = advancedFactory + self.sideMenuFactory = sideMenuFactory + self.accountDeleteFactory = accountDeleteFactory + } } public extension SettingsCoordinator { @@ -49,12 +54,17 @@ public extension SettingsCoordinator { pushPresenter.present(screen, from: parent) } - func toPopup(_ popup: UIViewController, from parent: UIViewController) { - bottomPresenter.present(popup, from: parent) + func toDrawer(_ drawer: UIViewController, from parent: UIViewController) { + bottomPresenter.present(drawer, from: parent) } func toActivityController(with items: [Any], from parent: UIViewController) { let screen = activityControllerFactory(items) modalPresenter.present(screen, from: parent) } + + func toSideMenu(from parent: UIViewController) { + let screen = sideMenuFactory(.settings, parent) + sidePresenter.present(screen, from: parent) + } } diff --git a/Sources/Shared/AutoGenerated/Assets.swift b/Sources/Shared/AutoGenerated/Assets.swift index 12a0ace416e24834f4c2089132bc2b773b74f75c..76164e8f59a5329d780a81ac348062fce2cebf66 100644 --- a/Sources/Shared/AutoGenerated/Assets.swift +++ b/Sources/Shared/AutoGenerated/Assets.swift @@ -60,6 +60,7 @@ public enum Asset { public static let contactListRequests = ImageAsset(name: "contactList_requests") public static let contactListSearch = ImageAsset(name: "contactList_search") public static let contactListUserSearch = ImageAsset(name: "contactList_user_search") + public static let drawerNegative = ImageAsset(name: "drawer_negative") public static let menuChats = ImageAsset(name: "menu_chats") public static let menuContacts = ImageAsset(name: "menu_contacts") public static let menuDashboard = ImageAsset(name: "menu_dashboard") @@ -78,16 +79,22 @@ public enum Asset { public static let permissionLibrary = ImageAsset(name: "permission_library") public static let permissionLogo = ImageAsset(name: "permission_logo") public static let permissionMicrophone = ImageAsset(name: "permission_microphone") - public static let popupNegative = ImageAsset(name: "popup_negative") public static let profileAdd = ImageAsset(name: "profile_add") public static let profileDelete = ImageAsset(name: "profile_delete") public static let profileEmail = ImageAsset(name: "profile_email") public static let profileImageButton = ImageAsset(name: "profile_image_button") public static let profileImagePlaceholder = ImageAsset(name: "profile_image_placeholder") public static let profilePhone = ImageAsset(name: "profile_phone") - public static let requestsAccept = ImageAsset(name: "requests_accept") + public static let requestAccepted = ImageAsset(name: "request_accepted") + public static let requestFailedToaster = ImageAsset(name: "request_failed_toaster") + public static let requestSentToaster = ImageAsset(name: "request_sent_toaster") public static let requestsReceivedPlaceholder = ImageAsset(name: "requests_received_placeholder") - public static let requestsReject = ImageAsset(name: "requests_reject") + public static let requestsResend = ImageAsset(name: "requests_resend") + public static let requestsResent = ImageAsset(name: "requests_resent") + public static let requestsTabFailed = ImageAsset(name: "requests_tab_failed") + public static let requestsTabReceived = ImageAsset(name: "requests_tab_received") + public static let requestsTabSent = ImageAsset(name: "requests_tab_sent") + public static let requestsVerificationFailed = ImageAsset(name: "requests_verification_failed") public static let restoreDrive = ImageAsset(name: "restore_drive") public static let restoreDropbox = ImageAsset(name: "restore_dropbox") public static let restoreIcloud = ImageAsset(name: "restore_icloud") @@ -96,7 +103,6 @@ public enum Asset { public static let scanError = ImageAsset(name: "scan_error") public static let scanPhone = ImageAsset(name: "scan_phone") public static let scanQr = ImageAsset(name: "scan_qr") - public static let scanSuccess = ImageAsset(name: "scan_success") public static let searchEmail = ImageAsset(name: "search_email") public static let searchLens = ImageAsset(name: "search_lens") public static let searchPhone = ImageAsset(name: "search_phone") @@ -129,6 +135,7 @@ public enum Asset { public static let replyAbort = ImageAsset(name: "reply_abort") public static let sharedCross = ImageAsset(name: "shared_cross") public static let sharedError = ImageAsset(name: "shared_error") + public static let sharedGroup = ImageAsset(name: "shared_group") public static let sharedScan = ImageAsset(name: "shared_scan") public static let sharedSuccess = ImageAsset(name: "shared_success") public static let sharedWhiteExclamation = ImageAsset(name: "shared_white_exclamation") @@ -147,7 +154,9 @@ public enum Asset { public static let neutralDark = ColorAsset(name: "neutral_dark") public static let neutralDisabled = ColorAsset(name: "neutral_disabled") public static let neutralLine = ColorAsset(name: "neutral_line") + public static let neutralOverlay = ColorAsset(name: "neutral_overlay") public static let neutralSecondary = ColorAsset(name: "neutral_secondary") + public static let neutralSecondaryAlternative = ColorAsset(name: "neutral_secondary_alternative") public static let neutralWeak = ColorAsset(name: "neutral_weak") public static let neutralWhite = ColorAsset(name: "neutral_white") public static let transferImagePlaceholder = ImageAsset(name: "transfer_image_placeholder") diff --git a/Sources/Shared/AutoGenerated/Strings.swift b/Sources/Shared/AutoGenerated/Strings.swift index 4acbf48e1c5824279a950c20dde174079ce18550..722a7d52b37b383c03d53eb9befc5bd20bb0a0c7 100644 --- a/Sources/Shared/AutoGenerated/Strings.swift +++ b/Sources/Shared/AutoGenerated/Strings.swift @@ -35,13 +35,13 @@ public enum Localized { public enum CreateGroup { /// createGroup.create public static let create = Localized.tr("Localizable", "accessibility.createGroup.create") - public enum Popup { - /// createGroup.popup.create - public static let create = Localized.tr("Localizable", "accessibility.createGroup.popup.create") - /// createGroup.popup.input - public static let input = Localized.tr("Localizable", "accessibility.createGroup.popup.input") - /// createGroup.popup.otherInput - public static let otherInput = Localized.tr("Localizable", "accessibility.createGroup.popup.otherInput") + public enum Drawer { + /// createGroup.drawer.create + public static let create = Localized.tr("Localizable", "accessibility.createGroup.drawer.create") + /// createGroup.drawer.input + public static let input = Localized.tr("Localizable", "accessibility.createGroup.drawer.input") + /// createGroup.drawer.otherInput + public static let otherInput = Localized.tr("Localizable", "accessibility.createGroup.drawer.otherInput") } } public enum Menu { @@ -181,12 +181,12 @@ public enum Localized { public static let header = Localized.tr("Localizable", "backup.header") /// iCloud public static let iCloud = Localized.tr("Localizable", "backup.iCloud") - /// Back up your account to a cloud storage service, you can restore it along with your contacts when you reinstall xx messenger on another device. + /// Back up your account to a cloud storage service, you can restore it along with only your contacts when you reinstall xx Messenger on another device. public static let subtitle = Localized.tr("Localizable", "backup.subtitle") public enum Config { /// Backup now public static let backupNow = Localized.tr("Localizable", "backup.config.backupNow") - /// Content backed up in %@ is not protected by xx network end-to-end encryption. + /// Content backed up in %@ is encrypted with your passphrase in a brute force resistant manner public static func disclaimer(_ p1: Any) -> String { return Localized.tr("Localizable", "backup.config.disclaimer", String(describing: p1)) } @@ -330,7 +330,7 @@ public enum Localized { public enum DeleteGroup { /// Leave group public static let action = Localized.tr("Localizable", "chatList.deleteGroup.action") - /// This will not only delete the messages sent to this group locally but will also remove you from it. + /// You will exit this group and you won’t receive any more messages from this group and your group messages will be lost. public static let subtitle = Localized.tr("Localizable", "chatList.deleteGroup.subtitle") /// Are you sure you want to delete a group? public static let title = Localized.tr("Localizable", "chatList.deleteGroup.title") @@ -472,18 +472,38 @@ public enum Localized { /// Username public static let username = Localized.tr("Localizable", "contactSearch.filter.username") } + public enum NicknameDrawer { + /// Save + public static let save = Localized.tr("Localizable", "contactSearch.nicknameDrawer.save") + /// Edit your new contact’s nickname so you know who they are. + public static let subtitle = Localized.tr("Localizable", "contactSearch.nicknameDrawer.subtitle") + /// Add a nickname + public static let title = Localized.tr("Localizable", "contactSearch.nicknameDrawer.title") + } public enum Placeholder { /// Searching is private by nature. The network cannot identify who a search request came from. public static let title = Localized.tr("Localizable", "contactSearch.placeholder.title") - public enum Popup { + public enum Drawer { /// Got it - public static let action = Localized.tr("Localizable", "contactSearch.placeholder.popup.action") + public static let action = Localized.tr("Localizable", "contactSearch.placeholder.drawer.action") /// You can search for users by their username, email, or phone number using the xx network’s #Anonymous Data Retrieval protocol# which keeps a user’s identity anonymous while requesting data. All sent requests contain salted hashes of what you are searching for. Raw data on emails, usernames, and phone numbers do not leave your phone. - public static let subtitle = Localized.tr("Localizable", "contactSearch.placeholder.popup.subtitle") + public static let subtitle = Localized.tr("Localizable", "contactSearch.placeholder.drawer.subtitle") /// Search - public static let title = Localized.tr("Localizable", "contactSearch.placeholder.popup.title") + public static let title = Localized.tr("Localizable", "contactSearch.placeholder.drawer.title") } } + public enum RequestDrawer { + /// Cancel + public static let cancel = Localized.tr("Localizable", "contactSearch.requestDrawer.cancel") + /// EMAIL ADDRESS + public static let email = Localized.tr("Localizable", "contactSearch.requestDrawer.email") + /// PHONE NUMBER + public static let phone = Localized.tr("Localizable", "contactSearch.requestDrawer.phone") + /// Send Contact Request + public static let send = Localized.tr("Localizable", "contactSearch.requestDrawer.send") + /// Request Contact + public static let title = Localized.tr("Localizable", "contactSearch.requestDrawer.title") + } } public enum Countries { @@ -500,29 +520,29 @@ public enum Localized { public static func title(_ p1: Any) -> String { return Localized.tr("Localizable", "createGroup.title", String(describing: p1)) } - public enum Popup { + public enum Drawer { /// Create Group - public static let action = Localized.tr("Localizable", "createGroup.popup.action") + public static let action = Localized.tr("Localizable", "createGroup.drawer.action") /// Cancel - public static let cancel = Localized.tr("Localizable", "createGroup.popup.cancel") + public static let cancel = Localized.tr("Localizable", "createGroup.drawer.cancel") /// Group Name - public static let input = Localized.tr("Localizable", "createGroup.popup.input") + public static let input = Localized.tr("Localizable", "createGroup.drawer.input") /// Needs to be 20 chars max or 256 bytes - public static let maximum = Localized.tr("Localizable", "createGroup.popup.maximum") + public static let maximum = Localized.tr("Localizable", "createGroup.drawer.maximum") /// Needs to be at least 4 chars - public static let minimum = Localized.tr("Localizable", "createGroup.popup.minimum") + public static let minimum = Localized.tr("Localizable", "createGroup.drawer.minimum") /// Initial Message - public static let otherInput = Localized.tr("Localizable", "createGroup.popup.otherInput") + public static let otherInput = Localized.tr("Localizable", "createGroup.drawer.otherInput") /// Say hi to your friends! - public static let otherPlaceholder = Localized.tr("Localizable", "createGroup.popup.otherPlaceholder") + public static let otherPlaceholder = Localized.tr("Localizable", "createGroup.drawer.otherPlaceholder") /// Secret Family - public static let placeholder = Localized.tr("Localizable", "createGroup.popup.placeholder") + public static let placeholder = Localized.tr("Localizable", "createGroup.drawer.placeholder") /// You are about to create a group message with %@ users. The information below will be visible to all members of the group. public static func subtitle(_ p1: Any) -> String { - return Localized.tr("Localizable", "createGroup.popup.subtitle", String(describing: p1)) + return Localized.tr("Localizable", "createGroup.drawer.subtitle", String(describing: p1)) } /// Create Group - public static let title = Localized.tr("Localizable", "createGroup.popup.title") + public static let title = Localized.tr("Localizable", "createGroup.drawer.title") } } @@ -777,21 +797,105 @@ public enum Localized { public enum Requests { /// Requests public static let title = Localized.tr("Localizable", "requests.title") + public enum Cell { + /// Retry + public static let failedRequest = Localized.tr("Localizable", "requests.cell.failedRequest") + /// Failed to verify + public static let failedVerification = Localized.tr("Localizable", "requests.cell.failedVerification") + /// Resend + public static let requested = Localized.tr("Localizable", "requests.cell.requested") + /// Resent + public static let resent = Localized.tr("Localizable", "requests.cell.resent") + /// Verifying + public static let verifying = Localized.tr("Localizable", "requests.cell.verifying") + } + public enum Confirmations { + /// Accepted your request + public static let toaster = Localized.tr("Localizable", "requests.confirmations.toaster") + } + public enum Drawer { + public enum Group { + /// Accept + public static let accept = Localized.tr("Localizable", "requests.drawer.group.accept") + /// Hide Request + public static let hide = Localized.tr("Localizable", "requests.drawer.group.hide") + /// GROUP CHAT REQUEST + public static let title = Localized.tr("Localizable", "requests.drawer.group.title") + public enum Success { + /// Later + public static let later = Localized.tr("Localizable", "requests.drawer.group.success.later") + /// Go to Chat + public static let send = Localized.tr("Localizable", "requests.drawer.group.success.send") + /// You are now part of the group chat. Would you like to check it out? + public static let subtitle = Localized.tr("Localizable", "requests.drawer.group.success.subtitle") + /// ACCEPTED + public static let title = Localized.tr("Localizable", "requests.drawer.group.success.title") + } + } + public enum Single { + /// Accept and Save + public static let accept = Localized.tr("Localizable", "requests.drawer.single.accept") + /// EMAIL ADDRESS + public static let email = Localized.tr("Localizable", "requests.drawer.single.email") + /// Hide Request + public static let hide = Localized.tr("Localizable", "requests.drawer.single.hide") + /// Edit your new contact’s nickname. + public static let nickname = Localized.tr("Localizable", "requests.drawer.single.nickname") + /// PHONE NUMBER + public static let phone = Localized.tr("Localizable", "requests.drawer.single.phone") + /// REQUEST FROM + public static let title = Localized.tr("Localizable", "requests.drawer.single.title") + public enum Success { + /// Later + public static let later = Localized.tr("Localizable", "requests.drawer.single.success.later") + /// Send a Message + public static let send = Localized.tr("Localizable", "requests.drawer.single.success.send") + /// Is now a connection, would you like to send a message? + public static let subtitle = Localized.tr("Localizable", "requests.drawer.single.success.subtitle") + /// NEW CONNECTION + public static let title = Localized.tr("Localizable", "requests.drawer.single.success.title") + } + } + } public enum Failed { + /// There are no failed requests + public static let empty = Localized.tr("Localizable", "requests.failed.empty") /// Failed public static let title = Localized.tr("Localizable", "requests.failed.title") + /// Your contact request to %@ has failed. + public static func toast(_ p1: Any) -> String { + return Localized.tr("Localizable", "requests.failed.toast", String(describing: p1)) + } } public enum Received { - /// No requests are currently waiting for review + /// Show hidden requests + public static let hidden = Localized.tr("Localizable", "requests.received.hidden") + /// No recent requests received public static let placeholder = Localized.tr("Localizable", "requests.received.placeholder") /// Received public static let title = Localized.tr("Localizable", "requests.received.title") + public enum Verifying { + /// OK + public static let action = Localized.tr("Localizable", "requests.received.verifying.action") + /// We are working on verifying the request to make sure it is not a spam. Please check again shortly. + public static let subtitle = Localized.tr("Localizable", "requests.received.verifying.subtitle") + /// Verifying + public static let title = Localized.tr("Localizable", "requests.received.verifying.title") + } } public enum Sent { - /// Add contact + /// Search for connections public static let action = Localized.tr("Localizable", "requests.sent.action") + /// You haven't sent any requests + public static let empty = Localized.tr("Localizable", "requests.sent.empty") /// Sent public static let title = Localized.tr("Localizable", "requests.sent.title") + public enum Toast { + /// Request successfully resent to %@ + public static func resent(_ p1: Any) -> String { + return Localized.tr("Localizable", "requests.sent.toast.resent", String(describing: p1)) + } + } } } @@ -966,6 +1070,16 @@ public enum Localized { public static let title = Localized.tr("Localizable", "settings.delete.info.title") } } + public enum Drawer { + /// %@ will be opened using your default browser + public static func subtitle(_ p1: Any) -> String { + return Localized.tr("Localizable", "settings.drawer.subtitle", String(describing: p1)) + } + /// Do you want to open %@? + public static func title(_ p1: Any) -> String { + return Localized.tr("Localizable", "settings.drawer.title", String(describing: p1)) + } + } public enum HideActiveApps { /// Hide screen in recent apps list public static let description = Localized.tr("Localizable", "settings.hideActiveApps.description") @@ -984,48 +1098,38 @@ public enum Localized { /// In-App Notifications public static let title = Localized.tr("Localizable", "settings.inAppNotifications.title") } - public enum InfoPopUp { + public enum InfoDrawer { /// Got it - public static let action = Localized.tr("Localizable", "settings.infoPopUp.action") + public static let action = Localized.tr("Localizable", "settings.infoDrawer.action") public enum Biometrics { /// Biometric authentication is stored through the native system on your phone, not by the xx messenger app. The xx network cannot access your biometric authentication data. - public static let subtitle = Localized.tr("Localizable", "settings.infoPopUp.biometrics.subtitle") + public static let subtitle = Localized.tr("Localizable", "settings.infoDrawer.biometrics.subtitle") /// Biometric Authentication - public static let title = Localized.tr("Localizable", "settings.infoPopUp.biometrics.title") + public static let title = Localized.tr("Localizable", "settings.infoDrawer.biometrics.title") } public enum Icognito { /// Predictive text is a feature offered by your phone’s operating system. It involves storing entered text within your phone’s operating system and may involve sending it to remote servers. As a result, it may significantly degrade your privacy. - public static let subtitle = Localized.tr("Localizable", "settings.infoPopUp.icognito.subtitle") + public static let subtitle = Localized.tr("Localizable", "settings.infoDrawer.icognito.subtitle") /// Predictive Text - public static let title = Localized.tr("Localizable", "settings.infoPopUp.icognito.title") + public static let title = Localized.tr("Localizable", "settings.infoDrawer.icognito.title") } public enum Notifications { /// Selecting this setting will share your account ID and unique phone identifiers with a notification service run by the xx network team. However, these details are obfuscated via an #ID collision system# when you receive a notification. As a result, both the notifications service and your notifications provider (Firebase on Android, Apple on iOS) cannot tell exactly when you receive a message. - public static let subtitle = Localized.tr("Localizable", "settings.infoPopUp.notifications.subtitle") + public static let subtitle = Localized.tr("Localizable", "settings.infoDrawer.notifications.subtitle") /// Notifications - public static let title = Localized.tr("Localizable", "settings.infoPopUp.notifications.title") + public static let title = Localized.tr("Localizable", "settings.infoDrawer.notifications.title") } public enum Privacy { /// Because xx messenger does not capture your personal data or save your private keys, we will not be able to, at this time, help new users recover their account in case of being locked out, changing devices, etc. Account recovery support that continues to protect your privacy and personal data will be coming soon. - public static let subtitle = Localized.tr("Localizable", "settings.infoPopUp.privacy.subtitle") + public static let subtitle = Localized.tr("Localizable", "settings.infoDrawer.privacy.subtitle") /// Please note - public static let title = Localized.tr("Localizable", "settings.infoPopUp.privacy.title") + public static let title = Localized.tr("Localizable", "settings.infoDrawer.privacy.title") } public enum Traffic { /// Cover Traffic hides when you are sending messages by randomly sending messages to random users. Other user’s phones will pick up these messages but they will not see them or know you sent them. As a result, it not only hides when you send messages, but helps hide who you are talking to. #Read more about it# - public static let subtitle = Localized.tr("Localizable", "settings.infoPopUp.traffic.subtitle") + public static let subtitle = Localized.tr("Localizable", "settings.infoDrawer.traffic.subtitle") /// Cover Traffic - public static let title = Localized.tr("Localizable", "settings.infoPopUp.traffic.title") - } - } - public enum Popup { - /// %@ will be opened using your default browser - public static func subtitle(_ p1: Any) -> String { - return Localized.tr("Localizable", "settings.popup.subtitle", String(describing: p1)) - } - /// Do you want to open %@? - public static func title(_ p1: Any) -> String { - return Localized.tr("Localizable", "settings.popup.title", String(describing: p1)) + public static let title = Localized.tr("Localizable", "settings.infoDrawer.traffic.title") } } public enum RemoteNotifications { diff --git a/Sources/Shared/Extensions/Colors.swift b/Sources/Shared/Extensions/Colors.swift new file mode 100644 index 0000000000000000000000000000000000000000..568f515724619f20a78636309adef24dcd506a51 --- /dev/null +++ b/Sources/Shared/Extensions/Colors.swift @@ -0,0 +1,18 @@ +import UIKit + +public extension UIColor { + static func fade(from color: UIColor, to: UIColor, pcent: CGFloat) -> UIColor { + var fromRed: CGFloat = 0, fromGreen: CGFloat = 0, fromBlue: CGFloat = 0, fromAlpha: CGFloat = 0 + color.getRed(&fromRed, green: &fromGreen, blue: &fromBlue, alpha: &fromAlpha) + + var toRed: CGFloat = 0, toGreen: CGFloat = 0, toBlue: CGFloat = 0, toAlpha: CGFloat = 0 + to.getRed(&toRed, green: &toGreen, blue: &toBlue, alpha: &toAlpha) + + let red = (toRed - fromRed) * pcent + fromRed + let green = (toGreen - fromGreen) * pcent + fromGreen + let blue = (toBlue - fromBlue) * pcent + fromBlue + let alpha = (toAlpha - fromAlpha) * pcent + fromAlpha + + return UIColor(red: red, green: green, blue: blue, alpha: alpha) + } +} diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsPopup/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsDrawer/Contents.json similarity index 100% rename from Sources/Shared/Resources/Assets.xcassets/AssetsPopup/Contents.json rename to Sources/Shared/Resources/Assets.xcassets/AssetsDrawer/Contents.json diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsPopup/popup_negative.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsDrawer/drawer_negative.imageset/Contents.json similarity index 100% rename from Sources/Shared/Resources/Assets.xcassets/AssetsPopup/popup_negative.imageset/Contents.json rename to Sources/Shared/Resources/Assets.xcassets/AssetsDrawer/drawer_negative.imageset/Contents.json diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsPopup/popup_negative.imageset/circle-bg 2-9.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsDrawer/drawer_negative.imageset/circle-bg 2-9.pdf similarity index 100% rename from Sources/Shared/Resources/Assets.xcassets/AssetsPopup/popup_negative.imageset/circle-bg 2-9.pdf rename to Sources/Shared/Resources/Assets.xcassets/AssetsDrawer/drawer_negative.imageset/circle-bg 2-9.pdf diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_success.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_accepted.imageset/Contents.json similarity index 85% rename from Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_success.imageset/Contents.json rename to Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_accepted.imageset/Contents.json index 902114534e813c60b003270ace57ee2b9b436fc4..5c2f805eb3317d819659b5d73f14b6a1b249804f 100644 --- a/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_success.imageset/Contents.json +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_accepted.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Icon-2.pdf", + "filename" : "Icon.pdf", "idiom" : "universal" } ], diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_accepted.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_accepted.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d1705804f448497cdc3bd9ea477758aa200dd519 --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_accepted.imageset/Icon.pdf @@ -0,0 +1,73 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 2.107544 3.523071 cm +0.000000 0.860000 0.516000 scn +11.785113 8.013858 m +3.771236 -0.000019 l +0.000000 3.771217 l +0.940000 4.711216 l +3.771236 1.886647 l +10.845114 8.953857 l +11.785113 8.013858 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 271 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 16.000000 16.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000000361 00000 n +0000000383 00000 n +0000000556 00000 n +0000000630 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +689 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_reject.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_failed_toaster.imageset/Contents.json similarity index 83% rename from Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_reject.imageset/Contents.json rename to Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_failed_toaster.imageset/Contents.json index 21dbcf85660641cd948448a96694c50b6f35b39c..5c2f805eb3317d819659b5d73f14b6a1b249804f 100644 --- a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_reject.imageset/Contents.json +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_failed_toaster.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Group 512227.pdf", + "filename" : "Icon.pdf", "idiom" : "universal" } ], diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_failed_toaster.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_failed_toaster.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..46d7e5af5bd507ee4c9de810d3e19a3215d179df --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_failed_toaster.imageset/Icon.pdf @@ -0,0 +1,125 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 3.000000 0.984436 cm +1.000000 1.000000 1.000000 scn +9.142858 19.015564 m +10.011101 19.511703 l +9.142858 21.031128 l +8.274614 19.511703 l +9.142858 19.015564 l +h +18.285715 3.015564 m +18.285715 2.015564 l +20.008894 2.015564 l +19.153957 3.511703 l +18.285715 3.015564 l +h +0.000000 3.015564 m +-0.868243 3.511703 l +-1.723180 2.015564 l +0.000000 2.015564 l +0.000000 3.015564 l +h +8.274614 18.519424 m +17.417471 2.519424 l +19.153957 3.511703 l +10.011101 19.511703 l +8.274614 18.519424 l +h +18.285715 4.015564 m +0.000000 4.015564 l +0.000000 2.015564 l +18.285715 2.015564 l +18.285715 4.015564 l +h +0.868243 2.519424 m +10.011101 18.519424 l +8.274614 19.511703 l +-0.868243 3.511703 l +0.868243 2.519424 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 12.142822 8.952393 cm +1.000000 1.000000 1.000000 scn +-1.000000 5.333328 m +-1.000000 -0.000005 l +1.000000 -0.000005 l +1.000000 5.333328 l +-1.000000 5.333328 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 11.380859 6.285713 cm +1.000000 1.000000 1.000000 scn +0.761905 0.000002 m +1.182693 0.000002 1.523810 0.341118 1.523810 0.761907 c +1.523810 1.182695 1.182693 1.523811 0.761905 1.523811 c +0.341116 1.523811 0.000000 1.182695 0.000000 0.761907 c +0.000000 0.341118 0.341116 0.000002 0.761905 0.000002 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 1312 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001402 00000 n +0000001425 00000 n +0000001598 00000 n +0000001672 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1731 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_accept.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_sent_toaster.imageset/Contents.json similarity index 83% rename from Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_accept.imageset/Contents.json rename to Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_sent_toaster.imageset/Contents.json index 664972f1767ea74f3e8f9585a7cd849c8cbc81d5..5c2f805eb3317d819659b5d73f14b6a1b249804f 100644 --- a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_accept.imageset/Contents.json +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_sent_toaster.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Group 512226.pdf", + "filename" : "Icon.pdf", "idiom" : "universal" } ], diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_sent_toaster.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_sent_toaster.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..af2137c18f1413f00b94c0def9c115b6d099ebfe --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/request_sent_toaster.imageset/Icon.pdf @@ -0,0 +1,101 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 3.000000 4.599083 cm +1.000000 1.000000 1.000000 scn +18.000000 9.500935 m +18.000000 1.800917 l +18.000000 0.806804 17.194111 0.000917 16.199999 0.000917 c +1.800000 0.000917 l +0.805887 0.000917 0.000000 0.806804 0.000000 1.800917 c +0.000000 12.679215 l +0.041948 13.642624 0.835680 14.401828 1.800000 14.400917 c +12.000000 14.400917 l +12.000000 13.766369 12.118205 13.159429 12.333797 12.600916 c +2.520000 12.600916 l +9.000000 8.280915 l +13.238572 11.106629 l +13.656529 10.629790 14.163707 10.233110 14.734041 9.942654 c +9.000000 6.120915 l +1.800000 10.919716 l +1.800000 1.800916 l +16.199999 1.800916 l +16.199999 9.464573 l +16.460485 9.422684 16.727701 9.400917 17.000000 9.400917 c +17.342466 9.400917 17.676889 9.435347 18.000000 9.500935 c +h +f* +n +Q +q +1.000000 0.000000 -0.000000 1.000000 16.000000 16.000000 cm +1.000000 1.000000 1.000000 scn +0.000000 3.000000 m +2.550000 3.000000 l +2.550000 0.000000 l +5.450000 0.000000 l +5.450000 3.000000 l +8.000000 3.000000 l +4.000000 7.000000 l +0.000000 3.000000 l +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1076 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001166 00000 n +0000001189 00000 n +0000001362 00000 n +0000001436 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1495 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_accept.imageset/Group 512226.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_accept.imageset/Group 512226.pdf deleted file mode 100644 index ee743a952a59a1a649b0a3268ad1f02d4c41f809..0000000000000000000000000000000000000000 --- a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_accept.imageset/Group 512226.pdf +++ /dev/null @@ -1,113 +0,0 @@ -%PDF-1.7 - -1 0 obj - << >> -endobj - -2 0 obj - << /Length 3 0 R >> -stream -/DeviceRGB CS -/DeviceRGB cs -q -32.000000 16.000000 m -32.000000 7.163445 24.836555 0.000000 16.000000 0.000000 c -7.163444 0.000000 0.000000 7.163445 0.000000 16.000000 c -0.000000 24.836555 7.163444 32.000000 16.000000 32.000000 c -24.836555 32.000000 32.000000 24.836555 32.000000 16.000000 c -h -W* -n -q -1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm -0.929167 0.929167 0.929167 scn -31.000000 16.000000 m -31.000000 7.715729 24.284271 1.000000 16.000000 1.000000 c -16.000000 -1.000000 l -25.388842 -1.000000 33.000000 6.611158 33.000000 16.000000 c -31.000000 16.000000 l -h -16.000000 1.000000 m -7.715729 1.000000 1.000000 7.715729 1.000000 16.000000 c --1.000000 16.000000 l --1.000000 6.611158 6.611159 -1.000000 16.000000 -1.000000 c -16.000000 1.000000 l -h -1.000000 16.000000 m -1.000000 24.284271 7.715729 31.000000 16.000000 31.000000 c -16.000000 33.000000 l -6.611159 33.000000 -1.000000 25.388840 -1.000000 16.000000 c -1.000000 16.000000 l -h -16.000000 31.000000 m -24.284271 31.000000 31.000000 24.284271 31.000000 16.000000 c -33.000000 16.000000 l -33.000000 25.388840 25.388842 33.000000 16.000000 33.000000 c -16.000000 31.000000 l -h -f -n -Q -Q -q -1.000000 0.000000 -0.000000 1.000000 7.161133 9.284668 cm -0.050980 0.725490 0.796078 scn -17.677670 12.020786 m -5.656854 -0.000029 l -0.000000 5.656826 l -1.410000 7.066825 l -5.656854 2.829971 l -16.267670 13.430786 l -17.677670 12.020786 l -h -f -n -Q - -endstream -endobj - -3 0 obj - 1392 -endobj - -4 0 obj - << /Annots [] - /Type /Page - /MediaBox [ 0.000000 0.000000 32.000000 32.000000 ] - /Resources 1 0 R - /Contents 2 0 R - /Parent 5 0 R - >> -endobj - -5 0 obj - << /Kids [ 4 0 R ] - /Count 1 - /Type /Pages - >> -endobj - -6 0 obj - << /Type /Catalog - /Pages 5 0 R - >> -endobj - -xref -0 7 -0000000000 65535 f -0000000010 00000 n -0000000034 00000 n -0000001482 00000 n -0000001505 00000 n -0000001678 00000 n -0000001752 00000 n -trailer -<< /ID [ (some) (id) ] - /Root 6 0 R - /Size 7 ->> -startxref -1811 -%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_reject.imageset/Group 512227.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_reject.imageset/Group 512227.pdf deleted file mode 100644 index c244d6bacadeccbbff8b27b77b3c80d349c78dc1..0000000000000000000000000000000000000000 --- a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_reject.imageset/Group 512227.pdf +++ /dev/null @@ -1,119 +0,0 @@ -%PDF-1.7 - -1 0 obj - << >> -endobj - -2 0 obj - << /Length 3 0 R >> -stream -/DeviceRGB CS -/DeviceRGB cs -q -32.000000 16.000000 m -32.000000 7.163445 24.836555 0.000000 16.000000 0.000000 c -7.163444 0.000000 0.000000 7.163445 0.000000 16.000000 c -0.000000 24.836555 7.163444 32.000000 16.000000 32.000000 c -24.836555 32.000000 32.000000 24.836555 32.000000 16.000000 c -h -W* -n -q -1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm -0.929167 0.929167 0.929167 scn -31.000000 16.000000 m -31.000000 7.715729 24.284271 1.000000 16.000000 1.000000 c -16.000000 -1.000000 l -25.388842 -1.000000 33.000000 6.611158 33.000000 16.000000 c -31.000000 16.000000 l -h -16.000000 1.000000 m -7.715729 1.000000 1.000000 7.715729 1.000000 16.000000 c --1.000000 16.000000 l --1.000000 6.611158 6.611159 -1.000000 16.000000 -1.000000 c -16.000000 1.000000 l -h -1.000000 16.000000 m -1.000000 24.284271 7.715729 31.000000 16.000000 31.000000 c -16.000000 33.000000 l -6.611159 33.000000 -1.000000 25.388840 -1.000000 16.000000 c -1.000000 16.000000 l -h -16.000000 31.000000 m -24.284271 31.000000 31.000000 24.284271 31.000000 16.000000 c -33.000000 16.000000 l -33.000000 25.388840 25.388842 33.000000 16.000000 33.000000 c -16.000000 31.000000 l -h -f -n -Q -Q -q -1.000000 0.000000 -0.000000 1.000000 11.000000 11.000000 cm -0.820833 0.177847 0.293585 scn -8.590000 10.000000 m -5.000000 6.410000 l -1.410000 10.000000 l -0.000000 8.590000 l -3.590000 5.000000 l -0.000000 1.410000 l -1.410000 0.000000 l -5.000000 3.590000 l -8.590000 0.000000 l -10.000000 1.410000 l -6.410000 5.000000 l -10.000000 8.590000 l -8.590000 10.000000 l -h -f -n -Q - -endstream -endobj - -3 0 obj - 1512 -endobj - -4 0 obj - << /Annots [] - /Type /Page - /MediaBox [ 0.000000 0.000000 32.000000 32.000000 ] - /Resources 1 0 R - /Contents 2 0 R - /Parent 5 0 R - >> -endobj - -5 0 obj - << /Kids [ 4 0 R ] - /Count 1 - /Type /Pages - >> -endobj - -6 0 obj - << /Type /Catalog - /Pages 5 0 R - >> -endobj - -xref -0 7 -0000000000 65535 f -0000000010 00000 n -0000000034 00000 n -0000001602 00000 n -0000001625 00000 n -0000001798 00000 n -0000001872 00000 n -trailer -<< /ID [ (some) (id) ] - /Root 6 0 R - /Size 7 ->> -startxref -1931 -%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_resend.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_resend.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..5c2f805eb3317d819659b5d73f14b6a1b249804f --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_resend.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_resend.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_resend.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..175610999e57bd48f558709604c2717912ceabbb --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_resend.imageset/Icon.pdf @@ -0,0 +1,106 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 4.166748 1.584803 cm +0.000000 0.827451 0.929412 scn +12.500000 8.853957 m +12.500000 10.477291 11.985000 11.684790 10.778333 12.890623 c +10.452499 13.215624 9.925834 13.215624 9.600000 12.890623 c +9.274167 12.564791 9.274167 12.037291 9.600000 11.711457 c +10.499166 10.813124 10.833333 10.038958 10.833333 8.853957 c +10.833333 7.629790 10.356667 6.478957 9.490833 5.613957 c +8.654166 4.778124 7.759167 4.362290 6.634167 4.286457 c +7.672500 5.325624 l +7.998333 5.651457 7.998333 6.178123 7.672500 6.503957 c +7.346666 6.829790 6.820000 6.829790 6.494167 6.503957 c +3.405000 3.414790 l +6.494167 0.325623 l +6.656667 0.163124 6.870000 0.081457 7.083333 0.081457 c +7.296667 0.081457 7.510000 0.163124 7.672500 0.325623 c +7.998333 0.651457 7.998333 1.178125 7.672500 1.503958 c +6.558333 2.617290 l +8.160833 2.685623 9.509999 3.277291 10.669166 4.434792 c +11.849999 5.614791 12.500000 7.183957 12.500000 8.853957 c +12.500000 8.853957 l +h +1.666667 8.831457 m +1.666667 10.055624 2.143333 11.206457 3.009167 12.072290 c +3.850000 12.913124 4.752500 13.328957 5.890000 13.400623 c +4.827500 12.338123 l +4.501667 12.012291 4.501667 11.485623 4.827500 11.159790 c +4.990000 10.996456 5.203333 10.914790 5.416667 10.914790 c +5.630000 10.914790 5.843333 10.996457 6.005833 11.158957 c +9.094999 14.248123 l +6.005833 17.337290 l +5.680000 17.663124 5.153333 17.663124 4.827500 17.337290 c +4.501667 17.011457 4.501667 16.484791 4.827500 16.158957 c +5.920000 15.066457 l +4.325000 14.994790 2.982500 14.403124 1.830833 13.250624 c +0.650000 12.070623 0.000000 10.501457 0.000000 8.831457 c +0.000000 7.208124 0.515000 6.000624 1.721667 4.794790 c +1.884167 4.632291 2.097500 4.551457 2.310833 4.551457 c +2.524166 4.551457 2.737500 4.633123 2.900000 4.795624 c +3.225833 5.121457 3.225833 5.648956 2.900000 5.974790 c +2.000833 6.872290 1.666667 7.646457 1.666667 8.831457 c +1.666667 8.831457 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 1937 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 20.000000 20.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000002027 00000 n +0000002050 00000 n +0000002223 00000 n +0000002297 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2356 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_resent.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_resent.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..5c2f805eb3317d819659b5d73f14b6a1b249804f --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_resent.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_resent.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_resent.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e439771cd7d8e375b501a0f3d176cdcd69f01464 --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_resent.imageset/Icon.pdf @@ -0,0 +1,73 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 2.634277 4.403830 cm +0.000000 0.860000 0.516000 scn +14.731392 10.017345 m +4.714046 0.000000 l +0.000000 4.714045 l +1.175000 5.889044 l +4.714046 2.358334 l +13.556392 11.192345 l +14.731392 10.017345 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 273 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 20.000000 20.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000000363 00000 n +0000000385 00000 n +0000000558 00000 n +0000000632 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +691 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_failed.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_failed.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..d9a97df899595ebc48148baa9cbedb9be8f763fa --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_failed.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_failed.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_failed.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6ec367e5ca0908ecab77ea83607868ff0eeef725 --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_failed.imageset/Icon.pdf @@ -0,0 +1,125 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 3.000000 0.984436 cm +0.000000 0.827451 0.929412 scn +9.142858 19.015564 m +10.011101 19.511703 l +9.142858 21.031128 l +8.274614 19.511703 l +9.142858 19.015564 l +h +18.285715 3.015564 m +18.285715 2.015564 l +20.008894 2.015564 l +19.153957 3.511703 l +18.285715 3.015564 l +h +0.000000 3.015564 m +-0.868243 3.511703 l +-1.723180 2.015564 l +0.000000 2.015564 l +0.000000 3.015564 l +h +8.274614 18.519424 m +17.417471 2.519424 l +19.153957 3.511703 l +10.011101 19.511703 l +8.274614 18.519424 l +h +18.285715 4.015564 m +0.000000 4.015564 l +0.000000 2.015564 l +18.285715 2.015564 l +18.285715 4.015564 l +h +0.868243 2.519424 m +10.011101 18.519424 l +8.274614 19.511703 l +-0.868243 3.511703 l +0.868243 2.519424 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 12.142822 8.952377 cm +0.000000 0.827451 0.929412 scn +-1.000000 5.333336 m +-1.000000 0.000002 l +1.000000 0.000002 l +1.000000 5.333336 l +-1.000000 5.333336 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 11.380981 6.285713 cm +0.000000 0.827451 0.929412 scn +0.761905 0.000002 m +1.182693 0.000002 1.523810 0.341118 1.523810 0.761907 c +1.523810 1.182695 1.182693 1.523811 0.761905 1.523811 c +0.341116 1.523811 0.000000 1.182695 0.000000 0.761907 c +0.000000 0.341118 0.341116 0.000002 0.761905 0.000002 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 1310 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001400 00000 n +0000001423 00000 n +0000001596 00000 n +0000001670 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1729 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_received.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_received.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..d9a97df899595ebc48148baa9cbedb9be8f763fa --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_received.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_received.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_received.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..93c26724327f1f3972c8755f8ac1f7e3339a34af --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_received.imageset/Icon.pdf @@ -0,0 +1,101 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 3.000000 4.599121 cm +0.000000 0.827451 0.929412 scn +18.000000 9.500896 m +18.000000 1.800878 l +18.000000 0.806765 17.194111 0.000878 16.199999 0.000878 c +1.800000 0.000878 l +0.805887 0.000878 0.000000 0.806765 0.000000 1.800878 c +0.000000 12.679176 l +0.041948 13.642585 0.835680 14.401790 1.800000 14.400878 c +12.000000 14.400878 l +12.000000 13.766330 12.118205 13.159389 12.333797 12.600877 c +2.520000 12.600877 l +9.000000 8.280876 l +13.238572 11.106590 l +13.656529 10.629751 14.163707 10.233070 14.734041 9.942616 c +9.000000 6.120876 l +1.800000 10.919677 l +1.800000 1.800877 l +16.199999 1.800877 l +16.199999 9.464534 l +16.460485 9.422646 16.727701 9.400878 17.000000 9.400878 c +17.342466 9.400878 17.676889 9.435308 18.000000 9.500896 c +h +f* +n +Q +q +-1.000000 0.000000 -0.000000 -1.000000 24.000000 23.000000 cm +0.000000 0.827451 0.929412 scn +0.000000 3.000000 m +2.550000 3.000000 l +2.550000 0.000000 l +5.450000 0.000000 l +5.450000 3.000000 l +8.000000 3.000000 l +4.000000 7.000000 l +0.000000 3.000000 l +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1078 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001168 00000 n +0000001191 00000 n +0000001364 00000 n +0000001438 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1497 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_sent.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_sent.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..d9a97df899595ebc48148baa9cbedb9be8f763fa --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_sent.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_sent.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_sent.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ef3e9791d0e75a4f82fb9d91d2cc574f827fae61 --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_tab_sent.imageset/Icon.pdf @@ -0,0 +1,101 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 3.000000 4.599091 cm +0.000000 0.827451 0.929412 scn +18.000000 9.500927 m +18.000000 1.800908 l +18.000000 0.806795 17.194111 0.000909 16.199999 0.000909 c +1.800000 0.000909 l +0.805887 0.000909 0.000000 0.806795 0.000000 1.800908 c +0.000000 12.679207 l +0.041948 13.642615 0.835680 14.401820 1.800000 14.400908 c +12.000000 14.400908 l +12.000000 13.766360 12.118205 13.159420 12.333797 12.600907 c +2.520000 12.600907 l +9.000000 8.280907 l +13.238572 11.106621 l +13.656529 10.629782 14.163707 10.233101 14.734041 9.942646 c +9.000000 6.120907 l +1.800000 10.919707 l +1.800000 1.800907 l +16.199999 1.800907 l +16.199999 9.464564 l +16.460485 9.422676 16.727701 9.400908 17.000000 9.400908 c +17.342466 9.400908 17.676889 9.435339 18.000000 9.500927 c +h +f* +n +Q +q +1.000000 0.000000 -0.000000 1.000000 16.000000 16.000000 cm +0.000000 0.827451 0.929412 scn +0.000000 3.000000 m +2.550000 3.000000 l +2.550000 0.000000 l +5.450000 0.000000 l +5.450000 3.000000 l +8.000000 3.000000 l +4.000000 7.000000 l +0.000000 3.000000 l +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1076 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001166 00000 n +0000001189 00000 n +0000001362 00000 n +0000001436 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1495 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_verification_failed.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_verification_failed.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..5c2f805eb3317d819659b5d73f14b6a1b249804f --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_verification_failed.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_verification_failed.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_verification_failed.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..836dddebcdbbba8c6b8f09e680d00845b9b9ab22 --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRequests/requests_verification_failed.imageset/Icon.pdf @@ -0,0 +1,121 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +-1.000000 0.000000 -0.000000 -1.000000 20.000002 20.000000 cm +0.820833 0.177847 0.293585 scn +10.000000 0.000000 m +11.369956 0.000000 12.655137 0.261238 13.855544 0.783716 c +15.062733 1.306192 16.127501 2.028839 17.049847 2.951654 c +17.972195 3.867685 18.694473 4.929602 19.216684 6.137405 c +19.738894 7.345208 20.000000 8.634437 20.000000 10.005090 c +20.000000 11.375742 19.735504 12.661577 19.206511 13.862596 c +18.684299 15.070398 17.962021 16.135708 17.039675 17.058525 c +16.124109 17.981340 15.062733 18.700594 13.855544 19.216286 c +12.648355 19.738762 11.359783 20.000000 9.989827 20.000000 c +8.626653 20.000000 7.341472 19.738762 6.134283 19.216286 c +4.927094 18.700594 3.862326 17.981340 2.939980 17.058525 c +2.024415 16.135708 1.305527 15.070398 0.783316 13.862596 c +0.261105 12.661577 0.000000 11.375742 0.000000 10.005090 c +0.000000 8.634437 0.261105 7.345208 0.783316 6.137405 c +1.305527 4.929602 2.024415 3.867685 2.939980 2.951654 c +3.862326 2.028839 4.927094 1.306192 6.134283 0.783716 c +7.341472 0.261238 8.630044 0.000000 10.000000 0.000000 c +h +10.000000 2.106871 m +8.908104 2.106871 7.884028 2.310432 6.927772 2.717558 c +5.971516 3.124683 5.130553 3.687872 4.404883 4.407125 c +3.685995 5.133164 3.123093 5.974555 2.716175 6.931298 c +2.309257 7.888041 2.105798 8.912638 2.105798 10.005090 c +2.105798 11.097541 2.309257 12.122137 2.716175 13.078880 c +3.123093 14.035624 3.685995 14.873622 4.404883 15.592875 c +5.130553 16.318914 5.968125 16.885496 6.917599 17.292622 c +7.873856 17.699745 8.897931 17.903309 9.989827 17.903309 c +11.088505 17.903309 12.112580 17.699745 13.062055 17.292622 c +14.018312 16.885496 14.855884 16.318914 15.574771 15.592875 c +16.300440 14.873622 16.866734 14.035624 17.273651 13.078880 c +17.687351 12.122137 17.894201 11.097541 17.894201 10.005090 c +17.900984 8.912638 17.697525 7.888041 17.283825 6.931298 c +16.876907 5.974555 16.314005 5.133164 15.595117 4.407125 c +14.876229 3.687872 14.038657 3.124683 13.082400 2.717558 c +12.126144 2.310432 11.098678 2.106871 10.000000 2.106871 c +h +9.989827 8.458015 m +10.559511 8.458015 10.854527 8.753181 10.874873 9.343512 c +11.027467 13.842239 l +11.041031 14.140798 10.946083 14.385073 10.742624 14.575064 c +10.539165 14.765055 10.284842 14.860051 9.979654 14.860051 c +9.674466 14.860051 9.423533 14.765055 9.226856 14.575064 c +9.030180 14.391857 8.938623 14.150976 8.952188 13.852417 c +9.084435 9.333334 l +9.104781 8.749788 9.406578 8.458015 9.989827 8.458015 c +h +9.989827 5.241731 m +10.315361 5.241731 10.590031 5.340119 10.813835 5.536896 c +11.044422 5.740458 11.159715 6.001697 11.159715 6.320611 c +11.159715 6.632740 11.044422 6.890586 10.813835 7.094148 c +10.590031 7.297710 10.315361 7.399491 9.989827 7.399491 c +9.664293 7.399491 9.389624 7.297710 9.165819 7.094148 c +8.942014 6.890586 8.830112 6.632740 8.830112 6.320611 c +8.830112 6.008482 8.942014 5.750636 9.165819 5.547074 c +9.396405 5.343512 9.671075 5.241731 9.989827 5.241731 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 2966 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 20.000000 20.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000003056 00000 n +0000003079 00000 n +0000003252 00000 n +0000003326 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +3385 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_success.imageset/Icon-2.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_success.imageset/Icon-2.pdf deleted file mode 100644 index 92d56470fca57609cf16ad73999843373777acd9..0000000000000000000000000000000000000000 --- a/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_success.imageset/Icon-2.pdf +++ /dev/null @@ -1,163 +0,0 @@ -%PDF-1.7 - -1 0 obj - << >> -endobj - -2 0 obj - << /Length 3 0 R >> -stream -/DeviceRGB CS -/DeviceRGB cs -q -q -1.000000 0.000000 -0.000000 1.000000 3.161133 5.284424 cm -0.141667 0.141667 0.141667 scn -17.677670 12.020908 m -5.656854 0.000093 l -0.000000 5.656948 l -1.410000 7.066947 l -5.656854 2.830093 l -16.267670 13.430908 l -17.677670 12.020908 l -h -f -n -Q -20.838802 17.305332 m -8.817987 5.284517 l -3.161133 10.941372 l -4.571133 12.351371 l -8.817987 8.114517 l -19.428802 18.715332 l -20.838802 17.305332 l -h -W* -n -q -1.000000 0.000000 -0.000000 1.000000 3.161133 5.284424 cm -0.172549 0.752941 0.411765 scn -17.677670 12.020908 m -18.384777 11.313802 l -19.091883 12.020908 l -18.384777 12.728015 l -17.677670 12.020908 l -h -5.656854 0.000093 m -4.949748 -0.707013 l -5.656854 -1.414120 l -6.363961 -0.707013 l -5.656854 0.000093 l -h -0.000000 5.656948 m --0.707107 6.364055 l --1.414214 5.656948 l --0.707107 4.949841 l -0.000000 5.656948 l -h -1.410000 7.066947 m -2.116272 7.774887 l -1.409167 8.480328 l -0.702893 7.774054 l -1.410000 7.066947 l -h -5.656854 2.830093 m -4.950582 2.122153 l -5.657354 1.417046 l -6.363627 2.122653 l -5.656854 2.830093 l -h -16.267670 13.430908 m -16.974777 14.138015 l -16.268003 14.844789 l -15.560896 14.138349 l -16.267670 13.430908 l -h -16.970562 12.728015 m -4.949748 0.707200 l -6.363961 -0.707013 l -18.384777 11.313802 l -16.970562 12.728015 l -h -6.363961 0.707200 m -0.707107 6.364054 l --0.707107 4.949841 l -4.949748 -0.707013 l -6.363961 0.707200 l -h -0.707107 4.949841 m -2.117106 6.359840 l -0.702893 7.774054 l --0.707107 6.364055 l -0.707107 4.949841 l -h -0.703727 6.359007 m -4.950582 2.122153 l -6.363127 3.538033 l -2.116272 7.774887 l -0.703727 6.359007 l -h -6.363627 2.122653 m -16.974443 12.723468 l -15.560896 14.138349 l -4.950081 3.537534 l -6.363627 2.122653 l -h -15.560563 12.723802 m -16.970562 11.313802 l -18.384777 12.728015 l -16.974777 14.138015 l -15.560563 12.723802 l -h -f -n -Q -Q - -endstream -endobj - -3 0 obj - 1803 -endobj - -4 0 obj - << /Annots [] - /Type /Page - /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] - /Resources 1 0 R - /Contents 2 0 R - /Parent 5 0 R - >> -endobj - -5 0 obj - << /Kids [ 4 0 R ] - /Count 1 - /Type /Pages - >> -endobj - -6 0 obj - << /Type /Catalog - /Pages 5 0 R - >> -endobj - -xref -0 7 -0000000000 65535 f -0000000010 00000 n -0000000034 00000 n -0000001893 00000 n -0000001916 00000 n -0000002089 00000 n -0000002163 00000 n -trailer -<< /ID [ (some) (id) ] - /Root 6 0 R - /Size 7 ->> -startxref -2222 -%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsShared/shared_group.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsShared/shared_group.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..5c2f805eb3317d819659b5d73f14b6a1b249804f --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsShared/shared_group.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsShared/shared_group.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsShared/shared_group.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..224862e0fd534a93ea841aed70fee4feebbdaf96 --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsShared/shared_group.imageset/Icon.pdf @@ -0,0 +1,159 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 2.115479 cm +1.000000 1.000000 1.000000 scn +4.615508 7.499920 m +4.615508 9.093041 5.906988 10.384521 7.500110 10.384521 c +9.093231 10.384521 10.384712 9.093041 10.384712 7.499920 c +10.384712 5.906799 9.093231 4.615319 7.500110 4.615319 c +5.906988 4.615319 4.615508 5.906799 4.615508 7.499920 c +h +7.500110 9.230680 m +6.544237 9.230680 5.769349 8.455793 5.769349 7.499920 c +5.769349 6.544047 6.544237 5.769159 7.500110 5.769159 c +8.455983 5.769159 9.230871 6.544047 9.230871 7.499920 c +9.230871 8.455793 8.455983 9.230680 7.500110 9.230680 c +h +11.538552 7.499886 m +11.720737 7.499886 11.900332 7.456744 12.062639 7.373991 c +12.224945 7.291238 12.365348 7.171227 12.472357 7.023781 c +12.579365 6.876334 12.649937 6.705643 12.678295 6.525679 c +12.706654 6.345715 12.691993 6.161593 12.635513 5.988384 c +12.579034 5.815175 12.482340 5.657803 12.353347 5.529148 c +12.224355 5.400493 12.066729 5.304211 11.893373 5.248186 c +11.720016 5.192160 11.535857 5.177982 11.355968 5.206811 c +11.176079 5.235641 11.005553 5.306633 10.858387 5.414027 c +10.178222 4.481973 l +10.178823 4.481534 l +10.473026 4.266985 10.813828 4.125098 11.173372 4.067476 c +11.212999 4.061125 11.252730 4.055818 11.292524 4.051550 c +11.614036 4.017076 11.939680 4.050513 12.248215 4.150227 c +12.594937 4.262281 12.910197 4.454848 13.168191 4.712167 c +13.426184 4.969485 13.619576 5.284239 13.732540 5.630668 c +13.845502 5.977096 13.874825 6.345350 13.818106 6.705289 c +13.761388 7.065228 13.620239 7.406620 13.406217 7.701522 c +13.215767 7.963944 12.972426 8.182915 12.692393 8.344590 c +12.657733 8.364601 12.622510 8.383735 12.586757 8.401964 c +12.262135 8.567474 11.902933 8.653726 11.538552 8.653726 c +11.538552 7.499886 l +h +13.845140 -0.000044 m +13.845140 0.302862 13.785478 0.602800 13.669561 0.882648 c +13.553644 1.162497 13.383743 1.416773 13.169556 1.630960 c +12.955370 1.845146 12.701093 2.015048 12.421246 2.130964 c +12.141397 2.246881 11.841457 2.306543 11.538552 2.306543 c +11.538552 3.461478 l +11.932058 3.461478 12.322229 3.394384 12.692393 3.263511 c +12.749838 3.243201 12.806801 3.221355 12.863220 3.197986 c +13.283191 3.024028 13.664786 2.769054 13.986218 2.447622 c +14.307652 2.126190 14.562624 1.744595 14.736583 1.324623 c +14.759952 1.268204 14.781797 1.211243 14.802107 1.153797 c +14.932980 0.783632 15.000074 0.393463 15.000074 -0.000044 c +13.845140 -0.000044 l +h +10.384712 -0.000044 m +11.538552 -0.000044 l +11.538552 2.230327 9.730480 4.038398 7.500110 4.038398 c +5.269740 4.038398 3.461667 2.230327 3.461667 -0.000044 c +4.615508 -0.000044 l +4.615508 1.593078 5.906988 2.884558 7.500110 2.884558 c +9.093231 2.884558 10.384712 1.593078 10.384712 -0.000044 c +h +3.461522 7.500342 m +3.279337 7.500342 3.099742 7.457201 2.937436 7.374448 c +2.775130 7.291695 2.634727 7.171684 2.527718 7.024238 c +2.420710 6.876791 2.350138 6.706100 2.321779 6.526135 c +2.293421 6.346171 2.308082 6.162050 2.364562 5.988841 c +2.421041 5.815632 2.517735 5.658259 2.646727 5.529604 c +2.775720 5.400949 2.933346 5.304668 3.106702 5.248642 c +3.280058 5.192616 3.464217 5.178438 3.644107 5.207268 c +3.823996 5.236098 3.994521 5.307089 4.141687 5.414483 c +4.821853 4.482430 l +4.821251 4.481991 l +4.527048 4.267442 4.186246 4.125555 3.826702 4.067932 c +3.787075 4.061581 3.747344 4.056274 3.707550 4.052007 c +3.386039 4.017533 3.060395 4.050969 2.751860 4.150683 c +2.405137 4.262738 2.089877 4.455305 1.831883 4.712623 c +1.573891 4.969941 1.380499 5.284696 1.267535 5.631124 c +1.154572 5.977552 1.125250 6.345807 1.181969 6.705746 c +1.238687 7.065684 1.379835 7.407077 1.593858 7.701979 c +1.784308 7.964401 2.027648 8.183371 2.307681 8.345047 c +2.342341 8.365059 2.377564 8.384192 2.413318 8.402421 c +2.737940 8.567931 3.097142 8.654182 3.461522 8.654182 c +3.461522 7.500342 l +h +1.330513 0.883106 m +1.214597 0.603256 1.154934 0.303318 1.154934 0.000412 c +0.000000 0.000412 l +0.000000 0.393919 0.067095 0.784090 0.197968 1.154253 c +0.218277 1.211699 0.240123 1.268661 0.263492 1.325079 c +0.437451 1.745051 0.692424 2.126646 1.013856 2.448078 c +1.335288 2.769510 1.716884 3.024484 2.136855 3.198442 c +2.193274 3.221811 2.250237 3.243657 2.307681 3.263967 c +2.677845 3.394840 3.068016 3.461935 3.461522 3.461935 c +3.461522 2.306999 l +3.158617 2.306999 2.858678 2.247337 2.578829 2.131421 c +2.298982 2.015504 2.044705 1.845602 1.830519 1.631416 c +1.616332 1.417229 1.446431 1.162952 1.330513 0.883106 c +h +f* +n +Q + +endstream +endobj + +3 0 obj + 4438 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 15.000000 15.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000004528 00000 n +0000004551 00000 n +0000004724 00000 n +0000004798 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +4857 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/ColorsNeutral/neutral_overlay.colorset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/ColorsNeutral/neutral_overlay.colorset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..db61e87dbab2ab2b058bfdc7a8bf315a759c1693 --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/ColorsNeutral/neutral_overlay.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.950", + "blue" : "0x38", + "green" : "0x33", + "red" : "0x30" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Shared/Resources/Assets.xcassets/ColorsNeutral/neutral_secondary_alternative.colorset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/ColorsNeutral/neutral_secondary_alternative.colorset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..4ea85ca7258fe0b522841921ba93c1e58da4bc06 --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/ColorsNeutral/neutral_secondary_alternative.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x78", + "green" : "0x75", + "red" : "0x72" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x78", + "green" : "0x75", + "red" : "0x72" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Shared/Resources/en.lproj/Localizable.strings b/Sources/Shared/Resources/en.lproj/Localizable.strings index 152ed8db26af658560e757a240688efbd9d8cd4f..58fb6862cc40f861409a72abe30398aa6614c75e 100644 --- a/Sources/Shared/Resources/en.lproj/Localizable.strings +++ b/Sources/Shared/Resources/en.lproj/Localizable.strings @@ -67,7 +67,7 @@ "chatList.deleteGroup.title" = "Are you sure you want to delete a group?"; "chatList.deleteGroup.subtitle" -= "This will not only delete the messages sent to this group locally but will also remove you from it."; += "You will exit this group and you won’t receive any more messages from this group and your group messages will be lost."; "chatList.deleteGroup.action" = "Leave group"; @@ -289,25 +289,93 @@ "requests.title" = "Requests"; +"requests.cell.resent" += "Resent"; +"requests.cell.verifying" += "Verifying"; +"requests.cell.failedVerification" += "Failed to verify"; +"requests.cell.requested" += "Resend"; +"requests.cell.failedRequest" += "Retry"; // RequestsFeature - Received "requests.received.title" = "Received"; "requests.received.placeholder" -= "No requests are currently waiting for review"; += "No recent requests received"; +"requests.received.hidden" += "Show hidden requests"; + +"requests.received.verifying.title" += "Verifying"; +"requests.received.verifying.subtitle" += "We are working on verifying the request to make sure it is not a spam. Please check again shortly."; +"requests.received.verifying.action" += "OK"; + +"requests.confirmations.toaster" += "Accepted your request"; // RequestsFeature - Sent "requests.sent.title" = "Sent"; "requests.sent.action" -= "Add contact"; += "Search for connections"; +"requests.sent.empty" += "You haven't sent any requests"; +"requests.sent.toast.resent" += "Request successfully resent to %@"; // RequestsFeature - Failed "requests.failed.title" = "Failed"; +"requests.failed.empty" += "There are no failed requests"; +"requests.failed.toast" += "Your contact request to %@ has failed."; + +"requests.drawer.single.title" += "REQUEST FROM"; +"requests.drawer.single.email" += "EMAIL ADDRESS"; +"requests.drawer.single.phone" += "PHONE NUMBER"; +"requests.drawer.single.nickname" += "Edit your new contact’s nickname."; +"requests.drawer.single.accept" += "Accept and Save"; +"requests.drawer.single.hide" += "Hide Request"; + +"requests.drawer.group.title" += "GROUP CHAT REQUEST"; +"requests.drawer.group.accept" += "Accept"; +"requests.drawer.group.hide" += "Hide Request"; + +"requests.drawer.single.success.title" += "NEW CONNECTION"; +"requests.drawer.single.success.subtitle" += "Is now a connection, would you like to send a message?"; +"requests.drawer.single.success.send" += "Send a Message"; +"requests.drawer.single.success.later" += "Later"; + +"requests.drawer.group.success.title" += "ACCEPTED"; +"requests.drawer.group.success.subtitle" += "You are now part of the group chat. Would you like to check it out?"; +"requests.drawer.group.success.send" += "Go to Chat"; +"requests.drawer.group.success.later" += "Later"; // ProfileFeature @@ -413,9 +481,9 @@ "settings.delete" = "Delete account"; -"settings.popup.title" +"settings.drawer.title" = "Do you want to open %@?"; -"settings.popup.subtitle" +"settings.drawer.subtitle" = "%@ will be opened using your default browser"; "settings.advanced.title" @@ -442,7 +510,7 @@ "backup.config.backupNow" = "Backup now"; "backup.config.disclaimer" -= "Content backed up in %@ is not protected by xx network end-to-end encryption."; += "Content backed up in %@ is encrypted with your passphrase in a brute force resistant manner"; "backup.config.latestBackup" = "LATEST BACKUP"; "backup.config.frequency" @@ -475,27 +543,27 @@ "settings.delete.info.subtitle" = "On deletion, all keys for your account are purged from your phone. This action will not notify your contacts. Your keys and any registered emails or phone numbers are removed from the user discovery system."; -"settings.infoPopUp.notifications.title" +"settings.infoDrawer.notifications.title" = "Notifications"; -"settings.infoPopUp.notifications.subtitle" +"settings.infoDrawer.notifications.subtitle" = "Selecting this setting will share your account ID and unique phone identifiers with a notification service run by the xx network team. However, these details are obfuscated via an #ID collision system# when you receive a notification. As a result, both the notifications service and your notifications provider (Firebase on Android, Apple on iOS) cannot tell exactly when you receive a message."; -"settings.infoPopUp.biometrics.title" +"settings.infoDrawer.biometrics.title" = "Biometric Authentication"; -"settings.infoPopUp.biometrics.subtitle" +"settings.infoDrawer.biometrics.subtitle" = "Biometric authentication is stored through the native system on your phone, not by the xx messenger app. The xx network cannot access your biometric authentication data."; -"settings.infoPopUp.icognito.title" +"settings.infoDrawer.icognito.title" = "Predictive Text"; -"settings.infoPopUp.icognito.subtitle" +"settings.infoDrawer.icognito.subtitle" = "Predictive text is a feature offered by your phone’s operating system. It involves storing entered text within your phone’s operating system and may involve sending it to remote servers. As a result, it may significantly degrade your privacy."; -"settings.infoPopUp.traffic.title" +"settings.infoDrawer.traffic.title" = "Cover Traffic"; -"settings.infoPopUp.traffic.subtitle" +"settings.infoDrawer.traffic.subtitle" = "Cover Traffic hides when you are sending messages by randomly sending messages to random users. Other user’s phones will pick up these messages but they will not see them or know you sent them. As a result, it not only hides when you send messages, but helps hide who you are talking to. #Read more about it#"; -"settings.infoPopUp.privacy.title" +"settings.infoDrawer.privacy.title" = "Please note"; -"settings.infoPopUp.privacy.subtitle" +"settings.infoDrawer.privacy.subtitle" = "Because xx messenger does not capture your personal data or save your private keys, we will not be able to, at this time, help new users recover their account in case of being locked out, changing devices, etc. Account recovery support that continues to protect your privacy and personal data will be coming soon."; -"settings.infoPopUp.action" +"settings.infoDrawer.action" = "Got it"; // Validator @@ -733,25 +801,25 @@ = "Contacts"; "createGroup.create" = "Create"; -"createGroup.popup.title" +"createGroup.drawer.title" = "Create Group"; -"createGroup.popup.subtitle" +"createGroup.drawer.subtitle" = "You are about to create a group message with %@ users. The information below will be visible to all members of the group."; -"createGroup.popup.input" +"createGroup.drawer.input" = "Group Name"; -"createGroup.popup.otherInput" +"createGroup.drawer.otherInput" = "Initial Message"; -"createGroup.popup.placeholder" +"createGroup.drawer.placeholder" = "Secret Family"; -"createGroup.popup.otherPlaceholder" +"createGroup.drawer.otherPlaceholder" = "Say hi to your friends!"; -"createGroup.popup.minimum" +"createGroup.drawer.minimum" = "Needs to be at least 4 chars"; -"createGroup.popup.maximum" +"createGroup.drawer.maximum" = "Needs to be 20 chars max or 256 bytes"; -"createGroup.popup.action" +"createGroup.drawer.action" = "Create Group"; -"createGroup.popup.cancel" +"createGroup.drawer.cancel" = "Cancel"; // SearchFeature @@ -760,11 +828,11 @@ = "Search"; "contactSearch.placeholder.title" = "Searching is private by nature. The network cannot identify who a search request came from."; -"contactSearch.placeholder.popup.title" +"contactSearch.placeholder.drawer.title" = "Search"; -"contactSearch.placeholder.popup.subtitle" +"contactSearch.placeholder.drawer.subtitle" = "You can search for users by their username, email, or phone number using the xx network’s #Anonymous Data Retrieval protocol# which keeps a user’s identity anonymous while requesting data. All sent requests contain salted hashes of what you are searching for. Raw data on emails, usernames, and phone numbers do not leave your phone."; -"contactSearch.placeholder.popup.action" +"contactSearch.placeholder.drawer.action" = "Got it"; "contactSearch.filter.phone" = "Phone"; @@ -776,6 +844,22 @@ = "User"; "contactSearch.noneFound" = "There are no users with that %@."; +"contactSearch.requestDrawer.title" += "Request Contact"; +"contactSearch.requestDrawer.email" += "EMAIL ADDRESS"; +"contactSearch.requestDrawer.phone" += "PHONE NUMBER"; +"contactSearch.requestDrawer.send" += "Send Contact Request"; +"contactSearch.requestDrawer.cancel" += "Cancel"; +"contactSearch.nicknameDrawer.title" += "Add a nickname"; +"contactSearch.nicknameDrawer.subtitle" += "Edit your new contact’s nickname so you know who they are."; +"contactSearch.nicknameDrawer.save" += "Save"; // Countries @@ -903,12 +987,12 @@ "accessibility.createGroup.create" = "createGroup.create"; -"accessibility.createGroup.popup.input" -= "createGroup.popup.input"; -"accessibility.createGroup.popup.otherInput" -= "createGroup.popup.otherInput"; -"accessibility.createGroup.popup.create" -= "createGroup.popup.create"; +"accessibility.createGroup.drawer.input" += "createGroup.drawer.input"; +"accessibility.createGroup.drawer.otherInput" += "createGroup.drawer.otherInput"; +"accessibility.createGroup.drawer.create" += "createGroup.drawer.create"; // - Search diff --git a/Sources/Shared/Views/AvatarView.swift b/Sources/Shared/Views/AvatarView.swift index 9072bc522122ed06ef6fc21f5496a525f9e015c3..a8fcfc1f32e5345697136110a71d842028da7a40 100644 --- a/Sources/Shared/Views/AvatarView.swift +++ b/Sources/Shared/Views/AvatarView.swift @@ -1,75 +1,88 @@ import UIKit public final class AvatarView: UIView { - // MARK: UI - - let initials = UILabel() - let image = UIImageView() + public enum Size { + case small + case medium + case large + } - // MARK: Lifecycle + let imageView = UIImageView() + let monogramLabel = UILabel() + let iconImageView = UIImageView() public init() { super.init(frame: .zero) - setup() - } - required init?(coder: NSCoder) { nil } + layer.masksToBounds = true + backgroundColor = Asset.brandPrimary.color - // MARK: Public - - public func set( - cornerRadius: CGFloat = 16, - fontSize: CGFloat = 14.0, - username: String, - image: Data? - ) { - layer.cornerRadius = cornerRadius - self.initials.text = "\(username.prefix(2))".uppercased() - self.image.image = image != nil ? UIImage(data: image!) : nil - self.initials.font = Fonts.Mulish.semiBold.font(size: fontSize) - self.backgroundColor = username.getColor() + imageView.contentMode = .scaleAspectFill + monogramLabel.textColor = Asset.neutralWhite.color + + addSubview(imageView) + addSubview(iconImageView) + addSubview(monogramLabel) + + imageView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + monogramLabel.snp.makeConstraints { + $0.center.equalToSuperview() + } + + iconImageView.snp.makeConstraints { + $0.center.equalToSuperview() + } } + required init?(coder: NSCoder) { nil } + public func prepareForReuse() { - image.image = nil - initials.text = nil + imageView.image = nil + monogramLabel.text = nil + iconImageView.image = nil } - // MARK: Private + public func setupProfile(title: String, image: Data?, size: AvatarView.Size) { + iconImageView.image = nil + monogramLabel.text = "\(title.prefix(2))".uppercased() - private func setup() { - layer.masksToBounds = true - image.contentMode = .scaleAspectFill - backgroundColor = Asset.accentSafe.color + // TODO: What are the font sizes and corner radius for small/medium avatars? - initials.textColor = Asset.neutralWhite.color - initials.font = Fonts.Mulish.semiBold.font(size: 14.0) + switch size { + case .small: + layer.cornerRadius = 13.0 + monogramLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) + case .medium: + layer.cornerRadius = 13.0 + monogramLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) + case .large: + layer.cornerRadius = 18.0 + monogramLabel.font = Fonts.Mulish.semiBold.font(size: 16.0) + } - addSubview(initials) - addSubview(image) + guard let image = image else { + imageView.image = nil + return + } - initials.snp.makeConstraints { $0.center.equalToSuperview() } - image.snp.makeConstraints { $0.edges.equalToSuperview() } + imageView.image = UIImage(data: image) } -} -private extension String { - func getColor() -> UIColor { - switch first?.uppercased() { - case "A", "G", "M", "S", "W": - return Asset.brandPrimary.color - case "B", "H", "N", "T", "Y": - return Asset.brandDefault.color - case "C", "I", "O", "U": - return Asset.accentDanger.color - case "D", "J", "P", "V": - return Asset.accentSafe.color - case "E", "K", "Q", "X": - return Asset.accentSuccess.color - case "F", "L", "R", "Z": - return Asset.accentWarning.color - default: - return Asset.neutralActive.color + public func setupGroup(size: AvatarView.Size) { + switch size { + case .small: + layer.cornerRadius = 13.0 + case .medium: + layer.cornerRadius = 13.0 + case .large: + layer.cornerRadius = 18.0 } + + imageView.image = nil + monogramLabel.text = nil + iconImageView.image = Asset.sharedGroup.image } } diff --git a/Sources/Shared/Views/SmallAvatarAndTitleCell.swift b/Sources/Shared/Views/SmallAvatarAndTitleCell.swift index 1f97e6221fc363802caf9eb93a8f764e26f7c133..4c37a96467729da2f4234ad646287a6127455f63 100644 --- a/Sources/Shared/Views/SmallAvatarAndTitleCell.swift +++ b/Sources/Shared/Views/SmallAvatarAndTitleCell.swift @@ -1,63 +1,50 @@ import UIKit public final class SmallAvatarAndTitleCell: UITableViewCell { - // MARK: UI - - public let title = UILabel() - public let avatar = AvatarView() - private let separator = UIView() - - // MARK: Lifecycle + let separatorView = UIView() + public let titleLabel = UILabel() + public let avatarView = AvatarView() public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) - setup() - } - - required init?(coder: NSCoder) { nil } - - public override func prepareForReuse() { - super.prepareForReuse() - title.text = nil - avatar.prepareForReuse() - } - // MARK: Private - - private func setup() { selectedBackgroundView = UIView() multipleSelectionBackgroundView = UIView() backgroundColor = Asset.neutralWhite.color - title.textColor = Asset.neutralActive.color - title.font = Fonts.Mulish.semiBold.font(size: 14.0) - separator.backgroundColor = Asset.neutralLine.color - - contentView.addSubview(title) - contentView.addSubview(avatar) - contentView.addSubview(separator) + titleLabel.textColor = Asset.neutralActive.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) + separatorView.backgroundColor = Asset.neutralLine.color - setupConstraints() - } + contentView.addSubview(titleLabel) + contentView.addSubview(avatarView) + contentView.addSubview(separatorView) - private func setupConstraints() { - avatar.snp.makeConstraints { make in - make.width.height.equalTo(30) - make.left.equalToSuperview().offset(25) - make.centerY.equalToSuperview() + avatarView.snp.makeConstraints { + $0.width.height.equalTo(36) + $0.left.equalToSuperview().offset(27) + $0.centerY.equalToSuperview() } - title.snp.makeConstraints { make in - make.centerY.equalTo(avatar) - make.left.equalTo(avatar.snp.right).offset(14) - make.right.lessThanOrEqualToSuperview().offset(-10) + titleLabel.snp.makeConstraints { + $0.centerY.equalTo(avatarView) + $0.left.equalTo(avatarView.snp.right).offset(14) + $0.right.lessThanOrEqualToSuperview().offset(-10) } - separator.snp.makeConstraints { make in - make.height.equalTo(1) - make.left.equalToSuperview().offset(25) - make.right.equalToSuperview() - make.bottom.equalToSuperview() + separatorView.snp.makeConstraints { + $0.height.equalTo(1) + $0.left.equalToSuperview().offset(25) + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() } } + + required init?(coder: NSCoder) { nil } + + public override func prepareForReuse() { + super.prepareForReuse() + titleLabel.text = nil + avatarView.prepareForReuse() + } } diff --git a/Sources/Shared/Views/UnselectableTextView.swift b/Sources/Shared/Views/UnselectableTextView.swift new file mode 100644 index 0000000000000000000000000000000000000000..c664c022bb340d50ccf007940a9d97ca3ba740cc --- /dev/null +++ b/Sources/Shared/Views/UnselectableTextView.swift @@ -0,0 +1,19 @@ +import UIKit + +public final class UnselectableTextView: UITextView { + public override var selectedTextRange: UITextRange? { + get { return nil } + set {} + } + + public override func point( + inside point: CGPoint, + with event: UIEvent? + ) -> Bool { + guard let pos = closestPosition(to: point) else { return false } + guard let range = tokenizer.rangeEnclosingPosition(pos, with: .character, inDirection: .layout(.left)) else { return false } + + let startIndex = offset(from: beginningOfDocument, to: range.start) + return attributedText.attribute(.link, at: startIndex, effectiveRange: nil) != nil + } +} diff --git a/Sources/Theme/Window.swift b/Sources/Theme/Window.swift index be36c11629db7bd67be9be4a97faa5ece1cc794d..8afb304005d85a8657b2f6bb2b1b5dba7a039546 100644 --- a/Sources/Theme/Window.swift +++ b/Sources/Theme/Window.swift @@ -3,23 +3,15 @@ import Combine import DependencyInjection public final class Window: UIWindow { - // MARK: Injected - @Dependency private var themeController: ThemeControlling - // MARK: Properties - private var cancellables = Set<AnyCancellable>() - // MARK: Lifecycle - public init() { super.init(frame: UIScreen.main.bounds) themeController.theme - .sink { [unowned self] in - overrideUserInterfaceStyle = $0.userInterfaceStyle - } + .sink { [unowned self] in overrideUserInterfaceStyle = $0.userInterfaceStyle } .store(in: &cancellables) } diff --git a/Sources/ToastFeature/ToastController.swift b/Sources/ToastFeature/ToastController.swift new file mode 100644 index 0000000000000000000000000000000000000000..1b0fd60e66297ace82beb3a6c1e877185d5ad066 --- /dev/null +++ b/Sources/ToastFeature/ToastController.swift @@ -0,0 +1,22 @@ +import Combine + +public final class ToastController { + private let queue = CurrentValueSubject<[ToastModel], Never>([]) + + var currentToast: AnyPublisher<ToastModel, Never> { + queue.compactMap(\.first) + .removeDuplicates(by: { $0.id == $1.id }) + .eraseToAnyPublisher() + } + + public init() {} + + public func enqueueToast(model: ToastModel) { + queue.value.append(model) + } + + public func dismissCurrentToast() { + guard queue.value.isEmpty == false else { return } + _ = queue.value.removeFirst() + } +} diff --git a/Sources/ToastFeature/ToastModel.swift b/Sources/ToastFeature/ToastModel.swift new file mode 100644 index 0000000000000000000000000000000000000000..06dd2a03fde9743309246e6438cdb9ca37383efc --- /dev/null +++ b/Sources/ToastFeature/ToastModel.swift @@ -0,0 +1,36 @@ +import UIKit +import Shared + +public struct ToastModel { + let id: UUID + let title: String + let color: UIColor + let subtitle: String? + let leftImage: UIImage + let timeToLive: Int + let buttonTitle: String? + let autodismissable: Bool + let onTapClosure: (() -> Void)? + + public init( + id: UUID = UUID(), + title: String, + color: UIColor = Asset.neutralOverlay.color, + subtitle: String? = nil, + leftImage: UIImage, + timeToLive: Int = 4, + buttonTitle: String? = nil, + onTapClosure: (() -> Void)? = nil, + autodismissable: Bool = true + ) { + self.id = id + self.title = title + self.color = color + self.subtitle = subtitle + self.leftImage = leftImage + self.timeToLive = timeToLive + self.buttonTitle = buttonTitle + self.onTapClosure = onTapClosure + self.autodismissable = autodismissable + } +} diff --git a/Sources/ToastFeature/ToastView.swift b/Sources/ToastFeature/ToastView.swift new file mode 100644 index 0000000000000000000000000000000000000000..c5c96561df06b942bd640a30cdf308bed014a80f --- /dev/null +++ b/Sources/ToastFeature/ToastView.swift @@ -0,0 +1,78 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..35c68a531da1d7197d3ed709472c07b452d463c6 --- /dev/null +++ b/Sources/ToastFeature/ToastViewController.swift @@ -0,0 +1,134 @@ +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/XCFrameworks/Bindings.xcframework/Info.plist b/XCFrameworks/Bindings.xcframework/Info.plist index 3c96df61083ca794226526858401b4539235d6ba..5da456bbdabbf3d610daca4ce17734b523413a53 100644 --- a/XCFrameworks/Bindings.xcframework/Info.plist +++ b/XCFrameworks/Bindings.xcframework/Info.plist @@ -6,30 +6,30 @@ <array> <dict> <key>LibraryIdentifier</key> - <string>ios-arm64_x86_64-simulator</string> + <string>ios-arm64</string> <key>LibraryPath</key> <string>Bindings.framework</string> <key>SupportedArchitectures</key> <array> <string>arm64</string> - <string>x86_64</string> </array> <key>SupportedPlatform</key> <string>ios</string> - <key>SupportedPlatformVariant</key> - <string>simulator</string> </dict> <dict> <key>LibraryIdentifier</key> - <string>ios-arm64</string> + <string>ios-arm64_x86_64-simulator</string> <key>LibraryPath</key> <string>Bindings.framework</string> <key>SupportedArchitectures</key> <array> <string>arm64</string> + <string>x86_64</string> </array> <key>SupportedPlatform</key> <string>ios</string> + <key>SupportedPlatformVariant</key> + <string>simulator</string> </dict> </array> <key>CFBundlePackageType</key> diff --git a/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Bindings b/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Bindings index 45357be1cabae91d29b35413baa479fc4f3f09ed..a57985b61520dd32a866b054803b66226dc8efce 100644 Binary files a/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Bindings and b/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Bindings differ diff --git a/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Headers/Bindings.objc.h b/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Headers/Bindings.objc.h index 209b34eb81ea47e4d05a9163fa87b2151842d99e..9c258ee7ff8d51dd0b95e8ecd49a3ff00f7d6183 100644 --- a/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Headers/Bindings.objc.h +++ b/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Headers/Bindings.objc.h @@ -132,7 +132,13 @@ @end @protocol BindingsListener <NSObject> +/** + * Hear is called to receive a message in the UI + */ - (void)hear:(BindingsMessage* _Nullable)message; +/** + * Returns a name, used for debugging + */ - (NSString* _Nonnull)name; @end @@ -161,6 +167,13 @@ @end @protocol BindingsRestoreContactsUpdater <NSObject> +/** + * RestoreContactsCallback is called to report the current # of contacts +that have been found and how many have been restored +against the total number that need to be +processed. If an error occurs it it set on the err variable as a +plain string. + */ - (void)restoreContactsCallback:(long)numFound numRestored:(long)numRestored total:(long)total err:(NSString* _Nullable)err; @end diff --git a/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/A/Bindings b/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/A/Bindings index 45357be1cabae91d29b35413baa479fc4f3f09ed..a57985b61520dd32a866b054803b66226dc8efce 100644 Binary files a/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/A/Bindings and b/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/A/Bindings differ diff --git a/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/A/Headers/Bindings.objc.h b/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/A/Headers/Bindings.objc.h index 209b34eb81ea47e4d05a9163fa87b2151842d99e..9c258ee7ff8d51dd0b95e8ecd49a3ff00f7d6183 100644 --- a/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/A/Headers/Bindings.objc.h +++ b/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/A/Headers/Bindings.objc.h @@ -132,7 +132,13 @@ @end @protocol BindingsListener <NSObject> +/** + * Hear is called to receive a message in the UI + */ - (void)hear:(BindingsMessage* _Nullable)message; +/** + * Returns a name, used for debugging + */ - (NSString* _Nonnull)name; @end @@ -161,6 +167,13 @@ @end @protocol BindingsRestoreContactsUpdater <NSObject> +/** + * RestoreContactsCallback is called to report the current # of contacts +that have been found and how many have been restored +against the total number that need to be +processed. If an error occurs it it set on the err variable as a +plain string. + */ - (void)restoreContactsCallback:(long)numFound numRestored:(long)numRestored total:(long)total err:(NSString* _Nullable)err; @end diff --git a/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/Current/Bindings b/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/Current/Bindings index 45357be1cabae91d29b35413baa479fc4f3f09ed..a57985b61520dd32a866b054803b66226dc8efce 100644 Binary files a/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/Current/Bindings and b/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/Current/Bindings differ diff --git a/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/Current/Headers/Bindings.objc.h b/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/Current/Headers/Bindings.objc.h index 209b34eb81ea47e4d05a9163fa87b2151842d99e..9c258ee7ff8d51dd0b95e8ecd49a3ff00f7d6183 100644 --- a/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/Current/Headers/Bindings.objc.h +++ b/XCFrameworks/Bindings.xcframework/ios-arm64/Bindings.framework/Versions/Current/Headers/Bindings.objc.h @@ -132,7 +132,13 @@ @end @protocol BindingsListener <NSObject> +/** + * Hear is called to receive a message in the UI + */ - (void)hear:(BindingsMessage* _Nullable)message; +/** + * Returns a name, used for debugging + */ - (NSString* _Nonnull)name; @end @@ -161,6 +167,13 @@ @end @protocol BindingsRestoreContactsUpdater <NSObject> +/** + * RestoreContactsCallback is called to report the current # of contacts +that have been found and how many have been restored +against the total number that need to be +processed. If an error occurs it it set on the err variable as a +plain string. + */ - (void)restoreContactsCallback:(long)numFound numRestored:(long)numRestored total:(long)total err:(NSString* _Nullable)err; @end diff --git a/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Bindings b/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Bindings index 731f69e41028398b03fffcccc5f2430ff8fa2191..26c80a07572c29fce947e8ba42ebc496dd22cc46 100644 Binary files a/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Bindings and b/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Bindings differ diff --git a/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Headers/Bindings.objc.h b/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Headers/Bindings.objc.h index 209b34eb81ea47e4d05a9163fa87b2151842d99e..9c258ee7ff8d51dd0b95e8ecd49a3ff00f7d6183 100644 --- a/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Headers/Bindings.objc.h +++ b/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Headers/Bindings.objc.h @@ -132,7 +132,13 @@ @end @protocol BindingsListener <NSObject> +/** + * Hear is called to receive a message in the UI + */ - (void)hear:(BindingsMessage* _Nullable)message; +/** + * Returns a name, used for debugging + */ - (NSString* _Nonnull)name; @end @@ -161,6 +167,13 @@ @end @protocol BindingsRestoreContactsUpdater <NSObject> +/** + * RestoreContactsCallback is called to report the current # of contacts +that have been found and how many have been restored +against the total number that need to be +processed. If an error occurs it it set on the err variable as a +plain string. + */ - (void)restoreContactsCallback:(long)numFound numRestored:(long)numRestored total:(long)total err:(NSString* _Nullable)err; @end diff --git a/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/A/Bindings b/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/A/Bindings index 731f69e41028398b03fffcccc5f2430ff8fa2191..26c80a07572c29fce947e8ba42ebc496dd22cc46 100644 Binary files a/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/A/Bindings and b/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/A/Bindings differ diff --git a/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/A/Headers/Bindings.objc.h b/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/A/Headers/Bindings.objc.h index 209b34eb81ea47e4d05a9163fa87b2151842d99e..9c258ee7ff8d51dd0b95e8ecd49a3ff00f7d6183 100644 --- a/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/A/Headers/Bindings.objc.h +++ b/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/A/Headers/Bindings.objc.h @@ -132,7 +132,13 @@ @end @protocol BindingsListener <NSObject> +/** + * Hear is called to receive a message in the UI + */ - (void)hear:(BindingsMessage* _Nullable)message; +/** + * Returns a name, used for debugging + */ - (NSString* _Nonnull)name; @end @@ -161,6 +167,13 @@ @end @protocol BindingsRestoreContactsUpdater <NSObject> +/** + * RestoreContactsCallback is called to report the current # of contacts +that have been found and how many have been restored +against the total number that need to be +processed. If an error occurs it it set on the err variable as a +plain string. + */ - (void)restoreContactsCallback:(long)numFound numRestored:(long)numRestored total:(long)total err:(NSString* _Nullable)err; @end diff --git a/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/Current/Bindings b/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/Current/Bindings index 731f69e41028398b03fffcccc5f2430ff8fa2191..26c80a07572c29fce947e8ba42ebc496dd22cc46 100644 Binary files a/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/Current/Bindings and b/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/Current/Bindings differ diff --git a/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/Current/Headers/Bindings.objc.h b/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/Current/Headers/Bindings.objc.h index 209b34eb81ea47e4d05a9163fa87b2151842d99e..9c258ee7ff8d51dd0b95e8ecd49a3ff00f7d6183 100644 --- a/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/Current/Headers/Bindings.objc.h +++ b/XCFrameworks/Bindings.xcframework/ios-arm64_x86_64-simulator/Bindings.framework/Versions/Current/Headers/Bindings.objc.h @@ -132,7 +132,13 @@ @end @protocol BindingsListener <NSObject> +/** + * Hear is called to receive a message in the UI + */ - (void)hear:(BindingsMessage* _Nullable)message; +/** + * Returns a name, used for debugging + */ - (NSString* _Nonnull)name; @end @@ -161,6 +167,13 @@ @end @protocol BindingsRestoreContactsUpdater <NSObject> +/** + * RestoreContactsCallback is called to report the current # of contacts +that have been found and how many have been restored +against the total number that need to be +processed. If an error occurs it it set on the err variable as a +plain string. + */ - (void)restoreContactsCallback:(long)numFound numRestored:(long)numRestored total:(long)total err:(NSString* _Nullable)err; @end