implement contacts access and native ui shell

This commit is contained in:
2026-02-10 11:54:31 +01:00
parent bea19ba448
commit d1e2703a04
8 changed files with 428 additions and 60 deletions

View 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()
}
}
}

View 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())
}
}

View 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)
}
}

View 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
}
}
}
}
}

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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
View 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()
}
}
}