Compare commits
3 Commits
0dd8241958
...
fb8413e68c
| Author | SHA1 | Date | |
|---|---|---|---|
| fb8413e68c | |||
| c96fe4991d | |||
| 3b860a5146 |
117
yobble/Network/ProfileModels.swift
Normal file
117
yobble/Network/ProfileModels.swift
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
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?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProfilePermissionsRequestPayload: Encodable {
|
||||||
|
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?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProfileUpdateRequestPayload: Encodable {
|
||||||
|
let profilePermissions: ProfilePermissionsRequestPayload
|
||||||
|
}
|
||||||
227
yobble/Network/ProfileService.swift
Normal file
227
yobble/Network/ProfileService.swift
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ProfileServiceError: LocalizedError {
|
||||||
|
case unexpectedStatus(String)
|
||||||
|
case decoding(debugDescription: String)
|
||||||
|
case encoding(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")
|
||||||
|
case .encoding(let message):
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProfile(_ payload: ProfileUpdateRequestPayload, completion: @escaping (Result<String, Error>) -> Void) {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||||
|
|
||||||
|
guard let body = try? encoder.encode(payload) else {
|
||||||
|
let message = NSLocalizedString("Не удалось подготовить данные запроса.", comment: "Profile update encoding error")
|
||||||
|
completion(.failure(ProfileServiceError.encoding(message)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client.request(
|
||||||
|
path: "/v1/profile/edit",
|
||||||
|
method: .put,
|
||||||
|
body: body,
|
||||||
|
requiresAuth: true
|
||||||
|
) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let response):
|
||||||
|
do {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
|
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
|
||||||
|
guard apiResponse.status == "fine" else {
|
||||||
|
let message = apiResponse.detail ?? NSLocalizedString("Не удалось сохранить изменения профиля.", comment: "Profile update unexpected status")
|
||||||
|
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(.success(apiResponse.data.message))
|
||||||
|
} catch {
|
||||||
|
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
||||||
|
if AppConfig.DEBUG {
|
||||||
|
print("[ProfileService] decode update response failed: \(debugMessage)")
|
||||||
|
}
|
||||||
|
if AppConfig.DEBUG {
|
||||||
|
completion(.failure(ProfileServiceError.decoding(debugDescription: debugMessage)))
|
||||||
|
} else {
|
||||||
|
let message = NSLocalizedString("Не удалось обработать ответ сервера.", comment: "Profile update decode error")
|
||||||
|
completion(.failure(ProfileServiceError.unexpectedStatus(message)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 updateProfile(_ payload: ProfileUpdateRequestPayload) async throws -> String {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
updateProfile(payload) { 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
|
||||||
|
}()
|
||||||
|
}
|
||||||
@ -74,6 +74,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"OK" : {
|
"OK" : {
|
||||||
|
"comment" : "Profile update alert button",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -219,6 +220,9 @@
|
|||||||
"Глобальный поиск" : {
|
"Глобальный поиск" : {
|
||||||
"comment" : "Global search section"
|
"comment" : "Global search section"
|
||||||
},
|
},
|
||||||
|
"Готово" : {
|
||||||
|
"comment" : "Profile update success title"
|
||||||
|
},
|
||||||
"Данные" : {
|
"Данные" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@ -570,10 +574,16 @@
|
|||||||
},
|
},
|
||||||
"Настройки приватности" : {
|
"Настройки приватности" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Настройки приватности обновлены." : {
|
||||||
|
"comment" : "Profile update success fallback"
|
||||||
},
|
},
|
||||||
"Не удалось выполнить поиск." : {
|
"Не удалось выполнить поиск." : {
|
||||||
"comment" : "Search error fallback\nSearch service decoding error"
|
"comment" : "Search error fallback\nSearch service decoding error"
|
||||||
},
|
},
|
||||||
|
"Не удалось загрузить профиль." : {
|
||||||
|
"comment" : "Profile service decoding error\nProfile unexpected status"
|
||||||
|
},
|
||||||
"Не удалось загрузить список чатов." : {
|
"Не удалось загрузить список чатов." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -618,6 +628,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Не удалось обработать ответ сервера." : {
|
"Не удалось обработать ответ сервера." : {
|
||||||
|
"comment" : "Profile update decode error",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -627,6 +638,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Не удалось подготовить данные запроса." : {
|
||||||
|
"comment" : "Profile update encoding error"
|
||||||
|
},
|
||||||
"Не удалось сериализовать данные запроса." : {
|
"Не удалось сериализовать данные запроса." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -637,6 +651,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Не удалось сохранить изменения профиля." : {
|
||||||
|
"comment" : "Profile update unexpected status"
|
||||||
|
},
|
||||||
"Неверный запрос (400)." : {
|
"Неверный запрос (400)." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -820,6 +837,7 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"Ошибка" : {
|
"Ошибка" : {
|
||||||
|
"comment" : "Profile update error title",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct EditPrivacyView: View {
|
struct EditPrivacyView: View {
|
||||||
@State private var profilePermissions = ProfilePermissionsResponse()
|
@State private var profilePermissions = ProfilePermissionsState()
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var loadError: String?
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var alertData: AlertData?
|
||||||
|
|
||||||
|
private let profileService = ProfileService()
|
||||||
|
|
||||||
private var privacyScopeOptions: [PrivacyScope] { PrivacyScope.allCases }
|
private var privacyScopeOptions: [PrivacyScope] { PrivacyScope.allCases }
|
||||||
|
|
||||||
@ -30,85 +36,121 @@ struct EditPrivacyView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section(header: Text("Профиль и поиск")) {
|
if isLoading {
|
||||||
Toggle("Разрешить поиск профиля", isOn: $profilePermissions.isSearchable)
|
Section {
|
||||||
Toggle("Разрешить пересылку сообщений", isOn: $profilePermissions.allowMessageForwarding)
|
ProgressView()
|
||||||
Toggle("Принимать сообщения от незнакомцев", isOn: $profilePermissions.allowMessagesFromNonContacts)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
} else if let loadError {
|
||||||
}
|
Section {
|
||||||
|
Text(loadError)
|
||||||
Section(header: Text("Приглашения и звонки")) {
|
.foregroundColor(.red)
|
||||||
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("Сбросить по умолчанию")
|
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.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 {
|
||||||
|
Task {
|
||||||
|
await saveProfile()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if isSaving {
|
||||||
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
} else {
|
||||||
|
Text("Сохранить изменения")
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isLoading || isSaving)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
profilePermissions = ProfilePermissionsState()
|
||||||
|
print("Настройки приватности сброшены к значениям по умолчанию")
|
||||||
|
} label: {
|
||||||
|
Text("Сбросить по умолчанию")
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(item: $alertData) { data in
|
||||||
|
Alert(
|
||||||
|
title: Text(data.kind == .success
|
||||||
|
? NSLocalizedString("Готово", comment: "Profile update success title")
|
||||||
|
: NSLocalizedString("Ошибка", comment: "Profile update error title")),
|
||||||
|
message: Text(data.message),
|
||||||
|
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Profile update alert button"))) {
|
||||||
|
alertData = nil
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.navigationTitle("Настройки приватности")
|
.navigationTitle("Настройки приватности")
|
||||||
.onChange(of: profilePermissions.forceAutoDeleteMessagesInPrivate) { newValue in
|
.onChange(of: profilePermissions.forceAutoDeleteMessagesInPrivate) { newValue in
|
||||||
@ -118,6 +160,9 @@ struct EditPrivacyView: View {
|
|||||||
profilePermissions.maxMessageAutoDeleteSeconds = nil
|
profilePermissions.maxMessageAutoDeleteSeconds = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
await loadProfile()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formattedAutoDeleteSeconds(_ value: Int) -> String {
|
private func formattedAutoDeleteSeconds(_ value: Int) -> String {
|
||||||
@ -153,7 +198,7 @@ private enum PrivacyScope: Int, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ProfilePermissionsResponse: Codable {
|
struct ProfilePermissionsState: Codable, Equatable {
|
||||||
var isSearchable: Bool = true
|
var isSearchable: Bool = true
|
||||||
var allowMessageForwarding: Bool = true
|
var allowMessageForwarding: Bool = true
|
||||||
var allowMessagesFromNonContacts: Bool = true
|
var allowMessagesFromNonContacts: Bool = true
|
||||||
@ -169,3 +214,121 @@ struct ProfilePermissionsResponse: Codable {
|
|||||||
var maxMessageAutoDeleteSeconds: Int? = nil
|
var maxMessageAutoDeleteSeconds: Int? = nil
|
||||||
var autoDeleteAfterDays: 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 ProfilePermissionsState {
|
||||||
|
var requestPayload: ProfilePermissionsRequestPayload {
|
||||||
|
ProfilePermissionsRequestPayload(
|
||||||
|
isSearchable: isSearchable,
|
||||||
|
allowMessageForwarding: allowMessageForwarding,
|
||||||
|
allowMessagesFromNonContacts: allowMessagesFromNonContacts,
|
||||||
|
showProfilePhotoToNonContacts: showProfilePhotoToNonContacts,
|
||||||
|
lastSeenVisibility: lastSeenVisibility,
|
||||||
|
showBioToNonContacts: showBioToNonContacts,
|
||||||
|
showStoriesToNonContacts: showStoriesToNonContacts,
|
||||||
|
allowServerChats: allowServerChats,
|
||||||
|
publicInvitePermission: publicInvitePermission,
|
||||||
|
groupInvitePermission: groupInvitePermission,
|
||||||
|
callPermission: callPermission,
|
||||||
|
forceAutoDeleteMessagesInPrivate: forceAutoDeleteMessagesInPrivate,
|
||||||
|
maxMessageAutoDeleteSeconds: maxMessageAutoDeleteSeconds,
|
||||||
|
autoDeleteAfterDays: autoDeleteAfterDays
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension EditPrivacyView {
|
||||||
|
func saveProfile() async {
|
||||||
|
let shouldProceed = await MainActor.run { () -> Bool in
|
||||||
|
if isSaving {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
isSaving = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
guard shouldProceed else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let requestPayload = ProfileUpdateRequestPayload(profilePermissions: profilePermissions.requestPayload)
|
||||||
|
let responseMessage = try await profileService.updateProfile(requestPayload)
|
||||||
|
let fallback = NSLocalizedString("Настройки приватности обновлены.", comment: "Profile update success fallback")
|
||||||
|
let message = responseMessage.isEmpty ? fallback : responseMessage
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
alertData = AlertData(kind: .success, message: message)
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
let message: String
|
||||||
|
if let error = error as? LocalizedError, let description = error.errorDescription {
|
||||||
|
message = description
|
||||||
|
} else {
|
||||||
|
message = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
alertData = AlertData(kind: .error, message: message)
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadProfile() async {
|
||||||
|
await MainActor.run {
|
||||||
|
if !isLoading {
|
||||||
|
isLoading = true
|
||||||
|
loadError = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AlertData: Identifiable {
|
||||||
|
enum Kind {
|
||||||
|
case success
|
||||||
|
case error
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = UUID()
|
||||||
|
let kind: Kind
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user