Compare commits
No commits in common. "aac0a25c4d387bb164a2636f1e2bd1dc50d3f1b5" and "813795aece9251f60b3813d0363cdce3ac4f4e81" have entirely different histories.
aac0a25c4d
...
813795aece
@ -11,7 +11,6 @@ struct TopBarView: View {
|
||||
// var viewModel: LoginViewModel
|
||||
@ObservedObject var viewModel: LoginViewModel
|
||||
@Binding var isSettingsPresented: Bool
|
||||
@Binding var isQrPresented: Bool
|
||||
|
||||
// Привязка для управления боковым меню
|
||||
@Binding var isSideMenuPresented: Bool
|
||||
@ -30,10 +29,6 @@ struct TopBarView: View {
|
||||
return title == "Profile"
|
||||
}
|
||||
|
||||
var isContactsTab: Bool {
|
||||
return title == "Contacts"
|
||||
}
|
||||
|
||||
private var statusMessage: String? {
|
||||
if viewModel.chatLoadingState == .loading {
|
||||
return NSLocalizedString("Загрузка чатов", comment: "")
|
||||
@ -117,14 +112,6 @@ struct TopBarView: View {
|
||||
.imageScale(.large)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
} else if isContactsTab {
|
||||
NavigationLink(isActive: $isQrPresented) {
|
||||
QrView()
|
||||
} label: {
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
.imageScale(.large)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
// else if isChatsTab {
|
||||
@ -233,7 +220,6 @@ struct TopBarView_Previews: PreviewProvider {
|
||||
@StateObject private var viewModel = LoginViewModel()
|
||||
@State private var searchText: String = ""
|
||||
@State private var isSettingsPresented = false
|
||||
@State private var isQrPresented = false
|
||||
|
||||
var body: some View {
|
||||
TopBarView(
|
||||
@ -243,7 +229,6 @@ struct TopBarView_Previews: PreviewProvider {
|
||||
accounts: [selectedAccount],
|
||||
viewModel: viewModel,
|
||||
isSettingsPresented: $isSettingsPresented,
|
||||
isQrPresented: $isSettingsPresented,
|
||||
isSideMenuPresented: $isSideMenuPresented,
|
||||
chatSearchRevealProgress: $revealProgress,
|
||||
chatSearchText: $searchText,
|
||||
|
||||
@ -1,173 +0,0 @@
|
||||
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,9 +158,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Qr" : {
|
||||
|
||||
},
|
||||
"Yobble" : {
|
||||
"localizations" : {
|
||||
@ -392,9 +389,6 @@
|
||||
"Добавление новых блокировок появится позже." : {
|
||||
"comment" : "Add blocked user placeholder message"
|
||||
},
|
||||
"Добавьте контакты, чтобы быстрее выходить на связь." : {
|
||||
"comment" : "Contacts empty state subtitle"
|
||||
},
|
||||
"Другое" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -602,12 +596,6 @@
|
||||
"Кликер в разработке" : {
|
||||
"comment" : "Concept tab placeholder title"
|
||||
},
|
||||
"Код дружбы" : {
|
||||
"comment" : "Friend code badge"
|
||||
},
|
||||
"Контактов пока нет" : {
|
||||
"comment" : "Contacts empty state title"
|
||||
},
|
||||
"Контакты" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -931,9 +919,6 @@
|
||||
},
|
||||
"Не удалось загрузить историю чата." : {
|
||||
|
||||
},
|
||||
"Не удалось загрузить контакты." : {
|
||||
"comment" : "Contacts service decoding error\nContacts service unexpected status"
|
||||
},
|
||||
"Не удалось загрузить профиль." : {
|
||||
"comment" : "Profile service decoding error\nProfile unexpected status"
|
||||
@ -1341,7 +1326,7 @@
|
||||
}
|
||||
},
|
||||
"Ошибка" : {
|
||||
"comment" : "Common error title\nContacts load error title\nProfile update error title",
|
||||
"comment" : "Common error title\nProfile update error title",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
|
||||
@ -1,213 +1,24 @@
|
||||
//
|
||||
// ContactsTab.swift
|
||||
// yobble
|
||||
//
|
||||
// Created by cheykrym on 23.10.2025.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
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 {
|
||||
List {
|
||||
if isLoading && contacts.isEmpty {
|
||||
loadingState
|
||||
} else if let loadError, contacts.isEmpty {
|
||||
errorState(loadError)
|
||||
} else if contacts.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
Section(header: Text(NSLocalizedString("Контакты", comment: ""))) {
|
||||
ForEach(contacts) { contact in
|
||||
ContactRow(contact: contact)
|
||||
}
|
||||
}
|
||||
VStack {
|
||||
VStack {
|
||||
Text("Здесь не будут чаты")
|
||||
.font(.title)
|
||||
.foregroundColor(.gray)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.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
|
||||
}
|
||||
// .background(Color(.secondarySystemBackground)) // Фон для всей вкладки
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,7 +18,6 @@ struct MainView: View {
|
||||
@State private var chatSearchRevealProgress: CGFloat = 0
|
||||
@State private var chatSearchText: String = ""
|
||||
@State private var isSettingsPresented = false
|
||||
@State private var isQrPresented = false
|
||||
@State private var deepLinkChatItem: PrivateChatListItem?
|
||||
@State private var isDeepLinkChatActive = false
|
||||
|
||||
@ -51,7 +50,6 @@ struct MainView: View {
|
||||
accounts: accounts,
|
||||
viewModel: viewModel,
|
||||
isSettingsPresented: $isSettingsPresented,
|
||||
isQrPresented: $isQrPresented,
|
||||
isSideMenuPresented: $isSideMenuPresented,
|
||||
chatSearchRevealProgress: $chatSearchRevealProgress,
|
||||
chatSearchText: $chatSearchText
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct QrView: View {
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
||||
}
|
||||
.navigationTitle("Qr")
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user