From b5d98cc45aacd74b7ded7bbd20457fb81b8a2b9c Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 25 Jun 2024 14:55:16 +0200 Subject: [PATCH] Start group call --- Linphone.xcodeproj/project.pbxproj | 4 + Linphone/Localizable.xcstrings | 81 +++ .../Fragments/ConversationFragment.swift | 6 - Linphone/UI/Main/Fragments/ToastView.swift | 10 +- .../History/Fragments/StartCallFragment.swift | 474 +++++++++++------- .../Fragments/StartGroupCallFragment.swift | 24 + .../ViewModel/StartCallViewModel.swift | 133 ++++- .../Viewmodel/AddParticipantsViewModel.swift | 4 +- .../Extensions/UIApplicationExtension.swift | 4 + 9 files changed, 557 insertions(+), 183 deletions(-) create mode 100644 Linphone/UI/Main/History/Fragments/StartGroupCallFragment.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 59c53d4f0..e33ed4f0c 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ D714035B2BE11E00004BD8CA /* CallMediaEncryptionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D714035A2BE11E00004BD8CA /* CallMediaEncryptionModel.swift */; }; D714DE602C1B3B34006C1F1D /* RegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D714DE5F2C1B3B34006C1F1D /* RegisterViewModel.swift */; }; D714DE622C1C4636006C1F1D /* RegisterCodeConfirmationFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D714DE612C1C4636006C1F1D /* RegisterCodeConfirmationFragment.swift */; }; + D71556362C297DB1009A8CEF /* StartGroupCallFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71556352C297DB1009A8CEF /* StartGroupCallFragment.swift */; }; D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; D71707202AC5989C0037746F /* TextExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071F2AC5989C0037746F /* TextExtension.swift */; }; D7173EBE2B7A5C0A00BCC481 /* LinphoneUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7173EBD2B7A5C0A00BCC481 /* LinphoneUtils.swift */; }; @@ -233,6 +234,7 @@ D714035A2BE11E00004BD8CA /* CallMediaEncryptionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMediaEncryptionModel.swift; sourceTree = ""; }; D714DE5F2C1B3B34006C1F1D /* RegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterViewModel.swift; sourceTree = ""; }; D714DE612C1C4636006C1F1D /* RegisterCodeConfirmationFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterCodeConfirmationFragment.swift; sourceTree = ""; }; + D71556352C297DB1009A8CEF /* StartGroupCallFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartGroupCallFragment.swift; sourceTree = ""; }; D717071D2AC5922E0037746F /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; D717071F2AC5989C0037746F /* TextExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextExtension.swift; sourceTree = ""; }; D7173EBD2B7A5C0A00BCC481 /* LinphoneUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinphoneUtils.swift; sourceTree = ""; }; @@ -624,6 +626,7 @@ D732A91A2B061BD900DB42BA /* HistoryListBottomSheet.swift */, D726E43C2B19E4FE0083C415 /* StartCallFragment.swift */, D79622332B1DFE600037EACD /* DialerBottomSheet.swift */, + D71556352C297DB1009A8CEF /* StartGroupCallFragment.swift */, ); path = Fragments; sourceTree = ""; @@ -1051,6 +1054,7 @@ 66E50A492BD12B2300AD61CA /* MeetingsView.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, D717630D2BD7BD0E00464097 /* ParticipantsListFragment.swift in Sources */, + D71556362C297DB1009A8CEF /* StartGroupCallFragment.swift in Sources */, C6A5A9452C10B6270070FEA4 /* OIDAuthStateExtension.swift in Sources */, D732A90F2B04C3B400DB42BA /* HistoryFragment.swift in Sources */, D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */, diff --git a/Linphone/Localizable.xcstrings b/Linphone/Localizable.xcstrings index 90003759d..c61bc684d 100644 --- a/Linphone/Localizable.xcstrings +++ b/Linphone/Localizable.xcstrings @@ -461,12 +461,45 @@ }, "Conditions de service" : { + }, + "conference_failed_to_create_group_call_toast" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to create a group call!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'appel de groupe n'a pas pu être créé!" + } + } + } }, "Configuration failed" : { }, "Configuration successfully applied" : { + }, + "Confirm" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmer" + } + } + } }, "Connexion à la réunion" : { @@ -758,6 +791,54 @@ }, "History has been deleted" : { + }, + "history_call_start_create_group_call" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a group call" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Démarrer un appel de groupe" + } + } + } + }, + "history_group_call_start_dialog_set_subject" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set group call subject" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nommer l'appel de groupe" + } + } + } + }, + "history_group_call_start_dialog_subject_hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Group call subject" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom de l'appel de groupe" + } + } + } }, "I prefere create an account" : { diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index ec8fe4443..1180f7a6a 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -570,12 +570,6 @@ struct ScrollOffsetPreferenceKey: PreferenceKey { } } -extension UIApplication { - func endEditing() { - sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - } -} - struct ImagePicker: UIViewControllerRepresentable { @ObservedObject var conversationViewModel: ConversationViewModel @Binding var selectedMedia: [Attachment] diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 6babc0fe0..446279c13 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -192,7 +192,15 @@ struct ToastView: View { .foregroundStyle(Color.redDanger500) .default_text_style(styleSize: 15) .padding(8) - + + + case "Failed_to_create_group_call_error": + Text("conference_failed_to_create_group_call_toast") + .multilineTextAlignment(.center) + .foregroundStyle(Color.redDanger500) + .default_text_style(styleSize: 15) + .padding(8) + default: Text("Error") .multilineTextAlignment(.center) diff --git a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift index d6c465356..4e0589afb 100644 --- a/Linphone/UI/Main/History/Fragments/StartCallFragment.swift +++ b/Linphone/UI/Main/History/Fragments/StartCallFragment.swift @@ -22,6 +22,8 @@ import linphonesw struct StartCallFragment: View { + @ObservedObject private var sharedMainViewModel = SharedMainViewModel.shared + @ObservedObject var contactsManager = ContactsManager.shared @ObservedObject var magicSearch = MagicSearchSingleton.shared @ObservedObject private var telecomManager = TelecomManager.shared @@ -35,213 +37,271 @@ struct StartCallFragment: View { @FocusState var isSearchFieldFocused: Bool @State private var delayedColor = Color.white + @FocusState var isMessageTextFocused: Bool + var resetCallView: () -> Void var body: some View { - ZStack { - VStack(spacing: 1) { - - Rectangle() - .foregroundColor(delayedColor) - .edgesIgnoringSafeArea(.top) - .frame(height: 0) - .task(delayColor) - - HStack { - Image("caret-left") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - .padding(.top, 2) - .padding(.leading, -10) - .onTapGesture { - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - - if callViewModel.isTransferInsteadCall == true { - callViewModel.isTransferInsteadCall = false + NavigationView { + ZStack { + VStack(spacing: 1) { + + Rectangle() + .foregroundColor(delayedColor) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + .task(delayColor) + + HStack { + Image("caret-left") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 2) + .padding(.leading, -10) + .onTapGesture { + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + + if callViewModel.isTransferInsteadCall == true { + callViewModel.isTransferInsteadCall = false + } + + resetCallView() } - resetCallView() + startCallViewModel.searchField = "" + magicSearch.currentFilterSuggestions = "" + delayColorDismiss() + withAnimation { + isShowStartCallFragment.toggle() + } } + + Text(!callViewModel.isTransferInsteadCall ? "New call" : "Transfer call to") + .multilineTextAlignment(.leading) + .default_text_style_orange_800(styleSize: 16) + + Spacer() + + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + + VStack(spacing: 0) { + ZStack(alignment: .trailing) { + TextField("Search contact or history call", text: $startCallViewModel.searchField) + .default_text_style(styleSize: 15) + .frame(height: 25) + .focused($isSearchFieldFocused) + .padding(.horizontal, 30) + .onChange(of: startCallViewModel.searchField) { newValue in + magicSearch.currentFilterSuggestions = newValue + magicSearch.searchForSuggestions() + } + .simultaneousGesture(TapGesture().onEnded { + showingDialer = false + }) - startCallViewModel.searchField = "" - magicSearch.currentFilterSuggestions = "" - delayColorDismiss() - withAnimation { - isShowStartCallFragment.toggle() + HStack { + Button(action: { + }, label: { + Image("magnifying-glass") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25) + }) + + Spacer() + + if startCallViewModel.searchField.isEmpty { + Button(action: { + if !showingDialer { + isSearchFieldFocused = false + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + showingDialer = true + } + } else { + showingDialer = false + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + isSearchFieldFocused = true + } + } + }, label: { + Image(!showingDialer ? "dialer" : "keyboard") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25) + }) + } else { + Button(action: { + startCallViewModel.searchField = "" + magicSearch.currentFilterSuggestions = "" + magicSearch.searchForSuggestions() + }, label: { + Image("x") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25) + }) + } } } - - Text(!callViewModel.isTransferInsteadCall ? "New call" : "Transfer call to") - .multilineTextAlignment(.leading) - .default_text_style_orange_800(styleSize: 16) - - Spacer() - - } - .frame(maxWidth: .infinity) - .frame(height: 50) - .padding(.horizontal) - .padding(.bottom, 4) - .background(.white) - - VStack(spacing: 0) { - ZStack(alignment: .trailing) { - TextField("Search contact or history call", text: $startCallViewModel.searchField) - .default_text_style(styleSize: 15) - .frame(height: 25) - .focused($isSearchFieldFocused) - .padding(.horizontal, 30) - .onChange(of: startCallViewModel.searchField) { newValue in - magicSearch.currentFilterSuggestions = newValue - magicSearch.searchForSuggestions() - } - .simultaneousGesture(TapGesture().onEnded { - showingDialer = false - }) + .padding(.horizontal, 15) + .padding(.vertical, 10) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(isSearchFieldFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) + ) + .padding(.vertical) + .padding(.horizontal) - HStack { - Button(action: { - }, label: { - Image("magnifying-glass") + NavigationLink(destination: { + StartGroupCallFragment(startCallViewModel: startCallViewModel) + }, label: { + HStack { + HStack(alignment: .center) { + Image("meetings") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 20, height: 20, alignment: .leading) + } + .padding(16) + .background(Color.orangeMain500) + .cornerRadius(40) + + Text("history_call_start_create_group_call") + .foregroundStyle(.black) + .default_text_style_800(styleSize: 16) + + Spacer() + + Image("caret-right") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c500) - .frame(width: 25, height: 25) - }) + .frame(width: 25, height: 25, alignment: .leading) + } + }) + .padding(.vertical, 10) + .padding(.horizontal, 20) + .background( + LinearGradient(gradient: Gradient(colors: [.grayMain2c100, .white]), startPoint: .leading, endPoint: .trailing) + .padding(.vertical, 10) + .padding(.horizontal, 40) + ) + + ScrollView { + if !ContactsManager.shared.lastSearch.isEmpty { + HStack(alignment: .center) { + Text("All contacts") + .default_text_style_800(styleSize: 16) + + Spacer() + } + .padding(.vertical, 10) + .padding(.horizontal, 16) + } - Spacer() - - if startCallViewModel.searchField.isEmpty { - Button(action: { - if !showingDialer { - isSearchFieldFocused = false + ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: { addr in + if callViewModel.isTransferInsteadCall { + showingDialer = false + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { - showingDialer = true + if callViewModel.isTransferInsteadCall == true { + callViewModel.isTransferInsteadCall = false } - } else { - showingDialer = false - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { - isSearchFieldFocused = true - } + resetCallView() } - }, label: { - Image(!showingDialer ? "dialer" : "keyboard") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c500) - .frame(width: 25, height: 25) - }) - } else { - Button(action: { + startCallViewModel.searchField = "" magicSearch.currentFilterSuggestions = "" - magicSearch.searchForSuggestions() - }, label: { - Image("x") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.grayMain2c500) - .frame(width: 25, height: 25) - }) - } - } - } - .padding(.horizontal, 15) - .padding(.vertical, 10) - .cornerRadius(60) - .overlay( - RoundedRectangle(cornerRadius: 60) - .inset(by: 0.5) - .stroke(isSearchFieldFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) - ) - .padding(.vertical) - .padding(.horizontal) - - ScrollView { - if !ContactsManager.shared.lastSearch.isEmpty { + delayColorDismiss() + + withAnimation { + isShowStartCallFragment.toggle() + callViewModel.blindTransferCallTo(toAddress: addr) + } + } else { + showingDialer = false + + DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { + magicSearch.searchForContacts( + sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) + + if callViewModel.isTransferInsteadCall == true { + callViewModel.isTransferInsteadCall = false + } + + resetCallView() + } + + startCallViewModel.searchField = "" + magicSearch.currentFilterSuggestions = "" + delayColorDismiss() + + withAnimation { + isShowStartCallFragment.toggle() + telecomManager.doCallOrJoinConf(address: addr) + } + } + }) + .padding(.horizontal, 16) + HStack(alignment: .center) { - Text("All contacts") + Text("Suggestions") .default_text_style_800(styleSize: 16) Spacer() } .padding(.vertical, 10) .padding(.horizontal, 16) - } - - ContactsListFragment(contactViewModel: ContactViewModel(), contactsListViewModel: ContactsListViewModel(), showingSheet: .constant(false), startCallFunc: { addr in - if callViewModel.isTransferInsteadCall { - showingDialer = false - - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - - if callViewModel.isTransferInsteadCall == true { - callViewModel.isTransferInsteadCall = false - } - - resetCallView() - } - - startCallViewModel.searchField = "" - magicSearch.currentFilterSuggestions = "" - delayColorDismiss() - - withAnimation { - isShowStartCallFragment.toggle() - callViewModel.blindTransferCallTo(toAddress: addr) - } - } else { - showingDialer = false - - DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { - magicSearch.searchForContacts( - sourceFlags: MagicSearch.Source.Friends.rawValue | MagicSearch.Source.LdapServers.rawValue) - - if callViewModel.isTransferInsteadCall == true { - callViewModel.isTransferInsteadCall = false - } - - resetCallView() - } - - startCallViewModel.searchField = "" - magicSearch.currentFilterSuggestions = "" - delayColorDismiss() - - withAnimation { - isShowStartCallFragment.toggle() - telecomManager.doCallOrJoinConf(address: addr) - } - } - }) - .padding(.horizontal, 16) - - HStack(alignment: .center) { - Text("Suggestions") - .default_text_style_800(styleSize: 16) - Spacer() + suggestionsList } - .padding(.vertical, 10) - .padding(.horizontal, 16) - - suggestionsList } + .frame(maxWidth: .infinity) + } + .background(.white) + + if !startCallViewModel.participants.isEmpty { + startCallPopup + .background(.black.opacity(0.65)) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + isMessageTextFocused = true + } + } + } + + if startCallViewModel.operationInProgress { + PopupLoadingView() + .background(.black.opacity(0.65)) + .onDisappear { + isShowStartCallFragment.toggle() + } } - .frame(maxWidth: .infinity) } - .background(.white) + .navigationBarHidden(true) } - .navigationBarHidden(true) } @Sendable private func delayColor() async { @@ -343,6 +403,72 @@ struct StartCallFragment: View { .listRowSeparator(.hidden) } } + + var startCallPopup: some View { + GeometryReader { geometry in + VStack(alignment: .leading) { + Text("history_group_call_start_dialog_set_subject") + .default_text_style_800(styleSize: 16) + .frame(alignment: .leading) + .padding(.bottom, 2) + + TextField("history_group_call_start_dialog_subject_hint", text: $startCallViewModel.messageText) + .default_text_style(styleSize: 15) + .frame(height: 25) + .padding(.horizontal, 20) + .padding(.vertical, 15) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(isMessageTextFocused ? Color.orangeMain500 : Color.gray200, lineWidth: 1) + ) + .padding(.bottom) + .focused($isMessageTextFocused) + + Button(action: { + startCallViewModel.participants.removeAll() + }, label: { + Text("Cancel") + .default_text_style_orange_600(styleSize: 20) + .frame(height: 35) + .frame(maxWidth: .infinity) + }) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(Color.orangeMain500, lineWidth: 1) + ) + .padding(.bottom, 10) + + Button(action: { + startCallViewModel.createGroupCall() + }, label: { + Text("Confirm") + .default_text_style_white_600(styleSize: 20) + .frame(height: 35) + .frame(maxWidth: .infinity) + }) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(startCallViewModel.messageText.isEmpty ? Color.orangeMain100 : Color.orangeMain500) + .cornerRadius(60) + .disabled(startCallViewModel.messageText.isEmpty) + } + .padding(.horizontal, 20) + .padding(.vertical, 20) + .background(.white) + .cornerRadius(20) + .padding(.horizontal) + .frame(maxHeight: .infinity) + .shadow(color: Color.orangeMain500, radius: 0, x: 0, y: 2) + .frame(maxWidth: sharedMainViewModel.maxWidth) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + } + } } #Preview { diff --git a/Linphone/UI/Main/History/Fragments/StartGroupCallFragment.swift b/Linphone/UI/Main/History/Fragments/StartGroupCallFragment.swift new file mode 100644 index 000000000..6e254127f --- /dev/null +++ b/Linphone/UI/Main/History/Fragments/StartGroupCallFragment.swift @@ -0,0 +1,24 @@ +// +// StartGroupCallFragment.swift +// Linphone +// +// Created by Benoît Martins on 24/06/2024. +// + +import SwiftUI + +struct StartGroupCallFragment: View { + @ObservedObject var startCallViewModel: StartCallViewModel + @State var addParticipantsViewModel = AddParticipantsViewModel() + + var body: some View { + AddParticipantsFragment(addParticipantsViewModel: addParticipantsViewModel, confirmAddParticipantsFunc: startCallViewModel.addParticipants) + .onAppear { + addParticipantsViewModel.participantsToAdd = startCallViewModel.participants + } + } +} + +#Preview { + StartGroupCallFragment(startCallViewModel: StartCallViewModel()) +} diff --git a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift index 0a41a9141..353633dd1 100644 --- a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift @@ -18,16 +18,147 @@ */ import linphonesw +import Combine class StartCallViewModel: ObservableObject { + static let TAG = "[StartCallViewModel]" + + private var coreContext = CoreContext.shared + @Published var searchField: String = "" var domain: String = "" + @Published var messageText: String = "" + + @Published var participants: [SelectedAddressModel] = [] + + @Published var operationInProgress: Bool = false + + private var conferenceSuscriptions = Set() + init() { - CoreContext.shared.doOnCoreQueue { core in + coreContext.doOnCoreQueue { core in self.domain = core.defaultAccount?.params?.domain ?? "" } } + + func addParticipants(participantsToAdd: [SelectedAddressModel]) { + var list = participants + for selectedAddr in participantsToAdd { + if let found = list.first(where: { $0.address.weakEqual(address2: selectedAddr.address) }) { + Log.info("\(ScheduleMeetingViewModel.TAG) Participant \(found.address.asStringUriOnly()) already in list, skipping") + continue + } + + list.append(selectedAddr) + Log.info("\(ScheduleMeetingViewModel.TAG) Added participant \(selectedAddr.address.asStringUriOnly())") + } + Log.info("\(ScheduleMeetingViewModel.TAG) [\(list.count - participants.count) participants added, now there are \(list.count) participants in list") + + participants = list + } + + func createGroupCall() { + coreContext.doOnCoreQueue { core in + let account = core.defaultAccount + if account == nil { + Log.error( + "\(StartCallViewModel.TAG) No default account found, can't create group call!" + ) + return + } + + DispatchQueue.main.async { + self.operationInProgress = true + } + + do { + let conferenceInfo = try Factory.Instance.createConferenceInfo() + conferenceInfo.organizer = account!.params?.identityAddress + conferenceInfo.subject = self.messageText + + var participantsList: [ParticipantInfo] = [] + self.participants.forEach { participant in + do { + let info = try Factory.Instance.createParticipantInfo(address: participant.address) + // For meetings, all participants must have Speaker role + info.role = Participant.Role.Speaker + participantsList.append(info) + } catch let error { + Log.error( + "\(StartCallViewModel.TAG) Can't create ParticipantInfo: \(error)" + ) + } + } + + self.participants.removeAll() + + conferenceInfo.addParticipantInfos(participantInfos: participantsList) + + Log.info( + "\(StartCallViewModel.TAG) Creating group call with subject \(self.messageText) and \(participantsList.count) participant(s)" + ) + + let conferenceScheduler = try core.createConferenceScheduler() + self.conferenceAddDelegate(core: core, conferenceScheduler: conferenceScheduler) + conferenceScheduler.account = account + // Will trigger the conference creation/update automatically + conferenceScheduler.info = conferenceInfo + } catch let error { + Log.error( + "\(StartCallViewModel.TAG) createGroupCall: \(error)" + ) + } + } + } + + func conferenceAddDelegate(core: Core, conferenceScheduler: ConferenceScheduler) { + self.conferenceSuscriptions.insert(conferenceScheduler.publisher?.onStateChanged?.postOnCoreQueue { + (conferenceScheduler: ConferenceScheduler, state: ConferenceScheduler.State) in + Log.info("\(StartCallViewModel.TAG) Conference scheduler state is \(state)") + if state == ConferenceScheduler.State.Ready { + self.conferenceSuscriptions.removeAll() + + let conferenceAddress = conferenceScheduler.info?.uri + if conferenceAddress != nil { + Log.info( + "\(StartCallViewModel.TAG) Conference info created, address is \(conferenceAddress?.asStringUriOnly() ?? "Error conference address")" + ) + + self.startVideoCall(core: core, conferenceAddress: conferenceAddress!) + } else { + Log.error("\(StartCallViewModel.TAG) Conference info URI is null!") + + ToastViewModel.shared.toastMessage = "Failed_to_create_group_call_error" + ToastViewModel.shared.displayToast = true + } + + DispatchQueue.main.async { + self.operationInProgress = false + } + } else if state == ConferenceScheduler.State.Error { + self.conferenceSuscriptions.removeAll() + Log.error("\(StartCallViewModel.TAG) Failed to create group call!") + + ToastViewModel.shared.toastMessage = "Failed_to_create_group_call_error" + ToastViewModel.shared.displayToast = true + + DispatchQueue.main.async { + self.operationInProgress = false + } + } + }) + } + + func startVideoCall(core: Core, conferenceAddress: Address) { + do { + TelecomManager.shared.doCallWithCore(addr: conferenceAddress, isVideo: true, isConference: true) + } catch let error { + Log.error( + "\(StartCallViewModel.TAG) StartVideoCall: \(error)" + ) + } + } } diff --git a/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift b/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift index 616692b07..3db4b0bc7 100644 --- a/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/AddParticipantsViewModel.swift @@ -32,7 +32,9 @@ class AddParticipantsViewModel: ObservableObject { } else { Log.info("[\(AddParticipantsViewModel.TAG)] Adding participant \(addr.asStringUriOnly()) to selection") ContactAvatarModel.getAvatarModelFromAddress(address: addr) { avatarResult in - self.participantsToAdd.append(SelectedAddressModel(addr: addr, avModel: avatarResult)) + DispatchQueue.main.async { + self.participantsToAdd.append(SelectedAddressModel(addr: addr, avModel: avatarResult)) + } } } } diff --git a/Linphone/Utils/Extensions/UIApplicationExtension.swift b/Linphone/Utils/Extensions/UIApplicationExtension.swift index 09f1d5d54..6ed48ae78 100644 --- a/Linphone/Utils/Extensions/UIApplicationExtension.swift +++ b/Linphone/Utils/Extensions/UIApplicationExtension.swift @@ -35,4 +35,8 @@ extension UIApplication { } return nil } + + func endEditing() { + sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } }