montana/Русский/Сайт/MontanaApp/Montana/Sources/Views/ContactsView.swift

350 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import Contacts
struct ContactsView: View {
@EnvironmentObject var appState: AppState
@State private var showImportSheet = false
@State private var phoneContacts: [PhoneContact] = []
@State private var selectedContacts: Set<String> = []
@State private var isLoading = false
@State private var searchText = ""
var filteredContacts: [Contact] {
if searchText.isEmpty {
return appState.contacts
}
return appState.contacts.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
$0.phone.contains(searchText)
}
}
var body: some View {
NavigationView {
ZStack {
Color("Background").ignoresSafeArea()
if appState.contacts.isEmpty {
// Empty state
VStack(spacing: 20) {
Image(systemName: "person.2.fill")
.font(.system(size: 60))
.foregroundColor(.secondary)
Text("Нет контактов")
.font(.title2)
.foregroundColor(.secondary)
Button(action: { showImportSheet = true }) {
HStack {
Image(systemName: "plus.circle.fill")
Text("Импорт из телефона")
}
.padding()
.background(Color("Gold"))
.foregroundColor(Color("Background"))
.cornerRadius(12)
}
}
} else {
// Contacts list
List {
ForEach(filteredContacts) { contact in
NavigationLink(destination: ConversationView(
contactPhone: contact.phone,
contactName: contact.name
)) {
ContactListRow(contact: contact)
}
.listRowBackground(Color("Card"))
}
}
.listStyle(.plain)
.searchable(text: $searchText, prompt: "Поиск")
}
}
.navigationTitle("Контакты")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showImportSheet = true }) {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showImportSheet) {
ImportContactsView(onComplete: {
appState.loadContacts()
})
}
}
}
}
// MARK: - Contact List Row
struct ContactListRow: View {
let contact: Contact
var body: some View {
HStack(spacing: 14) {
// Avatar
Text(String(contact.name.prefix(1)).uppercased())
.font(.headline)
.foregroundColor(Color("Background"))
.frame(width: 48, height: 48)
.background(
LinearGradient(
colors: [Color("Gold"), Color(hex: "FFA500")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(contact.name)
.font(.body)
.foregroundColor(.white)
Text(contact.phone)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
// Chat indicator
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
// MARK: - Import Contacts View
struct ImportContactsView: View {
@Environment(\.dismiss) var dismiss
@State private var phoneContacts: [PhoneContact] = []
@State private var selectedIds: Set<String> = []
@State private var isLoading = false
@State private var error: String?
@State private var contactsAccess = false
let onComplete: () -> Void
var body: some View {
NavigationView {
ZStack {
Color("Background").ignoresSafeArea()
if !contactsAccess {
// Request access
VStack(spacing: 20) {
Image(systemName: "person.crop.circle.badge.questionmark")
.font(.system(size: 60))
.foregroundColor(Color("Gold"))
Text("Доступ к контактам")
.font(.title2)
Text("Разреши доступ для импорта контактов")
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button(action: requestAccess) {
Text("Разрешить")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding()
.background(Color("Gold"))
.foregroundColor(Color("Background"))
.cornerRadius(12)
}
.padding(.horizontal, 40)
}
} else if isLoading {
ProgressView("Загрузка...")
} else if phoneContacts.isEmpty {
Text("Нет контактов для импорта")
.foregroundColor(.secondary)
} else {
// Contacts list
VStack {
List {
ForEach(phoneContacts) { contact in
PhoneContactRow(
contact: contact,
isSelected: selectedIds.contains(contact.id)
) {
if selectedIds.contains(contact.id) {
selectedIds.remove(contact.id)
} else {
selectedIds.insert(contact.id)
}
}
.listRowBackground(Color("Card"))
}
}
.listStyle(.plain)
// Import button
Button(action: importSelected) {
Text("Импортировать (\(selectedIds.count))")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding()
.background(selectedIds.isEmpty ? Color.gray : Color("Gold"))
.foregroundColor(Color("Background"))
.cornerRadius(12)
}
.disabled(selectedIds.isEmpty)
.padding()
}
}
if let error = error {
Text(error)
.foregroundColor(.red)
}
}
.navigationTitle("Импорт контактов")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Отмена") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Все") {
if selectedIds.count == phoneContacts.count {
selectedIds.removeAll()
} else {
selectedIds = Set(phoneContacts.map { $0.id })
}
}
}
}
.onAppear {
checkAccess()
}
}
}
private func checkAccess() {
let status = CNContactStore.authorizationStatus(for: .contacts)
if status == .authorized {
contactsAccess = true
loadContacts()
}
}
private func requestAccess() {
let store = CNContactStore()
store.requestAccess(for: .contacts) { granted, error in
DispatchQueue.main.async {
contactsAccess = granted
if granted {
loadContacts()
} else {
self.error = "Доступ запрещён. Разреши в Настройках."
}
}
}
}
private func loadContacts() {
isLoading = true
let store = CNContactStore()
let keys = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactPhoneNumbersKey] as [CNKeyDescriptor]
DispatchQueue.global(qos: .userInitiated).async {
do {
var contacts: [PhoneContact] = []
let request = CNContactFetchRequest(keysToFetch: keys)
try store.enumerateContacts(with: request) { contact, _ in
let name = "\(contact.givenName) \(contact.familyName)".trimmingCharacters(in: .whitespaces)
for phone in contact.phoneNumbers {
let number = phone.value.stringValue
contacts.append(PhoneContact(
id: contact.identifier + number,
name: name.isEmpty ? "Без имени" : name,
phone: number
))
}
}
DispatchQueue.main.async {
self.phoneContacts = contacts.sorted { $0.name < $1.name }
self.isLoading = false
}
} catch {
DispatchQueue.main.async {
self.error = "Ошибка загрузки контактов"
self.isLoading = false
}
}
}
}
private func importSelected() {
guard let telegramId = UserDefaults.standard.string(forKey: "telegramId") ??
UserDefaults.standard.string(forKey: "deviceId") else {
error = "Не авторизован"
return
}
isLoading = true
let selected = phoneContacts.filter { selectedIds.contains($0.id) }
let group = DispatchGroup()
for contact in selected {
group.enter()
API.shared.saveContact(telegramId: telegramId, name: contact.name, phone: contact.phone) { _ in
group.leave()
}
}
group.notify(queue: .main) {
isLoading = false
onComplete()
dismiss()
}
}
}
// MARK: - Phone Contact Row
struct PhoneContactRow: View {
let contact: PhoneContact
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 14) {
// Checkbox
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundColor(isSelected ? Color("Gold") : .secondary)
.font(.title2)
// Avatar
Text(String(contact.name.prefix(1)).uppercased())
.font(.headline)
.foregroundColor(Color("Background"))
.frame(width: 44, height: 44)
.background(Color("Gold").opacity(0.8))
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(contact.name)
.foregroundColor(.white)
Text(contact.phone)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
}