diff --git a/MobileMkch.xcodeproj/project.xcworkspace/xcuserdata/platon.xcuserdatad/UserInterfaceState.xcuserstate b/MobileMkch.xcodeproj/project.xcworkspace/xcuserdata/platon.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..525b529 Binary files /dev/null and b/MobileMkch.xcodeproj/project.xcworkspace/xcuserdata/platon.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/MobileMkch/APIClient.swift b/MobileMkch/APIClient.swift index a1bbf48..fa72b96 100644 --- a/MobileMkch/APIClient.swift +++ b/MobileMkch/APIClient.swift @@ -1,6 +1,13 @@ import Foundation import Network +struct UploadFile { + let name: String + let filename: String + let mimeType: String + let data: Data +} + class APIClient: ObservableObject { private let baseURL = "https://mkch.pooziqo.xyz" private let apiURL = "https://mkch.pooziqo.xyz/api" @@ -437,21 +444,21 @@ class APIClient: ObservableObject { } } - func createThread(boardCode: String, title: String, text: String, passcode: String, completion: @escaping (Error?) -> Void) { + func createThread(boardCode: String, title: String, text: String, passcode: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) { if !passcode.isEmpty { loginWithPasscode(passcode: passcode) { error in if let error = error { completion(error) return } - self.performCreateThread(boardCode: boardCode, title: title, text: text, completion: completion) + self.performCreateThread(boardCode: boardCode, title: title, text: text, files: files, completion: completion) } } else { - performCreateThread(boardCode: boardCode, title: title, text: text, completion: completion) + performCreateThread(boardCode: boardCode, title: title, text: text, files: files, completion: completion) } } - private func performCreateThread(boardCode: String, title: String, text: String, completion: @escaping (Error?) -> Void) { + private func performCreateThread(boardCode: String, title: String, text: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) { let formURL = "\(baseURL)/boards/board/\(boardCode)/new" let url = URL(string: formURL)! @@ -485,19 +492,30 @@ class APIClient: ObservableObject { return } - var formData = URLComponents() - formData.queryItems = [ - URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken), - URLQueryItem(name: "title", value: title), - URLQueryItem(name: "text", value: text) - ] - var postRequest = URLRequest(url: url) postRequest.httpMethod = "POST" - postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") postRequest.setValue(formURL, forHTTPHeaderField: "Referer") postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") - postRequest.httpBody = formData.query?.data(using: .utf8) + + if files.isEmpty { + var formData = URLComponents() + formData.queryItems = [ + URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken), + URLQueryItem(name: "title", value: title), + URLQueryItem(name: "text", value: text) + ] + postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + postRequest.httpBody = formData.query?.data(using: .utf8) + } else { + let boundary = "Boundary-\(UUID().uuidString)" + postRequest.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + let body = self.buildMultipartBody(parameters: [ + "csrfmiddlewaretoken": csrfToken, + "title": title, + "text": text + ], files: files, boundary: boundary) + postRequest.httpBody = body + } self.session.dataTask(with: postRequest) { _, postResponse, postError in DispatchQueue.main.async { @@ -520,21 +538,21 @@ class APIClient: ObservableObject { }.resume() } - func addComment(boardCode: String, threadId: Int, text: String, passcode: String, completion: @escaping (Error?) -> Void) { + func addComment(boardCode: String, threadId: Int, text: String, passcode: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) { if !passcode.isEmpty { loginWithPasscode(passcode: passcode) { error in if let error = error { completion(error) return } - self.performAddComment(boardCode: boardCode, threadId: threadId, text: text, completion: completion) + self.performAddComment(boardCode: boardCode, threadId: threadId, text: text, files: files, completion: completion) } } else { - performAddComment(boardCode: boardCode, threadId: threadId, text: text, completion: completion) + performAddComment(boardCode: boardCode, threadId: threadId, text: text, files: files, completion: completion) } } - private func performAddComment(boardCode: String, threadId: Int, text: String, completion: @escaping (Error?) -> Void) { + private func performAddComment(boardCode: String, threadId: Int, text: String, files: [UploadFile] = [], completion: @escaping (Error?) -> Void) { let formURL = "\(baseURL)/boards/board/\(boardCode)/thread/\(threadId)/comment" let url = URL(string: formURL)! @@ -568,18 +586,28 @@ class APIClient: ObservableObject { return } - var formData = URLComponents() - formData.queryItems = [ - URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken), - URLQueryItem(name: "text", value: text) - ] - var postRequest = URLRequest(url: url) postRequest.httpMethod = "POST" - postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") postRequest.setValue(formURL, forHTTPHeaderField: "Referer") postRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") - postRequest.httpBody = formData.query?.data(using: .utf8) + + if files.isEmpty { + var formData = URLComponents() + formData.queryItems = [ + URLQueryItem(name: "csrfmiddlewaretoken", value: csrfToken), + URLQueryItem(name: "text", value: text) + ] + postRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + postRequest.httpBody = formData.query?.data(using: .utf8) + } else { + let boundary = "Boundary-\(UUID().uuidString)" + postRequest.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + let body = self.buildMultipartBody(parameters: [ + "csrfmiddlewaretoken": csrfToken, + "text": text + ], files: files, boundary: boundary) + postRequest.httpBody = body + } self.session.dataTask(with: postRequest) { _, postResponse, postError in DispatchQueue.main.async { @@ -617,6 +645,25 @@ class APIClient: ObservableObject { return String(html[range]) } + private func buildMultipartBody(parameters: [String: String], files: [UploadFile], boundary: String) -> Data { + var body = Data() + let boundaryPrefix = "--\(boundary)\r\n" + for (key, value) in parameters { + body.append(boundaryPrefix.data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + body.append("\(value)\r\n".data(using: .utf8)!) + } + for file in files { + body.append(boundaryPrefix.data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(file.name)\"; filename=\"\(file.filename)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: \(file.mimeType)\r\n\r\n".data(using: .utf8)!) + body.append(file.data) + body.append("\r\n".data(using: .utf8)!) + } + body.append("--\(boundary)--\r\n".data(using: .utf8)!) + return body + } + func checkNewThreads(forBoard boardCode: String, lastKnownThreadId: Int, completion: @escaping (Result<[Thread], Error>) -> Void) { let url = URL(string: "\(apiURL)/board/\(boardCode)")! var request = URLRequest(url: url) diff --git a/MobileMkch/AddCommentView.swift b/MobileMkch/AddCommentView.swift index 95dcf8a..55404bc 100644 --- a/MobileMkch/AddCommentView.swift +++ b/MobileMkch/AddCommentView.swift @@ -1,4 +1,5 @@ import SwiftUI +import PhotosUI struct AddCommentView: View { let boardCode: String @@ -12,6 +13,8 @@ struct AddCommentView: View { @State private var errorMessage: String? @State private var showingSuccess = false @FocusState private var isTextFocused: Bool + @State private var pickedImages: [UIImage] = [] + @State private var showPicker: Bool = false var body: some View { NavigationView { @@ -56,6 +59,38 @@ struct AddCommentView: View { ) } + VStack(alignment: .leading, spacing: 8) { + Text("Фото") + .font(.headline) + .foregroundColor(.primary) + Button { + showPicker = true + } label: { + HStack { + Image(systemName: "photo.on.rectangle") + Text("Выбрать фото") + Spacer() + } + .padding(12) + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + } + if !pickedImages.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Array(pickedImages.enumerated()), id: \.offset) { _, img in + Image(uiImage: img) + .resizable() + .scaledToFill() + .frame(width: 64, height: 64) + .clipped() + .cornerRadius(8) + } + } + } + } + } + HStack(spacing: 8) { Image(systemName: settings.passcode.isEmpty ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") .foregroundColor(settings.passcode.isEmpty ? .orange : .green) @@ -126,6 +161,11 @@ struct AddCommentView: View { } message: { Text("Комментарий добавлен") } + .sheet(isPresented: $showPicker) { + ImagePickerView(selectionLimit: 4) { images in + pickedImages = images + } + } } } @@ -139,7 +179,8 @@ struct AddCommentView: View { boardCode: boardCode, threadId: threadId, text: text, - passcode: settings.passcode + passcode: settings.passcode, + files: buildUploadFiles() ) { error in DispatchQueue.main.async { self.isLoading = false @@ -152,6 +193,22 @@ struct AddCommentView: View { } } } + + private func buildUploadFiles() -> [UploadFile] { + var result: [UploadFile] = [] + for (idx, img) in pickedImages.enumerated() { + if let data = img.jpegData(compressionQuality: 0.9) { + let file = UploadFile( + name: "files", + filename: "photo_\(idx + 1).jpg", + mimeType: "image/jpeg", + data: data + ) + result.append(file) + } + } + return result + } } #Preview { diff --git a/MobileMkch/CreateThreadView.swift b/MobileMkch/CreateThreadView.swift index 0ce8b6b..ca95e53 100644 --- a/MobileMkch/CreateThreadView.swift +++ b/MobileMkch/CreateThreadView.swift @@ -1,4 +1,5 @@ import SwiftUI +import PhotosUI struct CreateThreadView: View { let boardCode: String @@ -13,6 +14,8 @@ struct CreateThreadView: View { @State private var showingSuccess = false @FocusState private var titleFocused: Bool @FocusState private var textFocused: Bool + @State private var pickedImages: [UIImage] = [] + @State private var showPicker: Bool = false var body: some View { NavigationView { @@ -41,6 +44,38 @@ struct CreateThreadView: View { ) } + VStack(alignment: .leading, spacing: 8) { + Text("Фото") + .font(.headline) + .foregroundColor(.primary) + Button { + showPicker = true + } label: { + HStack { + Image(systemName: "photo.on.rectangle") + Text("Выбрать фото") + Spacer() + } + .padding(12) + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + } + if !pickedImages.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Array(pickedImages.enumerated()), id: \.offset) { _, img in + Image(uiImage: img) + .resizable() + .scaledToFill() + .frame(width: 64, height: 64) + .clipped() + .cornerRadius(8) + } + } + } + } + } + VStack(alignment: .leading, spacing: 8) { HStack { Text("Содержание") @@ -149,6 +184,11 @@ struct CreateThreadView: View { } message: { Text("Тред создан") } + .sheet(isPresented: $showPicker) { + ImagePickerView(selectionLimit: 4) { images in + pickedImages = images + } + } } } @@ -162,7 +202,8 @@ struct CreateThreadView: View { boardCode: boardCode, title: title, text: text, - passcode: settings.passcode + passcode: settings.passcode, + files: buildUploadFiles() ) { error in DispatchQueue.main.async { self.isLoading = false @@ -175,6 +216,22 @@ struct CreateThreadView: View { } } } + + private func buildUploadFiles() -> [UploadFile] { + var result: [UploadFile] = [] + for (idx, img) in pickedImages.enumerated() { + if let data = img.jpegData(compressionQuality: 0.9) { + let file = UploadFile( + name: "files", + filename: "photo_\(idx + 1).jpg", + mimeType: "image/jpeg", + data: data + ) + result.append(file) + } + } + return result + } } #Preview { diff --git a/MobileMkch/ImagePicker.swift b/MobileMkch/ImagePicker.swift new file mode 100644 index 0000000..e1a2228 --- /dev/null +++ b/MobileMkch/ImagePicker.swift @@ -0,0 +1,57 @@ +import SwiftUI +import PhotosUI + +struct ImagePickerView: UIViewControllerRepresentable { + let selectionLimit: Int + let onComplete: ([UIImage]) -> Void + + func makeUIViewController(context: Context) -> PHPickerViewController { + var configuration = PHPickerConfiguration() + configuration.filter = .images + configuration.selectionLimit = selectionLimit + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(onComplete: onComplete) + } + + final class Coordinator: NSObject, PHPickerViewControllerDelegate { + let onComplete: ([UIImage]) -> Void + + init(onComplete: @escaping ([UIImage]) -> Void) { + self.onComplete = onComplete + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + guard !results.isEmpty else { + picker.dismiss(animated: true) + return + } + let providers = results.map { $0.itemProvider } + var images: [UIImage] = [] + let group = DispatchGroup() + for provider in providers { + if provider.canLoadObject(ofClass: UIImage.self) { + group.enter() + provider.loadObject(ofClass: UIImage.self) { object, _ in + if let img = object as? UIImage { + images.append(img) + } + group.leave() + } + } + } + group.notify(queue: .main) { + self.onComplete(images) + picker.dismiss(animated: true) + } + } + } +} + + diff --git a/MobileMkch/Info.plist b/MobileMkch/Info.plist index 2bd486b..b27b4e4 100644 --- a/MobileMkch/Info.plist +++ b/MobileMkch/Info.plist @@ -8,6 +8,8 @@ NSSupportsLiveActivities + NSPhotoLibraryUsageDescription + Нужно для выбора фото и загрузки вложений UIBackgroundModes background-processing diff --git a/MobileMkch/MobileMkchApp.swift b/MobileMkch/MobileMkchApp.swift index 0fcd0fc..8360add 100644 --- a/MobileMkch/MobileMkchApp.swift +++ b/MobileMkch/MobileMkchApp.swift @@ -37,7 +37,7 @@ struct MobileMkchApp: App { private func handleNotificationLaunch() { if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let userInfo = scene.session.userInfo { + let _ = scene.session.userInfo { print("Приложение запущено из уведомления") } notificationManager.clearBadge() diff --git a/MobileMkch/ThreadDetailView.swift b/MobileMkch/ThreadDetailView.swift index 372da34..3b7f52c 100644 --- a/MobileMkch/ThreadDetailView.swift +++ b/MobileMkch/ThreadDetailView.swift @@ -89,29 +89,7 @@ struct ThreadDetailView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - HStack { - Button("Обновить") { loadThreadDetail() } - if #available(iOS 16.1, *) { - Toggle("", isOn: $activityOn) - .toggleStyle(SwitchToggleStyle(tint: .blue)) - .labelsHidden() - .onChange(of: activityOn) { newValue in - guard settings.liveActivityEnabled else { return } - if newValue { - if let detail = threadDetail { - LiveActivityManager.shared.start(for: detail, comments: comments, settings: settings) - } - } else { - LiveActivityManager.shared.end(threadId: thread.id) - } - } - .onAppear { - if settings.liveActivityEnabled { - activityOn = LiveActivityManager.shared.isActive(threadId: thread.id) - } - } - } - } + Button("Обновить") { loadThreadDetail() } } } .sheet(isPresented: $showingAddComment) {