Compare commits
	
		
			2 Commits
		
	
	
		
			813795aece
			...
			aac0a25c4d
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| aac0a25c4d | |||
| e79cbd7ea4 | 
@ -11,6 +11,7 @@ struct TopBarView: View {
 | 
				
			|||||||
//    var viewModel: LoginViewModel
 | 
					//    var viewModel: LoginViewModel
 | 
				
			||||||
    @ObservedObject var viewModel: LoginViewModel
 | 
					    @ObservedObject var viewModel: LoginViewModel
 | 
				
			||||||
    @Binding var isSettingsPresented: Bool
 | 
					    @Binding var isSettingsPresented: Bool
 | 
				
			||||||
 | 
					    @Binding var isQrPresented: Bool
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    // Привязка для управления боковым меню
 | 
					    // Привязка для управления боковым меню
 | 
				
			||||||
    @Binding var isSideMenuPresented: Bool
 | 
					    @Binding var isSideMenuPresented: Bool
 | 
				
			||||||
@ -29,6 +30,10 @@ struct TopBarView: View {
 | 
				
			|||||||
        return title == "Profile"
 | 
					        return title == "Profile"
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
 | 
					    var isContactsTab: Bool {
 | 
				
			||||||
 | 
					        return title == "Contacts"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private var statusMessage: String? {
 | 
					    private var statusMessage: String? {
 | 
				
			||||||
        if viewModel.chatLoadingState == .loading {
 | 
					        if viewModel.chatLoadingState == .loading {
 | 
				
			||||||
            return NSLocalizedString("Загрузка чатов", comment: "")
 | 
					            return NSLocalizedString("Загрузка чатов", comment: "")
 | 
				
			||||||
@ -112,6 +117,14 @@ struct TopBarView: View {
 | 
				
			|||||||
                            .imageScale(.large)
 | 
					                            .imageScale(.large)
 | 
				
			||||||
                            .foregroundColor(.primary)
 | 
					                            .foregroundColor(.primary)
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
 | 
					                } else if isContactsTab {
 | 
				
			||||||
 | 
					                    NavigationLink(isActive: $isQrPresented) {
 | 
				
			||||||
 | 
					                        QrView()
 | 
				
			||||||
 | 
					                    } label: {
 | 
				
			||||||
 | 
					                        Image(systemName: "qrcode.viewfinder")
 | 
				
			||||||
 | 
					                            .imageScale(.large)
 | 
				
			||||||
 | 
					                            .foregroundColor(.primary)
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                
 | 
					                
 | 
				
			||||||
//                else if isChatsTab {
 | 
					//                else if isChatsTab {
 | 
				
			||||||
@ -220,6 +233,7 @@ struct TopBarView_Previews: PreviewProvider {
 | 
				
			|||||||
        @StateObject private var viewModel = LoginViewModel()
 | 
					        @StateObject private var viewModel = LoginViewModel()
 | 
				
			||||||
        @State private var searchText: String = ""
 | 
					        @State private var searchText: String = ""
 | 
				
			||||||
        @State private var isSettingsPresented = false
 | 
					        @State private var isSettingsPresented = false
 | 
				
			||||||
 | 
					        @State private var isQrPresented = false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        var body: some View {
 | 
					        var body: some View {
 | 
				
			||||||
            TopBarView(
 | 
					            TopBarView(
 | 
				
			||||||
@ -229,6 +243,7 @@ struct TopBarView_Previews: PreviewProvider {
 | 
				
			|||||||
                accounts: [selectedAccount],
 | 
					                accounts: [selectedAccount],
 | 
				
			||||||
                viewModel: viewModel,
 | 
					                viewModel: viewModel,
 | 
				
			||||||
                isSettingsPresented: $isSettingsPresented,
 | 
					                isSettingsPresented: $isSettingsPresented,
 | 
				
			||||||
 | 
					                isQrPresented: $isSettingsPresented,
 | 
				
			||||||
                isSideMenuPresented: $isSideMenuPresented,
 | 
					                isSideMenuPresented: $isSideMenuPresented,
 | 
				
			||||||
                chatSearchRevealProgress: $revealProgress,
 | 
					                chatSearchRevealProgress: $revealProgress,
 | 
				
			||||||
                chatSearchText: $searchText,
 | 
					                chatSearchText: $searchText,
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										173
									
								
								yobble/Network/ContactsService.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								yobble/Network/ContactsService.swift
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,173 @@
 | 
				
			|||||||
 | 
					import Foundation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					enum ContactsServiceError: 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: "Contacts service decoding error")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					struct ContactPayload: Decodable {
 | 
				
			||||||
 | 
					    let userId: UUID
 | 
				
			||||||
 | 
					    let login: String
 | 
				
			||||||
 | 
					    let fullName: String?
 | 
				
			||||||
 | 
					    let customName: String?
 | 
				
			||||||
 | 
					    let friendCode: Bool
 | 
				
			||||||
 | 
					    let createdAt: Date
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final class ContactsService {
 | 
				
			||||||
 | 
					    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 fetchContacts(completion: @escaping (Result<[ContactPayload], Error>) -> Void) {
 | 
				
			||||||
 | 
					        client.request(
 | 
				
			||||||
 | 
					            path: "/v1/user/contact/list",
 | 
				
			||||||
 | 
					            method: .get,
 | 
				
			||||||
 | 
					            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 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 contacts 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 fetchContacts() async throws -> [ContactPayload] {
 | 
				
			||||||
 | 
					        try await withCheckedThrowingContinuation { continuation in
 | 
				
			||||||
 | 
					            fetchContacts { 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
 | 
				
			||||||
 | 
					    }()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -158,6 +158,9 @@
 | 
				
			|||||||
          }
 | 
					          }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "Qr" : {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    "Yobble" : {
 | 
					    "Yobble" : {
 | 
				
			||||||
      "localizations" : {
 | 
					      "localizations" : {
 | 
				
			||||||
@ -389,6 +392,9 @@
 | 
				
			|||||||
    "Добавление новых блокировок появится позже." : {
 | 
					    "Добавление новых блокировок появится позже." : {
 | 
				
			||||||
      "comment" : "Add blocked user placeholder message"
 | 
					      "comment" : "Add blocked user placeholder message"
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "Добавьте контакты, чтобы быстрее выходить на связь." : {
 | 
				
			||||||
 | 
					      "comment" : "Contacts empty state subtitle"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "Другое" : {
 | 
					    "Другое" : {
 | 
				
			||||||
      "localizations" : {
 | 
					      "localizations" : {
 | 
				
			||||||
        "en" : {
 | 
					        "en" : {
 | 
				
			||||||
@ -596,6 +602,12 @@
 | 
				
			|||||||
    "Кликер в разработке" : {
 | 
					    "Кликер в разработке" : {
 | 
				
			||||||
      "comment" : "Concept tab placeholder title"
 | 
					      "comment" : "Concept tab placeholder title"
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "Код дружбы" : {
 | 
				
			||||||
 | 
					      "comment" : "Friend code badge"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "Контактов пока нет" : {
 | 
				
			||||||
 | 
					      "comment" : "Contacts empty state title"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "Контакты" : {
 | 
					    "Контакты" : {
 | 
				
			||||||
      "localizations" : {
 | 
					      "localizations" : {
 | 
				
			||||||
        "en" : {
 | 
					        "en" : {
 | 
				
			||||||
@ -919,6 +931,9 @@
 | 
				
			|||||||
    },
 | 
					    },
 | 
				
			||||||
    "Не удалось загрузить историю чата." : {
 | 
					    "Не удалось загрузить историю чата." : {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "Не удалось загрузить контакты." : {
 | 
				
			||||||
 | 
					      "comment" : "Contacts service decoding error\nContacts service unexpected status"
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    "Не удалось загрузить профиль." : {
 | 
					    "Не удалось загрузить профиль." : {
 | 
				
			||||||
      "comment" : "Profile service decoding error\nProfile unexpected status"
 | 
					      "comment" : "Profile service decoding error\nProfile unexpected status"
 | 
				
			||||||
@ -1326,7 +1341,7 @@
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    "Ошибка" : {
 | 
					    "Ошибка" : {
 | 
				
			||||||
      "comment" : "Common error title\nProfile update error title",
 | 
					      "comment" : "Common error title\nContacts load error title\nProfile update error title",
 | 
				
			||||||
      "localizations" : {
 | 
					      "localizations" : {
 | 
				
			||||||
        "en" : {
 | 
					        "en" : {
 | 
				
			||||||
          "stringUnit" : {
 | 
					          "stringUnit" : {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,24 +1,213 @@
 | 
				
			|||||||
//
 | 
					 | 
				
			||||||
//  ContactsTab.swift
 | 
					 | 
				
			||||||
//  yobble
 | 
					 | 
				
			||||||
//
 | 
					 | 
				
			||||||
//  Created by cheykrym on 23.10.2025.
 | 
					 | 
				
			||||||
//
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import SwiftUI
 | 
					import SwiftUI
 | 
				
			||||||
 | 
					
 | 
				
			||||||
struct ContactsTab: View {
 | 
					struct ContactsTab: View {
 | 
				
			||||||
 | 
					    @State private var contacts: [Contact] = []
 | 
				
			||||||
 | 
					    @State private var isLoading = false
 | 
				
			||||||
 | 
					    @State private var loadError: String?
 | 
				
			||||||
 | 
					    @State private var activeAlert: ContactsAlert?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private let contactsService = ContactsService()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    var body: some View {
 | 
					    var body: some View {
 | 
				
			||||||
        VStack {
 | 
					        List {
 | 
				
			||||||
            VStack {
 | 
					            if isLoading && contacts.isEmpty {
 | 
				
			||||||
                Text("Здесь не будут чаты")
 | 
					                loadingState
 | 
				
			||||||
                    .font(.title)
 | 
					            } else if let loadError, contacts.isEmpty {
 | 
				
			||||||
                    .foregroundColor(.gray)
 | 
					                errorState(loadError)
 | 
				
			||||||
                
 | 
					            } else if contacts.isEmpty {
 | 
				
			||||||
                Spacer()
 | 
					                emptyState
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                Section(header: Text(NSLocalizedString("Контакты", comment: ""))) {
 | 
				
			||||||
 | 
					                    ForEach(contacts) { contact in
 | 
				
			||||||
 | 
					                        ContactRow(contact: contact)
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
//        .background(Color(.secondarySystemBackground)) // Фон для всей вкладки
 | 
					        .listStyle(.insetGrouped)
 | 
				
			||||||
 | 
					        .background(Color(.systemGroupedBackground))
 | 
				
			||||||
 | 
					        .task {
 | 
				
			||||||
 | 
					            await loadContacts()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        .refreshable {
 | 
				
			||||||
 | 
					            await loadContacts()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        .alert(item: $activeAlert) { alert in
 | 
				
			||||||
 | 
					            switch alert {
 | 
				
			||||||
 | 
					            case .error(_, let message):
 | 
				
			||||||
 | 
					                return Alert(
 | 
				
			||||||
 | 
					                    title: Text(NSLocalizedString("Ошибка", comment: "Contacts load error title")),
 | 
				
			||||||
 | 
					                    message: Text(message),
 | 
				
			||||||
 | 
					                    dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private var loadingState: some View {
 | 
				
			||||||
 | 
					        Section {
 | 
				
			||||||
 | 
					            ProgressView()
 | 
				
			||||||
 | 
					                .frame(maxWidth: .infinity, alignment: .center)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private func errorState(_ message: String) -> some View {
 | 
				
			||||||
 | 
					        Section {
 | 
				
			||||||
 | 
					            Text(message)
 | 
				
			||||||
 | 
					                .foregroundColor(.red)
 | 
				
			||||||
 | 
					                .frame(maxWidth: .infinity, alignment: .center)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private var emptyState: some View {
 | 
				
			||||||
 | 
					        Section {
 | 
				
			||||||
 | 
					            VStack(spacing: 12) {
 | 
				
			||||||
 | 
					                Image(systemName: "person.crop.circle.badge.questionmark")
 | 
				
			||||||
 | 
					                    .font(.system(size: 48))
 | 
				
			||||||
 | 
					                    .foregroundColor(.secondary)
 | 
				
			||||||
 | 
					                Text(NSLocalizedString("Контактов пока нет", comment: "Contacts empty state title"))
 | 
				
			||||||
 | 
					                    .font(.headline)
 | 
				
			||||||
 | 
					                    .multilineTextAlignment(.center)
 | 
				
			||||||
 | 
					                Text(NSLocalizedString("Добавьте контакты, чтобы быстрее выходить на связь.", comment: "Contacts empty state subtitle"))
 | 
				
			||||||
 | 
					                    .font(.subheadline)
 | 
				
			||||||
 | 
					                    .foregroundColor(.secondary)
 | 
				
			||||||
 | 
					                    .multilineTextAlignment(.center)
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            .frame(maxWidth: .infinity)
 | 
				
			||||||
 | 
					            .padding(.vertical, 24)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @MainActor
 | 
				
			||||||
 | 
					    private func loadContacts() async {
 | 
				
			||||||
 | 
					        if isLoading {
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        isLoading = true
 | 
				
			||||||
 | 
					        loadError = nil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        do {
 | 
				
			||||||
 | 
					            let payloads = try await contactsService.fetchContacts()
 | 
				
			||||||
 | 
					            contacts = payloads.map(Contact.init)
 | 
				
			||||||
 | 
					        } catch {
 | 
				
			||||||
 | 
					            loadError = error.localizedDescription
 | 
				
			||||||
 | 
					            activeAlert = .error(message: error.localizedDescription)
 | 
				
			||||||
 | 
					            if AppConfig.DEBUG { print("[ContactsTab] load contacts failed: \(error)") }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        isLoading = false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					private struct ContactRow: View {
 | 
				
			||||||
 | 
					    let contact: Contact
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var body: some View {
 | 
				
			||||||
 | 
					        HStack(spacing: 12) {
 | 
				
			||||||
 | 
					            Circle()
 | 
				
			||||||
 | 
					                .fill(Color.accentColor.opacity(0.15))
 | 
				
			||||||
 | 
					                .frame(width: 44, height: 44)
 | 
				
			||||||
 | 
					                .overlay(
 | 
				
			||||||
 | 
					                    Text(contact.initials)
 | 
				
			||||||
 | 
					                        .font(.headline)
 | 
				
			||||||
 | 
					                        .foregroundColor(.accentColor)
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            VStack(alignment: .leading, spacing: 4) {
 | 
				
			||||||
 | 
					                Text(contact.displayName)
 | 
				
			||||||
 | 
					                    .font(.body)
 | 
				
			||||||
 | 
					                if let handle = contact.handle {
 | 
				
			||||||
 | 
					                    Text(handle)
 | 
				
			||||||
 | 
					                        .font(.caption)
 | 
				
			||||||
 | 
					                        .foregroundColor(.secondary)
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                HStack(spacing: 8) {
 | 
				
			||||||
 | 
					                    if contact.friendCode {
 | 
				
			||||||
 | 
					                        Label(NSLocalizedString("Код дружбы", comment: "Friend code badge"), systemImage: "heart.circle")
 | 
				
			||||||
 | 
					                            .font(.caption2)
 | 
				
			||||||
 | 
					                            .foregroundColor(.secondary)
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                    Text(contact.formattedCreatedAt)
 | 
				
			||||||
 | 
					                        .font(.caption2)
 | 
				
			||||||
 | 
					                        .foregroundColor(.secondary)
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            Spacer()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        .padding(.vertical, 4)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					private struct Contact: Identifiable, Equatable {
 | 
				
			||||||
 | 
					    let id: UUID
 | 
				
			||||||
 | 
					    let login: String
 | 
				
			||||||
 | 
					    let fullName: String?
 | 
				
			||||||
 | 
					    let customName: String?
 | 
				
			||||||
 | 
					    let friendCode: Bool
 | 
				
			||||||
 | 
					    let createdAt: Date
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let displayName: String
 | 
				
			||||||
 | 
					    let handle: String?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var initials: String {
 | 
				
			||||||
 | 
					        let components = displayName.split(separator: " ")
 | 
				
			||||||
 | 
					        let nameInitials = components.prefix(2).compactMap { $0.first }
 | 
				
			||||||
 | 
					        if !nameInitials.isEmpty {
 | 
				
			||||||
 | 
					            return nameInitials
 | 
				
			||||||
 | 
					                .map { String($0).uppercased() }
 | 
				
			||||||
 | 
					                .joined()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let filtered = login.filter { $0.isLetter }.prefix(2)
 | 
				
			||||||
 | 
					        if !filtered.isEmpty {
 | 
				
			||||||
 | 
					            return filtered.uppercased()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return "??"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var formattedCreatedAt: String {
 | 
				
			||||||
 | 
					        Self.relativeFormatter.localizedString(for: createdAt, relativeTo: Date())
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    init(payload: ContactPayload) {
 | 
				
			||||||
 | 
					        self.id = payload.userId
 | 
				
			||||||
 | 
					        self.login = payload.login
 | 
				
			||||||
 | 
					        self.fullName = payload.fullName
 | 
				
			||||||
 | 
					        self.customName = payload.customName
 | 
				
			||||||
 | 
					        self.friendCode = payload.friendCode
 | 
				
			||||||
 | 
					        self.createdAt = payload.createdAt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if let customName = payload.customName, !customName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
 | 
				
			||||||
 | 
					            self.displayName = customName
 | 
				
			||||||
 | 
					        } else if let fullName = payload.fullName, !fullName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
 | 
				
			||||||
 | 
					            self.displayName = fullName
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            self.displayName = payload.login
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if !payload.login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
 | 
				
			||||||
 | 
					            self.handle = "@\(payload.login)"
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            self.handle = nil
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private static let relativeFormatter: RelativeDateTimeFormatter = {
 | 
				
			||||||
 | 
					        let formatter = RelativeDateTimeFormatter()
 | 
				
			||||||
 | 
					        formatter.unitsStyle = .short
 | 
				
			||||||
 | 
					        return formatter
 | 
				
			||||||
 | 
					    }()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					private enum ContactsAlert: Identifiable {
 | 
				
			||||||
 | 
					    case error(id: UUID = UUID(), message: String)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var id: String {
 | 
				
			||||||
 | 
					        switch self {
 | 
				
			||||||
 | 
					        case .error(let id, _):
 | 
				
			||||||
 | 
					            return id.uuidString
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -18,6 +18,7 @@ struct MainView: View {
 | 
				
			|||||||
    @State private var chatSearchRevealProgress: CGFloat = 0
 | 
					    @State private var chatSearchRevealProgress: CGFloat = 0
 | 
				
			||||||
    @State private var chatSearchText: String = ""
 | 
					    @State private var chatSearchText: String = ""
 | 
				
			||||||
    @State private var isSettingsPresented = false
 | 
					    @State private var isSettingsPresented = false
 | 
				
			||||||
 | 
					    @State private var isQrPresented = false
 | 
				
			||||||
    @State private var deepLinkChatItem: PrivateChatListItem?
 | 
					    @State private var deepLinkChatItem: PrivateChatListItem?
 | 
				
			||||||
    @State private var isDeepLinkChatActive = false
 | 
					    @State private var isDeepLinkChatActive = false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -50,6 +51,7 @@ struct MainView: View {
 | 
				
			|||||||
                            accounts: accounts,
 | 
					                            accounts: accounts,
 | 
				
			||||||
                            viewModel: viewModel,
 | 
					                            viewModel: viewModel,
 | 
				
			||||||
                            isSettingsPresented: $isSettingsPresented,
 | 
					                            isSettingsPresented: $isSettingsPresented,
 | 
				
			||||||
 | 
					                            isQrPresented: $isQrPresented,
 | 
				
			||||||
                            isSideMenuPresented: $isSideMenuPresented,
 | 
					                            isSideMenuPresented: $isSideMenuPresented,
 | 
				
			||||||
                            chatSearchRevealProgress: $chatSearchRevealProgress,
 | 
					                            chatSearchRevealProgress: $chatSearchRevealProgress,
 | 
				
			||||||
                            chatSearchText: $chatSearchText
 | 
					                            chatSearchText: $chatSearchText
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										11
									
								
								yobble/Views/Tab/QrView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								yobble/Views/Tab/QrView.swift
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,11 @@
 | 
				
			|||||||
 | 
					import SwiftUI
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					struct QrView: View {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    var body: some View {
 | 
				
			||||||
 | 
					        Form {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        .navigationTitle("Qr")
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user