implement contacts access and native ui shell
This commit is contained in:
70
Kontakte/ContactDetailView.swift
Normal file
70
Kontakte/ContactDetailView.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import Contacts
|
||||
import ContactsUI
|
||||
import SwiftUI
|
||||
|
||||
struct ContactDetailView: UIViewControllerRepresentable {
|
||||
let contact: CNContact
|
||||
let contactStore: CNContactStore
|
||||
let onDismiss: () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onDismiss: onDismiss)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> CNContactViewController {
|
||||
let controller = CNContactViewController(for: contact)
|
||||
controller.contactStore = contactStore
|
||||
controller.delegate = context.coordinator
|
||||
controller.allowsEditing = true
|
||||
controller.allowsActions = true
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: CNContactViewController, context: Context) {
|
||||
uiViewController.contactStore = contactStore
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, CNContactViewControllerDelegate {
|
||||
private let onDismiss: () -> Void
|
||||
|
||||
init(onDismiss: @escaping () -> Void) {
|
||||
self.onDismiss = onDismiss
|
||||
}
|
||||
|
||||
func contactViewController(_ viewController: CNContactViewController, didCompleteWith contact: CNContact?) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NewContactView: UIViewControllerRepresentable {
|
||||
let contactStore: CNContactStore
|
||||
let onDismiss: () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onDismiss: onDismiss)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> UINavigationController {
|
||||
let controller = CNContactViewController(forNewContact: nil)
|
||||
controller.contactStore = contactStore
|
||||
controller.delegate = context.coordinator
|
||||
let nav = UINavigationController(rootViewController: controller)
|
||||
return nav
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, CNContactViewControllerDelegate {
|
||||
private let onDismiss: () -> Void
|
||||
|
||||
init(onDismiss: @escaping () -> Void) {
|
||||
self.onDismiss = onDismiss
|
||||
}
|
||||
|
||||
func contactViewController(_ viewController: CNContactViewController, didCompleteWith contact: CNContact?) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
49
Kontakte/ContactRowView.swift
Normal file
49
Kontakte/ContactRowView.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import Contacts
|
||||
import SwiftUI
|
||||
|
||||
struct ContactRowView: View {
|
||||
let contact: CNContact
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
avatar
|
||||
Text(displayName)
|
||||
.font(.body)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var displayName: String {
|
||||
CNContactFormatter.string(from: contact, style: .fullName) ?? "No Name"
|
||||
}
|
||||
|
||||
private var initials: String {
|
||||
let given = contact.givenName.prefix(1)
|
||||
let family = contact.familyName.prefix(1)
|
||||
let combined = String(given + family)
|
||||
return combined.isEmpty ? "?" : combined.uppercased()
|
||||
}
|
||||
|
||||
private var avatar: some View {
|
||||
Group {
|
||||
if let data = contact.thumbnailImageData,
|
||||
let uiImage = UIImage(data: data) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray5))
|
||||
Text(initials)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
71
Kontakte/ContactsAccessView.swift
Normal file
71
Kontakte/ContactsAccessView.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import Contacts
|
||||
import ContactsUI
|
||||
import SwiftUI
|
||||
|
||||
struct ContactsAccessView: View {
|
||||
@ObservedObject var viewModel: ContactsViewModel
|
||||
@State private var isPickerPresented = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "person.crop.circle.badge.checkmark")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Allow Contacts Access")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("Choose which contacts Kontakte can access. You can change this later.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button("Allow Full Access") {
|
||||
viewModel.requestAccess()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Select Contacts") {
|
||||
isPickerPresented = true
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Not Now") {
|
||||
viewModel.refresh()
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(24)
|
||||
.contactAccessPicker(isPresented: $isPickerPresented) { _ in
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactsAccessDeniedView: View {
|
||||
let onOpenSettings: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "person.crop.circle.badge.xmark")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.red)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Contacts Access Off")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("Enable access in Settings to see your contacts in Kontakte.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button("Open Settings") {
|
||||
onOpenSettings()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
75
Kontakte/ContactsViewModel.swift
Normal file
75
Kontakte/ContactsViewModel.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import Combine
|
||||
@preconcurrency import Contacts
|
||||
@preconcurrency import ContactsUI
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class ContactsViewModel: ObservableObject {
|
||||
@Published private(set) var contacts: [CNContact] = []
|
||||
@Published private(set) var authorizationStatus: CNAuthorizationStatus = .notDetermined
|
||||
@Published private(set) var lastError: String? = nil
|
||||
|
||||
let store = CNContactStore()
|
||||
|
||||
func refresh() {
|
||||
authorizationStatus = CNContactStore.authorizationStatus(for: .contacts)
|
||||
if authorizationStatus == .authorized || authorizationStatus == .limited {
|
||||
loadContacts()
|
||||
} else {
|
||||
contacts = []
|
||||
}
|
||||
}
|
||||
|
||||
func requestAccess() {
|
||||
store.requestAccess(for: .contacts) { [weak self] _, error in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
self.lastError = error.localizedDescription
|
||||
}
|
||||
self.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadContacts() {
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
var fetched: [CNContact] = []
|
||||
var fetchError: Error? = nil
|
||||
|
||||
do {
|
||||
let store = CNContactStore()
|
||||
let keys: [CNKeyDescriptor] = [
|
||||
CNContactViewController.descriptorForRequiredKeys(),
|
||||
CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactNicknameKey as CNKeyDescriptor,
|
||||
CNContactPhoneNumbersKey as CNKeyDescriptor,
|
||||
CNContactEmailAddressesKey as CNKeyDescriptor,
|
||||
CNContactImageDataAvailableKey as CNKeyDescriptor,
|
||||
CNContactThumbnailImageDataKey as CNKeyDescriptor
|
||||
]
|
||||
let request = CNContactFetchRequest(keysToFetch: keys)
|
||||
request.sortOrder = .userDefault
|
||||
|
||||
try store.enumerateContacts(with: request) { contact, _ in
|
||||
fetched.append(contact)
|
||||
}
|
||||
} catch {
|
||||
fetchError = error
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
if let fetchError {
|
||||
self.contacts = []
|
||||
self.lastError = fetchError.localizedDescription
|
||||
} else {
|
||||
self.contacts = fetched
|
||||
self.lastError = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,136 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// Kontakte
|
||||
//
|
||||
// Created by Felix Förtsch on 09.02.26.
|
||||
//
|
||||
|
||||
import Contacts
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query private var items: [Item]
|
||||
@StateObject private var viewModel = ContactsViewModel()
|
||||
@State private var searchText = ""
|
||||
@State private var isShowingLists = false
|
||||
@State private var isShowingNewContact = false
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
NavigationLink {
|
||||
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
|
||||
} label: {
|
||||
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
|
||||
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
|
||||
}
|
||||
.onDelete(perform: deleteItems)
|
||||
}
|
||||
.navigationTitle("Contacts")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
EditButton()
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Lists") {
|
||||
isShowingLists = true
|
||||
}
|
||||
}
|
||||
ToolbarItem {
|
||||
Button(action: addItem) {
|
||||
Label("Add Item", systemImage: "plus")
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
isShowingNewContact = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
Text("Select an item")
|
||||
.searchable(text: $searchText, prompt: "Search")
|
||||
}
|
||||
}
|
||||
|
||||
private func addItem() {
|
||||
withAnimation {
|
||||
let newItem = Item(timestamp: Date())
|
||||
modelContext.insert(newItem)
|
||||
.onAppear {
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteItems(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
for index in offsets {
|
||||
modelContext.delete(items[index])
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var contactsList: some View {
|
||||
let filtered = filteredContacts
|
||||
let sorted = filtered.sorted { lhs, rhs in
|
||||
let left = CNContactFormatter.string(from: lhs, style: .fullName) ?? ""
|
||||
let right = CNContactFormatter.string(from: rhs, style: .fullName) ?? ""
|
||||
return left.localizedCaseInsensitiveCompare(right) == .orderedAscending
|
||||
}
|
||||
|
||||
return List {
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "person.crop.circle.fill")
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("My Card")
|
||||
.font(.headline)
|
||||
Text("Kontakte")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||
}
|
||||
|
||||
Section("FAVORITES") {
|
||||
Text("No Favorites")
|
||||
.foregroundStyle(.secondary)
|
||||
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
||||
}
|
||||
|
||||
Section("ALL CONTACTS") {
|
||||
if sorted.isEmpty {
|
||||
Text("No Contacts")
|
||||
.foregroundStyle(.secondary)
|
||||
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
||||
} else {
|
||||
ForEach(sorted, id: \.identifier) { contact in
|
||||
NavigationLink {
|
||||
ContactDetailView(contact: contact, contactStore: viewModel.store) {
|
||||
viewModel.refresh()
|
||||
}
|
||||
.ignoresSafeArea(.container, edges: [.top, .bottom])
|
||||
} label: {
|
||||
ContactRowView(contact: contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.modelContainer(for: Item.self, inMemory: true)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>Kontakte needs access to your contacts to display and manage them.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
|
||||
@@ -1,32 +1,10 @@
|
||||
//
|
||||
// KontakteApp.swift
|
||||
// Kontakte
|
||||
//
|
||||
// Created by Felix Förtsch on 09.02.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct KontakteApp: App {
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
let schema = Schema([
|
||||
Item.self,
|
||||
])
|
||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||
|
||||
do {
|
||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||
} catch {
|
||||
fatalError("Could not create ModelContainer: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(sharedModelContainer)
|
||||
}
|
||||
}
|
||||
|
||||
48
Kontakte/ListsView.swift
Normal file
48
Kontakte/ListsView.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import Contacts
|
||||
import ContactsUI
|
||||
import SwiftUI
|
||||
|
||||
struct ListsView: View {
|
||||
@ObservedObject var viewModel: ContactsViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isPickerPresented = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("LISTS") {
|
||||
Label("All Contacts", systemImage: "person.2")
|
||||
}
|
||||
|
||||
Section("CONTACT ACCESS") {
|
||||
if viewModel.authorizationStatus == .limited {
|
||||
Button("Manage Access") {
|
||||
isPickerPresented = true
|
||||
}
|
||||
} else if viewModel.authorizationStatus == .authorized {
|
||||
Text("Full Access")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if viewModel.authorizationStatus == .denied || viewModel.authorizationStatus == .restricted {
|
||||
Text("Access Off")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Not Requested")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle("Lists")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.contactAccessPicker(isPresented: $isPickerPresented) { _ in
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user