diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index d4f18a7c6..9da8dc9a4 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -161,7 +161,7 @@ struct ChatBubbleView: View { func messageAttachments() -> some View { if message.attachments.count == 1 { if message.attachments.first!.type == .image || message.attachments.first!.type == .gif || message.attachments.first!.type == .video { - let result = imageDimensions(url: message.attachments.first!.full.absoluteString) + let result = imageDimensions(url: message.attachments.first!.thumbnail.absoluteString) ZStack { Rectangle() .fill(Color(.white)) @@ -186,31 +186,59 @@ struct ChatBubbleView: View { } if message.attachments.first!.type == .image || message.attachments.first!.type == .video { - AsyncImage(url: message.attachments.first!.full) { image in - ZStack { - image - .resizable() - .interpolation(.medium) - .aspectRatio(contentMode: .fill) - - if message.attachments.first!.type == .video { - Image("play-fill") - .renderingMode(.template) + if #available(iOS 16.0, *) { + AsyncImage(url: message.attachments.first!.thumbnail) { image in + ZStack { + image .resizable() - .foregroundStyle(.white) - .frame(width: 40, height: 40, alignment: .leading) + .interpolation(.medium) + .aspectRatio(contentMode: .fill) + + if message.attachments.first!.type == .video { + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 40, height: 40, alignment: .leading) + } } + } placeholder: { + ProgressView() + } + .layoutPriority(-1) + } else { + AsyncImage(url: message.attachments.first!.thumbnail) { image in + ZStack { + image + .resizable() + .interpolation(.medium) + .aspectRatio(contentMode: .fill) + + if message.attachments.first!.type == .video { + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 40, height: 40, alignment: .leading) + } + } + } placeholder: { + ProgressView() } - } placeholder: { - ProgressView() - } - .id(UUID()) - .layoutPriority(-1) - } else if message.attachments.first!.type == .gif { - GifImageView(message.attachments.first!.full) .id(UUID()) .layoutPriority(-1) - .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } else if message.attachments.first!.type == .gif { + if #available(iOS 16.0, *) { + GifImageView(message.attachments.first!.thumbnail) + .layoutPriority(-1) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + GifImageView(message.attachments.first!.thumbnail) + .id(UUID()) + .layoutPriority(-1) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } } } .clipShape(RoundedRectangle(cornerRadius: 4)) @@ -227,29 +255,51 @@ struct ChatBubbleView: View { .fill(Color(.white)) .frame(width: 120, height: 120) - AsyncImage(url: attachment.full) { image in - ZStack { - image - .resizable() - .interpolation(.medium) - .aspectRatio(contentMode: .fill) - - if attachment.type == .video { - Image("play-fill") - .renderingMode(.template) + if #available(iOS 16.0, *) { + AsyncImage(url: attachment.thumbnail) { image in + ZStack { + image .resizable() - .foregroundStyle(.white) - .frame(width: 40, height: 40, alignment: .leading) + .interpolation(.medium) + .aspectRatio(contentMode: .fill) + + if attachment.type == .video { + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 40, height: 40, alignment: .leading) + } } + } placeholder: { + ProgressView() } - } placeholder: { - ProgressView() + .layoutPriority(-1) + } else { + AsyncImage(url: attachment.thumbnail) { image in + ZStack { + image + .resizable() + .interpolation(.medium) + .aspectRatio(contentMode: .fill) + + if attachment.type == .video { + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 40, height: 40, alignment: .leading) + } + } + } placeholder: { + ProgressView() + } + .id(UUID()) + .layoutPriority(-1) } - .id(UUID()) - .layoutPriority(-1) } .clipShape(RoundedRectangle(cornerRadius: 4)) - .clipped() + .contentShape(Rectangle()) } } .frame( diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 011f85a35..aee68444a 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -44,6 +44,13 @@ struct ConversationFragment: View { @State private var displayFloatingButton = false + @State private var isShowPhotoLibrary = false + @State private var isShowCamera = false + + @State private var mediasToSend: [Attachment] = [] + @State private var mediasIsLoading = false + @State private var maxMediaCount = 12 + var body: some View { NavigationView { GeometryReader { geometry in @@ -313,6 +320,93 @@ struct ConversationFragment: View { } } + if !mediasToSend.isEmpty || mediasIsLoading { + ZStack(alignment: .top) { + HStack { + if mediasIsLoading { + HStack { + Spacer() + + ProgressView() + + Spacer() + } + .frame(height: 120) + } + + LazyVGrid(columns: [ + GridItem(.adaptive(minimum: 100), spacing: 1) + ], spacing: 3) { + ForEach(mediasToSend, id: \.id) { attachment in + ZStack { + Rectangle() + .fill(Color(.white)) + .frame(width: 100, height: 100) + + AsyncImage(url: attachment.thumbnail) { image in + ZStack { + image + .resizable() + .interpolation(.medium) + .aspectRatio(contentMode: .fill) + + if attachment.type == .video { + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 40, height: 40, alignment: .leading) + } + } + } placeholder: { + ProgressView() + } + .layoutPriority(-1) + .onTapGesture { + if mediasToSend.count == 1 { + withAnimation { + mediasToSend = [] + } + } else { + guard let index = self.mediasToSend.firstIndex(of: attachment) else { return } + self.mediasToSend.remove(at: index) + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 4)) + .contentShape(Rectangle()) + } + } + .frame( + width: geometry.size.width > 0 && CGFloat(102 * mediasToSend.count) > geometry.size.width - 20 + ? 102 * floor(CGFloat(geometry.size.width - 20) / 102) + : CGFloat(102 * mediasToSend.count) + ) + } + .frame(maxWidth: .infinity) + .padding(.all, mediasToSend.isEmpty ? 0 : 10) + .background(Color.gray100) + + if !mediasIsLoading { + HStack { + Spacer() + + Button(action: { + withAnimation { + mediasToSend = [] + } + }, label: { + Image("x") + .resizable() + .frame(width: 30, height: 30, alignment: .leading) + .padding(.all, 10) + }) + } + } + } + .transition(.move(edge: .bottom)) + } + HStack(spacing: 0) { Button { } label: { @@ -327,26 +421,31 @@ struct ConversationFragment: View { .padding(.horizontal, isMessageTextFocused ? 0 : 2) Button { + self.isShowPhotoLibrary = true + self.mediasIsLoading = true } label: { Image("paperclip") .renderingMode(.template) .resizable() - .foregroundStyle(Color.grayMain2c500) + .foregroundStyle(maxMediaCount <= mediasToSend.count || mediasIsLoading ? Color.grayMain2c300 : Color.grayMain2c500) .frame(width: isMessageTextFocused ? 0 : 28, height: isMessageTextFocused ? 0 : 28, alignment: .leading) .padding(.all, isMessageTextFocused ? 0 : 6) .padding(.top, 4) + .disabled(maxMediaCount <= mediasToSend.count || mediasIsLoading) } .padding(.horizontal, isMessageTextFocused ? 0 : 2) Button { + self.isShowCamera = true } label: { Image("camera") .renderingMode(.template) .resizable() - .foregroundStyle(Color.grayMain2c500) + .foregroundStyle(maxMediaCount <= mediasToSend.count || mediasIsLoading ? Color.grayMain2c300 : Color.grayMain2c500) .frame(width: isMessageTextFocused ? 0 : 28, height: isMessageTextFocused ? 0 : 28, alignment: .leading) .padding(.all, isMessageTextFocused ? 0 : 6) .padding(.top, 4) + .disabled(maxMediaCount <= mediasToSend.count || mediasIsLoading) } .padding(.horizontal, isMessageTextFocused ? 0 : 2) @@ -437,6 +536,28 @@ struct ConversationFragment: View { .onDisappear { conversationViewModel.removeConversationDelegate() } + .sheet(isPresented: $isShowPhotoLibrary) { + PhotoPicker(filter: nil, limit: maxMediaCount - mediasToSend.count) { results in + PhotoPicker.convertToAttachmentArray(fromResults: results) { mediasOrNil, errorOrNil in + if let error = errorOrNil { + print(error) + } + + if let medias = mediasOrNil { + mediasToSend.append(contentsOf: medias) + } + + self.mediasIsLoading = false + } + } + .edgesIgnoringSafeArea(.all) + } + /* + .fullScreenCover(isPresented: $isShowCamera) { + ImagePicker(selectedImage: self.$image, sourceType: .camera) + .edgesIgnoringSafeArea(.all) + } + */ } } .navigationViewStyle(.stack) @@ -456,6 +577,48 @@ extension UIApplication { } } +struct ImagePicker: UIViewControllerRepresentable { + @Binding var selectedImage: UIImage + @Environment(\.presentationMode) private var presentationMode + + var sourceType: UIImagePickerController.SourceType = .photoLibrary + + final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + + var parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + + if let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage { + parent.selectedImage = image + } + + parent.presentationMode.wrappedValue.dismiss() + } + } + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { + let imagePicker = UIImagePickerController() + imagePicker.allowsEditing = false + imagePicker.sourceType = sourceType + imagePicker.delegate = context.coordinator + + return imagePicker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) { + + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } +} + /* #Preview { ConversationFragment(conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), sections: [MessagesSection], ids: [""]) diff --git a/Linphone/Utils/Extensions/ViewExtension.swift b/Linphone/Utils/Extensions/ViewExtension.swift index 5f9765b89..ef1dbeff7 100644 --- a/Linphone/Utils/Extensions/ViewExtension.swift +++ b/Linphone/Utils/Extensions/ViewExtension.swift @@ -32,4 +32,6 @@ extension View { self } } + + func apply(@ViewBuilder _ block: (Self) -> V) -> V { block(self) } } diff --git a/Linphone/Utils/PhotoPicker.swift b/Linphone/Utils/PhotoPicker.swift index 8e4c951aa..6811d85b7 100644 --- a/Linphone/Utils/PhotoPicker.swift +++ b/Linphone/Utils/PhotoPicker.swift @@ -23,14 +23,16 @@ import PhotosUI struct PhotoPicker: UIViewControllerRepresentable { typealias UIViewControllerType = PHPickerViewController - let filter: PHPickerFilter + let filter: PHPickerFilter? var limit: Int = 0 let onComplete: ([PHPickerResult]) -> Void func makeUIViewController(context: Context) -> PHPickerViewController { var configuration = PHPickerConfiguration() - configuration.filter = filter + if filter != nil { + configuration.filter = filter + } configuration.selectionLimit = limit let controller = PHPickerViewController(configuration: configuration) @@ -63,6 +65,89 @@ struct PhotoPicker: UIViewControllerRepresentable { } } + static func convertToAttachmentArray(fromResults results: [PHPickerResult], onComplete: @escaping ([Attachment]?, Error?) -> Void) { + var medias = [Attachment]() + + let dispatchGroup = DispatchGroup() + for result in results { + dispatchGroup.enter() + let itemProvider = result.itemProvider + if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { urlFile, error in + if urlFile != nil { + do { + let dataResult = try Data(contentsOf: urlFile!) + let urlImage = self.saveMedia(name: urlFile!.lastPathComponent, data: dataResult, type: .image) + if urlImage != nil { + let attachment = Attachment(id: UUID().uuidString, url: urlImage!, type: .image) + medias.append(attachment) + } + } catch { + + } + } + + dispatchGroup.leave() + } + } else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { urlFile, error in + if urlFile != nil { + do { + let dataResult = try Data(contentsOf: urlFile!) + let urlImage = self.saveMedia(name: urlFile!.lastPathComponent, data: dataResult, type: .video) + let urlThumbnail = getURLThumbnail(name: urlFile!.lastPathComponent) + + if urlImage != nil { + let attachment = Attachment(id: UUID().uuidString, thumbnail: urlThumbnail, full: urlImage!, type: .video) + medias.append(attachment) + } + } catch { + + } + } + dispatchGroup.leave() + } + } + } + + dispatchGroup.notify(queue: .main) { + onComplete(medias, nil) + } + } + + static func saveMedia(name: String, data: Data, type: AttachmentType) -> URL? { + do { + let path = FileManager.default.temporaryDirectory.appendingPathComponent((name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "")) + + let decodedData: () = try data.write(to: path) + + if type == .video { + let asset = AVURLAsset(url: path, options: nil) + let imgGenerator = AVAssetImageGenerator(asset: asset) + imgGenerator.appliesPreferredTrackTransform = true + let cgImage = try imgGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil) + let thumbnail = UIImage(cgImage: cgImage) + + guard let data = thumbnail.jpegData(compressionQuality: 1) ?? thumbnail.pngData() else { + return nil + } + + let urlName = FileManager.default.temporaryDirectory.appendingPathComponent("preview_" + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".png") + + let decodedData: () = try data.write(to: urlName) + } + + return path + } catch let error { + print("*** Error generating thumbnail: \(error.localizedDescription)") + return nil + } + } + + static func getURLThumbnail(name: String) -> URL { + return FileManager.default.temporaryDirectory.appendingPathComponent("preview_" + (name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "") + ".png") + } + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} func makeCoordinator() -> Coordinator {