diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 8cf1f215c..64f20df16 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72343332ACEFFC3009AA24E /* QRScanner.swift */; }; D72343362AD037AF009AA24E /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72343352AD037AF009AA24E /* ToastView.swift */; }; D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */; }; + D732A9092AFD235500DB42BA /* ShareSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D732A9082AFD235500DB42BA /* ShareSheetController.swift */; }; D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D748BF2B2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift */; }; D748BF2E2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D748BF2D2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift */; }; D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74C9CF72ACACECE0021626A /* WelcomePage1Fragment.swift */; }; @@ -46,6 +47,11 @@ D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */; }; D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */; }; D7C365082AEFAB7F00FE6142 /* ContactListBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */; }; + D7C3650A2AF001C300FE6142 /* EditContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C365092AF001C300FE6142 /* EditContactFragment.swift */; }; + D7C3650C2AF0084000FE6142 /* EditContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C3650B2AF0084000FE6142 /* EditContactViewModel.swift */; }; + D7C3650E2AF15BF200FE6142 /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */; }; + D7C48DF42AFA66F900D938CB /* EditContactController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C48DF32AFA66F900D938CB /* EditContactController.swift */; }; + D7C48DF62AFCDF4700D938CB /* ContactInnerActionsFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C48DF52AFCDF4700D938CB /* ContactInnerActionsFragment.swift */; }; D7D1698C2AE66FA500109A5C /* MagicSearchSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */; }; D7D24D132AC1B4E800C6F35B /* NotoSans-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D7D24D0D2AC1B4E800C6F35B /* NotoSans-Medium.ttf */; }; D7D24D142AC1B4E800C6F35B /* NotoSans-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D7D24D0E2AC1B4E800C6F35B /* NotoSans-Regular.ttf */; }; @@ -88,6 +94,7 @@ D72343332ACEFFC3009AA24E /* QRScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRScanner.swift; sourceTree = ""; }; D72343352AD037AF009AA24E /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryContactFragment.swift; sourceTree = ""; }; + D732A9082AFD235500DB42BA /* ShareSheetController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheetController.swift; sourceTree = ""; }; D748BF2B2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartySipAccountLoginFragment.swift; sourceTree = ""; }; D748BF2D2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartySipAccountWarningFragment.swift; sourceTree = ""; }; D74C9CF72ACACECE0021626A /* WelcomePage1Fragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePage1Fragment.swift; sourceTree = ""; }; @@ -107,6 +114,11 @@ D7A2EDDA2AC19EEC005D90FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactInnerFragment.swift; sourceTree = ""; }; D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListBottomSheet.swift; sourceTree = ""; }; + D7C365092AF001C300FE6142 /* EditContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditContactFragment.swift; sourceTree = ""; }; + D7C3650B2AF0084000FE6142 /* EditContactViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditContactViewModel.swift; sourceTree = ""; }; + D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = ""; }; + D7C48DF32AFA66F900D938CB /* EditContactController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditContactController.swift; sourceTree = ""; }; + D7C48DF52AFCDF4700D938CB /* ContactInnerActionsFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactInnerActionsFragment.swift; sourceTree = ""; }; D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MagicSearchSingleton.swift; sourceTree = ""; }; D7D24D0D2AC1B4E800C6F35B /* NotoSans-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-Medium.ttf"; sourceTree = ""; }; D7D24D0E2AC1B4E800C6F35B /* NotoSans-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-Regular.ttf"; sourceTree = ""; }; @@ -150,6 +162,9 @@ D717071F2AC5989C0037746F /* TextExtension.swift */, D74C9D002ACB098C0021626A /* PermissionManager.swift */, D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */, + D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, + D7C48DF32AFA66F900D938CB /* EditContactController.swift */, + D732A9082AFD235500DB42BA /* ShareSheetController.swift */, ); path = Utils; sourceTree = ""; @@ -315,6 +330,8 @@ D7E6D0542AEBFCCE00A57AAF /* ContactsInnerFragment.swift */, D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */, D7C365072AEFAB7F00FE6142 /* ContactListBottomSheet.swift */, + D7C365092AF001C300FE6142 /* EditContactFragment.swift */, + D7C48DF52AFCDF4700D938CB /* ContactInnerActionsFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -325,6 +342,7 @@ D78290BA2ADD40B2004AA85C /* ContactViewModel.swift */, D71FCA7E2AE1397200D2E43E /* ContactsListViewModel.swift */, D7E6D04A2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift */, + D7C3650B2AF0084000FE6142 /* EditContactViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -484,21 +502,25 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D7C3650E2AF15BF200FE6142 /* PhotoPicker.swift in Sources */, D71707202AC5989C0037746F /* TextExtension.swift in Sources */, D719ABB92ABC67BF00B41C10 /* ContentView.swift in Sources */, D71FCA832AE14D6E00D2E43E /* ContactFragment.swift in Sources */, + D7C3650C2AF0084000FE6142 /* EditContactViewModel.swift in Sources */, D750D3392AD3E6EE00EC99C5 /* PopupLoadingView.swift in Sources */, D7E6D0492AE933AD00A57AAF /* FavoriteContactsListFragment.swift in Sources */, D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */, D777DBB32AE12C5900565A99 /* ContactsManager.swift in Sources */, + D7C3650A2AF001C300FE6142 /* EditContactFragment.swift in Sources */, D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, D78290BB2ADD40B2004AA85C /* ContactViewModel.swift in Sources */, D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */, D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */, D7E6D04D2AEBD77600A57AAF /* CustomBottomSheet.swift in Sources */, + D7C48DF42AFA66F900D938CB /* EditContactController.swift in Sources */, D74C9D012ACB098C0021626A /* PermissionManager.swift in Sources */, D7702EF22AC7205000557C00 /* WelcomeView.swift in Sources */, D71FCA7F2AE1397200D2E43E /* ContactsListViewModel.swift in Sources */, @@ -513,8 +535,10 @@ D748BF2C2ACD82D2004844EB /* ThirdPartySipAccountLoginFragment.swift in Sources */, D74C9CF82ACACECE0021626A /* WelcomePage1Fragment.swift in Sources */, D7E6D0552AEBFCCE00A57AAF /* ContactsInnerFragment.swift in Sources */, + D732A9092AFD235500DB42BA /* ShareSheetController.swift in Sources */, D72343362AD037AF009AA24E /* ToastView.swift in Sources */, D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */, + D7C48DF62AFCDF4700D938CB /* ContactInnerActionsFragment.swift in Sources */, D72343322ACEFF58009AA24E /* QRScannerController.swift in Sources */, D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */, D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, diff --git a/Linphone/Assets.xcassets/profil-picture-default.imageset/profil-picture-default.svg b/Linphone/Assets.xcassets/profil-picture-default.imageset/profil-picture-default.svg index b2bea3b29..557a5087a 100644 --- a/Linphone/Assets.xcassets/profil-picture-default.imageset/profil-picture-default.svg +++ b/Linphone/Assets.xcassets/profil-picture-default.imageset/profil-picture-default.svg @@ -1,10 +1,10 @@ - + - - + + - + diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 770c4a72a..fa9843527 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -20,62 +20,72 @@ import linphonesw import Contacts import SwiftUI +import ContactsUI -final class ContactsManager: ObservableObject { - - static let shared = ContactsManager() +final class ContactsManager { - private var coreContext = CoreContext.shared - private var magicSearch = MagicSearchSingleton.shared - - private let nativeAddressBookFriendList = "Native address-book" - let linphoneAddressBookFirendList = "Linphone address-book" + static let shared = ContactsManager() - @Published var friendList: FriendList? - - private init() { - fetchContacts() - } - - func fetchContacts() { + private var coreContext = CoreContext.shared + private var magicSearch = MagicSearchSingleton.shared + + private let nativeAddressBookFriendList = "Native address-book" + let linphoneAddressBookFriendList = "Linphone address-book" + + var friendList: FriendList? + var linphoneFriendList: FriendList? + + private init() { + fetchContacts() + } + + func fetchContacts() { coreContext.doOnCoreQueue { core in if core.globalState == GlobalState.Shutdown || core.globalState == GlobalState.Off { - print("$TAG Core is being stopped or already destroyed, abort") + print("\(#function) - Core is being stopped or already destroyed, abort") } else { - print("$TAG ${friends.size} friends created") - - self.friendList = core.getFriendListByName(name: self.nativeAddressBookFriendList) - if self.friendList == nil { - do { - self.friendList = try core.createFriendList() - } catch let error { - print("Failed to enumerate contact", error) - } - } - - if self.friendList!.displayName == nil || self.friendList!.displayName!.isEmpty { - print( - "$TAG Friend list [$nativeAddressBookFriendList] didn't exist yet, let's create it" - ) - - self.friendList!.databaseStorageEnabled = false // We don't want to store local address-book in DB - - self.friendList!.displayName = self.nativeAddressBookFriendList - core.addFriendList(list: self.friendList!) - } else { - print( - "$TAG Friend list [$LINPHONE_ADDRESS_BOOK_FRIEND_LIST] found, removing existing friends if any" - ) - self.friendList!.friends.forEach { friend in - _ = self.friendList!.removeFriend(linphoneFriend: friend) - } - } - } + + do { + self.friendList = try core.getFriendListByName(name: self.nativeAddressBookFriendList) ?? core.createFriendList() + } catch let error { + print("\(#function) - Failed to enumerate contacts: \(error)") + } + + if let friendList = self.friendList { + if friendList.displayName == nil || friendList.displayName!.isEmpty { + print("\(#function) - Friend list '\(self.nativeAddressBookFriendList)' didn't exist yet, let's create it") + friendList.databaseStorageEnabled = false // We don't want to store local address-book in DB + friendList.displayName = self.nativeAddressBookFriendList + core.addFriendList(list: friendList) + } else { + print("\(#function) - Friend list '\(friendList.displayName!) found, removing existing friends if any") + friendList.friends.forEach { friend in + _ = friendList.removeFriend(linphoneFriend: friend) + } + } + } + + do { + self.linphoneFriendList = try core.getFriendListByName(name: self.linphoneAddressBookFriendList) ?? core.createFriendList() + } catch let error { + print("\(#function) - Failed to enumerate contacts: \(error)") + } + + if let linphoneFriendList = self.linphoneFriendList { + if linphoneFriendList.displayName == nil || linphoneFriendList.displayName!.isEmpty { + print("\(#function) - Friend list \(self.linphoneAddressBookFriendList) didn't exist yet, let's create it") + linphoneFriendList.databaseStorageEnabled = true + linphoneFriendList.displayName = self.linphoneAddressBookFriendList + core.addFriendList(list: linphoneFriendList) + } + } + } let store = CNContactStore() + store.requestAccess(for: .contacts) { (granted, error) in if let error = error { - print("failed to request access", error) + print("\(#function) - failed to request access", error) return } if granted { @@ -83,47 +93,48 @@ final class ContactsManager: ObservableObject { CNContactFamilyNameKey, CNContactGivenNameKey, CNContactNicknameKey, CNContactPostalAddressesKey, CNContactIdentifierKey, CNInstantMessageAddressUsernameKey, CNContactInstantMessageAddressesKey, - CNContactImageDataKey, CNContactThumbnailImageDataKey, CNContactOrganizationNameKey] + CNContactOrganizationNameKey, CNContactImageDataAvailableKey, CNContactImageDataKey, CNContactThumbnailImageDataKey] let request = CNContactFetchRequest(keysToFetch: keys as [CNKeyDescriptor]) do { try store.enumerateContacts(with: request, usingBlock: { (contact, _) in - DispatchQueue.main.sync { let newContact = Contact( - firstName: contact.givenName, - lastName: contact.familyName, - organizationName: contact.organizationName, - displayName: contact.nickname, - sipAddresses: contact.instantMessageAddresses.map { $0.value.service == "SIP" ? $0.value.username : "" }, - phoneNumbers: contact.phoneNumbers.map { PhoneNumber(numLabel: $0.label ?? "", num: $0.value.stringValue)}, - imageData: "" - ) + identifier: contact.identifier, + firstName: contact.givenName, + lastName: contact.familyName, + organizationName: contact.organizationName, + jobTitle: "", + displayName: contact.nickname, + sipAddresses: contact.instantMessageAddresses.map { $0.value.service == "SIP" ? $0.value.username : "" }, + phoneNumbers: contact.phoneNumbers.map { PhoneNumber(numLabel: $0.label ?? "", num: $0.value.stringValue)}, + imageData: "" + ) + let imageThumbnail = UIImage(data: contact.thumbnailImageData ?? Data()) self.saveImage( - image: - UIImage(data: contact.thumbnailImageData ?? Data()) + image: imageThumbnail ?? self.textToImage( firstName: contact.givenName.isEmpty - && contact.familyName.isEmpty - && contact.phoneNumbers.first?.value.stringValue != nil - ? contact.phoneNumbers.first!.value.stringValue - : contact.givenName, lastName: contact.familyName), - name: contact.givenName + contact.familyName + String(Int.random(in: 1...1000)), - contact: newContact) + && contact.familyName.isEmpty + && contact.phoneNumbers.first?.value.stringValue != nil + ? contact.phoneNumbers.first!.value.stringValue + : contact.givenName, lastName: contact.familyName), + name: contact.givenName + contact.familyName + String(Int.random(in: 1...1000)) + ((imageThumbnail == nil) ? "-default" : ""), + contact: newContact, linphoneFriend: false, existingFriend: nil) } }) } catch let error { - print("Failed to enumerate contact", error) + print("\(#function) - Failed to enumerate contact", error) } } else { - print("access denied") + print("\(#function) - access denied") } } - self.magicSearch.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - } - } + self.magicSearch.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } + } func textToImage(firstName: String, lastName: String) -> UIImage { let lblNameInitialize = UILabel() @@ -152,93 +163,138 @@ final class ContactsManager: ObservableObject { return IBImgViewUserProfile } - - func saveImage(image: UIImage, name: String, contact: Contact) { - guard let data = image.jpegData(compressionQuality: 1) ?? image.pngData() else { - return - } + + func saveImage(image: UIImage, name: String, contact: Contact, linphoneFriend: Bool, existingFriend: Friend?) { + guard let data = image.jpegData(compressionQuality: 1) ?? image.pngData() else { + return + } awaitDataWrite(data: data, name: name) { _, result in - - self.coreContext.doOnCoreQueue() { core in - do { - var friend = try core.createFriend() - - friend.edit() - try friend.setName(newValue: contact.firstName + " " + contact.lastName) - friend.organization = contact.organizationName - - var friendAddresses: [Address] = [] - contact.sipAddresses.forEach { sipAddress in - let address = core.interpretUrl(url: sipAddress, applyInternationalPrefix: true) - - if address != nil && ((friendAddresses.firstIndex(where: {$0.asString() == address?.asString()})) == nil) { - friend.addAddress(address: address!) - friendAddresses.append(address!) - } + self.saveFriend(result: result, contact: contact, existingFriend: existingFriend) { resultFriend in + if resultFriend != nil { + if linphoneFriend && existingFriend == nil { + _ = self.linphoneFriendList?.addLocalFriend(linphoneFriend: resultFriend!) + self.linphoneFriendList?.updateSubscriptions() + } else if existingFriend == nil { + _ = self.friendList?.addLocalFriend(linphoneFriend: resultFriend!) + self.friendList?.updateSubscriptions() } - - var friendPhoneNumbers: [PhoneNumber] = [] - contact.phoneNumbers.forEach { phone in - do { - if (friendPhoneNumbers.firstIndex(where: {$0.numLabel == phone.numLabel})) == nil { - let labelDrop = String(phone.numLabel.dropFirst(4).dropLast(4)) - let phoneNumber = try Factory.Instance.createFriendPhoneNumber(phoneNumber: phone.num, label: labelDrop) - friend.addPhoneNumberWithLabel(phoneNumber: phoneNumber) - friendPhoneNumbers.append(phone) - } - } catch let error { - print("Failed to enumerate contact", error) - } - } - - let contactImage = result.dropFirst(8) - friend.photo = "file:/" + contactImage - - friend.organization = contact.organizationName - - friend.done() - - DispatchQueue.main.async { - _ = self.friendList!.addLocalFriend(linphoneFriend: friend) - - self.friendList!.updateSubscriptions() - } - } catch let error { - print("Failed to enumerate contact", error) } - } } } - func awaitDataWrite(data: Data, name: String, completion: @escaping ((), String) -> Void) { - let directory = FileManager.default.temporaryDirectory - - DispatchQueue.main.async { - do { - let decodedData: () = try data.write(to: directory.appendingPathComponent(name + ".png")) - completion(decodedData, directory.appendingPathComponent(name + ".png").absoluteString) // <--- here, return the results - } catch { - print("Error: ", error) // need to deal with errors - completion((), "") // <--- here, should return the error + func saveFriend(result: String, contact: Contact, existingFriend: Friend?, completion: @escaping (Friend?) -> Void) { + self.coreContext.doOnCoreQueue { core in + do { + let friend = try existingFriend ?? core.createFriend() + + friend.edit() + friend.nativeUri = contact.identifier + try friend.setName(newValue: contact.firstName + " " + contact.lastName) + + let friendvCard = friend.vcard + + if friendvCard != nil { + friendvCard!.givenName = contact.firstName + friendvCard!.familyName = contact.lastName } + + friend.organization = contact.organizationName + + var friendAddresses: [Address] = [] + friend.addresses.forEach({ address in + friend.removeAddress(address: address) + }) + contact.sipAddresses.forEach { sipAddress in + let address = core.interpretUrl(url: sipAddress, applyInternationalPrefix: true) + + if address != nil && ((friendAddresses.firstIndex(where: {$0.asString() == address?.asString()})) == nil) { + friend.addAddress(address: address!) + friendAddresses.append(address!) + } + } + + var friendPhoneNumbers: [PhoneNumber] = [] + friend.phoneNumbersWithLabel.forEach({ phoneNumber in + friend.removePhoneNumberWithLabel(phoneNumber: phoneNumber) + }) + contact.phoneNumbers.forEach { phone in + do { + if (friendPhoneNumbers.firstIndex(where: {$0.num == phone.num})) == nil { + let labelDrop = String(phone.numLabel.dropFirst(4).dropLast(4)) + let phoneNumber = try Factory.Instance.createFriendPhoneNumber(phoneNumber: phone.num, label: labelDrop) + friend.addPhoneNumberWithLabel(phoneNumber: phoneNumber) + friendPhoneNumbers.append(phone) + } + } catch let error { + print("\(#function) - Failed to create friend phone number for \(phone.numLabel):", error) + } + } + + friend.photo = "file:/" + result + friend.organization = contact.organizationName + friend.jobTitle = contact.jobTitle + + friend.done() + completion(friend) + } catch let error { + print("Failed to enumerate contact", error) + completion(nil) + } + } + } + + func getImagePath(friendPhotoPath: String) -> URL { + let friendPath = String(friendPhotoPath.dropFirst(6)) + + let imagePath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(friendPath) + + return imagePath + } + + func awaitDataWrite(data: Data, name: String, completion: @escaping ((), String) -> Void) { + let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + + if directory != nil { + DispatchQueue.main.async { + do { + let urlName = URL(string: name) + let imagePath = urlName != nil ? urlName!.absoluteString.replacingOccurrences(of: "%", with: "") : String(Int.random(in: 1...1000)) + let decodedData: () = try data.write(to: directory!.appendingPathComponent(imagePath + ".png")) + completion(decodedData, imagePath + ".png") + } catch { + print("Error: ", error) + completion((), "") + } + } + } + } + + func getFriend(contact: Contact) -> Friend? { + if friendList != nil { + let friend = friendList!.friends.first(where: {$0.nativeUri == contact.identifier}) + return friend + } else { + return nil } } } struct PhoneNumber { - var numLabel: String - var num: String + var numLabel: String + var num: String } struct Contact: Identifiable { - var id = UUID() - var firstName: String - var lastName: String - var organizationName: String - var displayName: String - var sipAddresses: [String] = [] - var phoneNumbers: [PhoneNumber] = [] - var imageData: String + var id = UUID() + var identifier: String + var firstName: String + var lastName: String + var organizationName: String + var jobTitle: String + var displayName: String + var sipAddresses: [String] = [] + var phoneNumbers: [PhoneNumber] = [] + var imageData: String } diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index a68b25127..350fb6a0d 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -36,7 +36,7 @@ final class CoreContext: ObservableObject { private init() {} - func doOnCoreQueue(synchronous : Bool = false, lambda: @escaping (Core) -> Void) { + func doOnCoreQueue(synchronous: Bool = false, lambda: @escaping (Core) -> Void) { if synchronous { coreQueue.sync { lambda(self.mCore) @@ -77,7 +77,9 @@ final class CoreContext: ObservableObject { } } - self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnMainQueue { (cbVal: (core: Core, account: Account, state: RegistrationState, message: String)) in + self.mCore.publisher?.onAccountRegistrationStateChanged?.postOnMainQueue {(cbVal: + (core: Core, account: Account, state: RegistrationState, message: String) + ) in // If account has been configured correctly, we will go through Progress and Ok states // Otherwise, we will be Failed. NSLog("New registration state is \(cbVal.state) for user id " + diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 17257a99f..85f14b81c 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -36,7 +36,7 @@ struct LinphoneApp: App { AssistantView(sharedMainViewModel: sharedMainViewModel) .toast(isShowing: $coreContext.toastMessage) } else if coreContext.defaultAccount != nil { - ContentView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) + ContentView(contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), historyViewModel: HistoryViewModel()) .toast(isShowing: $coreContext.toastMessage) } } else { diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 79915ed4f..0062a8ce1 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -3,6 +3,9 @@ "strings" : { "" : { + }, + " " : { + }, " et " : { @@ -33,6 +36,9 @@ }, "**Contacts** : Pour vous afficher vos contacts et retrouver qui utilise Linphone." : { + }, + "**Job :** %@" : { + }, "**Micro** : Pour permettre à vos correspondants de vous entendre." : { @@ -95,12 +101,18 @@ }, "Accept all" : { + }, + "Add a picture" : { + }, "Add to favourites" : { }, "All contacts" : { + }, + "All modifications will be canceled." : { + }, "Appel" : { @@ -121,9 +133,6 @@ } } } - }, - "Block" : { - }, "Block the address" : { @@ -145,6 +154,9 @@ }, "Close" : { + }, + "Company" : { + }, "Conditions de service" : { @@ -190,9 +202,21 @@ }, "Domain" : { + }, + "Don’t save modifications?" : { + }, "Edit" : { + }, + "Edit contact" : { + + }, + "Edit Contact" : { + + }, + "Edit picture" : { + }, "En continuant, vous acceptez ces conditions, " : { @@ -208,6 +232,12 @@ }, "Favourites" : { + }, + "First Name" : { + + }, + "First name*" : { + }, "History Contact fragment" : { @@ -235,6 +265,12 @@ }, "Invitation" : { + }, + "Job title" : { + + }, + "Last name" : { + }, "Linphone" : { @@ -248,10 +284,10 @@ "Message" : { }, - "Mute" : { + "My Profile" : { }, - "My Profile" : { + "New contact" : { }, "Next" : { @@ -297,9 +333,15 @@ }, "Personnalize your profil mode" : { + }, + "Phone :" : { + }, "Phone (%@) :" : { + }, + "Phone number" : { + }, "Plus tard" : { @@ -316,7 +358,10 @@ "Register" : { }, - "Remove to favourites" : { + "Remove from favourites" : { + + }, + "Remove picture" : { }, "Scan QR code" : { @@ -333,6 +378,9 @@ }, "Share" : { + }, + "SIP address" : { + }, "SIP address :" : { diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index d85b4233e..7462e16dd 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -52,7 +52,14 @@ class AccountLoginViewModel: ObservableObject { // userID is set to null as it's the same as the username in our case // ha1 is set to null as we are using the clear text password. Upon first register, the hash will be computed automatically. // The realm will be determined automatically from the first register, as well as the algorithm - let authInfo = try Factory.Instance.createAuthInfo(username: self.username, userid: "", passwd: self.passwd, ha1: "", realm: "", domain: self.domain) + let authInfo = try Factory.Instance.createAuthInfo( + username: self.username, + userid: "", + passwd: self.passwd, + ha1: "", + realm: "", + domain: self.domain + ) // Account object replaces deprecated ProxyConfig object // Account object is configured through an AccountParams object that we can obtain from the Core diff --git a/Linphone/UI/Main/Contacts/ContactsView.swift b/Linphone/UI/Main/Contacts/ContactsView.swift index 4d00f8bdb..a8191f047 100644 --- a/Linphone/UI/Main/Contacts/ContactsView.swift +++ b/Linphone/UI/Main/Contacts/ContactsView.swift @@ -23,17 +23,22 @@ struct ContactsView: View { @ObservedObject var contactViewModel: ContactViewModel @ObservedObject var historyViewModel: HistoryViewModel + @ObservedObject var editContactViewModel: EditContactViewModel + @Binding var isShowEditContactFragment: Bool @Binding var isShowDeletePopup: Bool var body: some View { NavigationView { ZStack(alignment: .bottomTrailing) { - ContactsFragment(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup) Button { - // Action + withAnimation { + editContactViewModel.selectedEditFriend = nil + editContactViewModel.resetValues() + isShowEditContactFragment.toggle() + } } label: { Image("user-plus") .padding() @@ -50,5 +55,11 @@ struct ContactsView: View { } #Preview { - ContactsView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel(), isShowDeletePopup: .constant(false)) + ContactsView( + contactViewModel: ContactViewModel(), + historyViewModel: HistoryViewModel(), + editContactViewModel: EditContactViewModel(), + isShowEditContactFragment: .constant(false), + isShowDeletePopup: .constant(false) + ) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift index 042169629..f1f427e2f 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift @@ -18,32 +18,87 @@ */ import SwiftUI +import Contacts struct ContactFragment: View { + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + @ObservedObject var contactViewModel: ContactViewModel + @ObservedObject var editContactViewModel: EditContactViewModel @Binding var isShowDeletePopup: Bool + @Binding var isShowDismissPopup: Bool @State private var showingSheet = false + @State private var showShareSheet = false var body: some View { if #available(iOS 16.0, *) { - ContactInnerFragment(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) + if idiom != .pad { + ContactInnerFragment( + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + cnContact: CNContact(), + isShowDeletePopup: $isShowDeletePopup, + showingSheet: $showingSheet, + showShareSheet: $showShareSheet, + isShowDismissPopup: $isShowDismissPopup + ) .sheet(isPresented: $showingSheet) { ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) .presentationDetents([.fraction(0.2)]) } - } else { - ContactInnerFragment(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) + .sheet(isPresented: $showShareSheet) { + ShareSheet(friendToShare: MagicSearchSingleton.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) + .presentationDetents([.medium]) + .edgesIgnoringSafeArea(.bottom) + } + } else { + ContactInnerFragment( + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + cnContact: CNContact(), + isShowDeletePopup: $isShowDeletePopup, + showingSheet: $showingSheet, + showShareSheet: $showShareSheet, + isShowDismissPopup: $isShowDismissPopup + ) .halfSheet(showSheet: $showingSheet) { ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) } onDismiss: {} + .sheet(isPresented: $showShareSheet) { + ShareSheet(friendToShare: MagicSearchSingleton.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) + .edgesIgnoringSafeArea(.bottom) + } + } + } else { + ContactInnerFragment( + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + cnContact: CNContact(), + isShowDeletePopup: $isShowDeletePopup, + showingSheet: $showingSheet, + showShareSheet: $showShareSheet, + isShowDismissPopup: $isShowDismissPopup + ) + .halfSheet(showSheet: $showingSheet) { + ContactListBottomSheet(contactViewModel: contactViewModel, showingSheet: $showingSheet) + } onDismiss: {} + .sheet(isPresented: $showShareSheet) { + ShareSheet(friendToShare: MagicSearchSingleton.shared.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) + .edgesIgnoringSafeArea(.bottom) + } } } } #Preview { - ContactFragment(contactViewModel: ContactViewModel(), isShowDeletePopup: .constant(false)) + ContactFragment( + contactViewModel: ContactViewModel(), + editContactViewModel: EditContactViewModel(), + isShowDeletePopup: .constant(false), + isShowDismissPopup: .constant(false) + ) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift new file mode 100644 index 000000000..ff8e3e2e7 --- /dev/null +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerActionsFragment.swift @@ -0,0 +1,442 @@ +// +// ContactInnerActionsFragment.swift +// Linphone +// +// Created by Benoît Martins on 09/11/2023. +// + +import SwiftUI + +struct ContactInnerActionsFragment: View { + + @ObservedObject var magicSearch = MagicSearchSingleton.shared + @ObservedObject var contactViewModel: ContactViewModel + @ObservedObject var editContactViewModel: EditContactViewModel + + @State private var informationIsOpen = true + + @Binding var showingSheet: Bool + @Binding var showShareSheet: Bool + @Binding var isShowDeletePopup: Bool + @Binding var isShowDismissPopup: Bool + + var actionEditButton: () -> Void + + var body: some View { + HStack(alignment: .center) { + Text("Information") + .default_text_style_800(styleSize: 16) + + Spacer() + + Image(informationIsOpen ? "caret-up" : "caret-down") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 25, height: 25, alignment: .leading) + } + .padding(.top, 30) + .padding(.bottom, 10) + .padding(.horizontal, 16) + .background(Color.gray100) + .onTapGesture { + withAnimation { + informationIsOpen.toggle() + } + } + + if informationIsOpen { + VStack(spacing: 0) { + if contactViewModel.indexDisplayedFriend != nil && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { + ForEach(0.. UIScreen.main.bounds.size.height) { - Image("caret-left") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.top, 2) - .onTapGesture { - withAnimation { - contactViewModel.displayedFriend = nil + NavigationView { + VStack(spacing: 1) { + Rectangle() + .foregroundColor(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + + HStack { + if !(orientation == .landscapeLeft || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { + Image("caret-left") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, 2) + .onTapGesture { + withAnimation { + contactViewModel.indexDisplayedFriend = nil + } } - } - } - - Spacer() - - Image("pencil-simple") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.top, 2) - .onTapGesture { - withAnimation { - - } } - } - .frame(maxWidth: .infinity) - .frame(height: 50) - .padding(.horizontal) - .padding(.bottom, 4) - .background(.white) - - ScrollView { - VStack(spacing: 0) { + + Spacer() + if contactViewModel.indexDisplayedFriend != nil && contactViewModel.indexDisplayedFriend! < magicSearch.lastSearch.count + && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil + && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.nativeUri != nil + && !magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.nativeUri!.isEmpty { + Button(action: { + editNativeContact() + }, label: { + Image("pencil-simple") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, 2) + }) + } else { + NavigationLink(destination: EditContactFragment( + editContactViewModel: editContactViewModel, + isShowEditContactFragment: .constant(false), + isShowDismissPopup: $isShowDismissPopup)) { + Image("pencil-simple") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, 2) + } + .simultaneousGesture( + TapGesture().onEnded { + editContactViewModel.selectedEditFriend = magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend + editContactViewModel.resetValues() + } + ) + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + + ScrollView { VStack(spacing: 0) { - if contactViewModel.displayedFriend != nil - && contactViewModel.displayedFriend!.photo != nil - && !contactViewModel.displayedFriend!.photo!.isEmpty { - AsyncImage(url: URL(string: contactViewModel.displayedFriend!.photo!)) { image in - switch image { - case .empty: - ProgressView() - .frame(width: 100, height: 100) - case .success(let image): - image - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - case .failure: + VStack(spacing: 0) { + VStack(spacing: 0) { + if contactViewModel.indexDisplayedFriend != nil && contactViewModel.indexDisplayedFriend! < magicSearch.lastSearch.count + && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil + && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo != nil + && !magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo!.isEmpty { + AsyncImage( + url: ContactsManager.shared.getImagePath( + friendPhotoPath: magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.photo!)) { image in + switch image { + case .empty: + ProgressView() + .frame(width: 100, height: 100) + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 100, height: 100) + .clipShape(Circle()) + case .failure: + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + @unknown default: + EmptyView() + } + } + } else if contactViewModel.indexDisplayedFriend != nil && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil { Image("profil-picture-default") .resizable() .frame(width: 100, height: 100) .clipShape(Circle()) - @unknown default: - EmptyView() } - } - } else if contactViewModel.displayedFriend != nil { - Image("profil-picture-default") - .resizable() - .frame(width: 100, height: 100) - .clipShape(Circle()) - } - if contactViewModel.displayedFriend != nil && contactViewModel.displayedFriend?.name != nil { - Text((contactViewModel.displayedFriend?.name)!) - .foregroundStyle(Color.grayMain2c700) - .multilineTextAlignment(.center) - .default_text_style(styleSize: 14) - .frame(maxWidth: .infinity) - .padding(.top, 10) - - Text("En ligne") - .foregroundStyle(Color.greenSuccess500) - .multilineTextAlignment(.center) - .default_text_style_300(styleSize: 12) - .frame(maxWidth: .infinity) - } - - } - .frame(minHeight: 150) - .frame(maxWidth: .infinity) - .padding(.top, 10) - .background(Color.gray100) - - HStack { - Spacer() - - Button(action: { - - }, label: { - VStack { - HStack(alignment: .center) { - Image("phone") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c600) - .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { - - } - } + if contactViewModel.indexDisplayedFriend != nil + && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil + && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend?.name != nil { + Text((magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend?.name)!) + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + .frame(maxWidth: .infinity) + .padding(.top, 10) + + Text("En ligne") + .foregroundStyle(Color.greenSuccess500) + .multilineTextAlignment(.center) + .default_text_style_300(styleSize: 12) + .frame(maxWidth: .infinity) } - .padding(16) - .background(Color.grayMain2c200) - .cornerRadius(40) - Text("Appel") - .default_text_style(styleSize: 14) } - }) - - Spacer() - - Button(action: { + .frame(minHeight: 150) + .frame(maxWidth: .infinity) + .padding(.top, 10) + .background(Color.gray100) - }, label: { - VStack { - HStack(alignment: .center) { - Image("chat-teardrop-text") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c600) - .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { - - } - } - } - .padding(16) - .background(Color.grayMain2c200) - .cornerRadius(40) + HStack { + Spacer() - Text("Message") - .default_text_style(styleSize: 14) - } - }) - - Spacer() - - Button(action: { - - }, label: { - VStack { - HStack(alignment: .center) { - Image("video-camera") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c600) - .frame(width: 25, height: 25) - .onTapGesture { - withAnimation { - - } - } - } - .padding(16) - .background(Color.grayMain2c200) - .cornerRadius(40) - - Text("Video Call") - .default_text_style(styleSize: 14) - } - }) - - Spacer() - } - .padding(.top, 20) - .frame(maxWidth: .infinity) - .background(Color.gray100) - - HStack(alignment: .center) { - Text("Information") - .default_text_style_800(styleSize: 16) - - Spacer() - - Image(informationIsOpen ? "caret-up" : "caret-down") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c600) - .frame(width: 25, height: 25, alignment: .leading) - } - .padding(.top, 30) - .padding(.bottom, 10) - .padding(.horizontal, 16) - .background(Color.gray100) - .onTapGesture { - withAnimation { - informationIsOpen.toggle() - } - } - - if informationIsOpen { - VStack(spacing: 0) { - if contactViewModel.displayedFriend != nil { - ForEach(0.. UIScreen.main.bounds.size.height { + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { Spacer() HStack { Spacer() @@ -91,7 +93,12 @@ struct ContactListBottomSheet: View { if contactViewModel.stringToCopy.prefix(4) != "sip:" { Button { if #available(iOS 16.0, *) { - showingSheet.toggle() + if idiom != .pad { + showingSheet.toggle() + } else { + showingSheet.toggle() + dismiss() + } } else { showingSheet.toggle() dismiss() diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift index 9196f23cc..ef48b5e84 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsFragment.swift @@ -20,29 +20,67 @@ import SwiftUI struct ContactsFragment: View { - - @ObservedObject var contactViewModel: ContactViewModel + + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + @ObservedObject var contactViewModel: ContactViewModel @Binding var isShowDeletePopup: Bool - - @State private var showingSheet = false - - var body: some View { + + @State private var showingSheet = false + @State private var showShareSheet = false + + var body: some View { ZStack { if #available(iOS 16.0, *) { - ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) - .sheet(isPresented: $showingSheet) { - ContactsListBottomSheet(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) - .presentationDetents([.fraction(0.2)]) - } + if idiom != .pad { + ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) + .sheet(isPresented: $showingSheet) { + ContactsListBottomSheet( + contactViewModel: contactViewModel, + isShowDeletePopup: $isShowDeletePopup, + showingSheet: $showingSheet, + showShareSheet: $showShareSheet + ) + .presentationDetents([.fraction(0.2)]) + } + .sheet(isPresented: $showShareSheet) { + ShareSheet(friendToShare: contactViewModel.selectedFriendToShare!) + .presentationDetents([.medium]) + .edgesIgnoringSafeArea(.bottom) + } + } else { + ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) + .halfSheet(showSheet: $showingSheet) { + ContactsListBottomSheet( + contactViewModel: contactViewModel, + isShowDeletePopup: $isShowDeletePopup, + showingSheet: $showingSheet, + showShareSheet: $showShareSheet + ) + } onDismiss: {} + .sheet(isPresented: $showShareSheet) { + ShareSheet(friendToShare: contactViewModel.selectedFriendToShare!) + .edgesIgnoringSafeArea(.bottom) + } + } } else { ContactsInnerFragment(contactViewModel: contactViewModel, showingSheet: $showingSheet) .halfSheet(showSheet: $showingSheet) { - ContactsListBottomSheet(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup, showingSheet: $showingSheet) + ContactsListBottomSheet( + contactViewModel: contactViewModel, + isShowDeletePopup: $isShowDeletePopup, + showingSheet: $showingSheet, + showShareSheet: $showShareSheet + ) } onDismiss: {} + .sheet(isPresented: $showShareSheet) { + ShareSheet(friendToShare: contactViewModel.selectedFriendToShare!) + .edgesIgnoringSafeArea(.bottom) + } } } - } + } } #Preview { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift index abfd51b4d..8e09e519f 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsInnerFragment.swift @@ -18,64 +18,77 @@ */ import SwiftUI +import linphonesw struct ContactsInnerFragment: View { - - @ObservedObject var magicSearch = MagicSearchSingleton.shared - @ObservedObject var contactViewModel: ContactViewModel - - @State private var isFavoriteOpen = true - - @Binding var showingSheet: Bool - - var body: some View { - VStack(alignment: .leading) { - if !magicSearch.lastSearch.filter({ $0.friend?.starred == true }).isEmpty { - HStack(alignment: .center) { - Text("Favourites") - .default_text_style_800(styleSize: 16) - - Spacer() - - Image(isFavoriteOpen ? "caret-up" : "caret-down") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c600) - .frame(width: 25, height: 25, alignment: .leading) - } - .padding(.top, 30) - .padding(.horizontal, 16) - .background(.white) - .onTapGesture { - withAnimation { - isFavoriteOpen.toggle() - } - } - - if isFavoriteOpen { - FavoriteContactsListFragment( - contactViewModel: contactViewModel, - favoriteContactsListViewModel: FavoriteContactsListViewModel(), - showingSheet: $showingSheet) - .zIndex(-1) - .transition(.move(edge: .top)) - } - - HStack(alignment: .center) { - Text("All contacts") - .default_text_style_800(styleSize: 16) - - Spacer() - } - .padding(.top, 10) - .padding(.horizontal, 16) - } - ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel(), showingSheet: $showingSheet) - } - .navigationBarHidden(true) - } + + @Environment(\.scenePhase) var scenePhase + + @ObservedObject var magicSearch = MagicSearchSingleton.shared + @ObservedObject var contactViewModel: ContactViewModel + + @State private var isFavoriteOpen = true + + @Binding var showingSheet: Bool + + var body: some View { + VStack(alignment: .leading) { + if !magicSearch.lastSearch.filter({ $0.friend?.starred == true }).isEmpty { + HStack(alignment: .center) { + Text("Favourites") + .default_text_style_800(styleSize: 16) + + Spacer() + + Image(isFavoriteOpen ? "caret-up" : "caret-down") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 25, height: 25, alignment: .leading) + } + .padding(.top, 30) + .padding(.horizontal, 16) + .background(.white) + .onTapGesture { + withAnimation { + isFavoriteOpen.toggle() + } + } + + if isFavoriteOpen { + FavoriteContactsListFragment( + contactViewModel: contactViewModel, + favoriteContactsListViewModel: FavoriteContactsListViewModel(), + showingSheet: $showingSheet) + .zIndex(-1) + .transition(.move(edge: .top)) + } + + HStack(alignment: .center) { + Text("All contacts") + .default_text_style_800(styleSize: 16) + + Spacer() + } + .padding(.top, 10) + .padding(.horizontal, 16) + } + ContactsListFragment(contactViewModel: contactViewModel, contactsListViewModel: ContactsListViewModel(), showingSheet: $showingSheet) + } + .navigationBarHidden(true) + .onChange(of: scenePhase) { newPhase in + if newPhase == .active { + ContactsManager.shared.fetchContacts() + print("Active") + } else if newPhase == .inactive { + print("Inactive") + } else if newPhase == .background { + print("Background") + } + } + } } #Preview { - ContactsInnerFragment(contactViewModel: ContactViewModel(), showingSheet: .constant(false)) + ContactsInnerFragment(contactViewModel: ContactViewModel(), showingSheet: .constant(false)) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift index a5d27c278..851eaef20 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListBottomSheet.swift @@ -19,138 +19,169 @@ import SwiftUI import linphonesw +import Contacts struct ContactsListBottomSheet: View { - - @ObservedObject var magicSearch = MagicSearchSingleton.shared - - @ObservedObject var contactViewModel: ContactViewModel - - @State private var orientation = UIDevice.current.orientation - - @Environment(\.dismiss) var dismiss + + @Environment(\.dismiss) var dismiss + + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + @ObservedObject var magicSearch = MagicSearchSingleton.shared + @ObservedObject var contactViewModel: ContactViewModel + + @State private var orientation = UIDevice.current.orientation @Binding var isShowDeletePopup: Bool - - @Binding var showingSheet: Bool - - var body: some View { - VStack(alignment: .leading) { - if orientation == .landscapeLeft - || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height { - Spacer() - HStack { - Spacer() - Button("Close") { - if #available(iOS 16.0, *) { - showingSheet.toggle() - } else { - showingSheet.toggle() - dismiss() - } - } - } - .padding(.trailing) - } - - Spacer() - Button { - if contactViewModel.selectedFriend != nil { - contactViewModel.selectedFriend!.starred.toggle() - } - self.magicSearch.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - - if #available(iOS 16.0, *) { - showingSheet.toggle() - } else { - showingSheet.toggle() - dismiss() - } - } label: { - HStack { + @Binding var showingSheet: Bool + @Binding var showShareSheet: Bool + + var body: some View { + VStack(alignment: .leading) { + if idiom != .pad && (orientation == .landscapeLeft + || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { + Spacer() + HStack { + Spacer() + Button("Close") { + if #available(iOS 16.0, *) { + showingSheet.toggle() + } else { + showingSheet.toggle() + dismiss() + } + } + } + .padding(.trailing) + } + + Spacer() + Button { + if contactViewModel.selectedFriend != nil { + contactViewModel.selectedFriend!.starred.toggle() + } + self.magicSearch.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + + if #available(iOS 16.0, *) { + if idiom != .pad { + showingSheet.toggle() + } else { + showingSheet.toggle() + dismiss() + } + } else { + showingSheet.toggle() + dismiss() + } + } label: { + HStack { Image(contactViewModel.selectedFriend != nil && contactViewModel.selectedFriend!.starred == true ? "heart-fill" : "heart") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c500) - .frame(width: 25, height: 25, alignment: .leading) - Text(contactViewModel.selectedFriend != nil && contactViewModel.selectedFriend!.starred == true - ? "Remove to favourites" - : "Add to favourites") - .default_text_style(styleSize: 16) - Spacer() - } - .frame(maxHeight: .infinity) - } - .padding(.horizontal, 30) - .background(Color.gray100) - - VStack { - Divider() - } - .frame(maxWidth: .infinity) - - Button { - if #available(iOS 16.0, *) { - showingSheet.toggle() - } else { - showingSheet.toggle() - dismiss() - } - } label: { - HStack { - Image("share-network") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c500) - .frame(width: 25, height: 25, alignment: .leading) - Text("Share") - .default_text_style(styleSize: 16) - Spacer() - } - .frame(maxHeight: .infinity) - } - .padding(.horizontal, 30) - .background(Color.gray100) - - VStack { - Divider() - } - .frame(maxWidth: .infinity) - - Button { - if contactViewModel.selectedFriend != nil { - isShowDeletePopup.toggle() - } + .renderingMode(.template) + .resizable() + .foregroundStyle( + contactViewModel.selectedFriend != nil && contactViewModel.selectedFriend!.starred == true + ? Color.redDanger500 + : Color.grayMain2c500 + ) + .frame(width: 25, height: 25, alignment: .leading) + Text(contactViewModel.selectedFriend != nil && contactViewModel.selectedFriend!.starred == true + ? "Remove from favourites" + : "Add to favourites") + .default_text_style(styleSize: 16) + Spacer() + } + .frame(maxHeight: .infinity) + } + .padding(.horizontal, 30) + .background(Color.gray100) + + VStack { + Divider() + } + .frame(maxWidth: .infinity) + + Button { + if #available(iOS 16.0, *) { + if idiom != .pad { + showingSheet.toggle() + } else { + showingSheet.toggle() + dismiss() + } + } else { + showingSheet.toggle() + dismiss() + } - if #available(iOS 16.0, *) { - showingSheet.toggle() - } else { - showingSheet.toggle() - dismiss() - } - } label: { - HStack { - Image("trash-simple") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.redDanger500) - .frame(width: 25, height: 25, alignment: .leading) - Text("Delete") - .foregroundStyle(Color.redDanger500) - .default_text_style(styleSize: 16) - Spacer() - } - .frame(maxHeight: .infinity) - } - .padding(.horizontal, 30) - .background(Color.gray100) - - } - .onRotate { newOrientation in - orientation = newOrientation - } - .background(Color.gray100) - .frame(maxWidth: .infinity) - } + contactViewModel.selectedFriendToShare = contactViewModel.selectedFriend + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + showShareSheet.toggle() + } + + } label: { + HStack { + Image("share-network") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + Text("Share") + .default_text_style(styleSize: 16) + Spacer() + } + .frame(maxHeight: .infinity) + } + .padding(.horizontal, 30) + .background(Color.gray100) + + VStack { + Divider() + } + .frame(maxWidth: .infinity) + + Button { + if contactViewModel.selectedFriend != nil { + isShowDeletePopup.toggle() + } + + if #available(iOS 16.0, *) { + if idiom != .pad { + showingSheet.toggle() + } else { + showingSheet.toggle() + dismiss() + } + } else { + showingSheet.toggle() + dismiss() + } + } label: { + HStack { + Image("trash-simple") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.redDanger500) + .frame(width: 25, height: 25, alignment: .leading) + Text("Delete") + .foregroundStyle(Color.redDanger500) + .default_text_style(styleSize: 16) + Spacer() + } + .frame(maxHeight: .infinity) + } + .padding(.horizontal, 30) + .background(Color.gray100) + + } + .background(Color.gray100) + .frame(maxWidth: .infinity) + .onRotate { newOrientation in + orientation = newOrientation + } + .onDisappear { + contactViewModel.selectedFriend = nil + } + } } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift index 355f67d0f..7b274e646 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactsListFragment.swift @@ -64,7 +64,7 @@ struct ContactsListFragment: View { } if magicSearch.lastSearch[index].friend!.photo != nil && !magicSearch.lastSearch[index].friend!.photo!.isEmpty { - AsyncImage(url: URL(string: magicSearch.lastSearch[index].friend!.photo!)) { image in + AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: magicSearch.lastSearch[index].friend!.photo!)) { image in switch image { case .empty: ProgressView() @@ -72,6 +72,7 @@ struct ContactsListFragment: View { case .success(let image): image .resizable() + .aspectRatio(contentMode: .fill) .frame(width: 45, height: 45) .clipShape(Circle()) case .failure: @@ -106,7 +107,7 @@ struct ContactsListFragment: View { TapGesture() .onEnded { _ in withAnimation { - contactViewModel.displayedFriend = magicSearch.lastSearch[index].friend + contactViewModel.indexDisplayedFriend = index } } ) diff --git a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift new file mode 100644 index 000000000..e7de05556 --- /dev/null +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -0,0 +1,531 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import linphonesw + +struct EditContactFragment: View { + + @ObservedObject var editContactViewModel: EditContactViewModel + @ObservedObject private var sharedMainViewModel = SharedMainViewModel() + + @Environment(\.dismiss) var dismiss + + @Binding var isShowEditContactFragment: Bool + @Binding var isShowDismissPopup: Bool + + @State private var hasTimeElapsed = false + @State private var delayedColor = Color.white + + @FocusState var isFirstNameFocused: Bool + @FocusState var isLastNameFocused: Bool + @FocusState var isSIPAddressFocused: Int? + @FocusState var isPhoneNumberFocused: Int? + @FocusState var isCompanyFocused: Bool + @FocusState var isJobTitleFocused: Bool + + @State private var showPhotoPicker = false + @State private var selectedImage: UIImage? + @State private var removedImage = false + + var body: some View { + ZStack { + VStack(spacing: 1) { + if editContactViewModel.selectedEditFriend == nil { + Rectangle() + .foregroundColor(delayedColor) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + .task(delayColor) + } else { + Rectangle() + .foregroundColor(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + } + + HStack { + Image("caret-left") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, 2) + .onTapGesture { + if editContactViewModel.selectedEditFriend == nil + && editContactViewModel.firstName.isEmpty + && editContactViewModel.lastName.isEmpty + && editContactViewModel.sipAddresses.first!.isEmpty + && editContactViewModel.phoneNumbers.first!.isEmpty + && editContactViewModel.company.isEmpty + && editContactViewModel.jobTitle.isEmpty { + delayColorDismiss() + withAnimation { + isShowEditContactFragment.toggle() + } + } else if editContactViewModel.selectedEditFriend == nil { + isShowDismissPopup.toggle() + } else { + if editContactViewModel.firstName.isEmpty + && editContactViewModel.lastName.isEmpty + && editContactViewModel.sipAddresses.first!.isEmpty + && editContactViewModel.phoneNumbers.first!.isEmpty + && editContactViewModel.company.isEmpty + && editContactViewModel.jobTitle.isEmpty { + withAnimation { + dismiss() + } + } else { + isShowDismissPopup.toggle() + } + } + } + + Text(editContactViewModel.selectedEditFriend == nil ? "New contact" : "Edit contact") + .multilineTextAlignment(.leading) + .default_text_style_orange_800(styleSize: 16) + + Spacer() + + Image("check") + .renderingMode(.template) + .resizable() + .foregroundStyle(editContactViewModel.firstName.isEmpty ? Color.orangeMain100 : Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.top, 2) + .disabled(editContactViewModel.firstName.isEmpty) + .onTapGesture { + withAnimation { + addOrEditFriend() + } + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + + ScrollView { + VStack(spacing: 0) { + VStack(spacing: 0) { + VStack(spacing: 0) { + if editContactViewModel.selectedEditFriend != nil + && editContactViewModel.selectedEditFriend!.photo != nil + && !editContactViewModel.selectedEditFriend!.photo!.isEmpty && selectedImage == nil && !removedImage { + AsyncImage(url: ContactsManager.shared.getImagePath(friendPhotoPath: editContactViewModel.selectedEditFriend!.photo!)) { image in + switch image { + case .empty: + ProgressView() + .frame(width: 100, height: 100) + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 100, height: 100) + .clipShape(Circle()) + case .failure: + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + @unknown default: + EmptyView() + } + } + } else if selectedImage == nil { + Image("profil-picture-default") + .resizable() + .frame(width: 100, height: 100) + .clipShape(Circle()) + } else { + Image(uiImage: selectedImage!) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 100, height: 100) + .clipShape(Circle()) + } + + if editContactViewModel.selectedEditFriend != nil + && editContactViewModel.selectedEditFriend!.photo != nil + && !editContactViewModel.selectedEditFriend!.photo!.isEmpty + && (editContactViewModel.selectedEditFriend!.photo!.suffix(11) != "default.png" || selectedImage != nil) && !removedImage { + HStack { + Spacer() + + Button(action: { + showPhotoPicker = true + }, label: { + HStack { + Image("pencil-simple") + .resizable() + .frame(width: 20, height: 20) + + Text("Edit picture") + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + } + }) + .padding(.top, 10) + .padding(.trailing, 10) + .sheet(isPresented: $showPhotoPicker) { + PhotoPicker(filter: .images, limit: 1) { results in + PhotoPicker.convertToUIImageArray(fromResults: results) { imagesOrNil, errorOrNil in + if let error = errorOrNil { + print(error) + } + if let images = imagesOrNil { + if let first = images.first { + selectedImage = first + removedImage = false + } + } + } + } + .edgesIgnoringSafeArea(.all) + } + + Button(action: { + removedImage = true + selectedImage = nil + }, label: { + HStack { + Image("trash-simple") + .resizable() + .frame(width: 20, height: 20) + + Text("Remove picture") + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + } + }) + .padding(.top, 10) + + Spacer() + } + } else { + Button(action: { + showPhotoPicker = true + }, label: { + HStack { + Image("camera") + .resizable() + .frame(width: 20, height: 20) + + Text("Add a picture") + .foregroundStyle(Color.grayMain2c700) + .multilineTextAlignment(.center) + .default_text_style(styleSize: 14) + } + }) + .padding(.top, 10) + .sheet(isPresented: $showPhotoPicker) { + PhotoPicker(filter: .images, limit: 1) { results in + PhotoPicker.convertToUIImageArray(fromResults: results) { imagesOrNil, errorOrNil in + if let error = errorOrNil { + print(error) + } + if let images = imagesOrNil { + if let first = images.first { + selectedImage = first + removedImage = false + } + } + } + } + .edgesIgnoringSafeArea(.all) + } + } + } + .frame(minHeight: 150) + .frame(maxWidth: .infinity) + .padding(.top, 10) + .background(Color.gray100) + + VStack(alignment: .leading) { + Text("First name*") + .default_text_style_700(styleSize: 15) + .padding(.bottom, -5) + + TextField("First Name", text: $editContactViewModel.firstName) + .default_text_style(styleSize: 15) + .frame(height: 25) + .padding(.horizontal, 20) + .padding(.vertical, 15) + .background(.white) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(isFirstNameFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) + ) + .padding(.bottom) + .focused($isFirstNameFocused) + } + + VStack(alignment: .leading) { + Text("Last name") + .default_text_style_700(styleSize: 15) + .padding(.bottom, -5) + + TextField("Last name", text: $editContactViewModel.lastName) + .default_text_style(styleSize: 15) + .frame(height: 25) + .padding(.horizontal, 20) + .padding(.vertical, 15) + .background(.white) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(isLastNameFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) + ) + .padding(.bottom) + .focused($isLastNameFocused) + } + + VStack(alignment: .leading) { + Text("SIP address") + .default_text_style_700(styleSize: 15) + .padding(.bottom, -5) + + ForEach(0... + */ + +import linphonesw + +class EditContactViewModel: ObservableObject { + + @Published var selectedEditFriend: Friend? + + @Published var identifier: String = "" + @Published var firstName: String = "" + @Published var lastName: String = "" + @Published var sipAddresses: [String] = [] + @Published var phoneNumbers: [String] = [] + @Published var company: String = "" + @Published var jobTitle: String = "" + @Published var removePopup: Bool = false + + init() { + resetValues() + } + + func resetValues() { + identifier = (selectedEditFriend == nil ? "" : selectedEditFriend!.nativeUri) ?? "" + firstName = (selectedEditFriend == nil ? "" : selectedEditFriend!.vcard?.givenName) ?? "" + lastName = (selectedEditFriend == nil ? "" : selectedEditFriend!.vcard?.familyName) ?? "" + sipAddresses = [] + phoneNumbers = [] + company = (selectedEditFriend == nil ? "" : selectedEditFriend!.organization) ?? "" + jobTitle = (selectedEditFriend == nil ? "" : selectedEditFriend!.jobTitle) ?? "" + + if selectedEditFriend != nil { + selectedEditFriend?.addresses.forEach({ address in + sipAddresses.append(String(address.asStringUriOnly().dropFirst(4))) + }) + + selectedEditFriend?.phoneNumbers.forEach({ phoneNumber in + phoneNumbers.append(phoneNumber) + }) + + } + + sipAddresses.append("") + phoneNumbers.append("") + } +} diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 913b29b45..d9a8307c1 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -26,6 +26,7 @@ struct ContentView: View { var magicSearch = MagicSearchSingleton.shared @ObservedObject var contactViewModel: ContactViewModel + @ObservedObject var editContactViewModel: EditContactViewModel @ObservedObject var historyViewModel: HistoryViewModel @ObservedObject private var coreContext = CoreContext.shared @@ -36,8 +37,10 @@ struct ContentView: View { @State private var searchIsActive = false @State private var text = "" @FocusState private var focusedField: Bool - @State var isMenuOpen: Bool = false + @State var isMenuOpen = false @State var isShowDeletePopup = false + @State var isShowEditContactFragment = false + @State var isShowDismissPopup = false var body: some View { GeometryReader { geometry in @@ -73,7 +76,7 @@ struct ContentView: View { Button(action: { self.index = 1 - contactViewModel.displayedFriend = nil + contactViewModel.indexDisplayedFriend = nil }, label: { VStack { Image("phone") @@ -273,7 +276,13 @@ struct ContentView: View { } if self.index == 0 { - ContactsView(contactViewModel: contactViewModel, historyViewModel: historyViewModel, isShowDeletePopup: $isShowDeletePopup) + ContactsView( + contactViewModel: contactViewModel, + historyViewModel: historyViewModel, + editContactViewModel: editContactViewModel, + isShowEditContactFragment: $isShowEditContactFragment, + isShowDeletePopup: $isShowDeletePopup + ) } else if self.index == 1 { HistoryView() } @@ -328,7 +337,7 @@ struct ContentView: View { Button(action: { self.index = 1 - contactViewModel.displayedFriend = nil + contactViewModel.indexDisplayedFriend = nil }, label: { VStack { Image("phone") @@ -358,7 +367,7 @@ struct ContentView: View { } } - if contactViewModel.displayedFriend != nil || !historyViewModel.historyTitle.isEmpty { + if contactViewModel.indexDisplayedFriend != nil || !historyViewModel.historyTitle.isEmpty { HStack(spacing: 0) { Spacer() .frame(maxWidth: @@ -369,7 +378,12 @@ struct ContentView: View { : 0 ) if self.index == 0 { - ContactFragment(contactViewModel: contactViewModel, isShowDeletePopup: $isShowDeletePopup) + ContactFragment( + contactViewModel: contactViewModel, + editContactViewModel: editContactViewModel, + isShowDeletePopup: $isShowDeletePopup, + isShowDismissPopup: $isShowDismissPopup + ) .frame(maxWidth: .infinity) .background(Color.gray100) .ignoresSafeArea(.keyboard) @@ -413,31 +427,46 @@ struct ContentView: View { .ignoresSafeArea(.all) .zIndex(2) + if isShowEditContactFragment { + EditContactFragment( + editContactViewModel: editContactViewModel, + isShowEditContactFragment: $isShowEditContactFragment, + isShowDismissPopup: $isShowDismissPopup + ) + .zIndex(3) + .transition(.move(edge: .bottom)) + .onAppear { + contactViewModel.indexDisplayedFriend = nil + } + } + if isShowDeletePopup { PopupView(sharedMainViewModel: SharedMainViewModel(), isShowPopup: $isShowDeletePopup, title: Text( - contactViewModel.selectedFriend != nil - ? "Delete \(contactViewModel.selectedFriend!.name!)?" - : (contactViewModel.displayedFriend != nil - ? "Delete \(contactViewModel.displayedFriend!.name!)?" - : "Error Name")), + contactViewModel.selectedFriend != nil + ? "Delete \(contactViewModel.selectedFriend!.name!)?" + : (contactViewModel.indexDisplayedFriend != nil + ? "Delete \(magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.name!)?" + : "Error Name")), content: Text("This contact will be deleted definitively."), titleFirstButton: Text("Cancel"), - actionFirstButton: {self.isShowDeletePopup.toggle()}, + actionFirstButton: { + self.isShowDeletePopup.toggle()}, titleSecondButton: Text("Ok"), actionSecondButton: { - if contactViewModel.selectedFriend != nil { - contactViewModel.selectedFriend!.remove() - if contactViewModel.displayedFriend != nil && contactViewModel.selectedFriend!.name == contactViewModel.displayedFriend!.name { + if contactViewModel.selectedFriendToDelete != nil { + if contactViewModel.indexDisplayedFriend != nil { withAnimation { - contactViewModel.displayedFriend = nil + contactViewModel.indexDisplayedFriend = nil } } - } else if contactViewModel.displayedFriend != nil { - contactViewModel.displayedFriend!.remove() + contactViewModel.selectedFriendToDelete!.remove() + } else if contactViewModel.indexDisplayedFriend != nil { + let tmpIndex = contactViewModel.indexDisplayedFriend withAnimation { - contactViewModel.displayedFriend = nil + contactViewModel.indexDisplayedFriend = nil } + magicSearch.lastSearch[tmpIndex!].friend!.remove() } magicSearch.searchForContacts( sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) @@ -448,6 +477,39 @@ struct ContentView: View { .onTapGesture { self.isShowDeletePopup.toggle() } + .onAppear { + contactViewModel.selectedFriendToDelete = contactViewModel.selectedFriend + } + } + + if isShowDismissPopup { + PopupView(sharedMainViewModel: SharedMainViewModel(), isShowPopup: $isShowDismissPopup, + title: Text("Don’t save modifications?"), + content: Text("All modifications will be canceled."), + titleFirstButton: Text("Cancel"), + actionFirstButton: {self.isShowDismissPopup.toggle()}, + titleSecondButton: Text("Ok"), + actionSecondButton: { + if editContactViewModel.selectedEditFriend == nil { + self.isShowDismissPopup.toggle() + editContactViewModel.removePopup = true + editContactViewModel.resetValues() + withAnimation { + isShowEditContactFragment.toggle() + } + } else { + self.isShowDismissPopup.toggle() + editContactViewModel.resetValues() + withAnimation { + editContactViewModel.removePopup = true + } + } + }) + .background(.black.opacity(0.65)) + .zIndex(3) + .onTapGesture { + self.isShowDismissPopup.toggle() + } } } } @@ -462,7 +524,7 @@ struct ContentView: View { } } .onRotate { newOrientation in - if (contactViewModel.displayedFriend != nil || !historyViewModel.historyTitle.isEmpty) && searchIsActive { + if (contactViewModel.indexDisplayedFriend != nil || !historyViewModel.historyTitle.isEmpty) && searchIsActive { self.focusedField = false } else if searchIsActive { self.focusedField = true @@ -479,5 +541,5 @@ struct ContentView: View { } #Preview { - ContentView(contactViewModel: ContactViewModel(), historyViewModel: HistoryViewModel()) + ContentView(contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), historyViewModel: HistoryViewModel()) } diff --git a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift index d95e98499..6adcc0dcc 100644 --- a/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/SharedMainViewModel.swift @@ -47,11 +47,9 @@ class SharedMainViewModel: ObservableObject { } if preferences.object(forKey: displayProfileModeKey) == nil { - print("displayProfileModeKeydisplayProfileModeKey nil") preferences.set(displayProfileMode, forKey: displayProfileModeKey) } else { displayProfileMode = preferences.bool(forKey: displayProfileModeKey) - print("displayProfileModeKeydisplayProfileModeKey \(displayProfileMode)") } } diff --git a/Linphone/Utils/EditContactController.swift b/Linphone/Utils/EditContactController.swift new file mode 100644 index 000000000..b3a9d250e --- /dev/null +++ b/Linphone/Utils/EditContactController.swift @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import ContactsUI +import linphonesw + +struct EditContactView: UIViewControllerRepresentable { + + class Coordinator: NSObject, CNContactViewControllerDelegate, UINavigationControllerDelegate { + func contactViewController(_ viewController: CNContactViewController, didCompleteWith contact: CNContact?) { + if let cnc = contact { + DispatchQueue.global().asyncAfter(deadline: .now() + 1) { + self.parent.contact = cnc + + let newContact = Contact( + identifier: cnc.identifier, + firstName: cnc.givenName, + lastName: cnc.familyName, + organizationName: cnc.organizationName, + jobTitle: "", + displayName: cnc.nickname, + sipAddresses: cnc.instantMessageAddresses.map { $0.value.service == "SIP" ? $0.value.username : "" }, + phoneNumbers: cnc.phoneNumbers.map { PhoneNumber(numLabel: $0.label ?? "", num: $0.value.stringValue)}, + imageData: "" + ) + + let imageThumbnail = UIImage(data: contact!.thumbnailImageData ?? Data()) + ContactsManager.shared.saveImage( + image: imageThumbnail + ?? ContactsManager.shared.textToImage( + firstName: cnc.givenName.isEmpty + && cnc.familyName.isEmpty + && cnc.phoneNumbers.first?.value.stringValue != nil + ? cnc.phoneNumbers.first!.value.stringValue + : cnc.givenName, lastName: cnc.familyName), + name: cnc.givenName + cnc.familyName + String(Int.random(in: 1...1000)) + ((imageThumbnail == nil) ? "-default" : ""), + contact: newContact, + linphoneFriend: false, + existingFriend: ContactsManager.shared.getFriend(contact: newContact)) + + MagicSearchSingleton.shared.searchForContacts(sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + } + } + viewController.dismiss(animated: true, completion: {}) + } + + func contactViewController(_ viewController: CNContactViewController, shouldPerformDefaultActionFor property: CNContactProperty) -> Bool { + return true + } + + var parent: EditContactView + + init(_ parent: EditContactView) { + self.parent = parent + } + } + + @Binding var contact: CNContact? + + init(contact: Binding) { + self._contact = contact + } + + typealias UIViewControllerType = CNContactViewController + + func makeCoordinator() -> Coordinator { + return Coordinator(self) + } + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> EditContactView.UIViewControllerType { + let vcontact = contact != nil ? CNContactViewController(for: contact!) : CNContactViewController(forNewContact: CNContact()) + vcontact.isEditing = true + vcontact.delegate = context.coordinator + return vcontact + } + + func updateUIViewController(_ uiViewController: EditContactView.UIViewControllerType, context: UIViewControllerRepresentableContext) { + } +} diff --git a/Linphone/Utils/MagicSearchSingleton.swift b/Linphone/Utils/MagicSearchSingleton.swift index 50c9f02e9..7ce369b0a 100644 --- a/Linphone/Utils/MagicSearchSingleton.swift +++ b/Linphone/Utils/MagicSearchSingleton.swift @@ -39,7 +39,7 @@ final class MagicSearchSingleton: ObservableObject { private var domainDefaultAccount = "" private init() { - coreContext.doOnCoreQueue{ core in + coreContext.doOnCoreQueue { core in self.domainDefaultAccount = core.defaultAccount?.params?.domain ?? "" self.magicSearch = try? core.createMagicSearch() @@ -53,7 +53,7 @@ final class MagicSearchSingleton: ObservableObject { } func searchForContacts(sourceFlags: Int) { - coreContext.doOnCoreQueue{ core in + coreContext.doOnCoreQueue { _ in var needResetCache = false DispatchQueue.main.sync { diff --git a/Linphone/Utils/PhotoPicker.swift b/Linphone/Utils/PhotoPicker.swift new file mode 100644 index 000000000..8e4c951aa --- /dev/null +++ b/Linphone/Utils/PhotoPicker.swift @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import PhotosUI + +struct PhotoPicker: UIViewControllerRepresentable { + typealias UIViewControllerType = PHPickerViewController + + let filter: PHPickerFilter + var limit: Int = 0 + let onComplete: ([PHPickerResult]) -> Void + + func makeUIViewController(context: Context) -> PHPickerViewController { + + var configuration = PHPickerConfiguration() + configuration.filter = filter + configuration.selectionLimit = limit + + let controller = PHPickerViewController(configuration: configuration) + + controller.delegate = context.coordinator + return controller + } + + static func convertToUIImageArray(fromResults results: [PHPickerResult], onComplete: @escaping ([UIImage]?, Error?) -> Void) { + var images = [UIImage]() + + let dispatchGroup = DispatchGroup() + for result in results { + dispatchGroup.enter() + let itemProvider = result.itemProvider + if itemProvider.canLoadObject(ofClass: UIImage.self) { + itemProvider.loadObject(ofClass: UIImage.self) { (imageOrNil, errorOrNil) in + if let error = errorOrNil { + onComplete(nil, error) + } + if let image = imageOrNil as? UIImage { + images.append(image) + } + dispatchGroup.leave() + } + } + } + dispatchGroup.notify(queue: .main) { + onComplete(images, nil) + } + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + return Coordinator(self) + } + + class Coordinator: PHPickerViewControllerDelegate { + + private let parent: PhotoPicker + + init(_ parent: PhotoPicker) { + self.parent = parent + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + parent.onComplete(results) + } + } +} diff --git a/Linphone/Utils/ShareSheetController.swift b/Linphone/Utils/ShareSheetController.swift new file mode 100644 index 000000000..8a512713c --- /dev/null +++ b/Linphone/Utils/ShareSheetController.swift @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import SwiftUI +import linphonesw + +struct ShareSheet: UIViewControllerRepresentable { + typealias Callback = (_ activityType: UIActivity.ActivityType?, _ completed: Bool, _ returnedItems: [Any]?, _ error: Error?) -> Void + + let friendToShare: Friend + var activityItems: [Any] = [] + let applicationActivities: [UIActivity]? = nil + let excludedActivityTypes: [UIActivity.ActivityType]? = nil + let callback: Callback? = nil + + func makeUIViewController(context: Context) -> UIActivityViewController { + let directoryURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + + if directoryURL != nil { + if friendToShare.name != nil { + let filename = friendToShare.name!.replacingOccurrences(of: " ", with: "") + + let fileURL = directoryURL! + .appendingPathComponent(filename) + .appendingPathExtension("vcf") + + if friendToShare.vcard != nil { + try? friendToShare.vcard!.asVcard4String().write(to: fileURL, atomically: false, encoding: String.Encoding.utf8) + + let controller = UIActivityViewController( + activityItems: [fileURL], + applicationActivities: applicationActivities + ) + controller.excludedActivityTypes = excludedActivityTypes + controller.completionWithItemsHandler = callback + return controller + } + } + } + + let controller = UIActivityViewController( + activityItems: activityItems, + applicationActivities: applicationActivities) + controller.excludedActivityTypes = excludedActivityTypes + controller.completionWithItemsHandler = callback + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { + // nothing to do here + } + + func shareContacts(friend: String) { + + let directoryURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + + if directoryURL != nil { + let filename = NSUUID().uuidString + + let fileURL = directoryURL! + .appendingPathComponent(filename) + .appendingPathExtension("vcf") + + try? friend.write(to: fileURL, atomically: false, encoding: String.Encoding.utf8) + } + + /* + let activityViewController = UIActivityViewController( + activityItems: [fileURL], + applicationActivities: nil + ) + */ + } +}