Compare commits

...

8 Commits

Author SHA1 Message Date
b1d9112806 fix privacy settings 2025-12-11 05:16:23 +03:00
997ddea9c4 add update contact 2025-12-11 05:04:26 +03:00
bb08452ff9 add delete contact 2025-12-11 04:53:42 +03:00
a1446ec8bf add contact add 2025-12-11 04:29:55 +03:00
c2177278e2 add contact add view 2025-12-11 04:11:26 +03:00
f5009157ca patch edit screen 2025-12-11 03:48:04 +03:00
b3617a58d4 add edit contact 2025-12-11 03:30:39 +03:00
951ee3f5f5 add button edit 2025-12-11 03:20:27 +03:00
8 changed files with 1053 additions and 51 deletions

View File

@ -434,7 +434,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = V22H44W47J;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
@ -475,7 +475,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = V22H44W47J;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;

View File

@ -331,6 +331,20 @@ struct RelationshipStatus: Decodable {
}
}
extension RelationshipStatus {
init(
isTargetInContactsOfCurrentUser: Bool,
isCurrentUserInContactsOfTarget: Bool,
isTargetUserBlockedByCurrentUser: Bool,
isCurrentUserInBlacklistOfTarget: Bool
) {
self.isTargetInContactsOfCurrentUser = isTargetInContactsOfCurrentUser
self.isCurrentUserInContactsOfTarget = isCurrentUserInContactsOfTarget
self.isTargetUserBlockedByCurrentUser = isTargetUserBlockedByCurrentUser
self.isCurrentUserInBlacklistOfTarget = isCurrentUserInBlacklistOfTarget
}
}
enum JSONValue: Decodable {
case string(String)
case int(Int)

View File

@ -3,6 +3,7 @@ import Foundation
enum ContactsServiceError: LocalizedError {
case unexpectedStatus(String)
case decoding(debugDescription: String)
case encoding(String)
var errorDescription: String? {
switch self {
@ -12,6 +13,8 @@ enum ContactsServiceError: LocalizedError {
return AppConfig.DEBUG
? debugDescription
: NSLocalizedString("Не удалось загрузить контакты.", comment: "Contacts service decoding error")
case .encoding(let message):
return message
}
}
}
@ -30,15 +33,35 @@ struct ContactsListPayload: Decodable {
let hasMore: Bool
}
private struct ContactCreateRequestPayload: Encodable {
let userId: UUID?
let login: String?
let friendCode: String?
let customName: String?
}
private struct ContactDeleteRequestPayload: Encodable {
let userId: UUID
}
private struct ContactUpdateRequestPayload: Encodable {
let userId: UUID
let customName: String?
}
final class ContactsService {
private let client: NetworkClient
private let decoder: JSONDecoder
private let encoder: JSONEncoder
init(client: NetworkClient = .shared) {
self.client = client
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
self.encoder = JSONEncoder()
self.encoder.keyEncodingStrategy = .convertToSnakeCase
}
func fetchContacts(limit: Int, offset: Int, completion: @escaping (Result<ContactsListPayload, Error>) -> Void) {
@ -88,6 +111,167 @@ final class ContactsService {
}
}
func addContact(userId: UUID, customName: String?, completion: @escaping (Result<ContactPayload, Error>) -> Void) {
let request = ContactCreateRequestPayload(
userId: userId,
login: nil,
friendCode: nil,
customName: customName
)
guard let body = try? encoder.encode(request) else {
let message = NSLocalizedString("Не удалось подготовить данные запроса.", comment: "Contacts service encoding error")
completion(.failure(ContactsServiceError.encoding(message)))
return
}
client.request(
path: "/v1/user/contact/add",
method: .post,
body: body,
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<ContactPayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось добавить контакт.", comment: "Contacts service add unexpected status")
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
return
}
completion(.success(apiResponse.data))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[ContactsService] decode contact add failed: \(debugMessage)")
}
completion(.failure(ContactsServiceError.decoding(debugDescription: debugMessage)))
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func addContact(userId: UUID, customName: String?) async throws -> ContactPayload {
try await withCheckedThrowingContinuation { continuation in
addContact(userId: userId, customName: customName) { result in
continuation.resume(with: result)
}
}
}
func removeContact(userId: UUID, completion: @escaping (Result<Void, Error>) -> Void) {
let request = ContactDeleteRequestPayload(userId: userId)
guard let body = try? encoder.encode(request) else {
let message = NSLocalizedString("Не удалось подготовить данные запроса.", comment: "Contacts service encoding error")
completion(.failure(ContactsServiceError.encoding(message)))
return
}
client.request(
path: "/v1/user/contact/remove",
method: .delete,
body: body,
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось удалить контакт.", comment: "Contacts service delete unexpected status")
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
return
}
completion(.success(()))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[ContactsService] decode contact delete failed: \(debugMessage)")
}
completion(.failure(ContactsServiceError.decoding(debugDescription: debugMessage)))
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func removeContact(userId: UUID) async throws {
try await withCheckedThrowingContinuation { continuation in
removeContact(userId: userId) { result in
continuation.resume(with: result)
}
}
}
func updateContact(userId: UUID, customName: String?, completion: @escaping (Result<Void, Error>) -> Void) {
let request = ContactUpdateRequestPayload(userId: userId, customName: customName)
guard let body = try? encoder.encode(request) else {
let message = NSLocalizedString("Не удалось подготовить данные запроса.", comment: "Contacts service encoding error")
completion(.failure(ContactsServiceError.encoding(message)))
return
}
client.request(
path: "/v1/user/contact/update",
method: .patch,
body: body,
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<MessagePayload>.self, from: response.data)
guard apiResponse.status == "fine" else {
let message = apiResponse.detail ?? NSLocalizedString("Не удалось обновить контакт.", comment: "Contacts service update unexpected status")
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
return
}
completion(.success(()))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {
print("[ContactsService] decode contact update failed: \(debugMessage)")
}
completion(.failure(ContactsServiceError.decoding(debugDescription: debugMessage)))
}
case .failure(let error):
if case let NetworkError.server(_, data) = error,
let data,
let message = Self.errorMessage(from: data) {
completion(.failure(ContactsServiceError.unexpectedStatus(message)))
return
}
completion(.failure(error))
}
}
}
func updateContact(userId: UUID, customName: String?) async throws {
try await withCheckedThrowingContinuation { continuation in
updateContact(userId: userId, customName: customName) { 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)

View File

@ -809,6 +809,28 @@
}
}
},
"Изменение фото недоступно" : {
"comment" : "Contact add avatar unavailable title\nContact edit avatar unavailable title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Photo change unavailable"
}
}
}
},
"Изменить" : {
"comment" : "Message profile edit contact button",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Edit"
}
}
}
},
"Изменить контакт" : {
"comment" : "Contacts context action edit"
},
@ -816,13 +838,27 @@
},
"Изменить фото" : {
"comment" : "Edit avatar button title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Change photo"
}
}
}
},
"Изображение" : {
"comment" : "Image message placeholder"
},
"Имя в чате" : {
},
"Имя контакта должно быть короче 32 символов." : {
"comment" : "Contact edit name too long message"
},
"Имя не может быть пустым." : {
"comment" : "Contact add empty name error\nContact edit empty name error"
},
"Имя, логин и статус — как в профиле Telegram." : {
"comment" : "Message profile about description"
@ -894,6 +930,20 @@
"Коды восстановления" : {
"comment" : "Раздел кодов восстановления 2FA"
},
"Контакт" : {
"comment" : "Contact edit title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Contact"
}
}
}
},
"Контакт \"%1$@\" будет удалён из списка." : {
"comment" : "Contact delete confirmation message"
},
"Контактов пока нет" : {
"comment" : "Contacts empty state title"
},
@ -1129,6 +1179,17 @@
"Мы отправим письмо, как только функция будет готова." : {
"comment" : "Сообщение при недоступной отправке письма"
},
"Мы пока не можем обновить фото контакта." : {
"comment" : "Contact add avatar unavailable message\nContact edit avatar unavailable message",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "We cant update the contact photo yet."
}
}
}
},
"Мы постараемся всё исправить. Напишите, что смутило." : {
"comment" : "feedback: rating description 2",
"localizations" : {
@ -1282,6 +1343,9 @@
"Не удалось выполнить поиск." : {
"comment" : "Search error fallback\nSearch service decoding error"
},
"Не удалось добавить контакт." : {
"comment" : "Contacts service add unexpected status"
},
"Не удалось заблокировать пользователя." : {
"comment" : "Blocked users create unexpected status"
},
@ -1291,6 +1355,17 @@
"Не удалось завершить сессию." : {
"comment" : "Sessions service revoke unexpected status"
},
"Не удалось загрузить данные контакта." : {
"comment" : "Contact edit missing profile message",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Failed to load contact data."
}
}
}
},
"Не удалось загрузить историю чата." : {
},
@ -1338,6 +1413,9 @@
"Не удалось обновить аватар." : {
"comment" : "Avatar upload unexpected status"
},
"Не удалось обновить контакт." : {
"comment" : "Contacts service update unexpected status"
},
"Не удалось обновить пароль." : {
"localizations" : {
"en" : {
@ -1372,9 +1450,15 @@
}
}
},
"Не удалось определить контакт." : {
"comment" : "Contact delete invalid user id error\nContact edit invalid user id error"
},
"Не удалось определить пользователя для блокировки." : {
"comment" : "Message profile missing user id error"
},
"Не удалось определить пользователя для добавления." : {
"comment" : "Contact add invalid user id error"
},
"Не удалось открыть чат" : {
"comment" : "Chat creation error title"
},
@ -1388,7 +1472,7 @@
},
"Не удалось подготовить данные запроса." : {
"comment" : "Blocked users create encoding error\nBlocked users delete encoding error\nProfile update encoding error"
"comment" : "Blocked users create encoding error\nBlocked users delete encoding error\nContacts service encoding error\nProfile update encoding error"
},
"Не удалось подготовить изображение для загрузки." : {
"comment" : "Avatar encoding error"
@ -1412,6 +1496,9 @@
"Не удалось сохранить изменения профиля." : {
"comment" : "Profile update unexpected status"
},
"Не удалось удалить контакт." : {
"comment" : "Contacts service delete unexpected status"
},
"Не удалось удалить пользователя из списка." : {
"comment" : "Blocked users delete unexpected status"
},
@ -1598,6 +1685,9 @@
"Новое сообщение" : {
"comment" : "Default banner subtitle"
},
"Новый контакт" : {
"comment" : "Contact add title"
},
"Новый пароль" : {
"comment" : "Новый пароль",
"localizations" : {
@ -1712,7 +1802,7 @@
"comment" : "Common cancel\nОбщий текст кнопки отмены"
},
"Отображаемое имя" : {
"comment" : "Display name field placeholder"
},
"Отправить код ещё раз" : {
@ -2396,7 +2486,7 @@
},
"Публичная информация" : {
"comment" : "Contact add public info section title\nProfile info section title"
},
"Публичное имя" : {
@ -2513,6 +2603,18 @@
}
}
},
"Редактирование контакта появится позже." : {
"comment" : "Message profile edit contact alert message",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Contact editing will be available later."
}
}
}
},
"Редактировать профиль" : {
"localizations" : {
"en" : {
@ -2659,6 +2761,9 @@
},
"Скрыть" : {
},
"Слишком длинное имя" : {
"comment" : "Contact edit name too long title"
},
"Слишком много запросов." : {
"localizations" : {
@ -2731,6 +2836,17 @@
"Сохраните секретный ключ и введите код из приложения, чтобы завершить настройку." : {
"comment" : "Сообщение после активации 2FA"
},
"Сохранить" : {
"comment" : "Contact add save button\nContact edit save button",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Save"
}
}
}
},
"Сохранить изменения" : {
"localizations" : {
"en" : {
@ -2899,11 +3015,29 @@
"Удаление контакта \"%1$@\" появится позже." : {
"comment" : "Contacts delete placeholder message"
},
"Удаление контакта появится позже." : {
"comment" : "Contact edit delete placeholder message",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Contact deletion will be available later."
}
}
}
},
"Удалить" : {
"comment" : "Contact delete confirm action"
},
"Удалить из заблокированных?" : {
"comment" : "Unblock confirmation title"
},
"Удалить контакт" : {
"comment" : "Contacts context action delete"
"comment" : "Contact edit delete action\nContacts context action delete"
},
"Удалить контакт?" : {
"comment" : "Contact delete confirmation title"
},
"Удалить фото" : {
"comment" : "Avatar delete"
@ -2918,6 +3052,9 @@
}
}
},
"Удаляем..." : {
"comment" : "Contact delete in progress"
},
"Удалять аккаунт через %lld дн." : {
"localizations" : {
"en" : {

View File

@ -43,6 +43,31 @@ struct MessageProfileView: View {
.background(Color(UIColor.systemGroupedBackground).ignoresSafeArea())
.navigationTitle(NSLocalizedString("Профиль", comment: "Message profile navigation title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if canEditContact {
if let profile = currentChatProfile {
NavigationLink {
ContactEditView(
contact: ContactEditInfo(profile: profile),
onContactDeleted: {
handleContactDeleted()
},
onContactUpdated: { newName in
handleContactUpdated(newName)
}
)
} label: {
Text(NSLocalizedString("Изменить", comment: "Message profile edit contact button"))
}
} else {
Button(NSLocalizedString("Изменить", comment: "Message profile edit contact button")) {
handleEditContactTap()
}
}
}
}
}
.alert(item: $placeholderAlert) { alert in
Alert(
title: Text(alert.title),
@ -270,12 +295,26 @@ struct MessageProfileView: View {
if shouldShowRelationshipQuickActions {
rowDivider
if let profile = currentChatProfile {
NavigationLink {
ContactAddView(contact: ContactEditInfo(profile: profile)) { payload in
handleContactAdded(payload)
}
} label: {
filledActionLabel(
title: NSLocalizedString("Добавить в контакты", comment: "Message profile add to contacts title"),
tint: Color.accentColor
)
}
.buttonStyle(.plain)
} else {
filledActionButton(
title: NSLocalizedString("Добавить в контакты", comment: "Message profile add to contacts title"),
tint: Color.accentColor
) {
handleAddContactTap()
}
}
rowDivider
filledActionButton(
@ -484,6 +523,16 @@ struct MessageProfileView: View {
action: @escaping () -> Void
) -> some View {
Button(action: action) {
filledActionLabel(title: title, subtitle: subtitle, tint: tint)
}
.buttonStyle(.plain)
}
private func filledActionLabel(
title: String,
subtitle: String? = nil,
tint: Color
) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.body)
@ -498,8 +547,6 @@ struct MessageProfileView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
}
.buttonStyle(.plain)
}
private func iconBackground<Content: View>(color: Color, @ViewBuilder content: () -> Content) -> some View {
RoundedRectangle(cornerRadius: 14, style: .continuous)
@ -526,6 +573,98 @@ struct MessageProfileView: View {
)
}
private func handleContactAdded(_ payload: ContactPayload) {
guard let profile = currentChatProfile else { return }
let existingRelationship = profile.relationship
let updatedRelationship = RelationshipStatus(
isTargetInContactsOfCurrentUser: true,
isCurrentUserInContactsOfTarget: existingRelationship?.isCurrentUserInContactsOfTarget ?? false,
isTargetUserBlockedByCurrentUser: existingRelationship?.isTargetUserBlockedByCurrentUser ?? false,
isCurrentUserInBlacklistOfTarget: existingRelationship?.isCurrentUserInBlacklistOfTarget ?? false
)
let updatedProfile = ChatProfile(
userId: profile.userId,
login: profile.login,
fullName: profile.fullName,
customName: payload.customName ?? profile.customName,
bio: profile.bio,
lastSeen: profile.lastSeen,
createdAt: profile.createdAt,
avatars: profile.avatars,
stories: profile.stories,
permissions: profile.permissions,
profilePermissions: profile.profilePermissions,
relationship: updatedRelationship,
rating: profile.rating,
isOfficial: profile.isOfficial
)
chatProfile = updatedProfile
}
private func handleContactUpdated(_ newName: String) {
guard let profile = currentChatProfile else { return }
let updatedProfile = ChatProfile(
userId: profile.userId,
login: profile.login,
fullName: profile.fullName,
customName: newName,
bio: profile.bio,
lastSeen: profile.lastSeen,
createdAt: profile.createdAt,
avatars: profile.avatars,
stories: profile.stories,
permissions: profile.permissions,
profilePermissions: profile.profilePermissions,
relationship: profile.relationship,
rating: profile.rating,
isOfficial: profile.isOfficial
)
chatProfile = updatedProfile
}
private func handleContactDeleted() {
guard let profile = currentChatProfile else { return }
let existingRelationship = profile.relationship
let updatedRelationship = RelationshipStatus(
isTargetInContactsOfCurrentUser: false,
isCurrentUserInContactsOfTarget: existingRelationship?.isCurrentUserInContactsOfTarget ?? false,
isTargetUserBlockedByCurrentUser: existingRelationship?.isTargetUserBlockedByCurrentUser ?? false,
isCurrentUserInBlacklistOfTarget: existingRelationship?.isCurrentUserInBlacklistOfTarget ?? false
)
let updatedProfile = ChatProfile(
userId: profile.userId,
login: profile.login,
fullName: profile.fullName,
customName: nil,
bio: profile.bio,
lastSeen: profile.lastSeen,
createdAt: profile.createdAt,
avatars: profile.avatars,
stories: profile.stories,
permissions: profile.permissions,
profilePermissions: profile.profilePermissions,
relationship: updatedRelationship,
rating: profile.rating,
isOfficial: profile.isOfficial
)
chatProfile = updatedProfile
}
private func handleEditContactTap() {
showPlaceholderAction(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: NSLocalizedString("Не удалось загрузить данные контакта.", comment: "Contact edit missing profile message")
)
}
private func handleBlockToggleTap() {
guard !isProcessingBlockAction else { return }
@ -826,6 +965,10 @@ struct MessageProfileView: View {
chatProfile ?? chat.chatData
}
private var canEditContact: Bool {
currentChatProfile?.relationship?.isTargetInContactsOfCurrentUser ?? false
}
private var isBlockedByCurrentUser: Bool { isBlockedByCurrentUserState }
private var avatarUrl: URL? {

View File

@ -0,0 +1,184 @@
import SwiftUI
struct ContactAddView: View {
let contact: ContactEditInfo
let onContactAdded: ((ContactPayload) -> Void)?
@Environment(\.dismiss) private var dismiss
private let contactsService = ContactsService()
@State private var displayName: String
@State private var activeAlert: ContactAddAlert?
@State private var isSaving = false
init(contact: ContactEditInfo, onContactAdded: ((ContactPayload) -> Void)? = nil) {
self.contact = contact
self.onContactAdded = onContactAdded
let initialName = contact.preferredName
_displayName = State(initialValue: initialName)
}
var body: some View {
Form {
avatarSection
Section(header: Text(NSLocalizedString("Публичная информация", comment: "Contact add public info section title"))) {
TextField(NSLocalizedString("Отображаемое имя", comment: "Display name field placeholder"), text: $displayName)
.disabled(isSaving)
}
}
.navigationTitle(NSLocalizedString("Новый контакт", comment: "Contact add title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
if isSaving {
ProgressView()
} else {
Button(NSLocalizedString("Сохранить", comment: "Contact add save button")) {
handleSaveTap()
}
.disabled(!hasChanges)
}
}
}
.alert(item: $activeAlert) { item in
Alert(
title: Text(item.title),
message: Text(item.message),
dismissButton: .default(Text(NSLocalizedString("Понятно", comment: "Placeholder alert dismiss")))
)
}
}
private var avatarSection: some View {
Section {
HStack {
Spacer()
VStack(spacing: 8) {
avatarView
.frame(width: 120, height: 120)
.clipShape(Circle())
Button(NSLocalizedString("Изменить фото", comment: "Edit avatar button title")) {
showAvatarUnavailableAlert()
}
}
Spacer()
}
}
.listRowBackground(Color(UIColor.systemGroupedBackground))
}
@ViewBuilder
private var avatarView: some View {
if let url = avatarURL,
let fileId = contact.avatarFileId {
CachedAvatarView(url: url, fileId: fileId, userId: contact.userId) {
avatarPlaceholder
}
.aspectRatio(contentMode: .fill)
} else {
avatarPlaceholder
}
}
private var avatarPlaceholder: some View {
Circle()
.fill(Color.accentColor.opacity(0.15))
.overlay(
Text(avatarInitial)
.font(.system(size: 48, weight: .semibold))
.foregroundColor(.accentColor)
)
}
private var avatarInitial: String {
let trimmedName = displayName.trimmedNonEmpty ?? contact.preferredName
if let first = trimmedName.trimmingCharacters(in: .whitespacesAndNewlines).first {
return String(first).uppercased()
}
if let login = contact.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
return String(login.prefix(1)).uppercased()
}
return "?"
}
private var avatarURL: URL? {
guard let fileId = contact.avatarFileId else { return nil }
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(contact.userId)?file_id=\(fileId)")
}
private var hasChanges: Bool {
let trimmed = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if let existing = contact.customName?.trimmedNonEmpty {
return trimmed != existing
}
return true
}
private func showAvatarUnavailableAlert() {
activeAlert = ContactAddAlert(
title: NSLocalizedString("Изменение фото недоступно", comment: "Contact add avatar unavailable title"),
message: NSLocalizedString("Мы пока не можем обновить фото контакта.", comment: "Contact add avatar unavailable message")
)
}
private func handleSaveTap() {
guard !isSaving else { return }
guard let userId = UUID(uuidString: contact.userId) else {
activeAlert = ContactAddAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: NSLocalizedString("Не удалось определить пользователя для добавления.", comment: "Contact add invalid user id error")
)
return
}
let trimmedName = displayName.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
guard !trimmedName.isEmpty else {
activeAlert = ContactAddAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: NSLocalizedString("Имя не может быть пустым.", comment: "Contact add empty name error")
)
return
}
isSaving = true
let customName = trimmedName
Task {
do {
let payload = try await contactsService.addContact(userId: userId, customName: customName)
await MainActor.run {
isSaving = false
onContactAdded?(payload)
dismiss()
}
} catch {
await MainActor.run {
isSaving = false
activeAlert = ContactAddAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: error.localizedDescription
)
}
}
}
}
}
private struct ContactAddAlert: Identifiable {
let id = UUID()
let title: String
let message: String
}
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}

View File

@ -0,0 +1,330 @@
import SwiftUI
struct ContactEditInfo {
let userId: String
let login: String?
let fullName: String?
let customName: String?
let avatarFileId: String?
init(userId: String, login: String?, fullName: String?, customName: String?, avatarFileId: String?) {
self.userId = userId
self.login = login
self.fullName = fullName
self.customName = customName
self.avatarFileId = avatarFileId
}
init(userId: UUID, login: String?, fullName: String?, customName: String?, avatarFileId: String?) {
self.init(userId: userId.uuidString, login: login, fullName: fullName, customName: customName, avatarFileId: avatarFileId)
}
init(profile: ChatProfile) {
self.init(
userId: profile.userId,
login: profile.login,
fullName: profile.fullName,
customName: profile.customName,
avatarFileId: profile.avatars?.current?.fileId
)
}
init(payload: ContactPayload) {
self.init(
userId: payload.userId,
login: payload.login,
fullName: payload.fullName,
customName: payload.customName,
avatarFileId: nil
)
}
var preferredName: String {
if let custom = customName?.trimmedNonEmpty {
return custom
}
if let full = fullName?.trimmedNonEmpty {
return full
}
if let login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "@\(login)"
}
return NSLocalizedString("Неизвестный пользователь", comment: "Message profile fallback title")
}
}
struct ContactEditView: View {
let contact: ContactEditInfo
let onContactDeleted: (() -> Void)?
let onContactUpdated: ((String) -> Void)?
@Environment(\.dismiss) private var dismiss
private let contactsService = ContactsService()
@State private var displayName: String
@State private var activeAlert: ContactEditAlert?
@State private var isSaving = false
@State private var isDeleting = false
@State private var showDeleteConfirmation = false
init(
contact: ContactEditInfo,
onContactDeleted: (() -> Void)? = nil,
onContactUpdated: ((String) -> Void)? = nil
) {
self.contact = contact
self.onContactDeleted = onContactDeleted
self.onContactUpdated = onContactUpdated
let initialName = contact.preferredName
_displayName = State(initialValue: initialName)
}
var body: some View {
Form {
avatarSection
Section(header: Text(NSLocalizedString("Публичная информация", comment: "Profile info section title"))) {
TextField(NSLocalizedString("Отображаемое имя", comment: "Display name field placeholder"), text: $displayName)
.disabled(isSaving || isDeleting)
}
Section {
Button(role: .destructive) {
handleDeleteTap()
} label: {
deleteButtonLabel
}
.disabled(isDeleting)
}
}
.navigationTitle(NSLocalizedString("Контакт", comment: "Contact edit title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
if isSaving {
ProgressView()
} else {
Button(NSLocalizedString("Сохранить", comment: "Contact edit save button")) {
handleSaveTap()
}
.disabled(!hasChanges || isDeleting)
}
}
}
.alert(item: $activeAlert) { item in
Alert(
title: Text(item.title),
message: Text(item.message),
dismissButton: .default(Text(NSLocalizedString("Понятно", comment: "Placeholder alert dismiss")))
)
}
.confirmationDialog(
NSLocalizedString("Удалить контакт?", comment: "Contact delete confirmation title"),
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button(NSLocalizedString("Удалить", comment: "Contact delete confirm action"), role: .destructive) {
confirmDelete()
}
Button(NSLocalizedString("Отмена", comment: "Common cancel"), role: .cancel) {
showDeleteConfirmation = false
}
} message: {
Text(String(
format: NSLocalizedString("Контакт \"%1$@\" будет удалён из списка.", comment: "Contact delete confirmation message"),
contact.preferredName
))
}
}
@ViewBuilder
private var deleteButtonLabel: some View {
if isDeleting {
HStack(spacing: 8) {
ProgressView()
Text(NSLocalizedString("Удаляем...", comment: "Contact delete in progress"))
}
.frame(maxWidth: .infinity, alignment: .center)
} else {
Text(NSLocalizedString("Удалить контакт", comment: "Contact edit delete action"))
.frame(maxWidth: .infinity, alignment: .center)
}
}
private var avatarSection: some View {
Section {
HStack {
Spacer()
VStack(spacing: 8) {
avatarView
.frame(width: 120, height: 120)
.clipShape(Circle())
Button(NSLocalizedString("Изменить фото", comment: "Edit avatar button title")) {
showAvatarUnavailableAlert()
}
}
Spacer()
}
}
.listRowBackground(Color(UIColor.systemGroupedBackground))
}
@ViewBuilder
private var avatarView: some View {
if let url = avatarURL,
let fileId = contact.avatarFileId {
CachedAvatarView(url: url, fileId: fileId, userId: contact.userId) {
avatarPlaceholder
}
.aspectRatio(contentMode: .fill)
} else {
avatarPlaceholder
}
}
private var avatarPlaceholder: some View {
Circle()
.fill(Color.accentColor.opacity(0.15))
.overlay(
Text(avatarInitial)
.font(.system(size: 48, weight: .semibold))
.foregroundColor(.accentColor)
)
}
private var avatarInitial: String {
let trimmedName = displayName.trimmedNonEmpty ?? contact.preferredName
if let first = trimmedName.trimmingCharacters(in: .whitespacesAndNewlines).first {
return String(first).uppercased()
}
if let login = contact.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
return String(login.prefix(1)).uppercased()
}
return "?"
}
private var avatarURL: URL? {
guard let fileId = contact.avatarFileId else { return nil }
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(contact.userId)?file_id=\(fileId)")
}
private var hasChanges: Bool {
let trimmed = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if let existing = contact.customName?.trimmedNonEmpty {
return trimmed != existing
}
return true
}
private func showAvatarUnavailableAlert() {
activeAlert = ContactEditAlert(
title: NSLocalizedString("Изменение фото недоступно", comment: "Contact edit avatar unavailable title"),
message: NSLocalizedString("Мы пока не можем обновить фото контакта.", comment: "Contact edit avatar unavailable message")
)
}
private func handleSaveTap() {
guard !isSaving, !isDeleting else { return }
guard let userId = UUID(uuidString: contact.userId) else {
activeAlert = ContactEditAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: NSLocalizedString("Не удалось определить контакт.", comment: "Contact edit invalid user id error")
)
return
}
let trimmed = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
activeAlert = ContactEditAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: NSLocalizedString("Имя не может быть пустым.", comment: "Contact edit empty name error")
)
return
}
if trimmed.count > 32 {
activeAlert = ContactEditAlert(
title: NSLocalizedString("Слишком длинное имя", comment: "Contact edit name too long title"),
message: NSLocalizedString("Имя контакта должно быть короче 32 символов.", comment: "Contact edit name too long message")
)
return
}
isSaving = true
Task {
do {
try await contactsService.updateContact(userId: userId, customName: trimmed)
await MainActor.run {
isSaving = false
onContactUpdated?(trimmed)
dismiss()
}
} catch {
await MainActor.run {
isSaving = false
activeAlert = ContactEditAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: error.localizedDescription
)
}
}
}
}
private func handleDeleteTap() {
guard !isDeleting else { return }
showDeleteConfirmation = true
}
private func confirmDelete() {
guard !isDeleting else { return }
guard let userId = UUID(uuidString: contact.userId) else {
activeAlert = ContactEditAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: NSLocalizedString("Не удалось определить контакт.", comment: "Contact delete invalid user id error")
)
return
}
isDeleting = true
showDeleteConfirmation = false
Task {
do {
try await contactsService.removeContact(userId: userId)
await MainActor.run {
isDeleting = false
onContactDeleted?()
dismiss()
}
} catch {
await MainActor.run {
isDeleting = false
activeAlert = ContactEditAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: error.localizedDescription
)
}
}
}
}
}
private struct ContactEditAlert: Identifiable {
let id = UUID()
let title: String
let message: String
}
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}

View File

@ -70,35 +70,27 @@ struct EditPrivacyView: View {
Toggle(NSLocalizedString("Показывать био не-контактам", comment: ""), isOn: $profilePermissions.showBioToNonContacts)
Toggle(NSLocalizedString("Показывать сторисы не-контактам", comment: ""), isOn: $profilePermissions.showStoriesToNonContacts)
Picker(NSLocalizedString("Видимость статуса 'был в сети'", comment: ""), selection: $profilePermissions.lastSeenVisibility) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
privacyScopePicker(
title: NSLocalizedString("Видимость статуса 'был в сети'", comment: ""),
selection: $profilePermissions.lastSeenVisibility
)
}
Section(header: Text(NSLocalizedString("Приглашения и звонки", comment: ""))) {
Picker(NSLocalizedString("Кто может приглашать в паблики", comment: ""), selection: $profilePermissions.publicInvitePermission) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
privacyScopePicker(
title: NSLocalizedString("Кто может приглашать в паблики", comment: ""),
selection: $profilePermissions.publicInvitePermission
)
Picker(NSLocalizedString("Кто может приглашать в беседы", comment: ""), selection: $profilePermissions.groupInvitePermission) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
privacyScopePicker(
title: NSLocalizedString("Кто может приглашать в беседы", comment: ""),
selection: $profilePermissions.groupInvitePermission
)
Picker(NSLocalizedString("Кто может звонить", comment: ""), selection: $profilePermissions.callPermission) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
privacyScopePicker(
title: NSLocalizedString("Кто может звонить", comment: ""),
selection: $profilePermissions.callPermission
)
}
Section(header: Text(NSLocalizedString("Чаты и хранение", comment: ""))) {
@ -192,6 +184,24 @@ struct EditPrivacyView: View {
return "\(secondsString) (≈ \(formattedHours) ч.)"
}
}
@ViewBuilder
private func privacyScopePicker(title: String, selection: Binding<Int>) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.body)
.foregroundColor(.primary)
Picker("", selection: selection) {
ForEach(privacyScopeOptions) { scope in
Text(scope.title).tag(scope.rawValue)
}
}
.pickerStyle(.segmented)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
}
}
private enum PrivacyScope: Int, CaseIterable, Identifiable {