Compare commits

...

2 Commits

Author SHA1 Message Date
aac0a25c4d add qr screen 2025-10-23 23:04:09 +03:00
e79cbd7ea4 add contact list 2025-10-23 22:49:19 +03:00
6 changed files with 422 additions and 17 deletions

View File

@ -11,6 +11,7 @@ struct TopBarView: View {
// var viewModel: LoginViewModel
@ObservedObject var viewModel: LoginViewModel
@Binding var isSettingsPresented: Bool
@Binding var isQrPresented: Bool
// Привязка для управления боковым меню
@Binding var isSideMenuPresented: Bool
@ -29,6 +30,10 @@ struct TopBarView: View {
return title == "Profile"
}
var isContactsTab: Bool {
return title == "Contacts"
}
private var statusMessage: String? {
if viewModel.chatLoadingState == .loading {
return NSLocalizedString("Загрузка чатов", comment: "")
@ -112,6 +117,14 @@ 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 {
@ -220,6 +233,7 @@ 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(
@ -229,6 +243,7 @@ struct TopBarView_Previews: PreviewProvider {
accounts: [selectedAccount],
viewModel: viewModel,
isSettingsPresented: $isSettingsPresented,
isQrPresented: $isSettingsPresented,
isSideMenuPresented: $isSideMenuPresented,
chatSearchRevealProgress: $revealProgress,
chatSearchText: $searchText,

View 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
}()
}

View File

@ -158,6 +158,9 @@
}
}
}
},
"Qr" : {
},
"Yobble" : {
"localizations" : {
@ -389,6 +392,9 @@
"Добавление новых блокировок появится позже." : {
"comment" : "Add blocked user placeholder message"
},
"Добавьте контакты, чтобы быстрее выходить на связь." : {
"comment" : "Contacts empty state subtitle"
},
"Другое" : {
"localizations" : {
"en" : {
@ -596,6 +602,12 @@
"Кликер в разработке" : {
"comment" : "Concept tab placeholder title"
},
"Код дружбы" : {
"comment" : "Friend code badge"
},
"Контактов пока нет" : {
"comment" : "Contacts empty state title"
},
"Контакты" : {
"localizations" : {
"en" : {
@ -919,6 +931,9 @@
},
"Не удалось загрузить историю чата." : {
},
"Не удалось загрузить контакты." : {
"comment" : "Contacts service decoding error\nContacts service 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" : {
"en" : {
"stringUnit" : {

View File

@ -1,24 +1,213 @@
//
// 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 {
VStack {
VStack {
Text("Здесь не будут чаты")
.font(.title)
.foregroundColor(.gray)
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)
}
}
}
}
.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")))
)
}
}
}
Spacer()
private var loadingState: some View {
Section {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
}
}
// .background(Color(.secondarySystemBackground)) // Фон для всей вкладки
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
}
}
}

View File

@ -18,6 +18,7 @@ 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
@ -50,6 +51,7 @@ struct MainView: View {
accounts: accounts,
viewModel: viewModel,
isSettingsPresented: $isSettingsPresented,
isQrPresented: $isQrPresented,
isSideMenuPresented: $isSideMenuPresented,
chatSearchRevealProgress: $chatSearchRevealProgress,
chatSearchText: $chatSearchText

View File

@ -0,0 +1,11 @@
import SwiftUI
struct QrView: View {
var body: some View {
Form {
}
.navigationTitle("Qr")
}
}