237 lines
9.0 KiB
Swift
237 lines
9.0 KiB
Swift
import Contacts
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
struct ContentView: View {
|
|
@StateObject private var viewModel = ContactsViewModel()
|
|
@State private var searchText = ""
|
|
@State private var isShowingLists = false
|
|
@State private var isShowingNewContact = false
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@State private var isShowingError = false
|
|
@State private var contactPendingDeletion: CNContact? = nil
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
switch viewModel.authorizationStatus {
|
|
case .notDetermined:
|
|
ContactsAccessView(viewModel: viewModel)
|
|
case .denied, .restricted:
|
|
ContactsAccessDeniedView {
|
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}
|
|
default:
|
|
contactsList
|
|
}
|
|
}
|
|
.navigationTitle("contacts.title")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
Button("contacts.lists") {
|
|
isShowingLists = true
|
|
}
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
isShowingNewContact = true
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
}
|
|
.accessibilityLabel(Text("contacts.add"))
|
|
}
|
|
}
|
|
.searchable(text: $searchText, prompt: "contacts.search")
|
|
}
|
|
.onAppear {
|
|
viewModel.refresh()
|
|
}
|
|
.onChange(of: scenePhase) {
|
|
if scenePhase == .active {
|
|
viewModel.refresh()
|
|
}
|
|
}
|
|
.sheet(isPresented: $isShowingLists) {
|
|
ListsView(viewModel: viewModel)
|
|
}
|
|
.sheet(isPresented: $isShowingNewContact) {
|
|
NewContactView(contactStore: viewModel.store) {
|
|
isShowingNewContact = false
|
|
viewModel.refresh()
|
|
}
|
|
}
|
|
.onChange(of: viewModel.lastError) {
|
|
isShowingError = viewModel.lastError != nil
|
|
}
|
|
.alert("contacts.error_title", isPresented: $isShowingError) {
|
|
Button("contacts.ok") {
|
|
viewModel.clearError()
|
|
}
|
|
} message: {
|
|
Text(viewModel.lastError ?? "")
|
|
}
|
|
.alert("contacts.delete_title", isPresented: Binding(
|
|
get: { contactPendingDeletion != nil },
|
|
set: { if !$0 { contactPendingDeletion = nil } }
|
|
)) {
|
|
Button("contacts.delete", role: .destructive) {
|
|
if let contactPendingDeletion {
|
|
viewModel.delete(contactPendingDeletion)
|
|
}
|
|
contactPendingDeletion = nil
|
|
}
|
|
Button("contacts.cancel", role: .cancel) {
|
|
contactPendingDeletion = nil
|
|
}
|
|
} message: {
|
|
if let contactPendingDeletion {
|
|
let name = CNContactFormatter.string(from: contactPendingDeletion, style: .fullName) ?? String(localized: "contacts.no_name")
|
|
Text(String(format: String(localized: "contacts.delete_message"), name))
|
|
} else {
|
|
Text("")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var contactsList: some View {
|
|
let filtered = filteredContacts
|
|
let favoriteContacts = filtered.filter { viewModel.isFavorite($0) }
|
|
let myCard = viewModel.myCard
|
|
|
|
return List {
|
|
Section {
|
|
if let myCard {
|
|
NavigationLink {
|
|
ContactDetailView(contact: myCard, contactStore: viewModel.store) {
|
|
viewModel.refresh()
|
|
}
|
|
.ignoresSafeArea(.container, edges: [.top, .bottom])
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "person.crop.circle.fill")
|
|
.font(.system(size: 44))
|
|
.foregroundStyle(.blue)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("contacts.my_card")
|
|
.font(.headline)
|
|
Text(CNContactFormatter.string(from: myCard, style: .fullName) ?? String(localized: "contacts.no_name"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
|
|
}
|
|
} else {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "person.crop.circle.fill")
|
|
.font(.system(size: 44))
|
|
.foregroundStyle(.blue)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("contacts.my_card")
|
|
.font(.headline)
|
|
Text("contacts.my_card_not_set")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
|
|
}
|
|
}
|
|
|
|
Section {
|
|
if favoriteContacts.isEmpty {
|
|
Text("contacts.favorites_empty")
|
|
.foregroundStyle(.secondary)
|
|
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
|
} else {
|
|
ForEach(favoriteContacts, id: \.identifier) { contact in
|
|
contactRow(for: contact)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("contacts.favorites")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
.textCase(.uppercase)
|
|
}
|
|
|
|
Section {
|
|
if filtered.isEmpty {
|
|
Text("contacts.none")
|
|
.foregroundStyle(.secondary)
|
|
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
|
} else {
|
|
ForEach(filtered, id: \.identifier) { contact in
|
|
contactRow(for: contact)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("contacts.all_contacts")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
.textCase(.uppercase)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.listSectionSpacing(.compact)
|
|
.environment(\.defaultMinListRowHeight, 44)
|
|
}
|
|
|
|
private var filteredContacts: [CNContact] {
|
|
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.isEmpty {
|
|
return viewModel.contacts
|
|
}
|
|
return viewModel.contacts.filter { contact in
|
|
let name = CNContactFormatter.string(from: contact, style: .fullName) ?? ""
|
|
let nickname = contact.nickname
|
|
return name.localizedCaseInsensitiveContains(trimmed) || nickname.localizedCaseInsensitiveContains(trimmed)
|
|
}
|
|
}
|
|
|
|
private func contactRow(for contact: CNContact) -> some View {
|
|
NavigationLink {
|
|
ContactDetailView(contact: contact, contactStore: viewModel.store) {
|
|
viewModel.refresh()
|
|
}
|
|
.ignoresSafeArea(.container, edges: [.top, .bottom])
|
|
} label: {
|
|
ContactRowView(contact: contact, isFavorite: viewModel.isFavorite(contact))
|
|
.contextMenu {
|
|
Button {
|
|
viewModel.toggleMyCard(for: contact)
|
|
} label: {
|
|
Label(
|
|
viewModel.isMyCard(contact) ? "contacts.unset_my_card" : "contacts.set_my_card",
|
|
systemImage: viewModel.isMyCard(contact) ? "person.crop.circle.badge.minus" : "person.crop.circle.badge.checkmark"
|
|
)
|
|
}
|
|
|
|
Button {
|
|
viewModel.toggleFavorite(for: contact)
|
|
} label: {
|
|
Label(
|
|
viewModel.isFavorite(contact) ? "contacts.unfavorite" : "contacts.favorite",
|
|
systemImage: viewModel.isFavorite(contact) ? "star.slash" : "star.fill"
|
|
)
|
|
}
|
|
|
|
Button(role: .destructive) {
|
|
contactPendingDeletion = contact
|
|
} label: {
|
|
Label("contacts.delete", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ContentView()
|
|
}
|