diff --git a/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj b/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj index f7538ea6..561d225c 100644 --- a/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj +++ b/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj @@ -13,6 +13,8 @@ 67138D922974851F00BBF450 /* XCUIElement+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67138D912974851F00BBF450 /* XCUIElement+.swift */; }; 67138D942974854F00BBF450 /* XCTestCase+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67138D932974854F00BBF450 /* XCTestCase+.swift */; }; 6718BCA42AD5327F002846A6 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6718BCA32AD5327F002846A6 /* SnapshotHelper.swift */; }; + 671B4AE92D4F623100286996 /* ModernPickedImagesGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671B4AE82D4F623100286996 /* ModernPickedImagesGrid.swift */; }; + 671B4AEB2D4F683E00286996 /* ImagePickerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671B4AEA2D4F683E00286996 /* ImagePickerViews.swift */; }; 671D7DEC28210D2F0068E728 /* EmptyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671D7DEB28210D2F0068E728 /* EmptyContentView.swift */; }; 67419ACF282E70B9004F5339 /* ParksListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67419ACE282E70B9004F5339 /* ParksListScreen.swift */; }; 6747575628113419002F0A24 /* ChangePasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6747575528113419002F0A24 /* ChangePasswordScreen.swift */; }; @@ -34,6 +36,7 @@ 67627750283A3A54009C203F /* JournalsListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6762774F283A3A54009C203F /* JournalsListScreen.swift */; }; 67627755283A4C77009C203F /* JournalEntriesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67627754283A4C77009C203F /* JournalEntriesScreen.swift */; }; 6762775B283A87AD009C203F /* JournalCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6762775A283A87AD009C203F /* JournalCell.swift */; }; + 6764D6382D52009F00699007 /* DialogsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6764D6372D52009F00699007 /* DialogsViewModel.swift */; }; 6765B2562D451771006164AB /* UIImage+toMediaFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6765B2552D451771006164AB /* UIImage+toMediaFile.swift */; }; 6765B2582D4544C8006164AB /* MainUserProfileScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6765B2572D4544C8006164AB /* MainUserProfileScreen.swift */; }; 6765B25B2D455D5C006164AB /* ProfileViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6765B25A2D455D5C006164AB /* ProfileViews.swift */; }; @@ -97,6 +100,8 @@ 67138D912974851F00BBF450 /* XCUIElement+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIElement+.swift"; sourceTree = ""; }; 67138D932974854F00BBF450 /* XCTestCase+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+.swift"; sourceTree = ""; }; 6718BCA32AD5327F002846A6 /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SnapshotHelper.swift; path = fastlane/SnapshotHelper.swift; sourceTree = SOURCE_ROOT; }; + 671B4AE82D4F623100286996 /* ModernPickedImagesGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModernPickedImagesGrid.swift; sourceTree = ""; }; + 671B4AEA2D4F683E00286996 /* ImagePickerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePickerViews.swift; sourceTree = ""; }; 671D7DEB28210D2F0068E728 /* EmptyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyContentView.swift; sourceTree = ""; }; 67419ACE282E70B9004F5339 /* ParksListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParksListScreen.swift; sourceTree = ""; }; 6747575528113419002F0A24 /* ChangePasswordScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangePasswordScreen.swift; sourceTree = ""; }; @@ -119,6 +124,7 @@ 6762774F283A3A54009C203F /* JournalsListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalsListScreen.swift; sourceTree = ""; }; 67627754283A4C77009C203F /* JournalEntriesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalEntriesScreen.swift; sourceTree = ""; }; 6762775A283A87AD009C203F /* JournalCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalCell.swift; sourceTree = ""; }; + 6764D6372D52009F00699007 /* DialogsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialogsViewModel.swift; sourceTree = ""; }; 6765B2552D451771006164AB /* UIImage+toMediaFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+toMediaFile.swift"; sourceTree = ""; }; 6765B2572D4544C8006164AB /* MainUserProfileScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainUserProfileScreen.swift; sourceTree = ""; }; 6765B25A2D455D5C006164AB /* ProfileViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViews.swift; sourceTree = ""; }; @@ -223,6 +229,7 @@ 67419AD5282E8E7C004F5339 /* Messages */ = { isa = PBXGroup; children = ( + 6764D6372D52009F00699007 /* DialogsViewModel.swift */, 67D916802838E2460098D3CB /* DialogsListScreen.swift */, 67D916852838F0DD0098D3CB /* DialogScreen.swift */, ); @@ -265,6 +272,8 @@ children = ( 67515698283FEC3100501346 /* PickedImagesGrid.swift */, 67A079F12A758E7D005EAF70 /* PickedPhotoView.swift */, + 671B4AE82D4F623100286996 /* ModernPickedImagesGrid.swift */, + 671B4AEA2D4F683E00286996 /* ImagePickerViews.swift */, ); path = ImagePicker; sourceTree = ""; @@ -609,9 +618,11 @@ 67419ACF282E70B9004F5339 /* ParksListScreen.swift in Sources */, 67EA685C2A71A99700697C88 /* PhotoDetailScreen.swift in Sources */, 674DF0402B11257D00828016 /* NavigationLink+.swift in Sources */, + 671B4AEB2D4F683E00286996 /* ImagePickerViews.swift in Sources */, 67BD2D012AF7D21B00F44064 /* ParksManager.swift in Sources */, 6705E7EE283B703400DABCC8 /* JournalSettingsScreen.swift in Sources */, 6798AA40280AEDC900DB76F1 /* RootScreen.swift in Sources */, + 671B4AE92D4F623100286996 /* ModernPickedImagesGrid.swift in Sources */, 675EC64F2814126800C2E229 /* TextEntryScreen.swift in Sources */, 674D0623282A9896007E75C6 /* SearchUsersScreen.swift in Sources */, 67A4710D2AEED8F8004D341D /* PastEventStorage.swift in Sources */, @@ -638,6 +649,7 @@ 6798AA84280C0F7D00DB76F1 /* EditProfileScreen.swift in Sources */, 6798AA73280B43FE00DB76F1 /* LoginScreen.swift in Sources */, 67D9169628396C1E0098D3CB /* SendMessageScreen.swift in Sources */, + 6764D6382D52009F00699007 /* DialogsViewModel.swift in Sources */, 6747575928128603002F0A24 /* ParkDetailScreen.swift in Sources */, 675EC6572815433600C2E229 /* UsersListScreen.swift in Sources */, 675EC65F2815532800C2E229 /* EventFormScreen.swift in Sources */, @@ -844,7 +856,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_ASSET_PATHS = "SwiftUI-WorkoutApp/Preview\\ Content/PreviewContent.swift SwiftUI-WorkoutApp/Preview\\ Content"; DEVELOPMENT_TEAM = CR68PP2Z3F; ENABLE_PREVIEWS = YES; @@ -855,6 +867,7 @@ INFOPLIST_KEY_CFBundleDisplayName = "SW Площадки"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Для отображения спортивных площадок поблизости"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Для выбора фото профиля требуется доступ к галерее"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -894,7 +907,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 9; DEVELOPMENT_ASSET_PATHS = "SwiftUI-WorkoutApp/Preview\\ Content/PreviewContent.swift SwiftUI-WorkoutApp/Preview\\ Content"; DEVELOPMENT_TEAM = CR68PP2Z3F; ENABLE_PREVIEWS = YES; @@ -905,6 +918,7 @@ INFOPLIST_KEY_CFBundleDisplayName = "SW Площадки"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Для отображения спортивных площадок поблизости"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Для выбора фото профиля требуется доступ к галерее"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/BodyMaker.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/BodyMaker.swift index 737b33dd..0273634c 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/BodyMaker.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/BodyMaker.swift @@ -2,12 +2,12 @@ import Foundation /// Делает `body` для запроса public enum BodyMaker { - public struct Parameter where T.RawValue == String { + struct Parameter { let key: String let value: String - public init(from element: Dictionary.Element) { - self.key = element.key.rawValue + init(from element: Dictionary.Element) { + self.key = element.key self.value = element.value } @@ -18,8 +18,8 @@ public enum BodyMaker { } /// Делает `body` из словаря - public static func makeBody( - with parameters: [Parameter] + static func makeBody( + with parameters: [Parameter] ) -> Data? { parameters.isEmpty ? nil @@ -30,11 +30,11 @@ public enum BodyMaker { } /// Делает `body` из словаря и медиа-файлов - public static func makeBodyWithMultipartForm( - with parameters: [Parameter], - and media: [MediaFile]? + static func makeBodyWithMultipartForm( + parameters: [Parameter], + media: [MediaFile]?, + boundary: String ) -> Data? { - let boundary = "FFF" let lineBreak = "\r\n" var body = Data() if !parameters.isEmpty { @@ -56,13 +56,25 @@ public enum BodyMaker { if !body.isEmpty { body.append("--\(boundary)--\(lineBreak)") return body - } else { - return nil + } + return nil + } +} + +public extension BodyMaker { + /// Модель для последующего создания тела запроса + struct Parts { + let parameters: [Parameter] + let mediaFiles: [MediaFile]? + + public init(_ parameters: [String: String], _ mediaFiles: [MediaFile]?) { + self.parameters = parameters.map(Parameter.init) + self.mediaFiles = mediaFiles } } /// Медиа-файл для отправки на сервер - public struct MediaFile: Codable, Equatable, Sendable { + struct MediaFile: Codable, Equatable, Sendable { public let key: String public let filename: String public let data: Data diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/RequestComponents.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/RequestComponents.swift index b6911f9b..98f98a72 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/RequestComponents.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/RequestComponents.swift @@ -5,7 +5,8 @@ public struct RequestComponents { let queryItems: [URLQueryItem] let httpMethod: HTTPMethod let hasMultipartFormData: Bool - let body: Data? + let bodyParts: BodyMaker.Parts? + let boundary: String let token: String? /// Инициализатор @@ -14,21 +15,24 @@ public struct RequestComponents { /// - queryItems: Параметры `query`, по умолчанию отсутствуют /// - httpMethod: Метод запроса /// - hasMultipartFormData: Есть ли в запросе файлы для отправки (в нашем случае картинки), по умолчанию `false` - /// - body: Тело запроса, по умолчанию `nil` + /// - bodyParts: Данные для тела запроса, по умолчанию `nil` + /// - boundary: `Boundary` для `body`, по умолчанию `UUID().uuidString` /// - token: Токен для авторизации, по умолчанию `nil` public init( path: String, queryItems: [URLQueryItem] = [], httpMethod: HTTPMethod, hasMultipartFormData: Bool = false, - body: Data? = nil, + bodyParts: BodyMaker.Parts? = nil, + boundary: String = UUID().uuidString, token: String? = nil ) { self.path = path self.queryItems = queryItems self.httpMethod = httpMethod self.hasMultipartFormData = hasMultipartFormData - self.body = body + self.bodyParts = bodyParts + self.boundary = boundary self.token = token } @@ -50,19 +54,37 @@ extension RequestComponents { guard let url else { return nil } var request = URLRequest(url: url) request.httpMethod = httpMethod.rawValue - request.httpBody = body + var allHeaders = [HTTPHeaderField]() - // TODO: генерировать boundary в одном месте (вместо FFF) - if let body { - allHeaders.append(.init(key: "Content-Length", value: "\(body.count)")) - } - if hasMultipartFormData { - allHeaders.append(.init(key: "Content-Type", value: "multipart/form-data; boundary=FFF")) + var httpBodyData: Data? + + if let bodyParts { + let parameters = bodyParts.parameters + if hasMultipartFormData { + httpBodyData = BodyMaker.makeBodyWithMultipartForm( + parameters: parameters, + media: bodyParts.mediaFiles, + boundary: boundary + ) + allHeaders.append(.init( + key: "Content-Type", + value: "multipart/form-data; boundary=\(boundary)" + )) + } else { + httpBodyData = BodyMaker.makeBody(with: parameters) + } + if let httpBodyData { + allHeaders.append(.init(key: "Content-Length", value: "\(httpBodyData.count)")) + } } + if let token, !token.isEmpty { allHeaders.append(.init(key: "Authorization", value: "Basic \(token)")) } - request.allHTTPHeaderFields = Dictionary(uniqueKeysWithValues: allHeaders.map { ($0.key, $0.value) }) + request.allHTTPHeaderFields = Dictionary( + uniqueKeysWithValues: allHeaders.map { ($0.key, $0.value) } + ) + request.httpBody = httpBodyData return request } } diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/BodyMakerTests.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/BodyMakerTests.swift index 7cb9c7de..c45aa773 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/BodyMakerTests.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/BodyMakerTests.swift @@ -3,85 +3,158 @@ import Foundation import Testing struct BodyMakerTests { - private typealias Parameter = BodyMaker.Parameter + @Test + func parameterInitializationFromDictionaryElement() { + let element = ("testKey", "testValue") + let parameter = BodyMaker.Parameter(from: element) + #expect(parameter.key == "testKey") + #expect(parameter.value == "testValue") + } + + // MARK: - makeBody @Test - func makeBody_noParameters() { - let parameters = [Parameter]() - let result = BodyMaker.makeBody(with: parameters) + func makeBodyWithNoParametersReturnsNil() { + let result = BodyMaker.makeBody(with: []) #expect(result == nil) } @Test - func makeBody_validParameters() throws { - let parameters: [Parameter] = [ - .init(key: TestKey.name.rawValue, value: "John"), - .init(key: TestKey.age.rawValue, value: "30") + func makeBodyWithSingleParameter() throws { + let parameter = BodyMaker.Parameter(key: "name", value: "John") + let result = try #require(BodyMaker.makeBody(with: [parameter])) + let expectedString = "name=John" + #expect(String(data: result, encoding: .utf8) == expectedString) + } + + @Test + func makeBodyWithMultipleParameters() throws { + let params = [ + BodyMaker.Parameter(key: "a", value: "1"), + BodyMaker.Parameter(key: "b", value: "2") ] - let expectedData = try #require("name=John&age=30".data(using: .utf8)) - let result = try #require(BodyMaker.makeBody(with: parameters)) - #expect(result == expectedData) + let result = try #require(BodyMaker.makeBody(with: params)) + let expectedString = "a=1&b=2" + #expect(String(data: result, encoding: .utf8) == expectedString) } + // MARK: - makeBodyWithMultipartForm + @Test - func makeBodyWithMultipartForm_noParameters_noMedia() { - let parameters = [Parameter]() - let result = BodyMaker.makeBodyWithMultipartForm(with: parameters, and: nil) + func multipartFormWithNoContentReturnsNil() { + let result = BodyMaker.makeBodyWithMultipartForm( + parameters: [], + media: nil, + boundary: "BOUNDARY" + ) #expect(result == nil) } @Test - func makeBodyWithMultipartForm_onlyDictionary() throws { - let parameters: [Parameter] = [ - .init(key: TestKey.name.rawValue, value: "John"), - .init(key: TestKey.age.rawValue, value: "30") + func multipartFormWithParametersOnly() throws { + let params = [BodyMaker.Parameter(key: "text", value: "Hello")] + let boundary = "TESTBOUNDARY" + let result = try #require(BodyMaker.makeBodyWithMultipartForm( + parameters: params, + media: nil, + boundary: boundary + )) + let string = try #require(String(data: result, encoding: .utf8)) + let expectedPatterns = [ + "--TESTBOUNDARY\r\n", + "Content-Disposition: form-data; name=\"text\"\r\n\r\n", + "Hello\r\n", + "--TESTBOUNDARY--\r\n" ] - let expectedData = try #require( - "--FFF\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nJohn\r\n--FFF\r\nContent-Disposition: form-data; name=\"age\"\r\n\r\n30\r\n--FFF--\r\n" - .data(using: .utf8) - ) - let result = try #require(BodyMaker.makeBodyWithMultipartForm(with: parameters, and: nil)) - #expect(result == expectedData) + for pattern in expectedPatterns { + #expect(string.contains(pattern)) + } } @Test - func makeBodyWithMultipartForm_onlyMedia() throws { - let parameters = [Parameter]() - let mediaFile = BodyMaker.MediaFile( - key: "file", - filename: "test.png", - data: Data("Test image content".utf8), - mimeType: "image/png" - ) - let expectedData = try #require( - "--FFF\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.png\"\r\nContent-Type: image/png\r\n\r\nTest image content\r\n--FFF--\r\n" - .data(using: .utf8) - ) - let result = try #require(BodyMaker.makeBodyWithMultipartForm(with: parameters, and: [mediaFile])) - #expect(result == expectedData) + func multipartFormWithMediaOnly() throws { + let media = [ + BodyMaker.MediaFile( + key: "file", + filename: "test.txt", + data: Data("file content".utf8), + mimeType: "text/plain" + ) + ] + let boundary = "MEDIA_BOUNDARY" + let result = try #require(BodyMaker.makeBodyWithMultipartForm( + parameters: [], + media: media, + boundary: boundary + )) + let string = try #require(String(data: result, encoding: .utf8)) + let expectedPatterns = [ + "--MEDIA_BOUNDARY\r\n", + "Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n", + "Content-Type: text/plain\r\n\r\n", + "file content\r\n", + "--MEDIA_BOUNDARY--\r\n" + ] + for pattern in expectedPatterns { + #expect(string.contains(pattern)) + } } @Test - func makeBodyWithMultipartForm_dictionaryAndMedia() throws { - let parameters: [Parameter] = [.init(key: TestKey.description.rawValue, value: "A test image")] - let mediaFile = BodyMaker.MediaFile( - key: "file", - filename: "test.png", - data: Data("Test image content".utf8), - mimeType: "image/png" - ) - let expectedData = try #require( - "--FFF\r\nContent-Disposition: form-data; name=\"description\"\r\n\r\nA test image\r\n--FFF\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.png\"\r\nContent-Type: image/png\r\n\r\nTest image content\r\n--FFF--\r\n" - .data(using: .utf8) - ) - let result = try #require(BodyMaker.makeBodyWithMultipartForm(with: parameters, and: [mediaFile])) - #expect(result == expectedData) + func multipartFormWithMixedContent() throws { + let params = [BodyMaker.Parameter(key: "title", value: "Document")] + let media = [ + BodyMaker.MediaFile( + key: "doc", + filename: "doc.pdf", + data: Data("pdf content".utf8), + mimeType: "application/pdf" + ) + ] + let boundary = "MIXEDBOUNDARY" + let result = try #require(BodyMaker.makeBodyWithMultipartForm( + parameters: params, + media: media, + boundary: boundary + )) + + let string = try #require(String(data: result, encoding: .utf8)) + + // Проверяем порядок: сначала параметры, потом медиа + let paramSection = """ + --MIXEDBOUNDARY\r\n\ + Content-Disposition: form-data; name="title"\r\n\r\n\ + Document\r\n + """ + + let mediaSection = """ + --MIXEDBOUNDARY\r\n\ + Content-Disposition: form-data; name="doc"; filename="doc.pdf"\r\n\ + Content-Type: application/pdf\r\n\r\n\ + pdf content\r\n + """ + + let closing = "--MIXEDBOUNDARY--\r\n" + + #expect(string.contains(paramSection)) + #expect(string.contains(mediaSection)) + #expect(string.contains(closing)) } -} -/// Пример ключа для тестирования -private enum TestKey: String { - case name - case age - case description + // MARK: - MediaFile Tests + + @Test + func mediaFileInitialization() { + let data = Data("test".utf8) + let media = BodyMaker.MediaFile( + key: "avatar", + filename: "image.jpg", + data: data, + mimeType: "image/jpeg" + ) + #expect(media.key == "avatar") + #expect(media.filename == "image.jpg") + #expect(media.data == data) + #expect(media.mimeType == "image/jpeg") + } } diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/RequestComponentsTests.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/RequestComponentsTests.swift index c6d2dc1b..51e4dc99 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/RequestComponentsTests.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/RequestComponentsTests.swift @@ -3,143 +3,214 @@ import Foundation import Testing struct RequestComponentsTests { - @Test - func initialization() { - let path = "/test" - let queryItems = [URLQueryItem(name: "key", value: "value")] - let httpMethod = HTTPMethod.get - let body = Data("test body".utf8) - let token = "token" - let requestComponents = RequestComponents( - path: path, - queryItems: queryItems, - httpMethod: httpMethod, - body: body, - token: token - ) - #expect(requestComponents.path == path) - #expect(requestComponents.queryItems == queryItems) - #expect(requestComponents.httpMethod == httpMethod) - #expect(requestComponents.body == body) - #expect(requestComponents.token == token) - } + // MARK: - URL - @Test(arguments: [HTTPMethod.put, .get, .post, .delete]) - func urlRequestCreationWithSpecificMethod(_ method: HTTPMethod) throws { - let requestComponents = RequestComponents( + @Test + func urlConstructionWithValidPath() throws { + let components = RequestComponents( path: "/test", - httpMethod: method + httpMethod: .get, + boundary: "TEST" ) - let urlRequest = try #require(requestComponents.urlRequest) - #expect(urlRequest.httpMethod == method.rawValue) + let url = try #require(components.url) + #expect(url.absoluteString == "https://workout.su/api/v3/test") } - @Test("Генерация URL без ведущего слеша в path") - func urlCreationFailure() { - let requestComponents = RequestComponents( - path: "invalidpath", + @Test + func urlConstructionWithInvalidPath() { + let components = RequestComponents( + path: "invalid", httpMethod: .get ) - #expect(requestComponents.url == nil) + #expect(components.url == nil) } @Test - func urlRequestCreationWithEmptyToken() throws { - let requestComponents = RequestComponents( - path: "/test", - httpMethod: .get, - token: "" + func urlWithQueryItems() throws { + let components = RequestComponents( + path: "/search", + queryItems: [URLQueryItem(name: "q", value: "test")], + httpMethod: .get ) - let urlRequest = try #require(requestComponents.urlRequest) - let headers = try #require(urlRequest.allHTTPHeaderFields) - #expect(headers["Authorization"] == nil) + let url = try #require(components.url) + let query = try #require(url.query) + #expect(query == "q=test") } + // MARK: - Настройка URLRequest + @Test - func urlGeneration() throws { - let requestComponents = RequestComponents( + func basicRequestConfiguration() throws { + let components = RequestComponents( path: "/test", - queryItems: [URLQueryItem(name: "key", value: "value")], - httpMethod: .get + httpMethod: .post, + boundary: "TEST" ) - let resultURL = try #require(requestComponents.url) - let expectedURLString = "https://workout.su/api/v3/test?key=value" - #expect(resultURL.absoluteString == expectedURLString) + let request = try #require(components.urlRequest) + let httpMethod = try #require(request.httpMethod) + #expect(httpMethod == "POST") + let urlString = try #require(request.url?.absoluteString) + #expect(urlString == "https://workout.su/api/v3/test") } + // MARK: - Хедеры и тело + @Test - func urlRequestCreationWithAuthToken() throws { - let body = Data("test body".utf8) - let requestComponents = RequestComponents( - path: "/test", + func multipartFormDataHeaders() throws { + let components = RequestComponents( + path: "/upload", httpMethod: .post, - body: body, - token: "token123" - ) - let urlRequest = try #require(requestComponents.urlRequest) - let headers = try #require(urlRequest.allHTTPHeaderFields) - #expect(urlRequest.httpBody == body) - #expect(headers.count == 2) - #expect(headers["Content-Length"] == "\(body.count)") - #expect(headers["Authorization"] == "Basic token123") + hasMultipartFormData: true, + bodyParts: .init(["text": "value"], nil), + boundary: "BOUNDARY123" + ) + let request = try #require(components.urlRequest) + let headers = try #require(request.allHTTPHeaderFields) + let contentType = try #require(headers["Content-Type"]) + #expect(contentType == "multipart/form-data; boundary=BOUNDARY123") + let bodyData = try #require(request.httpBody) + let contentLength = try #require(headers["Content-Length"]) + #expect(contentLength == "\(bodyData.count)") } @Test - func urlRequestCreationWithoutAuthToken() throws { - let body = Data("test body".utf8) - let requestComponents = RequestComponents( - path: "/test", - httpMethod: .post, - body: body + func authorizationHeader() throws { + let components = RequestComponents( + path: "/secure", + httpMethod: .get, + token: "secret_token" ) - let urlRequest = try #require(requestComponents.urlRequest) - let headerFields = try #require(urlRequest.allHTTPHeaderFields) - #expect(urlRequest.httpBody == body) - #expect(headerFields == ["Content-Length": "\(body.count)"]) + let headers = try #require(components.urlRequest?.allHTTPHeaderFields) + let authHeader = try #require(headers["Authorization"]) + #expect(authHeader == "Basic secret_token") } @Test - func urlRequestCreationWithoutBody() throws { - let requestComponents = RequestComponents( - path: "/test", - httpMethod: .get, - body: nil, - token: "token" - ) - let urlRequest = try #require(requestComponents.urlRequest) - let headers = try #require(urlRequest.allHTTPHeaderFields) - #expect(headers.count == 1) - #expect(headers["Content-Length"] == nil) - #expect(headers["Authorization"] == "Basic token") + func urlEncodedBody() throws { + let params = ["name": "John", "age": "30"] + let components = RequestComponents( + path: "/form", + httpMethod: .post, + bodyParts: .init(params, nil) + ) + let request = try #require(components.urlRequest) + let bodyData = try #require(request.httpBody) + let bodyString = try #require(String(data: bodyData, encoding: .utf8)) + let sortedBodyString = bodyString.components(separatedBy: "&").sorted().joined(separator: "&") + #expect(sortedBodyString == "age=30&name=John") } @Test - func urlRequestCreationWithMultipartFormData() throws { - let body = Data("test".utf8) - let requestComponents = RequestComponents( + func multipartBodyContent() throws { + let media = BodyMaker.MediaFile( + key: "file", + filename: "test.txt", + data: Data("content".utf8), + mimeType: "text/plain" + ) + let components = RequestComponents( path: "/upload", httpMethod: .post, hasMultipartFormData: true, - body: body, - token: "token" - ) - let urlRequest = try #require(requestComponents.urlRequest) - let headers = try #require(urlRequest.allHTTPHeaderFields) - #expect(headers.count == 3) - #expect(headers["Content-Type"] == "multipart/form-data; boundary=FFF") - #expect(headers["Content-Length"] == "\(body.count)") - #expect(headers["Authorization"] == "Basic token") + bodyParts: .init(["title": "Doc"], [media]), + boundary: "TESTBOUNDARY" + ) + let request = try #require(components.urlRequest) + let bodyData = try #require(request.httpBody) + let bodyString = try #require(String(data: bodyData, encoding: .utf8)) + + let expectedPatterns = [ + "--TESTBOUNDARY\r\n", + "Content-Disposition: form-data; name=\"title\"\r\n\r\nDoc\r\n", + "Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n", + "Content-Type: text/plain\r\n\r\ncontent\r\n", + "--TESTBOUNDARY--\r\n" + ] + + for pattern in expectedPatterns { + #expect(bodyString.contains(pattern)) + } } + // MARK: - Едж-кейсы + @Test - func urlGenerationWithSpecialCharacters() throws { - let requestComponents = RequestComponents( - path: "/test path", - queryItems: [URLQueryItem(name: "key", value: "value with space")], + func emptyRequestComponents() throws { + let components = RequestComponents( + path: "/", httpMethod: .get ) - let resultURL = try #require(requestComponents.url) - let expectedString = "https://workout.su/api/v3/test%20path?key=value%20with%20space" - #expect(resultURL.absoluteString == expectedString) + let request = try #require(components.urlRequest) + #expect(request.url?.absoluteString == "https://workout.su/api/v3/") + } + + @Test + func invalidTokenHandling() throws { + let components = RequestComponents( + path: "/secure", + httpMethod: .get, + token: "" + ) + let headers = try #require(components.urlRequest?.allHTTPHeaderFields) + #expect(!headers.keys.contains("Authorization")) + } + + @Test + func missingBodyHandling() throws { + let components = RequestComponents( + path: "/empty", + httpMethod: .post + ) + let request = try #require(components.urlRequest) + #expect(request.httpBody == nil) + } + + @Test + func urlWithEncodedQueryItems() throws { + let components = RequestComponents( + path: "/search", + queryItems: [URLQueryItem(name: "q", value: "test&value")], + httpMethod: .get + ) + let query = try #require(components.url?.query) + // Проверка кодировки амперсанда + #expect(query == "q=test%26value") + } + + @Test + func emptyParameterValueHandling() throws { + let components = RequestComponents( + path: "/form", + httpMethod: .post, + bodyParts: .init(["empty": ""], nil) + ) + let request = try #require(components.urlRequest) + let bodyString = try #require(request.httpBody.flatMap { String(data: $0, encoding: .utf8) }) + #expect(bodyString == "empty=") + } + + @Test + func largeDataHandling() throws { + // 10 MB данных + let bigData = Data(repeating: 0x55, count: 10_000_000) + let media = BodyMaker.MediaFile( + key: "bigfile", + filename: "large.bin", + data: bigData, + mimeType: "application/octet-stream" + ) + + let components = RequestComponents( + path: "/upload", + httpMethod: .post, + hasMultipartFormData: true, + bodyParts: .init([:], [media]), + boundary: "BIGBOUNDARY" + ) + + let request = try #require(components.urlRequest) + let contentLength = try #require(request.allHTTPHeaderFields?["Content-Length"]) + let expectedSize = request.httpBody?.count ?? 0 + #expect(contentLength == "\(expectedSize)") } } diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/Endpoint.swift b/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/Endpoint.swift index 0871ad81..b4510fc1 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/Endpoint.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/Endpoint.swift @@ -414,7 +414,7 @@ extension Endpoint { case classID = "class_id" } - var httpBody: Data? { + var bodyParts: BodyMaker.Parts? { switch self { case .login, .getUser, .getFriendsForUser, .getFriendRequests, .acceptFriendRequest, .declineFriendRequest, .findUsers, @@ -432,27 +432,25 @@ extension Endpoint { .deleteEventPhoto, .deleteParkPhoto: return nil case let .registration(form): - return BodyMaker.makeBody( - with: [ - ParameterKey.name: form.userName, - .fullname: form.fullName, - .email: form.email, - .password: form.password, - .genderCode: form.genderCode.description, - .countryID: form.country.id, - .cityID: form.city.id, - .birthDate: form.birthDateIsoString - ].map(BodyMaker.Parameter.init) - ) + return .init([ + ParameterKey.name.rawValue: form.userName, + ParameterKey.fullname.rawValue: form.fullName, + ParameterKey.email.rawValue: form.email, + ParameterKey.password.rawValue: form.password, + ParameterKey.genderCode.rawValue: form.genderCode.description, + ParameterKey.countryID.rawValue: form.country.id, + ParameterKey.cityID.rawValue: form.city.id, + ParameterKey.birthDate.rawValue: form.birthDateIsoString + ], nil) case let .editUser(_, form): let parameters = [ - ParameterKey.name: form.userName, - .fullname: form.fullName, - .email: form.email, - .genderCode: form.genderCode.description, - .countryID: form.country.id, - .cityID: form.city.id, - .birthDate: form.birthDateIsoString + ParameterKey.name.rawValue: form.userName, + ParameterKey.fullname.rawValue: form.fullName, + ParameterKey.email.rawValue: form.email, + ParameterKey.genderCode.rawValue: form.genderCode.description, + ParameterKey.countryID.rawValue: form.country.id, + ParameterKey.cityID.rawValue: form.city.id, + ParameterKey.birthDate.rawValue: form.birthDateIsoString ] let mediaFiles: [BodyMaker.MediaFile]? = if let image = form.image { [ @@ -466,90 +464,75 @@ extension Endpoint { } else { nil } - return BodyMaker.makeBodyWithMultipartForm( - with: parameters.map(BodyMaker.Parameter.init), - and: mediaFiles - ) + return .init(parameters, mediaFiles) case let .resetPassword(login): - return BodyMaker.makeBody( - with: [ParameterKey.usernameOrEmail: login].map(BodyMaker.Parameter.init) - ) + return .init([ParameterKey.usernameOrEmail.rawValue: login], nil) case let .changePassword(current, new): - return BodyMaker.makeBody( - with: [ParameterKey.password: current, .newPassword: new].map(BodyMaker.Parameter.init) - ) + return .init([ + ParameterKey.password.rawValue: current, + ParameterKey.newPassword.rawValue: new + ], nil) case let .addCommentToPark(_, comment), let .addCommentToEvent(_, comment), let .editParkComment(_, _, comment), let .editEventComment(_, _, comment): - return BodyMaker.makeBody( - with: [ParameterKey.comment: comment].map(BodyMaker.Parameter.init) - ) + return .init([ParameterKey.comment.rawValue: comment], nil) case let .sendMessageTo(message, _): - return BodyMaker.makeBody( - with: [ParameterKey.message: message].map(BodyMaker.Parameter.init) - ) + return .init([ParameterKey.message.rawValue: message], nil) case let .markAsRead(userID): - return BodyMaker.makeBody( - with: [ParameterKey.fromUserID: userID.description].map(BodyMaker.Parameter.init) - ) + return .init([ParameterKey.fromUserID.rawValue: userID.description], nil) case let .createJournal(_, title): - return BodyMaker.makeBody( - with: [ParameterKey.title: title].map(BodyMaker.Parameter.init) - ) + return .init([ParameterKey.title.rawValue: title], nil) case let .saveJournalEntry(_, _, message), let .editEntry(_, _, _, message): - return BodyMaker.makeBody( - with: [ParameterKey.message: message].map(BodyMaker.Parameter.init) - ) + return .init([ParameterKey.message.rawValue: message], nil) case let .editJournalSettings(_, _, title, viewAccess, commentAccess): - return BodyMaker.makeBody( - with: [ - ParameterKey.title: title, - .viewAccess: viewAccess.description, - .commentAccess: commentAccess.description - ].map(BodyMaker.Parameter.init) + return .init( + [ + ParameterKey.title.rawValue: title, + ParameterKey.viewAccess.rawValue: viewAccess.description, + ParameterKey.commentAccess.rawValue: commentAccess.description + ], + nil ) case let .createEvent(form), let .editEvent(_, form): let parameters = [ - ParameterKey.title: form.title, - .description: form.description, - .date: form.dateIsoString, - .areaID: form.parkID.description + ParameterKey.title.rawValue: form.title, + ParameterKey.description.rawValue: form.description, + ParameterKey.date.rawValue: form.dateIsoString, + ParameterKey.areaID.rawValue: form.parkID.description ] - let mediaFiles: [BodyMaker.MediaFile] = form.newMediaFiles.map { - .init( - key: $0.key, - filename: $0.filename, - data: $0.data, - mimeType: $0.mimeType - ) - } - return BodyMaker.makeBodyWithMultipartForm( - with: parameters.map(BodyMaker.Parameter.init), - and: mediaFiles - ) + let mediaFiles: [BodyMaker.MediaFile]? = form.newMediaFiles.isEmpty + ? nil + : form.newMediaFiles.map { + BodyMaker.MediaFile( + key: $0.key, + filename: $0.filename, + data: $0.data, + mimeType: $0.mimeType + ) + } + return .init(parameters, mediaFiles) case let .createPark(form), let .editPark(_, form): let parameters = [ - ParameterKey.address: form.address, - .latitude: form.latitude, - .longitude: form.longitude, - .cityID: form.cityID.description, - .typeID: form.typeID.description, - .classID: form.sizeID.description + ParameterKey.address.rawValue: form.address, + ParameterKey.latitude.rawValue: form.latitude, + ParameterKey.longitude.rawValue: form.longitude, + ParameterKey.cityID.rawValue: form.cityID.description, + ParameterKey.typeID.rawValue: form.typeID.description, + ParameterKey.classID.rawValue: form.sizeID.description ] - let mediaFiles: [BodyMaker.MediaFile] = form.newMediaFiles.map { - .init( - key: $0.key, - filename: $0.filename, - data: $0.data, - mimeType: $0.mimeType - ) - } - return BodyMaker.makeBodyWithMultipartForm( - with: parameters.map(BodyMaker.Parameter.init), - and: mediaFiles - ) + let mediaFiles: [BodyMaker.MediaFile]? = form.newMediaFiles.isEmpty + ? nil + : form.newMediaFiles.map { + BodyMaker.MediaFile( + key: $0.key, + filename: $0.filename, + data: $0.data, + mimeType: $0.mimeType + ) + } + return .init(parameters, mediaFiles) } } } diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/SWClient.swift b/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/SWClient.swift index 209ee2b1..1556b5b9 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/SWClient.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetworkClient/Sources/SWNetworkClient/SWClient.swift @@ -62,7 +62,6 @@ public struct SWClient: Sendable { /// - Parameters: /// - userID: `id` пользователя /// - Returns: вся информация о пользователе - @discardableResult public func getUserByID(_ userID: Int) async throws -> UserResponse { let endpoint = Endpoint.getUser(id: userID) return try await makeResult(for: endpoint) @@ -544,7 +543,7 @@ private extension SWClient { queryItems: endpoint.queryItems, httpMethod: endpoint.method, hasMultipartFormData: endpoint.hasMultipartFormData, - body: endpoint.httpBody, + bodyParts: endpoint.bodyParts, token: token ?? savedToken ) } diff --git a/SwiftUI-WorkoutApp/Resources/Info.plist b/SwiftUI-WorkoutApp/Resources/Info.plist index 9424126a..760de41c 100644 --- a/SwiftUI-WorkoutApp/Resources/Info.plist +++ b/SwiftUI-WorkoutApp/Resources/Info.plist @@ -2,6 +2,19 @@ + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + com.FGU.WorkOut + CFBundleURLSchemes + + swparks + + + ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes @@ -9,7 +22,5 @@ maps mailto - NSPhotoLibraryUsageDescription - Для выбора фото профиля требуется доступ к галерее diff --git a/SwiftUI-WorkoutApp/Resources/Localizable.xcstrings b/SwiftUI-WorkoutApp/Resources/Localizable.xcstrings index cf973d5d..77954f8c 100644 --- a/SwiftUI-WorkoutApp/Resources/Localizable.xcstrings +++ b/SwiftUI-WorkoutApp/Resources/Localizable.xcstrings @@ -211,6 +211,42 @@ } } }, + "ImageErrorDataLoadingFailed" : { + "comment" : "PhotosPicker", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to retrieve data from the gallery" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось получить данные из галереи" + } + } + } + }, + "ImageErrorImageCreationFailed" : { + "comment" : "PhotosPicker", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to create the image" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось создать изображение" + } + } + } + }, "journalsCount" : { "extractionState" : "manual", "localizations" : { @@ -2335,6 +2371,17 @@ } } }, + "Не удалось найти такого пользователя" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not find such a user" + } + } + } + }, "Не указан" : { "extractionState" : "manual", "localizations" : { diff --git a/SwiftUI-WorkoutApp/Screens/Common/ImagePicker/ImagePickerViews.swift b/SwiftUI-WorkoutApp/Screens/Common/ImagePicker/ImagePickerViews.swift new file mode 100644 index 00000000..1c3ecab3 --- /dev/null +++ b/SwiftUI-WorkoutApp/Screens/Common/ImagePicker/ImagePickerViews.swift @@ -0,0 +1,57 @@ +import SWDesignSystem +import SwiftUI + +enum ImagePickerViews {} + +extension ImagePickerViews { + static func makeHeaderString(for count: Int) -> String { + String.localizedStringWithFormat( + "photoSectionHeader".localized, + count + ) + } + + @ViewBuilder @MainActor + static func makeSubtitleView(selectionLimit: Int, isEmpty: Bool) -> some View { + let subtitle = if selectionLimit > 0 { + isEmpty + ? String(format: NSLocalizedString("Добавьте фото, максимум %lld", comment: ""), selectionLimit) + : String(format: NSLocalizedString("Можно добавить ещё %lld", comment: ""), selectionLimit) + } else { + "Добавлено максимальное количество фотографий".localized + } + Text(subtitle) + .font(.subheadline) + .foregroundStyle(Color.swMainText) + .multilineTextAlignment(.leading) + } + + @ViewBuilder @MainActor + static func makeGridView( + items: [PickedImageView.Model], + action: @escaping (_ index: Int, _ action: PickedImageView.Action) -> Void + ) -> some View { + LazyVGrid( + columns: .init( + repeating: .init( + .flexible(minimum: UIScreen.main.bounds.size.width * 0.287), + spacing: 11 + ), + count: 3 + ), + spacing: 12 + ) { + ForEach(Array(zip(items.indices, items)), id: \.0) { index, model in + GeometryReader { geo in + PickedImageView( + model: model, + height: geo.size.width, + action: { action(index, $0) } + ) + } + .aspectRatio(1, contentMode: .fit) + .cornerRadius(8) + } + } + } +} diff --git a/SwiftUI-WorkoutApp/Screens/Common/ImagePicker/ModernPickedImagesGrid.swift b/SwiftUI-WorkoutApp/Screens/Common/ImagePicker/ModernPickedImagesGrid.swift new file mode 100644 index 00000000..4df68695 --- /dev/null +++ b/SwiftUI-WorkoutApp/Screens/Common/ImagePicker/ModernPickedImagesGrid.swift @@ -0,0 +1,162 @@ +import PhotosUI +import SWDesignSystem +import SwiftUI +import SWUtils + +/// Сетка для добавления фотографий с использованием `PhotosPicker` +@available(iOS 16.0, *) +struct ModernPickedImagesGrid: View { + private var imagesArray: [PickedImageView.Model] { + var realImages = images.map(PickedImageView.Model.image) + if selectionLimit > 0 { + realImages.append(.addImageButton) + } + return realImages + } + + @State private var fullscreenImageInfo: PhotoDetailScreen.Model? + @State private var selectedItems = [PhotosPickerItem]() + @State private var isLoading = false + @Binding var images: [UIImage] + @Binding var showImagePicker: Bool + let selectionLimit: Int + + var body: some View { + SectionView(header: .init(header), mode: .regular) { + VStack(alignment: .leading, spacing: 12) { + ImagePickerViews.makeSubtitleView( + selectionLimit: selectionLimit, + isEmpty: images.isEmpty + ) + ImagePickerViews.makeGridView( + items: imagesArray, + action: { index, option in + switch option { + case .addImage: + showImagePicker.toggle() + case .deleteImage: + deletePhoto(at: index) + case let .showDetailImage(uiImage): + fullscreenImageInfo = .init(uiImage: uiImage, id: index) + } + } + ) + } + .fullScreenCover(item: $fullscreenImageInfo) { + fullscreenImageInfo = nil + } content: { model in + PhotoDetailScreen( + model: model, + canDelete: true, + reportPhotoClbk: {}, + deletePhotoClbk: deletePhoto + ) + } + } + .loadingOverlay(if: isLoading) + .photosPicker( + isPresented: $showImagePicker, + selection: $selectedItems, + matching: .any(of: [.images, .panoramas]) + ) + .task(id: selectedItems) { + do { + isLoading.toggle() + // TODO: Вывод картинок тяжелая задача, можно оптимизировать + let newImages = try await loadImages(from: selectedItems) + images.append(contentsOf: newImages) + } catch { + SWAlert.shared.presentDefaultUIKit(message: error.localizedDescription) + } + selectedItems.removeAll() + isLoading.toggle() + } + } +} + +@available(iOS 16.0, *) +private extension ModernPickedImagesGrid { + var header: String { ImagePickerViews.makeHeaderString(for: images.count) } + + func loadImages(from selectedItems: [PhotosPickerItem]) async throws -> [UIImage] { + try await withThrowingTaskGroup(of: UIImage.self) { group in + for item in selectedItems { + group.addTask { + guard let data = try await item.loadTransferable(type: Data.self) else { + throw ImageError.dataLoadingFailed + } + guard let image = UIImage(data: data) else { + throw ImageError.imageCreationFailed + } + return image + } + } + var images = [UIImage]() + for try await image in group { + images.append(image) + } + return images + } + } + + func deletePhoto(at index: Int) { + images.remove(at: index) + fullscreenImageInfo = nil + } + + enum ImageError: Error, LocalizedError { + case dataLoadingFailed + case imageCreationFailed + + var errorDescription: String? { + switch self { + case .dataLoadingFailed: "ImageErrorDataLoadingFailed".localized + case .imageCreationFailed: "ImageErrorImageCreationFailed".localized + } + } + } +} + +#if DEBUG +@available(iOS 16.0, *) +#Preview("Лимит 10, есть 0") { + ModernPickedImagesGrid( + images: .constant([]), + showImagePicker: .constant(false), + selectionLimit: 10 + ) +} + +@available(iOS 16.0, *) +#Preview("Лимит 7, есть 3") { + let images: [UIImage] = Array(1 ... 3).map { + .init(systemName: "\($0).circle.fill")! + } + ModernPickedImagesGrid( + images: .constant(images), + showImagePicker: .constant(false), + selectionLimit: 7 + ) +} + +@available(iOS 16.0, *) +#Preview("Лимит 0, есть 10") { + let images: [UIImage] = Array(1 ... 10).map { + .init(systemName: "\($0).circle.fill")! + } + ModernPickedImagesGrid( + images: .constant(images), + showImagePicker: .constant(false), + selectionLimit: 0 + ) +} + +@available(iOS 16.0, *) +#Preview("Лимит 0, есть 0") { + ModernPickedImagesGrid( + images: .constant([]), + showImagePicker: .constant(false), + selectionLimit: 0 + ) +} +#endif diff --git a/SwiftUI-WorkoutApp/Screens/Common/ImagePicker/PickedImagesGrid.swift b/SwiftUI-WorkoutApp/Screens/Common/ImagePicker/PickedImagesGrid.swift index a763cf27..2887f78c 100644 --- a/SwiftUI-WorkoutApp/Screens/Common/ImagePicker/PickedImagesGrid.swift +++ b/SwiftUI-WorkoutApp/Screens/Common/ImagePicker/PickedImagesGrid.swift @@ -26,43 +26,53 @@ struct PickedImagesGrid: View { let processExtraImages: () -> Void var body: some View { + Group { + if #available(iOS 16.0, *) { + ModernPickedImagesGrid( + images: $images, + showImagePicker: $showImagePicker, + selectionLimit: selectionLimit + ) + } else { + oldContentView + .sheet(isPresented: $showImagePicker) { + processExtraImages() + } content: { + ImagePicker( + pickedImages: $images, + selectionLimit: selectionLimit, + compressionQuality: 0 + ) + } + } + } + .animation(.default, value: images.count) + } +} + +private extension PickedImagesGrid { + var header: String { ImagePickerViews.makeHeaderString(for: images.count) } + + var oldContentView: some View { SectionView(header: .init(header), mode: .regular) { VStack(alignment: .leading, spacing: 12) { - Text(subtitle) - .font(.subheadline) - .foregroundStyle(Color.swMainText) - .multilineTextAlignment(.leading) - LazyVGrid( - columns: .init( - repeating: .init( - .flexible(minimum: screenWidth * 0.287), - spacing: 11 - ), - count: 3 - ), - spacing: 12 - ) { - ForEach(Array(zip(imagesArray.indices, imagesArray)), id: \.0) { index, model in - GeometryReader { geo in - PickedImageView( - model: model, - height: geo.size.width, - action: { option in - switch option { - case .addImage: - showImagePicker.toggle() - case .deleteImage: - deletePhoto(at: index) - case let .showDetailImage(uiImage): - fullscreenImageInfo = .init(uiImage: uiImage, id: index) - } - } - ) + ImagePickerViews.makeSubtitleView( + selectionLimit: selectionLimit, + isEmpty: images.isEmpty + ) + ImagePickerViews.makeGridView( + items: imagesArray, + action: { index, option in + switch option { + case .addImage: + showImagePicker.toggle() + case .deleteImage: + deletePhoto(at: index) + case let .showDetailImage(uiImage): + fullscreenImageInfo = .init(uiImage: uiImage, id: index) } - .aspectRatio(1, contentMode: .fit) - .cornerRadius(8) } - } + ) } .fullScreenCover(item: $fullscreenImageInfo) { fullscreenImageInfo = nil @@ -75,33 +85,6 @@ struct PickedImagesGrid: View { ) } } - .sheet(isPresented: $showImagePicker) { - processExtraImages() - } content: { - ImagePicker( - pickedImages: $images, - selectionLimit: selectionLimit, - compressionQuality: 0 - ) - } - } -} - -private extension PickedImagesGrid { - var header: String { - String.localizedStringWithFormat( - "photoSectionHeader".localized, - images.count - ) - } - - var subtitle: String { - guard selectionLimit > 0 else { - return "Добавлено максимальное количество фотографий".localized - } - return images.isEmpty - ? String(format: NSLocalizedString("Добавьте фото, максимум %lld", comment: ""), selectionLimit) - : String(format: NSLocalizedString("Можно добавить ещё %lld", comment: ""), selectionLimit) } func deletePhoto(at index: Int) { diff --git a/SwiftUI-WorkoutApp/Screens/Messages/DialogScreen.swift b/SwiftUI-WorkoutApp/Screens/Messages/DialogScreen.swift index 8e3b6ef3..481d413c 100644 --- a/SwiftUI-WorkoutApp/Screens/Messages/DialogScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Messages/DialogScreen.swift @@ -38,6 +38,7 @@ struct DialogScreen: View { sendMessageBar } } + .loadingOverlay(if: isLoading) .background(Color.swBackground) .task(priority: .low) { await markAsRead() } .task(priority: .high) { await askForMessages() } @@ -178,6 +179,7 @@ private extension DialogScreen { func sendMessage() { isLoading = true + isMessageBarFocused = false sendMessageTask = Task(priority: .userInitiated) { do { let userID = dialog.anotherUserID ?? 0 diff --git a/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift b/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift index 3d2bebd0..68851a08 100644 --- a/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Messages/DialogsListScreen.swift @@ -8,9 +8,8 @@ import SWUtils struct DialogsListScreen: View { @Environment(\.isNetworkConnected) private var isNetworkConnected @EnvironmentObject private var defaults: DefaultsService - @State private var dialogs = [DialogResponse]() + @EnvironmentObject private var viewModel: DialogsViewModel @State private var selectedDialog: DialogResponse? - @State private var isLoading = false @State private var indexToDelete: Int? @State private var openFriendList = false @State private var showDeleteConfirmation = false @@ -34,7 +33,8 @@ struct DialogsListScreen: View { .navigationTitle("Сообщения") } .navigationViewStyle(.stack) - .task { await askForDialogs() } + .onChange(of: defaults.isAuthorized, perform: viewModel.clearDialogsOnLogout) + .task(id: defaults.isAuthorized) { await askForDialogs() } } } @@ -42,14 +42,13 @@ private extension DialogsListScreen { var authorizedContentView: some View { dialogList .overlay { emptyContentView } - .loadingOverlay(if: isLoading) + .loadingOverlay(if: viewModel.isLoading) .background(Color.swBackground) .confirmationDialog( .init(Constants.Alert.deleteDialog), isPresented: $showDeleteConfirmation, titleVisibility: .visible ) { deleteDialogButton } - .refreshable { await askForDialogs(refresh: true) } .toolbar { ToolbarItem(placement: .topBarLeading) { refreshButton @@ -58,9 +57,6 @@ private extension DialogsListScreen { friendListButton } } - .onDisappear { - [refreshTask, deleteDialogTask].forEach { $0?.cancel() } - } } var refreshButton: some View { @@ -71,8 +67,8 @@ private extension DialogsListScreen { } label: { Icons.Regular.refresh.view } - .opacity(showEmptyView || !DeviceOSVersionChecker.iOS16Available ? 1 : 0) - .disabled(isLoading) + .opacity(viewModel.showEmptyView ? 1 : 0) + .disabled(viewModel.isLoading || !isNetworkConnected) } var friendListButton: some View { @@ -86,20 +82,16 @@ private extension DialogsListScreen { Icons.Regular.plus.view .symbolVariant(.circle) } - .opacity(hasFriends || !dialogs.isEmpty ? 1 : 0) + .opacity(hasFriends || viewModel.hasDialogs ? 1 : 0) .disabled(!isNetworkConnected) } var emptyContentView: some View { EmptyContentView( mode: .dialogs, - action: emptyViewAction + action: { openFriendList.toggle() } ) - .opacity(showEmptyView ? 1 : 0) - } - - var showEmptyView: Bool { - dialogs.isEmpty && !isLoading + .opacity(viewModel.showEmptyView ? 1 : 0) } @ViewBuilder @@ -107,7 +99,7 @@ private extension DialogsListScreen { ZStack { Color.swBackground List { - ForEach(dialogs) { model in + ForEach(viewModel.dialogs) { model in dialogListItem(model) .listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16)) .listRowBackground(Color.swBackground) @@ -116,9 +108,10 @@ private extension DialogsListScreen { .onDelete { initiateDeletion(at: $0) } } .listStyle(.plain) - .opacity(dialogs.isEmpty ? 0 : 1) + .opacity(viewModel.hasDialogs ? 1 : 0) + .refreshable { await askForDialogs(refresh: true) } } - .animation(.default, value: dialogs.count) + .animation(.default, value: viewModel.dialogs.count) .background( NavigationLink( destination: lazyDestination, @@ -130,7 +123,12 @@ private extension DialogsListScreen { @ViewBuilder var lazyDestination: some View { if let selectedDialog { - DialogScreen(dialog: selectedDialog) { markAsRead($0) } + DialogScreen( + dialog: selectedDialog, + markedAsReadClbk: { dialog in + viewModel.markAsRead(dialog, defaults: defaults) + } + ) } } @@ -162,39 +160,12 @@ private extension DialogsListScreen { defaults.hasFriends } - func emptyViewAction() { - openFriendList.toggle() - } - - func markAsRead(_ dialog: DialogResponse) { - dialogs = dialogs.map { item in - if item.id == dialog.id { - var updatedDialog = dialog - updatedDialog.unreadMessagesCount = 0 - return updatedDialog - } else { - return item - } - } - guard dialog.unreadMessagesCount > 0, - defaults.unreadMessagesCount >= dialog.unreadMessagesCount - else { return } - let newValue = defaults.unreadMessagesCount - dialog.unreadMessagesCount - defaults.saveUnreadMessagesCount(newValue) - } - func askForDialogs(refresh: Bool = false) async { - guard defaults.isAuthorized else { return } - if isLoading || (!dialogs.isEmpty && !refresh) { return } - if !refresh { isLoading = true } do { - dialogs = try await client.getDialogs() - let unreadMessagesCount = dialogs.map(\.unreadMessagesCount).reduce(0, +) - defaults.saveUnreadMessagesCount(unreadMessagesCount) + try await viewModel.askForDialogs(refresh: refresh, defaults: defaults) } catch { SWAlert.shared.presentDefaultUIKit(message: error.localizedDescription) } - isLoading = false } func initiateDeletion(at indexSet: IndexSet) { @@ -204,17 +175,11 @@ private extension DialogsListScreen { func deleteAction(at index: Int?) { deleteDialogTask = Task { - guard let index, !isLoading else { return } - isLoading = true do { - let dialogID = dialogs[index].id - if try await client.deleteDialog(dialogID) { - dialogs.remove(at: index) - } + try await viewModel.deleteDialog(at: index, defaults: defaults) } catch { SWAlert.shared.presentDefaultUIKit(message: error.localizedDescription) } - isLoading = false } } } diff --git a/SwiftUI-WorkoutApp/Screens/Messages/DialogsViewModel.swift b/SwiftUI-WorkoutApp/Screens/Messages/DialogsViewModel.swift new file mode 100644 index 00000000..f64ef1af --- /dev/null +++ b/SwiftUI-WorkoutApp/Screens/Messages/DialogsViewModel.swift @@ -0,0 +1,60 @@ +import Foundation +import SWModels +import SWNetworkClient +import SWUtils + +final class DialogsViewModel: ObservableObject { + @Published private(set) var dialogs = [DialogResponse]() + @Published private(set) var isLoading = false + var hasDialogs: Bool { !dialogs.isEmpty } + var showEmptyView: Bool { !hasDialogs && !isLoading } + + @MainActor + func askForDialogs( + refresh: Bool = false, + defaults: DefaultsService + ) async throws { + guard defaults.isAuthorized else { return } + if isLoading || (!dialogs.isEmpty && !refresh) { return } + if !refresh || dialogs.isEmpty { isLoading = true } + dialogs = try await SWClient(with: defaults).getDialogs() + let unreadMessagesCount = dialogs.map(\.unreadMessagesCount).reduce(0, +) + defaults.saveUnreadMessagesCount(unreadMessagesCount) + isLoading = false + } + + @MainActor + func deleteDialog(at index: Int?, defaults: DefaultsService) async throws { + guard let index, !isLoading else { return } + isLoading = true + let dialogID = dialogs[index].id + if try await SWClient(with: defaults).deleteDialog(dialogID) { + dialogs.remove(at: index) + } + isLoading = false + } + + @MainActor + func markAsRead(_ dialog: DialogResponse, defaults: DefaultsService) { + dialogs = dialogs.map { item in + if item.id == dialog.id { + var updatedDialog = dialog + updatedDialog.unreadMessagesCount = 0 + return updatedDialog + } else { + return item + } + } + guard dialog.unreadMessagesCount > 0, + defaults.unreadMessagesCount >= dialog.unreadMessagesCount + else { return } + let newValue = defaults.unreadMessagesCount - dialog.unreadMessagesCount + defaults.saveUnreadMessagesCount(newValue) + } + + @MainActor + func clearDialogsOnLogout(isAuthorized: Bool) { + guard !isAuthorized else { return } + dialogs.removeAll() + } +} diff --git a/SwiftUI-WorkoutApp/Screens/Profile/SearchUsersScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/SearchUsersScreen.swift index 578485f9..058988f0 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/SearchUsersScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/SearchUsersScreen.swift @@ -38,6 +38,7 @@ struct SearchUsersScreen: View { .onSubmit(of: .search, search) .loadingOverlay(if: isLoading) .background(Color.swBackground) + .navigationBarBackButtonHidden(mode == .chat) .toolbar { ToolbarItem(placement: .topBarLeading) { CloseButton(mode: .text) { dismiss() } @@ -130,7 +131,7 @@ private extension SearchUsersScreen { .findUsers(with: query.withoutSpaces) users = foundUsers if foundUsers.isEmpty { - SWAlert.shared.presentDefaultUIKit(message: "Не удалось найти такого пользователя") + SWAlert.shared.presentDefaultUIKit(message: "Не удалось найти такого пользователя".localized) } } catch { SWAlert.shared.presentDefaultUIKit(message: error.localizedDescription) diff --git a/SwiftUI-WorkoutApp/Screens/Root/RootScreen.swift b/SwiftUI-WorkoutApp/Screens/Root/RootScreen.swift index c520de9c..d7a0cd0f 100644 --- a/SwiftUI-WorkoutApp/Screens/Root/RootScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Root/RootScreen.swift @@ -2,8 +2,8 @@ import SWDesignSystem import SwiftUI struct RootScreen: View { - @Environment(\.userFlags) private var userFlags @Binding var selectedTab: TabViewModel.Tab + let unreadCount: Int var body: some View { TabView(selection: $selectedTab) { @@ -11,6 +11,7 @@ struct RootScreen: View { tab.screen .tabItem { tab.tabItemLabel } .tag(tab) + .badge(tab == .messages ? unreadCount : 0) } } .navigationViewStyle(.stack) @@ -18,9 +19,21 @@ struct RootScreen: View { } #if DEBUG -#Preview { - RootScreen(selectedTab: .constant(.map)) - .environmentObject(ParksManager()) - .environmentObject(DefaultsService()) +#Preview("Есть бейдж для чатов") { + RootScreen( + selectedTab: .constant(.map), + unreadCount: 1 + ) + .environmentObject(ParksManager()) + .environmentObject(DefaultsService()) +} + +#Preview("Нет бейджа") { + RootScreen( + selectedTab: .constant(.map), + unreadCount: 0 + ) + .environmentObject(ParksManager()) + .environmentObject(DefaultsService()) } #endif diff --git a/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift b/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift index c260d98d..2df92457 100644 --- a/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift +++ b/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift @@ -11,8 +11,10 @@ struct SwiftUI_WorkoutAppApp: App { @StateObject private var defaults = DefaultsService() @StateObject private var network = NetworkStatus() @StateObject private var parksManager = ParksManager() + @StateObject private var dialogsViewModel = DialogsViewModel() @State private var countriesUpdateTask: Task? @State private var socialUpdateTask: Task? + @State private var dialogsUpdateTask: Task? private let countriesStorage = SWAddress() private var client: SWClient { SWClient(with: defaults) } private var colorScheme: ColorScheme? { @@ -30,28 +32,24 @@ struct SwiftUI_WorkoutAppApp: App { var body: some Scene { WindowGroup { - RootScreen(selectedTab: $tabViewModel.selectedTab) - .environmentObject(tabViewModel) - .environmentObject(network) - .environmentObject(defaults) - .environmentObject(parksManager) - .preferredColorScheme(colorScheme) - .environment(\.isNetworkConnected, network.isConnected) - .environment(\.userFlags, defaults.userFlags) + RootScreen( + selectedTab: $tabViewModel.selectedTab, + unreadCount: defaults.unreadMessagesCount + ) + .environmentObject(tabViewModel) + .environmentObject(network) + .environmentObject(defaults) + .environmentObject(parksManager) + .environmentObject(dialogsViewModel) + .preferredColorScheme(colorScheme) + .environment(\.isNetworkConnected, network.isConnected) + .environment(\.userFlags, defaults.userFlags) } .onChange(of: scenePhase) { phase in switch phase { case .active: updateCountriesIfNeeded() - guard let mainUserId = defaults.mainUserInfo?.id else { return } - socialUpdateTask = Task { - if let result = await client.getSocialUpdates(userID: mainUserId) { - try? defaults.saveFriendsIds(result.friends.map(\.id)) - try? defaults.saveFriendRequests(result.friendRequests) - try? defaults.saveBlacklist(result.blacklist) - defaults.setUserNeedUpdate(false) - } - } + updateSocialInfoIfNeeded() default: [socialUpdateTask, countriesUpdateTask].forEach { $0?.cancel() } defaults.setUserNeedUpdate(true) @@ -68,6 +66,21 @@ struct SwiftUI_WorkoutAppApp: App { } } } + + private func updateSocialInfoIfNeeded() { + guard let mainUserId = defaults.mainUserInfo?.id else { return } + socialUpdateTask = Task { + if let result = await client.getSocialUpdates(userID: mainUserId) { + try? defaults.saveFriendsIds(result.friends.map(\.id)) + try? defaults.saveFriendRequests(result.friendRequests) + try? defaults.saveBlacklist(result.blacklist) + defaults.setUserNeedUpdate(false) + } + } + dialogsUpdateTask = Task { + try? await dialogsViewModel.askForDialogs(refresh: true, defaults: defaults) + } + } } private extension SwiftUI_WorkoutAppApp { @@ -80,7 +93,13 @@ private extension SwiftUI_WorkoutAppApp { $0.backgroundColor = .init(Color.swBackground) $0.shadowColor = nil } + let tabBarItemAppearance = makeTabBarItemAppearance() + tabBarAppearance.inlineLayoutAppearance = tabBarItemAppearance + tabBarAppearance.stackedLayoutAppearance = tabBarItemAppearance + tabBarAppearance.compactInlineLayoutAppearance = tabBarItemAppearance + UITabBar.appearance().standardAppearance = tabBarAppearance UITabBar.appearance().scrollEdgeAppearance = tabBarAppearance + UINavigationBar.appearance().standardAppearance = navBarAppearance UINavigationBar.appearance().scrollEdgeAppearance = navBarAppearance fixAlertAccentColor() if !DeviceOSVersionChecker.iOS16Available { @@ -91,15 +110,27 @@ private extension SwiftUI_WorkoutAppApp { /// Исправляет баг с accentColor у алертов, [обсуждение](https://developer.apple.com/forums/thread/673147) /// /// Без этой настройки у всех алертов при первом появлении стандартный tintColor (синий), - /// а при нажатии он меняется на `AccentColor` в проекте + /// а при нажатии он меняется на AccentColor в проекте func fixAlertAccentColor() { UIView.appearance().tintColor = .accent } + /// Настройки цветовых параметров для табов в таббаре + func makeTabBarItemAppearance() -> UITabBarItemAppearance { + let tabBarItemAppearance = UITabBarItemAppearance() + tabBarItemAppearance.normal.iconColor = .init(.swSmallElements) + tabBarItemAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor(.swSmallElements)] + tabBarItemAppearance.normal.badgeBackgroundColor = .accent + tabBarItemAppearance.normal.badgeTextAttributes = [.foregroundColor: UIColor(.swBackground)] + return tabBarItemAppearance + } + + #if DEBUG func prepareForUITestIfNeeded() { if ProcessInfo.processInfo.arguments.contains("UITest") { UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!) UIView.setAnimationsEnabled(false) } } + #endif }