Files
kontakte/Kontakte/ContentView.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()
}