350 lines
12 KiB
Swift
350 lines
12 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|