327 lines
11 KiB
Swift
327 lines
11 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 activeAlert: ContactsAlert?
|
|
|
|
private let contactsService = ContactsService()
|
|
|
|
var body: some View {
|
|
List {
|
|
if isLoading && contacts.isEmpty {
|
|
loadingState
|
|
}
|
|
|
|
if let loadError, contacts.isEmpty {
|
|
errorState(loadError)
|
|
} else if contacts.isEmpty {
|
|
emptyState
|
|
} else {
|
|
ForEach(contacts) { 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))
|
|
}
|
|
}
|
|
}
|
|
.background(Color(UIColor.systemBackground))
|
|
.listStyle(.plain)
|
|
.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")))
|
|
)
|
|
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 loadContacts() } }) {
|
|
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 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 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
|
|
)
|
|
}
|
|
}
|
|
}
|