diff --git a/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj b/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj index 266b9b89..4ef85a61 100644 --- a/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj +++ b/SwiftUI-WorkoutApp.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 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 */; }; + 674000402D55E97900E5CB06 /* BlackListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6740003F2D55E97900E5CB06 /* BlackListScreen.swift */; }; 67419ACF282E70B9004F5339 /* ParksListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67419ACE282E70B9004F5339 /* ParksListScreen.swift */; }; 6747575628113419002F0A24 /* ChangePasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6747575528113419002F0A24 /* ChangePasswordScreen.swift */; }; 6747575928128603002F0A24 /* ParkDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6747575828128603002F0A24 /* ParkDetailScreen.swift */; }; @@ -103,6 +104,7 @@ 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 = ""; }; + 6740003F2D55E97900E5CB06 /* BlackListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlackListScreen.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 = ""; }; 6747575828128603002F0A24 /* ParkDetailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkDetailScreen.swift; sourceTree = ""; }; @@ -408,6 +410,7 @@ 674D061A28280A63007E75C6 /* FriendRequestsView.swift */, 674D0622282A9896007E75C6 /* SearchUsersScreen.swift */, 6765B2572D4544C8006164AB /* MainUserProfileScreen.swift */, + 6740003F2D55E97900E5CB06 /* BlackListScreen.swift */, ); path = Profile; sourceTree = ""; @@ -624,6 +627,7 @@ 6798AA40280AEDC900DB76F1 /* RootScreen.swift in Sources */, 671B4AE92D4F623100286996 /* ModernPickedImagesGrid.swift in Sources */, 675EC64F2814126800C2E229 /* TextEntryScreen.swift in Sources */, + 674000402D55E97900E5CB06 /* BlackListScreen.swift in Sources */, 674D0623282A9896007E75C6 /* SearchUsersScreen.swift in Sources */, 67A4710D2AEED8F8004D341D /* PastEventStorage.swift in Sources */, 675FB8DB2ADDB87200C9671F /* ParksMapScreen+LocationSettingReminderView.swift in Sources */, @@ -856,7 +860,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "SwiftUI-WorkoutApp/Preview\\ Content/PreviewContent.swift SwiftUI-WorkoutApp/Preview\\ Content"; DEVELOPMENT_TEAM = CR68PP2Z3F; ENABLE_PREVIEWS = YES; @@ -907,7 +911,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "SwiftUI-WorkoutApp/Preview\\ Content/PreviewContent.swift SwiftUI-WorkoutApp/Preview\\ Content"; DEVELOPMENT_TEAM = CR68PP2Z3F; ENABLE_PREVIEWS = YES; diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/Data+.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/Data+.swift index bba8e6d8..8c57998f 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/Data+.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/Data+.swift @@ -7,7 +7,7 @@ extension Data { let json = String(data: jsonData, encoding: .utf8) { json } else { - "" + "отсутствует" } } diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/ErrorResponse.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/ErrorResponse.swift index 129a68ad..9dde8418 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/ErrorResponse.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/ErrorResponse.swift @@ -1,12 +1,12 @@ import Foundation -public struct ErrorResponse: Codable { - public let errors: [String]? - public let name, message: String? - public let code, status: Int? - public let type: String? +struct ErrorResponse: Codable { + let errors: [String]? + let name, message: String? + let code, status: Int? + let type: String? - public var realCode: Int { + var realCode: Int { if let code, code != 0 { code } else { diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/HTTPHeaderField.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/HTTPHeaderField.swift index b30b7a26..f4e5ccb9 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/HTTPHeaderField.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/HTTPHeaderField.swift @@ -1,8 +1,8 @@ -public struct HTTPHeaderField: Equatable { +struct HTTPHeaderField: Equatable { let key: String let value: String - public init(key: String, value: String) { + init(key: String, value: String) { self.key = key self.value = value } diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/SWNetwork.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/SWNetwork.swift index 10dfa8c8..99c777d5 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/SWNetwork.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Sources/SWNetwork/SWNetwork.swift @@ -42,7 +42,6 @@ extension SWNetworkService: SWNetworkProtocol { guard let decodedResult = try? decoder.decode(T.self, from: data) else { throw log( APIError.decodingError, - code: response.statusCode, request: request, data: data, response: response @@ -52,10 +51,8 @@ extension SWNetworkService: SWNetworkProtocol { return decodedResult default: let errorInfo = try decoder.decode(ErrorResponse.self, from: data) - let apiError = APIError(errorInfo, response.statusCode) throw log( - apiError, - code: response.statusCode, + APIError(errorInfo, response.statusCode), request: request, data: data, response: response @@ -80,15 +77,12 @@ extension SWNetworkService: SWNetworkProtocol { return true } if let errorInfo = try? decoder.decode(ErrorResponse.self, from: data) { - let apiError = APIError(errorInfo, response.statusCode) - log( - apiError, - code: response.statusCode, + throw log( + APIError(errorInfo, response.statusCode), request: request, data: data, response: response ) - throw apiError } return false } catch { @@ -135,19 +129,18 @@ private extension SWNetworkService { return error } - @discardableResult func log( _ error: Error, - code: Int, request: URLRequest, data: Data, - response _: HTTPURLResponse? + response: HTTPURLResponse ) -> Error { logger.error( """ - Код ответа: \(code, privacy: .public) + Код ответа: \(response.statusCode, privacy: .public) \(error.localizedDescription, privacy: .public) \nURL запроса: \(request.urlString, privacy: .public) + \nответ (response): \(response) \nJSON в ответе: \(data.prettyJson, privacy: .public) """ ) @@ -161,22 +154,21 @@ private extension SWNetworkService { /// - error: Исходная ошибка /// - request: Запрос, упавший в ошибку /// - Returns: Новая ошибка - @discardableResult func handleUrlSession(_ error: Error, _ request: URLRequest) -> Error { let errorCode = (error as NSError).code - if errorCode == -999 { + guard errorCode != -999 else { let message = "Запрос отменён! Код ошибки: -999. URL запроса: \(request.urlString)" logger.error("\(message)") - } else { - logger.error( - """ - Ошибка! - \(error.localizedDescription, privacy: .public) - Код ошибки: \(errorCode, privacy: .public) - \nURL запроса: \(request.urlString, privacy: .public) - """ - ) + return CancellationError() } + logger.error( + """ + Ошибка! + \(error.localizedDescription, privacy: .public) + Код ошибки: \(errorCode, privacy: .public) + \nURL запроса: \(request.urlString, privacy: .public) + """ + ) guard let urlError = error as? URLError else { return error } diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/DataTests.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/DataTests.swift index 66677419..91bc07c2 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/DataTests.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/DataTests.swift @@ -16,6 +16,32 @@ struct DataTests { #expect(prettyJson == expectedResult) } + @Test + func validJSON_producesFormattedString() throws { + let jsonObject = ["name": "Test", "value": 42] as [String: Any] + let data = try JSONSerialization.data(withJSONObject: jsonObject) + let result = data.prettyJson + #expect(!result.isEmpty) + #expect(result != "отсутствует") + #expect(result.contains("\n")) + #expect(result.contains(" ")) + } + + @Test(arguments: [Data(), Data([0x00, 0x01, 0x02])]) + func emptyOrInvalidData(_ data: Data) { + #expect(data.prettyJson == "отсутствует") + } + + @Test + func minimalValidJSON_keepsContentIntegrity() throws { + let originalJson = "{\"key\":\"value\"}" + let data = try #require(originalJson.data(using: .utf8)) + let prettyJson = data.prettyJson + #expect(prettyJson.contains("\"key\" : \"value\"")) + #expect(prettyJson.contains("{")) + #expect(prettyJson.contains("}")) + } + @Test func appendString() throws { var data = Data() diff --git a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/HTTPStatusCodeGroupTests.swift b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/HTTPStatusCodeGroupTests.swift index cbc10912..589ea3a6 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/HTTPStatusCodeGroupTests.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWNetwork/Tests/SWNetworkTests/HTTPStatusCodeGroupTests.swift @@ -2,21 +2,15 @@ import Testing struct StatusCodeGroupTests { - @Test - func isSuccess() { - let notSuccessCodes: [StatusCodeGroup] = [ - .info, .redirect, .clientError, .serverError, .unknown - ] - notSuccessCodes.forEach { #expect(!$0.isSuccess) } + @Test(arguments: [StatusCodeGroup.info, .redirect, .clientError, .serverError, .unknown]) + func isSuccess(notSuccessCode: StatusCodeGroup) { + #expect(!notSuccessCode.isSuccess) #expect(StatusCodeGroup.success.isSuccess) } - @Test - func isError() { - let notErrorCodes: [StatusCodeGroup] = [ - .success, .info, .redirect, .unknown - ] - notErrorCodes.forEach { #expect(!$0.isError) } + @Test(arguments: [StatusCodeGroup.success, .info, .redirect, .unknown]) + func isError(notErrorCode: StatusCodeGroup) { + #expect(!notErrorCode.isError) #expect(StatusCodeGroup.clientError.isError) #expect(StatusCodeGroup.serverError.isError) } diff --git a/SwiftUI-WorkoutApp/Libraries/SWUtils/Sources/SWUtils/SWAlert.swift b/SwiftUI-WorkoutApp/Libraries/SWUtils/Sources/SWUtils/SWAlert.swift index f3cf4c2b..535e46c0 100644 --- a/SwiftUI-WorkoutApp/Libraries/SWUtils/Sources/SWUtils/SWAlert.swift +++ b/SwiftUI-WorkoutApp/Libraries/SWUtils/Sources/SWUtils/SWAlert.swift @@ -38,7 +38,13 @@ public final class SWAlert { /// /// Игнорирует `CancellationError` public func presentDefaultUIKit(_ error: Error) { - guard (error as NSError).code != -999 else { return } + guard type(of: error) != CancellationError.self else { + // Баг в NavigationView + searchable приводит к ошибке отмены, + // если сначала нажать на поле поиска, а следующий модальный + // экран закрыть свайпом вниз. Будет исправлено переходом + // на iOS 16 min + NavigationStack + return + } presentDefaultUIKit(message: error.localizedDescription) } diff --git a/SwiftUI-WorkoutApp/Screens/Common/UsersListScreen.swift b/SwiftUI-WorkoutApp/Screens/Common/UsersListScreen.swift index b221c97b..5774ac49 100644 --- a/SwiftUI-WorkoutApp/Screens/Common/UsersListScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Common/UsersListScreen.swift @@ -57,8 +57,6 @@ extension UsersListScreen { case eventParticipants(list: [UserResponse]) /// Тренирующиеся на площадке case parkParticipants(list: [UserResponse]) - /// Черный список основного пользователя - case blacklist } } @@ -71,8 +69,6 @@ private extension UsersListScreen.Mode { "Участники мероприятия" case .parkParticipants: "Здесь тренируются" - case .blacklist: - "Черный список" } } } @@ -119,7 +115,7 @@ private extension UsersListScreen { } label: { userRowView(with: model) } - case .friends, .eventParticipants, .parkParticipants, .blacklist: + case .friends, .eventParticipants, .parkParticipants: NavigationLink { UserDetailsScreen(for: model) .navigationBarTitleDisplayMode(.inline) @@ -185,14 +181,6 @@ private extension UsersListScreen { } case let .eventParticipants(list), let .parkParticipants(list): users = list - case .blacklist: - if !users.isEmpty, !refresh { return } - if !refresh { isLoading = true } - users = try await client.getBlacklist() - try? defaults.saveBlacklist(users) - if users.isEmpty { - dismiss() - } } } catch { SWAlert.shared.presentDefaultUIKit(error) diff --git a/SwiftUI-WorkoutApp/Screens/Profile/BlackListScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/BlackListScreen.swift new file mode 100644 index 00000000..76e16ce8 --- /dev/null +++ b/SwiftUI-WorkoutApp/Screens/Profile/BlackListScreen.swift @@ -0,0 +1,119 @@ +import SWDesignSystem +import SwiftUI +import SWModels +import SWNetworkClient +import SWUtils + +/// Экран для списка заблокированных пользователей +struct BlackListScreen: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.isNetworkConnected) private var isNetworkConnected + @EnvironmentObject private var defaults: DefaultsService + @State private var users = [UserResponse]() + @State private var userToDelete: UserResponse? + @State private var isLoading = false + private var client: SWClient { SWClient(with: defaults) } + + var body: some View { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(users) { user in + Button { + userToDelete = user + } label: { + makeLabelFor(user) + } + .opacity(userToDelete == user ? 0.5 : 1) + .scaleEffect(userToDelete == user ? 0.95 : 1) + .offset(x: userToDelete == user ? -32 : 0) + .animation(.easeInOut(duration: 0.2), value: userToDelete) + } + } + .padding([.horizontal, .top]) + .frame(maxWidth: .infinity) + .confirmationDialog( + .init(BlacklistOption.remove.dialogTitle), + isPresented: .init( + get: { userToDelete != nil }, + set: { if !$0 { userToDelete = nil } } + ), + titleVisibility: .visible + ) { + Button( + .init(BlacklistOption.remove.rawValue), + role: .destructive, + action: unblock + ) + } message: { + Text(.init(BlacklistOption.remove.dialogMessage)) + } + } + .loadingOverlay(if: isLoading) + .background(Color.swBackground) + .task { await askForUsers() } + .refreshable { await askForUsers(refresh: true) } + .navigationTitle("Черный список") + .navigationBarTitleDisplayMode(.inline) + } +} + +private extension BlackListScreen { + func makeLabelFor(_ user: UserResponse) -> some View { + UserRowView( + mode: .regular( + .init( + imageURL: user.avatarURL, + name: user.userName ?? "", + address: SWAddress(user.countryID, user.cityID)?.address ?? "" + ) + ) + ) + } + + func askForUsers(refresh: Bool = false) async { + guard !isLoading else { return } + do { + if !users.isEmpty, !refresh { return } + if !refresh { isLoading = true } + users = try await client.getBlacklist() + try? defaults.saveBlacklist(users) + dismissIfEmpty() + } catch { + SWAlert.shared.presentDefaultUIKit(error) + } + isLoading = false + } + + func unblock() { + guard let user = userToDelete else { return } + isLoading = true + Task { + do { + let isSuccess = try await SWClient(with: defaults).blacklistAction( + user: user, option: .remove + ) + if isSuccess { + defaults.updateBlacklist(option: .remove, user: user) + users.removeAll(where: { $0.id == user.id }) + } + dismissIfEmpty() + } catch { + SWAlert.shared.presentDefaultUIKit(error) + } + isLoading = false + } + } + + func dismissIfEmpty() { + if users.isEmpty { dismiss() } + } +} + +#if DEBUG +#Preview { + NavigationView { + BlackListScreen() + .environmentObject(DefaultsService()) + } +} +#endif diff --git a/SwiftUI-WorkoutApp/Screens/Profile/MainUserProfileScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/MainUserProfileScreen.swift index 82b34292..2fb6c71a 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/MainUserProfileScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/MainUserProfileScreen.swift @@ -115,7 +115,7 @@ private extension MainUserProfileScreen { var blacklistButtonIfNeeded: some View { ZStack { if !defaults.blacklistedUsers.isEmpty { - NavigationLink(destination: UsersListScreen(mode: .blacklist)) { + NavigationLink(destination: BlackListScreen()) { FormRowView( title: "Черный список", trailingContent: .textWithChevron(defaults.blacklistedUsersCountString) diff --git a/SwiftUI-WorkoutApp/Screens/Profile/UserDetailsScreen.swift b/SwiftUI-WorkoutApp/Screens/Profile/UserDetailsScreen.swift index 770fcabf..92081486 100644 --- a/SwiftUI-WorkoutApp/Screens/Profile/UserDetailsScreen.swift +++ b/SwiftUI-WorkoutApp/Screens/Profile/UserDetailsScreen.swift @@ -198,7 +198,6 @@ private extension UserDetailsScreen { do { user = try await SWClient(with: defaults).getUserByID(user.id) } catch { - guard (error as NSError).code != -999 else { return } SWAlert.shared.presentDefaultUIKit(error) } } diff --git a/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift b/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift index 2df92457..2bf1a004 100644 --- a/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift +++ b/SwiftUI-WorkoutApp/SwiftUI_WorkoutAppApp.swift @@ -37,7 +37,6 @@ struct SwiftUI_WorkoutAppApp: App { unreadCount: defaults.unreadMessagesCount ) .environmentObject(tabViewModel) - .environmentObject(network) .environmentObject(defaults) .environmentObject(parksManager) .environmentObject(dialogsViewModel)