add load privacy settings from server

This commit is contained in:
cheykrym 2025-10-08 01:50:20 +03:00
parent 0dd8241958
commit 3b860a5146
4 changed files with 417 additions and 78 deletions

View File

@ -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<CodingKeys>, 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?
}

View File

@ -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<ProfileDataPayload, Error>) -> 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<ProfileDataPayload>.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..<index]) + ""
}
private static func errorMessage(from data: Data) -> 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
}()
}

View File

@ -574,6 +574,9 @@
"Не удалось выполнить поиск." : {
"comment" : "Search error fallback\nSearch service decoding error"
},
"Не удалось загрузить профиль." : {
"comment" : "Profile service decoding error\nProfile unexpected status"
},
"Не удалось загрузить список чатов." : {
"localizations" : {
"en" : {

View File

@ -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
}
}
}
}