From 924a7413fa40b935909708a5bdeaeb12d68300d2 Mon Sep 17 00:00:00 2001 From: QuentinArguillere Date: Fri, 19 Apr 2024 15:41:53 +0200 Subject: [PATCH] Integrate meetingsview in the main view, and implement date and participant selection in meeting scheduling --- Linphone/LinphoneApp.swift | 5 +- Linphone/UI/Main/ContentView.swift | 62 +- .../Fragments/AddParticipantsFragment.swift | 193 ++++-- .../Fragments/ScheduleMeetingFragment.swift | 581 +++++++++++++----- .../ViewModel/ScheduleMeetingViewModel.swift | 62 +- 5 files changed, 641 insertions(+), 262 deletions(-) diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 2bc7b715f..da5d206c1 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -79,6 +79,7 @@ struct LinphoneApp: App { @State private var meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel? @State private var conversationsListViewModel: ConversationsListViewModel? @State private var conversationViewModel: ConversationViewModel? + @State private var scheduleMeetingViewModel: ScheduleMeetingViewModel? var body: some Scene { WindowGroup { @@ -112,7 +113,8 @@ struct LinphoneApp: App { callViewModel: callViewModel!, meetingWaitingRoomViewModel: meetingWaitingRoomViewModel!, conversationsListViewModel: conversationsListViewModel!, - conversationViewModel: conversationViewModel! + conversationViewModel: conversationViewModel!, + scheduleMeetingViewModel: scheduleMeetingViewModel! ) } else { SplashScreen() @@ -129,6 +131,7 @@ struct LinphoneApp: App { meetingWaitingRoomViewModel = MeetingWaitingRoomViewModel() conversationsListViewModel = ConversationsListViewModel() conversationViewModel = ConversationViewModel() + scheduleMeetingViewModel = ScheduleMeetingViewModel() } } }.onChange(of: scenePhase) { newPhase in diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index 7f7fcae8e..f22199412 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -43,6 +43,7 @@ struct ContentView: View { @ObservedObject var meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel @ObservedObject var conversationsListViewModel: ConversationsListViewModel @ObservedObject var conversationViewModel: ConversationViewModel + @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel @State var index = 0 @State private var orientation = UIDevice.current.orientation @@ -62,6 +63,8 @@ struct ContentView: View { @State var fullscreenVideo = false @State var isShowCallsListFragment = false + @State var isShowScheduleMeetingFragment = false + var body: some View { let pub = NotificationCenter.default .publisher(for: NSNotification.Name("ContactLoaded")) @@ -229,7 +232,7 @@ struct ContentView: View { openMenu() } - Text(index == 0 ? "Contacts" : (index == 1 ? "Calls" : "Conversations")) + Text(index == 0 ? "Contacts" : (index == 1 ? "Calls" : (index == 2 ? "Conversations" : "Meetings"))) .default_text_style_white_800(styleSize: 20) .padding(.leading, 10) @@ -456,6 +459,11 @@ struct ContentView: View { ) } else if self.index == 2 { ConversationsView(conversationViewModel: conversationViewModel, conversationsListViewModel: conversationsListViewModel) + } else if self.index == 3 { + MeetingsView( + scheduleMeetingViewModel: scheduleMeetingViewModel, + isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment + ) } } .frame(maxWidth: @@ -505,7 +513,7 @@ struct ContentView: View { } }) .padding(.top) - .frame(width: 100) + .frame(width: 66) Spacer() @@ -546,15 +554,15 @@ struct ContentView: View { .frame(width: 25, height: 25) if self.index == 1 { Text("Calls") - .default_text_style_700(styleSize: 10) + .default_text_style_700(styleSize: 9) } else { Text("Calls") - .default_text_style(styleSize: 10) + .default_text_style(styleSize: 9) } } }) .padding(.top) - .frame(width: 100) + .frame(width: 66) } Spacer() @@ -594,17 +602,42 @@ struct ContentView: View { if self.index == 2 { Text("Conversations") - .default_text_style_700(styleSize: 10) + .default_text_style_700(styleSize: 9) } else { Text("Conversations") - .default_text_style(styleSize: 10) + .default_text_style(styleSize: 9) } } }) .padding(.top) - .frame(width: 100) + .frame(width: 66) } + Spacer() + Button(action: { + self.index = 3 + contactViewModel.indexDisplayedFriend = nil + historyViewModel.displayedCall = nil + conversationViewModel.displayedConversation = nil + }, label: { + VStack { + Image("meetings") + .renderingMode(.template) + .resizable() + .foregroundStyle(self.index == 3 ? Color.orangeMain500 : Color.grayMain2c600) + .frame(width: 25, height: 25) + if self.index == 3 { + Text("Meetings") + .default_text_style_700(styleSize: 9) + } else { + Text("Meetings") + .default_text_style(styleSize: 9) + } + } + }) + .padding(.top) + .frame(width: 66) + Spacer() } } @@ -857,6 +890,16 @@ struct ContentView: View { } } + if isShowScheduleMeetingFragment { + ScheduleMeetingFragment( + scheduleMeetingViewModel: scheduleMeetingViewModel, + isShowScheduleMeetingFragment: $isShowScheduleMeetingFragment + ) + .zIndex(3) + .transition(.move(edge: .bottom)) + .onAppear { + } + } if telecomManager.meetingWaitingRoomDisplayed { MeetingWaitingRoomFragment(meetingWaitingRoomViewModel: meetingWaitingRoomViewModel) .zIndex(3) @@ -923,7 +966,8 @@ struct ContentView: View { callViewModel: CallViewModel(), meetingWaitingRoomViewModel: MeetingWaitingRoomViewModel(), conversationsListViewModel: ConversationsListViewModel(), - conversationViewModel: ConversationViewModel() + conversationViewModel: ConversationViewModel(), + scheduleMeetingViewModel: ScheduleMeetingViewModel() ) } // swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift index 8775ce5d8..96e2ecebd 100644 --- a/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/AddParticipantsFragment.swift @@ -21,8 +21,6 @@ struct AddParticipantsFragment: View { @ObservedObject var scheduleMeetingViewModel: ScheduleMeetingViewModel @State private var delayedColor = Color.white - - @Binding var isShowAddParticipantFragment: Bool @FocusState var isSearchFieldFocused: Bool var body: some View { @@ -52,8 +50,8 @@ struct AddParticipantsFragment: View { .padding(.top, 2) .padding(.leading, -10) .onTapGesture { - isShowAddParticipantFragment = false scheduleMeetingViewModel.participantsToAdd = [] + dismiss() } VStack(alignment: .leading, spacing: 3) { @@ -72,26 +70,31 @@ struct AddParticipantsFragment: View { .padding(.bottom, 4) .background(.white) - ForEach(0.. UIScreen.main.bounds.size.height) { + Rectangle() + .foregroundColor(delayedColor) + .edgesIgnoringSafeArea(.top) + .frame(height: 1) + .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 { + withAnimation { + isShowScheduleMeetingFragment.toggle() + } + } + + Text("New meeting" ) + .multilineTextAlignment(.leading) + .default_text_style_orange_800(styleSize: 16) + + Spacer() + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + /* + HStack { + Spacer() + HStack(alignment: .center) { + Button(action: { + scheduleMeetingViewModel.isBroadcastSelected.toggle() + }, label: { + Image("users-three") + .renderingMode(.template) + .resizable() + .foregroundStyle(scheduleMeetingViewModel.isBroadcastSelected ? .white : Color.orangeMain500) + .frame(width: 25, height: 25) + }) + Text("Meeting") + .default_text_style_orange_500( styleSize: 15) + .foregroundStyle(scheduleMeetingViewModel.isBroadcastSelected ? .white : Color.orangeMain500) + } + .padding(.horizontal, 40) + .padding(.vertical, 10) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(Color.orangeMain500, lineWidth: 1) + .background(scheduleMeetingViewModel.isBroadcastSelected ? Color.orangeMain500 : Color.white) + ) + Spacer() + + HStack(alignment: .center) { + Button(action: { + scheduleMeetingViewModel.isBroadcastSelected.toggle() + }, label: { + Image("slideshow") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25) + }) + + Text("Broadcast") + .default_text_style_orange_500( styleSize: 15) + } + .padding(.horizontal, 40) + .padding(.vertical, 10) + .cornerRadius(60) + .overlay( + RoundedRectangle(cornerRadius: 60) + .inset(by: 0.5) + .stroke(Color.orangeMain500, lineWidth: 1) + ) + Spacer() + } + */ + HStack(alignment: .center, spacing: 8) { + Image("users-three") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 24, height: 24) + .padding(.leading, 16) + TextField("Subject", text: $scheduleMeetingViewModel.subject) + .default_text_style_700(styleSize: 20) + .frame(height: 29, alignment: .leading) + Spacer() + } + Rectangle() - .foregroundColor(delayedColor) - .edgesIgnoringSafeArea(.top) - .frame(height: 0) - .task(delayColor) - } else if idiom != .pad && !(orientation == .landscapeLeft || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height) { - Rectangle() - .foregroundColor(delayedColor) - .edgesIgnoringSafeArea(.top) + .foregroundStyle(.clear) .frame(height: 1) - .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 { + .background(Color.gray200) + + HStack(alignment: .center, spacing: 8) { + Image("clock") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c800) + .frame(width: 24, height: 24) + .padding(.leading, 16) + Text(scheduleMeetingViewModel.fromDateStr) + .fontWeight(.bold) + .default_text_style_500(styleSize: 16) + .onTapGesture { + setFromDate = true + selectedDate = scheduleMeetingViewModel.fromDate + showDatePicker.toggle() + } + Spacer() + } + + if !scheduleMeetingViewModel.allDayMeeting { + HStack(spacing: 8) { + Text(scheduleMeetingViewModel.fromTime) + .fontWeight(.bold) + .padding(.leading, 48) + .frame(height: 29, alignment: .leading) + .default_text_style_500(styleSize: 16) + .opacity(scheduleMeetingViewModel.allDayMeeting ? 0 : 1) + .onTapGesture { + setFromDate = true + selectedDate = scheduleMeetingViewModel.fromDate + showTimePicker.toggle() + } + Text(scheduleMeetingViewModel.toTime) + .fontWeight(.bold) + .padding(.leading, 8) + .frame(height: 29, alignment: .leading) + .default_text_style_500(styleSize: 16) + .opacity(scheduleMeetingViewModel.allDayMeeting ? 0 : 1) + .onTapGesture { + setFromDate = false + selectedDate = scheduleMeetingViewModel.toDate + showTimePicker.toggle() + } + Spacer() + Toggle("", isOn: $scheduleMeetingViewModel.allDayMeeting) + .labelsHidden() + .tint(Color.orangeMain300) + Text("All day") + .default_text_style_500(styleSize: 16) + .padding(.trailing, 16) } + } else { + HStack(alignment: .center, spacing: 8) { + Image("clock") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c800) + .frame(width: 24, height: 24) + .padding(.leading, 16) + Text(scheduleMeetingViewModel.toDateStr) + .fontWeight(.bold) + .default_text_style_500(styleSize: 16) + .onTapGesture { + setFromDate = false + selectedDate = scheduleMeetingViewModel.toDate + showDatePicker.toggle() + } + Spacer() + Toggle("", isOn: $scheduleMeetingViewModel.allDayMeeting) + .labelsHidden() + .tint(Color.orangeMain300) + Text("All day") + .default_text_style_500(styleSize: 16) + .padding(.trailing, 16) } + } + HStack(alignment: .center, spacing: 8) { + Image("earth") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c800) + .frame(width: 24, height: 24) + .padding(.leading, 16) + Text("TODO : timezone") + .fontWeight(.bold) + .padding(.leading, 8) + .default_text_style_500(styleSize: 16) + Spacer() + } - Text("New meeting" ) - .multilineTextAlignment(.leading) - .default_text_style_orange_800(styleSize: 16) + HStack(alignment: .center, spacing: 8) { + Image("arrow-clockwise") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c800) + .frame(width: 24, height: 24) + .padding(.leading, 16) + Text("TODO : repeat") + .fontWeight(.bold) + .padding(.leading, 8) + .default_text_style_500(styleSize: 16) + Spacer() + } + Rectangle() + .foregroundStyle(.clear) + .frame(height: 1) + .background(Color.gray200) + + HStack(alignment: .top, spacing: 8) { + Image("note") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 24, height: 24) + .padding(.leading, 16) + + TextField("Add a description", text: $scheduleMeetingViewModel.description) + .default_text_style_700(styleSize: 16) + } + + Rectangle() + .foregroundStyle(.clear) + .frame(height: 1) + .background(Color.gray200) + + VStack { + NavigationLink(destination: { + AddParticipantsFragment(scheduleMeetingViewModel: scheduleMeetingViewModel) + }, label: { + HStack(alignment: .center, spacing: 8) { + Image("users") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 24, height: 24) + .padding(.leading, 16) + + Text("Add participants") + .default_text_style_700(styleSize: 16) + .frame(height: 29, alignment: .leading) + Spacer() + } + }) + if !scheduleMeetingViewModel.participants.isEmpty { + ScrollView { + ForEach(0.. some View { + return GeometryReader { geometry in + VStack(alignment: .leading) { + Text("Select \(setFromDate ? "start" : "end") \(isTimeSelection ? "time" : "date")") + .default_text_style_800(styleSize: 16) + .frame(alignment: .leading) + .padding(.bottom, 2) + + DatePicker( + "", + selection: $selectedDate, + in: Date.now..., + displayedComponents: isTimeSelection ? [.hourAndMinute] : [.date] + ) + .if(isTimeSelection) { view in + view.datePickerStyle(.wheel) + } + .datePickerStyle(.graphical) + .tint(Color.orangeMain500) + .padding(.bottom, 20) + .default_text_style(styleSize: 15) + + HStack { + Spacer() + Text("Cancel") + .default_text_style_orange_500(styleSize: 16) + .onTapGesture { + if isTimeSelection { + showTimePicker.toggle() + } else { + showDatePicker.toggle() + } + } + Text("Ok") + .default_text_style_orange_500(styleSize: 16) + .onTapGesture { + pickDate() + if isTimeSelection { + showTimePicker.toggle() + } else { + showDatePicker.toggle() + } + } + } + } + .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) + } + .background(.black.opacity(0.65)) + } + + func pickDate() { + let duration = scheduleMeetingViewModel.fromDate.distance(to: scheduleMeetingViewModel.toDate) + if setFromDate { + scheduleMeetingViewModel.fromDate = selectedDate + // If new startdate is after previous end date, bump up the end date + if selectedDate > scheduleMeetingViewModel.toDate { + scheduleMeetingViewModel.toDate = Calendar.current.date(byAdding: .second, value: Int(duration), to: selectedDate)! + } + } else { + scheduleMeetingViewModel.toDate = selectedDate + if selectedDate < scheduleMeetingViewModel.fromDate { + // If new end date is before the previous start date, bump down the start date to the earlier possible from current time + if (Date.now.distance(to: selectedDate) < duration) { + scheduleMeetingViewModel.fromDate = Date.now + } else { + scheduleMeetingViewModel.fromDate = Calendar.current.date(byAdding: .second, value: (-1)*Int(duration), to: selectedDate)! + } + } + } + scheduleMeetingViewModel.computeDateLabels() + scheduleMeetingViewModel.computeTimeLabels() } @Sendable private func delayColor() async { @@ -217,7 +487,8 @@ struct ScheduleMeetingFragment: View { } #Preview { - ScheduleMeetingFragment(scheduleMeetingViewModel: ScheduleMeetingViewModel()) + ScheduleMeetingFragment(scheduleMeetingViewModel: ScheduleMeetingViewModel() + , isShowScheduleMeetingFragment: .constant(true)) } // swiftlint:enable line_length diff --git a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift index 9015389fb..71260c046 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/ScheduleMeetingViewModel.swift @@ -21,11 +21,11 @@ import Foundation import linphonesw import Combine -class SelectedAddressModel { +class SelectedAddressModel: ObservableObject { var address: Address - var avatarModel: ContactAvatarModel? + var avatarModel: ContactAvatarModel - init (addr: Address, avModel: ContactAvatarModel?) { + init (addr: Address, avModel: ContactAvatarModel) { address = addr avatarModel = avModel } @@ -37,7 +37,7 @@ class ScheduleMeetingViewModel: ObservableObject { @Published var isBroadcastSelected: Bool = false @Published var showBroadcastHelp: Bool = false @Published var subject: String = "" - @Published var description: String = "aaaaaa aaaaaa" + @Published var description: String = "" @Published var allDayMeeting: Bool = false @Published var fromDateStr: String = "" @Published var fromTime: String = "" @@ -45,27 +45,30 @@ class ScheduleMeetingViewModel: ObservableObject { @Published var toTime: String = "" @Published var timezone: String = "" @Published var sendInvitations: Bool = true + @Published var participantsToAdd: [SelectedAddressModel] = [] @Published var participants: [SelectedAddressModel] = [] @Published var operationInProgress: Bool = false @Published var conferenceCreatedEvent: Bool = false + @Published var searchField: String = "" + var conferenceScheduler: ConferenceScheduler? private var mSchedulerSubscriptions = Set() var conferenceInfoToEdit: ConferenceInfo? - private var fromDate: Date - private var toDate: Date + @Published var fromDate: Date + @Published var toDate: Date init() { fromDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date.now)! - toDate = Calendar.current.date(byAdding: .hour, value: 1, to: fromDate)! + toDate = Calendar.current.date(byAdding: .hour, value: 2, to: Date.now)! computeDateLabels() computeTimeLabels() updateTimezone() } - private func computeDateLabels() { + func computeDateLabels() { var day = fromDate.formatted(Date.FormatStyle().weekday(.wide)) var dayNumber = fromDate.formatted(Date.FormatStyle().day(.twoDigits)) var month = fromDate.formatted(Date.FormatStyle().month(.wide)) @@ -79,7 +82,7 @@ class ScheduleMeetingViewModel: ObservableObject { Log.info("\(ScheduleMeetingViewModel.TAG)) computed end date is \(toDateStr)") } - private func computeTimeLabels() { + func computeTimeLabels() { let formatter = DateFormatter() formatter.dateFormat = Locale.current.identifier == "fr_FR" ? "HH:mm" : "h:mm a" fromTime = formatter.string(from: fromDate) @@ -90,29 +93,28 @@ class ScheduleMeetingViewModel: ObservableObject { // TODO } - func addParticipants(toAdd: [String]) { - CoreContext.shared.doOnCoreQueue { _ in - var list = self.participants - for participant in toAdd { - if let address = try? Factory.Instance.createAddress(addr: participant) { - if let found = list.first(where: { $0.address.weakEqual(address2: address) }) { - Log.info("\(ScheduleMeetingViewModel.TAG) Participant \(found.address.asStringUriOnly()) already in list, skipping") - continue - } - - let avatarModel = ContactAvatarModel.getAvatarModelFromAddress(address: address) - list.append(SelectedAddressModel(addr: address, avModel: avatarModel)) - Log.info("\(ScheduleMeetingViewModel.TAG) Added participant \(address.asStringUriOnly())") - } else { - Log.error("\(ScheduleMeetingViewModel.TAG) Failed to parse \(participant) as address!") - } + func selectParticipant(addr: Address) { + if let idx = participantsToAdd.firstIndex(where: {$0.address.weakEqual(address2: addr)}) { + participantsToAdd.remove(at: idx) + } else { + participantsToAdd.append(SelectedAddressModel(addr: addr, avModel: ContactAvatarModel.getAvatarModelFromAddress(address: addr))) + } + } + func addParticipants() { + 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 } - Log.info("\(ScheduleMeetingViewModel.TAG) [\(toAdd.count) participants added, now there are \(list.count) participants in list") - DispatchQueue.main.async { - self.participants = list - } + 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 + participantsToAdd = [] } private func fillConferenceInfo(confInfo: ConferenceInfo) { @@ -169,7 +171,7 @@ class ScheduleMeetingViewModel: ObservableObject { Log.info("\(ScheduleMeetingViewModel.TAG) User didn't asked for invitations to be sent") DispatchQueue.main.async { self.operationInProgress = false - self.conferenceCreatedEvent = false + self.conferenceCreatedEvent = true } } }