diff --git a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings index 714c27ba..d79222a4 100644 --- a/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings +++ b/ArtemisKit/Sources/Messages/Resources/en.lproj/Localizable.strings @@ -26,6 +26,7 @@ "code" = "Code"; "link" = "Link"; "uploadImage" = "Upload Image"; +"uploadFile" = "Upload File"; // MARK: SendMessageMentionContentView "members" = "Members"; diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift index c2ef0032..c25a896d 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesService.swift @@ -49,9 +49,9 @@ protocol MessagesService { func sendAnswerMessage(for courseId: Int, message: Message, content: String) async -> NetworkResponse /** - * Perform a post request for uploading a jpeg image in a specific conversation to the server. - */ - func uploadImage(for courseId: Int, and conversationId: Int64, image: Data) async -> DataState + * Perform a post request for uploading a file in a specific conversation to the server. + */ + func uploadFile(for courseId: Int, and conversationId: Int64, file: Data, filename: String, mimeType: String) async -> DataState /** * Perform a delete request for a message in a specific course to the server. diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift index b953189d..cac82b7e 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceImpl.swift @@ -233,30 +233,32 @@ struct MessagesServiceImpl: MessagesService { } } - struct UploadImageResult: Codable { + struct UploadFileResult: Codable { let path: String } - func uploadImage(for courseId: Int, and conversationId: Int64, image: Data) async -> DataState { - if image.count > 5 * 1024 * 1024 { - return .failure(error: .init(title: "File too big to upload")) - } - - let request = MultipartFormDataRequest(path: "api/files/courses/\(courseId)/conversations/\(conversationId)") - request.addDataField(named: "file", - filename: "\(UUID().uuidString).jpg", - data: image, - mimeType: "image/jpeg") - - let result: Swift.Result<(UploadImageResult, Int), APIClientError> = await client.sendRequest(request) - - switch result { - case .success(let response): - return .done(response: response.0.path) - case .failure(let failure): - return .failure(error: .init(error: failure)) - } - } + func uploadFile(for courseId: Int, and conversationId: Int64, file: Data, filename: String, mimeType: String) async -> DataState { + // Check file size limit + let maxFileSize = 5 * 1024 * 1024 + if file.count > maxFileSize { + return .failure(error: .init(title: "File too big to upload")) + } + + let request = MultipartFormDataRequest(path: "api/files/courses/\(courseId)/conversations/\(conversationId)") + request.addDataField(named: "file", + filename: filename, + data: file, + mimeType: mimeType) + + let result: Swift.Result<(UploadFileResult, Int), APIClientError> = await client.sendRequest(request) + + switch result { + case .success(let response): + return .done(response: response.0.path) + case .failure(let failure): + return .failure(error: .init(error: failure)) + } + } struct DeleteMessageRequest: APIRequest { typealias Response = RawResponse diff --git a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift index f1fb4d34..3676ae5a 100644 --- a/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift +++ b/ArtemisKit/Sources/Messages/Services/MessagesService/MessagesServiceStub.swift @@ -204,8 +204,8 @@ extension MessagesServiceStub: MessagesService { func unarchiveChannel(for courseId: Int, channelId: Int64) async -> NetworkResponse { .loading } - - func uploadImage(for courseId: Int, and conversationId: Int64, image: Data) async -> DataState { + + func uploadFile(for courseId: Int, and conversationId: Int64, file: Data, filename: String, mimeType: String) async -> DataState { .loading } } diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadFileViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadFileViewModel.swift new file mode 100644 index 00000000..863319e2 --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadFileViewModel.swift @@ -0,0 +1,120 @@ +// +// SendMessageUploadFileViewModel.swift +// ArtemisKit +// +// Created by Eylul Naz Can on 9.12.2024. +// + +import Foundation +import UniformTypeIdentifiers +import Common +import SwiftUI + +@Observable +final class SendMessageUploadFileViewModel: UploadViewModel { + + var fileData: Data? + var fileName: String? + var isFilePickerPresented = false + + private let messagesService: MessagesService + + static let allowedFileTypes: [UTType] = [ + .png, + .jpeg, + .gif, + .svg, + .pdf, + .rtf, + .plainText, + .json, + .spreadsheet, + .presentation, + UTType(filenameExtension: "doc") ?? .data, + UTType(filenameExtension: "docx") ?? .data, + UTType(filenameExtension: "xls") ?? .spreadsheet, + UTType(filenameExtension: "xlsx") ?? .spreadsheet, + UTType(filenameExtension: "ppt") ?? .presentation, + UTType(filenameExtension: "pptx") ?? .presentation + ] + + init( + courseId: Int, + conversationId: Int64, + messagesService: MessagesService = MessagesServiceFactory.shared + ) { + self.messagesService = messagesService + super.init(courseId: courseId, conversationId: conversationId) + } + + /// Handles changes to the selected file URL + func filePicked(at url: URL) { + isFilePickerPresented = false + loadFileData(from: url) + } + + /// Reads the file data from the provided URL + private func loadFileData(from url: URL) { + uploadState = .compressing + filePath = nil + + do { + guard url.startAccessingSecurityScopedResource() else { + throw NSFileProviderError(.notAuthenticated) + } + let fileData = try Data(contentsOf: url) + url.stopAccessingSecurityScopedResource() + let fileName = url.lastPathComponent + handleFileSelection(fileData: fileData, fileName: fileName) + } catch { + uploadState = .failed(error: .init(title: "Failed to read the selected file. Please try again.")) + } + } + + /// Validates and handles the selected file data + private func handleFileSelection(fileData: Data, fileName: String) { + self.fileData = fileData + self.fileName = fileName + + if fileData.count > 5 * 1024 * 1024 { + uploadState = .failed( + error: .init(title: "The file size exceeds the 5MB limit. Please choose a smaller file.") + ) + return + } + + let fileExtension = (fileName as NSString).pathExtension.lowercased() + guard Self.allowedFileTypes.contains(where: { $0.preferredFilenameExtension == fileExtension }) else { + uploadState = .failed( + error: .init(title: "The file type '\(fileExtension)' is not supported.") + ) + return + } + + upload(data: fileData, fileName: fileName, mimeType: fileExtension) + } + + private func upload(data: Data, fileName: String, mimeType: String) { + uploadState = .uploading + + uploadTask = Task { + let result = await messagesService.uploadFile(for: courseId, and: conversationId, file: data, filename: fileName, mimeType: mimeType) + if Task.isCancelled { return } + + switch result { + case .loading: + break + case .failure(let error): + uploadState = .failed(error: error) + case .done(let response): + filePath = response + uploadState = .done + } + } + } + + func resetFileSelection() { + fileData = nil + fileName = nil + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageUploadImageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadImageViewModel.swift similarity index 57% rename from ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageUploadImageViewModel.swift rename to ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadImageViewModel.swift index a9d9e33f..bd535752 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageUploadImageViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/SendMessageUploadImageViewModel.swift @@ -1,5 +1,5 @@ // -// File.swift +// SendMessageUploadImageViewModel.swift // ArtemisKit // // Created by Anian Schleyer on 09.11.24. @@ -10,58 +10,11 @@ import Foundation import PhotosUI import SwiftUI -enum UploadState: Equatable { - case selectImage - case compressing - case uploading - case done - case failed(error: UserFacingError) -} - @Observable -final class SendMessageUploadImageViewModel { - - let courseId: Int - let conversationId: Int64 +final class SendMessageUploadImageViewModel: UploadViewModel { var selection: PhotosPickerItem? var image: UIImage? - var uploadState = UploadState.selectImage - var imagePath: String? - private var uploadTask: Task<(), Never>? - - var showUploadScreen: Binding { - .init { - self.uploadState != .selectImage - } set: { newValue in - if !newValue { - self.uploadState = .selectImage - } - } - } - var error: UserFacingError? { - switch uploadState { - case .failed(let error): - return error - default: - return nil - } - } - - var statusLabel: String { - switch uploadState { - case .selectImage: - "" - case .compressing: - R.string.localizable.loading() - case .uploading: - R.string.localizable.uploading() - case .done: - R.string.localizable.done() - case .failed(let error): - error.localizedDescription - } - } private let messagesService: MessagesService @@ -70,9 +23,8 @@ final class SendMessageUploadImageViewModel { conversationId: Int64, messagesService: MessagesService = MessagesServiceFactory.shared ) { - self.courseId = courseId - self.conversationId = conversationId self.messagesService = messagesService + super.init(courseId: courseId, conversationId: conversationId) } /// Register as change handler for selection on View @@ -86,7 +38,7 @@ final class SendMessageUploadImageViewModel { } uploadState = .compressing - imagePath = nil + filePath = nil Task { if let transferable = try? await item.loadTransferable(type: Data.self) { @@ -107,7 +59,7 @@ final class SendMessageUploadImageViewModel { uploadState = .uploading uploadTask = Task { - let result = await messagesService.uploadImage(for: courseId, and: conversationId, image: imageData) + let result = await messagesService.uploadFile(for: courseId, and: conversationId, file: imageData, filename: "\(UUID().uuidString).jpg", mimeType: "image/jpeg") if Task.isCancelled { return } @@ -118,7 +70,7 @@ final class SendMessageUploadImageViewModel { case .failure(let error): uploadState = .failed(error: error) case .done(let response): - imagePath = response + filePath = response uploadState = .done } selection = nil @@ -141,12 +93,4 @@ final class SendMessageUploadImageViewModel { return imageData } } - - func cancel() { - uploadTask?.cancel() - uploadTask = nil - selection = nil - image = nil - uploadState = .selectImage - } } diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/UploadViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/UploadViewModel.swift new file mode 100644 index 00000000..0d32bd4e --- /dev/null +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/FileUpload/UploadViewModel.swift @@ -0,0 +1,75 @@ +// +// UploadState.swift +// ArtemisKit +// +// Created by Eylul Naz Can on 9.12.2024. +// + +import Common +import Foundation +import SwiftUI + +enum UploadState: Equatable { + case idle + case compressing + case uploading + case done + case failed(error: UserFacingError) +} + +@Observable +class UploadViewModel { + var uploadState: UploadState = .idle + var filePath: String? + + private(set) var courseId: Int + private(set) var conversationId: Int64 + + internal var uploadTask: Task<(), Never>? + + init(courseId: Int, conversationId: Int64) { + self.courseId = courseId + self.conversationId = conversationId + } + + var showUploadScreen: Binding { + .init { + self.uploadState != .idle + } set: { newValue in + if !newValue { + self.uploadState = .idle + } + } + } + + var error: UserFacingError? { + switch uploadState { + case .failed(let error): + return error + default: + return nil + } + } + + var statusLabel: String { + switch uploadState { + case .idle: + return "" + case .compressing: + return R.string.localizable.loading() + case .uploading: + return R.string.localizable.uploading() + case .done: + return R.string.localizable.done() + case .failed(let error): + return error.localizedDescription + } + } + + func cancel() { + uploadTask?.cancel() + uploadTask = nil + uploadState = .idle + filePath = nil + } +} diff --git a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift index f9361022..ac40a79e 100644 --- a/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift +++ b/ArtemisKit/Sources/Messages/ViewModels/SendMessageViewModels/SendMessageViewModel.swift @@ -221,6 +221,10 @@ extension SendMessageViewModel { func insertImageMention(path: String) { appendToSelection(before: "![", after: "](\(path))", placeholder: "image") } + + func insertFileMention(path: String, fileName: String) { + appendToSelection(before: "[", after: "](\(path))", placeholder: fileName) + } /// Prepends/Appends the given snippets to text the user has selected. private func appendToSelection(before: String, after: String, placeholder: String) { diff --git a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift index 1f4ec102..5b293c58 100644 --- a/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift +++ b/ArtemisKit/Sources/Messages/Views/MessagesTabView/MessagesAvailableView.swift @@ -193,7 +193,8 @@ public struct MessagesAvailableView: View { } } } - .toolbar(.hidden, for: .navigationBar) + // Add a file picker here, inside the navigation it doesn't work sometimes + .supportsFilePicker() } @ViewBuilder var filterBar: some View { diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/FilePickerViewModifier.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/FilePickerViewModifier.swift new file mode 100644 index 00000000..81817c6b --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/FilePickerViewModifier.swift @@ -0,0 +1,91 @@ +// +// FilePickerViewModifier.swift +// ArtemisKit +// +// Created by Anian Schleyer on 13.12.24. +// + +import SwiftUI + +extension View { + /// Add this further up in the view hierarchy to use a file picker somewhere below + func supportsFilePicker() -> some View { + modifier(AddFilePickerViewModifier()) + } + + /// Use this to utitlize the previously added file picker + func filePicker(isPresented: Binding, + onFilePicked: @escaping (URL) -> Void, + onError: @escaping (Error) -> Void) -> some View { + modifier(UseFilePickerViewModifier(presentFilePicker: isPresented, + onFilePick: onFilePicked, + onError: onError)) + } +} + +/// Adds a fileImporter in the view hierarchy for use in lower levels. +/// Use this before you can use `.filePicker` on a View. +private struct AddFilePickerViewModifier: ViewModifier { + @State var manager = FilePickerManager() + func body(content: Content) -> some View { + @Bindable var manager = manager + + content + .environment(\.filePickerManager, manager) + .fileImporter(isPresented: $manager.isPresented, + allowedContentTypes: SendMessageUploadFileViewModel.allowedFileTypes, + allowsMultipleSelection: false) { result in + switch result { + case .success(let success): + if let url = success.first { + manager.onPicked(url) + } + case .failure(let failure): + manager.onError(failure) + } + } + } +} + +/// This uses the previously injected fileImporter. +/// *Prerequesite*: Use `.supportsFilePicker()` on a view further up +private struct UseFilePickerViewModifier: ViewModifier { + @Environment(\.filePickerManager) var manager + + @Binding var presentFilePicker: Bool + let onFilePick: (URL) -> Void + let onError: (Error) -> Void + + func body(content: Content) -> some View { + content + .onChange(of: presentFilePicker, initial: true) { _, newValue in + manager.isPresented = newValue + } + .onAppear { + manager.onPicked = onFilePick + manager.onError = onError + } + } +} + +@Observable +class FilePickerManager { + var isPresented = false + var onPicked: (URL) -> Void = { _ in } + var onError: (Error) -> Void = { _ in } +} + +private enum FilePickerManagerKey: EnvironmentKey { + static let defaultValue = FilePickerManager() +} + +private extension EnvironmentValues { + var filePickerManager: FilePickerManager { + get { + self[FilePickerManagerKey.self] + } + set { + self[FilePickerManagerKey.self] = newValue + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageFilePickerView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageFilePickerView.swift new file mode 100644 index 00000000..508e03b4 --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageFilePickerView.swift @@ -0,0 +1,32 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct SendMessageFilePickerView: View { + var sendViewModel: SendMessageViewModel + @Bindable private var viewModel: SendMessageUploadFileViewModel + + init(sendViewModel: SendMessageViewModel, viewModel: SendMessageUploadFileViewModel) { + self.viewModel = viewModel + self.sendViewModel = sendViewModel + } + + var body: some View { + Button { + viewModel.isFilePickerPresented = true + } label: { + Label("Upload File", systemImage: "doc.fill") + } + .filePicker(isPresented: $viewModel.isFilePickerPresented, + onFilePicked: viewModel.filePicked(at:)) { error in + viewModel.uploadState = .failed(error: .init(title: error.localizedDescription)) + } + .sheet(isPresented: viewModel.showUploadScreen) { + if let path = viewModel.filePath { + sendViewModel.insertFileMention(path: path, fileName: viewModel.fileName ?? "file") + } + viewModel.resetFileSelection() + } content: { + UploadFileProgressView(viewModel: viewModel) + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageImagePickerView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageImagePickerView.swift new file mode 100644 index 00000000..a888a0af --- /dev/null +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/SendMessageImagePickerView.swift @@ -0,0 +1,41 @@ +// +// SendMessageImagePickerView.swift +// ArtemisKit +// +// Created by Anian Schleyer on 09.11.24. +// + +import PhotosUI +import SwiftUI + +struct SendMessageImagePickerView: View { + + var sendViewModel: SendMessageViewModel + @State private var viewModel: SendMessageUploadImageViewModel + + init(sendMessageViewModel: SendMessageViewModel) { + self._viewModel = State(initialValue: .init(courseId: sendMessageViewModel.course.id, + conversationId: sendMessageViewModel.conversation.id)) + self.sendViewModel = sendMessageViewModel + } + + var body: some View { + PhotosPicker(selection: $viewModel.selection, + matching: .images, + preferredItemEncoding: .compatible) { + Label(R.string.localizable.uploadImage(), systemImage: "photo.fill") + } + .onChange(of: viewModel.selection) { + viewModel.onChange() + } + .sheet(isPresented: viewModel.showUploadScreen) { + if let path = viewModel.filePath { + sendViewModel.insertImageMention(path: path) + } + viewModel.selection = nil + viewModel.image = nil + } content: { + UploadFileProgressView(viewModel: viewModel) + } + } +} diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageImagePickerView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/UploadFileView.swift similarity index 60% rename from ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageImagePickerView.swift rename to ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/UploadFileView.swift index e6124dad..f65c4ad3 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageImagePickerView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/FileUpload/UploadFileView.swift @@ -1,47 +1,14 @@ // -// SendMessageImagePickerView.swift +// UploadFileView.swift // ArtemisKit // -// Created by Anian Schleyer on 09.11.24. +// Created by Anian Schleyer on 13.12.24. // -import PhotosUI import SwiftUI -struct SendMessageImagePickerView: View { - - var sendViewModel: SendMessageViewModel - @State private var viewModel: SendMessageUploadImageViewModel - - init(sendMessageViewModel: SendMessageViewModel) { - self._viewModel = State(initialValue: .init(courseId: sendMessageViewModel.course.id, - conversationId: sendMessageViewModel.conversation.id)) - self.sendViewModel = sendMessageViewModel - } - - var body: some View { - PhotosPicker(selection: $viewModel.selection, - matching: .images, - preferredItemEncoding: .compatible) { - Label(R.string.localizable.uploadImage(), systemImage: "photo.fill") - } - .onChange(of: viewModel.selection) { - viewModel.onChange() - } - .sheet(isPresented: viewModel.showUploadScreen) { - if let path = viewModel.imagePath { - sendViewModel.insertImageMention(path: path) - } - viewModel.selection = nil - viewModel.image = nil - } content: { - UploadImageView(viewModel: viewModel) - } - } -} - -private struct UploadImageView: View { - var viewModel: SendMessageUploadImageViewModel +struct UploadFileProgressView: View { + var viewModel: UploadViewModel @Environment(\.dismiss) var dismiss var body: some View { @@ -99,7 +66,8 @@ private struct UploadImageView: View { } @ViewBuilder var backgroundImage: some View { - if let image = viewModel.image { + if let vm = viewModel as? SendMessageUploadImageViewModel, + let image = vm.image { Image(uiImage: image) .resizable() .scaledToFill() @@ -110,3 +78,4 @@ private struct UploadImageView: View { } } } + diff --git a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift index 95eebe87..2cd36a2f 100644 --- a/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift +++ b/ArtemisKit/Sources/Messages/Views/SendMessageViews/SendMessageView.swift @@ -13,9 +13,17 @@ import SwiftUI struct SendMessageView: View { @State var viewModel: SendMessageViewModel + /// This has to be in here, otherwise it gets deinitialized while file picker is open, + /// due to the textfield losing focus and the toolbar disappearing + @State private var uploadFileViewModel: SendMessageUploadFileViewModel @FocusState private var isFocused: Bool + init(viewModel: SendMessageViewModel) { + self._viewModel = State(initialValue: viewModel) + self._uploadFileViewModel = State(initialValue: .init(courseId: viewModel.course.id, conversationId: viewModel.conversation.id)) + } + var body: some View { VStack(spacing: 0) { if viewModel.isEditing { @@ -195,6 +203,7 @@ private extension SendMessageView { Label(R.string.localizable.link(), systemImage: "link") } SendMessageImagePickerView(sendMessageViewModel: viewModel) + SendMessageFilePickerView(sendViewModel: viewModel, viewModel: uploadFileViewModel) } .labelStyle(.iconOnly) .font(.title3)