ios_app_v2/yobble/Views/Tab/ContactsTab.swift
2025-12-03 23:10:36 +03:00

385 lines
13 KiB
Swift

import SwiftUI
import Foundation
struct ContactsTab: View {
@State private var contacts: [Contact] = []
@State private var isLoading = false
@State private var loadError: String?
@State private var pagingError: String?
@State private var activeAlert: ContactsAlert?
@State private var hasMore = true
@State private var offset = 0
private let contactsService = ContactsService()
private let pageSize = 25
var body: some View {
List {
if isLoading && contacts.isEmpty {
loadingState
}
if let loadError, contacts.isEmpty {
errorState(loadError)
} else if contacts.isEmpty {
emptyState
} else {
ForEach(Array(contacts.enumerated()), id: \.element.id) { index, contact in
Button {
showContactPlaceholder(for: contact)
} label: {
ContactRow(contact: contact)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contextMenu {
Button {
handleContactAction(.edit, for: contact)
} label: {
Label(
NSLocalizedString("Изменить контакт", comment: "Contacts context action edit"),
systemImage: "square.and.pencil"
)
}
Button {
handleContactAction(.block, for: contact)
} label: {
Label(
NSLocalizedString("Заблокировать контакт", comment: "Contacts context action block"),
systemImage: "hand.raised.fill"
)
}
Button(role: .destructive) {
handleContactAction(.delete, for: contact)
} label: {
Label(
NSLocalizedString("Удалить контакт", comment: "Contacts context action delete"),
systemImage: "trash"
)
}
}
.listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12))
.onAppear {
if index >= contacts.count - 5 {
Task {
await loadContacts(reset: false)
}
}
}
}
if isLoading && !contacts.isEmpty {
loadingState
} else if let pagingError, !contacts.isEmpty {
pagingErrorState(pagingError)
}
}
}
.background(Color(UIColor.systemBackground))
.listStyle(.plain)
.task {
await loadContacts(reset: false)
}
.refreshable {
await refreshContacts()
}
.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")))
)
case .info(_, let title, let message):
return Alert(
title: Text(title),
message: Text(message),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "Common OK")))
)
}
}
}
private var loadingState: some View {
HStack {
Spacer()
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
Spacer()
}
.padding(.vertical, 18)
.listRowInsets(EdgeInsets(top: 18, leading: 12, bottom: 18, trailing: 12))
.listRowSeparator(.hidden)
}
private func errorState(_ message: String) -> some View {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text(message)
.font(.subheadline)
.foregroundColor(.orange)
Spacer()
Button(action: { Task { await refreshContacts() } }) {
Text(NSLocalizedString("Обновить", comment: "Contacts retry button"))
.font(.subheadline)
}
}
.padding(.vertical, 10)
.listRowInsets(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12))
.listRowSeparator(.hidden)
}
private func pagingErrorState(_ message: String) -> some View {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text(message)
.font(.subheadline)
.foregroundColor(.orange)
Spacer()
Button(action: { Task { await loadContacts(reset: false) } }) {
Text(NSLocalizedString("Обновить", comment: "Contacts retry button"))
.font(.subheadline)
}
}
.padding(.vertical, 10)
.listRowInsets(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12))
.listRowSeparator(.hidden)
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "person.crop.circle.badge.questionmark")
.font(.system(size: 52))
.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, 28)
.listRowInsets(EdgeInsets(top: 20, leading: 12, bottom: 20, trailing: 12))
.listRowSeparator(.hidden)
}
@MainActor
private func refreshContacts() async {
hasMore = true
offset = 0
pagingError = nil
loadError = nil
contacts.removeAll()
await loadContacts(reset: true)
}
@MainActor
private func loadContacts(reset: Bool) async {
if isLoading { return }
if !reset && !hasMore { return }
isLoading = true
if offset == 0 {
loadError = nil
}
pagingError = nil
do {
let payload = try await contactsService.fetchContacts(limit: pageSize, offset: offset)
let newContacts = payload.items.map(Contact.init)
if reset {
contacts = newContacts
} else {
contacts.append(contentsOf: newContacts)
}
offset += newContacts.count
hasMore = payload.hasMore
} catch {
let message = error.localizedDescription
if contacts.isEmpty {
loadError = message
} else {
pagingError = message
}
if AppConfig.DEBUG { print("[ContactsTab] load contacts failed: \(error)") }
}
isLoading = false
}
private func showContactPlaceholder(for contact: Contact) {
activeAlert = .info(
title: NSLocalizedString("Скоро", comment: "Contacts placeholder title"),
message: String(
format: NSLocalizedString("Просмотр \"%1$@\" появится позже.", comment: "Contacts placeholder message"),
contact.displayName
)
)
}
private func handleContactAction(_ action: ContactAction, for contact: Contact) {
activeAlert = .info(
title: NSLocalizedString("Скоро", comment: "Contacts placeholder title"),
message: action.placeholderMessage(for: contact)
)
}
}
private struct ContactRow: View {
let contact: Contact
var body: some View {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: 40, height: 40)
.overlay(
Text(contact.initials)
.font(.headline)
.foregroundColor(.accentColor)
)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline) {
Text(contact.displayName)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
Spacer()
Text(contact.formattedCreatedAt)
.font(.caption2)
.foregroundColor(.secondary)
}
if let handle = contact.handle {
Text(handle)
.font(.caption2)
.foregroundColor(.secondary)
}
if contact.friendCode {
friendCodeBadge
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 6)
}
private var friendCodeBadge: some View {
Text(NSLocalizedString("Код дружбы", comment: "Friend code badge"))
.font(.caption2.weight(.medium))
.foregroundColor(Color.accentColor)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color.accentColor.opacity(0.12))
.clipShape(Capsule())
}
}
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)
case info(id: UUID = UUID(), title: String, message: String)
var id: String {
switch self {
case .error(let id, _), .info(let id, _, _):
return id.uuidString
}
}
}
private enum ContactAction {
case edit
case block
case delete
func placeholderMessage(for contact: Contact) -> String {
switch self {
case .edit:
return String(
format: NSLocalizedString("Изменение контакта \"%1$@\" появится позже.", comment: "Contacts edit placeholder message"),
contact.displayName
)
case .block:
return String(
format: NSLocalizedString("Блокировка контакта \"%1$@\" появится позже.", comment: "Contacts block placeholder message"),
contact.displayName
)
case .delete:
return String(
format: NSLocalizedString("Удаление контакта \"%1$@\" появится позже.", comment: "Contacts delete placeholder message"),
contact.displayName
)
}
}
}