diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 0dfea3d76..d04b4595f 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ 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 */; }; 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 */; }; @@ -116,6 +117,7 @@ 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 = ""; }; 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 = ""; }; @@ -172,6 +174,7 @@ D74C9D002ACB098C0021626A /* PermissionManager.swift */, D7D1698B2AE66FA500109A5C /* MagicSearchSingleton.swift */, D7C3650D2AF15BF200FE6142 /* PhotoPicker.swift */, + D7C48DF32AFA66F900D938CB /* EditContactController.swift */, ); path = Utils; sourceTree = ""; @@ -568,6 +571,7 @@ 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 */, diff --git a/Linphone/Contacts/ContactsManager.swift b/Linphone/Contacts/ContactsManager.swift index 76eafa748..981bd7c6f 100644 --- a/Linphone/Contacts/ContactsManager.swift +++ b/Linphone/Contacts/ContactsManager.swift @@ -20,6 +20,7 @@ import linphonesw import Contacts import SwiftUI +import ContactsUI final class ContactsManager: ObservableObject { @@ -110,6 +111,7 @@ final class ContactsManager: ObservableObject { try store.enumerateContacts(with: request, usingBlock: { (contact, _) in DispatchQueue.main.sync { let newContact = Contact( + identifier: contact.identifier, firstName: contact.givenName, lastName: contact.familyName, organizationName: contact.organizationName, @@ -203,6 +205,8 @@ final class ContactsManager: ObservableObject { if friend != nil { friend!.edit() + friend!.nativeUri = contact.identifier + try friend!.setName(newValue: contact.firstName + " " + contact.lastName) let friendvCard = friend!.vcard @@ -284,6 +288,55 @@ final class ContactsManager: ObservableObject { } } } + + func getCNContact(friend: Friend, completion: @escaping (CNContact?) -> Void) { + DispatchQueue.global().async { + let store = CNContactStore() + store.requestAccess(for: .contacts) { (granted, error) in + if let error = error { + print("failed to request access", error) + return + } + if granted { + let keys = [CNContactEmailAddressesKey, CNContactPhoneNumbersKey, + CNContactFamilyNameKey, CNContactGivenNameKey, CNContactNicknameKey, + CNContactPostalAddressesKey, CNContactIdentifierKey, + CNInstantMessageAddressUsernameKey, CNContactInstantMessageAddressesKey, + CNContactImageDataKey, CNContactThumbnailImageDataKey, CNContactOrganizationNameKey] + let request = CNContactFetchRequest(keysToFetch: keys as [CNKeyDescriptor]) + do { + try store.enumerateContacts(with: request, usingBlock: { (contact, _) in + if contact.identifier == friend.nativeUri { + var contactFetched = contact + if !contactFetched.areKeysAvailable([CNContactViewController.descriptorForRequiredKeys()]) { + do { + contactFetched = try store.unifiedContact(withIdentifier: contact.identifier, keysToFetch: [CNContactViewController.descriptorForRequiredKeys()]) + completion(contactFetched) + } + catch { + completion(nil) + } + } + } + }) + } catch let error { + print("Failed to enumerate contact", error) + } + } else { + print("access denied") + } + } + } + } + + 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 { @@ -293,6 +346,7 @@ struct PhoneNumber { struct Contact: Identifiable { var id = UUID() + var identifier: String var firstName: String var lastName: String var organizationName: String diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 0a4d62d83..2742080e2 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -208,6 +208,9 @@ }, "Edit contact" : { + }, + "Edit Contact" : { + }, "Edit picture" : { diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift index abbd1492c..02d537c6f 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactFragment.swift @@ -74,5 +74,10 @@ struct ContactFragment: View { } #Preview { - ContactFragment(contactViewModel: ContactViewModel(), editContactViewModel: EditContactViewModel(), isShowDeletePopup: .constant(false), isShowDismissPopup: .constant(false)) + ContactFragment( + contactViewModel: ContactViewModel(), + editContactViewModel: EditContactViewModel(), + isShowDeletePopup: .constant(false), + isShowDismissPopup: .constant(false) + ) } diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift index 63ccd2937..3823d44df 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactInnerFragment.swift @@ -18,6 +18,7 @@ */ import SwiftUI +import Contacts struct ContactInnerFragment: View { @@ -30,6 +31,8 @@ struct ContactInnerFragment: View { @State private var orientation = UIDevice.current.orientation @State private var informationIsOpen = true + @State private var presentingEditContact = false + @State private var cnContact: CNContact? @Binding var isShowDeletePopup: Bool @Binding var showingSheet: Bool @@ -44,8 +47,7 @@ struct ContactInnerFragment: View { .frame(height: 0) HStack { - if !(orientation == .landscapeLeft - || orientation == .landscapeRight + if !(orientation == .landscapeLeft || orientation == .landscapeRight || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { Image("caret-left") .renderingMode(.template) @@ -61,21 +63,40 @@ struct ContactInnerFragment: View { } Spacer() - - 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() + if contactViewModel.indexDisplayedFriend != nil + && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil + && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.nativeUri != nil && !magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.nativeUri!.isEmpty { + Button(action: { + ContactsManager.shared.getCNContact(friend: magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!) { result in + cnContact = result + if cnContact != nil { + presentingEditContact.toggle() + } + } + }, 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) @@ -143,7 +164,6 @@ struct ContactInnerFragment: View { Spacer() Button(action: { - }, label: { VStack { HStack(alignment: .center) { @@ -458,8 +478,7 @@ struct ContactInnerFragment: View { && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred == true ? "heart-fill" : "heart") .renderingMode(.template) .resizable() - .foregroundStyle(contactViewModel.indexDisplayedFriend != nil - && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil + .foregroundStyle(contactViewModel.indexDisplayedFriend != nil && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend != nil && magicSearch.lastSearch[contactViewModel.indexDisplayedFriend!].friend!.starred == true ? Color.redDanger500 : Color.grayMain2c500) .frame(width: 25, height: 25) Text(contactViewModel.indexDisplayedFriend != nil @@ -600,6 +619,14 @@ struct ContactInnerFragment: View { .onRotate { newOrientation in orientation = newOrientation } + .sheet(isPresented: $presentingEditContact) { + NavigationView { + AnyView(EditContactView(contact: $cnContact) + .navigationBarTitle("Edit Contact") + .navigationBarTitleDisplayMode(.inline)) + .edgesIgnoringSafeArea(.top) + } + } } .navigationViewStyle(.stack) } 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/EditContactFragment.swift b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift index a3259fc84..f4666f2d2 100644 --- a/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift +++ b/Linphone/UI/Main/Contacts/Fragments/EditContactFragment.swift @@ -472,6 +472,7 @@ struct EditContactFragment: View { func addOrEditFriend() { let newContact = Contact( + identifier: editContactViewModel.identifier, firstName: editContactViewModel.firstName, lastName: editContactViewModel.lastName, organizationName: editContactViewModel.company, diff --git a/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift index 57ad4f478..a66fa422b 100644 --- a/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift +++ b/Linphone/UI/Main/Contacts/ViewModel/EditContactViewModel.swift @@ -23,6 +23,7 @@ class EditContactViewModel: ObservableObject { @Published var selectedEditFriend: Friend? + @Published var identifier: String = "" @Published var firstName: String = "" @Published var lastName: String = "" @Published var sipAddresses: [String] = [] @@ -36,6 +37,7 @@ class EditContactViewModel: ObservableObject { } func resetValues() { + identifier = (selectedEditFriend == nil ? "" : selectedEditFriend!.nativeUri) ?? "" firstName = (selectedEditFriend == nil ? "" : selectedEditFriend!.vcard?.givenName) ?? "" lastName = (selectedEditFriend == nil ? "" : selectedEditFriend!.vcard?.familyName) ?? "" sipAddresses = [] diff --git a/Linphone/Utils/EditContactController.swift b/Linphone/Utils/EditContactController.swift new file mode 100644 index 000000000..43a6e52a7 --- /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) { + } +}