From 3b860a5146ca08e568c66f2a33af0d386bc2c4b8 Mon Sep 17 00:00:00 2001 From: cheykrym Date: Wed, 8 Oct 2025 01:50:20 +0300 Subject: [PATCH] add load privacy settings from server --- yobble/Network/ProfileModels.swift | 96 ++++++++ yobble/Network/ProfileService.swift | 164 +++++++++++++ yobble/Resources/Localizable.xcstrings | 3 + .../Views/Tab/Settings/EditPrivacyView.swift | 232 ++++++++++++------ 4 files changed, 417 insertions(+), 78 deletions(-) create mode 100644 yobble/Network/ProfileModels.swift create mode 100644 yobble/Network/ProfileService.swift diff --git a/yobble/Network/ProfileModels.swift b/yobble/Network/ProfileModels.swift new file mode 100644 index 0000000..4537425 --- /dev/null +++ b/yobble/Network/ProfileModels.swift @@ -0,0 +1,96 @@ +import Foundation + +struct ProfileDataPayload: Decodable { + let userId: UUID + let login: String + let fullName: String? + let bio: String? + let balances: [WalletBalancePayload] + let createdAt: Date? + let stories: [JSONValue] + let profilePermissions: ProfilePermissionsPayload + + private enum CodingKeys: String, CodingKey { + case userId + case login + case fullName + case bio + case balances + case createdAt + case stories + case profilePermissions + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.userId = try container.decode(UUID.self, forKey: .userId) + self.login = try container.decode(String.self, forKey: .login) + self.fullName = try container.decodeIfPresent(String.self, forKey: .fullName) + self.bio = try container.decodeIfPresent(String.self, forKey: .bio) + self.balances = try container.decodeIfPresent([WalletBalancePayload].self, forKey: .balances) ?? [] + self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) + self.stories = try container.decodeIfPresent([JSONValue].self, forKey: .stories) ?? [] + self.profilePermissions = try container.decode(ProfilePermissionsPayload.self, forKey: .profilePermissions) + } +} + +struct WalletBalancePayload: Decodable { + let currency: String + let balance: Decimal + let displayBalance: Double? + + private enum CodingKeys: String, CodingKey { + case currency + case balance + case displayBalance + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.currency = try container.decode(String.self, forKey: .currency) + self.balance = try Self.decodeDecimal(from: container, forKey: .balance) + if let doubleValue = try? container.decode(Double.self, forKey: .displayBalance) { + self.displayBalance = doubleValue + } else if let stringValue = try? container.decode(String.self, forKey: .displayBalance), + let doubleValue = Double(stringValue) { + self.displayBalance = doubleValue + } else { + self.displayBalance = nil + } + } + + private static func decodeDecimal(from container: KeyedDecodingContainer, forKey key: CodingKeys) throws -> Decimal { + if let decimalValue = try? container.decode(Decimal.self, forKey: key) { + return decimalValue + } + if let stringValue = try? container.decode(String.self, forKey: key), + let decimal = Decimal(string: stringValue) { + return decimal + } + if let doubleValue = try? container.decode(Double.self, forKey: key) { + return Decimal(doubleValue) + } + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "Unable to decode decimal value for key \(key)" + ) + } +} + +struct ProfilePermissionsPayload: Decodable { + let isSearchable: Bool + let allowMessageForwarding: Bool + let allowMessagesFromNonContacts: Bool + let showProfilePhotoToNonContacts: Bool + let lastSeenVisibility: Int + let showBioToNonContacts: Bool + let showStoriesToNonContacts: Bool + let allowServerChats: Bool + let publicInvitePermission: Int + let groupInvitePermission: Int + let callPermission: Int + let forceAutoDeleteMessagesInPrivate: Bool + let maxMessageAutoDeleteSeconds: Int? + let autoDeleteAfterDays: Int? +} diff --git a/yobble/Network/ProfileService.swift b/yobble/Network/ProfileService.swift new file mode 100644 index 0000000..d16a68f --- /dev/null +++ b/yobble/Network/ProfileService.swift @@ -0,0 +1,164 @@ +import Foundation + +enum ProfileServiceError: LocalizedError { + case unexpectedStatus(String) + case decoding(debugDescription: String) + + var errorDescription: String? { + switch self { + case .unexpectedStatus(let message): + return message + case .decoding(let debugDescription): + return AppConfig.DEBUG + ? debugDescription + : NSLocalizedString("Не удалось загрузить профиль.", comment: "Profile service decoding error") + } + } +} + +final class ProfileService { + private let client: NetworkClient + private let decoder: JSONDecoder + + init(client: NetworkClient = .shared) { + self.client = client + self.decoder = JSONDecoder() + self.decoder.keyDecodingStrategy = .convertFromSnakeCase + self.decoder.dateDecodingStrategy = .custom(Self.decodeDate) + } + + func fetchMyProfile(completion: @escaping (Result) -> Void) { + client.request( + path: "/v1/profile/me", + method: .get, + requiresAuth: true + ) { [decoder] result in + switch result { + case .success(let response): + do { + let apiResponse = try decoder.decode(APIResponse.self, from: response.data) + guard apiResponse.status == "fine" else { + let message = apiResponse.detail ?? NSLocalizedString("Не удалось загрузить профиль.", comment: "Profile unexpected status") + completion(.failure(ProfileServiceError.unexpectedStatus(message))) + return + } + completion(.success(apiResponse.data)) + } catch { + let debugMessage = Self.describeDecodingError(error: error, data: response.data) + if AppConfig.DEBUG { + print("[ProfileService] decode profile failed: \(debugMessage)") + } + completion(.failure(ProfileServiceError.decoding(debugDescription: debugMessage))) + } + case .failure(let error): + if case let NetworkError.server(_, data) = error, + let data, + let message = Self.errorMessage(from: data) { + completion(.failure(ProfileServiceError.unexpectedStatus(message))) + return + } + completion(.failure(error)) + } + } + } + + func fetchMyProfile() async throws -> ProfileDataPayload { + try await withCheckedThrowingContinuation { continuation in + fetchMyProfile { result in + continuation.resume(with: result) + } + } + } + + private static func decodeDate(from decoder: Decoder) throws -> Date { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + if let date = iso8601WithFractionalSeconds.date(from: string) { + return date + } + if let date = iso8601Simple.date(from: string) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Невозможно декодировать дату: \(string)" + ) + } + + private static func describeDecodingError(error: Error, data: Data) -> String { + var parts: [String] = [] + + if let decodingError = error as? DecodingError { + parts.append(decodingDescription(from: decodingError)) + } else { + parts.append(error.localizedDescription) + } + + if let payload = truncatedPayload(from: data) { + parts.append("payload=\(payload)") + } + + return parts.joined(separator: "\n") + } + + private static func decodingDescription(from error: DecodingError) -> String { + switch error { + case .typeMismatch(let type, let context): + return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)" + case .valueNotFound(let type, let context): + return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)" + case .keyNotFound(let key, let context): + return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)" + case .dataCorrupted(let context): + return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)" + @unknown default: + return error.localizedDescription + } + } + + private static func codingPath(from context: DecodingError.Context) -> String { + let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty } + return path.isEmpty ? "root" : path.joined(separator: ".") + } + + private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? { + guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !string.isEmpty else { + return nil + } + + if string.count <= limit { + return string + } + + let index = string.index(string.startIndex, offsetBy: limit) + return String(string[string.startIndex.. String? { + if let apiError = try? JSONDecoder().decode(ErrorResponse.self, from: data) { + if let detail = apiError.detail, !detail.isEmpty { + return detail + } + if let message = apiError.data?.message, !message.isEmpty { + return message + } + } + if let string = String(data: data, encoding: .utf8), !string.isEmpty { + return string + } + return nil + } + + private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let iso8601Simple: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() +} diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 3ac1a45..42f57ae 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -574,6 +574,9 @@ "Не удалось выполнить поиск." : { "comment" : "Search error fallback\nSearch service decoding error" }, + "Не удалось загрузить профиль." : { + "comment" : "Profile service decoding error\nProfile unexpected status" + }, "Не удалось загрузить список чатов." : { "localizations" : { "en" : { diff --git a/yobble/Views/Tab/Settings/EditPrivacyView.swift b/yobble/Views/Tab/Settings/EditPrivacyView.swift index 25c719b..7ffa1f1 100644 --- a/yobble/Views/Tab/Settings/EditPrivacyView.swift +++ b/yobble/Views/Tab/Settings/EditPrivacyView.swift @@ -1,7 +1,11 @@ import SwiftUI struct EditPrivacyView: View { - @State private var profilePermissions = ProfilePermissionsResponse() + @State private var profilePermissions = ProfilePermissionsState() + @State private var isLoading = false + @State private var loadError: String? + + private let profileService = ProfileService() private var privacyScopeOptions: [PrivacyScope] { PrivacyScope.allCases } @@ -30,85 +34,103 @@ struct EditPrivacyView: View { var body: some View { Form { - Section(header: Text("Профиль и поиск")) { - Toggle("Разрешить поиск профиля", isOn: $profilePermissions.isSearchable) - Toggle("Разрешить пересылку сообщений", isOn: $profilePermissions.allowMessageForwarding) - Toggle("Принимать сообщения от незнакомцев", isOn: $profilePermissions.allowMessagesFromNonContacts) - } - - Section(header: Text("Видимость и контент")) { - Toggle("Показывать фото не-контактам", isOn: $profilePermissions.showProfilePhotoToNonContacts) - Toggle("Показывать био не-контактам", isOn: $profilePermissions.showBioToNonContacts) - Toggle("Показывать сторисы не-контактам", isOn: $profilePermissions.showStoriesToNonContacts) - - Picker("Видимость статуса 'был в сети'", selection: $profilePermissions.lastSeenVisibility) { - ForEach(privacyScopeOptions) { scope in - Text(scope.title).tag(scope.rawValue) - } - } - .pickerStyle(.segmented) - } - - Section(header: Text("Приглашения и звонки")) { - Picker("Кто может приглашать в паблики", selection: $profilePermissions.publicInvitePermission) { - ForEach(privacyScopeOptions) { scope in - Text(scope.title).tag(scope.rawValue) - } - } - .pickerStyle(.segmented) - - Picker("Кто может приглашать в беседы", selection: $profilePermissions.groupInvitePermission) { - ForEach(privacyScopeOptions) { scope in - Text(scope.title).tag(scope.rawValue) - } - } - .pickerStyle(.segmented) - - Picker("Кто может звонить", selection: $profilePermissions.callPermission) { - ForEach(privacyScopeOptions) { scope in - Text(scope.title).tag(scope.rawValue) - } - } - .pickerStyle(.segmented) - } - - Section(header: Text("Чаты и хранение")) { - Toggle("Разрешить хранить чаты на сервере (Обычный)", isOn: $profilePermissions.allowServerChats) - Toggle("Принудительное автоудаление в ЛС (Приватный)", isOn: $profilePermissions.forceAutoDeleteMessagesInPrivate) - - if profilePermissions.forceAutoDeleteMessagesInPrivate { - Stepper(value: forceAutoDeleteBinding, in: 5...86400, step: 5) { - Text("Таймер автоудаления: \(formattedAutoDeleteSeconds(forceAutoDeleteBinding.wrappedValue))") - } - } - } - - Section(header: Text("Автоудаление аккаунта")) { - Toggle("Включить автоудаление аккаунта", isOn: autoDeleteAccountEnabled) - - if autoDeleteAccountEnabled.wrappedValue { - Stepper(value: autoDeleteAccountBinding, in: 1...365) { - Text("Удалять аккаунт через \(autoDeleteAccountBinding.wrappedValue) дн.") - } - } - } - - Section { - Button("Сохранить изменения") { - print("Параметры приватности: \(profilePermissions)") - } - .frame(maxWidth: .infinity, alignment: .center) - } - - Section { - Button(role: .destructive) { - profilePermissions = ProfilePermissionsResponse() - print("Настройки приватности сброшены к значениям по умолчанию") - } label: { - Text("Сбросить по умолчанию") + if isLoading { + Section { + ProgressView() .frame(maxWidth: .infinity, alignment: .center) } } + + if let loadError { + Section { + Text(loadError) + .foregroundColor(.red) + .frame(maxWidth: .infinity, alignment: .center) + } + } + + if !isLoading && loadError == nil { + Section(header: Text("Профиль и поиск")) { + Toggle("Разрешить поиск профиля", isOn: $profilePermissions.isSearchable) + Toggle("Разрешить пересылку сообщений", isOn: $profilePermissions.allowMessageForwarding) + Toggle("Принимать сообщения от незнакомцев", isOn: $profilePermissions.allowMessagesFromNonContacts) + } + + Section(header: Text("Видимость и контент")) { + Toggle("Показывать фото не-контактам", isOn: $profilePermissions.showProfilePhotoToNonContacts) + Toggle("Показывать био не-контактам", isOn: $profilePermissions.showBioToNonContacts) + Toggle("Показывать сторисы не-контактам", isOn: $profilePermissions.showStoriesToNonContacts) + + Picker("Видимость статуса 'был в сети'", selection: $profilePermissions.lastSeenVisibility) { + ForEach(privacyScopeOptions) { scope in + Text(scope.title).tag(scope.rawValue) + } + } + .pickerStyle(.segmented) + } + + Section(header: Text("Приглашения и звонки")) { + Picker("Кто может приглашать в паблики", selection: $profilePermissions.publicInvitePermission) { + ForEach(privacyScopeOptions) { scope in + Text(scope.title).tag(scope.rawValue) + } + } + .pickerStyle(.segmented) + + Picker("Кто может приглашать в беседы", selection: $profilePermissions.groupInvitePermission) { + ForEach(privacyScopeOptions) { scope in + Text(scope.title).tag(scope.rawValue) + } + } + .pickerStyle(.segmented) + + Picker("Кто может звонить", selection: $profilePermissions.callPermission) { + ForEach(privacyScopeOptions) { scope in + Text(scope.title).tag(scope.rawValue) + } + } + .pickerStyle(.segmented) + } + + Section(header: Text("Чаты и хранение")) { + Toggle("Разрешить хранить чаты на сервере (Обычный)", isOn: $profilePermissions.allowServerChats) + Toggle("Принудительное автоудаление в ЛС (Приватный)", isOn: $profilePermissions.forceAutoDeleteMessagesInPrivate) + + if profilePermissions.forceAutoDeleteMessagesInPrivate { + Stepper(value: forceAutoDeleteBinding, in: 5...86400, step: 5) { + Text("Таймер автоудаления: \(formattedAutoDeleteSeconds(forceAutoDeleteBinding.wrappedValue))") + } + } + } + + Section(header: Text("Автоудаление аккаунта")) { + Toggle("Включить автоудаление аккаунта", isOn: autoDeleteAccountEnabled) + + if autoDeleteAccountEnabled.wrappedValue { + Stepper(value: autoDeleteAccountBinding, in: 1...365) { + Text("Удалять аккаунт через \(autoDeleteAccountBinding.wrappedValue) дн.") + } + } + } + + Section { + Button("Сохранить изменения") { + print("Параметры приватности: \(profilePermissions)") + } + .frame(maxWidth: .infinity, alignment: .center) + .disabled(isLoading) + } + + Section { + Button(role: .destructive) { + profilePermissions = ProfilePermissionsState() + print("Настройки приватности сброшены к значениям по умолчанию") + } label: { + Text("Сбросить по умолчанию") + .frame(maxWidth: .infinity, alignment: .center) + } + } + } } .navigationTitle("Настройки приватности") .onChange(of: profilePermissions.forceAutoDeleteMessagesInPrivate) { newValue in @@ -118,6 +140,9 @@ struct EditPrivacyView: View { profilePermissions.maxMessageAutoDeleteSeconds = nil } } + .task { + await loadProfile() + } } private func formattedAutoDeleteSeconds(_ value: Int) -> String { @@ -153,7 +178,7 @@ private enum PrivacyScope: Int, CaseIterable, Identifiable { } } -struct ProfilePermissionsResponse: Codable { +struct ProfilePermissionsState: Codable, Equatable { var isSearchable: Bool = true var allowMessageForwarding: Bool = true var allowMessagesFromNonContacts: Bool = true @@ -169,3 +194,54 @@ struct ProfilePermissionsResponse: Codable { var maxMessageAutoDeleteSeconds: Int? = nil var autoDeleteAfterDays: Int? = nil } + +extension ProfilePermissionsState { + init(payload: ProfilePermissionsPayload) { + self.isSearchable = payload.isSearchable + self.allowMessageForwarding = payload.allowMessageForwarding + self.allowMessagesFromNonContacts = payload.allowMessagesFromNonContacts + self.showProfilePhotoToNonContacts = payload.showProfilePhotoToNonContacts + self.lastSeenVisibility = payload.lastSeenVisibility + self.showBioToNonContacts = payload.showBioToNonContacts + self.showStoriesToNonContacts = payload.showStoriesToNonContacts + self.allowServerChats = payload.allowServerChats + self.publicInvitePermission = payload.publicInvitePermission + self.groupInvitePermission = payload.groupInvitePermission + self.callPermission = payload.callPermission + self.forceAutoDeleteMessagesInPrivate = payload.forceAutoDeleteMessagesInPrivate + self.maxMessageAutoDeleteSeconds = payload.maxMessageAutoDeleteSeconds + self.autoDeleteAfterDays = payload.autoDeleteAfterDays + } +} + +private extension EditPrivacyView { + func loadProfile() async { + await MainActor.run { + if !isLoading { + isLoading = true + loadError = nil + loadError = "oleg pidor" + } + } + + do { + let profile = try await profileService.fetchMyProfile() + await MainActor.run { + profilePermissions = ProfilePermissionsState(payload: profile.profilePermissions) + isLoading = false + } + } catch { + let message: String + if let error = error as? LocalizedError, let description = error.errorDescription { + message = description + } else { + message = error.localizedDescription + } + + await MainActor.run { + loadError = message + isLoading = false + } + } + } +}