diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/LaunchFeature.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/LaunchFeature.xcscheme new file mode 100644 index 0000000000000000000000000000000000000000..08a24384a1a211d4a7eb4696c6e957d4d0c8b5b1 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/LaunchFeature.xcscheme @@ -0,0 +1,67 @@ +<?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 = "LaunchFeature" + BuildableName = "LaunchFeature" + BlueprintName = "LaunchFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES"> + <Testables> + </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 = "LaunchFeature" + BuildableName = "LaunchFeature" + BlueprintName = "LaunchFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/PushFeature.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/PushFeature.xcscheme new file mode 100644 index 0000000000000000000000000000000000000000..2d6189486ea18021c361490678fd3abf55938ace --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/PushFeature.xcscheme @@ -0,0 +1,67 @@ +<?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 = "PushFeature" + BuildableName = "PushFeature" + BlueprintName = "PushFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES"> + <Testables> + </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 = "PushFeature" + BuildableName = "PushFeature" + BlueprintName = "PushFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/App/NotificationExtension/NotificationExtension.entitlements b/App/NotificationExtension/NotificationExtension.entitlements index 9d3669c70425621e3f5b2ea1e87bd52f5fbe284a..f45f1115897444c23f2156a3cb4d260925d976fc 100644 --- a/App/NotificationExtension/NotificationExtension.entitlements +++ b/App/NotificationExtension/NotificationExtension.entitlements @@ -6,7 +6,7 @@ <true/> <key>com.apple.security.application-groups</key> <array> - <string>group.io.xxlabs.notification</string> + <string>group.elixxir.messenger</string> </array> </dict> </plist> diff --git a/App/NotificationExtension/NotificationService.swift b/App/NotificationExtension/NotificationService.swift index 1c92ce4a7750684edcf92cbf933f616c89881f13..495958b9ab1e9b26981c9996f088b17b5fa1053d 100644 --- a/App/NotificationExtension/NotificationService.swift +++ b/App/NotificationExtension/NotificationService.swift @@ -1,117 +1,13 @@ -import os -import Bindings +import PushFeature import UserNotifications -class NotificationService: UNNotificationServiceExtension { - var contentHandler: ((UNNotificationContent) -> Void)? - var bestAttemptContent: UNMutableNotificationContent? +final class NotificationService: UNNotificationServiceExtension { + private let pushHandler = PushHandler() - let logger = Logger(subsystem: "logs_xxmessenger", category: "NotificationService.swift") - let signpostLogger = OSLog(subsystem: "logs_xxmessenger", category: "NotificationService.swift") - - override func didReceive(_ request: UNNotificationRequest, - withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - logger.debug("didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void)") - - self.contentHandler = contentHandler - bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - - guard let data = bestAttemptContent?.userInfo["notificationData"] as? String, - let defaults = UserDefaults(suiteName: "group.io.xxlabs.notification"), - let preImage = defaults.value(forKey: "preImage") as? String else { - contentHandler(UNNotificationContent()) - logger.error("Failure: No notification data on the payload or no UserDefaults for group.io.xxlabs.notification or no preImage stored.") - return - } - - var error: NSError? - - os_signpost(.begin, log: signpostLogger, name: "BindingsNotificationsForMe") - - guard let reports = BindingsNotificationsForMe(data, preImage, &error) else { - logger.error("Failure: report list from BindingsNotificationsForMe is nil") - os_signpost(.end, log: signpostLogger, name: "BindingsNotificationsForMe") - return - } - - os_signpost(.end, log: signpostLogger, name: "BindingsNotificationsForMe") - - let length = reports.len() - logger.debug("Amount of reports present: \(length, privacy: .public)") - - var showNotification = false - - let content = UNMutableNotificationContent() - content.sound = .default - content.badge = 1 - content.threadIdentifier = "new_message_identifier" - - for index in 0..<length { - do { - let report = try reports.get(index) - let isForMe = report.forMe() - let isNotDefault = report.type() != "default" - let isNotSilent = report.type() != "silent" - - switch report.type() { - case "default", "silent": - break - case "request": - content.title = "Request received" - case "confirm": - content.title = "Request accepted" - case "e2e": - content.title = "New private message" - case "group": - content.title = "New group message" - case "endFT": - content.title = "New media received" - case "groupRq": - content.title = "Group request received" - case "reset": - content.title = "One of your contacts has restored their account" - default: - break - } - - logger.log("Type present on the report being iterated: \(report.type(), privacy: .public)") - - if isForMe { - logger.debug("This notification is for me") - } else { - logger.debug("This notification is NOT for me") - } - - if isForMe && isNotSilent && isNotDefault { - logger.debug("This notification is for me AND its not silent AND is not default -> Will display") - showNotification = true - break - } else { - logger.debug("Failure: Its either typed default, silent or its not actually for me") - } - - } catch { - logger.error("Failure: reports.get raised an exception: \(error.localizedDescription, privacy: .public)") - } - } - - guard showNotification == true else { - logger.debug("Failure: One or more conditions failed. Aborting notification...") - return - } - - if let error = error { - contentHandler(UNNotificationContent()) - logger.error("Failure: An error was written by NotificationsForMe bindings function: \(error.localizedDescription, privacy: .public)") - return - } - - contentHandler(content) - - logger.debug("A push was successfully presented") - } - - override func serviceExtensionTimeWillExpire() { - logger.trace("serviceExtensionTimeWillExpire()") + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + pushHandler.handlePush(request, contentHandler) } } diff --git a/App/client-ios.xcodeproj/project.pbxproj b/App/client-ios.xcodeproj/project.pbxproj index 3fa6e6adb9c619d26155f6b6a66c375031a734b4..9ffb59c7aba66820aaf2a22dbf6b6c8e041cff8c 100644 --- a/App/client-ios.xcodeproj/project.pbxproj +++ b/App/client-ios.xcodeproj/project.pbxproj @@ -14,9 +14,9 @@ 32179BA826410149008B26EC /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32179BA726410149008B26EC /* NotificationService.swift */; }; 32179BAC26410149008B26EC /* NotificationExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 32179BA526410149008B26EC /* NotificationExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3273327126C7391D0027D79D /* App in Frameworks */ = {isa = PBXBuildFile; productRef = 3273327026C7391D0027D79D /* App */; }; - 32824C8F26EAE13D005D3FAC /* Bindings in Frameworks */ = {isa = PBXBuildFile; productRef = 32824C8E26EAE13D005D3FAC /* Bindings */; }; 32C194E02808C65500876917 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 32C194DF2808C65500876917 /* GoogleService-Info.plist */; }; 32C194E12808C65500876917 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 32C194DF2808C65500876917 /* GoogleService-Info.plist */; }; + 32CAAFAE2845836100446BB9 /* App in Frameworks */ = {isa = PBXBuildFile; productRef = 32CAAFAD2845836100446BB9 /* App */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -73,7 +73,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 32824C8F26EAE13D005D3FAC /* Bindings in Frameworks */, + 32CAAFAE2845836100446BB9 /* App in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -123,9 +123,9 @@ 32179BA626410149008B26EC /* NotificationExtension */ = { isa = PBXGroup; children = ( - 32DB0549264DD42000FDCCEB /* NotificationExtension.entitlements */, - 32179BA726410149008B26EC /* NotificationService.swift */, 32179BA926410149008B26EC /* Info.plist */, + 32179BA726410149008B26EC /* NotificationService.swift */, + 32DB0549264DD42000FDCCEB /* NotificationExtension.entitlements */, ); path = NotificationExtension; sourceTree = "<group>"; @@ -179,7 +179,7 @@ ); name = NotificationExtension; packageProductDependencies = ( - 32824C8E26EAE13D005D3FAC /* Bindings */, + 32CAAFAD2845836100446BB9 /* App */, ); productName = NotificationExtension; productReference = 32179BA526410149008B26EC /* NotificationExtension.appex */; @@ -448,7 +448,7 @@ CODE_SIGN_ENTITLEMENTS = "client-ios/Resources/client-ios.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 67; + CURRENT_PROJECT_VERSION = 129; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = S6JDM2WW29; ENABLE_BITCODE = NO; @@ -463,7 +463,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.9; + MARKETING_VERSION = 1.1.1; OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = xx.messenger.mock; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -487,7 +487,7 @@ CODE_SIGN_ENTITLEMENTS = "client-ios/Resources/client-ios.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 67; + CURRENT_PROJECT_VERSION = 129; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = S6JDM2WW29; ENABLE_BITCODE = NO; @@ -503,7 +503,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.9; + MARKETING_VERSION = 1.1.1; OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = xx.messenger; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -522,7 +522,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationExtension/NotificationExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 67; + CURRENT_PROJECT_VERSION = 129; DEVELOPMENT_TEAM = S6JDM2WW29; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -536,7 +536,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.9; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = xx.messenger.mock.notifications; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -553,7 +553,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationExtension/NotificationExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 67; + CURRENT_PROJECT_VERSION = 129; DEVELOPMENT_TEAM = S6JDM2WW29; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -567,7 +567,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.9; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = xx.messenger.notifications; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -614,9 +614,9 @@ isa = XCSwiftPackageProductDependency; productName = App; }; - 32824C8E26EAE13D005D3FAC /* Bindings */ = { + 32CAAFAD2845836100446BB9 /* App */ = { isa = XCSwiftPackageProductDependency; - productName = Bindings; + productName = App; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/App/client-ios/Resources/client-ios.entitlements b/App/client-ios/Resources/client-ios.entitlements index ce90eb85306477465bf77307eed53d5434c6ba48..88e463326dd0fda0a358f095f60612c555c77d3b 100644 --- a/App/client-ios/Resources/client-ios.entitlements +++ b/App/client-ios/Resources/client-ios.entitlements @@ -22,7 +22,7 @@ <true/> <key>com.apple.security.application-groups</key> <array> - <string>group.io.xxlabs.notification</string> + <string>group.elixxir.messenger</string> </array> </dict> </plist> diff --git a/Package.swift b/Package.swift index ce66cf78fb427b5813a1fc9878c8bea961f9d885..66b1af7d2459202cf5772e2d22e1009df35777d1 100644 --- a/Package.swift +++ b/Package.swift @@ -27,9 +27,11 @@ let package = Package( .library(name: "MenuFeature", targets: ["MenuFeature"]), .library(name: "Integration", targets: ["Integration"]), .library(name: "ChatFeature", targets: ["ChatFeature"]), + .library(name: "PushFeature", targets: ["PushFeature"]), .library(name: "CrashService", targets: ["CrashService"]), .library(name: "Presentation", targets: ["Presentation"]), .library(name: "BackupFeature", targets: ["BackupFeature"]), + .library(name: "LaunchFeature", targets: ["LaunchFeature"]), .library(name: "iCloudFeature", targets: ["iCloudFeature"]), .library(name: "SearchFeature", targets: ["SearchFeature"]), .library(name: "DrawerFeature", targets: ["DrawerFeature"]), @@ -44,7 +46,6 @@ let package = Package( .library(name: "ChatListFeature", targets: ["ChatListFeature"]), .library(name: "RequestsFeature", targets: ["RequestsFeature"]), .library(name: "ChatInputFeature", targets: ["ChatInputFeature"]), - .library(name: "PushNotifications", targets: ["PushNotifications"]), .library(name: "OnboardingFeature", targets: ["OnboardingFeature"]), .library(name: "GoogleDriveFeature", targets: ["GoogleDriveFeature"]), .library(name: "ContactListFeature", targets: ["ContactListFeature"]), @@ -152,10 +153,12 @@ let package = Package( "ScanFeature", "ChatFeature", "MenuFeature", + "PushFeature", "ToastFeature", "CrashService", "BackupFeature", "SearchFeature", + "LaunchFeature", "iCloudFeature", "DropboxFeature", "ContactFeature", @@ -165,7 +168,6 @@ let package = Package( "ChatListFeature", "SettingsFeature", "RequestsFeature", - "PushNotifications", "OnboardingFeature", "GoogleDriveFeature", "ContactListFeature" @@ -178,7 +180,7 @@ let package = Package( .target(name: "InputField", dependencies: ["Shared"]), .binaryTarget(name: "Bindings", path: "XCFrameworks/Bindings.xcframework"), - // MARK: - PushNotifications + // MARK: - Permissions .target( name: "Permissions", @@ -189,10 +191,13 @@ let package = Package( ] ), + // MARK: - PushFeature + .target( - name: "PushNotifications", + name: "PushFeature", dependencies: [ - "XXLogger", + "Models", + "Database", "Defaults", "Integration", "DependencyInjection" @@ -430,6 +435,7 @@ let package = Package( "Shared", "Database", "Bindings", + "ToastFeature", "BackupFeature", "CrashReporting", "NetworkMonitor", @@ -552,6 +558,24 @@ let package = Package( ] ), + // MARK: - LaunchFeature + + .target( + name: "LaunchFeature", + dependencies: [ + "HUD", + "Theme", + "Shared", + "Defaults", + "PushFeature", + "Integration", + "Permissions", + "DropboxFeature", + "VersionChecking", + "DependencyInjection" + ] + ), + // MARK: - RequestsFeature .target( @@ -632,11 +656,11 @@ let package = Package( "Countries", "InputField", "Permissions", + "PushFeature", "Integration", "Presentation", "DrawerFeature", "VersionChecking", - "PushNotifications", "DependencyInjection", .product( name: "ScrollViewController", @@ -729,12 +753,12 @@ let package = Package( "Defaults", "Keychain", "InputField", + "PushFeature", "Permissions", "MenuFeature", "Integration", "Presentation", "DrawerFeature", - "PushNotifications", "DependencyInjection", .product( name: "ScrollViewController", diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index b6319b9cef9b381e616ed1221d51c6c7c7fb33b7..ecb65d1f54eb3b05cda31850eb98431c6ec2d31f 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -1,23 +1,20 @@ import UIKit -import OSLog import BackgroundTasks import Theme import XXLogger import Defaults import Integration +import PushFeature import ToastFeature import SwiftyDropbox +import LaunchFeature +import DropboxFeature import CrashReporting -import PushNotifications import DependencyInjection -import OnboardingFeature -import DropboxFeature - -let logger = Logger(subsystem: "logs_xxmessenger", category: "AppDelegate.swift") - public class AppDelegate: UIResponder, UIApplicationDelegate { + @Dependency private var pushRouter: PushRouter @Dependency private var pushHandler: PushHandling @Dependency private var crashReporter: CrashReporter @Dependency private var dropboxService: DropboxInterface @@ -52,21 +49,17 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { UNUserNotificationCenter.current().delegate = self - let rootScreen = - StatusBarViewController( - ToastViewController( - UINavigationController( - rootViewController: OnboardingLaunchController() - ) - ) - ) + let window = Window() + let navController = UINavigationController(rootViewController: LaunchController()) + window.rootViewController = StatusBarViewController(ToastViewController(navController)) + window.backgroundColor = UIColor.white + window.makeKeyAndVisible() + self.window = window - window = Window() - window?.rootViewController = rootScreen - window?.backgroundColor = UIColor.white - window?.makeKeyAndVisible() + DependencyInjection.Container.shared.register( + PushRouter.live(navigationController: navController) + ) - UserDefaults.standard.set(false, forKey: "_UIConstraintBasedLayoutLogUnsatisfiable") return true } @@ -76,25 +69,19 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { public func applicationDidEnterBackground(_ application: UIApplication) { if let session = try? DependencyInjection.Container.shared.resolve() as SessionType { - let backgroundTask = application.beginBackgroundTask(withName: "xx.stop.network") { - logger.log("Background task will expire") - } + let backgroundTask = application.beginBackgroundTask(withName: "xx.stop.network") {} // An option here would be: create async completion closure backgroundTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in - logger.log("Background time remaining: \(UIApplication.shared.backgroundTimeRemaining)") - guard UIApplication.shared.backgroundTimeRemaining > 8 else { if !self.calledStopNetwork { self.calledStopNetwork = true session.stop() - logger.log("Stopping client threads...") } else { if session.hasRunningTasks == false { application.endBackgroundTask(backgroundTask) timer.invalidate() - logger.log("Finished background processes") } } @@ -104,10 +91,7 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { guard UIApplication.shared.backgroundTimeRemaining > 9 else { if !self.forceFailedPendingMessages { self.forceFailedPendingMessages = true - logger.log("Background time is running out. Will force-fail all pending messages") session.forceFailMessages() - } else { - logger.log("Background time is running out without pending messages") } return @@ -127,27 +111,18 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { public func applicationWillTerminate(_ application: UIApplication) { if let session = try? DependencyInjection.Container.shared.resolve() as SessionType { - logger.log("applicationWillTerminate but has an ongoing session. Calling stopNetwork...") session.stop() - } else { - logger.log("applicationWillTerminate without any session") } } public func applicationWillEnterForeground(_ application: UIApplication) { if backgroundTimer != nil { - logger.log("Invalidating background timer...") backgroundTimer?.invalidate() backgroundTimer = nil } if let session = try? DependencyInjection.Container.shared.resolve() as SessionType { - guard self.calledStopNetwork == true else { - logger.log("A client instance is already running. Moving on...") - return - } - - logger.log("A client instance is stopped. Starting network...") + guard self.calledStopNetwork == true else { return } session.start() self.calledStopNetwork = false } @@ -158,7 +133,11 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { coverView?.removeFromSuperview() } - public func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { + public func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey : Any] = [:] + ) -> Bool { dropboxService.handleOpenUrl(url) } } @@ -166,18 +145,27 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: Notifications extension AppDelegate: UNUserNotificationCenterDelegate { + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = response.notification.request.content.userInfo + pushHandler.handleAction(pushRouter, userInfo, completionHandler) + } + public func application( - _: UIApplication, + _ application: UIApplication, didReceiveRemoteNotification notification: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { - pushHandler.didReceiveRemote(notification, completionHandler) + pushHandler.handlePush(notification, completionHandler) } public func application( _: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { - pushHandler.didRegisterWith(deviceToken) + pushHandler.registerToken(deviceToken) } } diff --git a/Sources/App/DependencyRegistrator.swift b/Sources/App/DependencyRegistrator.swift index 5d87fd000b19903e982cd03e4ba4c9bfa742ab2a..b342330e9012ba1a245d39cbd26f47602c074db4 100644 --- a/Sources/App/DependencyRegistrator.swift +++ b/Sources/App/DependencyRegistrator.swift @@ -17,6 +17,7 @@ import Countries import Voxophone import Integration import Permissions +import PushFeature import CrashService import ToastFeature import iCloudFeature @@ -24,7 +25,6 @@ import CrashReporting import NetworkMonitor import DropboxFeature import VersionChecking -import PushNotifications import GoogleDriveFeature import DependencyInjection @@ -35,6 +35,7 @@ import ChatFeature import MenuFeature import BackupFeature import SearchFeature +import LaunchFeature import RestoreFeature import ContactFeature import ProfileFeature @@ -107,6 +108,15 @@ struct DependencyRegistrator { // MARK: Coordinators + container.register( + LaunchCoordinator( + requestsFactory: RequestsContainerController.init, + chatListFactory: ChatListController.init, + onboardingFactory: OnboardingStartController.init(_:), + singleChatFactory: SingleChatController.init(_:), + groupChatFactory: GroupChatController.init(_:) + ) as LaunchCoordinating) + container.register( BackupCoordinator( passphraseFactory: BackupPassphraseController.init(_:_:) @@ -190,7 +200,6 @@ struct DependencyRegistrator { searchFactory: SearchController.init, welcomeFactory: OnboardingWelcomeController.init, chatListFactory: ChatListController.init, - startFactory: OnboardingStartController.init(_:), usernameFactory: OnboardingUsernameController.init(_:), restoreListFactory: RestoreListController.init(_:), successFactory: OnboardingSuccessController.init(_:), @@ -214,20 +223,60 @@ struct DependencyRegistrator { container.register( ScanCoordinator( + emailFactory: ProfileEmailController.init, + phoneFactory: ProfilePhoneController.init, contactsFactory: ContactListController.init, requestsFactory: RequestsContainerController.init, contactFactory: ContactController.init(_:), sideMenuFactory: MenuController.init(_:_:) ) as ScanCoordinating) + container.register( ChatListCoordinator( scanFactory: ScanContainerController.init, searchFactory: SearchController.init, + newGroupFactory: CreateGroupController.init, contactsFactory: ContactListController.init, + contactFactory: ContactController.init(_:), singleChatFactory: SingleChatController.init(_:), groupChatFactory: GroupChatController.init(_:), sideMenuFactory: MenuController.init(_:_:) ) as ChatListCoordinating) } } + +extension PushRouter { + static func live(navigationController: UINavigationController) -> PushRouter { + PushRouter { route, completion in + if let launchController = navigationController.viewControllers.last as? LaunchController { + launchController.pendingPushRoute = route + } else { + switch route { + case .requests: + if (navigationController.viewControllers.last as? RequestsContainerController) == nil { + navigationController.setViewControllers([RequestsContainerController()], animated: true) + } + case .contactChat(id: let id): + if let session = try? DependencyInjection.Container.shared.resolve() as SessionType, + let contact = session.getContactWith(userId: id) { + navigationController.setViewControllers([ + ChatListController(), + SingleChatController(contact) + ], animated: true) + } + case .groupChat(id: let id): + if let session = try? DependencyInjection.Container.shared.resolve() as SessionType, + let info = session.getGroupChatInfoWith(groupId: id) { + navigationController.setViewControllers([ + ChatListController(), + GroupChatController(info) + ], animated: true) + } + } + } + + completion() + } + } +} diff --git a/Sources/ChatFeature/Controllers/GroupChatController.swift b/Sources/ChatFeature/Controllers/GroupChatController.swift index 174bb5fa7e2f39ca9d793b81a195150e68fdd407..e248c053bc68431171a9817454d68fffb6f6a4a2 100644 --- a/Sources/ChatFeature/Controllers/GroupChatController.swift +++ b/Sources/ChatFeature/Controllers/GroupChatController.swift @@ -5,6 +5,7 @@ import Shared import Combine import Voxophone import ChatLayout +import DrawerFeature import DifferenceKit import ChatInputFeature import DependencyInjection @@ -30,6 +31,7 @@ public final class GroupChatController: UIViewController { private let viewModel: GroupChatViewModel private let layoutDelegate = LayoutDelegate() private var cancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() private var sections = [ArraySection<ChatSection, GroupChatItem>]() private var currentInterfaceActions = SetActor<Set<InterfaceActions>, ReactionTypes>() @@ -157,10 +159,16 @@ public final class GroupChatController: UIViewController { } private func setupBindings() { - viewModel.roundURLPublisher + viewModel.routesPublisher .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toWebview(with: $0, from: self) } - .store(in: &cancellables) + .sink { [unowned self] in + switch $0 { + case .waitingRound: + coordinator.toDrawer(makeWaitingRoundDrawer(), from: self) + case .webview(let urlString): + coordinator.toWebview(with: urlString, from: self) + } + }.store(in: &cancellables) viewModel.messages .receive(on: DispatchQueue.main) @@ -219,6 +227,34 @@ public final class GroupChatController: UIViewController { coordinator.toMembersList(members, from: self) } + private func makeWaitingRoundDrawer() -> UIViewController { + let text = DrawerText( + font: Fonts.Mulish.semiBold.font(size: 14.0), + text: Localized.Chat.RoundDrawer.title, + color: Asset.neutralWeak.color, + lineHeightMultiple: 1.35, + spacingAfter: 25 + ) + + let button = DrawerCapsuleButton(model: .init( + title: Localized.Chat.RoundDrawer.action, + style: .brandColored + )) + + let drawer = DrawerController(with: [text, button]) + + button.action + .receive(on: DispatchQueue.main) + .sink { [weak drawer] in + drawer?.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) + + return drawer + } + func scrollToBottom(completion: (() -> Void)? = nil) { let contentOffsetAtBottom = CGPoint( x: collectionView.contentOffset.x, diff --git a/Sources/ChatFeature/Controllers/SingleChatController.swift b/Sources/ChatFeature/Controllers/SingleChatController.swift index 41526deb8afa5d814546dbcf6dfb23dcc3b18ad3..b6fd74a15936d7e89da27d03e555e5e16291af3e 100644 --- a/Sources/ChatFeature/Controllers/SingleChatController.swift +++ b/Sources/ChatFeature/Controllers/SingleChatController.swift @@ -25,7 +25,6 @@ public final class SingleChatController: UIViewController { @Dependency private var coordinator: ChatCoordinating @Dependency private var statusBarController: StatusBarStyleControlling - lazy private var infoView = UIControl() lazy private var nameLabel = UILabel() lazy private var avatarView = AvatarView() @@ -89,6 +88,7 @@ public final class SingleChatController: UIViewController { super.viewDidAppear(animated) collectionView.collectionViewLayout.invalidateLayout() becomeFirstResponder() + viewModel.viewDidAppear() } private var isFirstAppearance = true @@ -223,6 +223,8 @@ public final class SingleChatController: UIViewController { coordinator.toPermission(type: .library, from: self) case .webview(let urlString): coordinator.toWebview(with: urlString, from: self) + case .waitingRound: + coordinator.toDrawer(makeWaitingRoundDrawer(), from: self) case .none: break } @@ -351,6 +353,34 @@ public final class SingleChatController: UIViewController { } } + private func makeWaitingRoundDrawer() -> UIViewController { + let text = DrawerText( + font: Fonts.Mulish.semiBold.font(size: 14.0), + text: Localized.Chat.RoundDrawer.title, + color: Asset.neutralWeak.color, + lineHeightMultiple: 1.35, + spacingAfter: 25 + ) + + let button = DrawerCapsuleButton(model: .init( + title: Localized.Chat.RoundDrawer.action, + style: .brandColored + )) + + let drawer = DrawerController(with: [text, button]) + + button.action + .receive(on: DispatchQueue.main) + .sink { [weak drawer] in + drawer?.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) + + return drawer + } + private func presentDeleteAllDrawer() { let clearButton = CapsuleButton() clearButton.setStyle(.red) diff --git a/Sources/ChatFeature/Helpers/BubbleBuilder.swift b/Sources/ChatFeature/Helpers/BubbleBuilder.swift index e770bf23f89b1f7e001839fcf1dea22d7cc2389f..3f9da31df8b01008102bb6bd066a5b27a69ea326 100644 --- a/Sources/ChatFeature/Helpers/BubbleBuilder.swift +++ b/Sources/ChatFeature/Helpers/BubbleBuilder.swift @@ -10,32 +10,27 @@ final class Bubbler { switch item.status { case .received, .read: - audioBubble.lockerView.removeFromSuperview() + audioBubble.lockerImageView.removeFromSuperview() audioBubble.backgroundColor = Asset.neutralWhite.color audioBubble.dateLabel.textColor = Asset.neutralDisabled.color audioBubble.progressLabel.textColor = Asset.neutralDisabled.color case .receivingAttachment: - audioBubble.lockerView.animate() audioBubble.backgroundColor = Asset.neutralWhite.color audioBubble.dateLabel.textColor = Asset.neutralDisabled.color audioBubble.progressLabel.textColor = Asset.neutralDisabled.color case .timedOut: - audioBubble.lockerView.fail() audioBubble.backgroundColor = Asset.accentWarning.color audioBubble.dateLabel.textColor = Asset.neutralWhite.color audioBubble.progressLabel.textColor = Asset.neutralWhite.color case .failedToSend: - audioBubble.lockerView.fail() audioBubble.backgroundColor = Asset.accentDanger.color audioBubble.dateLabel.textColor = Asset.neutralWhite.color audioBubble.progressLabel.textColor = Asset.neutralWhite.color case .sent: - audioBubble.lockerView.stop() audioBubble.backgroundColor = Asset.brandBubble.color audioBubble.dateLabel.textColor = Asset.neutralWhite.color audioBubble.progressLabel.textColor = Asset.neutralWhite.color case .sending, .sendingAttachment: - audioBubble.lockerView.animate() audioBubble.backgroundColor = Asset.brandBubble.color audioBubble.dateLabel.textColor = Asset.neutralWhite.color audioBubble.progressLabel.textColor = Asset.neutralWhite.color @@ -52,32 +47,27 @@ final class Bubbler { switch item.status { case .received, .read: - imageBubble.lockerView.removeFromSuperview() + imageBubble.lockerImageView.removeFromSuperview() imageBubble.backgroundColor = Asset.neutralWhite.color imageBubble.dateLabel.textColor = Asset.neutralDisabled.color imageBubble.progressLabel.textColor = Asset.neutralDisabled.color case .receivingAttachment: - imageBubble.lockerView.animate() imageBubble.backgroundColor = Asset.neutralWhite.color imageBubble.dateLabel.textColor = Asset.neutralDisabled.color imageBubble.progressLabel.textColor = Asset.neutralDisabled.color case .failedToSend: - imageBubble.lockerView.fail() imageBubble.backgroundColor = Asset.accentDanger.color imageBubble.dateLabel.textColor = Asset.neutralWhite.color imageBubble.progressLabel.textColor = Asset.neutralWhite.color case .timedOut: - imageBubble.lockerView.fail() imageBubble.backgroundColor = Asset.accentWarning.color imageBubble.dateLabel.textColor = Asset.neutralWhite.color imageBubble.progressLabel.textColor = Asset.neutralWhite.color case .sent: - imageBubble.lockerView.stop() imageBubble.backgroundColor = Asset.brandBubble.color imageBubble.dateLabel.textColor = Asset.neutralWhite.color imageBubble.progressLabel.textColor = Asset.neutralWhite.color case .sending, .sendingAttachment: - imageBubble.lockerView.animate() imageBubble.backgroundColor = Asset.brandBubble.color imageBubble.dateLabel.textColor = Asset.neutralWhite.color imageBubble.progressLabel.textColor = Asset.neutralWhite.color @@ -96,32 +86,28 @@ final class Bubbler { switch item.status { case .received, .read, .receivingAttachment: - bubble.lockerView.removeFromSuperview() + bubble.lockerImageView.removeFromSuperview() bubble.backgroundColor = Asset.neutralWhite.color bubble.textView.textColor = Asset.neutralActive.color bubble.dateLabel.textColor = Asset.neutralDisabled.color roundButtonColor = Asset.neutralDisabled.color bubble.revertBottomStackOrder() case .timedOut: - bubble.lockerView.fail() bubble.backgroundColor = Asset.accentWarning.color bubble.textView.textColor = Asset.neutralWhite.color bubble.dateLabel.textColor = Asset.neutralWhite.color roundButtonColor = Asset.neutralWhite.color case .failedToSend: - bubble.lockerView.fail() bubble.backgroundColor = Asset.accentDanger.color bubble.textView.textColor = Asset.neutralWhite.color bubble.dateLabel.textColor = Asset.neutralWhite.color roundButtonColor = Asset.neutralWhite.color case .sent: - bubble.lockerView.stop() bubble.backgroundColor = Asset.brandBubble.color bubble.textView.textColor = Asset.neutralWhite.color bubble.dateLabel.textColor = Asset.neutralWhite.color roundButtonColor = Asset.neutralWhite.color case .sending, .sendingAttachment: - bubble.lockerView.animate() bubble.backgroundColor = Asset.brandBubble.color bubble.textView.textColor = Asset.neutralWhite.color bubble.dateLabel.textColor = Asset.neutralWhite.color @@ -137,8 +123,8 @@ final class Bubbler { .font: Fonts.Mulish.regular.font(size: 12.0) as Any ] ) + bubble.roundButton.setAttributedTitle(attrString, for: .normal) - bubble.roundButton.isHidden = item.roundURL == nil } static func buildGroup( @@ -158,7 +144,7 @@ final class Bubbler { bubble.textView.textColor = Asset.neutralActive.color bubble.dateLabel.textColor = Asset.neutralDisabled.color roundButtonColor = Asset.neutralDisabled.color - bubble.lockerView.removeFromSuperview() + bubble.lockerImageView.removeFromSuperview() bubble.revertBottomStackOrder() case .failed: bubble.senderLabel.removeFromSuperview() @@ -185,18 +171,6 @@ final class Bubbler { ) bubble.roundButton.setAttributedTitle(attrString, for: .normal) - bubble.roundButton.isHidden = item.roundURL == nil - - switch item.status { - case .sent: - bubble.lockerView.stop() - case .failed: - bubble.lockerView.fail() - case .sending: - bubble.lockerView.animate() - case .read, .received: - bubble.lockerView.removeFromSuperview() - } } static func buildReply( @@ -258,18 +232,6 @@ final class Bubbler { ] ) bubble.roundButton.setAttributedTitle(attrString, for: .normal) - bubble.roundButton.isHidden = item.roundURL == nil - - switch item.status { - case .sent: - bubble.lockerView.stop() - case .failedToSend, .timedOut: - bubble.lockerView.fail() - case .sending, .sendingAttachment: - bubble.lockerView.animate() - case .read, .received, .receivingAttachment: - bubble.lockerView.removeFromSuperview() - } } static func buildReplyGroup( @@ -295,7 +257,7 @@ final class Bubbler { roundButtonColor = Asset.neutralDisabled.color bubble.replyView.container.backgroundColor = Asset.brandDefault.color bubble.replyView.space.backgroundColor = Asset.brandPrimary.color - bubble.lockerView.removeFromSuperview() + bubble.lockerImageView.removeFromSuperview() bubble.revertBottomStackOrder() case .failed: bubble.senderLabel.removeFromSuperview() @@ -326,15 +288,5 @@ final class Bubbler { ) bubble.roundButton.setAttributedTitle(attrString, for: .normal) - bubble.roundButton.isHidden = item.roundURL == nil - - switch item.status { - case .failed: - bubble.lockerView.fail() - case .sent: - bubble.lockerView.stop() - default: - bubble.lockerView.animate() - } } } diff --git a/Sources/ChatFeature/Helpers/CellConfigurator.swift b/Sources/ChatFeature/Helpers/CellConfigurator.swift index 75ac2ce6d22af0a05d643286d9dc6bad24c7b4a2..5f2cb1b6b3348382304af392042bae3184010302 100644 --- a/Sources/ChatFeature/Helpers/CellConfigurator.swift +++ b/Sources/ChatFeature/Helpers/CellConfigurator.swift @@ -258,7 +258,6 @@ extension CellFactory { cell.canReply = item.status.canReply cell.performReply = performReply - cell.rightView.roundButton.isHidden = item.roundURL == nil cell.rightView.didTapShowRound = { showRound(item.roundURL) } return cell } @@ -290,7 +289,6 @@ extension CellFactory { ) cell.canReply = item.status.canReply cell.performReply = performReply - cell.leftView.roundButton.isHidden = item.roundURL == nil cell.leftView.didTapShowRound = { showRound(item.roundURL) } cell.leftView.revertBottomStackOrder() return cell @@ -346,7 +344,6 @@ extension CellFactory { Bubbler.build(bubble: cell.leftView, with: item) cell.canReply = item.status.canReply cell.performReply = performReply - cell.leftView.roundButton.isHidden = item.roundURL == nil cell.leftView.didTapShowRound = { showRound(item.roundURL) } cell.leftView.revertBottomStackOrder() return cell @@ -370,7 +367,6 @@ extension CellFactory { Bubbler.build(bubble: cell.rightView, with: item) cell.canReply = item.status.canReply cell.performReply = performReply - cell.rightView.roundButton.isHidden = item.roundURL == nil cell.rightView.didTapShowRound = { showRound(item.roundURL) } return cell diff --git a/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift b/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift index 375043827c258f12b894e210c77607795dc480f6..ad29c602f94972faaec2aec031838e81b45c2ca6 100644 --- a/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift +++ b/Sources/ChatFeature/ViewModels/GroupChatViewModel.swift @@ -6,18 +6,27 @@ import Integration import DifferenceKit import DependencyInjection +enum GroupChatNavigationRoutes: Equatable { + case waitingRound + case webview(String) +} + final class GroupChatViewModel { @Dependency private var session: SessionType + var replyPublisher: AnyPublisher<ReplyModel, Never> { + replySubject.eraseToAnyPublisher() + } + + var routesPublisher: AnyPublisher<GroupChatNavigationRoutes, Never> { + routesSubject.eraseToAnyPublisher() + } + let info: GroupChatInfo private var stagedReply: Reply? private var cancellables = Set<AnyCancellable>() private let replySubject = PassthroughSubject<ReplyModel, Never>() - - var roundURLPublisher: AnyPublisher<String, Never> { roundURLSubject.eraseToAnyPublisher() } - private let roundURLSubject = PassthroughSubject<String, Never>() - - var replyPublisher: AnyPublisher<ReplyModel, Never> { replySubject.eraseToAnyPublisher() } + private let routesSubject = PassthroughSubject<GroupChatNavigationRoutes, Never>() var messages: AnyPublisher<[ArraySection<ChatSection, GroupChatItem>], Never> { session.groupMessages(info.group) @@ -65,8 +74,11 @@ final class GroupChatViewModel { } func showRoundFrom(_ roundURL: String?) { - guard let urlString = roundURL else { return } - roundURLSubject.send(urlString) + if let urlString = roundURL, !urlString.isEmpty { + routesSubject.send(.webview(urlString)) + } else { + routesSubject.send(.waitingRound) + } } func abortReply() { diff --git a/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift b/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift index 3ec0712e0b1a481a9f82dadfc7102e1e702e5cc0..d1be4b6ee2b69fb786f1d371162c4f4d8c420f1c 100644 --- a/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift +++ b/Sources/ChatFeature/ViewModels/SingleChatViewModel.swift @@ -19,6 +19,7 @@ enum SingleChatNavigationRoutes: Equatable { case none case camera case library + case waitingRound case cameraPermission case libraryPermission case microphonePermission @@ -55,9 +56,23 @@ final class SingleChatViewModel { }.eraseToAnyPublisher() } + private func updateRecentState(_ contact: Contact) { + if contact.isRecent == true { + var contact = contact + contact.isRecent = false + session.update(contact) + } + } + + func viewDidAppear() { + updateRecentState(contact) + } + init(_ contact: Contact) { self.contactSubject = .init(contact) + updateRecentState(contact) + session.contacts(.withUserId(contact.userId)) .compactMap { $0.first } .sink { [unowned self] in contactSubject.send($0) } @@ -180,8 +195,11 @@ final class SingleChatViewModel { } func showRoundFrom(_ roundURL: String?) { - guard let urlString = roundURL else { return } - navigationRoutes.send(.webview(urlString)) + if let urlString = roundURL, !urlString.isEmpty { + navigationRoutes.send(.webview(urlString)) + } else { + navigationRoutes.send(.waitingRound) + } } func didRequestDelete(_ items: [ChatItem]) { diff --git a/Sources/ChatFeature/Views/Cells/AudioMessageView.swift b/Sources/ChatFeature/Views/Cells/AudioMessageView.swift index 38979a1438b4e603c10682e30bcf4c9ae1dfbed3..5982ffc49253c7d5b7a21939290b11f0f48e2f20 100644 --- a/Sources/ChatFeature/Views/Cells/AudioMessageView.swift +++ b/Sources/ChatFeature/Views/Cells/AudioMessageView.swift @@ -16,13 +16,14 @@ struct AudioMessageCellState { } final class AudioMessageView: UIView, CollectionCellContent { - private(set) var progressLabel = UILabel() - private(set) var dateLabel = UILabel() private let playerView = AudioView() private let stackView = UIStackView() private let shapeLayer = CAShapeLayer() private let bottomStack = UIStackView() - private(set) var lockerView = LockerView() + + private(set) var dateLabel = UILabel() + private(set) var progressLabel = UILabel() + private(set) var lockerImageView = UIImageView() var didTapLeft: (() -> Void)? var didTapRight: (() -> Void)? @@ -46,7 +47,6 @@ final class AudioMessageView: UIView, CollectionCellContent { dateLabel.text = nil progressLabel.text = nil playerView.timeLabel.text = nil - lockerView.icon.layer.removeAllAnimations() cancellables.removeAll() } @@ -77,6 +77,9 @@ final class AudioMessageView: UIView, CollectionCellContent { } private func setup() { + lockerImageView.contentMode = .center + lockerImageView.image = Asset.chatLocker.image + dateLabel.textColor = Asset.neutralWhite.color dateLabel.font = Fonts.Mulish.regular.font(size: 12.0) progressLabel.textColor = Asset.neutralWhite.color @@ -86,7 +89,7 @@ final class AudioMessageView: UIView, CollectionCellContent { bottomStack.spacing = 10 bottomStack.addArrangedSubview(progressLabel.pinning(at: .left(0))) bottomStack.addArrangedSubview(dateLabel.pinning(at: .right(0))) - bottomStack.addArrangedSubview(lockerView) + bottomStack.addArrangedSubview(lockerImageView) stackView.axis = .vertical stackView.addArrangedSubview(playerView) diff --git a/Sources/ChatFeature/Views/Cells/ImageMessageView.swift b/Sources/ChatFeature/Views/Cells/ImageMessageView.swift index a7564129d2cd2bdc5440e17888b446fd2a7d450c..88efa910ce3ef46a3b1f8012382b1287117b477d 100644 --- a/Sources/ChatFeature/Views/Cells/ImageMessageView.swift +++ b/Sources/ChatFeature/Views/Cells/ImageMessageView.swift @@ -5,17 +5,14 @@ typealias OutgoingImageCell = CollectionCell<FlexibleSpace, ImageMessageView> typealias IncomingImageCell = CollectionCell<ImageMessageView, FlexibleSpace> final class ImageMessageView: UIView, CollectionCellContent { - // MARK: UI - - let dateLabel = UILabel() - let progressLabel = UILabel() - let imageView = UIImageView() - let lockerView = LockerView() private let stackView = UIStackView() private let shapeLayer = CAShapeLayer() private let bottomStack = UIStackView() - // MARK: Lifecycle + private(set) var dateLabel = UILabel() + private(set) var progressLabel = UILabel() + private(set) var imageView = UIImageView() + private(set) var lockerImageView = UIImageView() override init(frame: CGRect) { super.init(frame: frame) @@ -33,12 +30,12 @@ final class ImageMessageView: UIView, CollectionCellContent { imageView.image = nil dateLabel.text = nil progressLabel.text = nil - lockerView.icon.layer.removeAllAnimations() } - // MARK: Private - private func setup() { + lockerImageView.contentMode = .center + lockerImageView.image = Asset.chatLocker.image + imageView.layer.cornerRadius = 10 dateLabel.font = Fonts.Mulish.regular.font(size: 12.0) @@ -48,7 +45,7 @@ final class ImageMessageView: UIView, CollectionCellContent { bottomStack.spacing = 10 bottomStack.addArrangedSubview(progressLabel.pinning(at: .left(0))) bottomStack.addArrangedSubview(dateLabel.pinning(at: .right(0))) - bottomStack.addArrangedSubview(lockerView) + bottomStack.addArrangedSubview(lockerImageView) stackView.axis = .vertical stackView.spacing = 5 diff --git a/Sources/ChatFeature/Views/Cells/ReplyStackMessageView.swift b/Sources/ChatFeature/Views/Cells/ReplyStackMessageView.swift index 594f9f88effa1a6d3f4fbb6551ab799232d20f61..20a74276710b492a845ad7b276fc79665a6a1baf 100644 --- a/Sources/ChatFeature/Views/Cells/ReplyStackMessageView.swift +++ b/Sources/ChatFeature/Views/Cells/ReplyStackMessageView.swift @@ -6,16 +6,17 @@ typealias OutgoingReplyCell = CollectionCell<FlexibleSpace, ReplyStackMessageVie typealias OutgoingFailedReplyCell = CollectionCell<FlexibleSpace, ReplyStackMessageView> final class ReplyStackMessageView: UIView, CollectionCellContent { - let roundButton = UIButton() - let dateLabel = UILabel() - let textView = TextView() - let replyView = ReplyView() - let lockerView = LockerView() - let senderLabel = UILabel() private let stackView = UIStackView() private let shapeLayer = CAShapeLayer() private let bottomStack = UIStackView() + private(set) var dateLabel = UILabel() + private(set) var textView = TextView() + private(set) var replyView = ReplyView() + private(set) var senderLabel = UILabel() + private(set) var roundButton = UIButton() + private(set) var lockerImageView = UIImageView() + var didTapShowRound: (() -> Void)? override init(frame: CGRect) { @@ -36,7 +37,6 @@ final class ReplyStackMessageView: UIView, CollectionCellContent { replyView.cleanUp() senderLabel.text = nil textView.resignFirstResponder() - lockerView.icon.layer.removeAllAnimations() didTapShowRound = nil } @@ -46,6 +46,9 @@ final class ReplyStackMessageView: UIView, CollectionCellContent { } private func setup() { + lockerImageView.contentMode = .center + lockerImageView.image = Asset.chatLocker.image + let attrString = NSAttributedString( string: "show mix", attributes: [ @@ -81,7 +84,7 @@ final class ReplyStackMessageView: UIView, CollectionCellContent { bottomStack.addArrangedSubview(roundButtonContainer) bottomStack.addArrangedSubview(dateLabel) - bottomStack.addArrangedSubview(lockerView) + bottomStack.addArrangedSubview(lockerImageView) bottomStack.setContentCompressionResistancePriority(.required, for: .horizontal) diff --git a/Sources/ChatFeature/Views/Cells/StackMessageView.swift b/Sources/ChatFeature/Views/Cells/StackMessageView.swift index 19e7361b3d7a536bd42e15990d76fa20646d7012..2c5a3c9f3f6c9e86735c28eb4edb64efcfd5d44f 100644 --- a/Sources/ChatFeature/Views/Cells/StackMessageView.swift +++ b/Sources/ChatFeature/Views/Cells/StackMessageView.swift @@ -6,15 +6,16 @@ typealias OutgoingTextCell = CollectionCell<FlexibleSpace, StackMessageView> typealias OutgoingFailedTextCell = CollectionCell<FlexibleSpace, StackMessageView> final class StackMessageView: UIView, CollectionCellContent { - let roundButton = UIButton() - let dateLabel = UILabel() - let textView = TextView() - let lockerView = LockerView() - let senderLabel = UILabel() private let stackView = UIStackView() private let shapeLayer = CAShapeLayer() private let bottomStack = UIStackView() + private(set) var dateLabel = UILabel() + private(set) var textView = TextView() + private(set) var senderLabel = UILabel() + private(set) var roundButton = UIButton() + private(set) var lockerImageView = UIImageView() + var didTapShowRound: (() -> Void)? override init(frame: CGRect) { @@ -34,7 +35,6 @@ final class StackMessageView: UIView, CollectionCellContent { textView.text = nil senderLabel.text = nil textView.resignFirstResponder() - lockerView.icon.layer.removeAllAnimations() didTapShowRound = nil } @@ -44,6 +44,9 @@ final class StackMessageView: UIView, CollectionCellContent { } private func setup() { + lockerImageView.contentMode = .center + lockerImageView.image = Asset.chatLocker.image + roundButton.addTarget( self, action: #selector(didTapRoundButton), @@ -70,7 +73,8 @@ final class StackMessageView: UIView, CollectionCellContent { bottomStack.addArrangedSubview(FlexibleSpace()) bottomStack.addArrangedSubview(roundButton) bottomStack.addArrangedSubview(dateLabel) - bottomStack.addArrangedSubview(lockerView) + bottomStack.addArrangedSubview(lockerImageView) + bottomStack.setCustomSpacing(8, after: dateLabel) stackView.spacing = 6 stackView.axis = .vertical diff --git a/Sources/ChatFeature/Views/LockerView.swift b/Sources/ChatFeature/Views/LockerView.swift deleted file mode 100644 index 552fed89103de016679cf1f1dbf3d5732e4bb6bd..0000000000000000000000000000000000000000 --- a/Sources/ChatFeature/Views/LockerView.swift +++ /dev/null @@ -1,44 +0,0 @@ -import UIKit -import Shared - -final class LockerView: UIView { - let icon = UIImageView() - let animation = CABasicAnimation() - - init() { - super.init(frame: .zero) - setup() - } - - required init?(coder: NSCoder) { nil } - - public func stop() { - icon.layer.removeAllAnimations() - icon.layer.opacity = 1.0 - } - - public func fail() { - icon.layer.removeAllAnimations() - icon.layer.opacity = 0.3 - } - - public func animate() { - icon.layer.removeAllAnimations() - icon.layer.add(animation, forKey: "opacity") - } - - private func setup() { - animation.fromValue = 1.0 - animation.toValue = 0.0 - animation.duration = 0.5 - animation.autoreverses = true - animation.repeatCount = .infinity - - icon.contentMode = .center - icon.image = Asset.chatLocker.image - - addSubview(icon) - - icon.snp.makeConstraints { $0.edges.equalToSuperview().inset(5) } - } -} diff --git a/Sources/ChatListFeature/Controller/ChatListController.swift b/Sources/ChatListFeature/Controller/ChatListController.swift index 20dce02844eff56a20f51c9951fa5c42e20fdbe6..d2d15a400f402ec532ee97a2407ccf88415c1c93 100644 --- a/Sources/ChatListFeature/Controller/ChatListController.swift +++ b/Sources/ChatListFeature/Controller/ChatListController.swift @@ -1,260 +1,228 @@ -import HUD -import DrawerFeature import UIKit import Theme +import Models import Shared import Combine import MenuFeature import DependencyInjection public final class ChatListController: UIViewController { - @Dependency private var hud: HUDType @Dependency private var coordinator: ChatListCoordinating @Dependency private var statusBarController: StatusBarStyleControlling - lazy private var menu = UIButton() - lazy private var cancel = UIButton() - lazy private var menuBadge = UILabel() - lazy private var titleLabel = UILabel() lazy private var screenView = ChatListView() - lazy private var menuView = ChatListMenuView() - lazy private var contactListButton = UIButton() - lazy private var contactSearchButton = UIButton() + lazy private var topLeftView = ChatListTopLeftNavView() + lazy private var topRightView = ChatListTopRightNavView() lazy private var tableController = ChatListTableController(viewModel) + lazy private var searchTableController = ChatSearchTableController(viewModel) + private var collectionDataSource: UICollectionViewDiffableDataSource<SectionId, Contact>! - private var shouldPresentMenu = false private let viewModel = ChatListViewModel() private var cancellables = Set<AnyCancellable>() private var drawerCancellables = Set<AnyCancellable>() - public override var canBecomeFirstResponder: Bool { true } - - public override var inputAccessoryView: UIView? { - if shouldPresentMenu { - tableController.numberOfSelectedRows = 0 + private var isEditingSearch = false { + didSet { + screenView.listContainerView + .showRecentsCollection(isEditingSearch ? false : shouldBeShowingRecents) } - - return shouldPresentMenu ? menuView : nil } - public init() { - super.init(nibName: nil, bundle: nil) + private var shouldBeShowingRecents = false { + didSet { + screenView.listContainerView + .showRecentsCollection(isEditingSearch ? false : shouldBeShowingRecents) + } } - required init?(coder: NSCoder) { nil } - public override func loadView() { view = screenView - - addChild(tableController) - screenView.insertSubview(tableController.view, belowSubview: screenView) - - tableController.view.snp.makeConstraints { make in - make.top.equalTo(screenView.searchView.snp.bottom) - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.equalToSuperview() - } - - tableController.didMove(toParent: self) } public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) statusBarController.style.send(.darkContent) - updateNavigationItems(false) - navigationController?.navigationBar.customize(backgroundColor: Asset.neutralWhite.color) } public override func viewDidLoad() { super.viewDidLoad() - setupNavigationBar() + setupChatList() setupBindings() + setupNavigationBar() + setupRecentContacts() } private func setupNavigationBar() { navigationItem.backButtonTitle = "" + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: topLeftView) + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: topRightView) + + topRightView.actionPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + switch $0 { + case .didTapSearch: + coordinator.toSearch(from: self) + case .didTapNewGroup: + coordinator.toNewGroup(from: self) + } + }.store(in: &cancellables) - contactSearchButton.tintColor = Asset.neutralDark.color - contactSearchButton.setImage(Asset.contactListSearch.image, for: .normal) - contactSearchButton.addTarget(self, action: #selector(didTapContactSearchButton), for: .touchUpInside) - - contactListButton.tintColor = Asset.neutralDark.color - contactListButton.setImage(Asset.chatListNew.image, for: .normal) - contactListButton.addTarget(self, action: #selector(didTapContactListButton), for: .touchUpInside) - - titleLabel.text = Localized.ChatList.title - titleLabel.textColor = Asset.neutralActive.color - titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) - - menu.tintColor = Asset.neutralDark.color - menu.setImage(Asset.chatListMenu.image, for: .normal) - menu.addTarget(self, action: #selector(didTapMenu), for: .touchUpInside) - menu.snp.makeConstraints { $0.width.equalTo(50) } - - menu.addSubview(menuBadge) - menuBadge.layer.cornerRadius = 5 - menuBadge.layer.masksToBounds = true - menuBadge.snp.makeConstraints { make in - make.centerY.equalTo(menu.snp.top) - make.centerX.equalTo(menu.snp.right).multipliedBy(0.8) + viewModel.badgeCountPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in topLeftView.updateBadge($0) } + .store(in: &cancellables) + + topLeftView.actionPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in coordinator.toSideMenu(from: self) } + .store(in: &cancellables) + } + + private func setupChatList() { + addChild(tableController) + addChild(searchTableController) + + screenView.listContainerView.addSubview(tableController.view) + screenView.searchListContainerView.addSubview(searchTableController.view) + + tableController.view.snp.makeConstraints { + $0.top.equalTo(screenView.listContainerView.collectionContainerView.snp.bottom) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() } - menuBadge.textColor = Asset.neutralWhite.color - menuBadge.backgroundColor = Asset.brandPrimary.color - menuBadge.font = Fonts.Mulish.bold.font(size: 14.0) + searchTableController.view.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + } - cancel.setTitleColor(Asset.neutralActive.color, for: .normal) - cancel.titleLabel?.font = Fonts.Mulish.semiBold.font(size: 14.0) - cancel.setTitle(Localized.ChatList.NavigationBar.cancel, for: .normal) + tableController.didMove(toParent: self) + searchTableController.didMove(toParent: self) + } - menu.accessibilityIdentifier = Localized.Accessibility.ChatList.menu + private func setupRecentContacts() { + screenView + .listContainerView + .collectionView + .register(ChatListRecentContactCell.self) + + collectionDataSource = UICollectionViewDiffableDataSource<SectionId, Contact>( + collectionView: screenView.listContainerView.collectionView + ) { collectionView, indexPath, contact in + let cell: ChatListRecentContactCell = collectionView.dequeueReusableCell(forIndexPath: indexPath) + cell.setup(title: contact.nickname ?? contact.username, image: contact.photo) + return cell + } + + screenView.listContainerView.collectionView.delegate = self + screenView.listContainerView.collectionView.dataSource = collectionDataSource + + viewModel.recentsPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + collectionDataSource.apply($0) + shouldBeShowingRecents = $0.numberOfItems > 0 + }.store(in: &cancellables) } private func setupBindings() { - viewModel.hud + screenView.searchView + .rightPublisher .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } + .sink { [unowned self] in coordinator.toScan(from: self) } .store(in: &cancellables) - viewModel.chatsRelay + screenView.searchView + .textPublisher + .removeDuplicates() .receive(on: DispatchQueue.main) - .sink { [unowned self] in - screenView.stackView.isHidden = !$0.isEmpty + .sink { [unowned self] query in + viewModel.updateSearch(query: query) + screenView.searchListContainerView.emptyView.updateSearched(content: query) + }.store(in: &cancellables) - if $0.isEmpty { - screenView.bringSubviewToFront(screenView.stackView) + Publishers.CombineLatest( + viewModel.searchPublisher, + screenView.searchView.textPublisher.removeDuplicates() + ) + .receive(on: DispatchQueue.main) + .sink { [unowned self] items, query in + guard query.isEmpty == false else { + screenView.searchListContainerView.isHidden = true + screenView.listContainerView.isHidden = false + screenView.bringSubviewToFront(screenView.listContainerView) + return } - }.store(in: &cancellables) - screenView.contactsButton - .publisher(for: .touchUpInside) - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toContacts(from: self) } - .store(in: &cancellables) + screenView.listContainerView.isHidden = true + screenView.searchListContainerView.isHidden = false - screenView.searchView.textPublisher - .removeDuplicates() - .sink { [unowned self] in viewModel.searchQueryRelay.send($0) } + guard items.numberOfItems > 0 else { + screenView.searchListContainerView.emptyView.isHidden = false + screenView.bringSubviewToFront(screenView.searchListContainerView) + screenView.searchListContainerView.bringSubviewToFront(screenView.searchListContainerView.emptyView) + return + } + + screenView.searchListContainerView.bringSubviewToFront(searchTableController.view) + screenView.searchListContainerView.emptyView.isHidden = true + } .store(in: &cancellables) - screenView.searchView.rightPublisher + screenView.searchView + .isEditingPublisher + .removeDuplicates() .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toScan(from: self) } + .sink { [unowned self] in isEditingSearch = $0 } .store(in: &cancellables) - tableController.deletePublisher + viewModel.chatsPublisher .receive(on: DispatchQueue.main) - .sink { [unowned self] ip in - if viewModel.isGroup(indexPath: ip) { - presentDrawer( - title: Localized.ChatList.DeleteGroup.title, - subtitle: Localized.ChatList.DeleteGroup.subtitle, - actionTitle: Localized.ChatList.DeleteGroup.action) { [weak self] in - self?.viewModel.deleteAndLeaveGroupFrom(indexPath: ip) - } - } else { - presentDrawer( - title: Localized.ChatList.Delete.title, - subtitle: Localized.ChatList.Delete.subtitle, - actionTitle: Localized.ChatList.Delete.delete) { [weak self] in - self?.viewModel.delete(indexPaths: [ip]) - } + .sink { [unowned self] in + guard $0.isEmpty == false else { + screenView.listContainerView.bringSubviewToFront(screenView.listContainerView.emptyView) + screenView.listContainerView.emptyView.isHidden = false + return } - }.store(in: &cancellables) - viewModel.badgeCount + screenView.listContainerView.bringSubviewToFront(tableController.view) + screenView.listContainerView.emptyView.isHidden = true + } + .store(in: &cancellables) + + screenView.searchListContainerView + .emptyView.searchButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in coordinator.toSearch(from: self) } + .store(in: &cancellables) + + screenView.listContainerView + .emptyView.contactsButton + .publisher(for: .touchUpInside) .receive(on: DispatchQueue.main) - .sink { [unowned self] in - menuBadge.text = " \($0) " - menuBadge.isHidden = $0 < 1 - }.store(in: &cancellables) + .sink { [unowned self] in coordinator.toContacts(from: self) } + .store(in: &cancellables) viewModel.isOnline .removeDuplicates() .receive(on: DispatchQueue.main) - .sink { [weak screenView] in screenView?.displayNetworkIssue(!$0) } + .sink { [weak screenView] connected in screenView?.showConnectingBanner(!connected) } .store(in: &cancellables) } +} - private func updateNavigationItems(_ isEditing: Bool) { - let leftStack = UIStackView() - leftStack.addArrangedSubview(titleLabel) - - let rightStack = UIStackView() - rightStack.spacing = 10 - rightStack.addArrangedSubview(contactListButton) - rightStack.addArrangedSubview(contactSearchButton) - - contactListButton.snp.makeConstraints { $0.width.equalTo(40) } - contactSearchButton.snp.makeConstraints { $0.width.equalTo(40) } - - if !isEditing { - leftStack.insertArrangedSubview(menu, at: 0) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: leftStack) - } else { - navigationItem.leftBarButtonItem = UIBarButtonItem( - customView: leftStack.pinning(at: .left(10)) - ) - } - - navigationItem.rightBarButtonItem = UIBarButtonItem( - customView: isEditing ? cancel : rightStack - ) - } - - @objc private func didTapContactListButton() { - coordinator.toContacts(from: self) - } - - @objc private func didTapContactSearchButton() { - coordinator.toSearch(from: self) - } - - @objc private func didTapMenu() { - coordinator.toSideMenu(from: self) - } - - private func presentDrawer( - title: String, - subtitle: String, - actionTitle: String, - action: @escaping () -> Void +extension ChatListController: UICollectionViewDelegate { + public func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath ) { - let actionButton = DrawerCapsuleButton(model: .init( - title: actionTitle, - style: .red - )) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: title, - color: Asset.neutralActive.color, - alignment: .left, - spacingAfter: 19 - ), - DrawerText( - font: Fonts.Mulish.regular.font(size: 16.0), - text: subtitle, - color: Asset.neutralBody.color, - alignment: .left, - lineHeightMultiple: 1.1, - spacingAfter: 39 - ), - actionButton - ]) - - actionButton.action.receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - action() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) + if let contact = collectionDataSource.itemIdentifier(for: indexPath) { + coordinator.toSingleChat(with: contact, from: self) + } } } diff --git a/Sources/ChatListFeature/Controller/ChatListSearchTableController.swift b/Sources/ChatListFeature/Controller/ChatListSearchTableController.swift new file mode 100644 index 0000000000000000000000000000000000000000..c1abc94ac78f3f3e95f1058a2e8ede9021d11616 --- /dev/null +++ b/Sources/ChatListFeature/Controller/ChatListSearchTableController.swift @@ -0,0 +1,128 @@ +import UIKit +import Shared +import Models +import Combine +import DependencyInjection + +class ChatSearchListTableViewDiffableDataSource: UITableViewDiffableDataSource<SearchSection, SearchItem> { + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch snapshot().sectionIdentifiers[section] { + case .chats: + return "CHATS" + case .connections: + return "CONNECTIONS" + } + } +} + +final class ChatSearchTableController: UITableViewController { + @Dependency private var coordinator: ChatListCoordinating + + private let viewModel: ChatListViewModel + private let cellHeight: CGFloat = 83.0 + private var cancellables = Set<AnyCancellable>() + private var tableDataSource: ChatSearchListTableViewDiffableDataSource? + + init(_ viewModel: ChatListViewModel) { + self.viewModel = viewModel + super.init(style: .grouped) + + tableDataSource = ChatSearchListTableViewDiffableDataSource( + tableView: tableView + ) { table, indexPath, item in + let cell = table.dequeueReusableCell(forIndexPath: indexPath, ofType: ChatListCell.self) + switch item { + case .chat(let subitem): + if case .contact(let info) = subitem { + cell.setupContact( + name: info.contact.nickname ?? info.contact.username, + image: info.contact.photo, + date: Date.fromTimestamp(info.lastMessage!.timestamp), + hasUnread: info.lastMessage!.unread, + preview: info.lastMessage!.payload.text + ) + } + + if case .group(let info) = subitem { + let date: Date = { + guard let lastMessage = info.lastMessage else { + return info.group.createdAt + } + + return Date.fromTimestamp(lastMessage.timestamp) + }() + + let hasUnread: Bool = { + guard let lastMessage = info.lastMessage else { + return false + } + + return lastMessage.unread + }() + + cell.setupGroup( + name: info.group.name, + date: date, + preview: info.lastMessage?.payload.text, + hasUnread: hasUnread + ) + } + + case .connection(let contact): + cell.setupContact( + name: contact.nickname ?? contact.username, + image: contact.photo, + date: nil, + hasUnread: false, + preview: contact.username + ) + } + + return cell + } + } + + required init?(coder: NSCoder) { nil } + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.separatorStyle = .none + tableView.tableFooterView = UIView() + tableView.sectionIndexColor = .blue + tableView.register(ChatListCell.self) + tableView.dataSource = tableDataSource + view.backgroundColor = Asset.neutralWhite.color + + viewModel.searchPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in tableDataSource?.apply($0, animatingDifferences: false) } + .store(in: &cancellables) + } +} + +extension ChatSearchTableController { + override func tableView( + _ tableView: UITableView, + heightForRowAt: IndexPath + ) -> CGFloat { + return cellHeight + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if let item = tableDataSource?.itemIdentifier(for: indexPath) { + switch item { + case .chat(let chat): + switch chat { + case .contact(let info): + guard info.contact.status == .friend else { return } + coordinator.toSingleChat(with: info.contact, from: self) + case .group(let info): + coordinator.toGroupChat(with: info, from: self) + } + case .connection(let contact): + coordinator.toContact(contact, from: self) + } + } + } +} diff --git a/Sources/ChatListFeature/Controller/ChatListTableController.swift b/Sources/ChatListFeature/Controller/ChatListTableController.swift index af7f3c55d81242a6173947f5dee2d8bc96341b7d..17b744113f0ecc43eb229f62cd5f4ddd5d8da385 100644 --- a/Sources/ChatListFeature/Controller/ChatListTableController.swift +++ b/Sources/ChatListFeature/Controller/ChatListTableController.swift @@ -1,38 +1,21 @@ import UIKit import Shared -import Combine import Models +import Combine import DifferenceKit +import DrawerFeature import DependencyInjection final class ChatListTableController: UITableViewController { - // MARK: Injected - @Dependency private var coordinator: ChatListCoordinating - // MARK: Published - - @Published var numberOfSelectedRows = 0 - - // MARK: Properties - - var longPressPublisher: AnyPublisher<Void, Never> { - longPressRelay.eraseToAnyPublisher() - } - - var deletePublisher: AnyPublisher<IndexPath, Never> { - deleteRelay.eraseToAnyPublisher() - } - - private var rows = [GenericChatInfo]() - private let viewModel: ChatListViewModelType + private var rows = [Chat]() + private let viewModel: ChatListViewModel + private let cellHeight: CGFloat = 83.0 private var cancellables = Set<AnyCancellable>() - private let longPressRelay = PassthroughSubject<Void, Never>() - private let deleteRelay = PassthroughSubject<IndexPath, Never>() - - // MARK: Lifecycle + private var drawerCancellables = Set<AnyCancellable>() - init(_ viewModel: ChatListViewModelType) { + init(_ viewModel: ChatListViewModel) { self.viewModel = viewModel super.init(style: .grouped) } @@ -41,25 +24,15 @@ final class ChatListTableController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() - setupTableView() - setupBindings() - } - // MARK: Private - - private func setupTableView() { tableView.separatorStyle = .none tableView.backgroundColor = .clear tableView.alwaysBounceVertical = true tableView.register(ChatListCell.self) - tableView.tintColor = Asset.brandPrimary.color - tableView.allowsMultipleSelectionDuringEditing = true tableView.tableFooterView = UIView() - } - private func setupBindings() { viewModel - .chatsRelay + .chatsPublisher .receive(on: DispatchQueue.main) .sink { [unowned self] in guard !self.rows.isEmpty else { @@ -81,79 +54,144 @@ final class ChatListTableController: UITableViewController { } }.store(in: &cancellables) } +} - // MARK: UITableViewDataSource - - override func tableView(_ tableView: UITableView, - cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(forIndexPath: indexPath, ofType: ChatListCell.self) - let chatInfo = rows[indexPath.row] - - if let contact = chatInfo.contact { - let name = contact.nickname ?? contact.username - cell.titleLabel.text = name - cell.avatarView.setupProfile(title: name, image: chatInfo.contact?.photo, size: .large) - } else { - cell.titleLabel.text = chatInfo.groupInfo!.group.name - cell.avatarView.setupGroup(size: .large) - } - - cell.didLongPress = { [weak longPressRelay] in - longPressRelay?.send() - } - - if let latestGroupMessage = chatInfo.groupInfo?.lastMessage { - cell.titleLabel.alpha = 1.0 - cell.avatarView.alpha = 1.0 - cell.date = Date.fromTimestamp(latestGroupMessage.timestamp) - cell.previewLabel.text = latestGroupMessage.payload.text - cell.unreadView.backgroundColor = latestGroupMessage.unread ? Asset.brandPrimary.color : .clear - } - - if let latestE2EMessage = chatInfo.latestE2EMessage { - cell.titleLabel.alpha = 1.0 - cell.avatarView.alpha = 1.0 - cell.date = Date.fromTimestamp(latestE2EMessage.timestamp) - cell.previewLabel.text = latestE2EMessage.payload.text - cell.unreadView.backgroundColor = latestE2EMessage.unread ? Asset.brandPrimary.color : .clear - } - - return cell +extension ChatListTableController { + override func tableView( + _ tableView: UITableView, + numberOfRowsInSection: Int + ) -> Int { + return rows.count } - override func tableView(_: UITableView, numberOfRowsInSection: Int) -> Int { rows.count } + override func tableView( + _ tableView: UITableView, + heightForRowAt: IndexPath + ) -> CGFloat { + return cellHeight + } - override func tableView(_: UITableView, heightForRowAt: IndexPath) -> CGFloat { 72 } + override func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { - override func tableView(_: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let delete = UIContextualAction(style: .normal, title: nil) { [weak self] _, _, complete in - self?.deleteRelay.send(indexPath) + guard let self = self else { return } + self.didRequestDeletionOf(self.rows[indexPath.row]) complete(true) } delete.image = Asset.chatListDeleteSwipe.image delete.backgroundColor = Asset.accentDanger.color - return UISwipeActionsConfiguration(actions: [delete]) } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if !tableView.isEditing { - let genericChat = viewModel.chatsRelay.value[indexPath.row] - - guard let contact = genericChat.contact else { - coordinator.toGroupChat(with: genericChat.groupInfo!, from: self) - return - } - - guard contact.status == .friend else { return } - coordinator.toSingleChat(with: contact, from: self) - } else { - numberOfSelectedRows += 1 + switch rows[indexPath.row] { + case .contact(let info): + guard info.contact.status == .friend else { return } + coordinator.toSingleChat(with: info.contact, from: self) + case .group(let info): + coordinator.toGroupChat(with: info, from: self) + } + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + + let cell = tableView.dequeueReusableCell(forIndexPath: indexPath, ofType: ChatListCell.self) + + if case .contact(let info) = rows[indexPath.row] { + cell.setupContact( + name: info.contact.nickname ?? info.contact.username, + image: info.contact.photo, + date: Date.fromTimestamp(info.lastMessage!.timestamp), + hasUnread: info.lastMessage!.unread, + preview: info.lastMessage!.payload.text + ) + } + + if case .group(let info) = rows[indexPath.row] { + let date: Date = { + guard let lastMessage = info.lastMessage else { + return info.group.createdAt + } + + return Date.fromTimestamp(lastMessage.timestamp) + }() + + let hasUnread: Bool = { + guard let lastMessage = info.lastMessage else { + return false + } + + return lastMessage.unread + }() + + cell.setupGroup( + name: info.group.name, + date: date, + preview: info.lastMessage?.payload.text, + hasUnread: hasUnread + ) } + + return cell } - override func tableView(_: UITableView, didDeselectRowAt: IndexPath) { - numberOfSelectedRows -= 1 + private func didRequestDeletionOf(_ item: Chat) { + let title: String + let subtitle: String + let actionTitle: String + let actionClosure: () -> Void + + switch item { + case .group(let info): + title = Localized.ChatList.DeleteGroup.title + subtitle = Localized.ChatList.DeleteGroup.subtitle + actionTitle = Localized.ChatList.DeleteGroup.action + actionClosure = { [weak viewModel] in viewModel?.leave(info.group) } + + case .contact(let info): + title = Localized.ChatList.Delete.title + subtitle = Localized.ChatList.Delete.subtitle + actionTitle = Localized.ChatList.Delete.delete + actionClosure = { [weak viewModel] in viewModel?.clear(info.contact) } + } + + let actionButton = DrawerCapsuleButton(model: .init(title: actionTitle, style: .red)) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: title, + color: Asset.neutralActive.color, + alignment: .left, + spacingAfter: 19 + ), + DrawerText( + font: Fonts.Mulish.regular.font(size: 16.0), + text: subtitle, + color: Asset.neutralBody.color, + alignment: .left, + lineHeightMultiple: 1.1, + spacingAfter: 39 + ), + actionButton + ]) + + actionButton.action.receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + actionClosure() + } + }.store(in: &drawerCancellables) + + coordinator.toDrawer(drawer, from: self) } } diff --git a/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift b/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift index 75958ab2f3d02726bc2af8464d5b9f8956d73d1c..42b7dda644aa04aa7f928ad8006d59715d442b4b 100644 --- a/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift +++ b/Sources/ChatListFeature/Coordinator/ChatListCoordinator.swift @@ -11,7 +11,9 @@ public protocol ChatListCoordinating { func toScan(from: UIViewController) func toSearch(from: UIViewController) func toContacts(from: UIViewController) + func toNewGroup(from: UIViewController) func toSideMenu(from: UIViewController) + func toContact(_: Contact, from: UIViewController) func toSingleChat(with: Contact, from: UIViewController) func toDrawer(_: UIViewController, from: UIViewController) func toGroupChat(with: GroupChatInfo, from: UIViewController) @@ -25,7 +27,9 @@ public struct ChatListCoordinator: ChatListCoordinating { var scanFactory: () -> UIViewController var searchFactory: () -> UIViewController + var newGroupFactory: () -> UIViewController var contactsFactory: () -> UIViewController + var contactFactory: (Contact) -> UIViewController var singleChatFactory: (Contact) -> UIViewController var groupChatFactory: (GroupChatInfo) -> UIViewController var sideMenuFactory: (MenuItem, UIViewController) -> UIViewController @@ -33,13 +37,17 @@ public struct ChatListCoordinator: ChatListCoordinating { public init( scanFactory: @escaping () -> UIViewController, searchFactory: @escaping () -> UIViewController, + newGroupFactory: @escaping () -> UIViewController, contactsFactory: @escaping () -> UIViewController, + contactFactory: @escaping (Contact) -> UIViewController, singleChatFactory: @escaping (Contact) -> UIViewController, groupChatFactory: @escaping (GroupChatInfo) -> UIViewController, sideMenuFactory: @escaping (MenuItem, UIViewController) -> UIViewController ) { self.scanFactory = scanFactory self.searchFactory = searchFactory + self.contactFactory = contactFactory + self.newGroupFactory = newGroupFactory self.contactsFactory = contactsFactory self.sideMenuFactory = sideMenuFactory self.groupChatFactory = groupChatFactory @@ -63,6 +71,11 @@ public extension ChatListCoordinator { pushPresenter.present(screen, from: parent) } + func toContact(_ contact: Contact, from parent: UIViewController) { + let screen = contactFactory(contact) + pushPresenter.present(screen, from: parent) + } + func toSingleChat(with contact: Contact, from parent: UIViewController) { let screen = singleChatFactory(contact) pushPresenter.present(screen, from: parent) @@ -78,6 +91,11 @@ public extension ChatListCoordinator { sidePresenter.present(screen, from: parent) } + func toNewGroup(from parent: UIViewController) { + let screen = newGroupFactory() + pushPresenter.present(screen, from: parent) + } + func toDrawer(_ drawer: UIViewController, from parent: UIViewController) { bottomPresenter.present(drawer, from: parent) } diff --git a/Sources/ChatListFeature/Models/Chat.swift b/Sources/ChatListFeature/Models/Chat.swift new file mode 100644 index 0000000000000000000000000000000000000000..3e159479aeaade0a5b1355d19eccf627f5d5f3b7 --- /dev/null +++ b/Sources/ChatListFeature/Models/Chat.swift @@ -0,0 +1,34 @@ +import Models +import Foundation +import DifferenceKit + +enum Chat: Equatable, Differentiable, Hashable { + case group(GroupChatInfo) + case contact(SingleChatInfo) + + var differenceIdentifier: Data { + switch self { + case .contact(let info): + return info.contact.userId + case .group(let info): + return info.group.groupId + } + } + + var orderingDate: Date { + switch self { + case .group(let info): + if let lastMessage = info.lastMessage { + return Date.fromTimestamp(lastMessage.timestamp) + } else { + return info.group.createdAt + } + case .contact(let info): + guard let lastMessage = info.lastMessage else { + fatalError("Should have an E2E chat without a last message") + } + + return Date.fromTimestamp(lastMessage.timestamp) + } + } +} diff --git a/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift b/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift index a78407c0f6225acef3e5efafb8a389c6bb85b69a..ceb096f383a164fd6ead74e9819089b4e45a5807 100644 --- a/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift +++ b/Sources/ChatListFeature/ViewModel/ChatListViewModel.swift @@ -1,190 +1,141 @@ import HUD +import UIKit import Shared import Models import Combine import Defaults -import Foundation import Integration import DependencyInjection -protocol ChatListViewModelType { - var myId: Data { get } - var username: String { get } - var editState: EditStateHandler { get } - var searchQueryRelay: CurrentValueSubject<String, Never> { get } - var chatsRelay: CurrentValueSubject<[GenericChatInfo], Never> { get } - - var isOnline: AnyPublisher<Bool, Never> { get } - var badgeCount: AnyPublisher<Int, Never> { get } +enum SearchSection { + case chats + case connections +} - func delete(indexPaths: [IndexPath]?) +enum SearchItem: Equatable, Hashable { + case chat(Chat) + case connection(Contact) } -final class ChatListViewModel: ChatListViewModelType { +typealias RecentsSnapshot = NSDiffableDataSourceSnapshot<SectionId, Contact> +typealias SearchSnapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem> + +final class ChatListViewModel { @Dependency private var session: SessionType - @KeyObject(.username, defaultValue: "") var myUsername: String + var isOnline: AnyPublisher<Bool, Never> { + session.isOnline + } - let editState = EditStateHandler() - let chatsRelay = CurrentValueSubject<[GenericChatInfo], Never>([]) - let searchQueryRelay = CurrentValueSubject<String, Never>("") - private var cancellables = Set<AnyCancellable>() + var chatsPublisher: AnyPublisher<[Chat], Never> { + chatsSubject.eraseToAnyPublisher() + } - var hud: AnyPublisher<HUDStatus, Never> { hudRelay.eraseToAnyPublisher() } - private let hudRelay = CurrentValueSubject<HUDStatus, Never>(.none) + var hudPublisher: AnyPublisher<HUDStatus, Never> { + hudSubject.eraseToAnyPublisher() + } + + var recentsPublisher: AnyPublisher<RecentsSnapshot, Never> { + session.contacts(.isRecent).map { + let section = SectionId() + var snapshot = RecentsSnapshot() + snapshot.appendSections([section]) + snapshot.appendItems($0, toSection: section) + return snapshot + }.eraseToAnyPublisher() + } + + var searchPublisher: AnyPublisher<SearchSnapshot, Never> { + Publishers.CombineLatest3( + session.contacts(.all), + chatsPublisher, + searchSubject + .removeDuplicates() + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + ) + .map { (contacts, chats, query) in + let connectionItems = contacts.filter { + let username = $0.username.lowercased().contains(query.lowercased()) + let nickname = $0.nickname?.lowercased().contains(query.lowercased()) ?? false + return username || nickname + }.map(SearchItem.connection) + + let chatItems = chats.filter { + switch $0 { + case .contact(let info): + let username = info.contact.username.lowercased().contains(query.lowercased()) + let nickname = info.contact.nickname?.lowercased().contains(query.lowercased()) ?? false + let lastMessage = info.lastMessage?.payload.text.lowercased().contains(query.lowercased()) ?? false + return username || nickname || lastMessage + + case .group(let info): + let name = info.group.name.lowercased().contains(query.lowercased()) + let last = info.lastMessage?.payload.text.lowercased().contains(query.lowercased()) ?? false + return name || last + } + }.map(SearchItem.chat) + + var snapshot = SearchSnapshot() + + if connectionItems.count > 0 { + snapshot.appendSections([.connections]) + snapshot.appendItems(connectionItems, toSection: .connections) + } + + if chatItems.count > 0 { + snapshot.appendSections([.chats]) + snapshot.appendItems(chatItems, toSection: .chats) + } + + return snapshot + }.eraseToAnyPublisher() + } - var badgeCount: AnyPublisher<Int, Never> { + var badgeCountPublisher: AnyPublisher<Int, Never> { Publishers.CombineLatest( session.contacts(.received), session.groups(.pending) - ).map { $0.0.count + $0.1.count } + ) + .map { $0.0.count + $0.1.count } .eraseToAnyPublisher() } - var isOnline: AnyPublisher<Bool, Never> { session.isOnline } - - var myId: Data { session.myId } - - var username: String { myUsername } + private var cancellables = Set<AnyCancellable>() + private let searchSubject = CurrentValueSubject<String, Never>("") + private let chatsSubject = CurrentValueSubject<[Chat], Never>([]) + private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) init() { - let searchStream = searchQueryRelay - .removeDuplicates() - .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) - .eraseToAnyPublisher() - - Publishers.CombineLatest3( + Publishers.CombineLatest( session.singleChats(.all), - session.groupChats(.accepted), - searchStream - ).map { data -> [GenericChatInfo] in - let singles = data.0 - let groupies = data.1 - let searched = data.2 - - var generics = [GenericChatInfo]() - - for single in singles { - generics.append( - GenericChatInfo( - contact: single.contact, - groupInfo: nil, - latestE2EMessage: single.lastMessage - ) - ) - } - - for group in groupies { - generics.append( - GenericChatInfo( - contact: nil, - groupInfo: group, - latestE2EMessage: nil - ) - ) - } - - if !searched.isEmpty { - generics = generics.filter { filtering in - if let contact = filtering.contact { - let username = contact.username.lowercased().contains(searched.lowercased()) - let nickname = contact.nickname?.lowercased().contains(searched.lowercased()) ?? false - let lastMessage = filtering.latestE2EMessage?.payload.text.lowercased().contains(searched.lowercased()) ?? false - - return username || nickname || lastMessage - } else { - if let group = filtering.groupInfo?.group { - let name = group.name.lowercased().contains(searched.lowercased()) - let last = filtering.groupInfo?.lastMessage?.payload.text.lowercased().contains(searched.lowercased()) ?? false - return name || last - } - } - - return false - } - } - - #warning("TODO: Use enum to differentiate chats") - - return generics.sorted { infoA, infoB in - if let singleA = infoA.latestE2EMessage { - if let singleB = infoB.latestE2EMessage { - /// aSingle bSingle - return singleA.timestamp > singleB.timestamp - } else { - /// aSingle bGroup - let groupB = infoB.groupInfo! - - if let lastGM = groupB.lastMessage { - /// aSingle bGroup w/ message - return singleA.timestamp > lastGM.timestamp - } else { - /// aSingle bGroup w/out message - return true - } - } - } else { - let groupA = infoA.groupInfo! - - if let lastGM = groupA.lastMessage { - /// aGroup w/ message - - if let singleB = infoB.latestE2EMessage { - /// aGroup w/ message bSingle - - return lastGM.timestamp > singleB.timestamp - } else { - let groupB = infoB.groupInfo! - /// aGroup w/ message bGroup - - if let lastGM2 = groupB.lastMessage { - return lastGM.timestamp > lastGM2.timestamp - } else { - return true - } - } - } else { - /// aGroup w/out message b? - return false - } - } - } - }.sink { [unowned self] in chatsRelay.send($0) } + session.groupChats(.accepted) + ).map { + let groups = $0.1.map(Chat.group) + let chats = $0.0.map(Chat.contact) + return (chats + groups).sorted { $0.orderingDate > $1.orderingDate } + } + .sink { [unowned self] in chatsSubject.send($0) } .store(in: &cancellables) } - func isGroup(indexPath: IndexPath) -> Bool { - chatsRelay.value[indexPath.row].contact == nil + func updateSearch(query: String) { + searchSubject.send(query) } - func deleteAndLeaveGroupFrom(indexPath: IndexPath) { - guard let group = chatsRelay.value[indexPath.row].groupInfo?.group else { - fatalError("Tried to delete a group from an index path that is not one") - } + func leave(_ group: Group) { + hudSubject.send(.on(nil)) do { - hudRelay.send(.on(nil)) try session.leave(group: group) - hudRelay.send(.none) + session.deleteAll(from: group) + hudSubject.send(.none) } catch { - hudRelay.send(.error(.init(with: error))) + hudSubject.send(.error(.init(with: error))) } } - func delete(indexPaths: [IndexPath]?) { - guard let selectedIndexPaths = indexPaths else { - let contacts = chatsRelay.value.compactMap { $0.contact } - let groups = chatsRelay.value.compactMap { $0.groupInfo?.group } - - groups.forEach(session.deleteAll(from:)) - contacts.forEach(session.deleteAll(from:)) - return - } - - let contacts = selectedIndexPaths.compactMap { chatsRelay.value[$0.row].contact } - let groups = selectedIndexPaths.compactMap { chatsRelay.value[$0.row].groupInfo?.group } - - groups.forEach(session.deleteAll(from:)) - contacts.forEach(session.deleteAll(from:)) + func clear(_ contact: Contact) { + session.deleteAll(from: contact) } } diff --git a/Sources/ChatListFeature/Views/ChatListCell.swift b/Sources/ChatListFeature/Views/ChatListCell.swift index fe59df03888602a5b06ccdc64dece68eb5e026f1..8eea62d2278b630efeb6afb1d12fad725cd23c18 100644 --- a/Sources/ChatListFeature/Views/ChatListCell.swift +++ b/Sources/ChatListFeature/Views/ChatListCell.swift @@ -2,99 +2,74 @@ import UIKit import Shared final class ChatListCell: UITableViewCell { - let titleLabel = UILabel() - let unreadView = UIView() - let previewLabel = UILabel() - let dateLabel = UILabel() - let avatarView = AvatarView() - let coloringView = UIView() - - private var timer: Timer? - - var date: Date? { - didSet { - updateTimeAgoLabel() - } + private let titleLabel = UILabel() + private let unreadView = UIView() + private let previewLabel = UILabel() + private let dateLabel = UILabel() + private let avatarView = AvatarView() + private var lastDate: Date? { + didSet { updateTimeAgoLabel() } } - deinit { - timer?.invalidate() - } + private var timer: Timer? - var didLongPress: EmptyClosure? - private let longPressGesture = UILongPressGestureRecognizer(target: nil, action: nil) + deinit { timer?.invalidate() } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) - longPressGesture.addTarget(self, action: #selector(longAction)) - addGestureRecognizer(longPressGesture) - - backgroundColor = .clear - selectedBackgroundView = UIView() - multipleSelectionBackgroundView = UIView() - - timer = Timer.scheduledTimer(withTimeInterval: 59, repeats: true) { [weak self] _ in - self?.updateTimeAgoLabel() - } - previewLabel.numberOfLines = 2 + dateLabel.textAlignment = .right + unreadView.layer.cornerRadius = 8 avatarView.layer.cornerRadius = 21 - dateLabel.textAlignment = .right avatarView.layer.masksToBounds = true - dateLabel.setContentHuggingPriority(.init(rawValue: 251), for: .vertical) - dateLabel.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - dateLabel.setContentCompressionResistancePriority(.init(rawValue: 751), for: .vertical) - dateLabel.setContentCompressionResistancePriority(.init(rawValue: 751), for: .horizontal) - + dateLabel.textAlignment = .right + selectedBackgroundView = UIView() unreadView.backgroundColor = .clear backgroundColor = Asset.neutralWhite.color - titleLabel.textColor = Asset.neutralActive.color - previewLabel.textColor = Asset.neutralDisabled.color dateLabel.textColor = Asset.neutralWeak.color + titleLabel.textColor = Asset.neutralActive.color + + dateLabel.font = Fonts.Mulish.semiBold.font(size: 13.0) + titleLabel.font = Fonts.Mulish.semiBold.font(size: 16.0) + + + timer = Timer.scheduledTimer(withTimeInterval: 59, repeats: true) { [weak self] _ in + self?.updateTimeAgoLabel() + } - titleLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) - previewLabel.font = Fonts.Mulish.regular.font(size: 12.0) - dateLabel.font = Fonts.Mulish.regular.font(size: 10.0) + dateLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - insertSubview(coloringView, belowSubview: contentView) contentView.addSubview(titleLabel) contentView.addSubview(unreadView) contentView.addSubview(avatarView) contentView.addSubview(previewLabel) contentView.addSubview(dateLabel) - coloringView.snp.makeConstraints { - $0.top.equalToSuperview().offset(6) - $0.left.equalToSuperview() - $0.right.equalToSuperview() - $0.bottom.equalToSuperview().offset(-6) - } - avatarView.snp.makeConstraints { - $0.top.equalTo(coloringView).offset(6) - $0.left.equalToSuperview().offset(28) + $0.top.equalToSuperview().offset(14) + $0.left.equalToSuperview().offset(24) $0.width.height.equalTo(48) - $0.bottom.equalTo(coloringView).offset(-6) } titleLabel.snp.makeConstraints { - $0.top.equalTo(coloringView).offset(4) + $0.top.equalToSuperview().offset(10) $0.left.equalTo(avatarView.snp.right).offset(16) $0.right.lessThanOrEqualTo(dateLabel.snp.left).offset(-10) } dateLabel.snp.makeConstraints { $0.top.equalTo(titleLabel) - $0.right.equalToSuperview().offset(-24) + $0.right.equalToSuperview().offset(-25) } previewLabel.snp.makeConstraints { $0.left.equalTo(titleLabel) - $0.top.equalTo(titleLabel.snp.bottom).offset(3) + $0.top.equalTo(titleLabel.snp.bottom).offset(2) $0.right.lessThanOrEqualTo(unreadView.snp.left).offset(-3) + $0.bottom.lessThanOrEqualToSuperview().offset(-10) } unreadView.snp.makeConstraints { @@ -108,43 +83,65 @@ final class ChatListCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() - date = nil + lastDate = nil titleLabel.text = nil - previewLabel.text = nil + previewLabel.attributedText = nil avatarView.prepareForReuse() } - override func willTransition(to state: UITableViewCell.StateMask) { - super.willTransition(to: state) - - UIView.transition(with: coloringView, duration: 0.4, options: .transitionCrossDissolve) { - let isEditing = state == .showingEditControl - self.coloringView.backgroundColor = isEditing ? - Asset.neutralSecondary.color : Asset.neutralWhite.color - } - - UIView.transition(with: dateLabel, duration: 0.4, options: .transitionCrossDissolve) { - let isEditing = state == .showingEditControl - self.dateLabel.alpha = isEditing ? 0.0 : 1.0 + private func updateTimeAgoLabel() { + if let date = lastDate { + dateLabel.text = date.asRelativeFromNow() } + } - UIView.transition(with: avatarView, duration: 0.1, options: .transitionCrossDissolve) { - let isEditing = state == .showingEditControl - - self.avatarView.snp.updateConstraints { - $0.left.equalToSuperview().offset(isEditing ? 16 : 28) - } + func setupContact( + name: String, + image: Data?, + date: Date?, + hasUnread: Bool, + preview: String + ) { + titleLabel.text = name + setPreview(string: preview) + avatarView.setupProfile(title: name, image: image, size: .large) + unreadView.backgroundColor = hasUnread ? Asset.brandPrimary.color : .clear + + if let date = date { + lastDate = date + } else { + dateLabel.text = nil } } - private func updateTimeAgoLabel() { - guard let date = date else { return } - dateLabel.text = date.asRelativeFromNow() + func setupGroup( + name: String, + date: Date, + preview: String?, + hasUnread: Bool + ) { + lastDate = date + titleLabel.text = name + setPreview(string: preview) + avatarView.setupGroup(size: .large) + unreadView.backgroundColor = hasUnread ? Asset.brandPrimary.color : .clear } - @objc private func longAction(_ sender: UILongPressGestureRecognizer) { - if sender.state == .began { - didLongPress?() + private func setPreview(string: String?) { + guard let preview = string else { + previewLabel.attributedText = nil + return } + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineHeightMultiple = 1.1 + + previewLabel.attributedText = NSAttributedString( + string: preview, + attributes: [ + .paragraphStyle: paragraphStyle, + .font: Fonts.Mulish.regular.font(size: 14.0), + .foregroundColor: Asset.neutralSecondaryAlternative.color + ]) } } diff --git a/Sources/ChatListFeature/Views/ChatListContainerView.swift b/Sources/ChatListFeature/Views/ChatListContainerView.swift new file mode 100644 index 0000000000000000000000000000000000000000..1be2c99cf0e9cc736f0c02acd1b9c3e54f5be688 --- /dev/null +++ b/Sources/ChatListFeature/Views/ChatListContainerView.swift @@ -0,0 +1,114 @@ +import UIKit +import Shared + +final class ChatSearchListContainerView: UIView{ + let emptyView = ChatSearchEmptyView() + + init() { + super.init(frame: .zero) + + addSubview(emptyView) + + emptyView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + required init?(coder: NSCoder) { nil } +} + +final class ChatListContainerView: UIView { + let separatorView = UIView() + let emptyView = ChatListEmptyView() + let collectionContainerView = UIView() + lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + + private let layout: UICollectionViewFlowLayout = { + let layout = UICollectionViewFlowLayout() + layout.minimumLineSpacing = 35 + layout.itemSize = CGSize(width: 56, height: 80) + layout.scrollDirection = .horizontal + return layout + }() + + init() { + super.init(frame: .zero) + + collectionView.showsHorizontalScrollIndicator = false + separatorView.backgroundColor = Asset.neutralLine.color + collectionView.backgroundColor = Asset.neutralWhite.color + collectionView.contentInset = UIEdgeInsets(top: 0, left: 30, bottom: 0, right: 30) + + addSubview(emptyView) + addSubview(collectionContainerView) + collectionContainerView.addSubview(collectionView) + collectionContainerView.addSubview(separatorView) + + collectionContainerView.snp.makeConstraints { + $0.bottom.equalTo(snp.top) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.height.equalTo(110) + } + + collectionView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.right.equalToSuperview() + } + + separatorView.snp.makeConstraints { + $0.top.equalTo(collectionView.snp.bottom).offset(20) + $0.height.equalTo(1) + $0.left.equalToSuperview().offset(24) + $0.right.equalToSuperview().offset(-24) + $0.bottom.equalToSuperview() + } + + emptyView.snp.makeConstraints { + $0.top.equalTo(collectionContainerView.snp.bottom) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + } + } + + required init?(coder: NSCoder) { nil } + + func showRecentsCollection(_ show: Bool) { + if show == true && collectionContainerView.alpha != 0.0 || + show == false && collectionContainerView.alpha == 0.0 { + return + } + + if show == true { + collectionContainerView.alpha = 0.0 + collectionContainerView.snp.updateConstraints { + $0.bottom.equalTo(snp.top).offset(collectionContainerView.bounds.height + 20) + } + + UIView.animate(withDuration: 0.3, delay: 0.3, options: .curveEaseInOut) { + self.collectionContainerView.alpha = 1.0 + } + + UIView.animate(withDuration: 0.3, delay: 0.15, options: .curveEaseInOut) { + self.setNeedsLayout() + self.layoutIfNeeded() + } + } else { + collectionContainerView.alpha = 1.0 + collectionContainerView.snp.updateConstraints { + $0.bottom.equalTo(snp.top) + } + + UIView.animate(withDuration: 0.2, delay: 0.0, options: .curveEaseInOut) { + self.collectionContainerView.alpha = 0.0 + } + + UIView.animate(withDuration: 0.2, delay: 0.15, options: .curveEaseInOut) { + self.setNeedsLayout() + self.layoutIfNeeded() + } + } + } +} diff --git a/Sources/ChatListFeature/Views/ChatListEmptyView.swift b/Sources/ChatListFeature/Views/ChatListEmptyView.swift new file mode 100644 index 0000000000000000000000000000000000000000..374e184970bcad877ee7ca507369bb219df32265 --- /dev/null +++ b/Sources/ChatListFeature/Views/ChatListEmptyView.swift @@ -0,0 +1,48 @@ +import UIKit +import Shared + +final class ChatListEmptyView: UIView { + private let titleLabel = UILabel() + private let stackView = UIStackView() + private(set) var contactsButton = CapsuleButton() + + init() { + super.init(frame: .zero) + + backgroundColor = Asset.neutralWhite.color + let paragraph = NSMutableParagraphStyle() + paragraph.lineHeightMultiple = 1.2 + paragraph.alignment = .center + + titleLabel.numberOfLines = 0 + titleLabel.attributedText = NSAttributedString( + string: Localized.ChatList.emptyTitle, + attributes: [ + .paragraphStyle: paragraph, + .foregroundColor: Asset.neutralActive.color, + .font: Fonts.Mulish.bold.font(size: 24.0) + ] + ) + + contactsButton.setStyle(.brandColored) + contactsButton.setTitle(Localized.ChatList.action, for: .normal) + + stackView.spacing = 24 + stackView.axis = .vertical + stackView.alignment = .center + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(contactsButton) + + addSubview(stackView) + + stackView.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.top.greaterThanOrEqualToSuperview() + $0.left.equalToSuperview().offset(24) + $0.right.equalToSuperview().offset(-24) + $0.bottom.lessThanOrEqualToSuperview() + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/ChatListFeature/Views/ChatListRecentContactCell.swift b/Sources/ChatListFeature/Views/ChatListRecentContactCell.swift new file mode 100644 index 0000000000000000000000000000000000000000..4adbdeef783245dcd73a1697f8325654952ab3e2 --- /dev/null +++ b/Sources/ChatListFeature/Views/ChatListRecentContactCell.swift @@ -0,0 +1,89 @@ +import UIKit +import Shared + +final class ChatListRecentContactCell: UICollectionViewCell { + private let titleLabel = UILabel() + private let containerView = UIView() + private let avatarView = AvatarView() + + override init(frame: CGRect) { + super.init(frame: frame) + + contentView.backgroundColor = .white + + let newLabel = UILabel() + newLabel.text = "NEW" + newLabel.textColor = Asset.neutralWhite.color + newLabel.font = Fonts.Mulish.bold.font(size: 8.0) + + let newContainerView = UIView() + newContainerView.layer.cornerRadius = 6.0 + newContainerView.layer.masksToBounds = true + newContainerView.backgroundColor = Asset.accentSafe.color + + titleLabel.numberOfLines = 2 + titleLabel.textAlignment = .center + titleLabel.lineBreakMode = .byWordWrapping + titleLabel.textColor = Asset.neutralDark.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 14.0) + + contentView.addSubview(titleLabel) + contentView.addSubview(containerView) + + containerView.addSubview(avatarView) + containerView.addSubview(newContainerView) + + newContainerView.addSubview(newLabel) + + containerView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.right.equalToSuperview() + } + + newContainerView.snp.makeConstraints { + $0.top.equalTo(containerView.snp.top) + $0.right.equalTo(containerView.snp.right) + } + + newLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(3) + $0.center.equalToSuperview() + $0.left.equalToSuperview().offset(3) + } + + avatarView.snp.makeConstraints { + $0.width.equalTo(48) + $0.height.equalTo(48) + $0.top.equalToSuperview().offset(4) + $0.left.equalToSuperview().offset(4) + $0.right.equalToSuperview().offset(-4) + $0.bottom.equalToSuperview().offset(-4) + } + + titleLabel.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.top.equalTo(containerView.snp.bottom).offset(5) + $0.left.greaterThanOrEqualToSuperview() + $0.right.lessThanOrEqualToSuperview() + $0.bottom.lessThanOrEqualToSuperview() + } + } + + required init?(coder: NSCoder) { nil } + + override func prepareForReuse() { + super.prepareForReuse() + titleLabel.text = nil + avatarView.prepareForReuse() + } + + func setup(title: String, image: Data?) { + titleLabel.text = title + avatarView.setupProfile( + title: title, + image: image, + size: .large + ) + } +} diff --git a/Sources/ChatListFeature/Views/ChatListTopLeftNavView.swift b/Sources/ChatListFeature/Views/ChatListTopLeftNavView.swift new file mode 100644 index 0000000000000000000000000000000000000000..6cc8a78df568a521afbb39b6fe69cd4197aa5e4e --- /dev/null +++ b/Sources/ChatListFeature/Views/ChatListTopLeftNavView.swift @@ -0,0 +1,72 @@ +import UIKit +import Shared +import Combine + +final class ChatListTopLeftNavView: UIView { + private let titleLabel = UILabel() + private let badgeLabel = UILabel() + private let menuButton = UIButton() + private let stackView = UIStackView() + private let badgeContainerView = UIView() + + var actionPublisher: AnyPublisher<Void, Never> { + actionSubject.eraseToAnyPublisher() + } + + private let actionSubject = PassthroughSubject<Void, Never>() + + init() { + super.init(frame: .zero) + + titleLabel.text = Localized.ChatList.title + titleLabel.textColor = Asset.neutralActive.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) + + menuButton.tintColor = Asset.neutralDark.color + menuButton.setImage(Asset.chatListMenu.image, for: .normal) + menuButton.addTarget(self, action: #selector(didTapMenu), for: .touchUpInside) + + badgeLabel.textColor = Asset.neutralWhite.color + badgeLabel.font = Fonts.Mulish.bold.font(size: 12.0) + + badgeContainerView.layer.cornerRadius = 5 + badgeContainerView.layer.masksToBounds = true + badgeContainerView.backgroundColor = Asset.brandPrimary.color + + badgeContainerView.addSubview(badgeLabel) + menuButton.addSubview(badgeContainerView) + stackView.addArrangedSubview(menuButton) + stackView.addArrangedSubview(titleLabel) + addSubview(stackView) + + badgeLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(3) + $0.center.equalToSuperview() + $0.left.equalToSuperview().offset(3) + } + + badgeContainerView.snp.makeConstraints { + $0.centerY.equalTo(menuButton.snp.top) + $0.centerX.equalTo(menuButton.snp.right).multipliedBy(0.8) + } + + menuButton.snp.makeConstraints { $0.width.equalTo(50) } + stackView.snp.makeConstraints { $0.edges.equalToSuperview() } + } + + required init?(coder: NSCoder) { nil } + + @objc private func didTapMenu() { + actionSubject.send() + } + + func updateBadge(_ count: Int) { + guard count > 0 else { + badgeContainerView.isHidden = true + return + } + + badgeLabel.text = "\(count)" + badgeContainerView.isHidden = false + } +} diff --git a/Sources/ChatListFeature/Views/ChatListTopRightNavView.swift b/Sources/ChatListFeature/Views/ChatListTopRightNavView.swift new file mode 100644 index 0000000000000000000000000000000000000000..817893e1a1df3d5f50cf241cfb0d5b60ababe2ae --- /dev/null +++ b/Sources/ChatListFeature/Views/ChatListTopRightNavView.swift @@ -0,0 +1,49 @@ +import UIKit +import Shared +import Combine + +final class ChatListTopRightNavView: UIView { + enum Action { + case didTapSearch + case didTapNewGroup + } + + var actionPublisher: AnyPublisher<Action, Never> { + actionSubject.eraseToAnyPublisher() + } + + private let stackView = UIStackView() + private let searchButton = UIButton() + private let newGroupButton = UIButton() + private let actionSubject = PassthroughSubject<Action, Never>() + + init() { + super.init(frame: .zero) + + searchButton.tintColor = Asset.neutralDark.color + newGroupButton.tintColor = Asset.neutralDark.color + searchButton.setImage(Asset.chatListUd.image, for: .normal) + newGroupButton.setImage(Asset.chatListNewGroup.image, for: .normal) + searchButton.addTarget(self, action: #selector(didTapSearch), for: .touchUpInside) + newGroupButton.addTarget(self, action: #selector(didTapNewGroup), for: .touchUpInside) + + stackView.spacing = 10 + stackView.addArrangedSubview(newGroupButton) + stackView.addArrangedSubview(searchButton) + addSubview(stackView) + + searchButton.snp.makeConstraints { $0.width.equalTo(40) } + newGroupButton.snp.makeConstraints { $0.width.equalTo(40) } + stackView.snp.makeConstraints { $0.edges.equalToSuperview() } + } + + required init?(coder: NSCoder) { nil } + + @objc private func didTapSearch() { + actionSubject.send(.didTapSearch) + } + + @objc private func didTapNewGroup() { + actionSubject.send(.didTapNewGroup) + } +} diff --git a/Sources/ChatListFeature/Views/ChatListView.swift b/Sources/ChatListFeature/Views/ChatListView.swift index 918cffcc651ccddb61d844ad61ce61799d1d6895..c7303c48d04db46983e2985d40745ce236d72c43 100644 --- a/Sources/ChatListFeature/Views/ChatListView.swift +++ b/Sources/ChatListFeature/Views/ChatListView.swift @@ -2,93 +2,75 @@ import UIKit import Shared final class ChatListView: UIView { - let titleLabel = UILabel() let snackBar = SnackBar() - let stackView = UIStackView() - let contactsButton = CapsuleButton() + let containerView = UIView() let searchView = SearchComponent() - - var networkIssueVisibleConstraint: NSLayoutConstraint? - var networkIssueInvisibleConstraint: NSLayoutConstraint? + let listContainerView = ChatListContainerView() + let searchListContainerView = ChatSearchListContainerView() init() { super.init(frame: .zero) - setup() - } - - required init?(coder: NSCoder) { nil } - - func displayNetworkIssue(_ flag: Bool) { - self.networkIssueInvisibleConstraint?.isActive = !flag - self.networkIssueVisibleConstraint?.isActive = flag - - snackBar.alpha = flag ? 0 : 1 - - UIView.animate(withDuration: 0.5) { - self.setNeedsLayout() - self.layoutIfNeeded() - self.snackBar.alpha = flag ? 1 : 0 - } - } - private func setup() { - snackBar.alpha = 0.0 backgroundColor = Asset.neutralWhite.color - - let paragraph = NSMutableParagraphStyle() - paragraph.lineHeightMultiple = 1.2 - paragraph.alignment = .center - - titleLabel.numberOfLines = 0 - titleLabel.attributedText = NSAttributedString( - string: Localized.ChatList.emptyTitle, - attributes: [ - .paragraphStyle: paragraph, - .foregroundColor: Asset.neutralActive.color, - .font: Fonts.Mulish.bold.font(size: 24.0) - ] - ) - - contactsButton.setStyle(.brandColored) - contactsButton.setTitle(Localized.ChatList.action, for: .normal) - + listContainerView.backgroundColor = Asset.neutralWhite.color + searchListContainerView.backgroundColor = Asset.neutralWhite.color searchView.update(placeholder: "Search chats") - stackView.spacing = 24 - stackView.axis = .vertical - stackView.alignment = .center - stackView.addArrangedSubview(titleLabel) - stackView.addArrangedSubview(contactsButton) - addSubview(snackBar) addSubview(searchView) - addSubview(stackView) + addSubview(containerView) + containerView.addSubview(searchListContainerView) + containerView.addSubview(listContainerView) + + snackBar.snp.makeConstraints { + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalTo(snp.top) + } - setupConstraints() - } + searchView.snp.makeConstraints { + $0.top.equalTo(snackBar.snp.bottom).offset(20) + $0.left.equalToSuperview().offset(20) + $0.right.equalToSuperview().offset(-20) + } - private func setupConstraints() { - NSLayoutConstraint.activate([ - snackBar.leftAnchor.constraint(equalTo: leftAnchor), - snackBar.rightAnchor.constraint(equalTo: rightAnchor) - ]) + containerView.snp.makeConstraints { + $0.top.equalTo(searchView.snp.bottom) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + } - networkIssueVisibleConstraint = snackBar.topAnchor.constraint(equalTo: topAnchor) - networkIssueInvisibleConstraint = snackBar.bottomAnchor.constraint(equalTo: topAnchor) + listContainerView.snp.makeConstraints { + $0.edges.equalToSuperview() + } - networkIssueInvisibleConstraint?.isActive = true - snackBar.translatesAutoresizingMaskIntoConstraints = false + searchListContainerView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + required init?(coder: NSCoder) { nil } - searchView.snp.makeConstraints { make in - make.top.equalTo(snackBar.snp.bottom).offset(20) - make.left.equalToSuperview().offset(20) - make.right.equalToSuperview().offset(-20) + 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) + } } - stackView.snp.makeConstraints { make in - make.centerY.equalToSuperview() - make.left.equalToSuperview().offset(24) - make.right.equalToSuperview().offset(-24) + 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/ChatListFeature/Views/ChatSearchEmptyView.swift b/Sources/ChatListFeature/Views/ChatSearchEmptyView.swift new file mode 100644 index 0000000000000000000000000000000000000000..2fe1471c5d1f7d9d967a2ca24b50a6950c4067c3 --- /dev/null +++ b/Sources/ChatListFeature/Views/ChatSearchEmptyView.swift @@ -0,0 +1,57 @@ +import UIKit +import Shared + +final class ChatSearchEmptyView: UIView { + private let titleLabel = UILabel() + private let stackView = UIStackView() + private let descriptionLabel = UILabel() + private(set) var searchButton = CapsuleButton() + + init() { + super.init(frame: .zero) + backgroundColor = Asset.neutralWhite.color + + titleLabel.textColor = Asset.brandPrimary.color + titleLabel.font = Fonts.Mulish.bold.font(size: 24.0) + + let paragraph = NSMutableParagraphStyle() + paragraph.lineHeightMultiple = 1.2 + + descriptionLabel.numberOfLines = 0 + descriptionLabel.attributedText = NSAttributedString( + string: "was not found in your connections or in a chat. Click below to search for them as a new connection.", + attributes: [ + .paragraphStyle: paragraph, + .foregroundColor: Asset.neutralActive.color, + .font: Fonts.Mulish.regular.font(size: 16.0) + ] + ) + + searchButton.setStyle(.brandColored) + searchButton.setTitle("Search for a connection", for: .normal) + + stackView.axis = .vertical + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(descriptionLabel) + stackView.addArrangedSubview(searchButton) + + stackView.setCustomSpacing(10, after: titleLabel) + stackView.setCustomSpacing(30, after: descriptionLabel) + + addSubview(stackView) + + stackView.snp.makeConstraints { + $0.centerY.equalToSuperview().multipliedBy(0.5) + $0.top.greaterThanOrEqualToSuperview() + $0.left.equalToSuperview().offset(24) + $0.right.equalToSuperview().offset(-24) + $0.bottom.lessThanOrEqualToSuperview() + } + } + + required init?(coder: NSCoder) { nil } + + func updateSearched(content: String) { + titleLabel.text = content + } +} diff --git a/Sources/Database/DB+Contact.swift b/Sources/Database/DB+Contact.swift index e8771ab9a3d359a0a4b51ea5d7d2b5d928dd4548..4207fea75d83ea0ef6e56ae361cb5a172c8773c7 100644 --- a/Sources/Database/DB+Contact.swift +++ b/Sources/Database/DB+Contact.swift @@ -10,6 +10,7 @@ extension Contact: Persistable { case userId case status case username + case isRecent case nickname case marshaled case createdAt @@ -23,6 +24,10 @@ extension Contact: Persistable { switch request { case .all: return Contact.all() + case .isRecent: + return Contact + .filter(Column.isRecent == true) + .order(Column.createdAt.desc) case .verificationInProgress: return Contact.filter(Column.status == Contact.Status.verificationInProgress.rawValue) case .failed: diff --git a/Sources/Database/DB+GroupChatInfo.swift b/Sources/Database/DB+GroupChatInfo.swift index 233b89525031ef534d1df7b84b8d59ac664086c6..4be4dfad3fccda964b68873b5ddcf3cc4253aeed 100644 --- a/Sources/Database/DB+GroupChatInfo.swift +++ b/Sources/Database/DB+GroupChatInfo.swift @@ -17,6 +17,15 @@ extension GroupChatInfo: Requestable { }.order(GroupMessage.Column.timestamp.desc) switch request { + case .fromGroup(let groupId): + return Group + .filter(Group.Column.status == Group.Status.participating.rawValue) + .filter(Group.Column.groupId == groupId) + .with(lastMessageCTE) + .including(optional: lastMessage) + .including(all: Group.members.forKey("members")) + .asRequest(of: Self.self) + case .accepted: return Group .filter(Group.Column.status == Group.Status.participating.rawValue) diff --git a/Sources/Database/DatabaseManager.swift b/Sources/Database/DatabaseManager.swift index db9d5565c113dc3c3d62e78d7a54dcf2b69dc983..86271c2ebfcc146182d867aedf7dd622168807df 100644 --- a/Sources/Database/DatabaseManager.swift +++ b/Sources/Database/DatabaseManager.swift @@ -115,15 +115,26 @@ extension GRDBDatabaseManager: DatabaseManager { public func setup() throws { var migrator = DatabaseMigrator() - let path = NSSearchPathForDirectoriesInDomains( - .documentDirectory, .userDomainMask, true - )[0] - .appending("/xxmessenger.sqlite") + let oldPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] + .appending("/xxmessenger.sqlite") + + let url = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: "group.elixxir.messenger")! + .appendingPathComponent("database") + .appendingPathExtension("sqlite") + + if FileManager.default.fileExists(atPath: oldPath) && !FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.moveItem(atPath: oldPath, toPath: url.path) + } catch { + fatalError("Couldn't migrate database from old path to new one: \(error.localizedDescription)") + } + } - databaseQueue = try DatabaseQueue(path: path) + databaseQueue = try DatabaseQueue(path: url.path) try FileManager.default.setAttributes([ .protectionKey : FileProtectionType.completeUntilFirstUserAuthentication - ], ofItemAtPath: path) + ], ofItemAtPath: url.path) migrator.registerMigration("v1") { db in try db.create(table: Contact.databaseTableName, ifNotExists: true) { table in @@ -237,6 +248,14 @@ extension GRDBDatabaseManager: DatabaseManager { try db.rename(table: "temp_\(Group.databaseTableName)", to: Group.databaseTableName) } + migrator.registerMigration("v2") { db in + try db.alter(table: Contact.databaseTableName) { table in + table.add(column: Contact.Column.isRecent.rawValue, .boolean) + } + + try Contact.updateAll(db, Contact.Column.isRecent.set(to: false)) + } + try migrator.migrate(databaseQueue) } } diff --git a/Sources/Integration/Client.swift b/Sources/Integration/Client.swift index 51bdf9d3365534ad96e669e99cb7230111d3a6dd..bdd6784d144195da495cfa24db7784fcdd08b999 100644 --- a/Sources/Integration/Client.swift +++ b/Sources/Integration/Client.swift @@ -225,7 +225,7 @@ public class Client { } private func updatePreImage() { - if let defaults = UserDefaults(suiteName: "group.io.xxlabs.notification") { + if let defaults = UserDefaults(suiteName: "group.elixxir.messenger") { defaults.set(bindings.getPreImages(), forKey: "preImage") } } diff --git a/Sources/Integration/Extensions.swift b/Sources/Integration/Extensions.swift index 5cfa63f047e404573bbcc20228d337c2c7e0cee4..93cf186a5df2963c605b2dc6a93c7ede36425468 100644 --- a/Sources/Integration/Extensions.swift +++ b/Sources/Integration/Extensions.swift @@ -12,7 +12,8 @@ extension Contact { marshaled: try! contact.marshal(), username: contact.retrieve(fact: .username) ?? "", nickname: nil, - createdAt: Date() + createdAt: Date(), + isRecent: false ) } } diff --git a/Sources/Integration/Implementations/Bindings.swift b/Sources/Integration/Implementations/Bindings.swift index c0ab7acfdef259bc1438b4a74de19c93bb1e18c1..ce843f4a7f44392a3705f8c738fd3b2cf99a09a4 100644 --- a/Sources/Integration/Implementations/Bindings.swift +++ b/Sources/Integration/Implementations/Bindings.swift @@ -9,6 +9,7 @@ public let evaluateNotification: NotificationEvaluation = BindingsNotificationsF public protocol NotificationReportProtocol { func forMe() -> Bool func type() -> String + func source() -> Data? } public protocol NotificationManyReportProtocol { @@ -369,22 +370,12 @@ extension BindingsClient: BindingsInterface { } } - /// Registers device token on backend for push notifications - /// - /// - Parameters: - /// - string: Device token provided by APNS - /// - /// - Throws: If an exception was raised on - /// backend such as timing out - /// - public func registerNotifications(_ token: String) throws { - log(type: .crumbs) + public func registerNotifications(_ token: Data) throws { + let tokenString = token.map { String(format: "%02hhx", $0) }.joined() do { - try register(forNotifications: token) - log(string: "Registered for notifications using token: \(token)", type: .info) + try register(forNotifications: tokenString) } catch { - log(string: error.localizedDescription, type: .error) throw error.friendly() } } diff --git a/Sources/Integration/Interfaces/BindingsInterface.swift b/Sources/Integration/Interfaces/BindingsInterface.swift index ed5b307775eac621c5dfe9395d827e5e8f9a9766..34e0e4207c6e1f7625cec55ddf44d7946fa18e7a 100644 --- a/Sources/Integration/Interfaces/BindingsInterface.swift +++ b/Sources/Integration/Interfaces/BindingsInterface.swift @@ -112,7 +112,7 @@ public protocol BindingsInterface { func getPreImages() -> String - func registerNotifications(_: String) throws + func registerNotifications(_ token: Data) throws func unregisterNotifications() throws diff --git a/Sources/Integration/Listeners.swift b/Sources/Integration/Listeners.swift index f820cb04f57dfa4dfcc383a24081943c0e9b246a..23f9f2234250a113862d693254238c8bc8664b74 100644 --- a/Sources/Integration/Listeners.swift +++ b/Sources/Integration/Listeners.swift @@ -5,6 +5,10 @@ import Foundation import os.log import Combine +import Combine + +import Combine + public extension BindingsClient { static func listenLogs() { let callback = LogCallback { log(string: $0 ?? "", type: .bindings) } @@ -13,7 +17,7 @@ public extension BindingsClient { func listenPreImageUpdates() { let callback = PreImageCallback { [weak self] _, _ in - if let defaults = UserDefaults(suiteName: "group.io.xxlabs.notification") { + if let defaults = UserDefaults(suiteName: "group.elixxir.messenger") { let preImage = self?.getPreImages() defaults.set(preImage, forKey: "preImage") } diff --git a/Sources/Integration/Mocks/BindingsMock.swift b/Sources/Integration/Mocks/BindingsMock.swift index 1235f0409f78f70e7c68f1bcd83b62c07b19f458..8eaf8a0688d3efdaab0ba1ccedad20bd4b6c7577 100644 --- a/Sources/Integration/Mocks/BindingsMock.swift +++ b/Sources/Integration/Mocks/BindingsMock.swift @@ -64,7 +64,7 @@ public final class BindingsMock: BindingsInterface { public func unregisterNotifications() throws {} - public func registerNotifications(_: String) throws {} + public func registerNotifications(_: Data) throws {} public func compress(image: Data, _: @escaping(Result<Data, Error>) -> Void) {} @@ -85,6 +85,7 @@ public final class BindingsMock: BindingsInterface { public func listenMessages(_: @escaping (Message) -> Void) throws {} + public func initializeBackup( passphrase: String, callback: @escaping (Data) -> Void @@ -94,6 +95,8 @@ public final class BindingsMock: BindingsInterface { callback: @escaping (Data) -> Void ) -> BackupInterface { BindingsBackupMock() } + public func listenBackups(_: @escaping (Data) -> Void) -> BackupInterface { fatalError() } + public func listenNetworkUpdates(_: @escaping (Bool) -> Void) {} public func confirm(_: Data, _ completion: @escaping (Result<Bool, Error>) -> Void) { @@ -207,7 +210,8 @@ extension Contact { marshaled: "brad\(n)".data(using: .utf8)!, username: "brad\(n)", nickname: nil, - createdAt: Date() + createdAt: Date(), + isRecent: false )) } @@ -223,7 +227,8 @@ extension Contact { marshaled: "angelinajolie".data(using: .utf8)!, username: "angelinajolie", nickname: "Angelica Jolie", - createdAt: Date() + createdAt: Date(), + isRecent: false ) static let carlRequested = Contact( @@ -235,7 +240,8 @@ extension Contact { marshaled: "carlsagan".data(using: .utf8)!, username: "carlsagan", nickname: "Carl Sagan", - createdAt: Date.distantPast + createdAt: Date.distantPast, + isRecent: false ) static let elonRequested = Contact( @@ -247,7 +253,8 @@ extension Contact { marshaled: "elonmusk".data(using: .utf8)!, username: "elonmusk", nickname: "Elon Musk", - createdAt: Date.distantPast + createdAt: Date.distantPast, + isRecent: false ) static let georgeDiscovered = Contact( @@ -259,7 +266,8 @@ extension Contact { marshaled: "georgebenson74".data(using: .utf8)!, username: "bruno_muniz74", nickname: "Bruno Muniz", - createdAt: Date() + createdAt: Date(), + isRecent: false ) } diff --git a/Sources/Integration/Mocks/UserDiscoveryMock.swift b/Sources/Integration/Mocks/UserDiscoveryMock.swift index 69cd094e0eae4ed5c281d5cc3058ad7e94945dec..910faf080c1b476af9c63677a25a6211f8f8a448 100644 --- a/Sources/Integration/Mocks/UserDiscoveryMock.swift +++ b/Sources/Integration/Mocks/UserDiscoveryMock.swift @@ -35,7 +35,8 @@ final class UserDiscoveryMock: UserDiscoveryInterface { marshaled: "mock_username".data(using: .utf8)!, username: "mock_username", nickname: "mock_nickname", - createdAt: Date() + createdAt: Date(), + isRecent: false ))) } } diff --git a/Sources/Integration/Session/Session+Contacts.swift b/Sources/Integration/Session/Session+Contacts.swift index 1c6690b55619b4c63a12f5025765923844fedeeb..53204708f1fdb09e27d1f48b0bbbde0a76c40a90 100644 --- a/Sources/Integration/Session/Session+Contacts.swift +++ b/Sources/Integration/Session/Session+Contacts.swift @@ -198,6 +198,8 @@ extension Session { switch $0 { case .success(let confirmed): + contact.isRecent = true + contact.createdAt = Date() contact.status = confirmed ? .friend : .confirmationFailed log(string: "Confirming request from \(title) = \(confirmed)", type: confirmed ? .info : .error) case .failure(let error): @@ -216,6 +218,8 @@ extension Session { stored.photo = contact.photo stored.phone = contact.phone stored.nickname = contact.nickname + stored.isRecent = contact.isRecent + stored.createdAt = contact.createdAt try dbManager.save(stored) try dbManager.updateAll( diff --git a/Sources/Integration/Session/Session+Notifications.swift b/Sources/Integration/Session/Session+Notifications.swift index 3eea889ce7d3b75dae46da9f277848ef8490ae1b..b4e98e9647d8292051d8df61679d629740c84407 100644 --- a/Sources/Integration/Session/Session+Notifications.swift +++ b/Sources/Integration/Session/Session+Notifications.swift @@ -1,6 +1,8 @@ +import Foundation + extension Session { - public func registerNotifications(_ string: String) throws { - try client.bindings.registerNotifications(string) + public func registerNotifications(_ token: Data) throws { + try client.bindings.registerNotifications(token) } public func unregisterNotifications() throws { diff --git a/Sources/Integration/Session/Session.swift b/Sources/Integration/Session/Session.swift index 7e0f40d9ba28d6ffac4c05f37f6aa0cbe5d6b16a..2982b295ff964a8eae68c7fd6d58d4ca37788a7c 100644 --- a/Sources/Integration/Session/Session.swift +++ b/Sources/Integration/Session/Session.swift @@ -380,8 +380,14 @@ public final class Session: SessionType { .store(in: &cancellables) client.messages - .sink { [unowned self] in _ = try? dbManager.save($0) } - .store(in: &cancellables) + .sink { [unowned self] in + if var contact: Contact = try? dbManager.fetch(.withUserId($0.sender)).first { + contact.isRecent = false + _ = try? dbManager.save(contact) + } + + _ = try? dbManager.save($0) + }.store(in: &cancellables) client.network .sink { [unowned self] in networkMonitor.update($0) } @@ -404,6 +410,8 @@ public final class Session: SessionType { .sink { [unowned self] in if var contact: Contact = try? dbManager.fetch(.withUserId($0.userId)).first { contact.status = .friend + contact.isRecent = true + contact.createdAt = Date() _ = try? dbManager.save(contact) toastController.enqueueToast(model: .init( @@ -424,4 +432,14 @@ public final class Session: SessionType { guard let message: GroupMessage = try? dbManager.fetch(.withUniqueId(messageId)).first else { return nil } return message.payload.text } + + public func getContactWith(userId: Data) -> Contact? { + let contact: Contact? = try? dbManager.fetch(.withUserId(userId)).first + return contact + } + + public func getGroupChatInfoWith(groupId: Data) -> GroupChatInfo? { + let info: GroupChatInfo? = try? dbManager.fetch(.fromGroup(groupId)).first + return info + } } diff --git a/Sources/Integration/Session/SessionType.swift b/Sources/Integration/Session/SessionType.swift index a1e2c7331472bbbe6d2c64d9a2daeeeaa7bbd64d..121469499c3841d9e1921f14b7f5efdf52a8093e 100644 --- a/Sources/Integration/Session/SessionType.swift +++ b/Sources/Integration/Session/SessionType.swift @@ -46,7 +46,7 @@ public protocol SessionType { // Notifications func unregisterNotifications() throws - func registerNotifications(_ string: String) throws + func registerNotifications(_ token: Data) throws // Network @@ -93,4 +93,7 @@ public protocol SessionType { members: [Contact], _ completion: @escaping (Result<(Group, [GroupMember]), Error>) -> Void ) + + func getContactWith(userId: Data) -> Contact? + func getGroupChatInfoWith(groupId: Data) -> GroupChatInfo? } diff --git a/Sources/Integration/XXNetwork.swift b/Sources/Integration/XXNetwork.swift index 34d656ec2d432f54ecac1693cbf8e27d05c9db30..1273a26a68e4487227c96fe953ecabe23994e450 100644 --- a/Sources/Integration/XXNetwork.swift +++ b/Sources/Integration/XXNetwork.swift @@ -143,7 +143,7 @@ extension XXNetwork: XXNetworking { let bindings = B.login(FileManager.xxPath, secret, "", &error) if let error = error { throw error } - if let defaults = UserDefaults(suiteName: "group.io.xxlabs.notification") { + if let defaults = UserDefaults(suiteName: "group.elixxir.messenger") { defaults.set(bindings!.receptionId.base64EncodedString(), forKey: "receptionId") } diff --git a/Sources/LaunchFeature/LaunchController.swift b/Sources/LaunchFeature/LaunchController.swift new file mode 100644 index 0000000000000000000000000000000000000000..33c2f8dad55d29d02a7e74b3d70437fbaea4aa6f --- /dev/null +++ b/Sources/LaunchFeature/LaunchController.swift @@ -0,0 +1,152 @@ +import HUD +import UIKit +import Shared +import Combine +import PushFeature +import DependencyInjection + + +public final class LaunchController: UIViewController { + @Dependency private var hud: HUDType + @Dependency private var coordinator: LaunchCoordinating + + lazy private var screenView = LaunchView() + + private let blocker = UpdateBlocker() + private let viewModel = LaunchViewModel() + public var pendingPushRoute: PushRouter.Route? + private var cancellables = Set<AnyCancellable>() + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewModel.viewDidAppear() + } + + public override func loadView() { + view = screenView + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController? + .navigationBar + .customize(translucent: true) + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + screenView.setupGradient() + } + + public override func viewDidLoad() { + super.viewDidLoad() + + viewModel.hudPublisher + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + viewModel.routePublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + switch $0 { + case .chats: + if let pushRoute = pendingPushRoute { + switch pushRoute { + case .requests: + coordinator.toRequests(from: self) + + case .groupChat(id: let groupId): + if let groupInfo = viewModel.getGroupInfoWith(groupId: groupId) { + coordinator.toGroupChat(with: groupInfo, from: self) + return + } + coordinator.toChats(from: self) + + case .contactChat(id: let userId): + if let contact = viewModel.getContactWith(userId: userId) { + coordinator.toSingleChat(with: contact, from: self) + return + } + coordinator.toChats(from: self) + } + + return + } + + coordinator.toChats(from: self) + + case .onboarding(let ndf): + coordinator.toOnboarding(with: ndf, from: self) + + case .update(let model): + offerUpdate(model: model) + } + }.store(in: &cancellables) + } + + private func offerUpdate(model: Update) { + let drawerView = UIView() + drawerView.backgroundColor = Asset.neutralSecondary.color + drawerView.layer.cornerRadius = 5 + + let vStack = UIStackView() + vStack.axis = .vertical + vStack.spacing = 10 + drawerView.addSubview(vStack) + + vStack.snp.makeConstraints { + $0.top.equalToSuperview().offset(18) + $0.left.equalToSuperview().offset(18) + $0.right.equalToSuperview().offset(-18) + $0.bottom.equalToSuperview().offset(-18) + } + + let title = UILabel() + title.text = "App Update" + title.textAlignment = .center + title.textColor = Asset.neutralDark.color + + let body = UILabel() + body.numberOfLines = 0 + body.textAlignment = .center + body.textColor = Asset.neutralDark.color + + let update = CapsuleButton() + update.publisher(for: .touchUpInside) + .sink { UIApplication.shared.open(.init(string: model.urlString)!, options: [:]) } + .store(in: &cancellables) + + vStack.addArrangedSubview(title) + vStack.addArrangedSubview(body) + vStack.addArrangedSubview(update) + + body.text = model.content + update.set( + style: model.actionStyle, + title: model.positiveActionTitle + ) + + if let negativeTitle = model.negativeActionTitle { + let negativeButton = CapsuleButton() + negativeButton.set(style: .simplestColoredRed, title: negativeTitle) + + negativeButton.publisher(for: .touchUpInside) + .sink { [unowned self] in + blocker.hideWindow() + viewModel.versionApproved() + }.store(in: &cancellables) + + vStack.addArrangedSubview(negativeButton) + } + + blocker.window?.addSubview(drawerView) + drawerView.snp.makeConstraints { + $0.left.equalToSuperview().offset(18) + $0.center.equalToSuperview() + $0.right.equalToSuperview().offset(-18) + } + + blocker.showWindow() + } +} diff --git a/Sources/LaunchFeature/LaunchCoordinator.swift b/Sources/LaunchFeature/LaunchCoordinator.swift new file mode 100644 index 0000000000000000000000000000000000000000..2f446810a29e5639190477705ffd8225bb88eb37 --- /dev/null +++ b/Sources/LaunchFeature/LaunchCoordinator.swift @@ -0,0 +1,64 @@ +import UIKit +import Models +import Presentation + +public protocol LaunchCoordinating { + func toChats(from: UIViewController) + func toRequests(from: UIViewController) + func toOnboarding(with: String, from: UIViewController) + func toSingleChat(with: Contact, from: UIViewController) + func toGroupChat(with: GroupChatInfo, from: UIViewController) +} + +public struct LaunchCoordinator: LaunchCoordinating { + var replacePresenter: Presenting = ReplacePresenter() + + var requestsFactory: () -> UIViewController + var chatListFactory: () -> UIViewController + var onboardingFactory: (String) -> UIViewController + var singleChatFactory: (Contact) -> UIViewController + var groupChatFactory: (GroupChatInfo) -> UIViewController + + public init( + requestsFactory: @escaping () -> UIViewController, + chatListFactory: @escaping () -> UIViewController, + onboardingFactory: @escaping (String) -> UIViewController, + singleChatFactory: @escaping (Contact) -> UIViewController, + groupChatFactory: @escaping (GroupChatInfo) -> UIViewController + ) { + self.requestsFactory = requestsFactory + self.chatListFactory = chatListFactory + self.groupChatFactory = groupChatFactory + self.onboardingFactory = onboardingFactory + self.singleChatFactory = singleChatFactory + } +} + +public extension LaunchCoordinator { + func toChats(from parent: UIViewController) { + let screen = chatListFactory() + replacePresenter.present(screen, from: parent) + } + + func toRequests(from parent: UIViewController) { + let screen = requestsFactory() + replacePresenter.present(screen, from: parent) + } + + func toOnboarding(with ndf: String, from parent: UIViewController) { + let screen = onboardingFactory(ndf) + replacePresenter.present(screen, from: parent) + } + + func toSingleChat(with contact: Contact, from parent: UIViewController) { + let chatListScreen = chatListFactory() + let singleChatScreen = singleChatFactory(contact) + replacePresenter.present(chatListScreen, singleChatScreen, from: parent) + } + + func toGroupChat(with group: GroupChatInfo, from parent: UIViewController) { + let chatListScreen = chatListFactory() + let groupChatScreen = groupChatFactory(group) + replacePresenter.present(chatListScreen, groupChatScreen, from: parent) + } +} diff --git a/Sources/LaunchFeature/LaunchView.swift b/Sources/LaunchFeature/LaunchView.swift new file mode 100644 index 0000000000000000000000000000000000000000..995c9f8c6b66501798069a736594c3e4a87a5537 --- /dev/null +++ b/Sources/LaunchFeature/LaunchView.swift @@ -0,0 +1,37 @@ +import UIKit +import Shared + +final class LaunchView: UIView { + private var imageView = UIImageView() + + init() { + super.init(frame: .zero) + imageView.image = Asset.splash.image + imageView.contentMode = .scaleAspectFit + backgroundColor = Asset.neutralWhite.color + + addSubview(imageView) + + imageView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.left.equalToSuperview().offset(100) + } + } + + required init?(coder: NSCoder) { nil } + + func setupGradient() { + let gradient = CAGradientLayer() + gradient.colors = [ + UIColor(red: 122/255, green: 235/255, blue: 239/255, alpha: 1).cgColor, + UIColor(red: 56/255, green: 204/255, blue: 232/255, alpha: 1).cgColor, + UIColor(red: 63/255, green: 186/255, blue: 253/255, alpha: 1).cgColor, + UIColor(red: 98/255, green: 163/255, blue: 255/255, alpha: 1).cgColor + ] + + gradient.frame = bounds + gradient.startPoint = CGPoint(x: 1, y: 0) + gradient.endPoint = CGPoint(x: 0, y: 1) + layer.insertSublayer(gradient, at: 0) + } +} diff --git a/Sources/LaunchFeature/LaunchViewModel.swift b/Sources/LaunchFeature/LaunchViewModel.swift new file mode 100644 index 0000000000000000000000000000000000000000..5e1b78a61a04e567242a2b058f7d5afa872bfb7f --- /dev/null +++ b/Sources/LaunchFeature/LaunchViewModel.swift @@ -0,0 +1,190 @@ +import HUD +import Shared +import Models +import Combine +import Defaults +import Foundation +import Integration +import Permissions +import DropboxFeature +import VersionChecking +import CombineSchedulers +import DependencyInjection + +struct Update { + let content: String + let urlString: String + let positiveActionTitle: String + let negativeActionTitle: String? + let actionStyle: CapsuleButtonStyle +} + +enum LaunchRoute { + case chats + case update(Update) + case onboarding(String) +} + +final class LaunchViewModel { + @Dependency private var network: XXNetworking + @Dependency private var versionChecker: VersionChecker + @Dependency private var dropboxService: DropboxInterface + @Dependency private var permissionHandler: PermissionHandling + + @KeyObject(.username, defaultValue: nil) var username: String? + @KeyObject(.biometrics, defaultValue: false) var isBiometricsOn: Bool + + var hudPublisher: AnyPublisher<HUDStatus, Never> { + hudSubject.eraseToAnyPublisher() + } + + var routePublisher: AnyPublisher<LaunchRoute, Never> { + routeSubject.eraseToAnyPublisher() + } + + var backgroundScheduler: AnySchedulerOf<DispatchQueue> = { + DispatchQueue.global().eraseToAnyScheduler() + }() + + var getSession: (String) throws -> SessionType = Session.init + + private var cancellables = Set<AnyCancellable>() + private let routeSubject = PassthroughSubject<LaunchRoute, Never>() + private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) + + func viewDidAppear() { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.hudSubject.send(.on(nil)) + self?.checkVersion() + } + } + + private func checkVersion() { + versionChecker().sink { [unowned self] in + switch $0 { + case .upToDate: + versionApproved() + case .failure(let error): + versionFailed(error: error) + case .updateRequired(let info): + versionUpdateRequired(info) + case .updateRecommended(let info): + versionUpdateRecommended(info) + } + }.store(in: &cancellables) + } + + func versionApproved() { + network.writeLogs() + + network.updateNDF { [weak self] in + guard let self = self else { return } + + switch $0 { + case .success(let ndf): + self.network.updateErrors() + + guard self.network.hasClient else { + self.hudSubject.send(.none) + self.routeSubject.send(.onboarding(ndf)) + self.dropboxService.unlink() + return + } + + guard self.username != nil else { + self.network.purgeFiles() + self.hudSubject.send(.none) + self.routeSubject.send(.onboarding(ndf)) + self.dropboxService.unlink() + return + } + + self.backgroundScheduler.schedule { [weak self] in + guard let self = self else { return } + + do { + let session = try self.getSession(ndf) + DependencyInjection.Container.shared.register(session as SessionType) + self.hudSubject.send(.none) + self.checkBiometrics() + } catch { + self.hudSubject.send(.error(HUDError(with: error))) + } + } + case .failure(let error): + self.hudSubject.send(.error(HUDError(with: error))) + } + } + } + + func getContactWith(userId: Data) -> Contact? { + guard let session = try? DependencyInjection.Container.shared.resolve() as SessionType, + let contact = session.getContactWith(userId: userId) else { + return nil + } + + return contact + } + + func getGroupInfoWith(groupId: Data) -> GroupChatInfo? { + guard let session: SessionType = try? DependencyInjection.Container.shared.resolve(), + let info = session.getGroupChatInfoWith(groupId: groupId) else { + return nil + } + + return info + } + + private func versionFailed(error: Error) { + let title = Localized.Launch.Version.failed + let content = error.localizedDescription + let hudError = HUDError(content: content, title: title, dismissable: false) + + hudSubject.send(.error(hudError)) + } + + private func versionUpdateRequired(_ info: DappVersionInformation) { + hudSubject.send(.none) + + let model = Update( + content: info.minimumMessage, + urlString: info.appUrl, + positiveActionTitle: Localized.Launch.Version.Required.positive, + negativeActionTitle: nil, + actionStyle: .brandColored + ) + + routeSubject.send(.update(model)) + } + + private func versionUpdateRecommended(_ info: DappVersionInformation) { + hudSubject.send(.none) + + let model = Update( + content: Localized.Launch.Version.Recommended.title, + urlString: info.appUrl, + positiveActionTitle: Localized.Launch.Version.Recommended.positive, + negativeActionTitle: Localized.Launch.Version.Recommended.negative, + actionStyle: .simplestColoredRed + ) + + routeSubject.send(.update(model)) + } + + private func checkBiometrics() { + if permissionHandler.isBiometricsAvailable && isBiometricsOn { + permissionHandler.requestBiometrics { [weak self] in + switch $0 { + case .success(let granted): + guard granted else { return } + self?.routeSubject.send(.chats) + + case .failure(let error): + self?.hudSubject.send(.error(HUDError(with: error))) + } + } + } else { + routeSubject.send(.chats) + } + } +} diff --git a/Sources/LaunchFeature/UpdateBlocker.swift b/Sources/LaunchFeature/UpdateBlocker.swift new file mode 100644 index 0000000000000000000000000000000000000000..571c2a527a33e8411c320cbc7b25fdec6145d399 --- /dev/null +++ b/Sources/LaunchFeature/UpdateBlocker.swift @@ -0,0 +1,24 @@ +import UIKit +import Theme +import Shared + +final class UpdateBlocker { + private(set) var window: Window? = Window() + + func showWindow() { + window?.backgroundColor = UIColor.black.withAlphaComponent(0.5) + window?.rootViewController = StatusBarViewController(nil) + window?.alpha = 0.0 + window?.makeKeyAndVisible() + + UIView.animate(withDuration: 0.3) { self.window?.alpha = 1.0 } + } + + func hideWindow() { + UIView.animate(withDuration: 0.3) { + self.window?.alpha = 0.0 + } completion: { _ in + self.window = nil + } + } +} diff --git a/Sources/Models/Contact.swift b/Sources/Models/Contact.swift index f80b0ae1d135c1bcf913efe8cc667a4e7d178aed..1959743eb66d6888db0d7c65e07f5cfd0e11826a 100644 --- a/Sources/Models/Contact.swift +++ b/Sources/Models/Contact.swift @@ -49,6 +49,7 @@ public struct Contact: Codable, Hashable, Equatable { case friends case received case requested + case isRecent case verificationInProgress case withUserId(Data) case withUserIds([Data]) @@ -79,6 +80,7 @@ public struct Contact: Codable, Hashable, Equatable { public var createdAt: Date public let username: String public var nickname: String? + public var isRecent: Bool public init( photo: Data?, @@ -89,7 +91,8 @@ public struct Contact: Codable, Hashable, Equatable { marshaled: Data, username: String, nickname: String?, - createdAt: Date + createdAt: Date, + isRecent: Bool ) { self.email = email self.phone = phone @@ -100,6 +103,7 @@ public struct Contact: Codable, Hashable, Equatable { self.nickname = nickname self.marshaled = marshaled self.createdAt = createdAt + self.isRecent = isRecent } public var differenceIdentifier: Data { userId } diff --git a/Sources/Models/GenericChatInfo.swift b/Sources/Models/GenericChatInfo.swift deleted file mode 100644 index e086573ebef7b0f01eecd1c88178b898c3a39368..0000000000000000000000000000000000000000 --- a/Sources/Models/GenericChatInfo.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation -import DifferenceKit - -public struct GenericChatInfo: Codable, Equatable, Hashable { - public var contact: Contact? - public var groupInfo: GroupChatInfo? - public var latestE2EMessage: Message? - public var differenceIdentifier: Data { contact?.userId ?? groupInfo!.group.groupId } - - public init( - contact: Contact?, - groupInfo: GroupChatInfo?, - latestE2EMessage: Message? - ) { - self.contact = contact - self.groupInfo = groupInfo - self.latestE2EMessage = latestE2EMessage - } -} - -extension GenericChatInfo: Differentiable {} diff --git a/Sources/Models/GroupChatInfo.swift b/Sources/Models/GroupChatInfo.swift index 073ac9a0706dab3e97392a16752761cfb3628d40..9b39ff6dbbd1cbad8202bd8117adfb9fc606b2c3 100644 --- a/Sources/Models/GroupChatInfo.swift +++ b/Sources/Models/GroupChatInfo.swift @@ -3,6 +3,7 @@ import Foundation public struct GroupChatInfo: Codable, Equatable, Hashable { public enum Request { case accepted + case fromGroup(Data) } public var group: Group diff --git a/Sources/NetworkMonitor/MockNetworkMonitor.swift b/Sources/NetworkMonitor/MockNetworkMonitor.swift index d84355e2a5c90bbdbee48820a7d6a11f51613e0b..473eb593ece68fd213a4896abe9a74eb3c23d286 100644 --- a/Sources/NetworkMonitor/MockNetworkMonitor.swift +++ b/Sources/NetworkMonitor/MockNetworkMonitor.swift @@ -1,4 +1,5 @@ import Combine +import Foundation public struct MockNetworkMonitor: NetworkMonitoring { private let statusRelay = PassthroughSubject<NetworkStatus, Never>() @@ -20,10 +21,24 @@ public struct MockNetworkMonitor: NetworkMonitoring { } public func start() { - // TODO + simulateOscilation(.available) } public func update(_ status: Bool) { // TODO } + + private func simulateOscilation(_ status: NetworkStatus) { + statusRelay.send(status) + + if status == .available { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + simulateOscilation(.internetNotAvailable) + } + } else if status == .internetNotAvailable { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + simulateOscilation(.available) + } + } + } } diff --git a/Sources/OnboardingFeature/Controllers/OnboardingLaunchController.swift b/Sources/OnboardingFeature/Controllers/OnboardingLaunchController.swift deleted file mode 100644 index f7e3087e4bc2425f901623b088d6b414bde1d65f..0000000000000000000000000000000000000000 --- a/Sources/OnboardingFeature/Controllers/OnboardingLaunchController.swift +++ /dev/null @@ -1,165 +0,0 @@ -import HUD -import UIKit -import Theme -import Shared -import Combine -import DependencyInjection - -public final class OnboardingLaunchController: UIViewController { - @Dependency private var hud: HUDType - @Dependency private var coordinator: OnboardingCoordinating - - private var imageView = UIImageView() - private let blocker = UpdateBlocker() - private var cancellables = Set<AnyCancellable>() - private let viewModel = OnboardingLaunchViewModel() - - public override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in - self?.viewModel.didFinishSplash() - } - } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationController?.navigationBar.customize(translucent: true) - } - - public override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - let gradient = CAGradientLayer() - gradient.colors = [ - UIColor(red: 122/255, green: 235/255, blue: 239/255, alpha: 1).cgColor, - UIColor(red: 56/255, green: 204/255, blue: 232/255, alpha: 1).cgColor, - UIColor(red: 63/255, green: 186/255, blue: 253/255, alpha: 1).cgColor, - UIColor(red: 98/255, green: 163/255, blue: 255/255, alpha: 1).cgColor - ] - - gradient.startPoint = CGPoint(x: 1, y: 0) - gradient.endPoint = CGPoint(x: 0, y: 1) - - gradient.frame = view.bounds - view.layer.insertSublayer(gradient, at: 0) - } - - public override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = Asset.neutralWhite.color - - imageView.image = Asset.splash.image - imageView.contentMode = .scaleAspectFit - view.addSubview(imageView) - - imageView.snp.makeConstraints { make in - make.center.equalToSuperview() - make.left.equalToSuperview().offset(100) - } - - viewModel.hud - .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - viewModel.chatsPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toChats(from: self) } - .store(in: &cancellables) - - viewModel.usernamePublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in coordinator.toStart(with: $0, from: self) } - .store(in: &cancellables) - - viewModel.updatePublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] updateModel in - let drawerView = UIView() - drawerView.backgroundColor = Asset.neutralSecondary.color - drawerView.layer.cornerRadius = 5 - - let vStack = UIStackView() - vStack.axis = .vertical - vStack.spacing = 10 - drawerView.addSubview(vStack) - - vStack.snp.makeConstraints { - $0.top.equalToSuperview().offset(18) - $0.left.equalToSuperview().offset(18) - $0.right.equalToSuperview().offset(-18) - $0.bottom.equalToSuperview().offset(-18) - } - - let title = UILabel() - title.text = "App Update" - title.textAlignment = .center - title.textColor = Asset.neutralDark.color - - let body = UILabel() - body.numberOfLines = 0 - body.textAlignment = .center - body.textColor = Asset.neutralDark.color - - let update = CapsuleButton() - update.publisher(for: .touchUpInside) - .sink { UIApplication.shared.open(.init(string: updateModel.appUrl)!, options: [:]) } - .store(in: &cancellables) - - vStack.addArrangedSubview(title) - vStack.addArrangedSubview(body) - vStack.addArrangedSubview(update) - - body.text = updateModel.body - update.set( - style: updateModel.updateStyle, - title: updateModel.updateTitle - ) - - if let notNowTitle = updateModel.notNowTitle { - let notNow = CapsuleButton() - notNow.set(style: .simplestColoredRed, title: notNowTitle) - - notNow.publisher(for: .touchUpInside) - .sink { [unowned self] in - blocker.hideWindow() - viewModel.versionApproved() - }.store(in: &cancellables) - - vStack.addArrangedSubview(notNow) - } - - blocker.window?.addSubview(drawerView) - drawerView.snp.makeConstraints { - $0.left.equalToSuperview().offset(18) - $0.center.equalToSuperview() - $0.right.equalToSuperview().offset(-18) - } - - blocker.showWindow() - - }.store(in: &cancellables) - } -} - -private final class UpdateBlocker { - private(set) var window: Window? = Window() - - func showWindow() { - window?.backgroundColor = UIColor.black.withAlphaComponent(0.5) - window?.rootViewController = StatusBarViewController(nil) - window?.alpha = 0.0 - window?.makeKeyAndVisible() - - UIView.animate(withDuration: 0.3) { self.window?.alpha = 1.0 } - } - - func hideWindow() { - UIView.animate(withDuration: 0.3) { - self.window?.alpha = 0.0 - } completion: { _ in - self.window = nil - } - } -} diff --git a/Sources/OnboardingFeature/Coordinator/OnboardingCoordinator.swift b/Sources/OnboardingFeature/Coordinator/OnboardingCoordinator.swift index da4249e349d52195df01de3ff897822b6399bb25..ca73d7c55d96709e85f076e6e06d74e6bc09a155 100644 --- a/Sources/OnboardingFeature/Coordinator/OnboardingCoordinator.swift +++ b/Sources/OnboardingFeature/Coordinator/OnboardingCoordinator.swift @@ -11,7 +11,6 @@ public protocol OnboardingCoordinating { func toEmail(from: UIViewController) func toPhone(from: UIViewController) func toWelcome(from: UIViewController) - func toStart(with: String, from: UIViewController) func toUsername(with: String, from: UIViewController) func toRestoreList(with: String, from: UIViewController) func toDrawer(_: UIViewController, from: UIViewController) @@ -45,7 +44,6 @@ public struct OnboardingCoordinator: OnboardingCoordinating { var searchFactory: () -> UIViewController var welcomeFactory: () -> UIViewController var chatListFactory: () -> UIViewController - var startFactory: (String) -> UIViewController var usernameFactory: (String) -> UIViewController var restoreListFactory: (String) -> UIViewController var successFactory: (OnboardingSuccessModel) -> UIViewController @@ -59,7 +57,6 @@ public struct OnboardingCoordinator: OnboardingCoordinating { searchFactory: @escaping () -> UIViewController, welcomeFactory: @escaping () -> UIViewController, chatListFactory: @escaping () -> UIViewController, - startFactory: @escaping (String) -> UIViewController, usernameFactory: @escaping (String) -> UIViewController, restoreListFactory: @escaping (String) -> UIViewController, successFactory: @escaping (OnboardingSuccessModel) -> UIViewController, @@ -69,7 +66,6 @@ public struct OnboardingCoordinator: OnboardingCoordinating { ) { self.emailFactory = emailFactory self.phoneFactory = phoneFactory - self.startFactory = startFactory self.searchFactory = searchFactory self.welcomeFactory = welcomeFactory self.successFactory = successFactory @@ -108,11 +104,6 @@ public extension OnboardingCoordinator { replacePresenter.present(screen, from: parent) } - func toStart(with ndf: String, from parent: UIViewController) { - let screen = startFactory(ndf) - replacePresenter.present(screen, from: parent) - } - func toUsername(with ndf: String, from parent: UIViewController) { let screen = usernameFactory(ndf) replacePresenter.present(screen, from: parent) diff --git a/Sources/OnboardingFeature/ViewModels/OnboardingLaunchViewModel.swift b/Sources/OnboardingFeature/ViewModels/OnboardingLaunchViewModel.swift deleted file mode 100644 index bfc03a0cf4f265157da60da3704e587c9253d755..0000000000000000000000000000000000000000 --- a/Sources/OnboardingFeature/ViewModels/OnboardingLaunchViewModel.swift +++ /dev/null @@ -1,152 +0,0 @@ -import HUD -import Shared -import Combine -import Defaults -import Foundation -import Integration -import Permissions -import VersionChecking -import CombineSchedulers -import DependencyInjection -import DropboxFeature - -struct UpdateDrawerModel { - let body: String - let updateTitle: String - let updateStyle: CapsuleButtonStyle - let notNowTitle: String? - let appUrl: String -} - -final class OnboardingLaunchViewModel { - @KeyObject(.username, defaultValue: nil) var username: String? - @KeyObject(.biometrics, defaultValue: false) var isBiometricsEnabled: Bool - - @Dependency private var network: XXNetworking - @Dependency private var versioning: VersionChecker - @Dependency private var permissions: PermissionHandling - @Dependency private var dropboxService: DropboxInterface - - var getSession: (String) throws -> SessionType = Session.init - var backgroundScheduler: AnySchedulerOf<DispatchQueue> = DispatchQueue.global().eraseToAnyScheduler() - - var chatsPublisher: AnyPublisher<Void, Never> { chatsRelay.eraseToAnyPublisher() } - private let chatsRelay = PassthroughSubject<Void, Never>() - - var usernamePublisher: AnyPublisher<String, Never> { usernameRelay.eraseToAnyPublisher() } - private let usernameRelay = PassthroughSubject<String, Never>() - - var hud: AnyPublisher<HUDStatus, Never> { hudRelay.eraseToAnyPublisher() } - private let hudRelay = CurrentValueSubject<HUDStatus, Never>(.none) - - var updatePublisher: AnyPublisher<UpdateDrawerModel, Never> { updateRelay.eraseToAnyPublisher() } - private let updateRelay = PassthroughSubject<UpdateDrawerModel, Never>() - - private var cancellables = Set<AnyCancellable>() - - func didFinishSplash() { - hudRelay.send(.on(nil)) - - versioning() - .sink { [unowned self] in - switch $0 { - case .upToDate: - versionApproved() - case .updateRecommended(let info): - hudRelay.send(.none) - updateRelay.send(.init( - body: "There is a new version available that enhance the current performance and usability.", - updateTitle: "Update", - updateStyle: .simplestColoredRed, - notNowTitle: "Not now", - appUrl: info.appUrl - )) - case .updateRequired(let info): - hudRelay.send(.none) - - updateRelay.send(.init( - body: info.minimumMessage, - updateTitle: "Okay", - updateStyle: .brandColored, - notNowTitle: nil, - appUrl: info.appUrl - )) - case .failure(let error): - hudRelay.send(.error(.init( - content: error.localizedDescription, - title: "Failed checking app version", - dismissable: false - ))) - } - } - .store(in: &cancellables) - } - - func versionApproved() { - hudRelay.send(.on(nil)) - network.writeLogs() - - network.updateNDF { [weak self] in - switch $0 { - case .success(let ndf): - self?.ndfApproved(ndf: ndf) - case .failure(let error): - print(error) - self?.hudRelay.send(.error(.init(with: error))) - } - } - } - - private func ndfApproved(ndf: String) { - network.updateErrors() - - guard network.hasClient == true else { - hudRelay.send(.none) - usernameRelay.send(ndf) - dropboxService.unlink() - return - } - - guard username != nil else { - network.purgeFiles() - hudRelay.send(.none) - usernameRelay.send(ndf) - dropboxService.unlink() - return - } - - backgroundScheduler.schedule { [weak self] in - guard let self = self else { return } - - do { - let session = try self.getSession(ndf) - DependencyInjection.Container.shared.register(session as SessionType) - self.hudRelay.send(.none) - self.checkBiometrics() - } catch { - self.hudRelay.send(.error(.init(with: error))) - print(error.localizedDescription) - } - } - } - - private func checkBiometrics() { - if permissions.isBiometricsAvailable && isBiometricsEnabled { - permissions.requestBiometrics { result in - switch result { - case .success(let granted): - if granted { - self.chatsRelay.send() - } else { - // TODO - } - - case .failure(let error): - print(error.localizedDescription) - } - } - } else { - self.chatsRelay.send() - } - } -} diff --git a/Sources/PushFeature/ContentsBuilder.swift b/Sources/PushFeature/ContentsBuilder.swift new file mode 100644 index 0000000000000000000000000000000000000000..9aa75041564c08334bf2f8068d118128c3a8b52f --- /dev/null +++ b/Sources/PushFeature/ContentsBuilder.swift @@ -0,0 +1,23 @@ +import UserNotifications + +public struct ContentsBuilder { + enum Constants { + static let threadIdentifier = "new_message_identifier" + } + + public var build: (String, Push) -> UNMutableNotificationContent +} + +public extension ContentsBuilder { + static let live = ContentsBuilder { title, push in + let content = UNMutableNotificationContent() + content.badge = 1 + content.body = title + content.title = title + content.sound = .default + content.userInfo["source"] = push.source + content.userInfo["type"] = push.type.rawValue + content.threadIdentifier = Constants.threadIdentifier + return content + } +} diff --git a/Sources/PushFeature/MockPushHandler.swift b/Sources/PushFeature/MockPushHandler.swift new file mode 100644 index 0000000000000000000000000000000000000000..41aced17ade52d9c4b02ec092feec29276fc21fb --- /dev/null +++ b/Sources/PushFeature/MockPushHandler.swift @@ -0,0 +1,39 @@ +import UIKit + +public struct MockPushHandler: PushHandling { + public init() {} + + public func registerToken(_ token: Data) { + // TODO + } + + public func requestAuthorization( + _ completion: @escaping (Result<Bool, Error>) -> Void + ) { + completion(.success(true)) + } + + public func handlePush( + _ notification: [AnyHashable : Any], + _ completion: @escaping (UIBackgroundFetchResult) -> Void + ) { + completion(.noData) + } + + public func handlePush( + _ request: UNNotificationRequest, + _ completion: @escaping (UNNotificationContent) -> Void + ) { + let content = UNMutableNotificationContent() + content.title = String(describing: Self.self) + completion(content) + } + + public func handleAction( + _ router: PushRouter, + _ userInfo: [AnyHashable : Any], + _ completion: @escaping () -> Void + ) { + completion() + } +} diff --git a/Sources/PushFeature/Push.swift b/Sources/PushFeature/Push.swift new file mode 100644 index 0000000000000000000000000000000000000000..51c25bd52f513816ca850930e07a4c0a72204798 --- /dev/null +++ b/Sources/PushFeature/Push.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct Push { + public let type: PushType + public let source: Data? + + public init?(type: String, source: Data?) { + guard let pushType = PushType(rawValue: type) else { + return nil + } + + self.type = pushType + self.source = source + } +} diff --git a/Sources/PushFeature/PushExtractor.swift b/Sources/PushFeature/PushExtractor.swift new file mode 100644 index 0000000000000000000000000000000000000000..045f0e898276350914d9c0d335a58997d4fbdfa1 --- /dev/null +++ b/Sources/PushFeature/PushExtractor.swift @@ -0,0 +1,38 @@ +import Foundation +import Integration + +public struct PushExtractor { + enum Constants { + static let preImage = "preImage" + static let appGroup = "group.elixxir.messenger" + static let notificationData = "notificationData" + } + + public var extractFrom: ([AnyHashable: Any]) -> Result<[Push]?, Error> +} + +public extension PushExtractor { + static let live = PushExtractor { dictionary in + var error: NSError? + + guard let data = dictionary[Constants.notificationData] as? String, + let defaults = UserDefaults(suiteName: Constants.appGroup), + let preImage = defaults.value(forKey: Constants.preImage) as? String, + let reports = evaluateNotification(data, preImage, &error) else { + return .success(nil) + } + + if let error = error { + return .failure(error) + } + + let pushes = (0..<reports.len()) + .compactMap { try? reports.get(index: $0) } + .filter { $0.forMe() } + .filter { $0.type() != PushType.silent.rawValue } + .filter { $0.type() != PushType.default.rawValue } + .compactMap { Push(type: $0.type(), source: $0.source()) } + + return .success(pushes) + } +} diff --git a/Sources/PushFeature/PushHandler.swift b/Sources/PushFeature/PushHandler.swift new file mode 100644 index 0000000000000000000000000000000000000000..bb0b19cd27060c2bf475ba948acf0333debc388e --- /dev/null +++ b/Sources/PushFeature/PushHandler.swift @@ -0,0 +1,165 @@ +import UIKit +import Models +import Defaults +import Database +import Integration +import DependencyInjection + +public final class PushHandler: PushHandling { + private enum Constants { + static let appGroup = "group.elixxir.messenger" + static let usernamesSetting = "isShowingUsernames" + } + + @KeyObject(.pushNotifications, defaultValue: false) var isPushEnabled: Bool + + let requestAuth: RequestAuth + public static let defaultRequestAuth = UNUserNotificationCenter.current().requestAuthorization + public typealias RequestAuth = (UNAuthorizationOptions, @escaping (Bool, Error?) -> Void) -> Void + + public var pushExtractor: PushExtractor + public var contentsBuilder: ContentsBuilder + public var applicationState: () -> UIApplication.State + + public init( + requestAuth: @escaping RequestAuth = defaultRequestAuth, + pushExtractor: PushExtractor = .live, + contentsBuilder: ContentsBuilder = .live, + applicationState: @escaping () -> UIApplication.State = { UIApplication.shared.applicationState } + ) { + self.requestAuth = requestAuth + self.pushExtractor = pushExtractor + self.contentsBuilder = contentsBuilder + self.applicationState = applicationState + } + + public func registerToken(_ token: Data) { + do { + let session = try DependencyInjection.Container.shared.resolve() as SessionType + try session.registerNotifications(token) + } catch { + isPushEnabled = false + } + } + + public func requestAuthorization( + _ completion: @escaping (Result<Bool, Error>) -> Void + ) { + let options: UNAuthorizationOptions = [.alert, .sound, .badge] + + requestAuth(options) { granted, error in + guard let error = error else { + completion(.success(granted)) + return + } + + completion(.failure(error)) + } + } + + public func handlePush( + _ userInfo: [AnyHashable: Any], + _ completion: @escaping (UIBackgroundFetchResult) -> Void + ) { + do { + guard + let pushes = try pushExtractor.extractFrom(userInfo).get(), + applicationState() == .background, + pushes.isEmpty == false + else { + completion(.noData) + return + } + + let content = contentsBuilder.build("New Messages Available", pushes.first!) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + let request = UNNotificationRequest(identifier: Bundle.main.bundleIdentifier!, content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) { error in + if error == nil { + completion(.newData) + } else { + completion(.failed) + } + } + } catch { + completion(.failed) + } + } + + public func handlePush( + _ request: UNNotificationRequest, + _ completion: @escaping (UNNotificationContent) -> Void + ) { + guard let pushes = try? pushExtractor.extractFrom(request.content.userInfo).get(), !pushes.isEmpty, + let defaults = UserDefaults(suiteName: Constants.appGroup) else { + return + } + + guard let showSender = defaults.value(forKey: Constants.usernamesSetting) as? Bool, showSender == true else { + pushes.map { ($0.type.unknownSenderContent!, $0) } + .map(contentsBuilder.build) + .forEach { completion($0) } + return + } + + let dbManager = GRDBDatabaseManager() + try? dbManager.setup() + + let tuples: [(String, Push)] = pushes.compactMap { + guard let userId = $0.source, let contact: Contact = try? dbManager.fetch(.withUserId(userId)).first else { + return ($0.type.unknownSenderContent!, $0) + } + + let name = contact.nickname ?? contact.username + return ($0.type.knownSenderContent(name)!, $0) + } + + tuples + .map(contentsBuilder.build) + .forEach { completion($0) } + } + + public func handleAction( + _ router: PushRouter, + _ userInfo: [AnyHashable : Any], + _ completion: @escaping () -> Void + ) { + guard let typeString = userInfo["type"] as? String, + let type = PushType(rawValue: typeString) else { + completion() + return + } + + let route: PushRouter.Route + + switch type { + case .e2e: + guard let source = userInfo["source"] as? Data else { + completion() + return + } + + route = .contactChat(id: source) + + case .group: + guard let source = userInfo["source"] as? Data else { + completion() + return + } + + route = .groupChat(id: source) + + case .request, .groupRq: + route = .requests + + case .silent, .`default`: + fatalError("Silent/Default push types should be filtered at this point") + + case .reset, .endFT, .confirm: + route = .requests + } + + router.navigateTo(route, completion) + } +} diff --git a/Sources/PushFeature/PushHandling.swift b/Sources/PushFeature/PushHandling.swift new file mode 100644 index 0000000000000000000000000000000000000000..c17c7e600d9c8f3ed222f3e7b8e1d6db035d85ce --- /dev/null +++ b/Sources/PushFeature/PushHandling.swift @@ -0,0 +1,70 @@ +import UIKit + +public protocol PushHandling { + + /// Submits the APNS token to a 3rd-party service. + /// This should be called whenever the user accepts + /// receiving remote push notifications. + /// + /// - Parameters: + /// - token: The APNS provided token + /// + func registerToken( + _ token: Data + ) + + /// Prompts a system alert to the user requesting + /// permission for receiving remote push notifications + /// + /// - Parameters: + /// - completion: Async result closure containing the user reponse + /// + func requestAuthorization( + _ completion: @escaping (Result<Bool, Error>) -> Void + ) + + /// Evaluates if the notification should be displayed or not + /// and if yes, how should it look like. + /// + /// - Note: This function should be called by the main app target + /// - Warning: The notifications should only appear if the app is in background + /// + /// - Parameters: + /// - userInfo: Dictionary contaning the payload of the remote push + /// - completion: Async closure containing the operation chosed + /// + func handlePush( + _ userInfo: [AnyHashable: Any], + _ completion: @escaping (UIBackgroundFetchResult) -> Void + ) + + /// Evaluates if the notification should be displayed or not + /// and if yes, how it should look like and who is it from + /// + /// - Note: This function should be called by the `NotificationExtension` + /// + /// - Parameters: + /// - request: The notification request that arrived for the `NotificationExtension` + /// - completion: Async closure containing the operation chosed + /// + func handlePush( + _ request: UNNotificationRequest, + _ completion: @escaping (UNNotificationContent) -> Void + ) + + /// Deeplinks to any UI flow set within the notification. + /// It can get called either when the user starts the app + /// from a notification or when the user has the app in + /// background and resumes the app by tapping on a push + /// + /// - Parameters: + /// - router: Router instance that will decide the correct UI flow + /// - userInfo: Dictionary contaning the payload of the notification + /// - completion: Async empty closure + /// + func handleAction( + _ router: PushRouter, + _ userInfo: [AnyHashable: Any], + _ completion: @escaping () -> Void + ) +} diff --git a/Sources/PushFeature/PushRouter.swift b/Sources/PushFeature/PushRouter.swift new file mode 100644 index 0000000000000000000000000000000000000000..6fc66797612acf41e56213ab64ffc9a7029d873e --- /dev/null +++ b/Sources/PushFeature/PushRouter.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct PushRouter { + public typealias NavigateTo = (Route, @escaping () -> Void) -> Void + + public enum Route { + case requests + case groupChat(id: Data) + case contactChat(id: Data) + } + + public var navigateTo: NavigateTo + + public init(navigateTo: @escaping NavigateTo) { + self.navigateTo = navigateTo + } +} + +public extension PushRouter { + static let noop = PushRouter { _, _ in } +} + diff --git a/Sources/PushFeature/PushType.swift b/Sources/PushFeature/PushType.swift new file mode 100644 index 0000000000000000000000000000000000000000..7cd2fefefcbce0968a7d4478aa4e5cc01d771f98 --- /dev/null +++ b/Sources/PushFeature/PushType.swift @@ -0,0 +1,53 @@ +public enum PushType: String { + case e2e + case reset + case endFT + case group + case silent + case groupRq + case confirm + case request + case `default` + + var unknownSenderContent: String? { + switch self { + case .silent, .`default`: + return nil + case .endFT: + return "New media received" + case .group: + return "New group message" + case .groupRq: + return "Group request received" + case .e2e: + return "New private message" + case .reset: + return "One of your contacts has restored their account" + case .request: + return "Request received" + case .confirm: + return "Request accepted" + } + } + + var knownSenderContent: (String) -> String? { + switch self { + case .silent, .`default`: + return { _ in nil } + case .e2e: + return { String(format: "%@ sent you a private message", $0) } + case .reset: + return { String(format: "%@ restored their account", $0) } + case .endFT: + return { String(format: "%@ sent you a file", $0) } + case .group: + return { String(format: "%@ sent you a group message", $0) } + case .groupRq: + return { String(format: "%@ sent you a group request", $0) } + case .confirm: + return { String(format: "%@ confirmed your contact request", $0) } + case .request: + return { String(format: "%@ sent you a contact request", $0) } + } + } +} diff --git a/Sources/PushNotifications/MockPushHandler.swift b/Sources/PushNotifications/MockPushHandler.swift deleted file mode 100644 index d49dba163de277007db1c95d649db917cd5a7b80..0000000000000000000000000000000000000000 --- a/Sources/PushNotifications/MockPushHandler.swift +++ /dev/null @@ -1,16 +0,0 @@ -import UIKit - -public struct MockPushHandler: PushHandling { - public init() {} - - public func didRegisterWith(_ deviceToken: Data) {} - - public func didRequestAuthorization( - _ completion: @escaping (Result<Bool, Error>) -> Void - ) {} - - public func didReceiveRemote( - _ notification: [AnyHashable : Any], - _ completion: @escaping (UIBackgroundFetchResult) -> Void - ) {} -} diff --git a/Sources/PushNotifications/PushHandler.swift b/Sources/PushNotifications/PushHandler.swift deleted file mode 100644 index 4a2c09d8b1305c9174e0280b858d9fabf7d98d23..0000000000000000000000000000000000000000 --- a/Sources/PushNotifications/PushHandler.swift +++ /dev/null @@ -1,102 +0,0 @@ -import UIKit -import XXLogger -import Defaults -import os.log -import Integration -import UserNotifications -import DependencyInjection - -public protocol PushHandling { - func didRegisterWith(_ deviceToken: Data) - - func didRequestAuthorization(_ completion: @escaping (Result<Bool, Error>) -> Void) - - func didReceiveRemote(_ notification: [AnyHashable : Any], - _ completion: @escaping (UIBackgroundFetchResult) -> Void) -} - -public final class PushHandler: PushHandling { - @Dependency private var logger: XXLogger - - @KeyObject(.pushNotifications, defaultValue: false) private var pushNotifications: Bool - - public init() {} - - public func didReceiveRemote( - _ notification: [AnyHashable : Any], - _ completion: @escaping (UIBackgroundFetchResult) -> Void - ) { - guard let data = notification["notificationData"] as? String, - let defaults = UserDefaults(suiteName: "group.io.xxlabs.notification"), - let preImage = defaults.value(forKey: "preImage") as? String else { - completion(.newData) - return - } - - var error: NSError? - - guard let reports = evaluateNotification(data, preImage, &error) else { return } - let length = reports.len() - - var showNotification = false - - for index in 0..<length { - if let report = try? reports.get(index: index) { - let isForMe = report.forMe() - let isNotDefault = report.type() != "default" - let isNotSilent = report.type() != "silent" - - if isForMe && isNotSilent && isNotDefault { - showNotification = true - break - } - } - } - - guard showNotification == true else { return } - - guard error == nil else { - logger.error(error as Any) - return - } - - let content = UNMutableNotificationContent() - content.title = "New Messages Available" - content.sound = UNNotificationSound.default - - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) - let request = UNNotificationRequest(identifier: "io.xxlabs.messenger", content: content, trigger: trigger) - - UNUserNotificationCenter.current().add(request) { [weak self] in self?.logger.error($0 as Any) } - } - - public func didRequestAuthorization(_ completion: @escaping (Result<Bool, Error>) -> Void) { - let center = UNUserNotificationCenter.current() - center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in - if let error = error { - completion(.failure(error)) - return - } - - completion(.success(granted)) - } - } - - public func didRegisterWith(_ deviceToken: Data) { - do { - logger.info("PushHandling: didRegisterWith(_ deviceToken: Data)") - let session = try DependencyInjection.Container.shared.resolve() as SessionType - try session.registerNotifications(deviceToken.hexEncodedString) - logger.info("PushHandling: didRegisterWith(_ deviceToken: Data) success") - } catch { - pushNotifications = false - logger.error(error) - } - } -} - -private extension Data { - var hexEncodedString: String { - map { String(format: "%02hhx", $0) }.joined() - } -} diff --git a/Sources/RestoreFeature/Controllers/RestoreController.swift b/Sources/RestoreFeature/Controllers/RestoreController.swift index a77cc3a3ac5bb90858ae32f0f2c3b833b4e3edef..b88932c148df5249e2c88184b3506e335596020f 100644 --- a/Sources/RestoreFeature/Controllers/RestoreController.swift +++ b/Sources/RestoreFeature/Controllers/RestoreController.swift @@ -36,7 +36,7 @@ public final class RestoreController: UIViewController { navigationItem.backButtonTitle = "" let title = UILabel() - title.text = Localized.Restore.header + title.text = Localized.AccountRestore.header title.textColor = Asset.neutralActive.color title.font = Fonts.Mulish.semiBold.font(size: 18.0) @@ -95,20 +95,20 @@ public final class RestoreController: UIViewController { extension RestoreController { private func presentWarning() { let actionButton = DrawerCapsuleButton(model: .init( - title: Localized.Restore.Warning.action, + title: Localized.AccountRestore.Warning.action, style: .brandColored )) let drawer = DrawerController(with: [ DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), - text: Localized.Restore.Warning.title, + text: Localized.AccountRestore.Warning.title, color: Asset.neutralActive.color, alignment: .left, spacingAfter: 19 ), DrawerText( - text: Localized.Restore.Warning.subtitle, + text: Localized.AccountRestore.Warning.subtitle, spacingAfter: 37 ), actionButton diff --git a/Sources/RestoreFeature/Controllers/RestoreListController.swift b/Sources/RestoreFeature/Controllers/RestoreListController.swift index 43767aed6f40e139a002be9ee31dc96efb9b6b1f..041717b6dde907568db4872827957d487010d006 100644 --- a/Sources/RestoreFeature/Controllers/RestoreListController.swift +++ b/Sources/RestoreFeature/Controllers/RestoreListController.swift @@ -90,18 +90,18 @@ public final class RestoreListController: UIViewController { extension RestoreListController { private func presentWarning() { let actionButton = DrawerCapsuleButton(model: .init( - title: Localized.Restore.Warning.action, + title: Localized.AccountRestore.Warning.action, style: .brandColored )) let drawer = DrawerController(with: [ DrawerText( font: Fonts.Mulish.bold.font(size: 26.0), - text: Localized.Restore.Warning.title, + text: Localized.AccountRestore.Warning.title, spacingAfter: 19 ), DrawerText( - text: Localized.Restore.Warning.subtitle, + text: Localized.AccountRestore.Warning.subtitle, spacingAfter: 37 ), actionButton diff --git a/Sources/RestoreFeature/Views/RestoreListView.swift b/Sources/RestoreFeature/Views/RestoreListView.swift index 26d937db458bc12f164141a32c665f1ac57ee12c..2a688760bc68cd509775fa97d28a37538dc44d3a 100644 --- a/Sources/RestoreFeature/Views/RestoreListView.swift +++ b/Sources/RestoreFeature/Views/RestoreListView.swift @@ -15,15 +15,15 @@ final class RestoreListView: UIView { super.init(frame: .zero) backgroundColor = Asset.neutralWhite.color - setupTitle(Localized.Restore.List.title) - setupSubtitle(Localized.Restore.List.firstSubtitle) + setupTitle(Localized.AccountRestore.List.title) + setupSubtitle(Localized.AccountRestore.List.firstSubtitle) let paragraph = NSMutableParagraphStyle() paragraph.alignment = .left paragraph.lineHeightMultiple = 1.15 let attrString = NSMutableAttributedString( - string: Localized.Restore.List.secondSubtitle, + string: Localized.AccountRestore.List.secondSubtitle, attributes: [ .foregroundColor: Asset.neutralBody.color, .font: Fonts.Mulish.regular.font(size: 16.0) as Any, @@ -38,7 +38,7 @@ final class RestoreListView: UIView { dropboxButton.setup(title: Localized.Backup.dropbox, icon: Asset.restoreDropbox.image) driveButton.setup(title: Localized.Backup.googleDrive, icon: Asset.restoreDrive.image) - cancelButton.set(style: .seeThrough, title: Localized.Restore.List.cancel) + cancelButton.set(style: .seeThrough, title: Localized.AccountRestore.List.cancel) stackView.axis = .vertical stackView.addArrangedSubview(driveButton) diff --git a/Sources/RestoreFeature/Views/RestoreSuccessView.swift b/Sources/RestoreFeature/Views/RestoreSuccessView.swift index 94f318951e8d3f447d72a2d2188a37aca398b84f..35bdd4c04fc356e32b54a729b642514e504f8c38 100644 --- a/Sources/RestoreFeature/Views/RestoreSuccessView.swift +++ b/Sources/RestoreFeature/Views/RestoreSuccessView.swift @@ -46,8 +46,8 @@ final class RestoreSuccessView: UIView { make.bottom.equalToSuperview().offset(-60) } - setTitle(Localized.Restore.Success.title) - setSubtitle(Localized.Restore.Success.subtitle) + setTitle(Localized.AccountRestore.Success.title) + setSubtitle(Localized.AccountRestore.Success.subtitle) } required init?(coder: NSCoder) { nil } diff --git a/Sources/RestoreFeature/Views/RestoreView.swift b/Sources/RestoreFeature/Views/RestoreView.swift index 52686480e81c655b774bb59703c30f7ef8dc3f99..e36e5d5b1dd97cb735ac84f9d1c5c56fdb19b5c3 100644 --- a/Sources/RestoreFeature/Views/RestoreView.swift +++ b/Sources/RestoreFeature/Views/RestoreView.swift @@ -23,9 +23,9 @@ final class RestoreView: UIView { titleLabel.textColor = Asset.neutralDark.color subtitleLabel.textColor = Asset.neutralDark.color - restoreButton.set(style: .brandColored, title: Localized.Restore.Found.restore) - cancelButton.set(style: .simplestColoredBrand, title: Localized.Restore.Found.cancel) - backButton.set(style: .seeThrough, title: Localized.Restore.NotFound.back) + restoreButton.set(style: .brandColored, title: Localized.AccountRestore.Found.restore) + cancelButton.set(style: .simplestColoredBrand, title: Localized.AccountRestore.Found.cancel) + backButton.set(style: .seeThrough, title: Localized.AccountRestore.NotFound.back) bottomStackView.axis = .vertical @@ -105,20 +105,20 @@ final class RestoreView: UIView { } private func showBackup(_ backup: Backup, fromCloud cloud: CloudService) { - titleLabel.text = Localized.Restore.Found.title - subtitleLabel.text = Localized.Restore.Found.subtitle + titleLabel.text = Localized.AccountRestore.Found.title + subtitleLabel.text = Localized.AccountRestore.Found.subtitle detailsView.titleLabel.text = cloud.name() detailsView.imageView.image = cloud.asset() detailsView.dateView.setup( - title: Localized.Restore.Found.date, + title: Localized.AccountRestore.Found.date, value: backup.date.backupStyle(), hasArrow: false ) detailsView.sizeView.setup( - title: Localized.Restore.Found.size, + title: Localized.AccountRestore.Found.size, value: String(format: "%.1f kb", backup.size/1000), hasArrow: false ) @@ -131,8 +131,8 @@ final class RestoreView: UIView { } private func showNoBackupForCloud(named cloud: String) { - titleLabel.text = Localized.Restore.NotFound.title - subtitleLabel.text = Localized.Restore.NotFound.subtitle(cloud) + titleLabel.text = Localized.AccountRestore.NotFound.title + subtitleLabel.text = Localized.AccountRestore.NotFound.subtitle(cloud) restoreButton.isHidden = true cancelButton.isHidden = true diff --git a/Sources/ScanFeature/Controllers/ScanContainerController.swift b/Sources/ScanFeature/Controllers/ScanContainerController.swift index f02b4ef89d5f757a4bfe19dd033ee3ba0616695a..fb1f9d440421512de2f3034c1b46b5d1fd70b2a3 100644 --- a/Sources/ScanFeature/Controllers/ScanContainerController.swift +++ b/Sources/ScanFeature/Controllers/ScanContainerController.swift @@ -1,8 +1,8 @@ import UIKit -import DrawerFeature import Theme import Shared import Combine +import DrawerFeature import DependencyInjection public final class ScanContainerController: UIViewController { @@ -11,25 +11,59 @@ public final class ScanContainerController: UIViewController { lazy private var screenView = ScanContainerView() + private var previousPoint: CGPoint = .zero + private let scanController = ScanController() + private let displayController = ScanDisplayController() + private var cancellables = Set<AnyCancellable>() private var drawerCancellables = Set<AnyCancellable>() public override func loadView() { view = screenView - screenView.scrollView.delegate = self - addChild(screenView.scanScreen) - screenView.scanScreen.didMove(toParent: self) - addChild(screenView.displayScreen) - screenView.displayScreen.didMove(toParent: self) + addChild(scanController) + addChild(displayController) + + screenView.scrollView.addSubview(scanController.view) + screenView.scrollView.addSubview(displayController.view) + + scanController.view.snp.makeConstraints { + $0.top.equalTo(screenView) + $0.width.equalTo(screenView) + $0.bottom.equalTo(screenView) + $0.left.equalToSuperview() + $0.right.equalTo(displayController.view.snp.left) + } + + displayController.view.snp.makeConstraints { + $0.top.equalTo(screenView.segmentedControl.snp.bottom) + $0.width.equalTo(scanController.view) + $0.bottom.equalTo(scanController.view) + } + + scanController.didMove(toParent: self) + displayController.didMove(toParent: self) + screenView.bringSubviewToFront(screenView.segmentedControl) } + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + screenView.scrollView.contentOffset = previousPoint + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + previousPoint = screenView.scrollView.contentOffset + screenView.scrollView.contentOffset = .zero + } + public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) statusBarController.style.send(.lightContent) navigationController?.navigationBar.customize(translucent: true) + screenView.scrollView.contentOffset = .zero } public override func viewDidLoad() { @@ -37,12 +71,22 @@ public final class ScanContainerController: UIViewController { setupNavigationBar() setupBindings() - screenView.displayScreen.didTapInfo = { [weak self] in + displayController.didTapInfo = { [weak self] in self?.presentInfo( title: Localized.Scan.Info.title, subtitle: Localized.Scan.Info.subtitle ) } + + displayController.didTapAddEmail = { [weak self] in + guard let self = self else { return } + self.coordinator.toEmail(from: self) + } + + displayController.didTapAddPhone = { [weak self] in + guard let self = self else { return } + self.coordinator.toPhone(from: self) + } } private func setupNavigationBar() { @@ -85,8 +129,8 @@ public final class ScanContainerController: UIViewController { public func scrollViewDidScroll(_ scrollView: UIScrollView) { let percentage = scrollView.contentOffset.x / view.frame.width - screenView.scanScreen.view.alpha = 1 - percentage - screenView.displayScreen.view.alpha = percentage + scanController.view.alpha = 1 - percentage + displayController.view.alpha = percentage screenView.segmentedControl.updateLeftConstraint(percentage) } diff --git a/Sources/ScanFeature/Controllers/ScanController.swift b/Sources/ScanFeature/Controllers/ScanController.swift index c172703bedd8b566cdf388172abc52a6804318f4..2a1b99aa6da269007f9692b5ebb0b3b81e6ca377 100644 --- a/Sources/ScanFeature/Controllers/ScanController.swift +++ b/Sources/ScanFeature/Controllers/ScanController.swift @@ -90,10 +90,6 @@ final class ScanController: UIViewController { .sink { [unowned self] in status = $0 screenView.update(with: $0) - - if case .failed(_) = $0 { - camera.stop() - } }.store(in: &cancellables) screenView.actionButton.publisher(for: .touchUpInside) diff --git a/Sources/ScanFeature/Controllers/ScanDisplayController.swift b/Sources/ScanFeature/Controllers/ScanDisplayController.swift index f772340fdc0bae3b03c8bfc4deab740082813215..e373d4de40ca31bae004de3d80c85702d538b604 100644 --- a/Sources/ScanFeature/Controllers/ScanDisplayController.swift +++ b/Sources/ScanFeature/Controllers/ScanDisplayController.swift @@ -1,6 +1,5 @@ import UIKit import Combine -import Countries final class ScanDisplayController: UIViewController { lazy private var screenView = ScanDisplayView() @@ -9,51 +8,57 @@ final class ScanDisplayController: UIViewController { private var cancellables = Set<AnyCancellable>() var didTapInfo: (() -> Void)? + var didTapAddPhone: (() -> Void)? + var didTapAddEmail: (() -> Void)? override func loadView() { view = screenView } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewModel.loadCached() + viewModel.generateQR() + } + override func viewDidLoad() { super.viewDidLoad() - viewModel.state - .map(\.image) + viewModel.statePublisher .receive(on: DispatchQueue.main) .sink { [unowned self] in - guard let ciimage = $0 else { return } - screenView.qrImage.image = UIImage(ciImage: ciimage) + if let image = $0.image { + screenView.setup(code: image) + } + + screenView.setupAttributes( + email: $0.email, + phone: $0.phone, + emailSharing: $0.isSharingEmail, + phoneSharing: $0.isSharingPhone + ) }.store(in: &cancellables) - if viewModel.email != nil || viewModel.phone != nil { - screenView.setupShareView { [weak self] in self?.didTapInfo?() } - - if let email = viewModel.email { - screenView.shareView.setup(email: email) - .sink { [unowned self] in viewModel.didToggleEmail() } - .store(in: &cancellables) - - viewModel.state.map(\.isSharingEmail) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.shareView.emailView.switcherView.setOn($0, animated: false) } - .store(in: &cancellables) - } - - if let phone = viewModel.phone { - let fullPhone = "\(Country.findFrom(phone).prefix)\(phone.dropLast(2))" - - screenView.shareView.setup(phone: fullPhone) - .sink { [unowned self] in viewModel.didTogglePhone() } - .store(in: &cancellables) - - viewModel.state.map(\.isSharingPhone) - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [unowned self] in screenView.shareView.phoneView.switcherView.setOn($0, animated: false) } - .store(in: &cancellables) - } - } + screenView.actionPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + switch $0 { + case .info: + didTapInfo?() + + case .addEmail: + didTapAddEmail?() + + case .addPhone: + didTapAddPhone?() + + case .toggleEmail: + viewModel.didToggleEmail() + + case .togglePhone: + viewModel.didTogglePhone() + } + }.store(in: &cancellables) viewModel.loadCached() viewModel.generateQR() diff --git a/Sources/ScanFeature/Coordinator/ScanCoordinator.swift b/Sources/ScanFeature/Coordinator/ScanCoordinator.swift index 86ed4bc1f15f158c27cff79fc121f6ec6761b8e8..6a36db1801d9874f687a3eef13dabf657125f943 100644 --- a/Sources/ScanFeature/Coordinator/ScanCoordinator.swift +++ b/Sources/ScanFeature/Coordinator/ScanCoordinator.swift @@ -5,6 +5,8 @@ import Presentation import ContactFeature public protocol ScanCoordinating { + func toEmail(from: UIViewController) + func toPhone(from: UIViewController) func toContacts(from: UIViewController) func toRequests(from: UIViewController) func toSideMenu(from: UIViewController) @@ -18,17 +20,23 @@ public struct ScanCoordinator: ScanCoordinating { var bottomPresenter: Presenting = BottomPresenter() var replacePresenter: Presenting = ReplacePresenter(mode: .replaceLast) + var emailFactory: () -> UIViewController + var phoneFactory: () -> UIViewController var contactsFactory: () -> UIViewController var requestsFactory: () -> UIViewController var contactFactory: (Contact) -> UIViewController var sideMenuFactory: (MenuItem, UIViewController) -> UIViewController public init( + emailFactory: @escaping () -> UIViewController, + phoneFactory: @escaping () -> UIViewController, contactsFactory: @escaping () -> UIViewController, requestsFactory: @escaping () -> UIViewController, contactFactory: @escaping (Contact) -> UIViewController, sideMenuFactory: @escaping (MenuItem, UIViewController) -> UIViewController ) { + self.emailFactory = emailFactory + self.phoneFactory = phoneFactory self.contactFactory = contactFactory self.contactsFactory = contactsFactory self.requestsFactory = requestsFactory @@ -37,6 +45,21 @@ public struct ScanCoordinator: ScanCoordinating { } public extension ScanCoordinator { + func toContact( + _ contact: Contact, + from parent: UIViewController + ) { + let screen = contactFactory(contact) + pushPresenter.present(screen, from: parent) + } + + func toDrawer( + _ drawer: UIViewController, + from parent: UIViewController + ) { + bottomPresenter.present(drawer, from: parent) + } + func toRequests(from parent: UIViewController) { let screen = requestsFactory() replacePresenter.present(screen, from: parent) @@ -47,17 +70,18 @@ public extension ScanCoordinator { replacePresenter.present(screen, from: parent) } - func toContact(_ contact: Contact, from parent: UIViewController) { - let screen = contactFactory(contact) - pushPresenter.present(screen, from: parent) + func toSideMenu(from parent: UIViewController) { + let screen = sideMenuFactory(.scan, parent) + sidePresenter.present(screen, from: parent) } - public func toDrawer(_ drawer: UIViewController, from parent: UIViewController) { - bottomPresenter.present(drawer, from: parent) + func toEmail(from parent: UIViewController) { + let screen = emailFactory() + pushPresenter.present(screen, from: parent) } - func toSideMenu(from parent: UIViewController) { - let screen = sideMenuFactory(.scan, parent) - sidePresenter.present(screen, from: parent) + func toPhone(from parent: UIViewController) { + let screen = phoneFactory() + pushPresenter.present(screen, from: parent) } } diff --git a/Sources/ScanFeature/ViewModels/ScanDisplayViewModel.swift b/Sources/ScanFeature/ViewModels/ScanDisplayViewModel.swift index 00ad9e1e70ba821cb43bae9e3cc176c53b6d180f..df4560653bbf5bd8f342148a3847ddef9f383d97 100644 --- a/Sources/ScanFeature/ViewModels/ScanDisplayViewModel.swift +++ b/Sources/ScanFeature/ViewModels/ScanDisplayViewModel.swift @@ -1,46 +1,57 @@ import UIKit import Combine import Defaults +import Countries import Integration import DependencyInjection struct ScanDisplayViewState: Equatable { var image: CIImage? + var email: String? + var phone: String? var isSharingEmail: Bool = false var isSharingPhone: Bool = false } final class ScanDisplayViewModel { - // MARK: Stored - - @KeyObject(.email, defaultValue: nil) var email: String? - @KeyObject(.phone, defaultValue: nil) var phone: String? - @KeyObject(.sharingEmail, defaultValue: false) var sharingEmail: Bool - @KeyObject(.sharingPhone, defaultValue: false) var sharingPhone: Bool + @Dependency private var session: SessionType - // MARK: Properties + @KeyObject(.email, defaultValue: nil) private var email: String? + @KeyObject(.phone, defaultValue: nil) private var phone: String? + @KeyObject(.sharingEmail, defaultValue: false) private var sharingEmail: Bool + @KeyObject(.sharingPhone, defaultValue: false) private var sharingPhone: Bool - var state: AnyPublisher<ScanDisplayViewState, Never> { stateRelay.eraseToAnyPublisher() } - private let stateRelay = CurrentValueSubject<ScanDisplayViewState, Never>(.init()) + var statePublisher: AnyPublisher<ScanDisplayViewState, Never> { + stateSubject.eraseToAnyPublisher() + } - // MARK: Injected + private let stateSubject = CurrentValueSubject<ScanDisplayViewState, Never>(.init()) - @Dependency private var session: SessionType + func loadCached() { + var cleanPhone: String? - // MARK: Public + if let dirtyPhone = phone { + cleanPhone = "\(Country.findFrom(dirtyPhone).prefix)\(dirtyPhone.dropLast(2))" + } - func loadCached() { - stateRelay.value.isSharingEmail = sharingEmail - stateRelay.value.isSharingPhone = sharingPhone + stateSubject.value = .init( + image: stateSubject.value.image, + email: email, + phone: cleanPhone, + isSharingEmail: sharingEmail, + isSharingPhone: sharingPhone + ) } func didToggleEmail() { sharingEmail.toggle() + stateSubject.value.isSharingEmail = sharingEmail generateQR() } func didTogglePhone() { sharingPhone.toggle() + stateSubject.value.isSharingPhone = sharingPhone generateQR() } @@ -51,7 +62,7 @@ final class ScanDisplayViewModel { let transform = CGAffineTransform(scaleX: 5, y: 5) if let output = filter.outputImage?.transformed(by: transform) { - stateRelay.value.image = output + stateSubject.value.image = output } } } diff --git a/Sources/ScanFeature/ViewModels/ScanViewModel.swift b/Sources/ScanFeature/ViewModels/ScanViewModel.swift index 8656ad14c35f1445c4c8b64da15e432a32e14095..c3f51841e1726407c69f47de05f15570f01c438f 100644 --- a/Sources/ScanFeature/ViewModels/ScanViewModel.swift +++ b/Sources/ScanFeature/ViewModels/ScanViewModel.swift @@ -78,7 +78,8 @@ final class ScanViewModel { marshaled: data, username: usernameAndId.0, nickname: nil, - createdAt: Date() + createdAt: Date(), + isRecent: false ) self.succeed(with: contact) diff --git a/Sources/ScanFeature/Views/AttributeSwitcher.swift b/Sources/ScanFeature/Views/AttributeSwitcher.swift index 4c62bdba700343900ef8c3612d08e5fcd7b6411b..4bc957c6816dfc1c36b50ed5c47e46d6cf0180f5 100644 --- a/Sources/ScanFeature/Views/AttributeSwitcher.swift +++ b/Sources/ScanFeature/Views/AttributeSwitcher.swift @@ -2,13 +2,45 @@ import UIKit import Shared final class AttributeSwitcher: UIView { - let contentLabel = UILabel() - let titleLabel = UILabel() - let iconImageView = UIImageView() - let separatorView = UIView() - let switcherView = UISwitch() - let stackView = UIStackView() - let verticalStackView = UIStackView() + struct State { + var content: String + var isVisible: Bool + } + + private let titleLabel = UILabel() + private let contentLabel = UILabel() + private let stackView = UIStackView() + private(set) var switcherView = UISwitch() + private let verticalStackView = UIStackView() + + private(set) var addButton: UIControl = { + let label = UILabel() + let icon = UIImageView() + let control = UIControl() + + icon.image = Asset.scanAdd.image + label.text = Localized.Scan.Display.Share.add + label.textColor = Asset.brandPrimary.color + + control.addSubview(icon) + control.addSubview(label) + + icon.snp.makeConstraints { + $0.left.equalToSuperview() + $0.top.equalToSuperview() + $0.bottom.equalToSuperview() + $0.width.equalTo(icon.snp.height) + } + + label.snp.makeConstraints { + $0.left.equalTo(icon.snp.right).offset(5) + $0.top.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + } + + return control + }() public init() { super.init(frame: .zero) @@ -16,60 +48,55 @@ final class AttributeSwitcher: UIView { contentLabel.textColor = Asset.neutralActive.color titleLabel.textColor = Asset.neutralWeak.color switcherView.onTintColor = Asset.brandPrimary.color - separatorView.backgroundColor = Asset.neutralLine.color - - iconImageView.contentMode = .center - iconImageView.setContentHuggingPriority(.required, for: .horizontal) contentLabel.numberOfLines = 0 contentLabel.font = Fonts.Mulish.regular.font(size: 16.0) titleLabel.font = Fonts.Mulish.bold.font(size: 12.0) addSubview(stackView) - addSubview(separatorView) - verticalStackView.spacing = 8 + verticalStackView.spacing = 5 verticalStackView.axis = .vertical verticalStackView.addArrangedSubview(titleLabel) verticalStackView.addArrangedSubview(contentLabel) - let icon = iconImageView.pinning(at: .top(10)) + switcherView.setContentCompressionResistancePriority(.required, for: .vertical) + switcherView.setContentCompressionResistancePriority(.required, for: .horizontal) - stackView.spacing = 20 - stackView.addArrangedSubview(icon) stackView.addArrangedSubview(verticalStackView) - stackView.addArrangedSubview(switcherView.pinning(at: .top(5))) + stackView.addArrangedSubview(FlexibleSpace()) - stackView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(26) - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.equalToSuperview().offset(-25) - } + let otherHStack = UIStackView() + otherHStack.addArrangedSubview(addButton) + otherHStack.addArrangedSubview(switcherView) - separatorView.snp.makeConstraints { make in - make.height.equalTo(1) - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.equalToSuperview() + let otherVStack = UIStackView() + otherVStack.axis = .vertical + otherVStack.addArrangedSubview(otherHStack) + otherVStack.addArrangedSubview(FlexibleSpace()) + + stackView.addArrangedSubview(otherVStack) + + stackView.snp.makeConstraints { + $0.edges.equalToSuperview() } } required init?(coder: NSCoder) { nil } - func set( - title: String, - text: String, - icon: UIImage, - separator: Bool = true - ) { + func setup(state: State?, title: String) { titleLabel.text = title - contentLabel.text = text - iconImageView.image = icon - guard separator == true else { - self.separatorView.removeFromSuperview() + guard let state = state else { + addButton.isHidden = false + switcherView.isHidden = true + contentLabel.text = Localized.Scan.Display.Share.notAdded return } + + addButton.isHidden = true + switcherView.isHidden = false + switcherView.isOn = state.isVisible + contentLabel.text = state.isVisible ? state.content : Localized.Scan.Display.Share.hidden } } diff --git a/Sources/ScanFeature/Views/ScanContainerView.swift b/Sources/ScanFeature/Views/ScanContainerView.swift index 5e8328284a7b2136a792471dc5fabf42e3d9ce3e..b9b35cef12d02a3a8ea88ead84692938e31ed773 100644 --- a/Sources/ScanFeature/Views/ScanContainerView.swift +++ b/Sources/ScanFeature/Views/ScanContainerView.swift @@ -3,41 +3,26 @@ import Shared final class ScanContainerView: UIView { let scrollView = UIScrollView() - let scanScreen = ScanController() let segmentedControl = SegmentedControl() - let displayScreen = ScanDisplayController() init() { super.init(frame: .zero) - backgroundColor = Asset.neutralActive.color + backgroundColor = Asset.neutralDark.color addSubview(segmentedControl) addSubview(scrollView) - scrollView.addSubview(scanScreen.view) - scrollView.addSubview(displayScreen.view) - - setupConstraints() - } - - required init?(coder: NSCoder) { nil } - - private func setupConstraints() { - scrollView.snp.makeConstraints { $0.edges.equalToSuperview() } - - displayScreen.view.snp.makeConstraints { $0.top.bottom.width.equalTo(scanScreen.view) } - - segmentedControl.snp.makeConstraints { make in - make.top.equalTo(safeAreaLayoutGuide).offset(40) - make.centerX.equalToSuperview() - make.height.equalTo(30) + scrollView.snp.makeConstraints { + $0.edges.equalToSuperview() } - scanScreen.view.snp.makeConstraints { make in - make.top.equalTo(self) - make.bottom.width.equalTo(self) - make.right.equalTo(displayScreen.view.snp.left) - make.left.equalToSuperview() + segmentedControl.snp.makeConstraints { + $0.top.equalTo(safeAreaLayoutGuide).offset(10) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.height.equalTo(60) } } + + required init?(coder: NSCoder) { nil } } diff --git a/Sources/ScanFeature/Views/ScanDisplayShareView.swift b/Sources/ScanFeature/Views/ScanDisplayShareView.swift index 79030c63d7eedc91d37fb23878c7c5ee8ab97cb7..acad98f7cab2ffd36c0b210737c17cd26dc21904 100644 --- a/Sources/ScanFeature/Views/ScanDisplayShareView.swift +++ b/Sources/ScanFeature/Views/ScanDisplayShareView.swift @@ -1,81 +1,198 @@ import UIKit import Shared +import SnapKit +import Combine final class ScanDisplayShareView: UIView { - let stackView = UIStackView() - let textWithInfo = TextWithInfoView() - let emailView = AttributeSwitcher() - let phoneView = AttributeSwitcher() + enum Action { + case info + case addEmail + case addPhone + case toggleEmail + case togglePhone + } + + private var isExpanded = false { + didSet { updateBottomConstraint() } + } + + private let upperView = UIView() + private let lowerView = UIView() + private var bottomConstraint: Constraint? - var didTapInfo: (() -> Void)? + private let imageView = UIImageView() + private let titleView = TextWithInfoView() + private let emailView = AttributeSwitcher() + private let phoneView = AttributeSwitcher() + private var cancellables = Set<AnyCancellable>() + + private var currentConstraintConstant: CGFloat = 0.0 { + didSet { bottomConstraint?.update(offset: currentConstraintConstant) } + } + + private var bottomConstraintExpanded: CGFloat { + -lowerView.frame.height + } + + private var bottomConstraintNotExpanded: CGFloat { + 0 + } + + var actionPublisher: AnyPublisher<Action, Never> { + actionSubject.eraseToAnyPublisher() + } + + private let actionSubject = PassthroughSubject<Action, Never>() init() { super.init(frame: .zero) + + upperView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(didPan(_:)))) + lowerView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(didPan(_:)))) + + layer.cornerRadius = 30 + imageView.image = Asset.scanDropdown.image backgroundColor = Asset.neutralWhite.color + clipsToBounds = true + layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - let titleContainer = UIView() - titleContainer.addSubview(textWithInfo) + addSubview(upperView) + addSubview(lowerView) + + upperView.addSubview(imageView) + upperView.addSubview(titleView) + lowerView.addSubview(emailView) + lowerView.addSubview(phoneView) let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineBreakMode = .byWordWrapping - textWithInfo.setup( + titleView.setup( text: Localized.Scan.Display.Share.title, attributes: [ .foregroundColor: Asset.neutralBody.color, .font: Fonts.Mulish.regular.font(size: 16.0) as Any, .paragraphStyle: paragraphStyle ], - didTapInfo: { [weak self] in self?.didTapInfo?() } + didTapInfo: { [weak self] in self?.actionSubject.send(.info) } ) - stackView.spacing = 5 - stackView.axis = .vertical - stackView.addArrangedSubview(titleContainer) + emailView.switcherView + .publisher(for: .valueChanged) + .sink { [unowned self] in actionSubject.send(.toggleEmail) } + .store(in: &cancellables) + + phoneView.switcherView + .publisher(for: .valueChanged) + .sink { [unowned self] in actionSubject.send(.togglePhone) } + .store(in: &cancellables) + + emailView.addButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in actionSubject.send(.addEmail) } + .store(in: &cancellables) + + phoneView.addButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in actionSubject.send(.addPhone) } + .store(in: &cancellables) + + emailView.setup(state: nil, title: Localized.Scan.Display.Share.email) + phoneView.setup(state: nil, title: Localized.Scan.Display.Share.phone) + emailView.alpha = 0.0 + phoneView.alpha = 0.0 + + imageView.snp.makeConstraints { + $0.top.equalToSuperview().offset(15) + $0.centerX.equalToSuperview() + } - addSubview(stackView) + titleView.snp.makeConstraints { + $0.top.equalTo(imageView.snp.bottom).offset(10) + $0.left.equalToSuperview().offset(40) + $0.right.lessThanOrEqualToSuperview().offset(-40) + $0.centerY.equalToSuperview() + } - stackView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(22) - make.left.equalToSuperview().offset(22) - make.right.equalToSuperview().offset(-22) - make.bottom.equalToSuperview().offset(-50) + emailView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview().offset(40) + $0.right.equalToSuperview().offset(-40) } - textWithInfo.snp.makeConstraints { make in - make.top.greaterThanOrEqualToSuperview() - make.centerY.equalToSuperview() - make.bottom.lessThanOrEqualToSuperview() - make.left.equalToSuperview().offset(5) - make.right.lessThanOrEqualToSuperview().offset(27) - make.height.equalTo(30) + phoneView.snp.makeConstraints { + $0.top.equalTo(emailView.snp.bottom).offset(25) + $0.left.equalToSuperview().offset(40) + $0.right.equalToSuperview().offset(-40) + $0.bottom.equalToSuperview().offset(-40) } - } - required init?(coder: NSCoder) { nil } + upperView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.right.equalToSuperview() + bottomConstraint = $0.bottom + .equalTo(safeAreaLayoutGuide) + .constraint + } - func setup(email: String) -> UIControl.EventPublisher { - stackView.addArrangedSubview(emailView) + lowerView.snp.makeConstraints { + $0.top.equalTo(upperView.snp.bottom).offset(-30) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + } + } - emailView.set( - title: Localized.Scan.Display.Share.email, - text: email, - icon: Asset.scanEmail.image - ) + required init?(coder: NSCoder) { nil } - return emailView.switcherView.publisher(for: .valueChanged) + func setup(email state: AttributeSwitcher.State?) { + emailView.setup(state: state, title: Localized.Scan.Display.Share.email) } - func setup(phone: String) -> UIControl.EventPublisher { - stackView.addArrangedSubview(phoneView) + func setup(phone state: AttributeSwitcher.State?) { + phoneView.setup(state: state, title: Localized.Scan.Display.Share.phone) + } - phoneView.set( - title: Localized.Scan.Display.Share.phone, - text: phone, - icon: Asset.scanPhone.image, - separator: false - ) + @objc private func didPan(_ sender: UIPanGestureRecognizer) { + switch sender.state { + case .began, .changed: + let isUpwards = sender.translation(in: self).y < 0 + let result = currentConstraintConstant + sender.translation(in: self).y + + if isUpwards { + currentConstraintConstant = max(bottomConstraintExpanded, result) + } else { + currentConstraintConstant = min(bottomConstraintNotExpanded, result) + } + + let currentMinusExpanded = currentConstraintConstant - bottomConstraintExpanded + let notExpandedMinusExpanded = bottomConstraintNotExpanded - bottomConstraintExpanded + let alpha = 1 - (currentMinusExpanded / abs(notExpandedMinusExpanded)) + emailView.alpha = alpha + phoneView.alpha = alpha + + case .cancelled, .ended, .failed: + let currentMinusExpanded = currentConstraintConstant - bottomConstraintExpanded + let notExpandedMinusExpanded = bottomConstraintNotExpanded - bottomConstraintExpanded + let percentage = currentMinusExpanded / abs(notExpandedMinusExpanded) + isExpanded = percentage < 0.5 + + case .possible: + break + @unknown default: + break + } + } - return phoneView.switcherView.publisher(for: .valueChanged) + private func updateBottomConstraint() { + if isExpanded { + emailView.alpha = 1.0 + phoneView.alpha = 1.0 + currentConstraintConstant = bottomConstraintExpanded + } else { + emailView.alpha = 0.0 + phoneView.alpha = 0.0 + currentConstraintConstant = bottomConstraintNotExpanded + } } } diff --git a/Sources/ScanFeature/Views/ScanDisplayView.swift b/Sources/ScanFeature/Views/ScanDisplayView.swift index c1a1b23b402e1286a2475edf225794f736432edd..fa2b02438c0d735708b9a30ffc4a6c2228926890 100644 --- a/Sources/ScanFeature/Views/ScanDisplayView.swift +++ b/Sources/ScanFeature/Views/ScanDisplayView.swift @@ -1,42 +1,101 @@ import UIKit import Shared +import Combine final class ScanDisplayView: UIView { - let qrView = UIView() - let qrImage = UIImageView() - let shareView = ScanDisplayShareView() + var actionPublisher: AnyPublisher<ScanDisplayShareView.Action, Never> { + shareSheetView.actionPublisher.eraseToAnyPublisher() + } + + private let copyLabel = UILabel() + private let codeButton = ScanQRButton() + private let copyImageView = UIImageView() + private let copyContainerButton = UIControl() + private var cancellables = Set<AnyCancellable>() + private let shareSheetView = ScanDisplayShareView() init() { super.init(frame: .zero) backgroundColor = Asset.neutralDark.color - qrView.backgroundColor = Asset.neutralWhite.color - qrView.layer.cornerRadius = 30 + copyImageView.image = Asset.scanCopy.image + copyLabel.text = Localized.Scan.Display.copy + copyLabel.textColor = Asset.neutralDisabled.color + copyLabel.font = Fonts.Mulish.semiBold.font(size: 13.0) + + codeButton.publisher(for: .touchUpInside) + .merge(with: copyContainerButton.publisher(for: .touchUpInside)) + .sink { [unowned self] in + UIGraphicsBeginImageContext(codeButton.frame.size) + codeButton.layer.render(in: UIGraphicsGetCurrentContext()!) + let output = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + UIImageWriteToSavedPhotosAlbum(output!, nil, nil, nil) + codeButton.blinkCopied() + }.store(in: &cancellables) + + addSubview(codeButton) + addSubview(copyContainerButton) + copyContainerButton.addSubview(copyLabel) + copyContainerButton.addSubview(copyImageView) + + addSubview(shareSheetView) + + codeButton.snp.makeConstraints { + $0.centerX.equalTo(safeAreaLayoutGuide) + $0.centerY.equalTo(safeAreaLayoutGuide).multipliedBy(0.6) + $0.width.equalTo(safeAreaLayoutGuide).multipliedBy(0.6) + $0.height.equalTo(codeButton.snp.width) + } + + copyContainerButton.snp.makeConstraints { + $0.top.equalTo(codeButton.snp.bottom).offset(33) + $0.centerX.equalTo(codeButton) + } - addSubview(qrView) - qrView.addSubview(qrImage) + copyImageView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.bottom.equalToSuperview() + } - qrView.snp.makeConstraints { make in - make.width.height.equalTo(207) - make.centerX.equalToSuperview() - make.centerY.equalToSuperview().multipliedBy(0.8) + copyLabel.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalTo(copyImageView.snp.right).offset(5) + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() } - qrImage.snp.makeConstraints { make in - make.center.equalToSuperview() - make.left.top.equalToSuperview().offset(20) + shareSheetView.snp.makeConstraints { + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() } } required init?(coder: NSCoder) { nil } - func setupShareView(info: @escaping () -> Void) { - addSubview(shareView) - shareView.didTapInfo = info + func setup(code image: CIImage) { + codeButton.setup(code: image) + } + + func setupAttributes( + email: String?, + phone: String?, + emailSharing: Bool, + phoneSharing: Bool + ) { + if let email = email { + shareSheetView.setup(email: .init(content: email, isVisible: emailSharing)) + } else { + shareSheetView.setup(email: nil) + } - shareView.snp.makeConstraints { make in - make.left.right.equalToSuperview() - make.bottom.equalTo(safeAreaLayoutGuide) + if let phone = phone { + shareSheetView.setup(phone: .init(content: phone, isVisible: phoneSharing)) + } else { + shareSheetView.setup(phone: nil) } } } diff --git a/Sources/ScanFeature/Views/ScanOverlayView.swift b/Sources/ScanFeature/Views/ScanOverlayView.swift index 995e7eb2255f6f0719359d92ae9d806e1686e79d..14bc4c5dc736a388a41d9c04f21a51fa49467e99 100644 --- a/Sources/ScanFeature/Views/ScanOverlayView.swift +++ b/Sources/ScanFeature/Views/ScanOverlayView.swift @@ -2,13 +2,13 @@ import UIKit import Shared final class ScanOverlayView: UIView { - let cropView = UIView() - let maskLayer = CAShapeLayer() - - let topLeftLayer = CAShapeLayer() - let topRightLayer = CAShapeLayer() - let bottomLeftLayer = CAShapeLayer() - let bottomRightLayer = CAShapeLayer() + 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) @@ -16,9 +16,11 @@ final class ScanOverlayView: UIView { addSubview(cropView) - cropView.snp.makeConstraints { make in - make.center.equalToSuperview() - make.width.height.equalTo(207) + cropView.snp.makeConstraints { + $0.width.equalTo(scanViewLength) + $0.centerY.equalToSuperview().offset(-50) + $0.centerX.equalToSuperview() + $0.height.equalTo(scanViewLength) } maskLayer.fillRule = .evenOdd @@ -63,38 +65,105 @@ final class ScanOverlayView: UIView { func topLeftPath() -> CGPath { let path = UIBezierPath() - path.addArc( - center: CGPoint(x: cropView.frame.minX + 10, y: cropView.frame.minY + 10), - startAngle: .pi - ) + + 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() - path.addArc( - center: CGPoint(x: cropView.frame.maxX - 10, y: cropView.frame.minY + 10), - startAngle: 3 * .pi/2 - ) + + 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() - path.addArc( - center: CGPoint(x: cropView.frame.maxX - 10, y: cropView.frame.maxY - 10), - startAngle: 0 - ) + + 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() - path.addArc( - center: CGPoint(x: cropView.frame.minX + 10, y: cropView.frame.maxY - 10), - startAngle: .pi/2 - ) + + 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 } } diff --git a/Sources/ScanFeature/Views/ScanQRButton.swift b/Sources/ScanFeature/Views/ScanQRButton.swift new file mode 100644 index 0000000000000000000000000000000000000000..66d911c602a67c4ad01a02002fc4f9de2b2563ac --- /dev/null +++ b/Sources/ScanFeature/Views/ScanQRButton.swift @@ -0,0 +1,59 @@ +import UIKit +import Shared + +final class ScanQRButton: UIControl { + private let overlayView = UIView() + private let copiedLabel = UILabel() + private(set) var imageView = UIImageView() + + init() { + super.init(frame: .zero) + + clipsToBounds = true + overlayView.alpha = 0.0 + layer.cornerRadius = 30 + backgroundColor = Asset.neutralWhite.color + overlayView.backgroundColor = Asset.brandDefault.color.withAlphaComponent(0.9) + + copiedLabel.text = Localized.Scan.Display.copied + copiedLabel.textColor = Asset.neutralWhite.color + copiedLabel.font = Fonts.Mulish.semiBold.font(size: 18.0) + + addSubview(imageView) + addSubview(overlayView) + overlayView.addSubview(copiedLabel) + + copiedLabel.snp.makeConstraints { + $0.center.equalToSuperview() + } + + imageView.snp.makeConstraints { + $0.top.equalToSuperview().offset(20) + $0.left.equalToSuperview().offset(20) + $0.right.equalToSuperview().offset(-20) + $0.bottom.equalToSuperview().offset(-20) + } + + overlayView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + required init?(coder: NSCoder) { nil } + + func setup(code image: CIImage) { + imageView.image = UIImage(ciImage: image) + } + + func blinkCopied() { + UIView.animateKeyframes(withDuration: 1.0, delay: 0) { + UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 0.1) { + self.overlayView.alpha = 1.0 + } + + UIView.addKeyframe(withRelativeStartTime: 0.8, relativeDuration: 0.2) { + self.overlayView.alpha = 0.0 + } + } + } +} diff --git a/Sources/ScanFeature/Views/ScanView.swift b/Sources/ScanFeature/Views/ScanView.swift index a21ceb854bea263ae0912541b4aff23a42b29b78..7aff4cf2c85928c1e9ef71bcebe2590b0eb1cf09 100644 --- a/Sources/ScanFeature/Views/ScanView.swift +++ b/Sources/ScanFeature/Views/ScanView.swift @@ -2,16 +2,16 @@ import UIKit import Shared final class ScanView: UIView { - let overlay = ScanOverlayView() - let animationView = DotAnimation() - let iconImageView = UIImageView() - let statusLabel = UILabel() - let actionButton = CapsuleButton() - let stackView = UIStackView() + private let statusLabel = UILabel() + private let imageView = UIImageView() + private let stackView = UIStackView() + private let animationView = DotAnimation() + private let overlayView = ScanOverlayView() + private(set) var actionButton = CapsuleButton() init() { super.init(frame: .zero) - iconImageView.contentMode = .center + imageView.contentMode = .center actionButton.setStyle(.brandColored) statusLabel.numberOfLines = 0 @@ -22,28 +22,28 @@ final class ScanView: UIView { stackView.spacing = 15 stackView.axis = .vertical stackView.addArrangedSubview(animationView) - stackView.addArrangedSubview(iconImageView) + stackView.addArrangedSubview(imageView) stackView.addArrangedSubview(statusLabel) stackView.addArrangedSubview(actionButton) - animationView.isHidden = false - iconImageView.isHidden = true + imageView.isHidden = true actionButton.isHidden = true + animationView.isHidden = false - addSubview(overlay) + addSubview(overlayView) addSubview(stackView) - overlay.snp.makeConstraints { make in - make.top.equalToSuperview() - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.equalToSuperview() + overlayView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() } - stackView.snp.makeConstraints { make in - make.left.equalToSuperview().offset(57) - make.right.equalToSuperview().offset(-57) - make.bottom.equalTo(safeAreaLayoutGuide).offset(-100) + stackView.snp.makeConstraints { + $0.left.equalToSuperview().offset(57) + $0.right.equalToSuperview().offset(-57) + $0.bottom.equalTo(safeAreaLayoutGuide).offset(-100) } } @@ -54,24 +54,24 @@ final class ScanView: UIView { switch state { case .reading, .processing: - iconImageView.isHidden = true + imageView.isHidden = true actionButton.isHidden = true text = Localized.Scan.Status.reading - overlay.updateCornerColor(Asset.brandPrimary.color) + overlayView.updateCornerColor(Asset.brandPrimary.color) case .success: animationView.isHidden = true actionButton.isHidden = true - iconImageView.isHidden = false - iconImageView.image = Asset.sharedSuccess.image + imageView.isHidden = false + imageView.image = Asset.sharedSuccess.image text = Localized.Scan.Status.success - overlay.updateCornerColor(Asset.accentSuccess.color) + overlayView.updateCornerColor(Asset.accentSuccess.color) case .failed(let error): animationView.isHidden = true - iconImageView.image = Asset.scanError.image - iconImageView.isHidden = false - overlay.updateCornerColor(Asset.accentDanger.color) + imageView.image = Asset.scanError.image + imageView.isHidden = false + overlayView.updateCornerColor(Asset.accentDanger.color) switch error { case .requestOpened: @@ -101,7 +101,7 @@ final class ScanView: UIView { attString.addAttribute(.paragraphStyle, value: paragraph) attString.addAttribute(.foregroundColor, value: Asset.neutralWhite.color) - attString.addAttribute(.font, value: Fonts.Mulish.semiBold.font(size: 18.0) as Any) + 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: "#") diff --git a/Sources/ScanFeature/Views/SegmentedControl.swift b/Sources/ScanFeature/Views/SegmentedControl.swift index 3b9d9a5e3a43372257b64220b4607a98d40b16bd..11faf5a55448b39530fc66a71610a8b49852dfd0 100644 --- a/Sources/ScanFeature/Views/SegmentedControl.swift +++ b/Sources/ScanFeature/Views/SegmentedControl.swift @@ -3,26 +3,32 @@ import Shared import SnapKit final class SegmentedControl: UIView { - let trackView = UIView() - let trackIndicatorView = UIView() - var leftConstraint: Constraint? - let leftButton = SegmentedControlButton() - let rightButton = SegmentedControlButton() - let stackView = UIStackView() + 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() init() { super.init(frame: .zero) - rightButton.icon.image = Asset.scanQr.image - leftButton.title.text = Localized.Scan.SegmentedControl.left - rightButton.title.text = Localized.Scan.SegmentedControl.right + rightButton.setup( + title: Localized.Scan.SegmentedControl.right, + icon: Asset.scanQr.image + ) - leftButton.title.font = Fonts.Mulish.semiBold.font(size: 14.0) - rightButton.title.font = Fonts.Mulish.semiBold.font(size: 14.0) + leftButton.setup( + title: Localized.Scan.SegmentedControl.left, + icon: Asset.scanScan.image + ) + trackView.backgroundColor = Asset.neutralLine.color trackIndicatorView.backgroundColor = Asset.brandPrimary.color - stackView.spacing = 40 + stackView.distribution = .fillEqually stackView.addArrangedSubview(leftButton) stackView.addArrangedSubview(rightButton) @@ -30,30 +36,45 @@ final class SegmentedControl: UIView { addSubview(trackView) trackView.addSubview(trackIndicatorView) - stackView.snp.makeConstraints { make in - make.top.equalTo(trackView.snp.bottom).offset(2) - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.equalToSuperview() + stackView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalTo(trackView.snp.top) } - trackView.snp.makeConstraints { make in - make.top.equalToSuperview() - make.left.equalTo(stackView) - make.right.equalTo(stackView) - make.height.equalTo(3) + trackView.snp.makeConstraints { + $0.height.equalTo(trackHeight) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() } - trackIndicatorView.snp.makeConstraints { make in - leftConstraint = make.left.equalToSuperview().constraint - make.top.bottom.equalToSuperview() - make.width.equalTo(75) + trackIndicatorView.snp.makeConstraints { + $0.top.equalToSuperview() + leftConstraint = $0.left.equalToSuperview().constraint + $0.width.equalToSuperview().dividedBy(numberOfTabs) + $0.bottom.equalToSuperview() } } required init?(coder: NSCoder) { nil } - func updateLeftConstraint(_ percentage: CGFloat) { - leftConstraint?.update(offset: percentage * 125) + func updateLeftConstraint(_ percentageScrolled: CGFloat) { + let tabWidth = bounds.width / numberOfTabs + let leftOffset = percentageScrolled * tabWidth + leftConstraint?.update(offset: leftOffset) + + leftButton.update(color: .fade( + from: Asset.brandPrimary.color, + to: Asset.neutralLine.color, + pcent: percentageScrolled + )) + + rightButton.update(color: .fade( + from: Asset.brandPrimary.color, + to: Asset.neutralLine.color, + pcent: 1 - percentageScrolled + )) } } diff --git a/Sources/ScanFeature/Views/SegmentedControlButton.swift b/Sources/ScanFeature/Views/SegmentedControlButton.swift index 8270a1947e8eed7360cd114fd31e13332e012566..c23f16c1539338457198024f84216ffffdca1590 100644 --- a/Sources/ScanFeature/Views/SegmentedControlButton.swift +++ b/Sources/ScanFeature/Views/SegmentedControlButton.swift @@ -2,28 +2,40 @@ import UIKit import Shared final class SegmentedControlButton: UIControl { - let title = UILabel() - let icon = UIImageView() - let stack = UIStackView() + private let titleLabel = UILabel() + private let imageView = UIImageView() init() { super.init(frame: .zero) - title.textColor = Asset.neutralWhite.color - title.font = Fonts.Mulish.bold.font(size: 15.0) + titleLabel.textAlignment = .center + titleLabel.textColor = Asset.neutralWhite.color + titleLabel.font = Fonts.Mulish.semiBold.font(size: 13.0) - addSubview(icon) - addSubview(title) + addSubview(titleLabel) + addSubview(imageView) - stack.spacing = 6 - stack.addArrangedSubview(icon) - stack.addArrangedSubview(title) - stack.isUserInteractionEnabled = false + imageView.snp.makeConstraints { + $0.top.equalToSuperview().offset(7.5) + $0.centerX.equalToSuperview() + } - addSubview(stack) - - stack.snp.makeConstraints { $0.edges.equalToSuperview() } + titleLabel.snp.makeConstraints { + $0.top.equalTo(imageView.snp.bottom).offset(2) + $0.centerX.equalToSuperview() + $0.bottom.equalToSuperview().offset(-7.5) + } } required init?(coder: NSCoder) { nil } + + func setup(title: String, icon: UIImage) { + titleLabel.text = title + imageView.image = icon + } + + func update(color: UIColor) { + imageView.tintColor = color + titleLabel.textColor = color + } } diff --git a/Sources/SearchFeature/ViewModels/SearchViewModel.swift b/Sources/SearchFeature/ViewModels/SearchViewModel.swift index f750fc38c472379547b721f1ac148139b77cc619..7702558f24ae08d75c701ef4203a52fe1607574f 100644 --- a/Sources/SearchFeature/ViewModels/SearchViewModel.swift +++ b/Sources/SearchFeature/ViewModels/SearchViewModel.swift @@ -6,7 +6,7 @@ import Defaults import Countries import Foundation import Integration -import PushNotifications +import PushFeature import CombineSchedulers import DependencyInjection @@ -143,7 +143,7 @@ final class SearchViewModel { private func verifyNotifications() { guard pushNotifications == false else { return } - pushHandler.didRequestAuthorization { [weak self] result in + pushHandler.requestAuthorization { [weak self] result in guard let self = self else { return } switch result { diff --git a/Sources/SettingsFeature/Controllers/SettingsAdvancedController.swift b/Sources/SettingsFeature/Controllers/SettingsAdvancedController.swift index 1645d25a32e6325eb902a122f2633cabdfd0cab7..aeb6791eefb9685595edd21c816026de9f2ebb0a 100644 --- a/Sources/SettingsFeature/Controllers/SettingsAdvancedController.swift +++ b/Sources/SettingsFeature/Controllers/SettingsAdvancedController.swift @@ -56,6 +56,11 @@ public final class SettingsAdvancedController: UIViewController { .sink { [weak viewModel] in viewModel?.didToggleRecordLogs() } .store(in: &cancellables) + screenView.showUsernamesSwitcher.switcherView + .publisher(for: .valueChanged) + .sink { [weak viewModel] in viewModel?.didToggleShowUsernames() } + .store(in: &cancellables) + screenView.crashReportingSwitcher.switcherView .publisher(for: .valueChanged) .sink { [weak viewModel] in viewModel?.didToggleCrashReporting() } @@ -71,6 +76,7 @@ public final class SettingsAdvancedController: UIViewController { .sink { [unowned self] state in screenView.logRecordingSwitcher.switcherView.setOn(state.isRecordingLogs, animated: true) screenView.crashReportingSwitcher.switcherView.setOn(state.isCrashReporting, animated: true) + screenView.showUsernamesSwitcher.switcherView.setOn(state.isShowingUsernames, animated: true) }.store(in: &cancellables) } diff --git a/Sources/SettingsFeature/ViewModels/SettingsAdvancedViewModel.swift b/Sources/SettingsFeature/ViewModels/SettingsAdvancedViewModel.swift index ed304656ddc9bf88a262bb01beb8c9aeb29194c9..8717024458fee4281461d03a5c05649cca3ce576 100644 --- a/Sources/SettingsFeature/ViewModels/SettingsAdvancedViewModel.swift +++ b/Sources/SettingsFeature/ViewModels/SettingsAdvancedViewModel.swift @@ -8,12 +8,15 @@ import DependencyInjection struct AdvancedViewState: Equatable { var isRecordingLogs = false var isCrashReporting = false + var isShowingUsernames = false } final class SettingsAdvancedViewModel { @KeyObject(.recordingLogs, defaultValue: true) var isRecordingLogs: Bool @KeyObject(.crashReporting, defaultValue: true) var isCrashReporting: Bool + private let isShowingUsernamesKey = "isShowingUsernames" + @Dependency private var logger: XXLogger @Dependency private var crashReporter: CrashReporter @@ -26,6 +29,29 @@ final class SettingsAdvancedViewModel { func loadCachedSettings() { stateRelay.value.isRecordingLogs = isRecordingLogs stateRelay.value.isCrashReporting = isCrashReporting + + guard let defaults = UserDefaults(suiteName: "group.elixxir.messenger") else { + print("^^^ Couldn't access user defaults in the app group container \(#file):\(#line)") + return + } + + guard let isShowingUsernames = defaults.value(forKey: isShowingUsernamesKey) as? Bool else { + defaults.set(false, forKey: isShowingUsernamesKey) + return + } + + stateRelay.value.isShowingUsernames = isShowingUsernames + } + + func didToggleShowUsernames() { + stateRelay.value.isShowingUsernames.toggle() + + guard let defaults = UserDefaults(suiteName: "group.elixxir.messenger") else { + print("^^^ Couldn't access user defaults in the app group container \(#file):\(#line)") + return + } + + defaults.set(stateRelay.value.isShowingUsernames, forKey: isShowingUsernamesKey) } func didToggleRecordLogs() { diff --git a/Sources/SettingsFeature/ViewModels/SettingsViewModel.swift b/Sources/SettingsFeature/ViewModels/SettingsViewModel.swift index 9eb30e134418cbaad8df3a6bd2e32562e594277d..97c146a6aa7645ef12298e8bcc2dae2edf2072e2 100644 --- a/Sources/SettingsFeature/ViewModels/SettingsViewModel.swift +++ b/Sources/SettingsFeature/ViewModels/SettingsViewModel.swift @@ -5,7 +5,7 @@ import Combine import Defaults import Permissions import Integration -import PushNotifications +import PushFeature import UserNotifications import CombineSchedulers import DependencyInjection @@ -113,7 +113,7 @@ final class SettingsViewModel { hudRelay.send(.on(nil)) if enable == true { - pushHandler.didRequestAuthorization { [weak self] result in + pushHandler.requestAuthorization { [weak self] result in guard let self = self else { return } switch result { diff --git a/Sources/SettingsFeature/Views/SettingsAdvancedView.swift b/Sources/SettingsFeature/Views/SettingsAdvancedView.swift index e7f96ee81c740660545ef29d402c419046a8fbb6..fb3df7c771e7b5a205b5ec610ca7780bce045f88 100644 --- a/Sources/SettingsFeature/Views/SettingsAdvancedView.swift +++ b/Sources/SettingsFeature/Views/SettingsAdvancedView.swift @@ -6,6 +6,7 @@ final class SettingsAdvancedView: UIView { let downloadLogsButton = UIButton() let logRecordingSwitcher = SettingsSwitcher() let crashReportingSwitcher = SettingsSwitcher() + let showUsernamesSwitcher = SettingsSwitcher() init() { super.init(frame: .zero) @@ -13,6 +14,12 @@ final class SettingsAdvancedView: UIView { backgroundColor = Asset.neutralWhite.color downloadLogsButton.setImage(Asset.settingsDownload.image, for: .normal) + showUsernamesSwitcher.set( + title: Localized.Settings.Advanced.ShowUsername.title, + text: Localized.Settings.Advanced.ShowUsername.description, + icon: Asset.settingsHide.image + ) + logRecordingSwitcher.set( title: Localized.Settings.Advanced.Logs.title, text: Localized.Settings.Advanced.Logs.description, @@ -29,9 +36,11 @@ final class SettingsAdvancedView: UIView { stackView.axis = .vertical stackView.addArrangedSubview(logRecordingSwitcher) stackView.addArrangedSubview(crashReportingSwitcher) + stackView.addArrangedSubview(showUsernamesSwitcher) stackView.setCustomSpacing(20, after: logRecordingSwitcher) stackView.setCustomSpacing(10, after: crashReportingSwitcher) + stackView.setCustomSpacing(10, after: showUsernamesSwitcher) addSubview(stackView) diff --git a/Sources/Shared/AutoGenerated/Assets.swift b/Sources/Shared/AutoGenerated/Assets.swift index 76164e8f59a5329d780a81ac348062fce2cebf66..2b2248fb83ad430ec95d9378a5d5762e8d74c046 100644 --- a/Sources/Shared/AutoGenerated/Assets.swift +++ b/Sources/Shared/AutoGenerated/Assets.swift @@ -45,8 +45,10 @@ public enum Asset { public static let chatListMenuDelete = ImageAsset(name: "chat_list_menu_delete") public static let chatListMenuPin = ImageAsset(name: "chat_list_menu_pin") public static let chatListNew = ImageAsset(name: "chat_list_new") + public static let chatListNewGroup = ImageAsset(name: "chat_list_new_group") public static let chatListPinSwipe = ImageAsset(name: "chat_list_pin_swipe") public static let chatListPlaceholder = ImageAsset(name: "chat_list_placeholder") + public static let chatListUd = ImageAsset(name: "chat_list_ud") public static let code = ImageAsset(name: "code") public static let contactAddPlaceholder = ImageAsset(name: "contact_add_placeholder") public static let contactDetailsPadlock = ImageAsset(name: "contact_details_padlock") @@ -99,10 +101,14 @@ public enum Asset { public static let restoreDropbox = ImageAsset(name: "restore_dropbox") public static let restoreIcloud = ImageAsset(name: "restore_icloud") public static let restoreSuccess = ImageAsset(name: "restore_success") + public static let scanAdd = ImageAsset(name: "scan_add") + public static let scanCopy = ImageAsset(name: "scan_copy") + public static let scanDropdown = ImageAsset(name: "scan_dropdown") public static let scanEmail = ImageAsset(name: "scan_email") public static let scanError = ImageAsset(name: "scan_error") public static let scanPhone = ImageAsset(name: "scan_phone") public static let scanQr = ImageAsset(name: "scan_qr") + public static let scanScan = ImageAsset(name: "scan_scan") public static let searchEmail = ImageAsset(name: "search_email") public static let searchLens = ImageAsset(name: "search_lens") public static let searchPhone = ImageAsset(name: "search_phone") @@ -182,6 +188,17 @@ public final class ColorAsset { return color }() + #if os(iOS) || os(tvOS) + @available(iOS 11.0, tvOS 11.0, *) + public func color(compatibleWith traitCollection: UITraitCollection) -> Color { + let bundle = BundleToken.bundle + guard let color = Color(named: name, in: bundle, compatibleWith: traitCollection) else { + fatalError("Unable to load color asset named \(name).") + } + return color + } + #endif + fileprivate init(name: String) { self.name = name } @@ -210,6 +227,7 @@ public struct ImageAsset { public typealias Image = UIImage #endif + @available(iOS 8.0, tvOS 9.0, watchOS 2.0, macOS 10.7, *) public var image: Image { let bundle = BundleToken.bundle #if os(iOS) || os(tvOS) @@ -225,9 +243,21 @@ public struct ImageAsset { } return result } + + #if os(iOS) || os(tvOS) + @available(iOS 8.0, tvOS 9.0, *) + public func image(compatibleWith traitCollection: UITraitCollection) -> Image { + let bundle = BundleToken.bundle + guard let result = Image(named: name, in: bundle, compatibleWith: traitCollection) else { + fatalError("Unable to load image asset named \(name).") + } + return result + } + #endif } public extension ImageAsset.Image { + @available(iOS 8.0, tvOS 9.0, watchOS 2.0, *) @available(macOS, deprecated, message: "This initializer is unsafe on macOS, please use the ImageAsset.image property") convenience init?(asset: ImageAsset) { diff --git a/Sources/Shared/AutoGenerated/Fonts.swift b/Sources/Shared/AutoGenerated/Fonts.swift index 9a556412b01200e3714ce73d06b3961f358a28d2..30c2dd3faf140aca2eaed5c7b30ac31eaf876dc5 100644 --- a/Sources/Shared/AutoGenerated/Fonts.swift +++ b/Sources/Shared/AutoGenerated/Fonts.swift @@ -1,7 +1,7 @@ // swiftlint:disable all // Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen -#if os(OSX) +#if os(macOS) import AppKit.NSFont #elseif os(iOS) || os(tvOS) || os(watchOS) import UIKit.UIFont @@ -51,7 +51,7 @@ public struct FontConvertible { public let family: String public let path: String - #if os(OSX) + #if os(macOS) public typealias Font = NSFont #elseif os(iOS) || os(tvOS) || os(watchOS) public typealias Font = UIFont @@ -82,7 +82,7 @@ public extension FontConvertible.Font { if !UIFont.fontNames(forFamilyName: font.family).contains(font.name) { font.register() } - #elseif os(OSX) + #elseif os(macOS) if let url = font.url, CTFontManagerGetScopeForURL(url as CFURL) == .none { font.register() } diff --git a/Sources/Shared/AutoGenerated/Strings.swift b/Sources/Shared/AutoGenerated/Strings.swift index 722a7d52b37b383c03d53eb9befc5bd20bb0a0c7..755693f39ac7ba661a99e93b8d64360590ed299b 100644 --- a/Sources/Shared/AutoGenerated/Strings.swift +++ b/Sources/Shared/AutoGenerated/Strings.swift @@ -172,6 +172,61 @@ public enum Localized { } } + public enum AccountRestore { + /// Account restore + public static let header = Localized.tr("Localizable", "accountRestore.header") + public enum Found { + /// Cancel + public static let cancel = Localized.tr("Localizable", "accountRestore.found.cancel") + /// BACKUP DATE + public static let date = Localized.tr("Localizable", "accountRestore.found.date") + /// Next + public static let next = Localized.tr("Localizable", "accountRestore.found.next") + /// Restore account + public static let restore = Localized.tr("Localizable", "accountRestore.found.restore") + /// FILE SIZE + public static let size = Localized.tr("Localizable", "accountRestore.found.size") + /// Restore your contacts from the following backup. + public static let subtitle = Localized.tr("Localizable", "accountRestore.found.subtitle") + /// Backup found + public static let title = Localized.tr("Localizable", "accountRestore.found.title") + } + public enum List { + /// Cancel + public static let cancel = Localized.tr("Localizable", "accountRestore.list.cancel") + /// Restore your account from a previous backup. You’ll be able to have access to all your contacts. + public static let firstSubtitle = Localized.tr("Localizable", "accountRestore.list.firstSubtitle") + /// Select the cloud storage service you previously used to create a backup. + public static let secondSubtitle = Localized.tr("Localizable", "accountRestore.list.secondSubtitle") + /// Restore your #account#. + public static let title = Localized.tr("Localizable", "accountRestore.list.title") + } + public enum NotFound { + /// Go back + public static let back = Localized.tr("Localizable", "accountRestore.notFound.back") + /// No account backup was found in %@ + public static func subtitle(_ p1: Any) -> String { + return Localized.tr("Localizable", "accountRestore.notFound.subtitle", String(describing: p1)) + } + /// Backup not found + public static let title = Localized.tr("Localizable", "accountRestore.notFound.title") + } + public enum Success { + /// You now have access to all your contacts. + public static let subtitle = Localized.tr("Localizable", "accountRestore.success.subtitle") + /// Your #account# has been successfully #restored#. + public static let title = Localized.tr("Localizable", "accountRestore.success.title") + } + public enum Warning { + /// I understand + public static let action = Localized.tr("Localizable", "accountRestore.warning.action") + /// xx messenger account can only run on a single device at a time. Using the same account on multiple devices may permanently damage your account and make it impossible to converse with your contacts + public static let subtitle = Localized.tr("Localizable", "accountRestore.warning.subtitle") + /// Warning + public static let title = Localized.tr("Localizable", "accountRestore.warning.title") + } + } + public enum Backup { /// Dropbox public static let dropbox = Localized.tr("Localizable", "backup.dropbox") @@ -267,13 +322,15 @@ public enum Localized { public static let title = Localized.tr("Localizable", "chat.clear.title") } public enum E2e { - /// You and %@ now have a #quantum-secure#, completely private channel for messaging.\n#Say hello#! + /// You and %@ now have a #quantum-secure#, completely private channel for messaging. + /// #Say hello#! public static func placeholder(_ p1: Any) -> String { return Localized.tr("Localizable", "chat.e2e.placeholder", String(describing: p1)) } } public enum Menu { - /// Delete\nAll + /// Delete + /// All public static let deleteAll = Localized.tr("Localizable", "chat.menu.deleteAll") } public enum RetrySheet { @@ -284,6 +341,12 @@ public enum Localized { /// Try again public static let retry = Localized.tr("Localizable", "chat.retrySheet.retry") } + public enum RoundDrawer { + /// OK + public static let action = Localized.tr("Localizable", "chat.roundDrawer.action") + /// The mix for this message will be available shortly, please check again later. + public static let title = Localized.tr("Localizable", "chat.roundDrawer.title") + } public enum SheetMenu { /// Clear chat public static let clear = Localized.tr("Localizable", "chat.sheetMenu.clear") @@ -322,7 +385,9 @@ public enum Localized { public enum DeleteAll { /// Delete All Chats public static let delete = Localized.tr("Localizable", "chatList.deleteAll.delete") - /// All chats will be deleted from this phone. However, your contacts and their copies of your chats will remain unchanged. Encrypted copies may remain on the decentralized network for up to three weeks.\n\nThis will only delete chats locally—they can remain on the network (only decryptable by you) for up to three weeks, and they will also remain on the recipient(s) device(s). + /// All chats will be deleted from this phone. However, your contacts and their copies of your chats will remain unchanged. Encrypted copies may remain on the decentralized network for up to three weeks. + /// + /// This will only delete chats locally—they can remain on the network (only decryptable by you) for up to three weeks, and they will also remain on the recipient(s) device(s). public static let subtitle = Localized.tr("Localizable", "chatList.deleteAll.subtitle") /// Delete All Chats? public static let title = Localized.tr("Localizable", "chatList.deleteAll.title") @@ -555,6 +620,25 @@ public enum Localized { } } + public enum Launch { + public enum Version { + /// Failed checking app version + public static let failed = Localized.tr("Localizable", "launch.version.failed") + public enum Recommended { + /// Not now + public static let negative = Localized.tr("Localizable", "launch.version.recommended.negative") + /// Update + public static let positive = Localized.tr("Localizable", "launch.version.recommended.positive") + /// There is a new version available that enhance the current performance and usability. + public static let title = Localized.tr("Localizable", "launch.version.recommended.title") + } + public enum Required { + /// Okay + public static let positive = Localized.tr("Localizable", "launch.version.required.positive") + } + } + } + public enum Menu { /// Build %@ public static func build(_ p1: Any) -> String { @@ -712,7 +796,9 @@ public enum Localized { public static let skip = Localized.tr("Localizable", "onboarding.welcome.skip") /// Would you like to register an email or phone number to help other users find your account? If not, you can still be found by your username, or completely off the grid using QR codes. public static let subtitle = Localized.tr("Localizable", "onboarding.welcome.subtitle") - /// %@,\nwelcome to\n#xx network# + /// %@, + /// welcome to + /// #xx network# public static func title(_ p1: Any) -> String { return Localized.tr("Localizable", "onboarding.welcome.title", String(describing: p1)) } @@ -733,7 +819,8 @@ public enum Localized { public static func resend(_ p1: Any) -> String { return Localized.tr("Localizable", "profile.code.resend", String(describing: p1)) } - /// Enter the code we just sent to\n%@ + /// Enter the code we just sent to + /// %@ public static func subtitle(_ p1: Any) -> String { return Localized.tr("Localizable", "profile.code.subtitle", String(describing: p1)) } @@ -899,77 +986,33 @@ public enum Localized { } } - public enum Restore { - /// Account restore - public static let header = Localized.tr("Localizable", "restore.header") - public enum Found { - /// Cancel - public static let cancel = Localized.tr("Localizable", "restore.found.cancel") - /// BACKUP DATE - public static let date = Localized.tr("Localizable", "restore.found.date") - /// Next - public static let next = Localized.tr("Localizable", "restore.found.next") - /// Restore account - public static let restore = Localized.tr("Localizable", "restore.found.restore") - /// FILE SIZE - public static let size = Localized.tr("Localizable", "restore.found.size") - /// Restore your contacts from the following backup. - public static let subtitle = Localized.tr("Localizable", "restore.found.subtitle") - /// Backup found - public static let title = Localized.tr("Localizable", "restore.found.title") - } - public enum List { - /// Cancel - public static let cancel = Localized.tr("Localizable", "restore.list.cancel") - /// Restore your account from a previous backup. You’ll be able to have access to all your contacts. - public static let firstSubtitle = Localized.tr("Localizable", "restore.list.firstSubtitle") - /// Select the cloud storage service you previously used to create a backup. - public static let secondSubtitle = Localized.tr("Localizable", "restore.list.secondSubtitle") - /// Restore your #account#. - public static let title = Localized.tr("Localizable", "restore.list.title") - } - public enum NotFound { - /// Go back - public static let back = Localized.tr("Localizable", "restore.notFound.back") - /// No account backup was found in %@ - public static func subtitle(_ p1: Any) -> String { - return Localized.tr("Localizable", "restore.notFound.subtitle", String(describing: p1)) - } - /// Backup not found - public static let title = Localized.tr("Localizable", "restore.notFound.title") - } - public enum Success { - /// You now have access to all your contacts. - public static let subtitle = Localized.tr("Localizable", "restore.success.subtitle") - /// Your #account# has been successfully #restored#. - public static let title = Localized.tr("Localizable", "restore.success.title") - } - public enum Warning { - /// I understand - public static let action = Localized.tr("Localizable", "restore.warning.action") - /// xx messenger account can only run on a single device at a time. Using the same account on multiple devices may permanently damage your account and make it impossible to converse with your contacts - public static let subtitle = Localized.tr("Localizable", "restore.warning.subtitle") - /// Warning - public static let title = Localized.tr("Localizable", "restore.warning.title") - } - } - public enum Scan { /// Go to contact public static let contact = Localized.tr("Localizable", "scan.contact") /// Check requests public static let requests = Localized.tr("Localizable", "scan.requests") - /// Sending as\n#%@# + /// Sending as + /// #%@# public static func sendingAs(_ p1: Any) -> String { return Localized.tr("Localizable", "scan.sendingAs", String(describing: p1)) } /// Go to settings public static let settings = Localized.tr("Localizable", "scan.settings") public enum Display { + /// Copied! + public static let copied = Localized.tr("Localizable", "scan.display.copied") + /// Tap code to copy + public static let copy = Localized.tr("Localizable", "scan.display.copy") public enum Share { + /// Add + public static let add = Localized.tr("Localizable", "scan.display.share.add") /// EMAIL ADDRESS public static let email = Localized.tr("Localizable", "scan.display.share.email") - /// PHONE + /// ・・・・・・・・・・ + public static let hidden = Localized.tr("Localizable", "scan.display.share.hidden") + /// Not added + public static let notAdded = Localized.tr("Localizable", "scan.display.share.notAdded") + /// PHONE NUMBER public static let phone = Localized.tr("Localizable", "scan.display.share.phone") /// Select what you'd like to share public static let title = Localized.tr("Localizable", "scan.display.share.title") @@ -978,7 +1021,8 @@ public enum Localized { public enum Error { /// Camera needs permission to be used public static let denied = Localized.tr("Localizable", "scan.error.denied") - /// You've already added \n#%@# + /// You've already added + /// #%@# public static func friends(_ p1: Any) -> String { return Localized.tr("Localizable", "scan.error.friends", String(describing: p1)) } @@ -1045,6 +1089,12 @@ public enum Localized { /// Record logs public static let title = Localized.tr("Localizable", "settings.advanced.logs.title") } + public enum ShowUsername { + /// Allow us to show a more detailed push notification + public static let description = Localized.tr("Localizable", "settings.advanced.showUsername.description") + /// Rich notifications + public static let title = Localized.tr("Localizable", "settings.advanced.showUsername.title") + } } public enum Biometrics { /// Enable unlocking with your device biometrics. @@ -1059,7 +1109,9 @@ public enum Localized { public static let delete = Localized.tr("Localizable", "settings.delete.delete") /// Your username public static let input = Localized.tr("Localizable", "settings.delete.input") - /// A deleted account cannot be recovered. The username associated with this account cannot be reused in the future.\n\nTo confirm your account deletion, type in your username. + /// A deleted account cannot be recovered. The username associated with this account cannot be reused in the future. + /// + /// To confirm your account deletion, type in your username. public static let subtitle = Localized.tr("Localizable", "settings.delete.subtitle") /// Delete Account public static let title = Localized.tr("Localizable", "settings.delete.title") @@ -1159,6 +1211,10 @@ public enum Localized { /// Search public static let placeholder = Localized.tr("Localizable", "shared.search.placeholder") } + public enum SnackBar { + /// Connecting to xx network... + public static let title = Localized.tr("Localizable", "shared.snackBar.title") + } } public enum Validator { diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsChatList/chat_list_new_group.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsChatList/chat_list_new_group.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..5c2f805eb3317d819659b5d73f14b6a1b249804f --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsChatList/chat_list_new_group.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsChatList/chat_list_new_group.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsChatList/chat_list_new_group.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..795087fa93911997334099cced454c4b1e35cebf --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsChatList/chat_list_new_group.imageset/Icon.pdf @@ -0,0 +1,193 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 3.384644 cm +0.141667 0.141667 0.141667 scn +7.384813 11.999994 m +7.384813 14.548988 9.451180 16.615356 12.000175 16.615356 c +14.549169 16.615356 16.615538 14.548988 16.615538 11.999994 c +16.615538 9.450999 14.549169 7.384631 12.000175 7.384631 c +9.451180 7.384631 7.384813 9.450999 7.384813 11.999994 c +h +12.000175 14.769212 m +10.470778 14.769212 9.230957 13.529390 9.230957 11.999994 c +9.230957 10.470597 10.470778 9.230776 12.000175 9.230776 c +13.529572 9.230776 14.769392 10.470597 14.769392 11.999994 c +14.769392 13.529390 13.529572 14.769212 12.000175 14.769212 c +h +18.461683 11.999939 m +18.753178 11.999939 19.040531 11.930912 19.300220 11.798508 c +19.559912 11.666103 19.784555 11.474085 19.955769 11.238171 c +20.126982 11.002256 20.239897 10.729151 20.285271 10.441208 c +20.330643 10.153265 20.307186 9.858670 20.216820 9.581535 c +20.126451 9.304401 19.971743 9.052606 19.765354 8.846758 c +19.558966 8.640909 19.306765 8.486859 19.029394 8.397219 c +18.752024 8.307577 18.457369 8.284892 18.169546 8.331019 c +17.881723 8.377148 17.608883 8.490733 17.373419 8.662563 c +16.285152 7.171278 l +16.286116 7.170577 l +16.756842 6.827298 17.302124 6.600278 17.877394 6.508082 c +17.940798 6.497921 18.004368 6.489429 18.068037 6.482602 c +18.582455 6.427444 19.103485 6.480942 19.597141 6.640484 c +20.151897 6.819772 20.656313 7.127878 21.069103 7.539588 c +21.481892 7.951297 21.791319 8.454904 21.972061 9.009190 c +22.152802 9.563475 22.199718 10.152681 22.108969 10.728584 c +22.018219 11.304486 21.792381 11.850714 21.449944 12.322557 c +21.145224 12.742432 20.755880 13.092785 20.307827 13.351466 c +20.252371 13.383484 20.196014 13.414097 20.138809 13.443264 c +19.619413 13.708080 19.044691 13.846084 18.461683 13.846084 c +18.461683 11.999939 l +h +22.152224 0.000051 m +22.152224 0.484699 22.056761 0.964602 21.871296 1.412360 c +21.685827 1.860116 21.413988 2.266958 21.071287 2.609656 c +20.728590 2.952355 20.321747 3.224197 19.873991 3.409665 c +19.426233 3.595132 18.946331 3.690590 18.461683 3.690590 c +18.461683 5.538486 l +19.091291 5.538486 19.715565 5.431134 20.307827 5.221738 c +20.399738 5.189242 20.490879 5.154289 20.581150 5.116899 c +21.253103 4.838566 21.863657 4.430608 22.377949 3.916316 c +22.892241 3.402025 23.300196 2.791472 23.578529 2.119518 c +23.615919 2.029247 23.650875 1.938107 23.683371 1.846196 c +23.892767 1.253934 24.000118 0.629662 24.000118 0.000051 c +22.152224 0.000051 l +h +16.615538 0.000051 m +18.461683 0.000051 l +18.461683 3.568644 15.568768 6.461558 12.000175 6.461558 c +8.431583 6.461558 5.538668 3.568644 5.538668 0.000051 c +7.384813 0.000051 l +7.384813 2.549046 9.451180 4.615414 12.000175 4.615414 c +14.549170 4.615414 16.615538 2.549046 16.615538 0.000051 c +h +5.538435 12.000669 m +5.246940 12.000669 4.959587 11.931643 4.699897 11.799238 c +4.440207 11.666834 4.215563 11.474816 4.044349 11.238902 c +3.873136 11.002987 3.760221 10.729881 3.714847 10.441939 c +3.669474 10.153996 3.692931 9.859402 3.783298 9.582268 c +3.873666 9.305134 4.028375 9.053337 4.234764 8.847488 c +4.441152 8.641641 4.693353 8.487591 4.970723 8.397950 c +5.248093 8.308309 5.542748 8.285624 5.830571 8.331751 c +6.118393 8.377879 6.391234 8.491465 6.626700 8.663296 c +7.714964 7.172009 l +7.714002 7.171309 l +7.243277 6.828030 6.697994 6.601009 6.122724 6.508814 c +6.059320 6.498652 5.995750 6.490161 5.932080 6.483334 c +5.417663 6.428175 4.896632 6.481673 4.402976 6.641215 c +3.848219 6.820502 3.343803 7.128610 2.931013 7.540319 c +2.518225 7.952028 2.208798 8.455635 2.028056 9.009921 c +1.847316 9.564205 1.800400 10.153413 1.891150 10.729315 c +1.981899 11.305218 2.207736 11.851444 2.550173 12.323289 c +2.854893 12.743163 3.244237 13.093515 3.692290 13.352198 c +3.747746 13.384215 3.804103 13.414828 3.861309 13.443995 c +4.380703 13.708812 4.955427 13.846815 5.538435 13.846815 c +5.538435 12.000669 l +h +2.128822 1.413092 m +1.943356 0.965334 1.847895 0.485430 1.847895 0.000782 c +0.000000 0.000782 l +0.000000 0.630393 0.107352 1.254665 0.316748 1.846928 c +0.349244 1.938839 0.384197 2.029980 0.421588 2.120250 c +0.699921 2.792204 1.107878 3.402757 1.622169 3.917048 c +2.136461 4.431339 2.747014 4.839297 3.418968 5.117630 c +3.509238 5.155020 3.600379 5.189974 3.692290 5.222469 c +4.284553 5.431867 4.908826 5.539218 5.538435 5.539218 c +5.538435 3.691321 l +5.053787 3.691321 4.573884 3.595862 4.126127 3.410397 c +3.678370 3.224929 3.271528 2.953086 2.928830 2.610388 c +2.586131 2.267690 2.314289 1.860847 2.128822 1.413092 c +h +f* +n +Q +q +1.000000 0.000000 -0.000000 1.000000 13.000000 14.000000 cm +1.000000 1.000000 1.000000 scn +10.000000 8.000000 m +10.000000 0.000000 l +3.500000 0.000000 l +3.500000 2.000000 l +0.000000 2.000000 l +0.000000 8.000000 l +10.000000 8.000000 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 15.000000 15.000000 cm +0.141667 0.141667 0.141667 scn +5.000000 0.000000 m +3.000000 0.000000 l +3.000000 3.000000 l +0.000000 3.000000 l +0.000000 5.000000 l +3.000000 5.000000 l +3.000000 8.000000 l +5.000000 8.000000 l +5.000000 5.000000 l +8.000000 5.000000 l +8.000000 3.000000 l +5.000000 3.000000 l +5.000000 0.000000 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 5120 +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 +0000005210 00000 n +0000005233 00000 n +0000005406 00000 n +0000005480 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +5539 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsChatList/chat_list_ud.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsChatList/chat_list_ud.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..5c2f805eb3317d819659b5d73f14b6a1b249804f --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsChatList/chat_list_ud.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/AssetsChatList/chat_list_ud.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsChatList/chat_list_ud.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..840f4655381abe01930b66d33c19691714be2e08 --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsChatList/chat_list_ud.imageset/Icon.pdf @@ -0,0 +1,199 @@ +%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 1.000000 cm +0.141667 0.141667 0.141667 scn +13.000000 3.000000 m +1.000000 3.000000 l +1.000000 1.000000 l +13.000000 1.000000 l +13.000000 3.000000 l +h +1.000000 3.000000 m +1.000000 19.000000 l +-1.000000 19.000000 l +-1.000000 3.000000 l +1.000000 3.000000 l +h +1.000000 19.000000 m +8.000000 19.000000 l +8.000000 21.000000 l +1.000000 21.000000 l +1.000000 19.000000 l +h +13.000000 12.500000 m +13.000000 3.000000 l +15.000000 3.000000 l +15.000000 12.500000 l +13.000000 12.500000 l +h +1.000000 3.000000 m +1.000000 3.000000 l +-1.000000 3.000000 l +-1.000000 1.895430 -0.104569 1.000000 1.000000 1.000000 c +1.000000 3.000000 l +h +13.000000 1.000000 m +14.104569 1.000000 15.000000 1.895430 15.000000 3.000000 c +13.000000 3.000000 l +13.000000 3.000000 l +13.000000 1.000000 l +h +1.000000 19.000000 m +1.000000 19.000000 l +1.000000 21.000000 l +-0.104570 21.000000 -1.000000 20.104568 -1.000000 19.000000 c +1.000000 19.000000 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 15.000000 15.000000 cm +0.141667 0.141667 0.141667 scn +5.000000 0.000000 m +3.000000 0.000000 l +3.000000 3.000000 l +0.000000 3.000000 l +0.000000 5.000000 l +3.000000 5.000000 l +3.000000 8.000000 l +5.000000 8.000000 l +5.000000 5.000000 l +8.000000 5.000000 l +8.000000 3.000000 l +5.000000 3.000000 l +5.000000 0.000000 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 9.000000 6.000000 cm +0.141667 0.141667 0.141667 scn +1.000000 1.000000 m +1.000000 2.104569 1.895430 3.000000 3.000000 3.000000 c +3.000000 5.000000 l +0.790861 5.000000 -1.000000 3.209139 -1.000000 1.000000 c +1.000000 1.000000 l +h +3.000000 3.000000 m +4.104569 3.000000 5.000000 2.104569 5.000000 1.000000 c +7.000000 1.000000 l +7.000000 3.209139 5.209139 5.000000 3.000000 5.000000 c +3.000000 3.000000 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 10.000000 10.000000 cm +0.141667 0.141667 0.141667 scn +3.000000 4.000000 m +3.000000 3.447715 2.552285 3.000000 2.000000 3.000000 c +2.000000 1.000000 l +3.656854 1.000000 5.000000 2.343146 5.000000 4.000000 c +3.000000 4.000000 l +h +2.000000 3.000000 m +1.447715 3.000000 1.000000 3.447715 1.000000 4.000000 c +-1.000000 4.000000 l +-1.000000 2.343146 0.343146 1.000000 2.000000 1.000000 c +2.000000 3.000000 l +h +1.000000 4.000000 m +1.000000 4.552285 1.447715 5.000000 2.000000 5.000000 c +2.000000 7.000000 l +0.343146 7.000000 -1.000000 5.656854 -1.000000 4.000000 c +1.000000 4.000000 l +h +2.000000 5.000000 m +2.552285 5.000000 3.000000 4.552285 3.000000 4.000000 c +5.000000 4.000000 l +5.000000 5.656854 3.656854 7.000000 2.000000 7.000000 c +2.000000 5.000000 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 3.000000 12.000000 cm +0.141667 0.141667 0.141667 scn +0.000000 2.000000 m +4.000000 2.000000 l +4.000000 4.000000 l +0.000000 4.000000 l +0.000000 2.000000 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 3.000000 6.000000 cm +0.141667 0.141667 0.141667 scn +0.000000 2.000000 m +4.000000 2.000000 l +4.000000 4.000000 l +0.000000 4.000000 l +0.000000 2.000000 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 2993 +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 +0000003083 00000 n +0000003106 00000 n +0000003279 00000 n +0000003353 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +3412 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_add.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_add.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..5c2f805eb3317d819659b5d73f14b6a1b249804f --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_add.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/AssetsScan/scan_add.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_add.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..16216a0b7b4bc311ea8abf1e543af2752bded38a --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_add.imageset/Icon.pdf @@ -0,0 +1,79 @@ +%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.166504 4.166992 cm +0.000000 0.827451 0.929412 scn +6.666667 4.999836 m +6.666667 -0.000164 l +5.000000 -0.000164 l +5.000000 4.999836 l +0.000000 4.999836 l +0.000000 6.666503 l +5.000000 6.666503 l +5.000000 11.666504 l +6.666667 11.666504 l +6.666667 6.666503 l +11.666668 6.666503 l +11.666668 4.999836 l +6.666667 4.999836 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 393 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 20.000000 20.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Pages 5 0 R + /Type /Catalog + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000000483 00000 n +0000000505 00000 n +0000000678 00000 n +0000000752 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +811 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_copy.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_copy.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..5c2f805eb3317d819659b5d73f14b6a1b249804f --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_copy.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/AssetsScan/scan_copy.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_copy.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..847db5913192df42797261645c76b454a29bc4a7 --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_copy.imageset/Icon.pdf @@ -0,0 +1,188 @@ +%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.750000 0.250000 cm +0.693500 0.711750 0.730000 scn +6.700000 3.000000 m +1.000000 3.000000 l +1.000000 1.000000 l +6.700000 1.000000 l +6.700000 3.000000 l +h +1.000000 3.000000 m +1.000000 11.500000 l +-1.000000 11.500000 l +-1.000000 3.000000 l +1.000000 3.000000 l +h +1.000000 11.500000 m +3.000000 11.500000 l +3.000000 13.500000 l +1.000000 13.500000 l +1.000000 11.500000 l +h +6.700000 5.000000 m +6.700000 3.000000 l +8.700000 3.000000 l +8.700000 5.000000 l +6.700000 5.000000 l +h +1.000000 3.000000 m +1.000000 3.000000 l +-1.000000 3.000000 l +-1.000000 1.895431 -0.104569 1.000000 1.000000 1.000000 c +1.000000 3.000000 l +h +6.700000 1.000000 m +7.804569 1.000000 8.700000 1.895431 8.700000 3.000000 c +6.700000 3.000000 l +6.700000 3.000000 l +6.700000 1.000000 l +h +1.000000 11.500000 m +1.000000 11.500000 l +1.000000 13.500000 l +-0.104569 13.500000 -1.000000 12.604569 -1.000000 11.500000 c +1.000000 11.500000 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 6.750000 3.250000 cm +0.693500 0.711750 0.730000 scn +5.046446 12.353554 m +4.339340 11.646446 l +5.046446 12.353554 l +h +6.700000 3.000000 m +1.000000 3.000000 l +1.000000 1.000000 l +6.700000 1.000000 l +6.700000 3.000000 l +h +1.000000 3.000000 m +1.000000 11.500000 l +-1.000000 11.500000 l +-1.000000 3.000000 l +1.000000 3.000000 l +h +1.000000 11.500000 m +4.692893 11.500000 l +4.692893 13.500000 l +1.000000 13.500000 l +1.000000 11.500000 l +h +6.700000 9.492893 m +6.700000 3.000001 l +8.700000 3.000001 l +8.700000 9.492893 l +6.700000 9.492893 l +h +4.339340 11.646446 m +6.846447 9.139339 l +8.260660 10.553554 l +5.753553 13.060660 l +4.339340 11.646446 l +h +8.700000 9.492893 m +8.700000 9.890718 8.541965 10.272249 8.260660 10.553554 c +6.846447 9.139339 l +6.752678 9.233109 6.700000 9.360285 6.700000 9.492893 c +8.700000 9.492893 l +h +4.692893 11.500000 m +4.560285 11.500000 4.433108 11.552678 4.339340 11.646446 c +5.753553 13.060660 l +5.472249 13.341965 5.090717 13.500000 4.692893 13.500000 c +4.692893 11.500000 l +h +1.000000 3.000000 m +1.000000 3.000000 l +-1.000000 3.000000 l +-1.000000 1.895431 -0.104569 1.000000 1.000000 1.000000 c +1.000000 3.000000 l +h +6.700000 1.000000 m +7.804570 1.000000 8.700000 1.895432 8.700000 3.000001 c +6.700000 3.000001 l +6.700000 3.000000 l +6.700000 1.000000 l +h +1.000000 11.500000 m +1.000000 11.500000 l +1.000000 13.500000 l +-0.104569 13.500000 -1.000000 12.604569 -1.000000 11.500000 c +1.000000 11.500000 l +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 11.299805 12.600098 cm +0.693500 0.711750 0.730000 scn +0.000000 -0.000097 m +0.000000 3.149902 l +3.150000 -0.000097 l +0.000000 -0.000097 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 2624 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 18.000000 18.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 +0000002714 00000 n +0000002737 00000 n +0000002910 00000 n +0000002984 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +3043 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_dropdown.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_dropdown.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..5c2f805eb3317d819659b5d73f14b6a1b249804f --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_dropdown.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/AssetsScan/scan_dropdown.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_dropdown.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8a8f535835295912913921bff26af9f4e484088c --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_dropdown.imageset/Icon.pdf @@ -0,0 +1,73 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 5.990234 8.287109 cm +0.141667 0.141667 0.141667 scn +6.010000 -0.000195 m +12.020000 6.009805 l +10.607000 7.424805 l +6.010000 2.824805 l +1.414000 7.424805 l +0.000000 6.010805 l +6.010000 -0.000195 l +h +f +n +Q + +endstream +endobj + +3 0 obj + 271 +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 +0000000361 00000 n +0000000383 00000 n +0000000556 00000 n +0000000630 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +689 +%%EOF \ No newline at end of file diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_qr.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_qr.imageset/Contents.json index 5c2f805eb3317d819659b5d73f14b6a1b249804f..d9a97df899595ebc48148baa9cbedb9be8f763fa 100644 --- a/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_qr.imageset/Contents.json +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_qr.imageset/Contents.json @@ -10,6 +10,7 @@ "version" : 1 }, "properties" : { - "preserves-vector-representation" : true + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" } } diff --git a/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_scan.imageset/Contents.json b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_scan.imageset/Contents.json new file mode 100644 index 0000000000000000000000000000000000000000..d9a97df899595ebc48148baa9cbedb9be8f763fa --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_scan.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/AssetsScan/scan_scan.imageset/Icon.pdf b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_scan.imageset/Icon.pdf new file mode 100644 index 0000000000000000000000000000000000000000..2a16f205557366e051c9f577878b86ef642d60a6 --- /dev/null +++ b/Sources/Shared/Resources/Assets.xcassets/AssetsScan/scan_scan.imageset/Icon.pdf @@ -0,0 +1,111 @@ +%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 3.750000 cm +1.000000 1.000000 1.000000 scn +0.875000 13.750000 m +0.875000 15.127285 1.997715 16.250000 3.375000 16.250000 c +5.625000 16.250000 l +5.625000 14.250000 l +3.375000 14.250000 l +3.102285 14.250000 2.875000 14.022715 2.875000 13.750000 c +2.875000 11.500000 l +0.875000 11.500000 l +0.875000 13.750000 l +h +14.625000 14.250000 m +12.375000 14.250000 l +12.375000 16.250000 l +14.625000 16.250000 l +16.002285 16.250000 17.125000 15.127285 17.125000 13.750000 c +17.125000 11.500000 l +15.125000 11.500000 l +15.125000 13.750000 l +15.125000 14.022715 14.897716 14.250000 14.625000 14.250000 c +h +2.875000 2.500000 m +2.875000 4.750000 l +0.875000 4.750000 l +0.875000 2.500000 l +0.875000 1.122715 1.997715 0.000000 3.375000 0.000000 c +5.625000 0.000000 l +5.625000 2.000000 l +3.375000 2.000000 l +3.102285 2.000000 2.875000 2.227284 2.875000 2.500000 c +h +17.125000 4.750000 m +17.125000 2.500000 l +17.125000 1.122715 16.002285 0.000000 14.625000 0.000000 c +12.375000 0.000000 l +12.375000 2.000000 l +14.625000 2.000000 l +14.897716 2.000000 15.125000 2.227284 15.125000 2.500000 c +15.125000 4.750000 l +17.125000 4.750000 l +h +0.000000 7.125000 m +18.000000 7.125000 l +18.000000 9.125000 l +0.000000 9.125000 l +0.000000 7.125000 l +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1298 +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 +0000001388 00000 n +0000001411 00000 n +0000001584 00000 n +0000001658 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1717 +%%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 58fb6862cc40f861409a72abe30398aa6614c75e..774e9759242a913fc93e1d9b025ced9ca2b847b9 100644 --- a/Sources/Shared/Resources/en.lproj/Localizable.strings +++ b/Sources/Shared/Resources/en.lproj/Localizable.strings @@ -89,6 +89,11 @@ // ChatFeature +"chat.roundDrawer.title" += "The mix for this message will be available shortly, please check again later."; +"chat.roundDrawer.action" += "OK"; + "chat.cancel" = "Cancel"; "chat.bubbleMenu.copy" @@ -167,13 +172,23 @@ = "Place QR code inside frame to scan"; "scan.status.success" = "Success"; +"scan.display.copied" += "Copied!"; "scan.display.share.title" = "Select what you'd like to share"; "scan.display.share.email" = "EMAIL ADDRESS"; "scan.display.share.phone" -= "PHONE"; += "PHONE NUMBER"; +"scan.display.share.add" += "Add"; +"scan.display.share.notAdded" += "Not added"; +"scan.display.share.hidden" += "・・・・・・・・・・"; +"scan.display.copy" += "Tap code to copy"; "scan.sendingAs" = "Sending as\n#%@#"; "scan.segmentedControl.left" @@ -377,6 +392,82 @@ "requests.drawer.group.success.later" = "Later"; +"requests.drawer.single.title" += "REQUEST FROM"; +"requests.drawer.single.email" += "EMAIL ADDRESS"; +"requests.drawer.single.phone" += "PHONE NUMBER"; +"requests.drawer.single.nickname" += "Edit your new contact’s nickname."; +"requests.drawer.single.accept" += "Accept and Save"; +"requests.drawer.single.hide" += "Hide Request"; + +"requests.drawer.group.title" += "GROUP CHAT REQUEST"; +"requests.drawer.group.accept" += "Accept"; +"requests.drawer.group.hide" += "Hide Request"; + +"requests.drawer.single.success.title" += "NEW CONNECTION"; +"requests.drawer.single.success.subtitle" += "Is now a connection, would you like to send a message?"; +"requests.drawer.single.success.send" += "Send a Message"; +"requests.drawer.single.success.later" += "Later"; + +"requests.drawer.group.success.title" += "ACCEPTED"; +"requests.drawer.group.success.subtitle" += "You are now part of the group chat. Would you like to check it out?"; +"requests.drawer.group.success.send" += "Go to Chat"; +"requests.drawer.group.success.later" += "Later"; + +"requests.drawer.single.title" += "REQUEST FROM"; +"requests.drawer.single.email" += "EMAIL ADDRESS"; +"requests.drawer.single.phone" += "PHONE NUMBER"; +"requests.drawer.single.nickname" += "Edit your new contact’s nickname."; +"requests.drawer.single.accept" += "Accept and Save"; +"requests.drawer.single.hide" += "Hide Request"; + +"requests.drawer.group.title" += "GROUP CHAT REQUEST"; +"requests.drawer.group.accept" += "Accept"; +"requests.drawer.group.hide" += "Hide Request"; + +"requests.drawer.single.success.title" += "NEW CONNECTION"; +"requests.drawer.single.success.subtitle" += "Is now a connection, would you like to send a message?"; +"requests.drawer.single.success.send" += "Send a Message"; +"requests.drawer.single.success.later" += "Later"; + +"requests.drawer.group.success.title" += "ACCEPTED"; +"requests.drawer.group.success.subtitle" += "You are now part of the group chat. Would you like to check it out?"; +"requests.drawer.group.success.send" += "Go to Chat"; +"requests.drawer.group.success.later" += "Later"; + // ProfileFeature "profile.email.title" @@ -481,6 +572,11 @@ "settings.delete" = "Delete account"; +"settings.advanced.showUsername.title" += "Rich notifications"; +"settings.advanced.showUsername.description" += "Allow us to show a more detailed push notification"; + "settings.drawer.title" = "Do you want to open %@?"; "settings.drawer.subtitle" @@ -717,47 +813,47 @@ "onboarding.welcome.info.subtitle" = "Registration is completely optional. When registering an email or phone number, they will be evaluated by twilio, a 3rd party partner. Afterwards, salted hashes will be registered in #User Discovery# to allow other uses to search for you using the registered data completely privately."; -"restore.warning.title" +"accountRestore.warning.title" = "Warning"; -"restore.warning.subtitle" +"accountRestore.warning.subtitle" = "xx messenger account can only run on a single device at a time. Using the same account on multiple devices may permanently damage your account and make it impossible to converse with your contacts"; -"restore.warning.action" +"accountRestore.warning.action" = "I understand"; -"restore.list.title" +"accountRestore.list.title" = "Restore your #account#."; -"restore.list.firstSubtitle" +"accountRestore.list.firstSubtitle" = "Restore your account from a previous backup. You’ll be able to have access to all your contacts."; -"restore.list.secondSubtitle" +"accountRestore.list.secondSubtitle" = "Select the cloud storage service you previously used to create a backup."; -"restore.list.cancel" +"accountRestore.list.cancel" = "Cancel"; -"restore.header" +"accountRestore.header" = "Account restore"; -"restore.found.title" +"accountRestore.found.title" = "Backup found"; -"restore.found.subtitle" +"accountRestore.found.subtitle" = "Restore your contacts from the following backup."; -"restore.found.date" +"accountRestore.found.date" = "BACKUP DATE"; -"restore.found.size" +"accountRestore.found.size" = "FILE SIZE"; -"restore.found.restore" +"accountRestore.found.restore" = "Restore account"; -"restore.found.cancel" +"accountRestore.found.cancel" = "Cancel"; -"restore.found.next" +"accountRestore.found.next" = "Next"; -"restore.notFound.title" +"accountRestore.notFound.title" = "Backup not found"; -"restore.notFound.subtitle" +"accountRestore.notFound.subtitle" = "No account backup was found in %@"; -"restore.notFound.back" +"accountRestore.notFound.back" = "Go back"; -"restore.success.title" +"accountRestore.success.title" = "Your #account# has been successfully #restored#."; -"restore.success.subtitle" +"accountRestore.success.subtitle" = "You now have access to all your contacts."; // Shared @@ -772,6 +868,8 @@ = "Resend"; "shared.done" = "Done"; +"shared.snackBar.title" += "Connecting to xx network..."; // HUD @@ -861,6 +959,19 @@ "contactSearch.nicknameDrawer.save" = "Save"; +// LaunchFeature + +"launch.version.failed" += "Failed checking app version"; +"launch.version.required.positive" += "Okay"; +"launch.version.recommended.title" += "There is a new version available that enhance the current performance and usability."; +"launch.version.recommended.positive" += "Update"; +"launch.version.recommended.negative" += "Not now"; + // Countries "countries.title" diff --git a/Sources/Shared/Views/AvatarView.swift b/Sources/Shared/Views/AvatarView.swift index a8fcfc1f32e5345697136110a71d842028da7a40..93a4789f6b22df1f60d4470c05df1db69e2d9cd6 100644 --- a/Sources/Shared/Views/AvatarView.swift +++ b/Sources/Shared/Views/AvatarView.swift @@ -17,12 +17,13 @@ public final class AvatarView: UIView { layer.masksToBounds = true backgroundColor = Asset.brandPrimary.color + iconImageView.contentMode = .center imageView.contentMode = .scaleAspectFill monogramLabel.textColor = Asset.neutralWhite.color - addSubview(imageView) - addSubview(iconImageView) addSubview(monogramLabel) + addSubview(iconImageView) + addSubview(imageView) imageView.snp.makeConstraints { $0.edges.equalToSuperview() diff --git a/Sources/Shared/Views/SearchComponent.swift b/Sources/Shared/Views/SearchComponent.swift index f3760e87ca8aa06364ee44f1cbf237260cf406b8..2e87cfbf6dceddcb50009a78b41fd2c0aa598ea5 100644 --- a/Sources/Shared/Views/SearchComponent.swift +++ b/Sources/Shared/Views/SearchComponent.swift @@ -21,10 +21,14 @@ public final class SearchComponent: UIView { } } - private var isEditing = false + public var isEditingPublisher: AnyPublisher<Bool, Never> { + isEditingSubject.eraseToAnyPublisher() + } + private var cancellables = Set<AnyCancellable>() private var rightSubject = PassthroughSubject<Void, Never>() private var textSubject = PassthroughSubject<String, Never>() + private var isEditingSubject = CurrentValueSubject<Bool, Never>(false) public init() { super.init(frame: .zero) @@ -74,7 +78,7 @@ public final class SearchComponent: UIView { inputField.text = nil textSubject.send("") inputField.endEditing(true) - isEditing = false + isEditingSubject.send(false) } private func setup() { @@ -110,7 +114,7 @@ public final class SearchComponent: UIView { rightButton.publisher(for: .touchUpInside) .sink { [weak rightSubject, self] in - if isEditing { + if isEditingSubject.value == true { abortEditing() } else { rightSubject?.send() @@ -127,31 +131,31 @@ public final class SearchComponent: UIView { } private func setupConstraints() { - containerView.snp.makeConstraints { make in - make.top.equalToSuperview() - make.left.equalToSuperview() - make.right.equalToSuperview() - make.bottom.equalToSuperview() - make.height.equalTo(50) + containerView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.equalToSuperview() + $0.height.equalTo(50) } - leftImageView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(10) - make.left.equalToSuperview().offset(13) - make.bottom.equalToSuperview().offset(-10) - make.height.equalTo(leftImageView.snp.width) + leftImageView.snp.makeConstraints { + $0.top.equalToSuperview().offset(10) + $0.left.equalToSuperview().offset(13) + $0.bottom.equalToSuperview().offset(-10) + $0.height.equalTo(leftImageView.snp.width) } - inputField.snp.makeConstraints { make in - make.top.bottom.equalToSuperview() - make.left.equalTo(leftImageView.snp.right).offset(20) - make.right.equalTo(rightButton.snp.left).offset(-32) + inputField.snp.makeConstraints { + $0.top.bottom.equalToSuperview() + $0.left.equalTo(leftImageView.snp.right).offset(20) + $0.right.equalTo(rightButton.snp.left).offset(-32) } - rightButton.snp.makeConstraints { make in - make.top.equalToSuperview() - make.right.equalToSuperview().offset(-13) - make.bottom.equalToSuperview() + rightButton.snp.makeConstraints { + $0.top.equalToSuperview() + $0.right.equalToSuperview().offset(-13) + $0.bottom.equalToSuperview() } } @@ -162,12 +166,12 @@ public final class SearchComponent: UIView { public func textFieldDidBeginEditing(_ textField: UITextField) { rightButton.setImage(Asset.sharedCross.image, for: .normal) - isEditing = true + isEditingSubject.send(true) } public func textFieldDidEndEditing(_ textField: UITextField) { rightButton.setImage(rightImage, for: .normal) - isEditing = false + isEditingSubject.send(false) } } diff --git a/Sources/Shared/Views/SnackBar.swift b/Sources/Shared/Views/SnackBar.swift index f3e169cd6ae70dc9bce9fa7f0392fdefe3bfe6d6..9240ccf9537d12cf8377bcfb4e0d222442325f4c 100644 --- a/Sources/Shared/Views/SnackBar.swift +++ b/Sources/Shared/Views/SnackBar.swift @@ -1,42 +1,35 @@ import UIKit public final class SnackBar: UIView { - // MARK: UI - - let title = UILabel() - let icon = UIImageView() - let stack = UIStackView() - - // MARK: Lifecycle + private let titleLabel = UILabel() + private let imageView = UIImageView() + private let stackView = UIStackView() public init() { super.init(frame: .zero) - setup() - } - required init?(coder: NSCoder) { nil } + //alpha = 0.0 + backgroundColor = Asset.brandPrimary.color - // MARK: Private + imageView.contentMode = .center + titleLabel.text = Localized.Shared.SnackBar.title + titleLabel.font = Fonts.Mulish.semiBold.font(size: 13.0) + titleLabel.textColor = Asset.neutralWhite.color + imageView.image = Asset.sharedWhiteExclamation.image - private func setup() { - backgroundColor = Asset.brandPrimary.color + stackView.spacing = 14 + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(titleLabel) + + addSubview(stackView) - icon.contentMode = .center - title.text = "Connecting to xx network..." - title.font = Fonts.Mulish.semiBold.font(size: 13.0) - title.textColor = Asset.neutralWhite.color - icon.image = Asset.sharedWhiteExclamation.image - - stack.spacing = 14 - stack.addArrangedSubview(icon) - stack.addArrangedSubview(title) - - addSubview(stack) - stack.snp.makeConstraints { make in - make.top.equalToSuperview().offset(16) - make.left.equalToSuperview().offset(20) - make.right.equalToSuperview().offset(-20) - make.bottom.equalToSuperview().offset(-16) + stackView.snp.makeConstraints { + $0.top.equalToSuperview().offset(16) + $0.left.equalToSuperview().offset(20) + $0.right.equalToSuperview().offset(-20) + $0.bottom.equalToSuperview().offset(-16) } } + + required init?(coder: NSCoder) { nil } }