diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CollectionView.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CollectionView.xcscheme new file mode 100644 index 0000000000000000000000000000000000000000..69208709b4458ff0057e77ddada42bc9d39e7f52 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/CollectionView.xcscheme @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1340" + version = "1.3"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "CollectionView" + BuildableName = "CollectionView" + BlueprintName = "CollectionView" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> + <Testables> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "CollectionViewTests" + BuildableName = "CollectionViewTests" + BlueprintName = "CollectionViewTests" + ReferencedContainer = "container:"> + </BuildableReference> + </TestableReference> + </Testables> + </TestAction> + <LaunchAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + launchStyle = "0" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "YES"> + </LaunchAction> + <ProfileAction + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + <MacroExpansion> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "CollectionView" + BuildableName = "CollectionView" + BlueprintName = "CollectionView" + ReferencedContainer = "container:"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/Package.swift b/Package.swift index 1da9ca4b239ac9755df880b5fd3f60131785fbe5..bcec957c2be3321cb1b9f1bcc1dd176814764b54 100644 --- a/Package.swift +++ b/Package.swift @@ -27,6 +27,7 @@ let package = Package( .library(name: "Integration", targets: ["Integration"]), .library(name: "ChatFeature", targets: ["ChatFeature"]), .library(name: "PushFeature", targets: ["PushFeature"]), + .library(name: "SFTPFeature", targets: ["SFTPFeature"]), .library(name: "CrashService", targets: ["CrashService"]), .library(name: "Presentation", targets: ["Presentation"]), .library(name: "BackupFeature", targets: ["BackupFeature"]), @@ -34,6 +35,7 @@ let package = Package( .library(name: "iCloudFeature", targets: ["iCloudFeature"]), .library(name: "SearchFeature", targets: ["SearchFeature"]), .library(name: "DrawerFeature", targets: ["DrawerFeature"]), + .library(name: "CollectionView", targets: ["CollectionView"]), .library(name: "RestoreFeature", targets: ["RestoreFeature"]), .library(name: "CrashReporting", targets: ["CrashReporting"]), .library(name: "ProfileFeature", targets: ["ProfileFeature"]), @@ -66,9 +68,12 @@ let package = Package( .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "0.5.0"), .package(url: "https://github.com/kishikawakatsumi/KeychainAccess", from: "4.2.1"), .package(url: "https://github.com/google/google-api-objectivec-client-for-rest", from: "1.6.0"), - .package(url: "https://git.xx.network/elixxir/client-ios-db.git", .upToNextMajor(from: "1.0.6")), + .package(url: "https://git.xx.network/elixxir/client-ios-db.git", .upToNextMajor(from: "1.0.8")), .package(url: "https://github.com/firebase/firebase-ios-sdk.git", .upToNextMajor(from: "8.10.0")), - .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git",.upToNextMajor(from: "0.32.0")) + .package(url: "https://github.com/darrarski/Shout.git", revision: "df5a662293f0ac15eeb4f2fd3ffd0c07b73d0de0"), + .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git",.upToNextMajor(from: "0.32.0")), + .package(url: "https://github.com/pointfreeco/swift-custom-dump.git", .upToNextMajor(from: "0.5.0")), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay.git", .upToNextMajor(from: "0.3.3")), ], targets: [ .target( @@ -81,6 +86,7 @@ let package = Package( "ChatFeature", "MenuFeature", "PushFeature", + "SFTPFeature", "ToastFeature", "CrashService", "BackupFeature", @@ -208,6 +214,25 @@ let package = Package( ] ), + // MARK: - SFTPFeature + + .target( + name: "SFTPFeature", + dependencies: [ + "HUD", + "Models", + "Shared", + "Keychain", + "InputField", + "Presentation", + "DependencyInjection", + .product( + name: "Shout", + package: "Shout" + ) + ] + ), + // MARK: - GoogleDriveFeature .target( @@ -398,6 +423,7 @@ let package = Package( dependencies: [ "HUD", "Shared", + "SFTPFeature", "Integration", "Presentation", "iCloudFeature", @@ -612,6 +638,7 @@ let package = Package( "Shared", "Models", "InputField", + "SFTPFeature", "Presentation", "iCloudFeature", "DrawerFeature", @@ -835,6 +862,23 @@ let package = Package( .product(name: "Quick", package: "Quick"), .product(name: "Nimble", package: "Nimble") ] - ) + ), + + // MARK: - CollectionView + + .target( + name: "CollectionView", + dependencies: [ + .product(name: "ChatLayout", package: "ChatLayout"), + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + ] + ), + .testTarget( + name: "CollectionViewTests", + dependencies: [ + .target(name: "CollectionView"), + .product(name: "CustomDump", package: "swift-custom-dump"), + ] + ), ] ) diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 3675b45050f041af457b16d9bc6105633dabab91..72a7f682477967599a823601b8315c46ed429ee6 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -1,8 +1,8 @@ import UIKit import BackgroundTasks -import XXModels import Theme +import XXModels import XXLogger import Defaults import Integration diff --git a/Sources/App/DependencyRegistrator.swift b/Sources/App/DependencyRegistrator.swift index 9102274af7c0c37ce986b8aba6b2607a33cd66ca..14a7b7bdb482974cff2b5b2be573d3f5fb1a33fc 100644 --- a/Sources/App/DependencyRegistrator.swift +++ b/Sources/App/DependencyRegistrator.swift @@ -18,6 +18,7 @@ import Voxophone import Integration import Permissions import PushFeature +import SFTPFeature import CrashService import ToastFeature import iCloudFeature @@ -63,6 +64,7 @@ struct DependencyRegistrator { /// Restore / Backup + container.register(SFTPService.mock) container.register(iCloudServiceMock() as iCloudInterface) container.register(DropboxServiceMock() as DropboxInterface) container.register(GoogleDriveServiceMock() as GoogleDriveInterface) @@ -86,6 +88,7 @@ struct DependencyRegistrator { /// Restore / Backup + container.register(SFTPService.live) container.register(iCloudService() as iCloudInterface) container.register(DropboxService() as DropboxInterface) container.register(GoogleDriveService() as GoogleDriveInterface) @@ -101,7 +104,7 @@ struct DependencyRegistrator { // MARK: Isolated - container.register(HUD() as HUDType) + container.register(HUD()) container.register(ThemeController() as ThemeControlling) container.register(ToastController()) container.register(StatusBarController() as StatusBarStyleControlling) @@ -134,6 +137,8 @@ struct DependencyRegistrator { container.register( SearchCoordinator( + contactsFactory: ContactListController.init, + requestsFactory: RequestsContainerController.init, contactFactory: ContactController.init(_:), countriesFactory: CountryListController.init(_:) ) as SearchCoordinating) @@ -185,7 +190,7 @@ struct DependencyRegistrator { container.register( RequestsCoordinator( - searchFactory: SearchController.init, + searchFactory: SearchContainerController.init, contactFactory: ContactController.init(_:), singleChatFactory: SingleChatController.init(_:), groupChatFactory: GroupChatController.init(_:), @@ -197,7 +202,7 @@ struct DependencyRegistrator { OnboardingCoordinator( emailFactory: OnboardingEmailController.init, phoneFactory: OnboardingPhoneController.init, - searchFactory: SearchController.init, + searchFactory: SearchContainerController.init, welcomeFactory: OnboardingWelcomeController.init, chatListFactory: ChatListController.init, usernameFactory: OnboardingUsernameController.init(_:), @@ -211,7 +216,7 @@ struct DependencyRegistrator { container.register( ContactListCoordinator( scanFactory: ScanContainerController.init, - searchFactory: SearchController.init, + searchFactory: SearchContainerController.init, newGroupFactory: CreateGroupController.init, requestsFactory: RequestsContainerController.init, contactFactory: ContactController.init(_:), @@ -235,7 +240,7 @@ struct DependencyRegistrator { container.register( ChatListCoordinator( scanFactory: ScanContainerController.init, - searchFactory: SearchController.init, + searchFactory: SearchContainerController.init, newGroupFactory: CreateGroupController.init, contactsFactory: ContactListController.init, contactFactory: ContactController.init(_:), diff --git a/Sources/BackupFeature/Controllers/BackupConfigController.swift b/Sources/BackupFeature/Controllers/BackupConfigController.swift index c61bd74d6c971c544087d7020db72ce16d11e763..a901e6b9668fc89df2cd1ae1184ca655fa8bdb1b 100644 --- a/Sources/BackupFeature/Controllers/BackupConfigController.swift +++ b/Sources/BackupFeature/Controllers/BackupConfigController.swift @@ -105,6 +105,11 @@ final class BackupConfigController: UIViewController { .sink { [unowned self] in viewModel.didToggleService(self, .dropbox, screenView.dropboxButton.switcherView.isOn) } .store(in: &cancellables) + screenView.sftpButton.switcherView + .publisher(for: .valueChanged) + .sink { [unowned self] in viewModel.didToggleService(self, .sftp, screenView.sftpButton.switcherView.isOn) } + .store(in: &cancellables) + screenView.iCloudButton.switcherView .publisher(for: .valueChanged) .sink { [unowned self] in viewModel.didToggleService(self, .icloud, screenView.iCloudButton.switcherView.isOn) } @@ -115,6 +120,11 @@ final class BackupConfigController: UIViewController { .sink { [unowned self] in viewModel.didTapService(.dropbox, self) } .store(in: &cancellables) + screenView.sftpButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in viewModel.didTapService(.sftp, self) } + .store(in: &cancellables) + screenView.iCloudButton .publisher(for: .touchUpInside) .sink { [unowned self] in viewModel.didTapService(.icloud, self) } @@ -128,16 +138,17 @@ final class BackupConfigController: UIViewController { case .none: break case .icloud: - serviceName = "iCloud" + serviceName = Localized.Backup.iCloud button = screenView.iCloudButton - case .dropbox: - serviceName = "Dropbox" + serviceName = Localized.Backup.dropbox button = screenView.dropboxButton - case .drive: - serviceName = "Google Drive" + serviceName = Localized.Backup.googleDrive button = screenView.googleDriveButton + case .sftp: + serviceName = Localized.Backup.sftp + button = screenView.sftpButton } screenView.enabledSubtitleLabel.text @@ -146,10 +157,12 @@ final class BackupConfigController: UIViewController { = Localized.Backup.Config.frequency(serviceName).uppercased() guard let button = button else { + screenView.sftpButton.isHidden = false screenView.iCloudButton.isHidden = false screenView.dropboxButton.isHidden = false screenView.googleDriveButton.isHidden = false + screenView.sftpButton.switcherView.isOn = false screenView.iCloudButton.switcherView.isOn = false screenView.dropboxButton.switcherView.isOn = false screenView.googleDriveButton.switcherView.isOn = false @@ -166,11 +179,13 @@ final class BackupConfigController: UIViewController { screenView.latestBackupDetailView.isHidden = false screenView.infrastructureDetailView.isHidden = false - [screenView.iCloudButton, screenView.dropboxButton, screenView.googleDriveButton] - .forEach { - $0.isHidden = $0 != button - $0.switcherView.isOn = $0 == button - } + [screenView.iCloudButton, + screenView.dropboxButton, + screenView.googleDriveButton, + screenView.sftpButton].forEach { + $0.isHidden = $0 != button + $0.switcherView.isOn = $0 == button + } } private func decorate(connectedServices: Set<CloudService>) { @@ -191,6 +206,12 @@ final class BackupConfigController: UIViewController { } else { screenView.googleDriveButton.showChevron() } + + if connectedServices.contains(.sftp) { + screenView.sftpButton.showSwitcher(enabled: false) + } else { + screenView.sftpButton.showChevron() + } } private func presentInfrastructureDrawer(wifiOnly: Bool) { diff --git a/Sources/BackupFeature/Controllers/BackupController.swift b/Sources/BackupFeature/Controllers/BackupController.swift index a01ff0caa91be5ffdfa55e6fe9fe8ea7ebab41bf..a9942d8c03466fe8b620d156c9eb1cc02f2ef70a 100644 --- a/Sources/BackupFeature/Controllers/BackupController.swift +++ b/Sources/BackupFeature/Controllers/BackupController.swift @@ -6,7 +6,7 @@ import Combine import DependencyInjection public final class BackupController: UIViewController { - @Dependency private var hud: HUDType + @Dependency var hud: HUD private let viewModel = BackupViewModel.live() private var cancellables = Set<AnyCancellable>() @@ -14,7 +14,7 @@ public final class BackupController: UIViewController { public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = Asset.neutralWhite.color - hud.update(with: .on(nil)) + hud.update(with: .on) setupNavigationBar() setupBindings() diff --git a/Sources/BackupFeature/Controllers/BackupSetupController.swift b/Sources/BackupFeature/Controllers/BackupSetupController.swift index 49e05a2bd74ce9141e19320cfebbc33f989414f7..e22de517680d7ab5fc348b56cbb396ca80c4e091 100644 --- a/Sources/BackupFeature/Controllers/BackupSetupController.swift +++ b/Sources/BackupFeature/Controllers/BackupSetupController.swift @@ -37,5 +37,10 @@ final class BackupSetupController: UIViewController { .publisher(for: .touchUpInside) .sink { [unowned self] in viewModel.didTapService(.icloud, self) } .store(in: &cancellables) + + screenView.sftpButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in viewModel.didTapService(.sftp, self) } + .store(in: &cancellables) } } diff --git a/Sources/BackupFeature/Coordinator/BackupCoordinator.swift b/Sources/BackupFeature/Coordinator/BackupCoordinator.swift index 9dbd110e659f066dd25d5bda897ebccc14047748..a49b51bd28fe3a670efeba3c2c9cff8e34e31af6 100644 --- a/Sources/BackupFeature/Coordinator/BackupCoordinator.swift +++ b/Sources/BackupFeature/Coordinator/BackupCoordinator.swift @@ -18,16 +18,10 @@ public protocol BackupCoordinating { public struct BackupCoordinator: BackupCoordinating { var bottomPresenter: Presenting = BottomPresenter() - var passphraseFactory: ( - @escaping EmptyClosure, - @escaping StringClosure - ) -> UIViewController + var passphraseFactory: (@escaping EmptyClosure, @escaping StringClosure) -> UIViewController public init( - passphraseFactory: @escaping ( - @escaping EmptyClosure, - @escaping StringClosure - ) -> UIViewController + passphraseFactory: @escaping (@escaping EmptyClosure, @escaping StringClosure) -> UIViewController ) { self.passphraseFactory = passphraseFactory } diff --git a/Sources/BackupFeature/Service/BackupService.swift b/Sources/BackupFeature/Service/BackupService.swift index 8b82a8f27c40af85acd836182144d05c945008e1..626adba515d28c1a9969b067da742d07e94c8da5 100644 --- a/Sources/BackupFeature/Service/BackupService.swift +++ b/Sources/BackupFeature/Service/BackupService.swift @@ -2,6 +2,8 @@ import UIKit import Models import Combine import Defaults +import Keychain +import SFTPFeature import iCloudFeature import DropboxFeature import NetworkMonitor @@ -9,10 +11,12 @@ import GoogleDriveFeature import DependencyInjection public final class BackupService { + @Dependency private var sftpService: SFTPService @Dependency private var icloudService: iCloudInterface @Dependency private var dropboxService: DropboxInterface - @Dependency private var driveService: GoogleDriveInterface @Dependency private var networkManager: NetworkMonitoring + @Dependency private var keychainHandler: KeychainHandling + @Dependency private var driveService: GoogleDriveInterface @KeyObject(.backupSettings, defaultValue: Data()) private var storedSettings: Data @@ -149,6 +153,15 @@ extension BackupService { self.refreshBackups() }.store(in: &cancellables) } + case .sftp: + if !sftpService.isAuthorized() { + sftpService.authorizeFlow((screen, { [weak self] in + guard let self = self else { return } + screen.navigationController?.popViewController(animated: true) + self.refreshConnections() + self.refreshBackups() + })) + } } } } @@ -167,6 +180,12 @@ extension BackupService { settings.value.connectedServices.remove(.dropbox) } + if sftpService.isAuthorized() && !settings.value.connectedServices.contains(.sftp) { + settings.value.connectedServices.insert(.sftp) + } else if !sftpService.isAuthorized() && settings.value.connectedServices.contains(.sftp) { + settings.value.connectedServices.remove(.sftp) + } + driveService.isAuthorized { [weak settings] isAuthorized in guard let settings = settings else { return } @@ -196,6 +215,23 @@ extension BackupService { } } + if sftpService.isAuthorized() { + sftpService.fetchMetadata { [weak settings] in + guard let settings = settings else { return } + + guard let metadata = try? $0.get()?.backup else { + settings.value.backups[.sftp] = nil + return + } + + settings.value.backups[.sftp] = Backup( + id: metadata.id, + date: metadata.date, + size: metadata.size + ) + } + } + if dropboxService.isAuthorized() { dropboxService.downloadMetadata { [weak settings] in guard let settings = settings else { return } @@ -241,7 +277,7 @@ extension BackupService { .appendingPathComponent(UUID().uuidString) do { - try data.write(to: url) + try data.write(to: url, options: .atomic) } catch { print("Couldn't write to temp: \(error.localizedDescription)") return @@ -260,8 +296,6 @@ extension BackupService { case .failure(let error): print(error.localizedDescription) } - - // try? FileManager.default.removeItem(at: url) } case .icloud: icloudService.uploadBackup(url) { @@ -275,8 +309,6 @@ extension BackupService { case .failure(let error): print(error.localizedDescription) } - - // try? FileManager.default.removeItem(at: url) } case .dropbox: dropboxService.uploadBackup(url) { @@ -290,8 +322,15 @@ extension BackupService { case .failure(let error): print(error.localizedDescription) } - - // try? FileManager.default.removeItem(at: url) + } + case .sftp: + sftpService.uploadBackup(url: url) { + switch $0 { + case .success(let backup): + self.settings.value.backups[.sftp] = backup + case .failure(let error): + print(error.localizedDescription) + } } } } diff --git a/Sources/BackupFeature/ViewModels/BackupConfigViewModel.swift b/Sources/BackupFeature/ViewModels/BackupConfigViewModel.swift index f136ca8c15c83578636ed3ef00643817d73b8de9..0731bc4a94f5be17d6e224d5f67573ea68ea608f 100644 --- a/Sources/BackupFeature/ViewModels/BackupConfigViewModel.swift +++ b/Sources/BackupFeature/ViewModels/BackupConfigViewModel.swift @@ -30,7 +30,7 @@ struct BackupConfigViewModel { extension BackupConfigViewModel { static func live() -> Self { class Context { - @Dependency var hud: HUDType + @Dependency var hud: HUD @Dependency var service: BackupService @Dependency var coordinator: BackupCoordinating } @@ -40,7 +40,7 @@ extension BackupConfigViewModel { return .init( didTapBackupNow: { context.service.performBackup() - context.hud.update(with: .on(nil)) + context.hud.update(with: .on) DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { context.hud.update(with: .none) } @@ -57,7 +57,7 @@ extension BackupConfigViewModel { context.service.toggle(service: service, enabling: false) }, passphraseClosure: { passphrase in context.service.passphrase = passphrase - context.hud.update(with: .on("Initializing and securing your backup file will take few seconds, please keep the app open.")) + context.hud.update(with: .onTitle("Initializing and securing your backup file will take few seconds, please keep the app open.")) DispatchQueue.global().async { context.service.toggle(service: service, enabling: enabling) diff --git a/Sources/BackupFeature/Views/BackupConfigView.swift b/Sources/BackupFeature/Views/BackupConfigView.swift index 8c400b3ff9162f380548d0478f2356454910833e..1c65f58f7222f1c069284c717518904dc2e596ca 100644 --- a/Sources/BackupFeature/Views/BackupConfigView.swift +++ b/Sources/BackupFeature/Views/BackupConfigView.swift @@ -7,6 +7,7 @@ final class BackupConfigView: UIView { let actionView = BackupActionView() let stackView = UIStackView() + let sftpButton = BackupSwitcherButton() let iCloudButton = BackupSwitcherButton() let dropboxButton = BackupSwitcherButton() let googleDriveButton = BackupSwitcherButton() @@ -44,6 +45,9 @@ final class BackupConfigView: UIView { subtitleLabel.numberOfLines = 0 subtitleLabel.attributedText = attString + sftpButton.titleLabel.text = Localized.Backup.sftp + sftpButton.logoImageView.image = Asset.restoreSFTP.image + iCloudButton.titleLabel.text = Localized.Backup.iCloud iCloudButton.logoImageView.image = Asset.restoreIcloud.image @@ -65,6 +69,7 @@ final class BackupConfigView: UIView { stackView.addArrangedSubview(googleDriveButton) stackView.addArrangedSubview(iCloudButton) stackView.addArrangedSubview(dropboxButton) + stackView.addArrangedSubview(sftpButton) stackView.addArrangedSubview(enabledSubtitleView) stackView.addArrangedSubview(latestBackupDetailView) stackView.addArrangedSubview(frequencyDetailView) @@ -75,36 +80,36 @@ final class BackupConfigView: UIView { addSubview(actionView) addSubview(stackView) - titleLabel.snp.makeConstraints { make in - make.top.equalToSuperview().offset(15) - make.left.equalToSuperview().offset(38) - make.right.equalToSuperview().offset(-41) + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(15) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-41) } - enabledSubtitleLabel.snp.makeConstraints { make in - make.top.equalToSuperview().offset(-10) - make.left.equalToSuperview().offset(92) - make.right.equalToSuperview().offset(-48) - make.bottom.equalToSuperview() + enabledSubtitleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(-10) + $0.left.equalToSuperview().offset(92) + $0.right.equalToSuperview().offset(-48) + $0.bottom.equalToSuperview() } - subtitleLabel.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(8) - make.left.equalToSuperview().offset(38) - make.right.equalToSuperview().offset(-41) + subtitleLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(8) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-41) } - actionView.snp.makeConstraints { make in - make.top.equalTo(subtitleLabel.snp.bottom).offset(15) - make.left.equalToSuperview().offset(38) - make.right.equalToSuperview().offset(-38) + actionView.snp.makeConstraints { + $0.top.equalTo(subtitleLabel.snp.bottom).offset(15) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-38) } - stackView.snp.makeConstraints { make in - make.top.equalTo(actionView.snp.bottom).offset(28) - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.lessThanOrEqualToSuperview() + stackView.snp.makeConstraints { + $0.top.equalTo(actionView.snp.bottom).offset(28) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.lessThanOrEqualToSuperview() } } diff --git a/Sources/BackupFeature/Views/BackupSetupView.swift b/Sources/BackupFeature/Views/BackupSetupView.swift index eeaad48195da765ac9833293ccd7f7f9a7673ca0..3e19d50034f4d03ba62d3e4d038d82d24196af89 100644 --- a/Sources/BackupFeature/Views/BackupSetupView.swift +++ b/Sources/BackupFeature/Views/BackupSetupView.swift @@ -6,6 +6,7 @@ final class BackupSetupView: UIView { let subtitleLabel = UILabel() let stackView = UIStackView() + let sftpButton = BackupSwitcherButton() let iCloudButton = BackupSwitcherButton() let dropboxButton = BackupSwitcherButton() let googleDriveButton = BackupSwitcherButton() @@ -60,32 +61,37 @@ final class BackupSetupView: UIView { googleDriveButton.logoImageView.image = Asset.restoreDrive.image googleDriveButton.showChevron() + sftpButton.titleLabel.text = Localized.Backup.sftp + sftpButton.logoImageView.image = Asset.restoreSFTP.image + sftpButton.showChevron() + stackView.axis = .vertical stackView.addArrangedSubview(googleDriveButton) stackView.addArrangedSubview(iCloudButton) stackView.addArrangedSubview(dropboxButton) + stackView.addArrangedSubview(sftpButton) addSubview(titleLabel) addSubview(subtitleLabel) addSubview(stackView) - titleLabel.snp.makeConstraints { make in - make.top.equalToSuperview().offset(15) - make.left.equalToSuperview().offset(38) - make.right.equalToSuperview().offset(-41) + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(15) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-41) } - subtitleLabel.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(8) - make.left.equalToSuperview().offset(38) - make.right.equalToSuperview().offset(-41) + subtitleLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(8) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-41) } - stackView.snp.makeConstraints { make in - make.top.equalTo(subtitleLabel.snp.bottom).offset(28) - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.lessThanOrEqualToSuperview() + stackView.snp.makeConstraints { + $0.top.equalTo(subtitleLabel.snp.bottom).offset(28) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.lessThanOrEqualToSuperview() } } diff --git a/Sources/ChatFeature/Controllers/SingleChatController.swift b/Sources/ChatFeature/Controllers/SingleChatController.swift index b35d314725ea97149e84edb8b9525e1fc010d4f6..f34be438f7df3afd84d5ba7c6db04612bc891cc6 100644 --- a/Sources/ChatFeature/Controllers/SingleChatController.swift +++ b/Sources/ChatFeature/Controllers/SingleChatController.swift @@ -24,7 +24,7 @@ extension Message: Differentiable { } public final class SingleChatController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var logger: XXLogger @Dependency private var voxophone: Voxophone @Dependency private var coordinator: ChatCoordinating diff --git a/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift b/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift index cd78765910d84d7242ee96ff145c3cd612c2492d..f51a893610402c317f938f0b29445317de9bb6d4 100644 --- a/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift +++ b/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift @@ -107,7 +107,7 @@ final class SingleChatViewModel { func didSend(image: UIImage) { guard let imageData = image.orientedUp().jpegData(compressionQuality: 1.0) else { return } - hudRelay.send(.on(nil)) + hudRelay.send(.on) session.send(imageData: imageData, to: contact) { [weak self] in switch $0 { diff --git a/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift b/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift index 98febff09e352ff1e0ff495c032f1e7d1ec1613a..f481b96fb7e3e14b74096919090bf1df8c96c840 100644 --- a/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift +++ b/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift @@ -147,7 +147,7 @@ final class ChatListViewModel { } func leave(_ group: Group) { - hudSubject.send(.on(nil)) + hudSubject.send(.on) do { try session.leave(group: group) diff --git a/Sources/ChatListFeature/Views/ChatListView.swift b/Sources/ChatListFeature/Views/ChatListView.swift index c7303c48d04db46983e2985d40745ce236d72c43..03a407980700123d9843d9239e4822b9247dac63 100644 --- a/Sources/ChatListFeature/Views/ChatListView.swift +++ b/Sources/ChatListFeature/Views/ChatListView.swift @@ -14,7 +14,7 @@ final class ChatListView: UIView { backgroundColor = Asset.neutralWhite.color listContainerView.backgroundColor = Asset.neutralWhite.color searchListContainerView.backgroundColor = Asset.neutralWhite.color - searchView.update(placeholder: "Search chats") + searchView.update(placeholder: Localized.ChatList.Search.title) addSubview(snackBar) addSubview(searchView) @@ -22,6 +22,34 @@ final class ChatListView: UIView { containerView.addSubview(searchListContainerView) containerView.addSubview(listContainerView) + setupConstraints() + } + + required init?(coder: NSCoder) { nil } + + func showConnectingBanner(_ show: Bool) { + if show == true { + snackBar.alpha = 0.0 + snackBar.snp.updateConstraints { + $0.bottom + .equalTo(snp.top) + .offset(snackBar.bounds.height) + } + } else { + snackBar.alpha = 1.0 + snackBar.snp.updateConstraints { + $0.bottom.equalTo(snp.top) + } + } + + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { + self.setNeedsLayout() + self.layoutIfNeeded() + self.snackBar.alpha = show ? 1.0 : 0.0 + } + } + + private func setupConstraints() { snackBar.snp.makeConstraints { $0.left.equalToSuperview() $0.right.equalToSuperview() @@ -49,28 +77,4 @@ final class ChatListView: UIView { $0.edges.equalToSuperview() } } - - required init?(coder: NSCoder) { nil } - - func showConnectingBanner(_ show: Bool) { - if show == true { - snackBar.alpha = 0.0 - snackBar.snp.updateConstraints { - $0.bottom - .equalTo(snp.top) - .offset(snackBar.bounds.height) - } - } else { - snackBar.alpha = 1.0 - snackBar.snp.updateConstraints { - $0.bottom.equalTo(snp.top) - } - } - - UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { - self.setNeedsLayout() - self.layoutIfNeeded() - self.snackBar.alpha = show ? 1.0 : 0.0 - } - } } diff --git a/Sources/CollectionView/CellFactory.swift b/Sources/CollectionView/CellFactory.swift new file mode 100644 index 0000000000000000000000000000000000000000..bc7bc9bec131565d07dc5805865bdcc13e0959dc --- /dev/null +++ b/Sources/CollectionView/CellFactory.swift @@ -0,0 +1,76 @@ +import UIKit +import XCTestDynamicOverlay + +public struct CellFactory<Model> { + public struct Registrar { + public init(register: @escaping (UICollectionView) -> Void) { + self.register = register + } + + public var register: (UICollectionView) -> Void + + public func callAsFunction(in view: UICollectionView) { + register(view) + } + } + + public struct Builder { + public init(build: @escaping (Model, UICollectionView, IndexPath) -> UICollectionViewCell?) { + self.build = build + } + + public var build: (Model, UICollectionView, IndexPath) -> UICollectionViewCell? + + public func callAsFunction( + for model: Model, + in view: UICollectionView, + at indexPath: IndexPath + ) -> UICollectionViewCell? { + build(model, view, indexPath) + } + } + + public init( + register: Registrar, + build: Builder + ) { + self.register = register + self.build = build + } + + public var register: Registrar + public var build: Builder +} + +extension CellFactory { + public static func combined(_ factories: CellFactory...) -> CellFactory { + combined(factories) + } + + public static func combined(_ factories: [CellFactory]) -> CellFactory { + CellFactory( + register: .init { collectionView in + factories.forEach { $0.register(in: collectionView) } + }, + build: .init { model, collectionView, indexPath in + for factory in factories { + if let cell = factory.build(for: model, in: collectionView, at: indexPath) { + return cell + } + } + return nil + } + ) + } +} + +#if DEBUG +extension CellFactory { + public static func unimplemented() -> CellFactory { + CellFactory( + register: .init(register: XCTUnimplemented("\(Self.self).Registrar")), + build: .init(build: XCTUnimplemented("\(Self.self).Builder")) + ) + } +} +#endif diff --git a/Sources/CollectionView/ViewConfigurator.swift b/Sources/CollectionView/ViewConfigurator.swift new file mode 100644 index 0000000000000000000000000000000000000000..631f9e8d216b0a4ea6da2ce40db4fe48d7f103ef --- /dev/null +++ b/Sources/CollectionView/ViewConfigurator.swift @@ -0,0 +1,22 @@ +import UIKit +import XCTestDynamicOverlay + +public struct ViewConfigurator<View: UIView, Model> { + public init(configure: @escaping (View, Model) -> Void) { + self.configure = configure + } + + public var configure: (View, Model) -> Void + + public func callAsFunction(_ view: View, with model: Model) { + configure(view, model) + } +} + +#if DEBUG +extension ViewConfigurator { + public static func unimplemented() -> ViewConfigurator { + ViewConfigurator(configure: XCTUnimplemented("\(Self.self)")) + } +} +#endif diff --git a/Sources/ContactFeature/Controllers/ContactController.swift b/Sources/ContactFeature/Controllers/ContactController.swift index cf64268edbdd1cad2986105c38d72a74b2273499..02859bab60bf861a5615795a016352a4f4509900 100644 --- a/Sources/ContactFeature/Controllers/ContactController.swift +++ b/Sources/ContactFeature/Controllers/ContactController.swift @@ -10,7 +10,7 @@ import DependencyInjection import ScrollViewController public final class ContactController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: ContactCoordinating @Dependency private var statusBarController: StatusBarStyleControlling diff --git a/Sources/ContactFeature/ViewModels/ContactViewModel.swift b/Sources/ContactFeature/ViewModels/ContactViewModel.swift index 67005d4b9d01cbc493ead72d8f6b89e42e64535e..5e6e06698cbfeb359a79ce231dde08f37c03ee6a 100644 --- a/Sources/ContactFeature/ViewModels/ContactViewModel.swift +++ b/Sources/ContactFeature/ViewModels/ContactViewModel.swift @@ -62,7 +62,7 @@ final class ContactViewModel { } func didTapDelete() { - hudRelay.send(.on(nil)) + hudRelay.send(.on) do { try session.deleteContact(contact) @@ -91,7 +91,7 @@ final class ContactViewModel { } func didTapResend() { - hudRelay.send(.on(nil)) + hudRelay.send(.on) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } @@ -107,7 +107,7 @@ final class ContactViewModel { } func didTapRequest(with nickname: String) { - hudRelay.send(.on(nil)) + hudRelay.send(.on) contact.nickname = nickname backgroundScheduler.schedule { [weak self] in @@ -124,7 +124,7 @@ final class ContactViewModel { } func didTapAccept(_ nickname: String) { - hudRelay.send(.on(nil)) + hudRelay.send(.on) contact.nickname = nickname backgroundScheduler.schedule { [weak self] in diff --git a/Sources/ContactListFeature/Controllers/ContactListTableController.swift b/Sources/ContactListFeature/Controllers/ContactListTableController.swift index f940366ae08cfe77f9cd54e5d1008a55b7149bc6..ac7a06628045f199393f1839e2a6f700a7cb194e 100644 --- a/Sources/ContactListFeature/Controllers/ContactListTableController.swift +++ b/Sources/ContactListFeature/Controllers/ContactListTableController.swift @@ -30,7 +30,7 @@ final class ContactListTableController: UITableViewController { private func setupTableView() { tableView.separatorStyle = .none - tableView.register(SmallAvatarAndTitleCell.self) + tableView.register(AvatarCell.self) tableView.backgroundColor = Asset.neutralWhite.color tableView.sectionIndexColor = Asset.neutralDark.color tableView.contentInset = UIEdgeInsets(top: -20, left: 0, bottom: 0, right: 0) @@ -45,11 +45,11 @@ final class ContactListTableController: UITableViewController { } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: SmallAvatarAndTitleCell = tableView.dequeueReusableCell(forIndexPath: indexPath) + let cell: AvatarCell = tableView.dequeueReusableCell(forIndexPath: indexPath) let contact = sections[indexPath.section][indexPath.row] let name = (contact.nickname ?? contact.username) ?? "Fetching username..." - cell.titleLabel.text = name - cell.avatarView.setupProfile(title: name, image: contact.photo, size: .medium) + + cell.setup(title: name, image: contact.photo) return cell } diff --git a/Sources/ContactListFeature/Controllers/CreateGroupController.swift b/Sources/ContactListFeature/Controllers/CreateGroupController.swift index 9e9d039dd5e5fe2dd4adfa179fc736ab51fa6c4b..a9559f5c3c38ccdf7a90543a9963748155e6fa6b 100644 --- a/Sources/ContactListFeature/Controllers/CreateGroupController.swift +++ b/Sources/ContactListFeature/Controllers/CreateGroupController.swift @@ -7,7 +7,7 @@ import XXModels import DependencyInjection public final class CreateGroupController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: ContactListCoordinating lazy private var titleLabel = UILabel() @@ -66,7 +66,7 @@ public final class CreateGroupController: UIViewController { private func setupTableAndCollection() { screenView.tableView.rowHeight = 64.0 - screenView.tableView.register(SmallAvatarAndTitleCell.self) + screenView.tableView.register(AvatarCell.self) screenView.collectionView.register(CreateGroupCollectionCell.self) collectionDataSource = UICollectionViewDiffableDataSource<SectionId, Contact>( @@ -84,10 +84,10 @@ public final class CreateGroupController: UIViewController { tableDataSource = DiffEditableDataSource<SectionId, Contact>( tableView: screenView.tableView ) { [weak self] tableView, indexPath, contact in - let cell = tableView.dequeueReusableCell(forIndexPath: indexPath, ofType: SmallAvatarAndTitleCell.self) + let cell = tableView.dequeueReusableCell(forIndexPath: indexPath, ofType: AvatarCell.self) let title = (contact.nickname ?? contact.username) ?? "" - cell.titleLabel.text = title - cell.avatarView.setupProfile(title: title, image: contact.photo, size: .medium) + + cell.setup(title: title, image: contact.photo) if let selectedElements = self?.selectedElements, selectedElements.contains(contact) { tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none) diff --git a/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift b/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift index 67e70645ed6c0d00f02862bdb02f199d3d735d9d..a8f94de8042f3cd7f7d0da14a9de703f217f573b 100644 --- a/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift +++ b/Sources/ContactListFeature/ViewModels/CreateGroupViewModel.swift @@ -76,7 +76,7 @@ final class CreateGroupViewModel { } func create(name: String, welcome: String?, members: [Contact]) { - hudRelay.send(.on(nil)) + hudRelay.send(.on) session.createGroup(name: name, welcome: welcome, members: members) { [weak self] in guard let self = self else { return } diff --git a/Sources/HUD/HUD.swift b/Sources/HUD/HUD.swift index 9f45b18078d75a2d11654f99a9bd2d1b2746f4ee..207f3473c9173a3a64c8636a8373d734d207807b 100644 --- a/Sources/HUD/HUD.swift +++ b/Sources/HUD/HUD.swift @@ -10,15 +10,17 @@ private enum Constants { } public enum HUDStatus: Equatable { - case on(String?) case none + case on + case onTitle(String) + case onAction(String) case error(HUDError) var isPresented: Bool { switch self { case .none: return false - case .on, .error: + case .on, .error, .onTitle, .onAction: return true } } @@ -50,15 +52,12 @@ public struct HUDError: Equatable { } } -public protocol HUDType { - func update(with status: HUDStatus) -} - -public final class HUD: HUDType { +public final class HUD { private(set) var window: UIWindow? private(set) var errorView: ErrorView? private(set) var titleLabel: UILabel? private(set) var animation: DotAnimation? + public var actionButton: CapsuleButton? private var cancellables = Set<AnyCancellable>() private var status: HUDStatus = .none { @@ -67,16 +66,23 @@ public final class HUD: HUDType { self.errorView = nil self.animation = nil self.window = nil + self.actionButton = nil self.titleLabel = nil switch status { - case .on(let text): + case .on: + animation = DotAnimation() + + case .onTitle(let text): animation = DotAnimation() + titleLabel = UILabel() + titleLabel!.text = text + + case .onAction(let title): + animation = DotAnimation() + actionButton = CapsuleButton() + actionButton!.set(style: .seeThroughWhite, title: title) - if let text = text { - titleLabel = UILabel() - titleLabel!.text = text - } case .error(let error): errorView = ErrorView(with: error) case .none: @@ -88,13 +94,19 @@ public final class HUD: HUDType { if oldValue.isPresented == false && status.isPresented == true { switch status { - case .on(let text): + case .on: animation = DotAnimation() - if let text = text { - titleLabel = UILabel() - titleLabel!.text = text - } + case .onTitle(let text): + animation = DotAnimation() + titleLabel = UILabel() + titleLabel!.text = text + + case .onAction(let title): + animation = DotAnimation() + actionButton = CapsuleButton() + actionButton!.set(style: .seeThroughWhite, title: title) + case .error(let error): errorView = ErrorView(with: error) case .none: @@ -118,7 +130,7 @@ public final class HUD: HUDType { private func showWindow() { window = Window() - window?.backgroundColor = UIColor.black.withAlphaComponent(0.5) + window?.backgroundColor = UIColor.black.withAlphaComponent(0.8) window?.rootViewController = StatusBarViewController(nil) if let animation = animation { @@ -138,6 +150,15 @@ public final class HUD: HUDType { } } + if let actionButton = actionButton { + window?.addSubview(actionButton) + actionButton.snp.makeConstraints { + $0.left.equalToSuperview().offset(18) + $0.right.equalToSuperview().offset(-18) + $0.bottom.equalToSuperview().offset(-50) + } + } + if let errorView = errorView { window?.addSubview(errorView) errorView.snp.makeConstraints { make in @@ -166,6 +187,7 @@ public final class HUD: HUDType { self.cancellables.removeAll() self.errorView = nil self.animation = nil + self.actionButton = nil self.titleLabel = nil self.window = nil } diff --git a/Sources/InputField/OutlinedInputField.swift b/Sources/InputField/OutlinedInputField.swift new file mode 100644 index 0000000000000000000000000000000000000000..3bb8ad3005167b9e9e99511243da912f7082d434 --- /dev/null +++ b/Sources/InputField/OutlinedInputField.swift @@ -0,0 +1,85 @@ +import UIKit +import Shared +import Combine + +public final class OutlinedInputField: UIView { + private let stackView = UIStackView() + private let textField = UITextField() + private let placeholderLabel = UILabel() + private let inputContainerView = UIView() + + private let secureInputButton = SecureInputButton() + + public var textPublisher: AnyPublisher<String, Never> { + textField.textPublisher + } + + public init() { + super.init(frame: .zero) + + layer.borderWidth = 1.0 + layer.cornerRadius = 4.0 + layer.masksToBounds = true + layer.borderColor = Asset.neutralWeak.color.cgColor + + textField.delegate = self + textField.backgroundColor = .clear + textField.textColor = Asset.neutralDark.color + placeholderLabel.textColor = Asset.neutralWeak.color + placeholderLabel.font = Fonts.Mulish.regular.font(size: 16.0) + + secureInputButton.button.addTarget(self, action: #selector(didTapRight), for: .touchUpInside) + + inputContainerView.addSubview(placeholderLabel) + inputContainerView.addSubview(textField) + + stackView.addArrangedSubview(inputContainerView) + stackView.addArrangedSubview(secureInputButton) + + addSubview(stackView) + + placeholderLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(15) + $0.left.equalToSuperview().offset(15) + $0.right.lessThanOrEqualToSuperview().offset(-15) + $0.bottom.equalToSuperview().offset(-18) + } + + textField.snp.makeConstraints { + $0.top.equalToSuperview().offset(15) + $0.left.equalToSuperview().offset(15) + $0.right.equalToSuperview().offset(-15) + $0.bottom.equalToSuperview().offset(-18) + } + + stackView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + required init?(coder: NSCoder) { nil } + + public func setup(title: String, sensitive: Bool = false) { + placeholderLabel.text = title + textField.isSecureTextEntry = sensitive + secureInputButton.isHidden = !sensitive + } + + @objc private func didTapRight() { + textField.isSecureTextEntry.toggle() + secureInputButton.setSecure(textField.isSecureTextEntry) + } +} + +extension OutlinedInputField: UITextFieldDelegate { + public func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + placeholderLabel.alpha = (textField.text! as NSString) + .replacingCharacters(in: range, with: string) + .count > 0 ? 0.0 : 1.0 + return true + } +} diff --git a/Sources/InputField/SecureInputButton.swift b/Sources/InputField/SecureInputButton.swift new file mode 100644 index 0000000000000000000000000000000000000000..1f2e6b20751370755ec23b966c153ca5440c9f32 --- /dev/null +++ b/Sources/InputField/SecureInputButton.swift @@ -0,0 +1,31 @@ +import UIKit +import Shared + +final class SecureInputButton: UIView { + private(set) var button = UIButton() + private let color = Asset.neutralSecondaryAlternative.color + private lazy var openedImage = Asset.eyeOpen.image.withTintColor(color) + private lazy var closedImage = Asset.eyeClosed.image.withTintColor(color) + + init() { + super.init(frame: .zero) + + button.setContentCompressionResistancePriority(.required, for: .horizontal) + button.setImage(Asset.eyeClosed.image.withTintColor(color), for: .normal) + + addSubview(button) + + button.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview().offset(10) + $0.right.equalToSuperview().offset(-10) + $0.bottom.equalToSuperview() + } + } + + required init?(coder: NSCoder) { nil } + + func setSecure(_ bool: Bool) { + button.setImage(bool ? closedImage : openedImage, for: .normal) + } +} diff --git a/Sources/Integration/Client.swift b/Sources/Integration/Client.swift index 7d53fb7efaf8ebe3b4f6485bc38dd101ce8960e5..450c674ebc5647268114eb3b251e6b56c12efca4 100644 --- a/Sources/Integration/Client.swift +++ b/Sources/Integration/Client.swift @@ -89,7 +89,12 @@ public class Client { // } public func addJson(_ string: String) { - guard let backupManager = backupManager else { return } + guard let backupManager = backupManager else { + fatalError("Trying to add json parameters to backup but no backup manager created yet") + } + + print("^^^ Set params: \(string) to backup") + backupManager.addJson(string) } diff --git a/Sources/Integration/Implementations/Bindings.swift b/Sources/Integration/Implementations/Bindings.swift index 7c6feb9f10688bc0d34726aec2479c8174438484..a05387dda2653644a26a3bc28b40e011c6eb5e03 100644 --- a/Sources/Integration/Implementations/Bindings.swift +++ b/Sources/Integration/Implementations/Bindings.swift @@ -154,17 +154,15 @@ extension BindingsClient: BindingsInterface { for env: NetworkEnvironment, _ completion: @escaping (Result<Data?, Error>) -> Void ) { - log(type: .crumbs) - var error: NSError? let ndf = BindingsDownloadAndVerifySignedNdfWithUrl(env.url, env.cert, &error) - if let error = error { - log(string: error.localizedDescription, type: .error) - completion(.failure(error)) - } else { - completion(.success(ndf)) + guard error == nil else { + Self.updateNDF(for: env, completion) + return } + + completion(.success(ndf)) } /// Fetches a JSON with up-to-date error descriptions diff --git a/Sources/Integration/Session/Session+Contacts.swift b/Sources/Integration/Session/Session+Contacts.swift index fa27fe2ece740c14217d809d8bab6fd48fae670a..7bbe5254ab00b17b8053323586347ef819f10ff1 100644 --- a/Sources/Integration/Session/Session+Contacts.swift +++ b/Sources/Integration/Session/Session+Contacts.swift @@ -94,7 +94,7 @@ extension Session { } public func retryRequest(_ contact: Contact) throws { - log(string: "Retrying to request a contact", type: .info) + let name = (contact.nickname ?? contact.username) ?? "" client.bindings.add(contact.marshaled!, from: myQR) { [weak self, contact] in var contact = contact @@ -103,11 +103,21 @@ extension Session { do { switch $0 { case .success: - log(string: "Retrying to request a contact -- Success", type: .info) contact.authStatus = .requested - case .failure(let error): - log(string: "Retrying to request a contact -- Failed: \(error.localizedDescription)", type: .error) + + self.toastController.enqueueToast(model: .init( + title: Localized.Requests.Sent.Toast.resent(name), + leftImage: Asset.sharedSuccess.image + )) + + case .failure: contact.createdAt = Date() + + self.toastController.enqueueToast(model: .init( + title: Localized.Requests.Sent.Toast.resentFailed(name), + color: Asset.accentDanger.color, + leftImage: Asset.requestFailedToaster.image + )) } _ = try self.dbManager.saveContact(contact) @@ -118,29 +128,41 @@ extension Session { } public func add(_ contact: Contact) throws { + /// Make sure we are not adding ourselves + /// guard contact.username != username else { throw NSError.create("You can't add yourself") } - var contactToOperate: Contact! + var contact = contact - if [.requestFailed, .confirmationFailed, .stranger].contains(contact.authStatus) { - contactToOperate = contact + /// Check if this contact is actually + /// being requested/confirmed after failing + /// + if [.requestFailed, .confirmationFailed].contains(contact.authStatus) { + /// If it is being re-requested or + /// re-confirmed, no need to save again + /// + contact.createdAt = Date() + + if contact.authStatus == .confirmationFailed { + try confirm(contact) + return + } } else { + /// If its not failed, lets make sure that + /// this is an actual new contact + /// if let _ = try? dbManager.fetchContacts(.init(id: [contact.id])).first { + /// Found a user w/ that id already stored + /// throw NSError.create("This user has already been requested") } - contactToOperate = try dbManager.saveContact(contact) - } - - guard contactToOperate.authStatus != .confirmationFailed else { - contactToOperate.createdAt = Date() - try confirm(contact) - return + contact.authStatus = .requesting } - contactToOperate.authStatus = .requesting + contact = try dbManager.saveContact(contact) let myself = client.bindings.meMarshalled( username!, @@ -148,22 +170,30 @@ extension Session { phone: isSharingPhone ? phone : nil ) - client.bindings.add(contactToOperate.marshaled!, from: myself) { [weak self, contactToOperate] in - guard let self = self, var contactToOperate = contactToOperate else { return } + client.bindings.add(contact.marshaled!, from: myself) { [weak self, contact] in + guard let self = self else { return } + var contact = contact do { switch $0 { case .success(let success): - contactToOperate.authStatus = success ? .requested : .requestFailed - contactToOperate = try self.dbManager.saveContact(contactToOperate) + contact.authStatus = success ? .requested : .requestFailed + contact = try self.dbManager.saveContact(contact) + + let name = contact.nickname ?? contact.username + + self.toastController.enqueueToast(model: .init( + title: Localized.Requests.Sent.Toast.sent(name ?? ""), + leftImage: Asset.sharedSuccess.image + )) case .failure: - contactToOperate.authStatus = .requestFailed - contactToOperate.createdAt = Date() - contactToOperate = try self.dbManager.saveContact(contactToOperate) + contact.createdAt = Date() + contact.authStatus = .requestFailed + contact = try self.dbManager.saveContact(contact) self.toastController.enqueueToast(model: .init( - title: Localized.Requests.Failed.toast(contactToOperate.nickname ?? contact.username!), + title: Localized.Requests.Failed.toast(contact.nickname ?? contact.username!), color: Asset.accentDanger.color, leftImage: Asset.requestFailedToaster.image )) diff --git a/Sources/Integration/Session/Session+UD.swift b/Sources/Integration/Session/Session+UD.swift index 6dd7472fedb8b3c9fd798134d8ede35a29ce3804..1fe3e2e09b2f7f0ae327b039602320bbc884eb2b 100644 --- a/Sources/Integration/Session/Session+UD.swift +++ b/Sources/Integration/Session/Session+UD.swift @@ -1,8 +1,35 @@ import Models import XXModels import Foundation +import Combine extension Session { + public func search(fact: String) -> AnyPublisher<Contact, Error> { + Deferred { + Future { promise in + guard let ud = self.client.userDiscovery else { + let error = NSError(domain: "", code: 0) + promise(.failure(error)) + return + } + + do { + try self.client.bindings.nodeRegistrationStatus() + try ud.search(fact: fact) { + switch $0 { + case .success(let contact): + promise(.success(contact)) + case .failure(let error): + promise(.failure(error)) + } + } + } catch { + promise(.failure(error)) + } + } + }.eraseToAnyPublisher() + } + public func search(fact: String, _ completion: @escaping (Result<Contact, Error>) -> Void) throws { guard let ud = client.userDiscovery else { return } try client.bindings.nodeRegistrationStatus() @@ -41,20 +68,13 @@ extension Session { switch $0 { case .success(_): - _ = try? self.dbManager.saveContact(.init( - id: self.client.bindings.myId, - marshaled: self.client.bindings.meMarshalled, - username: value, - email: nil, - phone: nil, - nickname: nil, - photo: nil, - authStatus: .friend, - isRecent: false, - createdAt: Date() - )) - self.username = value + + if var me = try? self.myContact() { + me.username = value + _ = try? self.dbManager.saveContact(me) + } + completion(.success(nil)) case .failure(let error): completion(.failure(error)) @@ -76,6 +96,8 @@ extension Session { phone = confirmation.content } - updateFactsOnBackup() + if let _ = client.backupManager { + updateFactsOnBackup() + } } } diff --git a/Sources/Integration/Session/Session.swift b/Sources/Integration/Session/Session.swift index ae01b853ac2ac24d7e8427f3a868dcb18b34dcde..f85a6c6f0e3350e20c2bb234e6a92935e589b01f 100644 --- a/Sources/Integration/Session/Session.swift +++ b/Sources/Integration/Session/Session.swift @@ -130,8 +130,20 @@ public final class Session: SessionType { let params = try! JSONDecoder().decode(BackupParameters.self, from: Data(report.parameters.utf8)) username = params.username - phone = params.phone - email = params.email + + if let paramsPhone = params.phone, !paramsPhone.isEmpty { + phone = paramsPhone + } + + if let paramsEmail = params.email, !paramsEmail.isEmpty { + email = paramsEmail + } + } + + print("^^^ \(report.parameters)") + + guard username!.isEmpty == false else { + fatalError("Trying to restore an account that has no username") } try continueInitialization() @@ -183,6 +195,15 @@ public final class Session: SessionType { } private func continueInitialization() throws { + var myContact = try self.myContact() + myContact.marshaled = client.bindings.meMarshalled + myContact.username = username + myContact.email = email + myContact.phone = phone + myContact.authStatus = .friend + myContact.isRecent = false + _ = try dbManager.saveContact(myContact) + setupBindings() networkMonitor.start() @@ -356,6 +377,11 @@ public final class Session: SessionType { ).jsonFormat client.addJson(params) + + guard username!.isEmpty == false else { + fatalError("Tried to build a backup with my username but an empty string was set to it") + } + backupService.performBackupIfAutomaticIsEnabled() } @@ -381,7 +407,6 @@ public final class Session: SessionType { .store(in: &cancellables) client.backup - .throttle(for: .seconds(5), scheduler: DispatchQueue.main, latest: true) .sink { [unowned self] in backupService.updateBackup(data: $0) } .store(in: &cancellables) @@ -390,9 +415,10 @@ public final class Session: SessionType { /// This will get called when my contact restore its contact. /// TODO: Hold a record on the chat that this contact restored. /// - var contact = $0 - contact.authStatus = .friend - _ = try? dbManager.saveContact(contact) + if var contact = try? dbManager.fetchContacts(.init(id: [$0.id])).first { + contact.authStatus = .friend + _ = try? dbManager.saveContact(contact) + } }.store(in: &cancellables) backupService.settingsPublisher @@ -402,7 +428,6 @@ public final class Session: SessionType { if $0 == true { guard let passphrase = backupService.passphrase else { client.resumeBackup() - updateFactsOnBackup() return } @@ -416,10 +441,6 @@ public final class Session: SessionType { } .store(in: &cancellables) - networkMonitor.statusPublisher - .sink { print($0) } - .store(in: &cancellables) - client.messages .sink { [unowned self] in if var contact = try? dbManager.fetchContacts(.init(id: [$0.senderId])).first { @@ -468,4 +489,12 @@ public final class Session: SessionType { } .store(in: &cancellables) } + + func myContact() throws -> Contact { + if let contact = try dbManager.fetchContacts(.init(id: [client.bindings.myId])).first { + return contact + } else { + return try dbManager.saveContact(.init(id: client.bindings.myId)) + } + } } diff --git a/Sources/Integration/Session/SessionType.swift b/Sources/Integration/Session/SessionType.swift index e99c6f37fb45599aae26836c7a8f248ae20f02f1..b871332b216e5c7dbdc0adfebc4bac3f306913b7 100644 --- a/Sources/Integration/Session/SessionType.swift +++ b/Sources/Integration/Session/SessionType.swift @@ -66,4 +66,6 @@ public protocol SessionType { members: [Contact], _ completion: @escaping (Result<GroupInfo, Error>) -> Void ) + + func search(fact: String) -> AnyPublisher<Contact, Error> } diff --git a/Sources/Keychain/KeychainHandler.swift b/Sources/Keychain/KeychainHandler.swift index 23f248879a7f2275b0faddd9f4c08962d9ed6246..6ac0d645d6def33360ee5ed0d0ab1e973a0487fc 100644 --- a/Sources/Keychain/KeychainHandler.swift +++ b/Sources/Keychain/KeychainHandler.swift @@ -1,15 +1,24 @@ import Foundation import KeychainAccess +public enum KeychainSFTP: String { + case pwd + case host + case username +} + public protocol KeychainHandling { func clear() throws func getPassword() throws -> Data? func store(password pwd: Data) throws + + func get(key: KeychainSFTP) throws -> String? + func store(key: KeychainSFTP, value: String) throws } public struct KeychainHandler: KeychainHandling { - private let password = "password" private let keychain: Keychain + private let password = "password" public init() { self.keychain = Keychain(service: "XXM") @@ -26,4 +35,12 @@ public struct KeychainHandler: KeychainHandling { public func getPassword() throws -> Data? { try keychain.getData(password) } + + public func get(key: KeychainSFTP) throws -> String? { + try keychain.get(key.rawValue) + } + + public func store(key: KeychainSFTP, value: String) throws { + try keychain.set(value, key: key.rawValue) + } } diff --git a/Sources/Keychain/MockKeychainHandler.swift b/Sources/Keychain/MockKeychainHandler.swift index c1ff10dd802d5cd230cffbe25f20b744aa06126b..39d4a33ddb1e0e6d8a75d8b5a1ea980d8fb189bf 100644 --- a/Sources/Keychain/MockKeychainHandler.swift +++ b/Sources/Keychain/MockKeychainHandler.swift @@ -6,4 +6,6 @@ public struct MockKeychainHandler: KeychainHandling { public func clear() throws {} public func store(password pwd: Data) throws {} public func getPassword() throws -> Data? { Data() } + public func get(key: KeychainSFTP) throws -> String? { nil } + public func store(key: KeychainSFTP, value: String) throws {} } diff --git a/Sources/LaunchFeature/LaunchController.swift b/Sources/LaunchFeature/LaunchController.swift index 33c2f8dad55d29d02a7e74b3d70437fbaea4aa6f..2eb373f55784bed3e9a11293acc7cca9aee2fc08 100644 --- a/Sources/LaunchFeature/LaunchController.swift +++ b/Sources/LaunchFeature/LaunchController.swift @@ -7,7 +7,7 @@ import DependencyInjection public final class LaunchController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: LaunchCoordinating lazy private var screenView = LaunchView() diff --git a/Sources/LaunchFeature/LaunchViewModel.swift b/Sources/LaunchFeature/LaunchViewModel.swift index 054432b4403e7177bf129c21680a58ac4abb6b12..fefbe7cf6035b19d804febd7e544e22166d11bf8 100644 --- a/Sources/LaunchFeature/LaunchViewModel.swift +++ b/Sources/LaunchFeature/LaunchViewModel.swift @@ -5,6 +5,7 @@ import Models import Combine import Defaults import XXModels +import Keychain import Foundation import Integration import Permissions @@ -31,6 +32,7 @@ final class LaunchViewModel { @Dependency private var network: XXNetworking @Dependency private var versionChecker: VersionChecker @Dependency private var dropboxService: DropboxInterface + @Dependency private var keychainHandler: KeychainHandling @Dependency private var permissionHandler: PermissionHandling @KeyObject(.username, defaultValue: nil) var username: String? @@ -56,7 +58,7 @@ final class LaunchViewModel { func viewDidAppear() { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in - self?.hudSubject.send(.on(nil)) + self?.hudSubject.send(.on) self?.checkVersion() } } @@ -90,6 +92,7 @@ final class LaunchViewModel { self.hudSubject.send(.none) self.routeSubject.send(.onboarding(ndf)) self.dropboxService.unlink() + try? self.keychainHandler.clear() return } @@ -98,6 +101,7 @@ final class LaunchViewModel { self.hudSubject.send(.none) self.routeSubject.send(.onboarding(ndf)) self.dropboxService.unlink() + try? self.keychainHandler.clear() return } diff --git a/Sources/MenuFeature/Controllers/MenuController.swift b/Sources/MenuFeature/Controllers/MenuController.swift index a960075a03c8d2a1f337d4d16581a650317420eb..682e23d450bde134916a2db8a00d13c2279c9321 100644 --- a/Sources/MenuFeature/Controllers/MenuController.swift +++ b/Sources/MenuFeature/Controllers/MenuController.swift @@ -51,6 +51,7 @@ public final class MenuController: UIViewController { image: viewModel.avatar ) + screenView.select(item: previousItem) screenView.xxdkVersionLabel.text = "XXDK \(viewModel.xxdk)" screenView.buildLabel.text = Localized.Menu.build(viewModel.build) screenView.versionLabel.text = Localized.Menu.version(viewModel.version) diff --git a/Sources/MenuFeature/Views/MenuSectionButton.swift b/Sources/MenuFeature/Views/MenuSectionButton.swift index b24c5dc44efb74c226dee1316f5642f53a1a874d..c5f6ea371a10f0ff3fc087b31f6dc744e11fd4a9 100644 --- a/Sources/MenuFeature/Views/MenuSectionButton.swift +++ b/Sources/MenuFeature/Views/MenuSectionButton.swift @@ -40,9 +40,17 @@ final class MenuSectionButton: UIControl { notificationLabel.text = " \(count) " } - func set(title: String, image: UIImage, color: UIColor = Asset.neutralWeak.color) { - titleLabel.text = title + func set(color: UIColor) { titleLabel.textColor = color - imageView.image = image.withTintColor(color) + + if let image = imageView.image { + imageView.image = image.withTintColor(color) + } + } + + func set(title: String, image: UIImage) { + titleLabel.text = title + titleLabel.textColor = Asset.neutralWeak.color + imageView.image = image.withTintColor(Asset.neutralWeak.color) } } diff --git a/Sources/MenuFeature/Views/MenuView.swift b/Sources/MenuFeature/Views/MenuView.swift index 48bee02edc7f7e3e22edfc75281395e968c0c973..b19ec9d1d1367f5d910dfe97d40ee9198b7e3ec0 100644 --- a/Sources/MenuFeature/Views/MenuView.swift +++ b/Sources/MenuFeature/Views/MenuView.swift @@ -18,20 +18,9 @@ final class MenuView: UIView { init() { super.init(frame: .zero) - setup() - } - - required init?(coder: NSCoder) { nil } - - private func setup() { backgroundColor = Asset.neutralDark.color - chatsButton.set( - title: Localized.Menu.chats, - image: Asset.menuChats.image, - color: Asset.brandPrimary.color - ) - + chatsButton.set(title: Localized.Menu.chats, image: Asset.menuChats.image) scanButton.set(title: Localized.Menu.scan, image: Asset.menuScan.image) requestsButton.set(title: Localized.Menu.requests, image: Asset.menuRequests.image) contactsButton.set(title: Localized.Menu.contacts, image: Asset.menuContacts.image) @@ -64,33 +53,42 @@ final class MenuView: UIView { addSubview(infoStackView) setupConstraints() - setupAccessibility() } - private func setupConstraints() { - headerView.snp.makeConstraints { make in - make.top.equalTo(safeAreaLayoutGuide).offset(20) - make.left.equalToSuperview().offset(30) - make.right.equalToSuperview().offset(-24) + required init?(coder: NSCoder) { nil } + + func select(item: MenuItem) { + switch item { + case .chats: + chatsButton.set(color: Asset.brandPrimary.color) + case .contacts: + contactsButton.set(color: Asset.brandPrimary.color) + case .requests: + requestsButton.set(color: Asset.brandPrimary.color) + case .scan: + scanButton.set(color: Asset.brandPrimary.color) + case .settings: + settingsButton.set(color: Asset.brandPrimary.color) + case .profile, .dashboard, .join: + break } + } - stackView.snp.makeConstraints { make in - make.left.equalToSuperview().offset(26) - make.top.equalTo(headerView.snp.bottom).offset(75) + private func setupConstraints() { + headerView.snp.makeConstraints { + $0.top.equalTo(safeAreaLayoutGuide).offset(20) + $0.left.equalToSuperview().offset(30) + $0.right.equalToSuperview().offset(-24) } - infoStackView.snp.makeConstraints { make in - make.bottom.equalTo(safeAreaLayoutGuide).offset(-20) - make.left.equalToSuperview().offset(20) + stackView.snp.makeConstraints { + $0.left.equalToSuperview().offset(26) + $0.top.equalTo(headerView.snp.bottom).offset(75) } - } - private func setupAccessibility() { - scanButton.accessibilityIdentifier = Localized.Accessibility.Menu.scan - chatsButton.accessibilityIdentifier = Localized.Accessibility.Menu.chats - headerView.accessibilityIdentifier = Localized.Accessibility.Menu.header - contactsButton.accessibilityIdentifier = Localized.Accessibility.Menu.contacts - requestsButton.accessibilityIdentifier = Localized.Accessibility.Menu.requests - settingsButton.accessibilityIdentifier = Localized.Accessibility.Menu.settings + infoStackView.snp.makeConstraints { + $0.bottom.equalTo(safeAreaLayoutGuide).offset(-20) + $0.left.equalToSuperview().offset(20) + } } } diff --git a/Sources/Models/CloudService.swift b/Sources/Models/CloudService.swift index 5daba7238d74388dac591b63220dbbc8b1c9f911..d217dca853e6ecf22f119520d9805567f7ead3a5 100644 --- a/Sources/Models/CloudService.swift +++ b/Sources/Models/CloudService.swift @@ -2,4 +2,5 @@ public enum CloudService: Equatable, Codable { case drive case icloud case dropbox + case sftp } diff --git a/Sources/NetworkMonitor/MockNetworkMonitor.swift b/Sources/NetworkMonitor/MockNetworkMonitor.swift index 473eb593ece68fd213a4896abe9a74eb3c23d286..30bf846df9e36359ed80a7572a966e56091dce4d 100644 --- a/Sources/NetworkMonitor/MockNetworkMonitor.swift +++ b/Sources/NetworkMonitor/MockNetworkMonitor.swift @@ -32,11 +32,11 @@ public struct MockNetworkMonitor: NetworkMonitoring { statusRelay.send(status) if status == .available { - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { simulateOscilation(.internetNotAvailable) } } else if status == .internetNotAvailable { - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { simulateOscilation(.available) } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift b/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift index 75b7d0ffb081bec57a2c8cf06d0fa6915fd07092..90c210569b58b904473f5c2e2020740e2945a09d 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingEmailConfirmationController.swift @@ -9,7 +9,7 @@ import ScrollViewController import Models public final class OnboardingEmailConfirmationController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: OnboardingCoordinating @Dependency private var statusBarController: StatusBarStyleControlling diff --git a/Sources/OnboardingFeature/Controllers/OnboardingEmailController.swift b/Sources/OnboardingFeature/Controllers/OnboardingEmailController.swift index 68b10159a32ab4e5167d10ba98d993f453fdf24d..54fb8cf4efcf4cd29489729598ed22144b918f65 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingEmailController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingEmailController.swift @@ -8,7 +8,7 @@ import DependencyInjection import ScrollViewController public final class OnboardingEmailController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: OnboardingCoordinating @Dependency private var statusBarController: StatusBarStyleControlling diff --git a/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift b/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift index dbef5be43b8314ffcc71a2481bb4bcc0d67a5e6d..0017798bc5fc2a4703a56cf6b4fc2a45fbcffa55 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingPhoneConfirmationController.swift @@ -9,7 +9,7 @@ import ScrollViewController import Models public final class OnboardingPhoneConfirmationController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: OnboardingCoordinating @Dependency private var statusBarController: StatusBarStyleControlling diff --git a/Sources/OnboardingFeature/Controllers/OnboardingPhoneController.swift b/Sources/OnboardingFeature/Controllers/OnboardingPhoneController.swift index b935ae07434e90d94818a2ea400202924323ea32..8793508fe6ec8d94e3ee00ff5f3052031c70ee53 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingPhoneController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingPhoneController.swift @@ -8,7 +8,7 @@ import DependencyInjection import ScrollViewController public final class OnboardingPhoneController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: OnboardingCoordinating @Dependency private var statusBarController: StatusBarStyleControlling diff --git a/Sources/OnboardingFeature/Controllers/OnboardingStartController.swift b/Sources/OnboardingFeature/Controllers/OnboardingStartController.swift index 2ed639b116b86ab36b2cdf8924b63626bd653d0c..d95169bbd9c5ce078687a77c96768d91e9d2336d 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingStartController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingStartController.swift @@ -6,7 +6,7 @@ import Combine import DependencyInjection public final class OnboardingStartController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: OnboardingCoordinating lazy private var screenView = OnboardingStartView() diff --git a/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift b/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift index 19fc945b63b2495f5823af11fb21de205f538d85..3bc4493ebd99899a4b9ea646964816ff0140009e 100644 --- a/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift +++ b/Sources/OnboardingFeature/Controllers/OnboardingUsernameController.swift @@ -1,14 +1,14 @@ import HUD -import DrawerFeature import Theme import UIKit import Shared import Combine +import DrawerFeature import DependencyInjection import ScrollViewController public final class OnboardingUsernameController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: OnboardingCoordinating @Dependency private var statusBarController: StatusBarStyleControlling diff --git a/Sources/OnboardingFeature/ViewModels/OnboardingEmailConfirmationViewModel.swift b/Sources/OnboardingFeature/ViewModels/OnboardingEmailConfirmationViewModel.swift index 1263fc26efb0f01d50f00a25e8b4b27e3b821b81..b3871d6234517f1d493ab61da1466bc754446fd7 100644 --- a/Sources/OnboardingFeature/ViewModels/OnboardingEmailConfirmationViewModel.swift +++ b/Sources/OnboardingFeature/ViewModels/OnboardingEmailConfirmationViewModel.swift @@ -58,7 +58,7 @@ final class OnboardingEmailConfirmationViewModel { } func didTapNext() { - hudRelay.send(.on(nil)) + hudRelay.send(.on) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } diff --git a/Sources/OnboardingFeature/ViewModels/OnboardingEmailViewModel.swift b/Sources/OnboardingFeature/ViewModels/OnboardingEmailViewModel.swift index 618a903459267835ea47210d17ab0e5f763ac68e..c3cbbb897840964e15f908b9fa83d5d6537f7b23 100644 --- a/Sources/OnboardingFeature/ViewModels/OnboardingEmailViewModel.swift +++ b/Sources/OnboardingFeature/ViewModels/OnboardingEmailViewModel.swift @@ -38,7 +38,7 @@ final class OnboardingEmailViewModel { } func didTapNext() { - hudRelay.send(.on(nil)) + hudRelay.send(.on) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } diff --git a/Sources/OnboardingFeature/ViewModels/OnboardingPhoneConfirmationViewModel.swift b/Sources/OnboardingFeature/ViewModels/OnboardingPhoneConfirmationViewModel.swift index 0af84d605d2c56a938e474d27fd79ad324943ee3..2bd5a7ae35fbca7bf871479f998abeef9e4ce625 100644 --- a/Sources/OnboardingFeature/ViewModels/OnboardingPhoneConfirmationViewModel.swift +++ b/Sources/OnboardingFeature/ViewModels/OnboardingPhoneConfirmationViewModel.swift @@ -58,7 +58,7 @@ final class OnboardingPhoneConfirmationViewModel { } func didTapNext() { - hudRelay.send(.on(nil)) + hudRelay.send(.on) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } diff --git a/Sources/OnboardingFeature/ViewModels/OnboardingPhoneViewModel.swift b/Sources/OnboardingFeature/ViewModels/OnboardingPhoneViewModel.swift index 4698673d45cbe3b05f73fb3b1943f598f1c9655e..0aff02f402420b959d03f166ba4eb456d1098bea 100644 --- a/Sources/OnboardingFeature/ViewModels/OnboardingPhoneViewModel.swift +++ b/Sources/OnboardingFeature/ViewModels/OnboardingPhoneViewModel.swift @@ -47,7 +47,7 @@ final class OnboardingPhoneViewModel { } func didTapNext() { - hudRelay.send(.on(nil)) + hudRelay.send(.on) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } diff --git a/Sources/OnboardingFeature/ViewModels/OnboardingUsernameViewModel.swift b/Sources/OnboardingFeature/ViewModels/OnboardingUsernameViewModel.swift index 8766aac8e7629e4bdb9079cd8a3774aa2d1989a2..ecb64035e73e259bf147ce4ee7f427723d40b3f3 100644 --- a/Sources/OnboardingFeature/ViewModels/OnboardingUsernameViewModel.swift +++ b/Sources/OnboardingFeature/ViewModels/OnboardingUsernameViewModel.swift @@ -42,7 +42,7 @@ final class OnboardingUsernameViewModel { } func didTapRegister() { - hudRelay.send(.on(nil)) + hudRelay.send(.on) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } diff --git a/Sources/ProfileFeature/Controllers/ProfileCodeController.swift b/Sources/ProfileFeature/Controllers/ProfileCodeController.swift index d909662aa961fda594217d71d4dd940bd0cf80af..c005b0bf9c10daa6db1f8521799da86bbfad26a4 100644 --- a/Sources/ProfileFeature/Controllers/ProfileCodeController.swift +++ b/Sources/ProfileFeature/Controllers/ProfileCodeController.swift @@ -10,7 +10,7 @@ import ScrollViewController public typealias ControllerClosure = (UIViewController, AttributeConfirmation) -> Void public final class ProfileCodeController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD lazy private var screenView = ProfileCodeView() lazy private var scrollViewController = ScrollViewController() diff --git a/Sources/ProfileFeature/Controllers/ProfileController.swift b/Sources/ProfileFeature/Controllers/ProfileController.swift index 62a2c90ee9bfb56674f8601bb1007d62c5f3323d..e3138f275dd967d73d58548649e4156409178e57 100644 --- a/Sources/ProfileFeature/Controllers/ProfileController.swift +++ b/Sources/ProfileFeature/Controllers/ProfileController.swift @@ -7,7 +7,7 @@ import Combine import DependencyInjection public final class ProfileController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: ProfileCoordinating @Dependency private var statusBarController: StatusBarStyleControlling diff --git a/Sources/ProfileFeature/Controllers/ProfileEmailController.swift b/Sources/ProfileFeature/Controllers/ProfileEmailController.swift index fb85221449bea9ecd89b1a9507db4182b7365622..97ced65c65a10b7d4ac8033a5b2c0d80cf959997 100644 --- a/Sources/ProfileFeature/Controllers/ProfileEmailController.swift +++ b/Sources/ProfileFeature/Controllers/ProfileEmailController.swift @@ -7,7 +7,7 @@ import DependencyInjection import ScrollViewController public final class ProfileEmailController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: ProfileCoordinating @Dependency private var statusBarController: StatusBarStyleControlling diff --git a/Sources/ProfileFeature/Controllers/ProfilePhoneController.swift b/Sources/ProfileFeature/Controllers/ProfilePhoneController.swift index b6ddaffb5574bdb44f70994fe2ce2c7b0e98b3c0..eb77fd14d36bce3960f30a73aa4ac7c117e453e7 100644 --- a/Sources/ProfileFeature/Controllers/ProfilePhoneController.swift +++ b/Sources/ProfileFeature/Controllers/ProfilePhoneController.swift @@ -9,7 +9,7 @@ import ScrollViewController #warning("TODO: Merge ProfilePhoneController/ProfileEmailController") public final class ProfilePhoneController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: ProfileCoordinating @Dependency private var statusBarController: StatusBarStyleControlling diff --git a/Sources/ProfileFeature/ViewModels/ProfileCodeViewModel.swift b/Sources/ProfileFeature/ViewModels/ProfileCodeViewModel.swift index 0cc8f60cfaea6921d99a90ccbba74a0cdfc322c2..d9763e989e23e37c9736582a41fcf1b63aef03b2 100644 --- a/Sources/ProfileFeature/ViewModels/ProfileCodeViewModel.swift +++ b/Sources/ProfileFeature/ViewModels/ProfileCodeViewModel.swift @@ -57,7 +57,7 @@ final class ProfileCodeViewModel { } func didTapNext() { - hudRelay.send(.on(nil)) + hudRelay.send(.on) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } diff --git a/Sources/ProfileFeature/ViewModels/ProfileEmailViewModel.swift b/Sources/ProfileFeature/ViewModels/ProfileEmailViewModel.swift index df2bb28cdaa889462d1db1eb506cb14129567312..6b57bc81fe4040faaccaeefff057a275a90deaac 100644 --- a/Sources/ProfileFeature/ViewModels/ProfileEmailViewModel.swift +++ b/Sources/ProfileFeature/ViewModels/ProfileEmailViewModel.swift @@ -40,7 +40,7 @@ final class ProfileEmailViewModel { } func didTapNext() { - hudRelay.send(.on(nil)) + hudRelay.send(.on) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } diff --git a/Sources/ProfileFeature/ViewModels/ProfilePhoneViewModel.swift b/Sources/ProfileFeature/ViewModels/ProfilePhoneViewModel.swift index 9bf5da6e11788124e2bba6b04ba05f2f64b7f033..1013725b419a118c4e437d3e8959c50748447d72 100644 --- a/Sources/ProfileFeature/ViewModels/ProfilePhoneViewModel.swift +++ b/Sources/ProfileFeature/ViewModels/ProfilePhoneViewModel.swift @@ -47,7 +47,7 @@ final class ProfilePhoneViewModel { } func didTapNext() { - hudRelay.send(.on(nil)) + hudRelay.send(.on) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } diff --git a/Sources/ProfileFeature/ViewModels/ProfileViewModel.swift b/Sources/ProfileFeature/ViewModels/ProfileViewModel.swift index 8f03379f02adde54606a70e4bd7c76f3e1fea87d..a7066eccfaada6e74960beb7a5b3b8b7e72548cb 100644 --- a/Sources/ProfileFeature/ViewModels/ProfileViewModel.swift +++ b/Sources/ProfileFeature/ViewModels/ProfileViewModel.swift @@ -83,7 +83,7 @@ final class ProfileViewModel { } func didTapDelete(isEmail: Bool) { - hudRelay.send(.on(nil)) + hudRelay.send(.on) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } diff --git a/Sources/RequestsFeature/Controllers/RequestsFailedController.swift b/Sources/RequestsFeature/Controllers/RequestsFailedController.swift index c166da6b2c97b01f38e273b2a1a91ccbed475055..c0af65d5f83a4bc1528065eef1551e597a52f6c9 100644 --- a/Sources/RequestsFeature/Controllers/RequestsFailedController.swift +++ b/Sources/RequestsFeature/Controllers/RequestsFailedController.swift @@ -5,7 +5,7 @@ import Combine import DependencyInjection final class RequestsFailedController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD lazy private var screenView = RequestsFailedView() private var cancellables = Set<AnyCancellable>() diff --git a/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift b/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift index 5edb77726fa6d43ba7d4e2a816192fcadd39303b..0a30d05fb32b5ec4d3eb54b1fdeb9ced674a17ac 100644 --- a/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift +++ b/Sources/RequestsFeature/Controllers/RequestsReceivedController.swift @@ -10,7 +10,7 @@ import DrawerFeature import DependencyInjection final class RequestsReceivedController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var toaster: ToastController @Dependency private var coordinator: RequestsCoordinating diff --git a/Sources/RequestsFeature/Controllers/RequestsSentController.swift b/Sources/RequestsFeature/Controllers/RequestsSentController.swift index 36c245f4476fdd8795a6c0c38612f86c1ec6953e..ceed2cc28989b51d72d0191e63e81ade2f930bdd 100644 --- a/Sources/RequestsFeature/Controllers/RequestsSentController.swift +++ b/Sources/RequestsFeature/Controllers/RequestsSentController.swift @@ -5,7 +5,7 @@ import Combine import DependencyInjection final class RequestsSentController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD var connectionsPublisher: AnyPublisher<Void, Never> { connectionSubject.eraseToAnyPublisher() diff --git a/Sources/RequestsFeature/ViewModels/RequestsFailedViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsFailedViewModel.swift index 9214c9ef3f19d337ae66f0f9a6c8368741ff1096..b7d81db3a65b791e001a897506418295b56f8b3a 100644 --- a/Sources/RequestsFeature/ViewModels/RequestsFailedViewModel.swift +++ b/Sources/RequestsFeature/ViewModels/RequestsFailedViewModel.swift @@ -38,7 +38,7 @@ final class RequestsFailedViewModel { func didTapStateButtonFor(request: Request) { guard case let .contact(contact) = request, request.status == .failedToRequest else { return } - hudSubject.send(.on(nil)) + hudSubject.send(.on) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } diff --git a/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift index 7dd9e8c1d07c9b15d9829803be59c55c0955fd12..ded18f77a4da0d956d2bdb0b1779495c818d41f5 100644 --- a/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift +++ b/Sources/RequestsFeature/ViewModels/RequestsReceivedViewModel.swift @@ -150,7 +150,7 @@ final class RequestsReceivedViewModel { } func didRequestAccept(group: Group) { - hudSubject.send(.on(nil)) + hudSubject.send(.on) backgroundScheduler.schedule { [weak self] in do { @@ -208,7 +208,7 @@ final class RequestsReceivedViewModel { } func didRequestAccept(contact: Contact, nickname: String? = nil) { - hudSubject.send(.on(nil)) + hudSubject.send(.on) var contact = contact contact.nickname = nickname ?? contact.username diff --git a/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift b/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift index 3789428ffa0a9539e35b3344fdd820ee896d03a2..f94ed8f5164de5c5a419a9b9271499ae7daa4952 100644 --- a/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift +++ b/Sources/RequestsFeature/ViewModels/RequestsSentViewModel.swift @@ -3,6 +3,7 @@ import UIKit import Models import Shared import Combine +import XXModels import Integration import ToastFeature import CombineSchedulers @@ -32,7 +33,12 @@ final class RequestsSentViewModel { var backgroundScheduler: AnySchedulerOf<DispatchQueue> = DispatchQueue.global().eraseToAnyScheduler() init() { - session.dbManager.fetchContactsPublisher(.init(authStatus: [.requested])) + let query = Contact.Query(authStatus: [ + .requested, + .requesting + ]) + + session.dbManager.fetchContactsPublisher(query) .assertNoFailure() .removeDuplicates() .map { data -> NSDiffableDataSourceSnapshot<Section, RequestSent> in @@ -47,7 +53,7 @@ final class RequestsSentViewModel { func didTapStateButtonFor(request item: RequestSent) { guard case let .contact(contact) = item.request, item.request.status == .requested else { return } - hudSubject.send(.on(nil)) + hudSubject.send(.on) backgroundScheduler.schedule { [weak self] in guard let self = self else { return } @@ -65,8 +71,10 @@ final class RequestsSentViewModel { item.isResent = true allRequests.append(item) + let name = (contact.nickname ?? contact.username) ?? "" + self.toastController.enqueueToast(model: .init( - title: Localized.Requests.Sent.Toast.resent(contact.nickname ?? contact.username), + title: Localized.Requests.Sent.Toast.resent(name), leftImage: Asset.requestSentToaster.image )) diff --git a/Sources/RestoreFeature/Controllers/RestoreListController.swift b/Sources/RestoreFeature/Controllers/RestoreListController.swift index 041717b6dde907568db4872827957d487010d006..b396d1069df7008772290e5c9e2f275adb5ccf4b 100644 --- a/Sources/RestoreFeature/Controllers/RestoreListController.swift +++ b/Sources/RestoreFeature/Controllers/RestoreListController.swift @@ -1,12 +1,12 @@ import HUD -import DrawerFeature -import Shared import UIKit +import Shared import Combine +import DrawerFeature import DependencyInjection public final class RestoreListController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: RestoreCoordinating lazy private var screenView = RestoreListView() @@ -51,15 +51,16 @@ public final class RestoreListController: UIViewController { } private func setupBindings() { - viewModel.hud + viewModel.hudPublisher .receive(on: DispatchQueue.main) .sink { [hud] in hud.update(with: $0) } .store(in: &cancellables) - viewModel.didFetchBackup + viewModel.backupPublisher .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toRestore(using: ndf, with: $0, from: self) } - .store(in: &cancellables) + .sink { [unowned self] in + coordinator.toRestore(using: ndf, with: $0, from: self) + }.store(in: &cancellables) screenView.cancelButton .publisher(for: .touchUpInside) @@ -68,18 +69,27 @@ public final class RestoreListController: UIViewController { screenView.driveButton .publisher(for: .touchUpInside) - .sink { [unowned self] in viewModel.didTapCloud(.drive, from: self) } - .store(in: &cancellables) + .sink { [unowned self] in + viewModel.didTapCloud(.drive, from: self) + }.store(in: &cancellables) screenView.icloudButton .publisher(for: .touchUpInside) - .sink { [unowned self] in viewModel.didTapCloud(.icloud, from: self) } - .store(in: &cancellables) + .sink { [unowned self] in + viewModel.didTapCloud(.icloud, from: self) + }.store(in: &cancellables) screenView.dropboxButton .publisher(for: .touchUpInside) - .sink { [unowned self] in viewModel.didTapCloud(.dropbox, from: self) } - .store(in: &cancellables) + .sink { [unowned self] in + viewModel.didTapCloud(.dropbox, from: self) + }.store(in: &cancellables) + + screenView.sftpButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in + viewModel.didTapCloud(.sftp, from: self) + }.store(in: &cancellables) } @objc private func didTapBack() { diff --git a/Sources/RestoreFeature/Service/MockRestoreService.swift b/Sources/RestoreFeature/Service/MockRestoreService.swift deleted file mode 100644 index 1a013434211580df1c99ac1fa3842ab0fc797d59..0000000000000000000000000000000000000000 --- a/Sources/RestoreFeature/Service/MockRestoreService.swift +++ /dev/null @@ -1,30 +0,0 @@ -import UIKit -import Models -import Combine -import Foundation -import GoogleDriveFeature -import DependencyInjection - -public struct RestoreServiceMock: RestoreServiceType { - public var inProgress: AnyPublisher<Void, Never> { - fatalError() - } - - public var settings: AnyPublisher<RestoreSettings, Never> { - fatalError() - } - - public init() {} - - public func didSelectBackup(at url: URL) {} - - public func authorize(service: CloudService, from: UIViewController) {} - - public func download( - from settings: RestoreSettings, - progress: @escaping RestoreProgress, - whenFinished: @escaping RestoreDownloadFinished - ) { - fatalError() - } -} diff --git a/Sources/RestoreFeature/Service/RestoreServiceType.swift b/Sources/RestoreFeature/Service/RestoreServiceType.swift deleted file mode 100644 index 78a32e9f6d9e199d78fa9c17a4a06b7ca1de51f3..0000000000000000000000000000000000000000 --- a/Sources/RestoreFeature/Service/RestoreServiceType.swift +++ /dev/null @@ -1,20 +0,0 @@ -import UIKit -import Models -import Combine - -public typealias RestoreProgress = (Float) -> Void -public typealias RestoreDownloadFinished = (Result<Data, Error>) -> Void - -public protocol RestoreServiceType { - var inProgress: AnyPublisher<Void, Never> { get } - - var settings: AnyPublisher<RestoreSettings, Never> { get } - - func authorize(service: CloudService, from: UIViewController) - - func download( - from settings: RestoreSettings, - progress: @escaping RestoreProgress, - whenFinished: @escaping RestoreDownloadFinished - ) -} diff --git a/Sources/RestoreFeature/ViewModels/RestoreListViewModel.swift b/Sources/RestoreFeature/ViewModels/RestoreListViewModel.swift index 3e91448aed07d65e2cdf650033be9d15be08fb4d..438207bb1cc5df10104d3be19029ef01cc7d3c19 100644 --- a/Sources/RestoreFeature/ViewModels/RestoreListViewModel.swift +++ b/Sources/RestoreFeature/ViewModels/RestoreListViewModel.swift @@ -6,20 +6,26 @@ import Combine import BackupFeature import DependencyInjection +import SFTPFeature import iCloudFeature import DropboxFeature import GoogleDriveFeature final class RestoreListViewModel { - @Dependency private var icloud: iCloudInterface - @Dependency private var dropbox: DropboxInterface - @Dependency private var drive: GoogleDriveInterface + @Dependency private var sftpService: SFTPService + @Dependency private var icloudService: iCloudInterface + @Dependency private var dropboxService: DropboxInterface + @Dependency private var googleDriveService: GoogleDriveInterface - var hud: AnyPublisher<HUDStatus, Never> { hudSubject.eraseToAnyPublisher() } - var didFetchBackup: AnyPublisher<RestoreSettings, Never> { backupSubject.eraseToAnyPublisher() } + var hudPublisher: AnyPublisher<HUDStatus, Never> { + hudSubject.eraseToAnyPublisher() + } - private var dropboxAuthCancellable: AnyCancellable? + var backupPublisher: AnyPublisher<RestoreSettings, Never> { + backupSubject.eraseToAnyPublisher() + } + private var dropboxAuthCancellable: AnyCancellable? private let hudSubject = PassthroughSubject<HUDStatus, Never>() private let backupSubject = PassthroughSubject<RestoreSettings, Never>() @@ -31,15 +37,43 @@ final class RestoreListViewModel { didRequestICloudAuthorization() case .dropbox: didRequestDropboxAuthorization(from: parent) + case .sftp: + didRequestSFTPAuthorization(from: parent) } } + private func didRequestSFTPAuthorization(from controller: UIViewController) { + let params = SFTPAuthorizationParams(controller, { [weak self] in + guard let self = self else { return } + controller.navigationController?.popViewController(animated: true) + + self.hudSubject.send(.on) + + self.sftpService.fetchMetadata{ result in + switch result { + case .success(let settings): + self.hudSubject.send(.none) + + if let settings = settings { + self.backupSubject.send(settings) + } else { + self.backupSubject.send(.init(cloudService: .sftp)) + } + case .failure(let error): + self.hudSubject.send(.error(.init(with: error))) + } + } + }) + + sftpService.authorizeFlow(params) + } + private func didRequestDriveAuthorization(from controller: UIViewController) { - drive.authorize(presenting: controller) { authResult in + googleDriveService.authorize(presenting: controller) { authResult in switch authResult { case .success: - self.hudSubject.send(.on(nil)) - self.drive.downloadMetadata { downloadResult in + self.hudSubject.send(.on) + self.googleDriveService.downloadMetadata { downloadResult in switch downloadResult { case .success(let metadata): var backup: Backup? @@ -62,10 +96,10 @@ final class RestoreListViewModel { } private func didRequestICloudAuthorization() { - if icloud.isAuthorized() { - self.hudSubject.send(.on(nil)) + if icloudService.isAuthorized() { + self.hudSubject.send(.on) - icloud.downloadMetadata { result in + icloudService.downloadMetadata { result in switch result { case .success(let metadata): var backup: Backup? @@ -83,20 +117,20 @@ final class RestoreListViewModel { } else { /// This could be an alert controller asking if user wants to enable/deeplink /// - icloud.openSettings() + icloudService.openSettings() } } private func didRequestDropboxAuthorization(from controller: UIViewController) { - dropboxAuthCancellable = dropbox.authorize(presenting: controller) + dropboxAuthCancellable = dropboxService.authorize(presenting: controller) .receive(on: DispatchQueue.main) .sink { [unowned self] authResult in switch authResult { case .success(let bool): guard bool == true else { return } - self.hudSubject.send(.on(nil)) - dropbox.downloadMetadata { metadataResult in + self.hudSubject.send(.on) + dropboxService.downloadMetadata { metadataResult in switch metadataResult { case .success(let metadata): var backup: Backup? diff --git a/Sources/RestoreFeature/ViewModels/RestoreViewModel.swift b/Sources/RestoreFeature/ViewModels/RestoreViewModel.swift index 37a5bbe469665e83df59f9a5c3215c4223e426d5..bea37e5113ad33a547e5680e478a0ed7717db741 100644 --- a/Sources/RestoreFeature/ViewModels/RestoreViewModel.swift +++ b/Sources/RestoreFeature/ViewModels/RestoreViewModel.swift @@ -8,6 +8,7 @@ import Integration import BackupFeature import DependencyInjection +import SFTPFeature import iCloudFeature import DropboxFeature import GoogleDriveFeature @@ -38,13 +39,16 @@ extension RestorationStep: Equatable { } final class RestoreViewModel { + @Dependency private var sftpService: SFTPService @Dependency private var iCloudService: iCloudInterface @Dependency private var dropboxService: DropboxInterface @Dependency private var googleService: GoogleDriveInterface @KeyObject(.username, defaultValue: nil) var username: String? - var step: AnyPublisher<RestorationStep, Never> { stepRelay.eraseToAnyPublisher() } + var step: AnyPublisher<RestorationStep, Never> { + stepRelay.eraseToAnyPublisher() + } // TO REFACTOR: // @@ -80,6 +84,22 @@ final class RestoreViewModel { downloadBackupForDropbox(backup) case .icloud: downloadBackupForiCloud(backup) + case .sftp: + downloadBackupForSFTP(backup) + } + } + + private func downloadBackupForSFTP(_ backup: Backup) { + sftpService.downloadBackup(path: backup.id) { [weak self] in + guard let self = self else { return } + self.stepRelay.send(.downloading(backup.size, backup.size)) + + switch $0 { + case .success(let data): + self.continueRestoring(data: data) + case .failure(let error): + self.stepRelay.send(.failDownload(error)) + } } } diff --git a/Sources/RestoreFeature/Views/RestoreListView.swift b/Sources/RestoreFeature/Views/RestoreListView.swift index 2a688760bc68cd509775fa97d28a37538dc44d3a..a955173a742b7c0b94f95601f90634353ada327f 100644 --- a/Sources/RestoreFeature/Views/RestoreListView.swift +++ b/Sources/RestoreFeature/Views/RestoreListView.swift @@ -6,6 +6,7 @@ final class RestoreListView: UIView { let stackView = UIStackView() let firstSubtitleLabel = UILabel() let secondSubtitleLabel = UILabel() + let sftpButton = RowButton() let driveButton = RowButton() let icloudButton = RowButton() let dropboxButton = RowButton() @@ -34,6 +35,7 @@ final class RestoreListView: UIView { secondSubtitleLabel.numberOfLines = 0 secondSubtitleLabel.attributedText = attrString + sftpButton.setup(title: Localized.Backup.sftp, icon: Asset.restoreSFTP.image) icloudButton.setup(title: Localized.Backup.iCloud, icon: Asset.restoreIcloud.image) dropboxButton.setup(title: Localized.Backup.dropbox, icon: Asset.restoreDropbox.image) driveButton.setup(title: Localized.Backup.googleDrive, icon: Asset.restoreDrive.image) @@ -41,9 +43,11 @@ final class RestoreListView: UIView { cancelButton.set(style: .seeThrough, title: Localized.AccountRestore.List.cancel) stackView.axis = .vertical + stackView.distribution = .fillEqually stackView.addArrangedSubview(driveButton) stackView.addArrangedSubview(icloudButton) stackView.addArrangedSubview(dropboxButton) + stackView.addArrangedSubview(sftpButton) addSubview(titleLabel) addSubview(firstSubtitleLabel) @@ -51,35 +55,35 @@ final class RestoreListView: UIView { addSubview(stackView) addSubview(cancelButton) - titleLabel.snp.makeConstraints { make in - make.top.equalTo(safeAreaLayoutGuide).offset(15) - make.left.equalToSuperview().offset(38) - make.right.equalToSuperview().offset(-41) + titleLabel.snp.makeConstraints { + $0.top.equalTo(safeAreaLayoutGuide).offset(15) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-41) } - firstSubtitleLabel.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(8) - make.left.equalToSuperview().offset(38) - make.right.equalToSuperview().offset(-41) + firstSubtitleLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(8) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-41) } - secondSubtitleLabel.snp.makeConstraints { make in - make.top.equalTo(firstSubtitleLabel.snp.bottom).offset(8) - make.left.equalToSuperview().offset(38) - make.right.equalToSuperview().offset(-41) + secondSubtitleLabel.snp.makeConstraints { + $0.top.equalTo(firstSubtitleLabel.snp.bottom).offset(8) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-41) } - stackView.snp.makeConstraints { make in - make.top.equalTo(secondSubtitleLabel.snp.bottom).offset(28) - make.left.equalToSuperview().offset(24) - make.right.equalToSuperview().offset(-24) + stackView.snp.makeConstraints { + $0.top.equalTo(secondSubtitleLabel.snp.bottom).offset(28) + $0.left.equalToSuperview().offset(24) + $0.right.equalToSuperview().offset(-24) } - cancelButton.snp.makeConstraints { make in - make.top.greaterThanOrEqualTo(stackView.snp.bottom).offset(20) - make.left.equalToSuperview().offset(40) - make.right.equalToSuperview().offset(-40) - make.bottom.equalTo(safeAreaLayoutGuide).offset(-50) + cancelButton.snp.makeConstraints { + $0.top.greaterThanOrEqualTo(stackView.snp.bottom).offset(20) + $0.left.equalToSuperview().offset(40) + $0.right.equalToSuperview().offset(-40) + $0.bottom.equalTo(safeAreaLayoutGuide).offset(-50) } } diff --git a/Sources/RestoreFeature/Views/RestoreView.swift b/Sources/RestoreFeature/Views/RestoreView.swift index e36e5d5b1dd97cb735ac84f9d1c5c56fdb19b5c3..ba2e643cafc4dcca652ac7daac8ad14d22f52d15 100644 --- a/Sources/RestoreFeature/Views/RestoreView.swift +++ b/Sources/RestoreFeature/Views/RestoreView.swift @@ -39,36 +39,36 @@ final class RestoreView: UIView { bottomStackView.addArrangedSubview(cancelButton) bottomStackView.addArrangedSubview(backButton) - titleLabel.snp.makeConstraints { make in - make.top.equalTo(safeAreaLayoutGuide).offset(20) - make.left.equalToSuperview().offset(38) - make.right.equalToSuperview().offset(-38) + titleLabel.snp.makeConstraints { + $0.top.equalTo(safeAreaLayoutGuide).offset(20) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-38) } - subtitleLabel.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(20) - make.left.equalToSuperview().offset(38) - make.right.equalToSuperview().offset(-38) + subtitleLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(20) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-38) } - detailsView.snp.makeConstraints { make in - make.top.equalTo(subtitleLabel.snp.bottom).offset(40) - make.left.equalToSuperview() - make.right.equalToSuperview() + detailsView.snp.makeConstraints { + $0.top.equalTo(subtitleLabel.snp.bottom).offset(40) + $0.left.equalToSuperview() + $0.right.equalToSuperview() } - progressView.snp.makeConstraints { make in - make.top.greaterThanOrEqualTo(detailsView.snp.bottom) - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.lessThanOrEqualTo(bottomStackView.snp.top) + progressView.snp.makeConstraints { + $0.top.greaterThanOrEqualTo(detailsView.snp.bottom) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.lessThanOrEqualTo(bottomStackView.snp.top) } - bottomStackView.snp.makeConstraints { make in - make.top.greaterThanOrEqualTo(detailsView.snp.bottom).offset(10) - make.left.equalToSuperview().offset(40) - make.right.equalToSuperview().offset(-40) - make.bottom.equalTo(safeAreaLayoutGuide).offset(-20) + bottomStackView.snp.makeConstraints { + $0.top.greaterThanOrEqualTo(detailsView.snp.bottom).offset(10) + $0.left.equalToSuperview().offset(40) + $0.right.equalToSuperview().offset(-40) + $0.bottom.equalTo(safeAreaLayoutGuide).offset(-20) } } @@ -151,6 +151,8 @@ private extension CloudService { return Localized.Backup.iCloud case .dropbox: return Localized.Backup.dropbox + case .sftp: + return Localized.Backup.sftp } } @@ -162,6 +164,8 @@ private extension CloudService { return Asset.restoreIcloud.image case .dropbox: return Asset.restoreDropbox.image + case .sftp: + return Asset.restoreSFTP.image } } } diff --git a/Sources/SFTPFeature/ActionHandlers/SFTPAuthenticator.swift b/Sources/SFTPFeature/ActionHandlers/SFTPAuthenticator.swift new file mode 100644 index 0000000000000000000000000000000000000000..389cdd46fc0c0136247e09f3dbd900ec265a7858 --- /dev/null +++ b/Sources/SFTPFeature/ActionHandlers/SFTPAuthenticator.swift @@ -0,0 +1,54 @@ +import Shout +import Socket +import Keychain +import Foundation +import DependencyInjection + +public struct SFTPAuthenticator { + public var authenticate: (String, String, String) throws -> Void + + public func callAsFunction(host: String, username: String, password: String) throws { + try authenticate(host, username, password) + } +} + +extension SFTPAuthenticator { + static let mock = SFTPAuthenticator { host, username, password in + print("^^^ Requested authentication on sftp service.") + print("^^^ Host: \(host)") + print("^^^ Username: \(username)") + print("^^^ Password: \(password)") + } + + static let live = SFTPAuthenticator { host, username, password in + do { + try SSH.connect( + host: host, + port: 22, + username: username, + authMethod: SSHPassword(password)) { ssh in + _ = try ssh.openSftp() + + let keychain = try DependencyInjection.Container.shared.resolve() as KeychainHandling + try keychain.store(key: .host, value: host) + try keychain.store(key: .pwd, value: password) + try keychain.store(key: .username, value: username) + } + } catch { + if let error = error as? SSHError { + print(error.kind) + print(error.message) + print(error.description) + } else if let error = error as? Socket.Error { + print(error.errorCode) + print(error.description) + print(error.errorReason) + print(error.localizedDescription) + } else { + print(error.localizedDescription) + } + + throw error + } + } +} diff --git a/Sources/SFTPFeature/ActionHandlers/SFTPDownloader.swift b/Sources/SFTPFeature/ActionHandlers/SFTPDownloader.swift new file mode 100644 index 0000000000000000000000000000000000000000..6a435df0051a2755f2fb73522f7be92a24c4dd77 --- /dev/null +++ b/Sources/SFTPFeature/ActionHandlers/SFTPDownloader.swift @@ -0,0 +1,56 @@ +import Shout +import Socket +import Keychain +import Foundation +import DependencyInjection + +public typealias SFTPDownloadResult = (Result<Data, Error>) -> Void + +public struct SFTPDownloader { + public var download: (String, @escaping SFTPDownloadResult) -> Void + + public func callAsFunction(path: String, completion: @escaping SFTPDownloadResult) { + download(path, completion) + } +} + +extension SFTPDownloader { + static let mock = SFTPDownloader { path, _ in + print("^^^ Requested backup download on sftp service.") + print("^^^ Path: \(path)") + } + + static let live = SFTPDownloader { path, completion in + DispatchQueue.global().async { + do { + let keychain = try DependencyInjection.Container.shared.resolve() as KeychainHandling + let host = try keychain.get(key: .host) + let password = try keychain.get(key: .pwd) + let username = try keychain.get(key: .username) + + let ssh = try SSH(host: host!, port: 22) + try ssh.authenticate(username: username!, password: password!) + let sftp = try ssh.openSftp() + + let localURL = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: "group.elixxir.messenger")! + .appendingPathComponent("sftp") + + try sftp.download(remotePath: path, localURL: localURL) + + let data = try Data(contentsOf: localURL) + completion(.success(data)) + } catch { + completion(.failure(error)) + + if var error = error as? SSHError { + print(error.kind) + print(error.message) + print(error.description) + } else { + print(error.localizedDescription) + } + } + } + } +} diff --git a/Sources/SFTPFeature/ActionHandlers/SFTPFetcher.swift b/Sources/SFTPFeature/ActionHandlers/SFTPFetcher.swift new file mode 100644 index 0000000000000000000000000000000000000000..a27df80ffe8e9be6f41f09185e9962351c44cff9 --- /dev/null +++ b/Sources/SFTPFeature/ActionHandlers/SFTPFetcher.swift @@ -0,0 +1,68 @@ +import Shout +import Socket +import Models +import Keychain +import Foundation +import DependencyInjection + +public typealias SFTPFetchResult = (Result<RestoreSettings?, Error>) -> Void + +public struct SFTPFetcher { + public var fetch: (@escaping SFTPFetchResult) -> Void + + public func callAsFunction(completion: @escaping SFTPFetchResult) { + fetch(completion) + } +} + +extension SFTPFetcher { + static let mock = SFTPFetcher { _ in + print("^^^ Requested backup metadata on sftp service.") + } + + static let live = SFTPFetcher { completion in + DispatchQueue.global().async { + do { + let keychain = try DependencyInjection.Container.shared.resolve() as KeychainHandling + let host = try keychain.get(key: .host) + let password = try keychain.get(key: .pwd) + let username = try keychain.get(key: .username) + + let ssh = try SSH(host: host!, port: 22) + try ssh.authenticate(username: username!, password: password!) + let sftp = try ssh.openSftp() + + if let files = try? sftp.listFiles(in: "backup"), + let backup = files.filter({ file in file.0 == "backup.xxm" }).first { + completion(.success(.init( + backup: .init( + id: "backup/backup.xxm", + date: backup.value.lastModified, + size: Float(backup.value.size) + ), + cloudService: .sftp + ))) + + return + } + + completion(.success(nil)) + } catch { + if let error = error as? SSHError { + print(error.kind) + print(error.message) + print(error.description) + } else if let error = error as? Socket.Error { + print(error.errorCode) + print(error.description) + print(error.errorReason) + print(error.localizedDescription) + } else { + print(error.localizedDescription) + } + + completion(.failure(error)) + } + } + } +} diff --git a/Sources/SFTPFeature/ActionHandlers/SFTPUploader.swift b/Sources/SFTPFeature/ActionHandlers/SFTPUploader.swift new file mode 100644 index 0000000000000000000000000000000000000000..fee691d1b7e1669226fecfad6ecc37c97e64f9c5 --- /dev/null +++ b/Sources/SFTPFeature/ActionHandlers/SFTPUploader.swift @@ -0,0 +1,69 @@ +import Shout +import Socket +import Models +import Keychain +import Foundation +import DependencyInjection + +public typealias SFTPUploadResult = (Result<Backup, Error>) -> Void + +public struct SFTPUploader { + public var upload: (URL, @escaping SFTPUploadResult) -> Void + + public func callAsFunction(url: URL, completion: @escaping SFTPUploadResult) { + upload(url, completion) + } +} + +extension SFTPUploader { + static let mock = SFTPUploader( + upload: { url, _ in + print("^^^ Requested upload on sftp service") + print("^^^ URL path: \(url.path)") + } + ) + + static let live = SFTPUploader { url, completion in + DispatchQueue.global().async { + do { + let keychain = try DependencyInjection.Container.shared.resolve() as KeychainHandling + let host = try keychain.get(key: .host) + let password = try keychain.get(key: .pwd) + let username = try keychain.get(key: .username) + + let ssh = try SSH(host: host!, port: 22) + try ssh.authenticate(username: username!, password: password!) + let sftp = try ssh.openSftp() + + let data = try Data(contentsOf: url) + + if (try? sftp.listFiles(in: "backup")) == nil { + try sftp.createDirectory("backup") + } + + try sftp.upload(data: data, remotePath: "backup/backup.xxm") + + completion(.success(.init( + id: "backup/backup.xxm", + date: Date(), + size: Float(data.count) + ))) + } catch { + if let error = error as? SSHError { + print(error.kind) + print(error.message) + print(error.description) + } else if let error = error as? Socket.Error { + print(error.errorCode) + print(error.description) + print(error.errorReason) + print(error.localizedDescription) + } else { + print(error.localizedDescription) + } + + completion(.failure(error)) + } + } + } +} diff --git a/Sources/SFTPFeature/SFTPController.swift b/Sources/SFTPFeature/SFTPController.swift new file mode 100644 index 0000000000000000000000000000000000000000..f80908b9cdf6106bacef3c2fa9f6c4eac46e8145 --- /dev/null +++ b/Sources/SFTPFeature/SFTPController.swift @@ -0,0 +1,96 @@ +import HUD +import UIKit +import Combine +import DependencyInjection +import ScrollViewController + +public final class SFTPController: UIViewController { + @Dependency private var hud: HUD + + lazy private var screenView = SFTPView() + lazy private var scrollViewController = ScrollViewController() + + private let completion: () -> Void + private let viewModel = SFTPViewModel() + private var cancellables = Set<AnyCancellable>() + + public init(_ completion: @escaping () -> Void) { + self.completion = completion + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + + public override func viewDidLoad() { + super.viewDidLoad() + setupScrollView() + setupNavigationBar() + setupBindings() + } + + private func setupScrollView() { + scrollViewController.scrollView.backgroundColor = .white + + addChild(scrollViewController) + view.addSubview(scrollViewController.view) + scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } + scrollViewController.didMove(toParent: self) + scrollViewController.contentView = screenView + } + + private func setupNavigationBar() { + navigationItem.backButtonTitle = "" + + let back = UIButton.back() + back.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) + + navigationItem.leftBarButtonItem = UIBarButtonItem( + customView: UIStackView(arrangedSubviews: [back]) + ) + } + + private func setupBindings() { + viewModel.hudPublisher + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + viewModel.authPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in completion() } + .store(in: &cancellables) + + screenView.hostField + .textPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in viewModel.didEnterHost($0) } + .store(in: &cancellables) + + screenView.usernameField + .textPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in viewModel.didEnterUsername($0) } + .store(in: &cancellables) + + screenView.passwordField + .textPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in viewModel.didEnterPassword($0) } + .store(in: &cancellables) + + viewModel.statePublisher + .receive(on: DispatchQueue.main) + .map(\.isButtonEnabled) + .sink { [unowned self] in screenView.loginButton.isEnabled = $0 } + .store(in: &cancellables) + + screenView.loginButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in viewModel.didTapLogin() } + .store(in: &cancellables) + } + + @objc private func didTapBack() { + navigationController?.popViewController(animated: true) + } +} diff --git a/Sources/SFTPFeature/SFTPService.swift b/Sources/SFTPFeature/SFTPService.swift new file mode 100644 index 0000000000000000000000000000000000000000..f1a908564df810ed2aeaa1b4af358b367253f84b --- /dev/null +++ b/Sources/SFTPFeature/SFTPService.swift @@ -0,0 +1,47 @@ +import UIKit +import Keychain +import Presentation +import DependencyInjection + +public typealias SFTPAuthorizationParams = (UIViewController, () -> Void) + +public struct SFTPService { + public var isAuthorized: () -> Bool + public var fetchMetadata: SFTPFetcher + public var uploadBackup: SFTPUploader + public var authorizeFlow: (SFTPAuthorizationParams) -> Void + public var authenticate: SFTPAuthenticator + public var downloadBackup: SFTPDownloader +} + +public extension SFTPService { + static var mock = SFTPService( + isAuthorized: { true }, + fetchMetadata: .mock, + uploadBackup: .mock, + authorizeFlow: { (_, completion) in completion() }, + authenticate: .mock, + downloadBackup: .mock + ) + + static var live = SFTPService( + isAuthorized: { + if let keychain = try? DependencyInjection.Container.shared.resolve() as KeychainHandling, + let pwd = try? keychain.get(key: .pwd), + let host = try? keychain.get(key: .host), + let username = try? keychain.get(key: .username) { + return true + } + + return false + }, + fetchMetadata: .live, + uploadBackup: .live , + authorizeFlow: { controller, completion in + var pushPresenter: Presenting = PushPresenter() + pushPresenter.present(SFTPController(completion), from: controller) + }, + authenticate: .live, + downloadBackup: .live + ) +} diff --git a/Sources/SFTPFeature/SFTPView.swift b/Sources/SFTPFeature/SFTPView.swift new file mode 100644 index 0000000000000000000000000000000000000000..3e62e4caeb0eca188fd1dad4db740545f0b3f045 --- /dev/null +++ b/Sources/SFTPFeature/SFTPView.swift @@ -0,0 +1,76 @@ +import UIKit +import Shared +import InputField + +final class SFTPView: UIView { + let titleLabel = UILabel() + let subtitleLabel = UILabel() + let hostField = OutlinedInputField() + let usernameField = OutlinedInputField() + let passwordField = OutlinedInputField() + let loginButton = CapsuleButton() + let stackView = UIStackView() + + init() { + super.init(frame: .zero) + backgroundColor = Asset.neutralWhite.color + + titleLabel.textColor = Asset.neutralDark.color + titleLabel.text = Localized.AccountRestore.Sftp.title + titleLabel.font = Fonts.Mulish.bold.font(size: 24.0) + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .left + paragraph.lineHeightMultiple = 1.15 + + let attString = NSAttributedString( + string: Localized.AccountRestore.Sftp.subtitle, + attributes: [ + .foregroundColor: Asset.neutralBody.color, + .font: Fonts.Mulish.regular.font(size: 16.0) as Any, + .paragraphStyle: paragraph + ]) + + subtitleLabel.numberOfLines = 0 + subtitleLabel.attributedText = attString + + hostField.setup(title: Localized.AccountRestore.Sftp.host) + usernameField.setup(title: Localized.AccountRestore.Sftp.username) + passwordField.setup(title: Localized.AccountRestore.Sftp.password, sensitive: true) + + loginButton.set(style: .brandColored, title: Localized.AccountRestore.Sftp.login) + + stackView.spacing = 30 + stackView.axis = .vertical + stackView.distribution = .fillEqually + stackView.addArrangedSubview(hostField) + stackView.addArrangedSubview(usernameField) + stackView.addArrangedSubview(passwordField) + stackView.addArrangedSubview(loginButton) + + addSubview(titleLabel) + addSubview(subtitleLabel) + addSubview(stackView) + + titleLabel.snp.makeConstraints { + $0.top.equalTo(safeAreaLayoutGuide).offset(15) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-41) + } + + subtitleLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(8) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-41) + } + + stackView.snp.makeConstraints { + $0.top.equalTo(subtitleLabel.snp.bottom).offset(28) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-38) + $0.bottom.lessThanOrEqualToSuperview() + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/SFTPFeature/SFTPViewModel.swift b/Sources/SFTPFeature/SFTPViewModel.swift new file mode 100644 index 0000000000000000000000000000000000000000..e64536bfd96277642d25ddb13f5c81570836a22b --- /dev/null +++ b/Sources/SFTPFeature/SFTPViewModel.swift @@ -0,0 +1,77 @@ +import HUD +import Combine +import Foundation +import DependencyInjection + +struct SFTPViewState { + var host: String = "" + var username: String = "" + var password: String = "" + var isButtonEnabled: Bool = false +} + +final class SFTPViewModel { + @Dependency private var service: SFTPService + + var hudPublisher: AnyPublisher<HUDStatus, Never> { + hudSubject.eraseToAnyPublisher() + } + + var statePublisher: AnyPublisher<SFTPViewState, Never> { + stateSubject.eraseToAnyPublisher() + } + + var authPublisher: AnyPublisher<Void, Never> { + authSubject.eraseToAnyPublisher() + } + + private let authSubject = PassthroughSubject<Void, Never>() + private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) + private let stateSubject = CurrentValueSubject<SFTPViewState, Never>(.init()) + + func didEnterHost(_ string: String) { + stateSubject.value.host = string + validate() + } + + func didEnterUsername(_ string: String) { + stateSubject.value.username = string + validate() + } + + func didEnterPassword(_ string: String) { + stateSubject.value.password = string + validate() + } + + func didTapLogin() { + hudSubject.send(.on) + + let host = stateSubject.value.host + let username = stateSubject.value.username + let password = stateSubject.value.password + + DispatchQueue.global().async { [weak self] in + guard let self = self else { return } + do { + try self.service.authenticate( + host: host, + username: username, + password: password + ) + + self.hudSubject.send(.none) + self.authSubject.send(()) + } catch { + self.hudSubject.send(.error(.init(with: error))) + } + } + } + + private func validate() { + stateSubject.value.isButtonEnabled = + !stateSubject.value.host.isEmpty && + !stateSubject.value.username.isEmpty && + !stateSubject.value.password.isEmpty + } +} diff --git a/Sources/ScanFeature/Views/ScanContainerView.swift b/Sources/ScanFeature/Views/ScanContainerView.swift index b9b35cef12d02a3a8ea88ead84692938e31ed773..7e5eeee37a1a32765a33735ec8e1c65a05473dd5 100644 --- a/Sources/ScanFeature/Views/ScanContainerView.swift +++ b/Sources/ScanFeature/Views/ScanContainerView.swift @@ -3,7 +3,7 @@ import Shared final class ScanContainerView: UIView { let scrollView = UIScrollView() - let segmentedControl = SegmentedControl() + let segmentedControl = ScanSegmentedControl() init() { super.init(frame: .zero) diff --git a/Sources/ScanFeature/Views/SegmentedControl.swift b/Sources/ScanFeature/Views/ScanSegmentedControl.swift similarity index 93% rename from Sources/ScanFeature/Views/SegmentedControl.swift rename to Sources/ScanFeature/Views/ScanSegmentedControl.swift index 11faf5a55448b39530fc66a71610a8b49852dfd0..f3fd72acf3326b05b8057e5a9fe0948aa113688c 100644 --- a/Sources/ScanFeature/Views/SegmentedControl.swift +++ b/Sources/ScanFeature/Views/ScanSegmentedControl.swift @@ -2,15 +2,15 @@ import UIKit import Shared import SnapKit -final class SegmentedControl: UIView { +final class ScanSegmentedControl: UIView { private let trackHeight = 2.0 private let numberOfTabs = 2.0 private let trackView = UIView() private let stackView = UIStackView() private var leftConstraint: Constraint? private let trackIndicatorView = UIView() - private(set) var leftButton = SegmentedControlButton() - private(set) var rightButton = SegmentedControlButton() + private(set) var leftButton = ScanSegmentedControlButton() + private(set) var rightButton = ScanSegmentedControlButton() init() { super.init(frame: .zero) diff --git a/Sources/ScanFeature/Views/SegmentedControlButton.swift b/Sources/ScanFeature/Views/ScanSegmentedControlButton.swift similarity index 95% rename from Sources/ScanFeature/Views/SegmentedControlButton.swift rename to Sources/ScanFeature/Views/ScanSegmentedControlButton.swift index c23f16c1539338457198024f84216ffffdca1590..b9e711c614b239dfb55158c530adbe121f0ca691 100644 --- a/Sources/ScanFeature/Views/SegmentedControlButton.swift +++ b/Sources/ScanFeature/Views/ScanSegmentedControlButton.swift @@ -1,7 +1,7 @@ import UIKit import Shared -final class SegmentedControlButton: UIControl { +final class ScanSegmentedControlButton: UIControl { private let titleLabel = UILabel() private let imageView = UIImageView() diff --git a/Sources/SearchFeature/Controllers/CameraController.swift b/Sources/SearchFeature/Controllers/CameraController.swift new file mode 100644 index 0000000000000000000000000000000000000000..7473920844e51bf6b77634818ee56282cb00b5d2 --- /dev/null +++ b/Sources/SearchFeature/Controllers/CameraController.swift @@ -0,0 +1,61 @@ +import Combine +import AVFoundation + +final class CameraController: NSObject { + var dataPublisher: AnyPublisher<Data, Never> { + dataSubject + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + lazy var previewLayer: CALayer = { + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + return layer + }() + + private let session = AVCaptureSession() + private let metadataOutput = AVCaptureMetadataOutput() + private let dataSubject = PassthroughSubject<Data, Never>() + + override init() { + super.init() + setupCameraDevice() + } + + func start() { + guard session.isRunning == false else { return } + session.startRunning() + } + + func stop() { + guard session.isRunning == true else { return } + session.stopRunning() + } + + private func setupCameraDevice() { + if let captureDevice = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: captureDevice) { + + if session.canAddInput(input) && session.canAddOutput(metadataOutput) { + session.addInput(input) + session.addOutput(metadataOutput) + } + + metadataOutput.setMetadataObjectsDelegate(self, queue: .main) + metadataOutput.metadataObjectTypes = [.qr] + } + } + + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let data = object.stringValue?.data(using: .nonLossyASCII), object.type == .qr else { return } + dataSubject.send(data) + } +} + +extension CameraController: AVCaptureMetadataOutputObjectsDelegate {} diff --git a/Sources/SearchFeature/Controllers/SearchContainerController.swift b/Sources/SearchFeature/Controllers/SearchContainerController.swift new file mode 100644 index 0000000000000000000000000000000000000000..ad85b25640a1f1a5e418bf9974b2a0656021d202 --- /dev/null +++ b/Sources/SearchFeature/Controllers/SearchContainerController.swift @@ -0,0 +1,185 @@ +import UIKit +import Theme +import Shared +import Combine +import XXModels +import DrawerFeature +import DependencyInjection + +public final class SearchContainerController: UIViewController { + @Dependency var coordinator: SearchCoordinating + @Dependency var statusBarController: StatusBarStyleControlling + + lazy private var screenView = SearchContainerView() + + private var contentOffset: CGPoint? + private var cancellables = Set<AnyCancellable>() + private let viewModel = SearchContainerViewModel() + private let leftController = SearchLeftController() + private let rightController = SearchRightController() + private var drawerCancellables = Set<AnyCancellable>() + + public override func loadView() { + view = screenView + embedControllers() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + statusBarController.style.send(.darkContent) + navigationController?.navigationBar.customize( + backgroundColor: Asset.neutralWhite.color + ) + + if let contentOffset = self.contentOffset { + screenView.scrollView.setContentOffset(contentOffset, animated: true) + } + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + contentOffset = screenView.scrollView.contentOffset + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewModel.didAppear() + rightController.viewModel.viewWillAppear() + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + setupBindings() + } + + private func setupNavigationBar() { + navigationItem.backButtonTitle = " " + + let titleLabel = UILabel() + titleLabel.text = Localized.Ud.title + titleLabel.textColor = Asset.neutralActive.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) + + let backButton = UIButton.back() + backButton.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) + + navigationItem.leftBarButtonItem = UIBarButtonItem( + customView: UIStackView(arrangedSubviews: [backButton, titleLabel]) + ) + } + + private func setupBindings() { + screenView.segmentedControl + .actionPublisher + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + if $0 == .qr { + let point = CGPoint(x: screenView.frame.width, y: 0.0) + screenView.scrollView.setContentOffset(point, animated: true) + leftController.endEditing() + } else { + screenView.scrollView.setContentOffset(.zero, animated: true) + leftController.viewModel.didSelectItem($0) + } + }.store(in: &cancellables) + + viewModel.coverTrafficPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in presentCoverTrafficDrawer() } + .store(in: &cancellables) + } + + @objc private func didTapBack() { + navigationController?.popViewController(animated: true) + } + + private func embedControllers() { + addChild(leftController) + addChild(rightController) + + screenView.scrollView.addSubview(leftController.view) + screenView.scrollView.addSubview(rightController.view) + + leftController.view.snp.makeConstraints { + $0.top.equalTo(screenView.segmentedControl.snp.bottom) + $0.width.equalTo(screenView) + $0.bottom.equalTo(screenView) + $0.left.equalToSuperview() + $0.right.equalTo(rightController.view.snp.left) + } + + rightController.view.snp.makeConstraints { + $0.top.equalTo(screenView.segmentedControl.snp.bottom) + $0.width.equalTo(screenView) + $0.bottom.equalTo(screenView) + } + + leftController.didMove(toParent: self) + rightController.didMove(toParent: self) + } +} + +extension SearchContainerController { + private func presentCoverTrafficDrawer() { + let enableButton = CapsuleButton() + enableButton.set( + style: .brandColored, + title: Localized.ChatList.Traffic.positive + ) + + let dismissButton = CapsuleButton() + dismissButton.set( + style: .seeThrough, + title: Localized.ChatList.Traffic.negative + ) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: Localized.ChatList.Traffic.title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: Localized.ChatList.Traffic.subtitle, + color: Asset.neutralBody.color, + alignment: .left, + lineHeightMultiple: 1.1, + spacingAfter: 39 + ), + DrawerStack( + axis: .horizontal, + spacing: 20, + distribution: .fillEqually, + views: [enableButton, dismissButton] + ) + ]) + + enableButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + self.viewModel.didEnableCoverTraffic() + } + }.store(in: &drawerCancellables) + + dismissButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) + + coordinator.toDrawer(drawer, from: self) + } +} diff --git a/Sources/SearchFeature/Controllers/SearchController.swift b/Sources/SearchFeature/Controllers/SearchLeftController.swift similarity index 54% rename from Sources/SearchFeature/Controllers/SearchController.swift rename to Sources/SearchFeature/Controllers/SearchLeftController.swift index 795c60009cb827d2aacca5d3e27eb89e7114b26b..bbfabd2836990bb00c86e01a09527bdbac8b1145 100644 --- a/Sources/SearchFeature/Controllers/SearchController.swift +++ b/Sources/SearchFeature/Controllers/SearchLeftController.swift @@ -1,259 +1,312 @@ import HUD -import Theme import UIKit import Shared -import Models import Combine -import Defaults import XXModels +import Defaults import Countries import DrawerFeature import DependencyInjection -import ScrollViewController -public final class SearchController: UIViewController { +final class SearchLeftController: UIViewController { + @Dependency private var hud: HUD + @Dependency private var coordinator: SearchCoordinating + @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 - - lazy private var tableController = SearchTableController(viewModel) - lazy private var screenView = SearchView { - let actionButton = CapsuleButton() - actionButton.set( - style: .seeThrough, - title: Localized.ContactSearch.Placeholder.Drawer.action - ) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: Localized.ContactSearch.Placeholder.Drawer.title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerLinkText( - text: Localized.ContactSearch.Placeholder.Drawer.subtitle, - urlString: "https://links.xx.network/adrp", - spacingAfter: 37 - ), - DrawerStack(views: [ - actionButton, - FlexibleSpace() - ]) - ]) - - actionButton.publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &self.drawerCancellables) + lazy private var screenView = SearchLeftView() - self.coordinator.toDrawer(drawer, from: self) - } + private var dataSource: SearchDiffableDataSource! + private(set) var viewModel = SearchLeftViewModel() + private var drawerCancellables = Set<AnyCancellable>() + private let adrpURLString = "https://links.xx.network/adrp" - private let viewModel = SearchViewModel() private var cancellables = Set<AnyCancellable>() - private var drawerCancellables = Set<AnyCancellable>() + private var hudCancellables = Set<AnyCancellable>() - public override func loadView() { + override func loadView() { view = screenView } - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - statusBarController.style.send(.darkContent) - - navigationController?.navigationBar.customize( - backgroundColor: Asset.neutralWhite.color, - shadowColor: Asset.neutralDisabled.color - ) - } - - public override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - viewModel.didAppear() - } - - public override func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() - setupNavigationBar() setupTableView() setupBindings() - setupFilterBindings() } - private func setupTableView() { - addChild(tableController) - screenView.addSubview(tableController.view) + func endEditing() { + screenView.inputField.endEditing(true) + } - tableController.view.snp.makeConstraints { - $0.top.equalTo(screenView.stack.snp.bottom).offset(20) - $0.left.bottom.right.equalToSuperview() - } + private func setupTableView() { + screenView.tableView.separatorStyle = .none + screenView.tableView.tableFooterView = UIView() + screenView.tableView.register(AvatarCell.self) + screenView.tableView.dataSource = dataSource + screenView.tableView.delegate = self + + dataSource = SearchDiffableDataSource( + tableView: screenView.tableView + ) { tableView, indexPath, item in + let contact: Contact + let cell = tableView.dequeueReusableCell(forIndexPath: indexPath, ofType: AvatarCell.self) + + let h1Text: String + var h2Text: String? + + switch item { + case .stranger(let stranger): + contact = stranger + h1Text = stranger.username ?? "" + + if stranger.authStatus == .requested { + h2Text = "Request pending" + } else if stranger.authStatus == .requestFailed { + h2Text = "Request failed" + } - tableController.didMove(toParent: self) - tableController.tableView.delegate = self - screenView.bringSubviewToFront(screenView.empty) - screenView.bringSubviewToFront(screenView.placeholder) - } + case .connection(let connection): + contact = connection + h1Text = (connection.nickname ?? contact.username) ?? "" - private func setupNavigationBar() { - navigationItem.backButtonTitle = " " + if connection.nickname != nil { + h2Text = contact.username ?? "" + } + } - let titleLabel = UILabel() - titleLabel.text = Localized.ContactSearch.title - titleLabel.textColor = Asset.neutralActive.color - titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) + cell.setup( + title: h1Text, + image: contact.photo, + firstSubtitle: h2Text, + secondSubtitle: contact.email, + thirdSubtitle: contact.phone, + showSeparator: false, + sent: contact.authStatus == .requested + ) - let backButton = UIButton.back() - backButton.addTarget(self, action: #selector(didTapBack), for: .touchUpInside) + cell.didTapStateButton = { [weak self] in + guard let self = self else { return } + self.viewModel.didTapResend(contact: contact) + cell.updateToResent() + } - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: UIStackView(arrangedSubviews: [backButton, titleLabel]) - ) + return cell + } } private func setupBindings() { - viewModel.successPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in presentSucessDrawerFor(contact: $0) } - .store(in: &cancellables) - viewModel.hudPublisher + .removeDuplicates() .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } + .sink { [unowned self] in + hud.update(with: $0) + + if case .onAction = $0, let hudBtn = hud.actionButton { + hudBtn.publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in viewModel.didTapCancelSearch() } + .store(in: &self.hudCancellables) + } else { + hudCancellables.forEach { $0.cancel() } + hudCancellables.removeAll() + } + } .store(in: &cancellables) - viewModel.coverTrafficPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in presentCoverTrafficDrawer() } - .store(in: &cancellables) - viewModel - .itemsRelay + viewModel.statePublisher + .map(\.item) .removeDuplicates() - .map(\.count) .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.empty.isHidden = $0 > 0 } + .sink { [unowned self] in screenView.updateUIForItem(item: $0) } .store(in: &cancellables) - viewModel.placeholderPublisher + viewModel.statePublisher + .map(\.country) + .removeDuplicates() .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.placeholder.isHidden = !$0 } + .sink { [unowned self] in screenView.countryButton.setFlag($0.flag, prefix: $0.prefix) } .store(in: &cancellables) viewModel.statePublisher - .map(\.country) - .removeDuplicates() + .compactMap(\.snapshot) .receive(on: DispatchQueue.main) .sink { [unowned self] in - screenView.phoneInput.set(prefix: $0.prefixWithFlag) - screenView.phoneInput.update(placeholder: $0.example) - } + screenView.placeholderView.isHidden = true + screenView.emptyView.isHidden = $0.numberOfItems != 0 + + dataSource.apply($0, animatingDifferences: false) + }.store(in: &cancellables) + + screenView.placeholderView + .infoPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in presentSearchDisclaimer() } .store(in: &cancellables) - screenView.input + screenView.countryButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + coordinator.toCountries(from: self) { [weak self] country in + guard let self = self else { return } + self.viewModel.didPick(country: country) + } + }.store(in: &cancellables) + + screenView.inputField .textPublisher - .removeDuplicates() - .compactMap { $0 } - .sink { [unowned self] in viewModel.didInput($0) } + .receive(on: DispatchQueue.main) + .sink { [unowned self] in viewModel.didEnterInput($0) } .store(in: &cancellables) - screenView.input + screenView.inputField .returnPublisher - .sink { [unowned self] in viewModel.didTapSearch() } + .receive(on: DispatchQueue.main) + .sink { [unowned self] _ in viewModel.didStartSearching() } .store(in: &cancellables) - screenView.phoneInput - .returnPublisher - .sink { [unowned self] in viewModel.didTapSearch() } - .store(in: &cancellables) + screenView.inputField + .isEditingPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] isEditing in + UIView.animate(withDuration: 0.25) { + self.screenView.placeholderView.titleLabel.alpha = isEditing ? 0.1 : 1.0 + self.screenView.placeholderView.subtitleWithInfo.alpha = isEditing ? 0.1 : 1.0 + } + }.store(in: &cancellables) - screenView - .phoneInput - .textPublisher - .removeDuplicates() + viewModel.successPublisher .receive(on: DispatchQueue.main) - .sink { [unowned self] in viewModel.didInputPhone($0) } + .sink { [unowned self] in presentSucessDrawerFor(contact: $0) } .store(in: &cancellables) + } + + private func presentSearchDisclaimer() { + let actionButton = CapsuleButton() + actionButton.set( + style: .seeThrough, + title: Localized.Ud.Placeholder.Drawer.action + ) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: Localized.Ud.Placeholder.Drawer.title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerLinkText( + text: Localized.Ud.Placeholder.Drawer.subtitle, + urlString: adrpURLString, + spacingAfter: 37 + ), + DrawerStack(views: [ + actionButton, + FlexibleSpace() + ]) + ]) - screenView - .phoneInput - .codePublisher + actionButton.publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) - .sink { [unowned self] in - coordinator.toCountries(from: self) { - self.viewModel.didChooseCountry($0) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() } - }.store(in: &cancellables) + }.store(in: &self.drawerCancellables) + + coordinator.toDrawer(drawer, from: self) } - private func setupFilterBindings() { - screenView.username - .publisher(for: .touchUpInside) - .sink { [unowned self] _ in viewModel.didSelect(filter: .username) } - .store(in: &cancellables) + private func presentSucessDrawerFor(contact: Contact) { + var items: [DrawerItem] = [] - screenView.phone - .publisher(for: .touchUpInside) - .sink { [unowned self] _ in viewModel.didSelect(filter: .phone) } - .store(in: &cancellables) + let drawerTitle = DrawerText( + font: Fonts.Mulish.extraBold.font(size: 26.0), + text: Localized.Ud.NicknameDrawer.title, + color: Asset.neutralDark.color, + spacingAfter: 20 + ) - screenView.email - .publisher(for: .touchUpInside) - .sink { [unowned self] _ in viewModel.didSelect(filter: .email) } - .store(in: &cancellables) + let drawerSubtitle = DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: Localized.Ud.NicknameDrawer.subtitle, + color: Asset.neutralDark.color, + spacingAfter: 20 + ) - viewModel.statePublisher - .map(\.selectedFilter) - .removeDuplicates() - .sink { [unowned self] in screenView.alternateFieldsOver(filter: $0) } - .store(in: &cancellables) + items.append(contentsOf: [ + drawerTitle, + drawerSubtitle + ]) - viewModel.statePublisher - .map(\.selectedFilter) - .removeDuplicates() - .dropFirst() - .sink { [unowned self] in screenView.select(filter: $0) } - .store(in: &cancellables) - } + let drawerNicknameInput = DrawerInput( + placeholder: contact.username!, + validator: .init( + wrongIcon: .image(Asset.sharedError.image), + correctIcon: .image(Asset.sharedSuccess.image), + shouldAcceptPlaceholder: true + ), + spacingAfter: 29 + ) - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } + items.append(drawerNicknameInput) - public func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) { - let contact = viewModel.itemsRelay.value[indexPath.row] + let drawerSaveButton = DrawerCapsuleButton( + model: .init( + title: Localized.Ud.NicknameDrawer.save, + style: .brandColored + ), spacingAfter: 5 + ) - guard contact.authStatus == .stranger else { - coordinator.toContact(contact, from: self) - return - } + items.append(drawerSaveButton) - presentRequestDrawer(forContact: contact) - } -} + let drawer = DrawerController(with: items) + var nickname: String? + var allowsSave = true + + drawerNicknameInput.validationPublisher + .receive(on: DispatchQueue.main) + .sink { allowsSave = $0 } + .store(in: &drawerCancellables) -extension SearchController: UITableViewDelegate {} + drawerNicknameInput.inputPublisher + .receive(on: DispatchQueue.main) + .sink { + guard !$0.isEmpty else { + nickname = contact.username + return + } -// MARK: - Contact Request Drawer + 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) + } -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, + text: Localized.Ud.RequestDrawer.title, color: Asset.neutralDark.color, spacingAfter: 20 ) @@ -288,7 +341,7 @@ extension SearchController { if let email = email { let drawerEmail = DrawerSwitch( - title: Localized.ContactSearch.RequestDrawer.email, + title: Localized.Ud.RequestDrawer.email, content: email, spacingAfter: phone != nil ? 23 : 31, isInitiallyOn: isSharingEmail @@ -304,7 +357,7 @@ extension SearchController { if let phone = phone { let drawerPhone = DrawerSwitch( - title: Localized.ContactSearch.RequestDrawer.phone, + title: Localized.Ud.RequestDrawer.phone, content: "\(Country.findFrom(phone).prefix) \(phone.dropLast(2))", spacingAfter: 31, isInitiallyOn: isSharingPhone @@ -320,14 +373,14 @@ extension SearchController { let drawerSendButton = DrawerCapsuleButton( model: .init( - title: Localized.ContactSearch.RequestDrawer.send, + title: Localized.Ud.RequestDrawer.send, style: .brandColored ), spacingAfter: 5 ) let drawerCancelButton = DrawerCapsuleButton( model: .init( - title: Localized.ContactSearch.RequestDrawer.cancel, + title: Localized.Ud.RequestDrawer.cancel, style: .simplestColoredBrand ), spacingAfter: 5 ) @@ -350,149 +403,31 @@ extension SearchController { 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 +extension SearchLeftController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if let item = dataSource.itemIdentifier(for: indexPath) { + switch item { + case .stranger(let contact): + didTap(contact: contact) + case .connection(let contact): + didTap(contact: contact) } - .store(in: &drawerCancellables) + } + } - drawerSaveButton.action - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - guard allowsSave else { return } + func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { + (view as! UITableViewHeaderFooterView).textLabel?.textColor = Asset.neutralWeak.color + } - drawer.dismiss(animated: true) { - self.viewModel.didSet(nickname: nickname ?? contact.username!, for: contact) - } - } - .store(in: &drawerCancellables) + private func didTap(contact: Contact) { + guard contact.authStatus == .stranger else { + coordinator.toContact(contact, from: self) + return + } - coordinator.toNicknameDrawer(drawer, from: self) + presentRequestDrawer(forContact: contact) } } diff --git a/Sources/SearchFeature/Controllers/SearchRightController.swift b/Sources/SearchFeature/Controllers/SearchRightController.swift new file mode 100644 index 0000000000000000000000000000000000000000..35240054497992ff2b5a277618321dceffeb652a --- /dev/null +++ b/Sources/SearchFeature/Controllers/SearchRightController.swift @@ -0,0 +1,81 @@ +import UIKit +import Combine +import DependencyInjection + +final class SearchRightController: UIViewController { + @Dependency var coordinator: SearchCoordinating + + lazy private var screenView = SearchRightView() + + private var cancellables = Set<AnyCancellable>() + private let cameraController = CameraController() + private(set) var viewModel = SearchRightViewModel() + + override func loadView() { + view = screenView + } + + override func viewDidLoad() { + super.viewDidLoad() + screenView.layer.insertSublayer(cameraController.previewLayer, at: 0) + setupBindings() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + cameraController.previewLayer.frame = screenView.bounds + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + viewModel.viewWillDisappear() + } + + private func setupBindings() { + cameraController + .dataPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in viewModel.didScan(data: $0) } + .store(in: &cancellables) + + viewModel.cameraSemaphorePublisher + .removeDuplicates() + .receive(on: DispatchQueue.global()) + .sink { [unowned self] setOn in + if setOn { + cameraController.start() + } else { + cameraController.stop() + } + }.store(in: &cancellables) + + viewModel.foundPublisher + .receive(on: DispatchQueue.main) + .delay(for: 1, scheduler: DispatchQueue.main) + .sink { [unowned self] in coordinator.toContact($0, from: self) } + .store(in: &cancellables) + + viewModel.statusPublisher + .receive(on: DispatchQueue.main) + .removeDuplicates() + .sink { [unowned self] in screenView.update(status: $0) } + .store(in: &cancellables) + + screenView.actionButton + .publisher(for: .touchUpInside) + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + switch viewModel.statusSubject.value { + case .failed(.cameraPermission): + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url, options: [:]) + case .failed(.requestOpened): + coordinator.toRequests(from: self) + case .failed(.alreadyFriends): + coordinator.toContacts(from: self) + default: + break + } + }.store(in: &cancellables) + } +} diff --git a/Sources/SearchFeature/Controllers/SearchTableController.swift b/Sources/SearchFeature/Controllers/SearchTableController.swift deleted file mode 100644 index a18b5856fe98c454c1a54b3ee4ca3c70d25d49dd..0000000000000000000000000000000000000000 --- a/Sources/SearchFeature/Controllers/SearchTableController.swift +++ /dev/null @@ -1,60 +0,0 @@ -import UIKit -import Models -import Combine -import XXModels - -final class SearchTableController: UITableViewController { - // MARK: Properties - - private let viewModel: SearchViewModel - private var cancellables = Set<AnyCancellable>() - private(set) var dataSource = [Contact]() - - // MARK: Lifecycle - - init(_ viewModel: SearchViewModel) { - self.viewModel = viewModel - super.init(style: .grouped) - } - - required init?(coder: NSCoder) { nil } - - override func viewDidLoad() { - super.viewDidLoad() - setupTableView() - setupBindings() - } - - // MARK: Private - - private func setupTableView() { - tableView.backgroundColor = .clear - tableView.separatorStyle = .none - tableView.register(SearchCell.self) - } - - private func setupBindings() { - viewModel - .itemsRelay - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - dataSource = $0 - tableView.reloadData() - }.store(in: &cancellables) - } - - // MARK: UITableViewDataSource - - override func tableView(_ tableView: UITableView, - cellForRowAt indexPath: IndexPath) -> UITableViewCell { - 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.setupProfile(title: dataSource[indexPath.row].username!, image: nil, size: .large) - return cell - } - - override func tableView(_ tableView: UITableView, - numberOfRowsInSection section: Int) -> Int { dataSource.count } -} - diff --git a/Sources/SearchFeature/Coordinator/SearchCoordinator.swift b/Sources/SearchFeature/Coordinator/SearchCoordinator.swift index 2f66a6228fd96bb156266a80c16a9ac5ba53c5d9..21a9d7b3d5eb9b1cef283a410135157fb7571ef1 100644 --- a/Sources/SearchFeature/Coordinator/SearchCoordinator.swift +++ b/Sources/SearchFeature/Coordinator/SearchCoordinator.swift @@ -6,6 +6,8 @@ import Presentation import ScrollViewController public protocol SearchCoordinating { + func toRequests(from: UIViewController) + func toContacts(from: UIViewController) func toContact(_: Contact, from: UIViewController) func toDrawer(_: UIViewController, from: UIViewController) func toNicknameDrawer(_: UIViewController, from: UIViewController) @@ -15,21 +17,38 @@ public protocol SearchCoordinating { public struct SearchCoordinator { var pushPresenter: Presenting = PushPresenter() var bottomPresenter: Presenting = BottomPresenter() + var replacePresenter: Presenting = ReplacePresenter() var fullscreenPresenter: Presenting = FullscreenPresenter() + var contactsFactory: () -> UIViewController + var requestsFactory: () -> UIViewController var contactFactory: (Contact) -> UIViewController var countriesFactory: (@escaping (Country) -> Void) -> UIViewController public init( + contactsFactory: @escaping () -> UIViewController, + requestsFactory: @escaping () -> UIViewController, contactFactory: @escaping (Contact) -> UIViewController, countriesFactory: @escaping (@escaping (Country) -> Void) -> UIViewController ) { self.contactFactory = contactFactory + self.contactsFactory = contactsFactory + self.requestsFactory = requestsFactory self.countriesFactory = countriesFactory } } extension SearchCoordinator: SearchCoordinating { + public func toRequests(from parent: UIViewController) { + let screen = requestsFactory() + replacePresenter.present(screen, from: parent) + } + + public func toContacts(from parent: UIViewController) { + let screen = contactsFactory() + replacePresenter.present(screen, from: parent) + } + public func toContact(_ contact: Contact, from parent: UIViewController) { let screen = contactFactory(contact) pushPresenter.present(screen, from: parent) diff --git a/Sources/SearchFeature/Utils/SearchDiffableDataSource.swift b/Sources/SearchFeature/Utils/SearchDiffableDataSource.swift new file mode 100644 index 0000000000000000000000000000000000000000..10d7bccc8c0f100b32cd34bf7a91312796ae9f15 --- /dev/null +++ b/Sources/SearchFeature/Utils/SearchDiffableDataSource.swift @@ -0,0 +1,23 @@ +import UIKit +import XXModels + +enum SearchSection { + case stranger + case connections +} + +enum SearchItem: Equatable, Hashable { + case stranger(Contact) + case connection(Contact) +} + +class SearchDiffableDataSource: UITableViewDiffableDataSource<SearchSection, SearchItem> { + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch snapshot().sectionIdentifiers[section] { + case .stranger: + return "" + case .connections: + return "LOCAL RESULTS" + } + } +} diff --git a/Sources/SearchFeature/ViewModels/SearchContainerViewModel.swift b/Sources/SearchFeature/ViewModels/SearchContainerViewModel.swift new file mode 100644 index 0000000000000000000000000000000000000000..e308f6a5b21134224ec17155b9585a9718cf1204 --- /dev/null +++ b/Sources/SearchFeature/ViewModels/SearchContainerViewModel.swift @@ -0,0 +1,58 @@ +import UIKit +import Combine +import Defaults +import Integration +import PushFeature +import DependencyInjection + +final class SearchContainerViewModel { + @Dependency var session: SessionType + @Dependency var pushHandler: PushHandling + + @KeyObject(.dummyTrafficOn, defaultValue: false) var isCoverTrafficEnabled + @KeyObject(.pushNotifications, defaultValue: false) var pushNotifications + @KeyObject(.askedDummyTrafficOnce, defaultValue: false) var offeredCoverTraffic + + var coverTrafficPublisher: AnyPublisher<Void, Never> { + coverTrafficSubject.eraseToAnyPublisher() + } + + private let coverTrafficSubject = PassthroughSubject<Void, Never>() + + func didAppear() { + verifyCoverTraffic() + verifyNotifications() + } + + func didEnableCoverTraffic() { + isCoverTrafficEnabled = true + session.setDummyTraffic(status: true) + } + + private func verifyCoverTraffic() { + guard offeredCoverTraffic == false else { return } + offeredCoverTraffic = true + coverTrafficSubject.send() + } + + private func verifyNotifications() { + guard pushNotifications == false else { return } + + pushHandler.requestAuthorization { [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 + } + } + } +} diff --git a/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift b/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift new file mode 100644 index 0000000000000000000000000000000000000000..30f77b191cbd9115f856d6133a2df3b2fa01fb0b --- /dev/null +++ b/Sources/SearchFeature/ViewModels/SearchLeftViewModel.swift @@ -0,0 +1,137 @@ +import HUD +import UIKit +import Shared +import Combine +import XXModels +import Countries +import Integration +import DependencyInjection + +typealias SearchSnapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem> + +struct SearchLeftViewState { + var input = "" + var snapshot: SearchSnapshot? + var country: Country = .fromMyPhone() + var item: SearchSegmentedControl.Item = .username +} + +final class SearchLeftViewModel { + @Dependency var session: SessionType + + var hudPublisher: AnyPublisher<HUDStatus, Never> { + hudSubject.eraseToAnyPublisher() + } + + var successPublisher: AnyPublisher<Contact, Never> { + successSubject.eraseToAnyPublisher() + } + + var statePublisher: AnyPublisher<SearchLeftViewState, Never> { + stateSubject.eraseToAnyPublisher() + } + + private var searchCancellables = Set<AnyCancellable>() + private let successSubject = PassthroughSubject<Contact, Never>() + private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) + private let stateSubject = CurrentValueSubject<SearchLeftViewState, Never>(.init()) + + func didEnterInput(_ string: String) { + stateSubject.value.input = string + } + + func didPick(country: Country) { + stateSubject.value.country = country + } + + func didSelectItem(_ item: SearchSegmentedControl.Item) { + stateSubject.value.item = item + } + + func didTapCancelSearch() { + searchCancellables.forEach { $0.cancel() } + searchCancellables.removeAll() + hudSubject.send(.none) + } + + func didStartSearching() { + guard stateSubject.value.input.isEmpty == false else { return } + + hudSubject.send(.onAction(Localized.Ud.Search.cancel)) + + var content = stateSubject.value.input + let prefix = stateSubject.value.item.written.first!.uppercased() + + if stateSubject.value.item == .phone { + content += stateSubject.value.country.code + } + + session.search(fact: "\(prefix)\(content)") + .sink { [unowned self] in + if case .failure(let error) = $0 { + self.appendToLocalSearch(nil) + self.hudSubject.send(.error(.init(with: error))) + } + } receiveValue: { contact in + self.hudSubject.send(.none) + self.appendToLocalSearch(contact) + }.store(in: &searchCancellables) + } + + func didTapResend(contact: Contact) { + hudSubject.send(.on) + + do { + try self.session.retryRequest(contact) + hudSubject.send(.none) + } catch { + hudSubject.send(.error(.init(with: error))) + } + } + + func didTapRequest(contact: Contact) { + hudSubject.send(.on) + var contact = contact + contact.nickname = contact.username + + do { + try self.session.add(contact) + hudSubject.send(.none) + successSubject.send(contact) + } catch { + hudSubject.send(.error(.init(with: error))) + } + } + + func didSet(nickname: String, for contact: Contact) { + if var contact = try? session.dbManager.fetchContacts(.init(id: [contact.id])).first { + contact.nickname = nickname + _ = try? session.dbManager.saveContact(contact) + } + } + + private func appendToLocalSearch(_ user: Contact?) { + var snapshot = SearchSnapshot() + + if var user = user { + if let contact = try? session.dbManager.fetchContacts(.init(id: [user.id])).first { + user.authStatus = contact.authStatus + } + + if user.authStatus != .friend { + snapshot.appendSections([.stranger]) + snapshot.appendItems([.stranger(user)], toSection: .stranger) + } + } + + let localsQuery = Contact.Query(text: stateSubject.value.input, authStatus: [.friend]) + + if let locals = try? session.dbManager.fetchContacts(localsQuery), locals.count > 0 { + let localsWithoutMe = locals.filter { $0.id != session.myId } + snapshot.appendSections([.connections]) + snapshot.appendItems(localsWithoutMe.map(SearchItem.connection), toSection: .connections) + } + + stateSubject.value.snapshot = snapshot + } +} diff --git a/Sources/SearchFeature/ViewModels/SearchRightViewModel.swift b/Sources/SearchFeature/ViewModels/SearchRightViewModel.swift new file mode 100644 index 0000000000000000000000000000000000000000..5cfe38db68c498a15522f745174e41415281ba17 --- /dev/null +++ b/Sources/SearchFeature/ViewModels/SearchRightViewModel.swift @@ -0,0 +1,111 @@ +import Shared +import Combine +import XXModels +import Foundation +import Permissions +import Integration +import DependencyInjection + +enum ScanningStatus: Equatable { + case reading + case processing + case success + case failed(ScanningError) +} + +enum ScanningError: Equatable { + case requestOpened + case unknown(String) + case cameraPermission + case alreadyFriends(String) +} + +final class SearchRightViewModel { + @Dependency var session: SessionType + @Dependency var permissions: PermissionHandling + + var foundPublisher: AnyPublisher<Contact, Never> { + foundSubject.eraseToAnyPublisher() + } + + var cameraSemaphorePublisher: AnyPublisher<Bool, Never> { + cameraSemaphoreSubject.eraseToAnyPublisher() + } + + var statusPublisher: AnyPublisher<ScanningStatus, Never> { + statusSubject.eraseToAnyPublisher() + } + + private let foundSubject = PassthroughSubject<Contact, Never>() + private let cameraSemaphoreSubject = PassthroughSubject<Bool, Never>() + private(set) var statusSubject = CurrentValueSubject<ScanningStatus, Never>(.reading) + + func viewWillAppear() { + permissions.requestCamera { [weak self] granted in + guard let self = self else { return } + + if granted { + self.statusSubject.value = .reading + self.cameraSemaphoreSubject.send(true) + } else { + self.statusSubject.send(.failed(.cameraPermission)) + } + } + } + + func viewWillDisappear() { + cameraSemaphoreSubject.send(false) + } + + func didScan(data: Data) { + /// We need to be accepting new readings in order + /// to process what just got scanned. + /// + guard statusSubject.value == .reading else { return } + statusSubject.send(.processing) + + /// Whatever got scanned, needs to have id and username + /// otherwise is just noise or an unknown qr code + /// + guard let userId = session.getId(from: data), + let username = try? session.extract(fact: .username, from: data) else { + let errorTitle = Localized.Scan.Error.invalid + statusSubject.send(.failed(.unknown(errorTitle))) + return + } + + /// Make sure we are not processing a contact + /// that we already have + /// + if let alreadyContact = try? session.dbManager.fetchContacts(.init(id: [userId])).first { + /// Show error accordingly to the auth status + /// + if alreadyContact.authStatus == .friend { + statusSubject.send(.failed(.alreadyFriends(username))) + } else if [.requested, .verified].contains(alreadyContact.authStatus) { + statusSubject.send(.failed(.requestOpened)) + } else { + let generalErrorTitle = Localized.Scan.Error.general + statusSubject.send(.failed(.unknown(generalErrorTitle))) + } + + return + } + + statusSubject.send(.success) + cameraSemaphoreSubject.send(false) + + foundSubject.send(.init( + id: userId, + marshaled: data, + username: username, + email: try? session.extract(fact: .email, from: data), + phone: try? session.extract(fact: .phone, from: data), + nickname: nil, + photo: nil, + authStatus: .stranger, + isRecent: false, + createdAt: Date() + )) + } +} diff --git a/Sources/SearchFeature/ViewModels/SearchViewModel.swift b/Sources/SearchFeature/ViewModels/SearchViewModel.swift deleted file mode 100644 index 7b52fb9762f47cfab9d17f2fd02383560be228bd..0000000000000000000000000000000000000000 --- a/Sources/SearchFeature/ViewModels/SearchViewModel.swift +++ /dev/null @@ -1,193 +0,0 @@ -import HUD -import UIKit -import Models -import Combine -import Defaults -import XXModels -import Countries -import Foundation -import Integration -import PushFeature -import CombineSchedulers -import DependencyInjection - -enum SelectedFilter { - case username - case email - case phone - - var prefix: String { - switch self { - case .username: - return "U" - case .phone: - return "P" - case .email: - return "E" - } - } -} - -struct SearchViewState: Equatable { - var input: String = "" - var phoneInput: String = "" - var selectedFilter: SelectedFilter = .username - var country: Country = .fromMyPhone() -} - -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 - - 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 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) { - stateSubject.value.selectedFilter = filter - } - - func didInput(_ string: String) { - stateSubject.value.input = string.trimmingCharacters(in: .whitespacesAndNewlines) - } - - func didInputPhone(_ string: String) { - stateSubject.value.phoneInput = string.trimmingCharacters(in: .whitespacesAndNewlines) - } - - func didChooseCountry(_ country: Country) { - stateSubject.value.country = country - } - - func didEnableCoverTraffic() { - isCoverTrafficEnabled = true - session.setDummyTraffic(status: true) - } - - func didTapSearch() { - hudSubject.send(.on(nil)) - - backgroundScheduler.schedule { [weak self] in - guard let self = self else { return } - - do { - var content = self.stateSubject.value.selectedFilter.prefix - - if self.stateSubject.value.selectedFilter == .phone { - content += self.stateSubject.value.phoneInput + self.stateSubject.value.country.code - } else { - content += self.stateSubject.value.input - } - - try self.session.search(fact: content) { result in - self.placeholderSubject.send(false) - - switch result { - case .success(let searched): - self.hudSubject.send(.none) - self.itemsRelay.send([searched]) - case .failure(let error): - self.hudSubject.send(.error(.init(with: error))) - self.itemsRelay.send([]) - } - } - } catch { - 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.requestAuthorization { [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 } - _ = try? self.session.dbManager.saveContact(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/SearchFeature/Views/FilterItemView.swift b/Sources/SearchFeature/Views/FilterItemView.swift deleted file mode 100644 index 2bf4d2a81a2e998b5bad69b6d71c5cfa688127ee..0000000000000000000000000000000000000000 --- a/Sources/SearchFeature/Views/FilterItemView.swift +++ /dev/null @@ -1,68 +0,0 @@ -import UIKit -import Shared - -final class FilterItemView: UIControl { - enum Style { - case selected - case unselected - } - - private let title = UILabel() - private let image = UIImageView() - - private var icon: UIImage? - - var style: Style = .unselected { - didSet { - image.image = icon?.withRenderingMode(.alwaysTemplate) - - switch style { - case .selected: - backgroundColor = Asset.brandDefault.color - image.tintColor = Asset.neutralWhite.color - title.textColor = Asset.neutralWhite.color - title.font = Fonts.Mulish.bold.font(size: 14.0) - layer.borderColor = Asset.brandDefault.color.cgColor - - case .unselected: - image.tintColor = Asset.neutralActive.color - title.textColor = Asset.neutralActive.color - backgroundColor = Asset.neutralSecondary.color - title.font = Fonts.Mulish.regular.font(size: 14.0) - layer.borderColor = Asset.neutralLine.color.cgColor - } - } - } - - init() { - super.init(frame: .zero) - - layer.borderWidth = 1 - layer.cornerRadius = 4 - image.contentMode = .center - - let stack = UIStackView() - stack.isUserInteractionEnabled = false - - stack.spacing = 8 - stack.addArrangedSubview(image) - stack.addArrangedSubview(title) - - addSubview(stack) - - stack.snp.makeConstraints { $0.center.equalToSuperview() } - snp.makeConstraints { $0.height.equalTo(40) } - } - - required init?(coder: NSCoder) { nil } - - func set( - title: String, - icon: UIImage?, - style: Style = .unselected - ) { - self.icon = icon - self.style = style - self.title.text = title - } -} diff --git a/Sources/SearchFeature/Views/OverlayView.swift b/Sources/SearchFeature/Views/OverlayView.swift new file mode 100644 index 0000000000000000000000000000000000000000..8242857716936fe49cf21a59bad280195c726165 --- /dev/null +++ b/Sources/SearchFeature/Views/OverlayView.swift @@ -0,0 +1,181 @@ +import UIKit +import Shared + +final class OverlayView: UIView { + private let cropView = UIView() + private let scanViewLength = 266.0 + private let maskLayer = CAShapeLayer() + private let topLeftLayer = CAShapeLayer() + private let topRightLayer = CAShapeLayer() + private let bottomLeftLayer = CAShapeLayer() + private let bottomRightLayer = CAShapeLayer() + + init() { + super.init(frame: .zero) + backgroundColor = Asset.neutralDark.color.withAlphaComponent(0.5) + + addSubview(cropView) + + cropView.snp.makeConstraints { + $0.width.equalTo(scanViewLength) + $0.centerY.equalToSuperview().offset(-50) + $0.centerX.equalToSuperview() + $0.height.equalTo(scanViewLength) + } + + maskLayer.fillRule = .evenOdd + layer.mask = maskLayer + layer.masksToBounds = true + + [topLeftLayer, topRightLayer, bottomLeftLayer, bottomRightLayer].forEach { + $0.strokeColor = Asset.brandPrimary.color.cgColor + $0.fillColor = UIColor.clear.cgColor + $0.lineWidth = 3.0 + $0.lineCap = .round + layer.addSublayer($0) + } + } + + required init?(coder: NSCoder) { nil } + + override func layoutSubviews() { + super.layoutSubviews() + + maskLayer.frame = bounds + let path = UIBezierPath(rect: bounds) + path.append(UIBezierPath(roundedRect: cropView.frame, cornerRadius: 30.0)) + maskLayer.path = path.cgPath + + topLeftLayer.frame = bounds + topRightLayer.frame = bounds + bottomRightLayer.frame = bounds + bottomLeftLayer.frame = bounds + + topLeftLayer.path = topLeftPath() + topRightLayer.path = topRightPath() + bottomRightLayer.path = bottomRightPath() + bottomLeftLayer.path = bottomLeftPath() + } + + func updateCornerColor(_ color: UIColor) { + [topLeftLayer, topRightLayer, bottomLeftLayer, bottomRightLayer].forEach { + $0.strokeColor = color.cgColor + } + } + + func topLeftPath() -> CGPath { + let path = UIBezierPath() + + let vert0X = cropView.frame.minX - 15 + let vert0Y = cropView.frame.minY + 45 + let vert0 = CGPoint(x: vert0X, y: vert0Y) + path.move(to: vert0) + + let vertNX = cropView.frame.minX - 15 + let vertNY = cropView.frame.minY + 15 + let vertN = CGPoint(x: vertNX, y: vertNY) + path.addLine(to: vertN) + + let arcCenterX = cropView.frame.minX + 15 + let arcCenterY = cropView.frame.minY + 15 + let arcCenter = CGPoint(x: arcCenterX , y: arcCenterY) + path.addArc(center: arcCenter, startAngle: .pi) + + let horizX = cropView.frame.minX + 45 + let horizY = cropView.frame.minY - 15 + let horiz = CGPoint(x: horizX, y: horizY) + path.addLine(to: horiz) + + return path.cgPath + } + + func topRightPath() -> CGPath { + let path = UIBezierPath() + + let horiz0X = cropView.frame.maxX - 45 + let horiz0Y = cropView.frame.minY - 15 + let horiz0 = CGPoint(x: horiz0X, y: horiz0Y) + path.move(to: horiz0) + + let horizNX = cropView.frame.maxX - 15 + let horizNY = cropView.frame.minY - 15 + let horizN = CGPoint(x: horizNX, y: horizNY) + path.addLine(to: horizN) + + let arcCenterX = cropView.frame.maxX - 15 + let arcCenterY = cropView.frame.minY + 15 + let arcCenter = CGPoint(x: arcCenterX, y: arcCenterY) + path.addArc(center: arcCenter, startAngle: 3 * .pi/2) + + let vertX = cropView.frame.maxX + 15 + let vertY = cropView.frame.minY + 45 + let vert = CGPoint(x: vertX, y: vertY) + path.addLine(to: vert) + + return path.cgPath + } + + func bottomRightPath() -> CGPath { + let path = UIBezierPath() + + let vert0X = cropView.frame.maxX + 15 + let vert0Y = cropView.frame.maxY - 45 + let vert0 = CGPoint(x: vert0X, y: vert0Y) + path.move(to: vert0) + + let vertNX = cropView.frame.maxX + 15 + let vertNY = cropView.frame.maxY - 15 + let vertN = CGPoint(x: vertNX, y: vertNY) + path.addLine(to: vertN) + + let arcCenterX = cropView.frame.maxX - 15 + let arcCenterY = cropView.frame.maxY - 15 + let arcCenter = CGPoint(x: arcCenterX, y: arcCenterY) + path.addArc(center: arcCenter, startAngle: 0) + + let horizX = cropView.frame.maxX - 45 + let horizY = cropView.frame.maxY + 15 + let horiz = CGPoint(x: horizX, y: horizY) + path.addLine(to: horiz) + + return path.cgPath + } + + func bottomLeftPath() -> CGPath { + let path = UIBezierPath() + + let horiz0X = cropView.frame.minX + 45 + let horiz0Y = cropView.frame.maxY + 15 + let horiz0 = CGPoint(x: horiz0X, y: horiz0Y) + path.move(to: horiz0) + + let horizNX = cropView.frame.minX + 15 + let horizNY = cropView.frame.maxY + 15 + let horizN = CGPoint(x: horizNX, y: horizNY) + path.addLine(to: horizN) + + let arcCenterX = cropView.frame.minX + 15 + let arcCenterY = cropView.frame.maxY - 15 + let arcCenter = CGPoint(x: arcCenterX, y: arcCenterY) + path.addArc(center: arcCenter, startAngle: .pi/2) + + let vertX = cropView.frame.minX - 15 + let vertY = cropView.frame.maxY - 45 + let vert = CGPoint(x: vertX, y: vertY) + path.addLine(to: vert) + + return path.cgPath + } +} + +private extension UIBezierPath { + func addArc(center: CGPoint, startAngle: CGFloat) { + addArc( + withCenter: center, + radius: 30, + startAngle: startAngle, + endAngle: startAngle + .pi/2, + clockwise: true + ) + } +} diff --git a/Sources/SearchFeature/Views/SearchCell.swift b/Sources/SearchFeature/Views/SearchCell.swift deleted file mode 100644 index 8819425791ed9dbde382626ac7d03fd868b7b31e..0000000000000000000000000000000000000000 --- a/Sources/SearchFeature/Views/SearchCell.swift +++ /dev/null @@ -1,73 +0,0 @@ -import UIKit -import Shared - -final class SearchCell: UITableViewCell { - // MARK: UI - - let title = UILabel() - let subtitle = UILabel() - let separator = UIView() - let avatar = AvatarView() - - // 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 - } - - // MARK: Private - - private func setup() { - selectionStyle = .none - backgroundColor = Asset.neutralWhite.color - - title.textColor = Asset.neutralActive.color - subtitle.textColor = Asset.neutralDisabled.color - separator.backgroundColor = Asset.neutralLine.color - - title.font = Fonts.Mulish.semiBold.font(size: 14.0) - subtitle.font = Fonts.Mulish.regular.font(size: 12.0) - - contentView.addSubview(title) - contentView.addSubview(avatar) - contentView.addSubview(subtitle) - contentView.addSubview(separator) - - setupConstraints() - } - - private func setupConstraints() { - title.snp.makeConstraints { make in - make.top.equalToSuperview().offset(10) - make.left.equalTo(avatar.snp.right).offset(16) - make.right.lessThanOrEqualToSuperview().offset(-20) - } - - subtitle.snp.makeConstraints { make in - make.top.equalTo(title.snp.bottom).offset(3) - make.left.equalTo(title) - make.bottom.equalToSuperview().offset(-22) - } - - avatar.snp.makeConstraints { make in - make.left.equalToSuperview().offset(28) - make.width.height.equalTo(48) - make.bottom.equalToSuperview().offset(-16) - } - - separator.snp.makeConstraints { make in - make.height.equalTo(1) - make.left.equalToSuperview().offset(24) - make.right.equalToSuperview().offset(-24) - make.bottom.equalToSuperview() - } - } -} diff --git a/Sources/SearchFeature/Views/SearchContainerView.swift b/Sources/SearchFeature/Views/SearchContainerView.swift new file mode 100644 index 0000000000000000000000000000000000000000..3a270bc5a52891fc915951bf68f738f2e5cdcd6f --- /dev/null +++ b/Sources/SearchFeature/Views/SearchContainerView.swift @@ -0,0 +1,35 @@ +import UIKit +import Shared + +final class SearchContainerView: UIView { + let scrollView = UIScrollView() + let segmentedControl = SearchSegmentedControl() + + init() { + super.init(frame: .zero) + + backgroundColor = Asset.neutralWhite.color + addSubview(segmentedControl) + addSubview(scrollView) + + setupConstraints() + } + + required init?(coder: NSCoder) { nil } + + private func setupConstraints() { + segmentedControl.snp.makeConstraints { + $0.top.equalTo(safeAreaLayoutGuide).offset(10) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.height.equalTo(60) + } + + scrollView.snp.makeConstraints { + $0.top.equalTo(segmentedControl.snp.bottom) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + } + } +} diff --git a/Sources/SearchFeature/Views/SearchLeftEmptyView.swift b/Sources/SearchFeature/Views/SearchLeftEmptyView.swift new file mode 100644 index 0000000000000000000000000000000000000000..84c64c87a6096a16b2bbf793a9eadc1c95495324 --- /dev/null +++ b/Sources/SearchFeature/Views/SearchLeftEmptyView.swift @@ -0,0 +1,26 @@ +import UIKit +import Shared + +final class SearchLeftEmptyView: UIView { + let titleLabel = UILabel() + + init() { + super.init(frame: .zero) + backgroundColor = Asset.neutralWhite.color + + titleLabel.numberOfLines = 0 + titleLabel.textAlignment = .center + titleLabel.font = Fonts.Mulish.regular.font(size: 15.0) + titleLabel.textColor = Asset.neutralSecondaryAlternative.color + + addSubview(titleLabel) + + titleLabel.snp.makeConstraints { + $0.center.equalToSuperview() + $0.left.equalToSuperview().offset(20) + $0.right.equalToSuperview().offset(-20) + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/SearchFeature/Views/SearchLeftPlaceholderView.swift b/Sources/SearchFeature/Views/SearchLeftPlaceholderView.swift new file mode 100644 index 0000000000000000000000000000000000000000..7742ff1d64c56151a88ae9b5ae47e82ebb59dc1b --- /dev/null +++ b/Sources/SearchFeature/Views/SearchLeftPlaceholderView.swift @@ -0,0 +1,74 @@ +import UIKit +import Shared +import Combine + +final class SearchLeftPlaceholderView: UIView { + let titleLabel = UILabel() + let subtitleWithInfo = TextWithInfoView() + + var infoPublisher: AnyPublisher<Void, Never> { + infoSubject.eraseToAnyPublisher() + } + + private let infoSubject = PassthroughSubject<Void, Never>() + + init() { + super.init(frame: .zero) + backgroundColor = Asset.neutralWhite.color + + let attrString = NSMutableAttributedString( + string: Localized.Ud.Search.Placeholder.title, + attributes: [ + .foregroundColor: Asset.neutralDark.color, + .font: Fonts.Mulish.bold.font(size: 32.0) + ] + ) + + attrString.addAttribute( + name: .foregroundColor, + value: Asset.brandPrimary.color, + betweenCharacters: "#" + ) + + titleLabel.numberOfLines = 0 + titleLabel.attributedText = attrString + + let paragraph = NSMutableParagraphStyle() + paragraph.lineHeightMultiple = 1.3 + + subtitleWithInfo.setup( + text: Localized.Ud.Search.Placeholder.subtitle, + attributes: [ + .paragraphStyle: paragraph, + .foregroundColor: Asset.neutralBody.color, + .font: Fonts.Mulish.regular.font(size: 16.0) + ], + didTapInfo: { [weak self] in + guard let self = self else { return } + self.infoSubject.send(()) + } + ) + + addSubview(titleLabel) + addSubview(subtitleWithInfo) + + setupConstraints() + } + + required init?(coder: NSCoder) { nil } + + private func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(50) + $0.left.equalToSuperview().offset(32.5) + $0.right.equalToSuperview().offset(-32.5) + } + + subtitleWithInfo.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(30) + $0.left.equalToSuperview().offset(32.5) + $0.right.equalToSuperview().offset(-32.5) + $0.bottom.equalToSuperview() + } + } +} diff --git a/Sources/SearchFeature/Views/SearchLeftView.swift b/Sources/SearchFeature/Views/SearchLeftView.swift new file mode 100644 index 0000000000000000000000000000000000000000..bbdda3f926818439e4c28d5beb020239a623048a --- /dev/null +++ b/Sources/SearchFeature/Views/SearchLeftView.swift @@ -0,0 +1,71 @@ +import UIKit +import Shared + +final class SearchLeftView: UIView { + let tableView = UITableView() + let inputStackView = UIStackView() + let inputField = SearchComponent() + let emptyView = SearchLeftEmptyView() + let countryButton = SearchCountryComponent() + let placeholderView = SearchLeftPlaceholderView() + + init() { + super.init(frame: .zero) + + emptyView.isHidden = true + backgroundColor = Asset.neutralWhite.color + tableView.backgroundColor = Asset.neutralWhite.color + + inputStackView.spacing = 5 + inputStackView.addArrangedSubview(countryButton) + inputStackView.addArrangedSubview(inputField) + + addSubview(inputStackView) + addSubview(tableView) + addSubview(emptyView) + addSubview(placeholderView) + + setupConstraints() + } + + required init?(coder: NSCoder) { nil } + + func updateUIForItem(item: SearchSegmentedControl.Item) { + countryButton.isHidden = item != .phone + + let emptyTitle = Localized.Ud.Search.empty(item.written) + emptyView.titleLabel.text = emptyTitle + + let inputFieldTitle = Localized.Ud.Search.input(item.written) + inputField.set(placeholder: inputFieldTitle, imageAtRight: nil) + } + + private func setupConstraints() { + inputStackView.snp.makeConstraints { + $0.top.equalToSuperview().offset(20) + $0.left.equalToSuperview().offset(20) + $0.right.equalToSuperview().offset(-20) + } + + tableView.snp.makeConstraints { + $0.top.equalTo(inputField.snp.bottom).offset(20) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + } + + emptyView.snp.makeConstraints { + $0.top.equalTo(inputField.snp.bottom).offset(20) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + } + + placeholderView.snp.makeConstraints { + $0.top.equalTo(inputField.snp.bottom) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + } + } +} diff --git a/Sources/SearchFeature/Views/SearchPlaceholderView.swift b/Sources/SearchFeature/Views/SearchPlaceholderView.swift deleted file mode 100644 index e6697688f13ab4b13c023f9e8579f081ff00be44..0000000000000000000000000000000000000000 --- a/Sources/SearchFeature/Views/SearchPlaceholderView.swift +++ /dev/null @@ -1,65 +0,0 @@ -import UIKit -import Shared - -final class SearchPlaceholderView: UIView { - let titleView = TextWithInfoView() - let didTapInfo: () -> Void - - init(didTapInfo: @escaping () -> Void) { - self.didTapInfo = didTapInfo - - super.init(frame: .zero) - - let paragraph = NSMutableParagraphStyle() - paragraph.lineSpacing = 5 - paragraph.lineHeightMultiple = 1.0 - paragraph.alignment = .center - - titleView.setup( - text: "Your searches are anonymous.\nSearch information is never linked to your account or personally identifiable.", - attributes: [ - .foregroundColor: Asset.neutralBody.color, - .font: Fonts.Mulish.regular.font(size: 16.0) as Any, - .paragraphStyle: paragraph - ], - didTapInfo: { didTapInfo() } - ) - - addSubview(titleView) - - titleView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(60) - make.left.equalToSuperview().offset(60) - make.right.equalToSuperview().offset(-60) - } - } - - required init?(coder: NSCoder) { nil } -} - -final class SearchEmptyView: UIView { - private let title = UILabel() - - init() { - super.init(frame: .zero) - - backgroundColor = Asset.neutralWhite.color - - title.textColor = Asset.neutralBody.color - title.font = Fonts.Mulish.regular.font(size: 12.0) - - addSubview(title) - - title.snp.makeConstraints { make in - make.top.equalToSuperview().offset(20) - make.left.equalToSuperview().offset(30) - make.right.equalToSuperview().offset(-30) - } - } - - required init?(coder: NSCoder) { nil } - - func set(filter: String) { - title.text = Localized.ContactSearch.noneFound(filter) - } -} diff --git a/Sources/SearchFeature/Views/SearchRightView.swift b/Sources/SearchFeature/Views/SearchRightView.swift new file mode 100644 index 0000000000000000000000000000000000000000..363808754852969a75c3c811836faa9b40455034 --- /dev/null +++ b/Sources/SearchFeature/Views/SearchRightView.swift @@ -0,0 +1,116 @@ +import UIKit +import Shared + +final class SearchRightView: UIView { + let statusLabel = UILabel() + let imageView = UIImageView() + let stackView = UIStackView() + let overlayView = OverlayView() + let animationView = DotAnimation() + let actionButton = CapsuleButton() + + init() { + super.init(frame: .zero) + imageView.contentMode = .center + actionButton.setStyle(.brandColored) + + statusLabel.numberOfLines = 0 + statusLabel.textAlignment = .center + statusLabel.textColor = Asset.neutralWhite.color + statusLabel.font = Fonts.Mulish.regular.font(size: 14.0) + + stackView.spacing = 15 + stackView.axis = .vertical + stackView.addArrangedSubview(animationView) + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(statusLabel) + stackView.addArrangedSubview(actionButton) + + imageView.isHidden = true + actionButton.isHidden = true + animationView.isHidden = false + + addSubview(overlayView) + addSubview(stackView) + + setupConstraints() + } + + required init?(coder: NSCoder) { nil } + + func update(status: ScanningStatus) { + var text: String + + switch status { + case .reading, .processing: + imageView.isHidden = true + actionButton.isHidden = true + text = Localized.Scan.Status.reading + overlayView.updateCornerColor(Asset.brandPrimary.color) + + case .success: + animationView.isHidden = true + actionButton.isHidden = true + imageView.isHidden = false + imageView.image = Asset.sharedSuccess.image + text = Localized.Scan.Status.success + overlayView.updateCornerColor(Asset.accentSuccess.color) + + case .failed(let error): + animationView.isHidden = true + imageView.image = Asset.scanError.image + imageView.isHidden = false + overlayView.updateCornerColor(Asset.accentDanger.color) + + switch error { + case .requestOpened: + text = Localized.Scan.Error.requested + actionButton.setTitle(Localized.Scan.requests, for: .normal) + actionButton.isHidden = false + + case .alreadyFriends(let name): + text = Localized.Scan.Error.friends(name) + actionButton.setTitle(Localized.Scan.contact, for: .normal) + actionButton.isHidden = false + + case .cameraPermission: + text = Localized.Scan.Error.denied + actionButton.setTitle(Localized.Scan.settings, for: .normal) + actionButton.isHidden = false + + case .unknown(let content): + text = content + } + } + + let attString = NSMutableAttributedString(string: text) + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + paragraph.lineHeightMultiple = 1.35 + + attString.addAttribute(.paragraphStyle, value: paragraph) + attString.addAttribute(.foregroundColor, value: Asset.neutralWhite.color) + attString.addAttribute(.font, value: Fonts.Mulish.regular.font(size: 14.0) as Any) + + if text.contains("#") { + attString.addAttribute(name: .foregroundColor, value: Asset.brandPrimary.color, betweenCharacters: "#") + } + + statusLabel.attributedText = attString + } + + private func setupConstraints() { + overlayView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + } + + stackView.snp.makeConstraints { + $0.left.equalToSuperview().offset(57) + $0.right.equalToSuperview().offset(-57) + $0.bottom.equalTo(safeAreaLayoutGuide).offset(-100) + } + } +} diff --git a/Sources/SearchFeature/Views/SearchSegmentedButton.swift b/Sources/SearchFeature/Views/SearchSegmentedButton.swift new file mode 100644 index 0000000000000000000000000000000000000000..3b8e65fb1b778703a748626155d6f2b0b18ec94b --- /dev/null +++ b/Sources/SearchFeature/Views/SearchSegmentedButton.swift @@ -0,0 +1,49 @@ +import UIKit +import Shared + +final class SearchSegmentedButton: UIControl { + private let titleLabel = UILabel() + private let imageView = UIImageView() + private let highlightColor = Asset.brandPrimary.color + private let discreteColor = Asset.neutralDisabled.color + + init() { + super.init(frame: .zero) + + imageView.contentMode = .center + titleLabel.textAlignment = .center + titleLabel.font = Fonts.Mulish.semiBold.font(size: 13.0) + + addSubview(titleLabel) + addSubview(imageView) + + setupConstraints() + } + + required init?(coder: NSCoder) { nil } + + func setup(title: String, icon: UIImage) { + imageView.image = icon + titleLabel.text = title + imageView.tintColor = discreteColor + titleLabel.textColor = discreteColor + } + + func setSelected(_ bool: Bool) { + imageView.tintColor = bool ? highlightColor : discreteColor + titleLabel.textColor = bool ? highlightColor : discreteColor + } + + private func setupConstraints() { + imageView.snp.makeConstraints { + $0.top.equalToSuperview().offset(7.5) + $0.centerX.equalToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.top.equalTo(imageView.snp.bottom).offset(2) + $0.centerX.equalToSuperview() + $0.bottom.equalToSuperview().offset(-7.5) + } + } +} diff --git a/Sources/SearchFeature/Views/SearchSegmentedControl.swift b/Sources/SearchFeature/Views/SearchSegmentedControl.swift new file mode 100644 index 0000000000000000000000000000000000000000..6141360378cd659e89dbdf67c4180f4a0e9b4ce6 --- /dev/null +++ b/Sources/SearchFeature/Views/SearchSegmentedControl.swift @@ -0,0 +1,120 @@ +import UIKit +import Shared +import SnapKit +import Combine + +final class SearchSegmentedControl: UIView { + enum Item: Int { + case username = 0 + case email + case phone + case qr + + var written: String { + switch self { + case .qr: return "qr" + case .email: return "email" + case .phone: return "phone number" + case .username: return "username" + } + } + } + + private let trackView = UIView() + private let stackView = UIStackView() + private var leftConstraint: Constraint? + private let trackIndicatorView = UIView() + private let emailButton = SearchSegmentedButton() + private let phoneButton = SearchSegmentedButton() + private let qrCodeButton = SearchSegmentedButton() + private let usernameButton = SearchSegmentedButton() + + var actionPublisher: AnyPublisher<Item, Never> { + actionSubject.eraseToAnyPublisher() + } + + private var cancellables = Set<AnyCancellable>() + private let actionSubject = CurrentValueSubject<Item, Never>(.username) + + init() { + super.init(frame: .zero) + trackView.backgroundColor = Asset.neutralLine.color + trackIndicatorView.backgroundColor = Asset.brandPrimary.color + + qrCodeButton.setup(title: Localized.Ud.Tab.qr, icon: Asset.searchTabQr.image) + emailButton.setup(title: Localized.Ud.Tab.email, icon: Asset.searchTabEmail.image) + phoneButton.setup(title: Localized.Ud.Tab.phone, icon: Asset.searchTabPhone.image) + usernameButton.setup(title: Localized.Ud.Tab.username, icon: Asset.searchTabUsername.image) + + stackView.distribution = .fillEqually + stackView.addArrangedSubview(usernameButton) + stackView.addArrangedSubview(emailButton) + stackView.addArrangedSubview(phoneButton) + stackView.addArrangedSubview(qrCodeButton) + stackView.backgroundColor = Asset.neutralWhite.color + + addSubview(stackView) + addSubview(trackView) + trackView.addSubview(trackIndicatorView) + + setupBindings() + setupConstraints() + } + + required init?(coder: NSCoder) { nil } + + private func setupBindings() { + usernameButton.publisher(for: .touchUpInside) + .sink { [unowned self] in actionSubject.send(.username) } + .store(in: &cancellables) + + emailButton.publisher(for: .touchUpInside) + .sink { [unowned self] in actionSubject.send(.email) } + .store(in: &cancellables) + + phoneButton.publisher(for: .touchUpInside) + .sink { [unowned self] in actionSubject.send(.phone) } + .store(in: &cancellables) + + qrCodeButton.publisher(for: .touchUpInside) + .sink { [unowned self] in actionSubject.send(.qr) } + .store(in: &cancellables) + + actionSubject + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + let tabWidth = bounds.width / 4 + if let leftConstraint = leftConstraint { + leftConstraint.update(offset: tabWidth * CGFloat($0.rawValue)) + setNeedsLayout() + UIView.animate(withDuration: 0.25) { self.layoutIfNeeded() } + } + + qrCodeButton.setSelected($0 == .qr) + emailButton.setSelected($0 == .email) + phoneButton.setSelected($0 == .phone) + usernameButton.setSelected($0 == .username) + }.store(in: &cancellables) + } + + private func setupConstraints() { + stackView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + trackView.snp.makeConstraints { + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + $0.height.equalTo(2) + } + + trackIndicatorView.snp.makeConstraints { + $0.top.equalToSuperview() + leftConstraint = $0.left.equalToSuperview().constraint + $0.width.equalToSuperview().dividedBy(4) + $0.bottom.equalToSuperview() + } + } +} diff --git a/Sources/SearchFeature/Views/SearchView.swift b/Sources/SearchFeature/Views/SearchView.swift deleted file mode 100644 index 86f929b1000aca396cd1fd8c63de372ff399472f..0000000000000000000000000000000000000000 --- a/Sources/SearchFeature/Views/SearchView.swift +++ /dev/null @@ -1,129 +0,0 @@ -import UIKit -import Shared -import InputField - -final class SearchView: UIView { - private enum Constants { - static let phone = Localized.ContactSearch.Filter.phone - static let email = Localized.ContactSearch.Filter.email - static let username = Localized.ContactSearch.Filter.username - } - - let input = InputField() - let stack = UIStackView() - let filters = UIStackView() - let email = FilterItemView() - let phone = FilterItemView() - let empty = SearchEmptyView() - let phoneInput = InputField() - let username = FilterItemView() - lazy var placeholder = SearchPlaceholderView { self.didTapInfo() } - - let didTapInfo: () -> Void - - init(didTapInfo: @escaping () -> Void) { - self.didTapInfo = didTapInfo - - super.init(frame: .zero) - setup() - } - - required init?(coder: NSCoder) { nil } - - func alternateFieldsOver(filter: SelectedFilter) { - switch filter { - case .username, .email: - input.isHidden = false - phoneInput.isHidden = true - case .phone: - input.isHidden = true - phoneInput.isHidden = false - } - } - - func select(filter: SelectedFilter) { - [username, email, phone].forEach { $0.style = .unselected } - - switch filter { - case .username: - username.style = .selected - empty.set(filter: Constants.username.lowercased()) - input.makeFirstResponder() - case .email: - email.style = .selected - empty.set(filter: Constants.email.lowercased()) - input.makeFirstResponder() - case .phone: - phone.style = .selected - empty.set(filter: Constants.phone.lowercased()) - phoneInput.makeFirstResponder() - } - } - - // MARK: Private - - private func setup() { - backgroundColor = Asset.neutralWhite.color - - input.setup( - placeholder: Localized.ContactSearch.title, - leftView: .image(Asset.lens.image.withTintColor(Asset.neutralDisabled.color)), - accessibility: Localized.Accessibility.Search.input, - allowsEmptySpace: false, - autocapitalization: .none, - returnKeyType: .search, - clearable: true - ) - - phoneInput.setup( - style: .phone, - placeholder: "1509192596", - rightView: .image(Asset.searchLens.image), - accessibility: Localized.Accessibility.Search.phoneInput, - keyboardType: .numberPad, - contentType: .telephoneNumber, - returnKeyType: .search, - toolbarButtonTitle: Localized.Shared.Search.placeholder, - codeAccessibility: Localized.Accessibility.Search.countryCode - ) - - email.set(title: Constants.email, icon: Asset.searchEmail.image) - phone.set(title: Constants.phone, icon: Asset.searchPhone.image) - username.set(title: Constants.username, icon: Asset.searchUsername.image, style: .selected) - - email.accessibilityIdentifier = Localized.Accessibility.Search.email - phone.accessibilityIdentifier = Localized.Accessibility.Search.phone - username.accessibilityIdentifier = Localized.Accessibility.Search.username - - filters.addArrangedSubview(username) - filters.addArrangedSubview(email) - filters.addArrangedSubview(phone) - filters.distribution = .fillEqually - filters.spacing = 20 - - stack.axis = .vertical - stack.addArrangedSubview(filters) - stack.addArrangedSubview(input) - stack.addArrangedSubview(phoneInput) - - addSubview(stack) - addSubview(empty) - addSubview(placeholder) - - stack.snp.makeConstraints { make in - make.top.equalToSuperview().offset(14) - make.left.equalToSuperview().offset(17) - make.right.equalToSuperview().offset(-17) - } - - placeholder.snp.makeConstraints { make in - make.top.equalTo(stack.snp.bottom) - make.left.bottom.right.equalToSuperview() - } - - empty.snp.makeConstraints { make in - make.top.equalTo(stack.snp.bottom) - make.left.bottom.right.equalToSuperview() - } - } -} diff --git a/Sources/SettingsFeature/Controllers/AccountDeleteController.swift b/Sources/SettingsFeature/Controllers/AccountDeleteController.swift index 7eae36405a3bf673064d10fd743b648d99d0fc28..1dc36e2ef958cc0c7fba6177e621915d96cfc4cc 100644 --- a/Sources/SettingsFeature/Controllers/AccountDeleteController.swift +++ b/Sources/SettingsFeature/Controllers/AccountDeleteController.swift @@ -10,7 +10,7 @@ import DependencyInjection public final class AccountDeleteController: UIViewController { @KeyObject(.username, defaultValue: "") var username: String - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: SettingsCoordinating lazy private var screenView = AccountDeleteView() diff --git a/Sources/SettingsFeature/Controllers/SettingsController.swift b/Sources/SettingsFeature/Controllers/SettingsController.swift index e12674906cde540f35bbff5741f3ec5e816b4507..52e78e17a42c3933f671917aca89b186362f349a 100644 --- a/Sources/SettingsFeature/Controllers/SettingsController.swift +++ b/Sources/SettingsFeature/Controllers/SettingsController.swift @@ -8,7 +8,7 @@ import DependencyInjection import ScrollViewController public final class SettingsController: UIViewController { - @Dependency private var hud: HUDType + @Dependency private var hud: HUD @Dependency private var coordinator: SettingsCoordinating @Dependency private var statusBarController: StatusBarStyleControlling diff --git a/Sources/SettingsFeature/ViewModels/AccountDeleteViewModel.swift b/Sources/SettingsFeature/ViewModels/AccountDeleteViewModel.swift index 0547bbbce7fd175563e525276af29f27c2a2df6c..db7e64016a317b8fe74fa496102437c771f500db 100644 --- a/Sources/SettingsFeature/ViewModels/AccountDeleteViewModel.swift +++ b/Sources/SettingsFeature/ViewModels/AccountDeleteViewModel.swift @@ -17,7 +17,7 @@ final class AccountDeleteViewModel { deleting = true DispatchQueue.main.async { [weak self] in - self?.hudRelay.send(.on(nil)) + self?.hudRelay.send(.on) } do { diff --git a/Sources/SettingsFeature/ViewModels/SettingsViewModel.swift b/Sources/SettingsFeature/ViewModels/SettingsViewModel.swift index 97c146a6aa7645ef12298e8bcc2dae2edf2072e2..9b985922329599ac17010bdd2d16c43dec81a454 100644 --- a/Sources/SettingsFeature/ViewModels/SettingsViewModel.swift +++ b/Sources/SettingsFeature/ViewModels/SettingsViewModel.swift @@ -110,7 +110,7 @@ final class SettingsViewModel { } private func pushNotifications(enable: Bool) { - hudRelay.send(.on(nil)) + hudRelay.send(.on) if enable == true { pushHandler.requestAuthorization { [weak self] result in diff --git a/Sources/Shared/AutoGenerated/Assets.swift b/Sources/Shared/AutoGenerated/Assets.swift index 2b2248fb83ad430ec95d9378a5d5762e8d74c046..6755de526a369e8245365d38a47984ea96a02e42 100644 --- a/Sources/Shared/AutoGenerated/Assets.swift +++ b/Sources/Shared/AutoGenerated/Assets.swift @@ -97,6 +97,7 @@ public enum Asset { 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 restoreSFTP = ImageAsset(name: "restore_SFTP") public static let restoreDrive = ImageAsset(name: "restore_drive") public static let restoreDropbox = ImageAsset(name: "restore_dropbox") public static let restoreIcloud = ImageAsset(name: "restore_icloud") @@ -113,6 +114,10 @@ public enum Asset { public static let searchLens = ImageAsset(name: "search_lens") public static let searchPhone = ImageAsset(name: "search_phone") public static let searchPlaceholderImage = ImageAsset(name: "search_placeholder_image") + public static let searchTabEmail = ImageAsset(name: "search_tab_email") + public static let searchTabPhone = ImageAsset(name: "search_tab_phone") + public static let searchTabQr = ImageAsset(name: "search_tab_qr") + public static let searchTabUsername = ImageAsset(name: "search_tab_username") public static let searchUsername = ImageAsset(name: "search_username") public static let icon32 = ImageAsset(name: "Icon-32") public static let settingsAdvanced = ImageAsset(name: "settings_advanced") diff --git a/Sources/Shared/AutoGenerated/Strings.swift b/Sources/Shared/AutoGenerated/Strings.swift index 07f2cee03ff185b2bd7f9fd768d00758302c74a2..ed861800297b7c53e92a0e83f19aa322de123ec4 100644 --- a/Sources/Shared/AutoGenerated/Strings.swift +++ b/Sources/Shared/AutoGenerated/Strings.swift @@ -211,6 +211,20 @@ public enum Localized { /// Backup not found public static let title = Localized.tr("Localizable", "accountRestore.notFound.title") } + public enum Sftp { + /// Host + public static let host = Localized.tr("Localizable", "accountRestore.sftp.host") + /// Login + public static let login = Localized.tr("Localizable", "accountRestore.sftp.login") + /// Password + public static let password = Localized.tr("Localizable", "accountRestore.sftp.password") + /// Login to your server. Your credentials will be automatically and securely saved locally on your device. + public static let subtitle = Localized.tr("Localizable", "accountRestore.sftp.subtitle") + /// Login to your SFTP + public static let title = Localized.tr("Localizable", "accountRestore.sftp.title") + /// Username + public static let username = Localized.tr("Localizable", "accountRestore.sftp.username") + } public enum Success { /// You now have access to all your contacts. public static let subtitle = Localized.tr("Localizable", "accountRestore.success.subtitle") @@ -236,6 +250,8 @@ public enum Localized { public static let header = Localized.tr("Localizable", "backup.header") /// iCloud public static let iCloud = Localized.tr("Localizable", "backup.iCloud") + /// SFTP + public static let sftp = Localized.tr("Localizable", "backup.SFTP") /// 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 { @@ -414,6 +430,10 @@ public enum Localized { /// Cancel public static let cancel = Localized.tr("Localizable", "chatList.navigationBar.cancel") } + public enum Search { + /// Search chats + public static let title = Localized.tr("Localizable", "chatList.search.title") + } public enum Traffic { /// Not now public static let negative = Localized.tr("Localizable", "chatList.traffic.negative") @@ -534,57 +554,6 @@ public enum Localized { } } - public enum ContactSearch { - /// There are no users with that %@. - public static func noneFound(_ p1: Any) -> String { - return Localized.tr("Localizable", "contactSearch.noneFound", String(describing: p1)) - } - /// User - public static let sectionTitle = Localized.tr("Localizable", "contactSearch.sectionTitle") - /// Search - public static let title = Localized.tr("Localizable", "contactSearch.title") - public enum Filter { - /// Email - public static let email = Localized.tr("Localizable", "contactSearch.filter.email") - /// Phone - public static let phone = Localized.tr("Localizable", "contactSearch.filter.phone") - /// 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 Drawer { - /// Got it - 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.drawer.subtitle") - /// Search - 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 { /// Country Code public static let title = Localized.tr("Localizable", "countries.title") @@ -996,6 +965,14 @@ public enum Localized { public static func resent(_ p1: Any) -> String { return Localized.tr("Localizable", "requests.sent.toast.resent", String(describing: p1)) } + /// Request couldn't be resent to %@ + public static func resentFailed(_ p1: Any) -> String { + return Localized.tr("Localizable", "requests.sent.toast.resentFailed", String(describing: p1)) + } + /// Request successfully sent to %@ + public static func sent(_ p1: Any) -> String { + return Localized.tr("Localizable", "requests.sent.toast.sent", String(describing: p1)) + } } } } @@ -1231,6 +1208,73 @@ public enum Localized { } } + public enum Ud { + /// There are no users with that %@. + public static func noneFound(_ p1: Any) -> String { + return Localized.tr("Localizable", "ud.noneFound", String(describing: p1)) + } + /// Search + public static let title = Localized.tr("Localizable", "ud.title") + public enum NicknameDrawer { + /// Save + public static let save = Localized.tr("Localizable", "ud.nicknameDrawer.save") + /// Edit your new contact’s nickname so you know who they are. + public static let subtitle = Localized.tr("Localizable", "ud.nicknameDrawer.subtitle") + /// Add a nickname + public static let title = Localized.tr("Localizable", "ud.nicknameDrawer.title") + } + public enum Placeholder { + public enum Drawer { + /// Got it + public static let action = Localized.tr("Localizable", "ud.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", "ud.placeholder.drawer.subtitle") + /// Search + public static let title = Localized.tr("Localizable", "ud.placeholder.drawer.title") + } + } + public enum RequestDrawer { + /// Cancel + public static let cancel = Localized.tr("Localizable", "ud.requestDrawer.cancel") + /// EMAIL ADDRESS + public static let email = Localized.tr("Localizable", "ud.requestDrawer.email") + /// PHONE NUMBER + public static let phone = Localized.tr("Localizable", "ud.requestDrawer.phone") + /// Send Contact Request + public static let send = Localized.tr("Localizable", "ud.requestDrawer.send") + /// Request Contact + public static let title = Localized.tr("Localizable", "ud.requestDrawer.title") + } + public enum Search { + /// Cancel search + public static let cancel = Localized.tr("Localizable", "ud.search.cancel") + /// There are no users with that %@. + public static func empty(_ p1: Any) -> String { + return Localized.tr("Localizable", "ud.search.empty", String(describing: p1)) + } + /// Search by %@ + public static func input(_ p1: Any) -> String { + return Localized.tr("Localizable", "ud.search.input", String(describing: p1)) + } + public enum Placeholder { + /// Your searches are anonymous. Search information is never linked to your account or personally identifiable. + public static let subtitle = Localized.tr("Localizable", "ud.search.placeholder.subtitle") + /// Search for #friends# anonymously, add them to your #connections# to start a completely private messaging channel. + public static let title = Localized.tr("Localizable", "ud.search.placeholder.title") + } + } + public enum Tab { + /// Email + public static let email = Localized.tr("Localizable", "ud.tab.email") + /// Phone + public static let phone = Localized.tr("Localizable", "ud.tab.phone") + /// QR Code + public static let qr = Localized.tr("Localizable", "ud.tab.qr") + /// Username + public static let username = Localized.tr("Localizable", "ud.tab.username") + } + } + public enum Validator { public enum Code { /// Code length should be at least 4 chars diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsRestore/restore_SFTP.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsRestore/restore_SFTP.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..5c2f805eb3317d819659b5d73f14b6a1b249804f --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRestore/restore_SFTP.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/AssetsRestore/restore_SFTP.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsRestore/restore_SFTP.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..08fcf11943d754dfc4e7427d290def530dc7dbcb --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsRestore/restore_SFTP.imageset/Icon.pdf @@ -0,0 +1,447 @@ +%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 15.426392 33.381592 cm +0.693500 0.711750 0.730000 scn +23.717567 7.403564 m +1.429677 7.403564 l +0.639939 7.403564 0.000000 6.763626 0.000000 5.973887 c +0.000000 0.000000 l +25.147245 0.000000 l +25.147245 5.973887 l +25.147245 6.763626 24.507305 7.403564 23.717567 7.403564 c +h +3.436188 1.708138 m +2.466323 1.708138 l +2.293242 1.708138 2.152940 1.848440 2.152940 2.021521 c +2.152940 2.194602 2.293242 2.334904 2.466323 2.334904 c +3.436188 2.334904 l +3.609268 2.334904 3.749571 2.194602 3.749571 2.021521 c +3.749265 1.848440 3.608962 1.708138 3.436188 1.708138 c +h +3.436188 3.388399 m +2.466323 3.388399 l +2.293242 3.388399 2.152940 3.528702 2.152940 3.701782 c +2.152940 3.874863 2.293242 4.015165 2.466323 4.015165 c +3.436188 4.015165 l +3.609268 4.015165 3.749571 3.874863 3.749571 3.701782 c +3.749265 3.528702 3.608962 3.388399 3.436188 3.388399 c +h +3.436188 5.069273 m +2.466323 5.069273 l +2.293242 5.069273 2.152940 5.209575 2.152940 5.382656 c +2.152940 5.555737 2.293242 5.696039 2.466323 5.696039 c +3.436188 5.696039 l +3.609268 5.696039 3.749571 5.555737 3.749571 5.382656 c +3.749571 5.209575 3.608962 5.069273 3.436188 5.069273 c +h +6.515186 1.708138 m +5.545321 1.708138 l +5.372241 1.708138 5.231938 1.848440 5.231938 2.021521 c +5.231938 2.194602 5.372241 2.334904 5.545321 2.334904 c +6.515186 2.334904 l +6.688267 2.334904 6.828569 2.194602 6.828569 2.021521 c +6.828263 1.848440 6.687960 1.708138 6.515186 1.708138 c +h +6.515186 3.388399 m +5.545321 3.388399 l +5.372241 3.388399 5.231938 3.528702 5.231938 3.701782 c +5.231938 3.874863 5.372241 4.015165 5.545321 4.015165 c +6.515186 4.015165 l +6.688267 4.015165 6.828569 3.874863 6.828569 3.701782 c +6.828263 3.528702 6.687960 3.388399 6.515186 3.388399 c +h +6.515186 5.069273 m +5.545321 5.069273 l +5.372241 5.069273 5.231938 5.209575 5.231938 5.382656 c +5.231938 5.555737 5.372241 5.696039 5.545321 5.696039 c +6.515186 5.696039 l +6.688267 5.696039 6.828569 5.555737 6.828569 5.382656 c +6.828569 5.209575 6.687960 5.069273 6.515186 5.069273 c +h +9.594184 1.708138 m +8.624320 1.708138 l +8.451240 1.708138 8.310936 1.848440 8.310936 2.021521 c +8.310936 2.194602 8.451240 2.334904 8.624320 2.334904 c +9.594184 2.334904 l +9.767264 2.334904 9.907569 2.194602 9.907569 2.021521 c +9.907262 1.848440 9.766958 1.708138 9.594184 1.708138 c +h +9.594184 3.388399 m +8.624320 3.388399 l +8.451240 3.388399 8.310936 3.528702 8.310936 3.701782 c +8.310936 3.874863 8.451240 4.015165 8.624320 4.015165 c +9.594184 4.015165 l +9.767264 4.015165 9.907569 3.874863 9.907569 3.701782 c +9.907262 3.528702 9.766958 3.388399 9.594184 3.388399 c +h +9.594184 5.069273 m +8.624320 5.069273 l +8.451240 5.069273 8.310936 5.209575 8.310936 5.382656 c +8.310936 5.555737 8.451240 5.696039 8.624320 5.696039 c +9.594184 5.696039 l +9.767264 5.696039 9.907569 5.555737 9.907569 5.382656 c +9.907569 5.209575 9.766958 5.069273 9.594184 5.069273 c +h +12.673183 1.708138 m +11.703625 1.708138 l +11.530544 1.708138 11.390241 1.848440 11.390241 2.021521 c +11.390241 2.194602 11.530544 2.334904 11.703625 2.334904 c +12.673183 2.334904 l +12.846264 2.334904 12.986566 2.194602 12.986566 2.021521 c +12.986259 1.848440 12.845958 1.708138 12.673183 1.708138 c +h +12.673183 3.388399 m +11.703625 3.388399 l +11.530544 3.388399 11.390241 3.528702 11.390241 3.701782 c +11.390241 3.874863 11.530544 4.015165 11.703625 4.015165 c +12.673183 4.015165 l +12.846264 4.015165 12.986566 3.874863 12.986566 3.701782 c +12.986259 3.528702 12.845958 3.388399 12.673183 3.388399 c +h +12.673183 5.069273 m +11.703625 5.069273 l +11.530544 5.069273 11.390241 5.209575 11.390241 5.382656 c +11.390241 5.555737 11.530544 5.696039 11.703625 5.696039 c +12.673183 5.696039 l +12.846264 5.696039 12.986566 5.555737 12.986566 5.382656 c +12.986566 5.209575 12.845958 5.069273 12.673183 5.069273 c +h +16.798935 2.525446 m +16.149193 2.525446 15.622601 3.052040 15.622601 3.701782 c +15.622601 4.351524 16.149193 4.878118 16.798935 4.878118 c +17.448677 4.878118 17.975273 4.351524 17.975273 3.701782 c +17.975273 3.052040 17.448677 2.525446 16.798935 2.525446 c +h +21.504585 2.525446 m +20.854843 2.525446 20.328251 3.052040 20.328251 3.701782 c +20.328251 4.351524 20.854843 4.878118 21.504585 4.878118 c +22.154327 4.878118 22.680923 4.351524 22.680923 3.701782 c +22.680923 3.052040 22.154327 2.525446 21.504585 2.525446 c +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 15.426392 23.298096 cm +0.693500 0.711750 0.730000 scn +0.000000 7.403564 m +0.000000 0.000000 l +25.147245 0.000000 l +25.147245 7.403564 l +0.000000 7.403564 l +h +3.436188 1.707832 m +2.466323 1.707832 l +2.293242 1.707832 2.152940 1.847827 2.152940 2.021214 c +2.152940 2.194602 2.293242 2.334598 2.466323 2.334598 c +3.436188 2.334598 l +3.609268 2.334598 3.749571 2.194602 3.749571 2.021214 c +3.749571 1.847827 3.608962 1.707832 3.436188 1.707832 c +h +3.436188 3.388399 m +2.466323 3.388399 l +2.293242 3.388399 2.152940 3.528702 2.152940 3.701782 c +2.152940 3.874863 2.293242 4.015165 2.466323 4.015165 c +3.436188 4.015165 l +3.609268 4.015165 3.749571 3.874863 3.749571 3.701782 c +3.749265 3.528702 3.608962 3.388399 3.436188 3.388399 c +h +3.436188 5.068967 m +2.466323 5.068967 l +2.293242 5.068967 2.152940 5.209269 2.152940 5.382350 c +2.152940 5.555430 2.293242 5.695733 2.466323 5.695733 c +3.436188 5.695733 l +3.609268 5.695733 3.749571 5.555430 3.749571 5.382350 c +3.749265 5.209269 3.608962 5.068967 3.436188 5.068967 c +h +6.515186 1.707832 m +5.545321 1.707832 l +5.372241 1.707832 5.231938 1.847827 5.231938 2.021214 c +5.231938 2.194602 5.372241 2.334598 5.545321 2.334598 c +6.515186 2.334598 l +6.688267 2.334598 6.828569 2.194602 6.828569 2.021214 c +6.828569 1.847827 6.687960 1.707832 6.515186 1.707832 c +h +6.515186 3.388399 m +5.545321 3.388399 l +5.372241 3.388399 5.231938 3.528702 5.231938 3.701782 c +5.231938 3.874863 5.372241 4.015165 5.545321 4.015165 c +6.515186 4.015165 l +6.688267 4.015165 6.828569 3.874863 6.828569 3.701782 c +6.828263 3.528702 6.687960 3.388399 6.515186 3.388399 c +h +6.515186 5.068967 m +5.545321 5.068967 l +5.372241 5.068967 5.231938 5.209269 5.231938 5.382350 c +5.231938 5.555430 5.372241 5.695733 5.545321 5.695733 c +6.515186 5.695733 l +6.688267 5.695733 6.828569 5.555430 6.828569 5.382350 c +6.828263 5.209269 6.687960 5.068967 6.515186 5.068967 c +h +9.594184 1.707832 m +8.624320 1.707832 l +8.451240 1.707832 8.310936 1.847827 8.310936 2.021214 c +8.310936 2.194602 8.451240 2.334598 8.624320 2.334598 c +9.594184 2.334598 l +9.767264 2.334598 9.907569 2.194602 9.907569 2.021214 c +9.907569 1.847827 9.766958 1.707832 9.594184 1.707832 c +h +9.594184 3.388399 m +8.624320 3.388399 l +8.451240 3.388399 8.310936 3.528702 8.310936 3.701782 c +8.310936 3.874863 8.451240 4.015165 8.624320 4.015165 c +9.594184 4.015165 l +9.767264 4.015165 9.907569 3.874863 9.907569 3.701782 c +9.907262 3.528702 9.766958 3.388399 9.594184 3.388399 c +h +9.594184 5.068967 m +8.624320 5.068967 l +8.451240 5.068967 8.310936 5.209269 8.310936 5.382350 c +8.310936 5.555430 8.451240 5.695733 8.624320 5.695733 c +9.594184 5.695733 l +9.767264 5.695733 9.907569 5.555430 9.907569 5.382350 c +9.907262 5.209269 9.766958 5.068967 9.594184 5.068967 c +h +12.673183 1.707832 m +11.703625 1.707832 l +11.530544 1.707832 11.390241 1.847827 11.390241 2.021214 c +11.390241 2.194602 11.530544 2.334598 11.703625 2.334598 c +12.673183 2.334598 l +12.846264 2.334598 12.986566 2.194602 12.986566 2.021214 c +12.986566 1.847827 12.845958 1.707832 12.673183 1.707832 c +h +12.673183 3.388399 m +11.703625 3.388399 l +11.530544 3.388399 11.390241 3.528702 11.390241 3.701782 c +11.390241 3.874863 11.530544 4.015165 11.703625 4.015165 c +12.673183 4.015165 l +12.846264 4.015165 12.986566 3.874863 12.986566 3.701782 c +12.986259 3.528702 12.845958 3.388399 12.673183 3.388399 c +h +12.673183 5.068967 m +11.703625 5.068967 l +11.530544 5.068967 11.390241 5.209269 11.390241 5.382350 c +11.390241 5.555430 11.530544 5.695733 11.703625 5.695733 c +12.673183 5.695733 l +12.846264 5.695733 12.986566 5.555430 12.986566 5.382350 c +12.986259 5.209269 12.845958 5.068967 12.673183 5.068967 c +h +16.798935 2.525446 m +16.149193 2.525446 15.622601 3.052040 15.622601 3.701782 c +15.622601 4.351524 16.149193 4.878119 16.798935 4.878119 c +17.448677 4.878119 17.975273 4.351524 17.975273 3.701782 c +17.975273 3.052040 17.448677 2.525446 16.798935 2.525446 c +h +21.504585 2.525446 m +20.854843 2.525446 20.328251 3.052040 20.328251 3.701782 c +20.328251 4.351524 20.854843 4.878119 21.504585 4.878119 c +22.154327 4.878119 22.680923 4.351524 22.680923 3.701782 c +22.680923 3.052040 22.154327 2.525446 21.504585 2.525446 c +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 15.426392 13.214844 cm +0.693500 0.711750 0.730000 scn +0.000000 7.403564 m +0.000000 1.429677 l +0.000000 0.639939 0.639939 0.000000 1.429677 0.000000 c +23.717876 0.000000 l +24.507307 0.000000 25.147552 0.639939 25.147552 1.429677 c +25.147552 7.403564 l +0.000000 7.403564 l +h +3.436188 1.707830 m +2.466323 1.707830 l +2.293243 1.707830 2.152940 1.847827 2.152940 2.021214 c +2.152940 2.194295 2.293243 2.334599 2.466323 2.334599 c +3.436188 2.334599 l +3.609269 2.334599 3.749571 2.194602 3.749571 2.021214 c +3.749265 1.848134 3.608963 1.707830 3.436188 1.707830 c +h +3.436188 3.388398 m +2.466323 3.388398 l +2.293243 3.388398 2.152940 3.528395 2.152940 3.701782 c +2.152940 3.875169 2.293243 4.015166 2.466323 4.015166 c +3.436188 4.015166 l +3.609269 4.015166 3.749571 3.875169 3.749571 3.701782 c +3.749571 3.528395 3.608963 3.388398 3.436188 3.388398 c +h +3.436188 5.068966 m +2.466323 5.068966 l +2.293243 5.068966 2.152940 5.208962 2.152940 5.382350 c +2.152940 5.555430 2.293243 5.695734 2.466323 5.695734 c +3.436188 5.695734 l +3.609269 5.695734 3.749571 5.555737 3.749571 5.382350 c +3.749265 5.209269 3.608963 5.068966 3.436188 5.068966 c +h +6.515187 1.707830 m +5.545322 1.707830 l +5.372241 1.707830 5.231939 1.847827 5.231939 2.021214 c +5.231939 2.194295 5.372241 2.334599 5.545322 2.334599 c +6.515187 2.334599 l +6.688267 2.334599 6.828570 2.194602 6.828570 2.021214 c +6.828264 1.848134 6.687961 1.707830 6.515187 1.707830 c +h +6.515187 3.388398 m +5.545322 3.388398 l +5.372241 3.388398 5.231939 3.528395 5.231939 3.701782 c +5.231939 3.875169 5.372241 4.015166 5.545322 4.015166 c +6.515187 4.015166 l +6.688267 4.015166 6.828570 3.875169 6.828570 3.701782 c +6.828570 3.528395 6.687961 3.388398 6.515187 3.388398 c +h +6.515187 5.068966 m +5.545322 5.068966 l +5.372241 5.068966 5.231939 5.208962 5.231939 5.382350 c +5.231939 5.555430 5.372241 5.695734 5.545322 5.695734 c +6.515187 5.695734 l +6.688267 5.695734 6.828570 5.555737 6.828570 5.382350 c +6.828264 5.209269 6.687961 5.068966 6.515187 5.068966 c +h +9.594185 1.707830 m +8.624321 1.707830 l +8.451241 1.707830 8.310937 1.847827 8.310937 2.021214 c +8.310937 2.194295 8.451241 2.334599 8.624321 2.334599 c +9.594185 2.334599 l +9.767265 2.334599 9.907570 2.194602 9.907570 2.021214 c +9.907263 1.848134 9.766959 1.707830 9.594185 1.707830 c +h +9.594185 3.388398 m +8.624321 3.388398 l +8.451241 3.388398 8.310937 3.528395 8.310937 3.701782 c +8.310937 3.875169 8.451241 4.015166 8.624321 4.015166 c +9.594185 4.015166 l +9.767265 4.015166 9.907570 3.875169 9.907570 3.701782 c +9.907570 3.528395 9.766959 3.388398 9.594185 3.388398 c +h +9.594185 5.068966 m +8.624321 5.068966 l +8.451241 5.068966 8.310937 5.208962 8.310937 5.382350 c +8.310937 5.555430 8.451241 5.695734 8.624321 5.695734 c +9.594185 5.695734 l +9.767265 5.695734 9.907570 5.555737 9.907570 5.382350 c +9.907263 5.209269 9.766959 5.068966 9.594185 5.068966 c +h +12.673184 1.707830 m +11.703626 1.707830 l +11.530545 1.707830 11.390242 1.847827 11.390242 2.021214 c +11.390242 2.194295 11.530545 2.334599 11.703626 2.334599 c +12.673184 2.334599 l +12.846266 2.334599 12.986567 2.194602 12.986567 2.021214 c +12.986260 1.848134 12.845959 1.707830 12.673184 1.707830 c +h +12.673184 3.388398 m +11.703626 3.388398 l +11.530545 3.388398 11.390242 3.528395 11.390242 3.701782 c +11.390242 3.875169 11.530545 4.015166 11.703626 4.015166 c +12.673184 4.015166 l +12.846266 4.015166 12.986567 3.875169 12.986567 3.701782 c +12.986567 3.528395 12.845959 3.388398 12.673184 3.388398 c +h +12.673184 5.068966 m +11.703626 5.068966 l +11.530545 5.068966 11.390242 5.208962 11.390242 5.382350 c +11.390242 5.555430 11.530545 5.695734 11.703626 5.695734 c +12.673184 5.695734 l +12.846266 5.695734 12.986567 5.555737 12.986567 5.382350 c +12.986260 5.209269 12.845959 5.068966 12.673184 5.068966 c +h +16.798937 2.525447 m +16.149195 2.525447 15.622602 3.052040 15.622602 3.701782 c +15.622602 4.351524 16.149195 4.878119 16.798937 4.878119 c +17.448679 4.878119 17.975275 4.351524 17.975275 3.701782 c +17.975275 3.052040 17.448679 2.525447 16.798937 2.525447 c +h +21.504587 2.525447 m +20.854845 2.525447 20.328253 3.052040 20.328253 3.701782 c +20.328253 4.351524 20.854845 4.878119 21.504587 4.878119 c +22.154329 4.878119 22.680925 4.351524 22.680925 3.701782 c +22.680925 3.052040 22.154329 2.525447 21.504587 2.525447 c +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 17.400757 21.498291 cm +0.693500 0.711750 0.730000 scn +21.201620 0.918945 m +0.000000 0.918945 l +0.000000 -0.000067 l +21.201620 -0.000067 l +21.201620 0.918945 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 17.400757 31.582764 cm +0.693500 0.711750 0.730000 scn +21.201620 0.918945 m +0.000000 0.918945 l +0.000000 -0.000067 l +21.201620 -0.000067 l +21.201620 0.918945 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 13265 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 56.000000 56.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 +0000013355 00000 n +0000013379 00000 n +0000013552 00000 n +0000013626 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +13685 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_email.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_email.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..d9a97df899595ebc48148baa9cbedb9be8f763fa --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_email.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/AssetsSearch/search_tab_email.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_email.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..488ddd5591377db8b2e8c7e3199c76dfe643394f --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_email.imageset/Icon.pdf @@ -0,0 +1,87 @@ +%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.809948 0.827749 0.850000 scn +16.199999 0.000879 m +1.800000 0.000879 l +0.805887 0.000879 0.000000 0.806765 0.000000 1.800879 c +0.000000 12.679177 l +0.041948 13.642586 0.835680 14.401790 1.800000 14.400878 c +16.199999 14.400878 l +17.194113 14.400878 18.000000 13.594990 18.000000 12.600878 c +18.000000 1.800879 l +18.000000 0.806765 17.194113 0.000879 16.199999 0.000879 c +h +1.800000 10.919678 m +1.800000 1.800878 l +16.199999 1.800878 l +16.199999 10.919678 l +9.000000 6.120877 l +1.800000 10.919678 l +h +2.520000 12.600878 m +9.000000 8.280877 l +15.480000 12.600878 l +2.520000 12.600878 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 681 +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 +0000000771 00000 n +0000000793 00000 n +0000000966 00000 n +0000001040 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1099 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_phone.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_phone.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..d9a97df899595ebc48148baa9cbedb9be8f763fa --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_phone.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/AssetsSearch/search_tab_phone.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_phone.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e53e9789a38f9a7ffb48759a3c5c0e8c23dbd900 --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_phone.imageset/Icon.pdf @@ -0,0 +1,202 @@ +%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.831543 cm +0.809948 0.827749 0.850000 scn +5.310195 13.179303 m +4.370402 13.521046 l +4.207593 13.073322 l +4.452703 12.664807 l +5.310195 13.179303 l +h +5.934925 14.897307 m +5.006448 15.268698 l +5.000554 15.253963 l +4.995131 15.239050 l +5.934925 14.897307 l +h +6.052061 16.107719 m +7.032641 16.303835 l +7.027975 16.327169 l +7.022203 16.350256 l +6.052061 16.107719 l +h +5.036876 20.168457 m +6.007019 20.410994 l +5.787323 21.289776 l +4.891161 21.157784 l +5.036876 20.168457 l +h +0.000000 19.426592 m +-0.145715 20.415918 l +-1.053928 20.282150 l +-0.998153 19.365835 l +0.000000 19.426592 l +h +5.310195 7.517696 m +4.598751 6.814928 l +4.603089 6.810590 l +5.310195 7.517696 l +h +17.414316 2.168457 m +17.363102 1.169769 l +18.311787 1.121119 l +18.409060 2.066057 l +17.414316 2.168457 l +h +17.960955 7.478652 m +18.955698 7.376251 l +19.046246 8.255862 l +18.184332 8.453384 l +17.960955 7.478652 l +h +14.212583 8.337654 m +14.435959 9.312387 l +14.413817 9.317461 l +14.391468 9.321525 l +14.212583 8.337654 l +h +12.963124 8.181472 m +13.363943 7.265314 l +13.387419 7.275585 l +13.410338 7.287045 l +12.963124 8.181472 l +h +11.088938 7.361515 m +10.574442 6.504023 l +11.016961 6.238511 l +11.489756 6.445358 l +11.088938 7.361515 l +h +6.249989 12.837560 m +6.874718 14.555564 l +4.995131 15.239050 l +4.370402 13.521046 l +6.249989 12.837560 l +h +6.863401 14.525917 m +7.089864 15.092072 7.149697 15.718555 7.032641 16.303835 c +5.071480 15.911604 l +5.110607 15.715972 5.092350 15.483454 5.006448 15.268698 c +6.863401 14.525917 l +h +7.022203 16.350256 m +6.007019 20.410994 l +4.066734 19.925920 l +5.081918 15.865184 l +7.022203 16.350256 l +h +4.891161 21.157784 m +-0.145715 20.415918 l +0.145715 18.437265 l +5.182591 19.179131 l +4.891161 21.157784 l +h +-0.998153 19.365835 m +-0.710203 14.635235 1.265032 10.189831 4.598764 6.814941 c +6.021627 8.220452 l +3.029979 11.249034 1.256841 15.237471 0.998153 19.487349 c +-0.998153 19.365835 l +h +4.603089 6.810590 m +8.022314 3.391365 12.552481 1.416468 17.363102 1.169769 c +17.465532 3.167145 l +13.139493 3.388992 9.079639 5.162467 6.017303 8.224804 c +4.603089 6.810590 l +h +18.409060 2.066057 m +18.955698 7.376251 l +16.966211 7.581052 l +16.419573 2.270857 l +18.409060 2.066057 l +h +18.184332 8.453384 m +14.435959 9.312387 l +13.989206 7.362922 l +17.737579 6.503920 l +18.184332 8.453384 l +h +14.391468 9.321525 m +13.786641 9.431493 13.131458 9.383673 12.515911 9.075899 c +13.410338 7.287045 l +13.575702 7.369726 13.779521 7.399999 14.033697 7.353785 c +14.391468 9.321525 l +h +12.562305 9.097629 m +10.688119 8.277673 l +11.489756 6.445358 l +13.363943 7.265314 l +12.562305 9.097629 l +h +11.603433 8.219008 m +10.504310 8.878483 9.475373 9.686473 8.555264 10.606584 c +7.141050 9.192369 l +8.173217 8.160202 9.330832 7.250189 10.574442 6.504023 c +11.603433 8.219008 l +h +8.555264 10.606584 m +7.605314 11.556533 6.834208 12.582933 6.167688 13.693798 c +4.452703 12.664807 l +5.191823 11.432940 6.060631 10.272789 7.141050 9.192369 c +8.555264 10.606584 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 3012 +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 +0000003102 00000 n +0000003125 00000 n +0000003298 00000 n +0000003372 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +3431 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_qr.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_qr.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..d9a97df899595ebc48148baa9cbedb9be8f763fa --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_qr.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/AssetsSearch/search_tab_qr.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_qr.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8ff4fc0bf67f1a731cc8516ce77a2de344bc843c --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_qr.imageset/Icon.pdf @@ -0,0 +1,189 @@ +%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 5.000000 12.871094 cm +0.809948 0.827749 0.850000 scn +5.685554 6.128906 m +0.435554 6.128906 l +0.378356 6.128906 0.321724 6.117636 0.268880 6.095747 c +0.216036 6.073858 0.168026 6.041771 0.127580 6.001326 c +0.087135 5.960881 0.055048 5.912870 0.033159 5.860026 c +0.011270 5.807182 0.000000 5.750551 0.000000 5.693353 c +0.000000 0.443353 l +-0.000018 0.327108 0.045616 0.215499 0.127082 0.132578 c +0.208548 0.049657 0.319328 0.002053 0.435554 0.000013 c +5.685554 0.000013 l +5.802508 0.002013 5.914114 0.049368 5.996826 0.132080 c +6.079538 0.214792 6.126893 0.326398 6.128893 0.443353 c +6.128893 5.693353 l +6.126853 5.809579 6.079249 5.920358 5.996328 6.001824 c +5.913407 6.083291 5.801798 6.128924 5.685554 6.128906 c +h +4.371108 1.757799 m +1.750000 1.757799 l +1.750000 4.378906 l +4.371108 4.378906 l +4.371108 1.757799 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 5.000000 5.000000 cm +0.809948 0.827749 0.850000 scn +5.685554 6.128906 m +0.435554 6.128906 l +0.319328 6.126867 0.208548 6.079262 0.127082 5.996341 c +0.045616 5.913420 -0.000018 5.801811 0.000000 5.685567 c +0.000000 0.435567 l +0.000000 0.378369 0.011270 0.321738 0.033159 0.268894 c +0.055048 0.216050 0.087135 0.168039 0.127580 0.127594 c +0.168026 0.087149 0.216036 0.055061 0.268880 0.033173 c +0.321724 0.011284 0.378356 0.000013 0.435554 0.000013 c +5.685554 0.000013 l +5.801798 -0.000004 5.913407 0.045629 5.996328 0.127095 c +6.079249 0.208562 6.126853 0.319341 6.128893 0.435567 c +6.128893 5.685567 l +6.126893 5.802522 6.079538 5.914128 5.996826 5.996840 c +5.914114 6.079551 5.802508 6.126906 5.685554 6.128906 c +h +4.371108 1.750013 m +1.750000 1.750013 l +1.750000 4.371121 l +4.371108 4.371121 l +4.371108 1.750013 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 12.871094 12.871094 cm +0.809948 0.827749 0.850000 scn +5.693339 6.128906 m +0.443339 6.128906 l +0.327095 6.128924 0.215486 6.083291 0.132565 6.001824 c +0.049644 5.920358 0.002039 5.809579 0.000000 5.693353 c +0.000000 0.443353 l +0.002000 0.326398 0.049355 0.214792 0.132067 0.132080 c +0.214778 0.049368 0.326384 0.002013 0.443339 0.000013 c +5.693339 0.000013 l +5.809565 0.002053 5.920344 0.049657 6.001811 0.132578 c +6.083277 0.215499 6.128911 0.327108 6.128893 0.443353 c +6.128893 5.693353 l +6.128893 5.808869 6.082995 5.919643 6.001312 6.001326 c +5.919630 6.083008 5.808856 6.128906 5.693339 6.128906 c +h +4.378893 1.757799 m +1.757785 1.757799 l +1.757785 4.378906 l +4.378893 4.378906 l +4.378893 1.757799 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 12.871094 8.500000 cm +0.809948 0.827749 0.850000 scn +0.443339 0.000013 m +0.327095 -0.000005 0.215486 0.045629 0.132565 0.127095 c +0.049644 0.208562 0.002039 0.319341 0.000000 0.435567 c +0.000000 2.185567 l +0.002000 2.302522 0.049355 2.414128 0.132067 2.496840 c +0.214778 2.579551 0.326384 2.626907 0.443339 2.628906 c +2.193339 2.628906 l +2.309565 2.626867 2.420345 2.579262 2.501811 2.496341 c +2.583277 2.413420 2.628911 2.301811 2.628893 2.185567 c +2.628893 0.435567 l +2.628893 0.320050 2.582995 0.209276 2.501312 0.127594 c +2.419630 0.045911 2.308856 0.000013 2.193339 0.000013 c +0.443339 0.000013 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 12.871094 5.000000 cm +0.809948 0.827749 0.850000 scn +5.693339 6.128906 m +4.814446 6.128906 l +4.698220 6.126867 4.587441 6.079262 4.505975 5.996341 c +4.424509 5.913420 4.378875 5.801811 4.378893 5.685567 c +4.378893 3.500014 l +3.064446 3.500014 l +2.948930 3.500014 2.838156 3.454116 2.756473 3.372433 c +2.674791 3.290751 2.628893 3.179976 2.628893 3.064460 c +2.628893 1.750013 l +0.443339 1.750013 l +0.327095 1.750031 0.215486 1.704398 0.132565 1.622931 c +0.049644 1.541465 0.002039 1.430686 0.000000 1.314460 c +0.000000 0.435567 l +0.002039 0.319341 0.049644 0.208562 0.132565 0.127095 c +0.215486 0.045629 0.327095 -0.000004 0.443339 0.000013 c +5.693339 0.000013 l +5.808856 0.000013 5.919630 0.045911 6.001312 0.127594 c +6.082995 0.209276 6.128893 0.320051 6.128893 0.435567 c +6.128893 5.685567 l +6.128911 5.801811 6.083277 5.913420 6.001811 5.996341 c +5.920344 6.079262 5.809565 6.126867 5.693339 6.128906 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 4107 +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 +0000004197 00000 n +0000004220 00000 n +0000004393 00000 n +0000004467 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +4526 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_username.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_username.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..d9a97df899595ebc48148baa9cbedb9be8f763fa --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_username.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/AssetsSearch/search_tab_username.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_username.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5f75d04ba8fcb3c602e0eb08226b68a3e6cc3d1c --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsSearch/search_tab_username.imageset/Icon.pdf @@ -0,0 +1,95 @@ +%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.000000 11.000000 cm +0.809948 0.827749 0.850000 scn +3.000000 5.000000 m +3.000000 7.761424 5.238576 10.000000 8.000000 10.000000 c +10.761423 10.000000 13.000000 7.761424 13.000000 5.000000 c +13.000000 2.238576 10.761423 0.000000 8.000000 0.000000 c +5.238576 0.000000 3.000000 2.238576 3.000000 5.000000 c +h +8.000000 2.000000 m +9.656855 2.000000 11.000000 3.343146 11.000000 5.000000 c +11.000000 6.656854 9.656855 8.000000 8.000000 8.000000 c +6.343146 8.000000 5.000000 6.656854 5.000000 5.000000 c +5.000000 3.343146 6.343146 2.000000 8.000000 2.000000 c +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 4.000000 13.000000 cm +0.809948 0.827749 0.850000 scn +2.343146 -5.343145 m +0.842855 -6.843436 0.000000 -8.878267 0.000000 -11.000000 c +2.000000 -11.000000 l +2.000000 -9.408701 2.632141 -7.882577 3.757360 -6.757359 c +4.882578 -5.632140 6.408701 -5.000000 8.000000 -5.000000 c +9.591299 -5.000000 11.117423 -5.632140 12.242641 -6.757359 c +13.367860 -7.882577 14.000000 -9.408701 14.000000 -11.000000 c +16.000000 -11.000000 l +16.000000 -8.878267 15.157146 -6.843436 13.656855 -5.343145 c +12.156564 -3.842854 10.121732 -3.000000 8.000000 -3.000000 c +5.878268 -3.000000 3.843437 -3.842854 2.343146 -5.343145 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 1279 +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 +0000001369 00000 n +0000001392 00000 n +0000001565 00000 n +0000001639 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1698 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/en.lproj/Localizable.strings b/Sources/Shared/Resources/en.lproj/Localizable.strings index aebff7b7bcdb9b2c872577060b8f9244300baa90..dd95d4002c125c9caa98007bf7074c172bf5aaf8 100644 --- a/Sources/Shared/Resources/en.lproj/Localizable.strings +++ b/Sources/Shared/Resources/en.lproj/Localizable.strings @@ -25,6 +25,8 @@ // ChatListFeature +"chatList.search.title" += "Search chats"; "chatList.navigationBar.cancel" = "Cancel"; "chatList.title" @@ -351,8 +353,12 @@ = "Search for connections"; "requests.sent.empty" = "You haven't sent any requests"; +"requests.sent.toast.sent" += "Request successfully sent to %@"; "requests.sent.toast.resent" = "Request successfully resent to %@"; +"requests.sent.toast.resentFailed" += "Request couldn't be resent to %@"; // RequestsFeature - Failed @@ -629,6 +635,8 @@ = "Dropbox"; "backup.googleDrive" = "Google Drive"; +"backup.SFTP" += "SFTP"; // Settings - Delete Account @@ -838,6 +846,19 @@ "accountRestore.list.cancel" = "Cancel"; +"accountRestore.sftp.title" += "Login to your SFTP"; +"accountRestore.sftp.subtitle" += "Login to your server. Your credentials will be automatically and securely saved locally on your device."; +"accountRestore.sftp.host" += "Host"; +"accountRestore.sftp.username" += "Username"; +"accountRestore.sftp.password" += "Password"; +"accountRestore.sftp.login" += "Login"; + "accountRestore.header" = "Account restore"; "accountRestore.found.title" @@ -931,43 +952,53 @@ // SearchFeature -"contactSearch.title" +"ud.title" = "Search"; -"contactSearch.placeholder.title" -= "Searching is private by nature. The network cannot identify who a search request came from."; -"contactSearch.placeholder.drawer.title" +"ud.tab.username" += "Username"; +"ud.tab.email" += "Email"; +"ud.tab.phone" += "Phone"; +"ud.tab.qr" += "QR Code"; +"ud.placeholder.drawer.title" = "Search"; -"contactSearch.placeholder.drawer.subtitle" +"ud.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.drawer.action" +"ud.placeholder.drawer.action" = "Got it"; -"contactSearch.filter.phone" -= "Phone"; -"contactSearch.filter.email" -= "Email"; -"contactSearch.filter.username" -= "Username"; -"contactSearch.sectionTitle" -= "User"; -"contactSearch.noneFound" +"ud.noneFound" = "There are no users with that %@."; -"contactSearch.requestDrawer.title" +"ud.requestDrawer.title" = "Request Contact"; -"contactSearch.requestDrawer.email" +"ud.requestDrawer.email" = "EMAIL ADDRESS"; -"contactSearch.requestDrawer.phone" +"ud.requestDrawer.phone" = "PHONE NUMBER"; -"contactSearch.requestDrawer.send" +"ud.requestDrawer.send" = "Send Contact Request"; -"contactSearch.requestDrawer.cancel" +"ud.requestDrawer.cancel" = "Cancel"; -"contactSearch.nicknameDrawer.title" +"ud.nicknameDrawer.title" = "Add a nickname"; -"contactSearch.nicknameDrawer.subtitle" +"ud.nicknameDrawer.subtitle" = "Edit your new contact’s nickname so you know who they are."; -"contactSearch.nicknameDrawer.save" +"ud.nicknameDrawer.save" = "Save"; +"ud.search.input" += "Search by %@"; +"ud.search.empty" += "There are no users with that %@."; +"ud.search.cancel" += "Cancel search"; + +"ud.search.placeholder.title" += "Search for #friends# anonymously, add them to your #connections# to start a completely private messaging channel."; +"ud.search.placeholder.subtitle" += "Your searches are anonymous. Search information is never linked to your account or personally identifiable."; + // LaunchFeature "launch.version.failed" diff --git a/Sources/Shared/Views/AvatarCell.swift b/Sources/Shared/Views/AvatarCell.swift new file mode 100644 index 0000000000000000000000000000000000000000..43f0e3fd6686464b6d66c31770627134e9c075c0 --- /dev/null +++ b/Sources/Shared/Views/AvatarCell.swift @@ -0,0 +1,187 @@ +import UIKit +import Combine + +final class AvatarCellButton: 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 } +} + +public final class AvatarCell: UITableViewCell { + let h1Label = UILabel() + let h2Label = UILabel() + let h3Label = UILabel() + let h4Label = UILabel() + let separatorView = UIView() + let avatarView = AvatarView() + let stackView = UIStackView() + let stateButton = AvatarCellButton() + + var cancellables = Set<AnyCancellable>() + public var didTapStateButton: (() -> Void)! + + public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + selectedBackgroundView = UIView() + multipleSelectionBackgroundView = UIView() + backgroundColor = Asset.neutralWhite.color + + h1Label.textColor = Asset.neutralActive.color + h2Label.textColor = Asset.neutralSecondaryAlternative.color + h3Label.textColor = Asset.neutralSecondaryAlternative.color + h4Label.textColor = Asset.neutralSecondaryAlternative.color + + h1Label.font = Fonts.Mulish.semiBold.font(size: 14.0) + h2Label.font = Fonts.Mulish.regular.font(size: 14.0) + h3Label.font = Fonts.Mulish.regular.font(size: 14.0) + h4Label.font = Fonts.Mulish.regular.font(size: 14.0) + + stackView.spacing = 4 + stackView.axis = .vertical + + stackView.addArrangedSubview(h1Label) + stackView.addArrangedSubview(h2Label) + stackView.addArrangedSubview(h3Label) + stackView.addArrangedSubview(h4Label) + + separatorView.backgroundColor = Asset.neutralLine.color + + contentView.addSubview(stackView) + contentView.addSubview(avatarView) + contentView.addSubview(stateButton) + contentView.addSubview(separatorView) + + setupConstraints() + } + + required init?(coder: NSCoder) { nil } + + public override func prepareForReuse() { + super.prepareForReuse() + h1Label.text = nil + h2Label.text = nil + h3Label.text = nil + h4Label.text = nil + + stateButton.imageView.image = nil + stateButton.titleLabel.text = nil + + avatarView.prepareForReuse() + cancellables.removeAll() + } + + public func setup( + title: String, + image: Data?, + firstSubtitle: String? = nil, + secondSubtitle: String? = nil, + thirdSubtitle: String? = nil, + showSeparator: Bool = true, + sent: Bool = false + ) { + h1Label.text = title + + if let firstSubtitle = firstSubtitle { + h2Label.isHidden = false + h2Label.text = firstSubtitle + } else { + h2Label.isHidden = true + } + + if let secondSubtitle = secondSubtitle { + h3Label.isHidden = false + h3Label.text = secondSubtitle + } else { + h3Label.isHidden = true + } + + if let thirdSubtitle = thirdSubtitle { + h4Label.isHidden = false + h4Label.text = thirdSubtitle + } else { + h4Label.isHidden = true + } + + avatarView.setupProfile(title: title, image: image, size: .medium) + separatorView.alpha = showSeparator ? 1.0 : 0.0 + + cancellables.removeAll() + + if sent { + stateButton.imageView.image = Asset.requestsResend.image + stateButton.titleLabel.text = Localized.Requests.Cell.requested + stateButton.titleLabel.textColor = Asset.brandPrimary.color + + stateButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in didTapStateButton() } + .store(in: &cancellables) + } + } + + public func updateToResent() { + stateButton.imageView.image = Asset.requestsResent.image + stateButton.titleLabel.text = Localized.Requests.Cell.resent + stateButton.titleLabel.textColor = Asset.neutralWeak.color + + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } + + private func setupConstraints() { + avatarView.snp.makeConstraints { + $0.width.height.equalTo(36) + $0.left.equalToSuperview().offset(27) + $0.centerY.equalToSuperview() + } + + stackView.snp.makeConstraints { + $0.top.equalTo(avatarView) + $0.left.equalTo(avatarView.snp.right).offset(14) + $0.right.lessThanOrEqualToSuperview().offset(-10) + $0.bottom.greaterThanOrEqualTo(avatarView) + $0.bottom.lessThanOrEqualToSuperview() + } + + separatorView.snp.makeConstraints { + $0.height.equalTo(1) + $0.top.greaterThanOrEqualTo(stackView.snp.bottom).offset(10) + $0.left.equalToSuperview().offset(25) + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + } + + stateButton.snp.makeConstraints { + $0.centerY.equalTo(stackView) + $0.right.equalToSuperview().offset(-24) + } + } +} diff --git a/Sources/Shared/Views/SearchComponent.swift b/Sources/Shared/Views/SearchComponent.swift index 2e87cfbf6dceddcb50009a78b41fd2c0aa598ea5..9608aad71aaa56a5da852516405d069837a703c5 100644 --- a/Sources/Shared/Views/SearchComponent.swift +++ b/Sources/Shared/Views/SearchComponent.swift @@ -15,6 +15,10 @@ public final class SearchComponent: UIView { textSubject.eraseToAnyPublisher() } + public var returnPublisher: AnyPublisher<Void, Never> { + returnSubject.eraseToAnyPublisher() + } + private var rightImage = Asset.sharedScan.image { didSet { rightButton.setImage(rightImage, for: .normal) @@ -26,13 +30,59 @@ public final class SearchComponent: UIView { } private var cancellables = Set<AnyCancellable>() - private var rightSubject = PassthroughSubject<Void, Never>() - private var textSubject = PassthroughSubject<String, Never>() - private var isEditingSubject = CurrentValueSubject<Bool, Never>(false) + private let rightSubject = PassthroughSubject<Void, Never>() + private let textSubject = PassthroughSubject<String, Never>() + private let returnSubject = PassthroughSubject<Void, Never>() + private let isEditingSubject = CurrentValueSubject<Bool, Never>(false) public init() { super.init(frame: .zero) - setup() + + containerView.layer.cornerRadius = 25 + containerView.backgroundColor = Asset.neutralSecondary.color + + leftImageView.image = Asset.lens.image + leftImageView.contentMode = .center + leftImageView.tintColor = Asset.neutralDisabled.color + + rightButton.tintColor = Asset.neutralBody.color + rightButton.setImage(rightImage, for: .normal) + rightButton.setContentHuggingPriority(.required, for: .horizontal) + rightButton.setContentCompressionResistancePriority(.required, for: .horizontal) + + inputField.delegate = self + inputField.textColor = Asset.neutralActive.color + inputField.font = Fonts.Mulish.regular.font(size: 16.0) + + let attrPlaceholder + = NSAttributedString( + string: Localized.Shared.Search.placeholder, + attributes: [ + .font: Fonts.Mulish.regular.font(size: 14.0) as Any, + .foregroundColor: Asset.neutralWeak.color + ]) + + inputField.attributedPlaceholder = attrPlaceholder + + inputField.textPublisher + .sink { [weak textSubject] in textSubject?.send($0) } + .store(in: &cancellables) + + rightButton.publisher(for: .touchUpInside) + .sink { [weak rightSubject, self] in + if isEditingSubject.value == true { + abortEditing() + } else { + rightSubject?.send() + } + }.store(in: &cancellables) + + addSubview(containerView) + containerView.addSubview(inputField) + containerView.addSubview(leftImageView) + containerView.addSubview(rightButton) + + setupConstraints() } required init?(coder: NSCoder) { nil } @@ -81,55 +131,6 @@ public final class SearchComponent: UIView { isEditingSubject.send(false) } - private func setup() { - containerView.layer.cornerRadius = 25 - containerView.backgroundColor = Asset.neutralSecondary.color - - leftImageView.image = Asset.lens.image - leftImageView.contentMode = .center - leftImageView.tintColor = Asset.neutralDisabled.color - - rightButton.tintColor = Asset.neutralBody.color - rightButton.setImage(rightImage, for: .normal) - rightButton.setContentHuggingPriority(.required, for: .horizontal) - rightButton.setContentCompressionResistancePriority(.required, for: .horizontal) - - inputField.delegate = self - inputField.textColor = Asset.neutralActive.color - inputField.font = Fonts.Mulish.regular.font(size: 16.0) - - let attrPlaceholder - = NSAttributedString( - string: Localized.Shared.Search.placeholder, - attributes: [ - .font: Fonts.Mulish.regular.font(size: 14.0) as Any, - .foregroundColor: Asset.neutralWeak.color - ]) - - inputField.attributedPlaceholder = attrPlaceholder - - inputField.textPublisher - .sink { [weak textSubject] in textSubject?.send($0) } - .store(in: &cancellables) - - rightButton.publisher(for: .touchUpInside) - .sink { [weak rightSubject, self] in - if isEditingSubject.value == true { - abortEditing() - } else { - rightSubject?.send() - } - }.store(in: &cancellables) - - addSubview(containerView) - containerView.addSubview(inputField) - containerView.addSubview(leftImageView) - containerView.addSubview(rightButton) - - setupConstraints() - setupAccessibility() - } - private func setupConstraints() { containerView.snp.makeConstraints { $0.top.equalToSuperview() @@ -159,16 +160,17 @@ public final class SearchComponent: UIView { } } - private func setupAccessibility() { - inputField.accessibilityIdentifier = Localized.Accessibility.Shared.Search.textField - rightButton.accessibilityIdentifier = Localized.Accessibility.Shared.Search.rightButton - } - public func textFieldDidBeginEditing(_ textField: UITextField) { rightButton.setImage(Asset.sharedCross.image, for: .normal) isEditingSubject.send(true) } + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + inputField.resignFirstResponder() + returnSubject.send(()) + return true + } + public func textFieldDidEndEditing(_ textField: UITextField) { rightButton.setImage(rightImage, for: .normal) isEditingSubject.send(false) diff --git a/Sources/Shared/Views/SearchCountryComponent.swift b/Sources/Shared/Views/SearchCountryComponent.swift new file mode 100644 index 0000000000000000000000000000000000000000..186c58b95981ce45a799c93e27c8a53530b16cf4 --- /dev/null +++ b/Sources/Shared/Views/SearchCountryComponent.swift @@ -0,0 +1,57 @@ +import UIKit + +public final class SearchCountryComponent: UIControl { + let flagLabel = UILabel() + let prefixLabel = UILabel() + let containerView = UIView() + + public init() { + super.init(frame: .zero) + + containerView.layer.cornerRadius = 25 + containerView.backgroundColor = Asset.neutralSecondary.color + + flagLabel.text = "🇺🇸" + prefixLabel.text = "+1" + prefixLabel.textColor = Asset.neutralDisabled.color + prefixLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) + + addSubview(containerView) + containerView.addSubview(flagLabel) + containerView.addSubview(prefixLabel) + + containerView.isUserInteractionEnabled = false + + setupConstraints() + flagLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + prefixLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + } + + required init?(coder: NSCoder) { nil } + + public func setFlag(_ flag: String, prefix: String) { + flagLabel.text = flag + prefixLabel.text = prefix + } + + private func setupConstraints() { + containerView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + $0.height.equalTo(50) + } + + flagLabel.snp.makeConstraints { + $0.left.equalToSuperview().offset(13) + $0.centerY.equalToSuperview() + } + + prefixLabel.snp.makeConstraints { + $0.left.equalTo(flagLabel.snp.right).offset(10) + $0.right.equalToSuperview().offset(-13) + $0.centerY.equalToSuperview() + } + } +} diff --git a/Sources/Shared/Views/SmallAvatarAndTitleCell.swift b/Sources/Shared/Views/SmallAvatarAndTitleCell.swift deleted file mode 100644 index 4c37a96467729da2f4234ad646287a6127455f63..0000000000000000000000000000000000000000 --- a/Sources/Shared/Views/SmallAvatarAndTitleCell.swift +++ /dev/null @@ -1,50 +0,0 @@ -import UIKit - -public final class SmallAvatarAndTitleCell: UITableViewCell { - 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) - - selectedBackgroundView = UIView() - multipleSelectionBackgroundView = UIView() - backgroundColor = Asset.neutralWhite.color - - titleLabel.textColor = Asset.neutralActive.color - titleLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) - separatorView.backgroundColor = Asset.neutralLine.color - - contentView.addSubview(titleLabel) - contentView.addSubview(avatarView) - contentView.addSubview(separatorView) - - avatarView.snp.makeConstraints { - $0.width.height.equalTo(36) - $0.left.equalToSuperview().offset(27) - $0.centerY.equalToSuperview() - } - - titleLabel.snp.makeConstraints { - $0.centerY.equalTo(avatarView) - $0.left.equalTo(avatarView.snp.right).offset(14) - $0.right.lessThanOrEqualToSuperview().offset(-10) - } - - 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/Tests/CollectionViewTests/CellFactoryTests.swift b/Tests/CollectionViewTests/CellFactoryTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..0fc5063570ee1d2f615d24e5cc2d38292e0ab003 --- /dev/null +++ b/Tests/CollectionViewTests/CellFactoryTests.swift @@ -0,0 +1,105 @@ +import CustomDump +import XCTest +@testable import CollectionView + +final class CellFactoryTests: XCTestCase { + func testCombined() { + struct Cell: Equatable { + var model: Int + var collectionView: UICollectionView + var indexPath: IndexPath + } + + var didRegisterFirst = [UICollectionView]() + var didRegisterSecond = [UICollectionView]() + var didRegisterThird = [UICollectionView]() + + var didBuildFirst = [Cell]() + var didBuildSecond = [Cell]() + var didBuildThird = [Cell]() + + let factory = CellFactory<Int>.combined( + .init( + register: .init { didRegisterFirst.append($0) }, + build: .init { model, collectionView, indexPath in + guard model == 1 else { return nil } + didBuildFirst.append(Cell(model: model, collectionView: collectionView, indexPath: indexPath)) + return UICollectionViewCell() + } + ), + .init( + register: .init { didRegisterSecond.append($0) }, + build: .init { model, collectionView, indexPath in + guard model == 2 else { return nil } + didBuildSecond.append(Cell(model: model, collectionView: collectionView, indexPath: indexPath)) + return UICollectionViewCell() + } + ), + .init( + register: .init { didRegisterThird.append($0) }, + build: .init { model, collectionView, indexPath in + guard model == 3 else { return nil } + didBuildThird.append(Cell(model: model, collectionView: collectionView, indexPath: indexPath)) + return UICollectionViewCell() + } + ) + ) + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: .init()) + + factory.register(in: collectionView) + + XCTAssertEqual(didRegisterFirst, [collectionView]) + XCTAssertEqual(didRegisterSecond, [collectionView]) + XCTAssertEqual(didRegisterThird, [collectionView]) + + let firstCell = factory.build(for: 1, in: collectionView, at: IndexPath(item: 0, section: 1)) + + XCTAssertNotNil(firstCell) + XCTAssertNoDifference(didBuildFirst, [Cell( + model: 1, + collectionView: collectionView, + indexPath: IndexPath(row: 0, section: 1) + )]) + XCTAssertNoDifference(didBuildSecond, []) + XCTAssertNoDifference(didBuildThird, []) + + didBuildFirst = [] + didBuildSecond = [] + didBuildThird = [] + let secondCell = factory.build(for: 2, in: collectionView, at: IndexPath(item: 2, section: 3)) + + XCTAssertNotNil(secondCell) + XCTAssertNoDifference(didBuildFirst, []) + XCTAssertNoDifference(didBuildSecond, [Cell( + model: 2, + collectionView: collectionView, + indexPath: IndexPath(row: 2, section: 3) + )]) + XCTAssertNoDifference(didBuildThird, []) + + didBuildFirst = [] + didBuildSecond = [] + didBuildThird = [] + let thirdCell = factory.build(for: 3, in: collectionView, at: IndexPath(item: 4, section: 5)) + + XCTAssertNotNil(thirdCell) + XCTAssertNoDifference(didBuildFirst, []) + XCTAssertNoDifference(didBuildSecond, []) + XCTAssertNoDifference(didBuildThird, [Cell( + model: 3, + collectionView: collectionView, + indexPath: IndexPath(row: 4, section: 5) + )]) + + didBuildFirst = [] + didBuildSecond = [] + didBuildThird = [] + let otherCell = factory.build(for: 4, in: collectionView, at: IndexPath(item: 0, section: 0)) + + XCTAssertNil(otherCell) + XCTAssertNoDifference(didBuildFirst, []) + XCTAssertNoDifference(didBuildSecond, []) + XCTAssertNoDifference(didBuildThird, []) + } +} diff --git a/Tests/CollectionViewTests/ViewConfiguratorTests.swift b/Tests/CollectionViewTests/ViewConfiguratorTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..df2e993b0f70bceefc124cca9fda9c3be5723db6 --- /dev/null +++ b/Tests/CollectionViewTests/ViewConfiguratorTests.swift @@ -0,0 +1,33 @@ +import CustomDump +import XCTest +@testable import CollectionView + +// MARK: - Example view configurator: + +private class ProfileView: UIView { + let username = UILabel() +} + +private struct User { + var name: String +} + +private extension ViewConfigurator where View == ProfileView, Model == User { + static let profileViewUserConfigurator = ViewConfigurator { view, model in + view.username.text = model.name + } +} + +// MARK: - Tests: + +final class ViewConfiguratorTests: XCTestCase { + func testExampleConfigurator() { + let profileView = ProfileView() + let user = User(name: "John") + + let configure = ViewConfigurator.profileViewUserConfigurator + configure(profileView, with: user) + + XCTAssertNoDifference(profileView.username.text, user.name) + } +} diff --git a/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved b/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved index d12bf1ea4dafe0fcef393271bb98e5e8709031ae..2932261c8cc3c80fb2730138af5c8ff904e9334f 100644 --- a/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/client-ios.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -27,6 +27,15 @@ "version" : "1.4.0" } }, + { + "identity" : "bluesocket", + "kind" : "remoteSourceControl", + "location" : "https://github.com/IBM-Swift/BlueSocket.git", + "state" : { + "revision" : "c9894fd117457f1d006575fbfb2fdfd6f79eac03", + "version" : "1.0.200" + } + }, { "identity" : "boringssl-swiftpm", "kind" : "remoteSourceControl", @@ -50,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://git.xx.network/elixxir/client-ios-db.git", "state" : { - "revision" : "f52a83a8095c36f4e84afa51b52c929117c8779a", - "version" : "1.0.6" + "revision" : "785e1f653ee5eaaaf58a82c8abbcda2174fbc27a", + "version" : "1.0.8" } }, { @@ -207,6 +216,15 @@ "version" : "1.22.2" } }, + { + "identity" : "libssh2prebuild", + "kind" : "remoteSourceControl", + "location" : "https://github.com/DimaRU/Libssh2Prebuild.git", + "state" : { + "branch" : "1.10.0+OpenSSL_1_1_1o", + "revision" : "a91bcf205a6cbc84144f840c44145656abbd266a" + } + }, { "identity" : "nanopb", "kind" : "remoteSourceControl", @@ -261,6 +279,14 @@ "version" : "1.2.0" } }, + { + "identity" : "shout", + "kind" : "remoteSourceControl", + "location" : "https://github.com/darrarski/Shout.git", + "state" : { + "revision" : "df5a662293f0ac15eeb4f2fd3ffd0c07b73d0de0" + } + }, { "identity" : "snapkit", "kind" : "remoteSourceControl", @@ -302,8 +328,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "51698ece74ecf31959d3fa81733f0a5363ef1b4e", - "version" : "0.3.0" + "revision" : "21ec1d717c07cea5a026979cb0471dd95c7087e7", + "version" : "0.5.0" } }, { @@ -347,8 +373,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "50a70a9d3583fe228ce672e8923010c8df2deddd", - "version" : "0.2.1" + "revision" : "ef8e14e7ce1c0c304c644c6ba365d06c468ded6b", + "version" : "0.3.3" } } ],