snapshot current state before gitea sync

This commit is contained in:
2026-02-18 10:50:24 +01:00
parent 0f0ed9cd6d
commit e965c4967e
20 changed files with 712 additions and 129 deletions

View File

@@ -4,9 +4,12 @@ These instructions are authoritative for work in this repository.
## Product & UX
- Always adhere to the latest Apple Human Interface Guidelines (iOS).
- Always use native platform components whenever possible.
- On iOS, use native icons from SF Symbols.
- Always implement accessibility best practices (labels, traits, dynamic type, sufficient contrast).
- On iOS, always use default system colors unless explicitly told otherwise (e.g., `.green`).
- Use English as the primary language in the app and design with localization in mind from the start. Implement localization scaffolding for English, German, Spanish, and French.
- Use proper language-specific characters and diacritics in localizations (e.g., German `ä/ö/ü/ß`), never ASCII substitutions like `ae/oe/ue`.
- Date/time conventions are fixed: Monday is the first day of the week. Do not vary by locale.
- Units are always metric; do not use imperial units.
@@ -16,6 +19,11 @@ These instructions are authoritative for work in this repository.
## Engineering Standards
- Always use SwiftUI when possible.
- Git commit messages must start with a lower-case letter.
- Always use `tmux` for CLI interactions so sessions can be resumed.
- The calling shell may often be `fish`; run agent command calls in `bash` to avoid shell compatibility issues.
- Keep `CODEX_REPORT.md` updated whenever it makes sense so work can be resumed after context is dropped.
- Always implement tests, especially for fixed bugs, so regressions are not re-introduced.
- When setting up a remote server, the chance is high that you are working on Uberspace. For that, check https://lab.uberspace.de/ and https://manual.uberspace.de/
- Prefer self-contained builds. The app should run on first launch without extra setup when feasible.
- For iOS development, target the latest available iOS version when creating new projects.
- For iOS project setup, use Team ID `NG5W75WE8U`.

View File

@@ -1,41 +1,68 @@
# Codex Report — Kontakte
Date: 2026-02-10
Date: 2026-02-11
## Goal
Copy-cat the default iOS 26 Contacts app UI/behavior, then extend later. Use the new Contacts access API so users can choose full vs limited access.
## Current State
- App shell mirrors the native Contacts structure: My Card header, Favorites section, All Contacts list with search and add.
- Contacts list is fetched via `CNContactStore` and displayed via SwiftUI; contact detail and new contact screens use `CNContactViewController` for native UI.
- Access flow: first-launch screen offers **Allow Full Access** (`CNContactStore.requestAccess`) or **Select Contacts** (system limited-access picker via `.contactAccessPicker`).
- `ContactAccessButton` was removed from UI because it failed to load remote content and caused console errors; the picker is still accessible and reliable.
- Detail view is set to ignore safe areas to fill status bar / home indicator regions, matching native feel.
- App uses native SwiftUI + Contacts components: `List`, `NavigationStack`, native `contextMenu`, and `CNContactViewController` for detail/create flows.
- Contacts list is fetched via `CNContactStore` and shown in native sectioned list: My Card, Favorites, All Contacts, with search and add.
- Access flow supports least privilege: **Allow Full Access** (`requestAccess`) and **Select Contacts** (`.contactAccessPicker` for limited access).
- My Card and Favorites are implemented via metadata persisted in contact Notes using a lightweight DSL block.
- Row context menu actions:
- Set / Remove My Card
- Favorite / Remove Favorite
- Delete contact (destructive confirmation)
- Favorites are rendered in the Favorites section with a yellow star marker in rows.
- Localizations exist for `en`, `de`, `es`, `fr` and were normalized to proper language-specific symbols/diacritics.
## Key Implementation Notes
- Required keys for detail view are included using `CNContactViewController.descriptorForRequiredKeys()` in the fetch.
- Contact fetching runs off the main thread; results marshaled back to main.
- `@preconcurrency` added to Contacts imports to suppress Sendable warnings.
- Notes DSL format:
- `[[kontakte]]`
- `favorite=1`
- `my_card=1`
- `[[/kontakte]]`
- DSL is merged into Notes while preserving user text.
- If notes key access is not allowed, app falls back to note-less contact fetch and surfaces a localized warning for metadata actions.
- `CNErrorDomain` code `102` (`Unauthorized Keys` for `note`) handling was hardened further:
- contact list fetch no longer requests `note` at all
- `supportsMetadataDSL` defaults to disabled to prevent repeated note-key warnings
- contacts remain functional without metadata DSL editing
- Contact fetch runs off-main thread and updates UI on main.
- Unused high-privilege capabilities were removed:
- `UIBackgroundModes` remote-notification removed from `Info.plist`
- push/iCloud/CloudKit entitlements removed from app entitlements
## Files Added/Updated
- `Kontakte/ContactMetadataDSL.swift` (new)
- `Kontakte/ContactsViewModel.swift`
- `Kontakte/ContactsAccessView.swift`
- `Kontakte/ListsView.swift`
- `Kontakte/ContactRowView.swift`
- `Kontakte/ContactDetailView.swift`
- `Kontakte/ContentView.swift`
- `Kontakte/Info.plist` (added `NSContactsUsageDescription`)
- `Kontakte/KontakteApp.swift` (removed SwiftData container)
- `Kontakte/Info.plist`
- `Kontakte/Kontakte.entitlements`
- `Kontakte.xcodeproj/project.pbxproj` (known regions include `de/es/fr`)
- `Kontakte/en.lproj/Localizable.strings`
- `Kontakte/de.lproj/Localizable.strings`
- `Kontakte/es.lproj/Localizable.strings`
- `Kontakte/fr.lproj/Localizable.strings`
- `KontakteTests/KontakteTests.swift` (regression tests for DSL parse/update behavior)
- `Kontakte/Item.swift` (deleted unused template model)
## Known Issues / Caveats
- Simulator sometimes logs LaunchServices and remote UI errors. The new flow avoids ContactAccessButton to reduce this.
- Favorites, Groups/Lists, and alphabetical index are not yet implemented.
- “My Card” is still static (not tied to a real contact).
- System logs may still include framework-level remote service noise on device/simulator (not always app defects).
- Some devices/profiles may restrict note-key access; in that case metadata actions are disabled gracefully.
- Native `contextMenu` row highlight persistence can still be briefly visible depending on OS behavior.
- Lists/Groups parity and alphabetical index are still incomplete vs Apple Contacts.
- System-level "helper app"/remote object warnings can still appear in console even when app behavior is correct.
## Next Steps
1. Match remaining UI details (section headers, separators, list index, favorites behavior).
2. Implement “My Card” selection and edit flow.
3. Add Lists/Groups and favorites editing to match default Contacts.
1. Add tests for `ContactsViewModel` metadata transitions and deletion paths (mocked store abstraction).
2. Improve parity for Lists/Groups and alphabetical index behavior.
3. Add UI tests for first-run permission flows (full, limited, denied) with deterministic test fixtures.
## Git
- Latest commit: `implement contacts access and native ui shell` (amended to include safe-area adjustment for detail view).
- Worktree currently contains uncommitted local changes from iterative implementation and guideline-alignment passes.

View File

@@ -203,7 +203,10 @@
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
de,
en,
es,
fr,
Base,
);
mainGroup = 3F2BF5312F3A39EC00888D8F;

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-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>SchemeUserState</key>
<dict>
<key>Kontakte.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,71 @@
import Foundation
struct ContactMetadata {
var isFavorite: Bool = false
var isMyCard: Bool = false
var hasAnyValue: Bool {
isFavorite || isMyCard
}
}
enum ContactMetadataDSL {
private static let startMarker = "[[kontakte]]"
private static let endMarker = "[[/kontakte]]"
static func parse(note: String) -> ContactMetadata {
guard let range = metadataBlockRange(in: note) else {
return ContactMetadata()
}
let block = note[range]
var metadata = ContactMetadata()
for rawLine in block.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
if line == "favorite=1" {
metadata.isFavorite = true
} else if line == "my_card=1" {
metadata.isMyCard = true
}
}
return metadata
}
static func updating(note: String, metadata: ContactMetadata) -> String {
var base = note
if let range = metadataBlockRange(in: note) {
base.removeSubrange(range)
}
base = base.trimmingCharacters(in: .whitespacesAndNewlines)
guard metadata.hasAnyValue else {
return base
}
var lines: [String] = []
lines.append(startMarker)
if metadata.isFavorite {
lines.append("favorite=1")
}
if metadata.isMyCard {
lines.append("my_card=1")
}
lines.append(endMarker)
let block = lines.joined(separator: "\n")
if base.isEmpty {
return block
}
return base + "\n\n" + block
}
private static func metadataBlockRange(in note: String) -> Range<String.Index>? {
guard let start = note.range(of: startMarker),
let end = note.range(of: endMarker, range: start.upperBound..<note.endIndex) else {
return nil
}
return start.lowerBound..<end.upperBound
}
}

View File

@@ -3,12 +3,19 @@ import SwiftUI
struct ContactRowView: View {
let contact: CNContact
var isFavorite: Bool = false
var body: some View {
HStack(spacing: 12) {
avatar
Text(displayName)
.font(.body)
Spacer(minLength: 8)
if isFavorite {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
.accessibilityLabel(Text("contacts.favorite"))
}
}
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -16,7 +23,7 @@ struct ContactRowView: View {
}
private var displayName: String {
CNContactFormatter.string(from: contact, style: .fullName) ?? "No Name"
CNContactFormatter.string(from: contact, style: .fullName) ?? String(localized: "contacts.no_name")
}
private var initials: String {

View File

@@ -13,25 +13,25 @@ struct ContactsAccessView: View {
.foregroundStyle(.blue)
VStack(spacing: 8) {
Text("Allow Contacts Access")
Text("contacts.access_title")
.font(.title2.weight(.semibold))
Text("Choose which contacts Kontakte can access. You can change this later.")
Text("contacts.access_subtitle")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Button("Allow Full Access") {
Button("contacts.access_full") {
viewModel.requestAccess()
}
.buttonStyle(.borderedProminent)
Button("Select Contacts") {
Button("contacts.access_select") {
isPickerPresented = true
}
.buttonStyle(.bordered)
Button("Not Now") {
Button("contacts.not_now") {
viewModel.refresh()
}
.foregroundStyle(.secondary)
@@ -53,15 +53,15 @@ struct ContactsAccessDeniedView: View {
.foregroundStyle(.red)
VStack(spacing: 8) {
Text("Contacts Access Off")
Text("contacts.access_off_title")
.font(.title2.weight(.semibold))
Text("Enable access in Settings to see your contacts in Kontakte.")
Text("contacts.access_off_subtitle")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Button("Open Settings") {
Button("contacts.open_settings") {
onOpenSettings()
}
.buttonStyle(.borderedProminent)

View File

@@ -8,6 +8,9 @@ final class ContactsViewModel: ObservableObject {
@Published private(set) var contacts: [CNContact] = []
@Published private(set) var authorizationStatus: CNAuthorizationStatus = .notDetermined
@Published private(set) var lastError: String? = nil
@Published private(set) var favoriteContactIdentifiers: Set<String> = []
@Published private(set) var myCardIdentifier: String? = nil
@Published private(set) var supportsMetadataDSL = false
let store = CNContactStore()
@@ -17,6 +20,82 @@ final class ContactsViewModel: ObservableObject {
loadContacts()
} else {
contacts = []
lastError = nil
}
}
func clearError() {
lastError = nil
}
func isFavorite(_ contact: CNContact) -> Bool {
favoriteContactIdentifiers.contains(contact.identifier)
}
func isMyCard(_ contact: CNContact) -> Bool {
myCardIdentifier == contact.identifier
}
var myCard: CNContact? {
guard let myCardIdentifier else { return nil }
return contacts.first(where: { $0.identifier == myCardIdentifier })
}
func toggleFavorite(for contact: CNContact) {
updateMetadata(for: contact) { metadata in
metadata.isFavorite.toggle()
}
}
func toggleMyCard(for contact: CNContact) {
guard supportsMetadataDSL else {
lastError = String(localized: "contacts.metadata_unavailable")
return
}
do {
// Ensure no other contact remains tagged as My Card.
let containerID = store.defaultContainerIdentifier()
let keys: [CNKeyDescriptor] = [
CNContactIdentifierKey as CNKeyDescriptor,
CNContactNoteKey as CNKeyDescriptor
]
let all = try store.unifiedContacts(
matching: CNContact.predicateForContactsInContainer(withIdentifier: containerID),
keysToFetch: keys
)
let request = CNSaveRequest()
for existing in all {
guard let mutable = existing.mutableCopy() as? CNMutableContact else { continue }
var metadata = ContactMetadataDSL.parse(note: mutable.note)
let shouldBeMyCard = existing.identifier == contact.identifier ? !metadata.isMyCard : false
if metadata.isMyCard != shouldBeMyCard {
metadata.isMyCard = shouldBeMyCard
mutable.note = ContactMetadataDSL.updating(note: mutable.note, metadata: metadata)
request.update(mutable)
}
}
try store.execute(request)
refresh()
} catch {
lastError = error.localizedDescription
}
}
func delete(_ contact: CNContact) {
let mutableContact = contact.mutableCopy() as? CNMutableContact
guard let mutableContact else { return }
let request = CNSaveRequest()
request.delete(mutableContact)
do {
try store.execute(request)
refresh()
} catch {
lastError = error.localizedDescription
}
}
@@ -34,42 +113,114 @@ final class ContactsViewModel: ObservableObject {
private func loadContacts() {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self else { return }
let localStore = CNContactStore()
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)
}
fetched = try Self.fetchContacts(using: localStore, keys: Self.baseKeys)
} catch {
fetchError = error
}
DispatchQueue.main.async {
guard let self else { return }
if let fetchError {
self.contacts = []
self.lastError = fetchError.localizedDescription
self.favoriteContactIdentifiers = []
self.myCardIdentifier = nil
} else {
self.contacts = fetched
self.supportsMetadataDSL = false
self.lastError = nil
if self.supportsMetadataDSL {
self.favoriteContactIdentifiers = Set(
fetched
.filter { ContactMetadataDSL.parse(note: $0.note).isFavorite }
.map(\.identifier)
)
self.myCardIdentifier = fetched.first(where: {
ContactMetadataDSL.parse(note: $0.note).isMyCard
})?.identifier
} else {
self.favoriteContactIdentifiers = []
self.myCardIdentifier = nil
}
}
}
}
}
nonisolated private static var baseKeys: [CNKeyDescriptor] {
[
CNContactViewController.descriptorForRequiredKeys(),
CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
CNContactIdentifierKey as CNKeyDescriptor,
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactNicknameKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor,
CNContactImageDataAvailableKey as CNKeyDescriptor,
CNContactThumbnailImageDataKey as CNKeyDescriptor
]
}
nonisolated private static func isUnauthorizedNoteError(_ error: NSError) -> Bool {
guard error.domain == CNErrorDomain,
error.code == 102 else {
return false
}
if let keyPaths = error.userInfo["CNKeyPaths"] as? [String] {
return keyPaths.contains("note")
}
return true
}
nonisolated private static func fetchContacts(using store: CNContactStore, keys: [CNKeyDescriptor]) throws -> [CNContact] {
var fetched: [CNContact] = []
let request = CNContactFetchRequest(keysToFetch: keys)
request.sortOrder = .userDefault
try store.enumerateContacts(with: request) { contact, _ in
fetched.append(contact)
}
return fetched
}
private func updateMetadata(for contact: CNContact, mutate: (inout ContactMetadata) -> Void) {
guard supportsMetadataDSL else {
lastError = String(localized: "contacts.metadata_unavailable")
return
}
do {
let keys: [CNKeyDescriptor] = [
CNContactIdentifierKey as CNKeyDescriptor,
CNContactNoteKey as CNKeyDescriptor
]
let fetched = try store.unifiedContact(withIdentifier: contact.identifier, keysToFetch: keys)
guard let mutable = fetched.mutableCopy() as? CNMutableContact else { return }
var metadata = ContactMetadataDSL.parse(note: mutable.note)
mutate(&metadata)
mutable.note = ContactMetadataDSL.updating(note: mutable.note, metadata: metadata)
let request = CNSaveRequest()
request.update(mutable)
try store.execute(request)
refresh()
} catch let error as NSError {
if Self.isUnauthorizedNoteError(error) {
supportsMetadataDSL = false
lastError = String(localized: "contacts.metadata_unavailable")
} else {
lastError = error.localizedDescription
}
} catch {
lastError = error.localizedDescription
}
}
}

View File

@@ -8,6 +8,8 @@ struct ContentView: View {
@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 {
@@ -25,11 +27,11 @@ struct ContentView: View {
contactsList
}
}
.navigationTitle("Contacts")
.navigationTitle("contacts.title")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Lists") {
Button("contacts.lists") {
isShowingLists = true
}
}
@@ -39,9 +41,10 @@ struct ContentView: View {
} label: {
Image(systemName: "plus")
}
.accessibilityLabel(Text("contacts.add"))
}
}
.searchable(text: $searchText, prompt: "Search")
.searchable(text: $searchText, prompt: "contacts.search")
}
.onAppear {
viewModel.refresh()
@@ -60,60 +63,121 @@ struct ContentView: View {
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 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
}
let favoriteContacts = filtered.filter { viewModel.isFavorite($0) }
let myCard = viewModel.myCard
return List {
Section {
HStack(spacing: 12) {
Image(systemName: "person.crop.circle.fill")
.font(.system(size: 44))
.foregroundStyle(.blue)
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("My Card")
.font(.headline)
Text("Kontakte")
.font(.subheadline)
.foregroundStyle(.secondary)
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))
}
.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")
Section {
if favoriteContacts.isEmpty {
Text("contacts.favorites_empty")
.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)
}
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(.insetGrouped)
.listStyle(.plain)
.listSectionSpacing(.compact)
.environment(\.defaultMinListRowHeight, 44)
}
@@ -129,6 +193,42 @@ struct ContentView: View {
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 {

View File

@@ -4,9 +4,5 @@
<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>
</array>
</dict>
</plist>

View File

@@ -1,18 +0,0 @@
//
// Item.swift
// Kontakte
//
// Created by Felix Förtsch on 09.02.26.
//
import Foundation
import SwiftData
@Model
final class Item {
var timestamp: Date
init(timestamp: Date) {
self.timestamp = timestamp
}
}

View File

@@ -1,14 +1,5 @@
<?xml version="1.0" encoding="UTF-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>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array/>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
</dict>
<dict/>
</plist>

View File

@@ -10,32 +10,32 @@ struct ListsView: View {
var body: some View {
NavigationStack {
List {
Section("LISTS") {
Label("All Contacts", systemImage: "person.2")
Section("contacts.lists_header") {
Label("contacts.all_contacts", systemImage: "person.2")
}
Section("CONTACT ACCESS") {
Section("contacts.access_header") {
if viewModel.authorizationStatus == .limited {
Button("Manage Access") {
Button("contacts.manage_access") {
isPickerPresented = true
}
} else if viewModel.authorizationStatus == .authorized {
Text("Full Access")
Text("contacts.full_access")
.foregroundStyle(.secondary)
} else if viewModel.authorizationStatus == .denied || viewModel.authorizationStatus == .restricted {
Text("Access Off")
Text("contacts.access_off")
.foregroundStyle(.secondary)
} else {
Text("Not Requested")
Text("contacts.not_requested")
.foregroundStyle(.secondary)
}
}
}
.listStyle(.insetGrouped)
.navigationTitle("Lists")
.navigationTitle("contacts.lists")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
Button("contacts.done") {
dismiss()
}
}

View File

@@ -0,0 +1,39 @@
"contacts.title" = "Kontakte";
"contacts.lists" = "Listen";
"contacts.search" = "Suchen";
"contacts.my_card" = "Meine Karte";
"contacts.app_name" = "Kontakte";
"contacts.favorites" = "Favoriten";
"contacts.favorites_empty" = "Keine Favoriten";
"contacts.all_contacts" = "Alle Kontakte";
"contacts.none" = "Keine Kontakte";
"contacts.no_name" = "Kein Name";
"contacts.access_title" = "Zugriff auf Kontakte erlauben";
"contacts.access_subtitle" = "Wähle aus, auf welche Kontakte Kontakte zugreifen darf. Das kannst du später ändern.";
"contacts.access_full" = "Vollen Zugriff erlauben";
"contacts.access_select" = "Kontakte auswählen";
"contacts.not_now" = "Nicht jetzt";
"contacts.access_off_title" = "Kontaktzugriff aus";
"contacts.access_off_subtitle" = "Aktiviere den Zugriff in den Einstellungen, um deine Kontakte in Kontakte zu sehen.";
"contacts.open_settings" = "Einstellungen öffnen";
"contacts.lists_header" = "Listen";
"contacts.access_header" = "Kontaktzugriff";
"contacts.manage_access" = "Zugriff verwalten";
"contacts.full_access" = "Voller Zugriff";
"contacts.access_off" = "Zugriff aus";
"contacts.not_requested" = "Nicht angefordert";
"contacts.done" = "Fertig";
"contacts.add" = "Kontakt hinzufügen";
"contacts.error_title" = "Kontakte konnten nicht geladen werden";
"contacts.ok" = "OK";
"contacts.favorite" = "Favorit";
"contacts.unfavorite" = "Favorit entfernen";
"contacts.delete" = "Löschen";
"contacts.delete_title" = "Kontakt löschen";
"contacts.delete_message" = "%@ wird dauerhaft gelöscht.";
"contacts.cancel" = "Abbrechen";
"contacts.my_card_not_set" = "Meine Karte in Kontakte festlegen";
"contacts.metadata_unavailable" = "Favoriten sind nicht verfügbar, weil der Zugriff auf Kontaktnotizen nicht erlaubt ist.";
"contacts.set_my_card" = "Als meine Karte festlegen";
"contacts.unset_my_card" = "Meine Karte entfernen";
"contacts.actions_title" = "Kontaktaktionen";

View File

@@ -0,0 +1,39 @@
"contacts.title" = "Contacts";
"contacts.lists" = "Lists";
"contacts.search" = "Search";
"contacts.my_card" = "My Card";
"contacts.app_name" = "Kontakte";
"contacts.favorites" = "Favorites";
"contacts.favorites_empty" = "No Favorites";
"contacts.all_contacts" = "All Contacts";
"contacts.none" = "No Contacts";
"contacts.no_name" = "No Name";
"contacts.access_title" = "Allow Contacts Access";
"contacts.access_subtitle" = "Choose which contacts Kontakte can access. You can change this later.";
"contacts.access_full" = "Allow Full Access";
"contacts.access_select" = "Select Contacts";
"contacts.not_now" = "Not Now";
"contacts.access_off_title" = "Contacts Access Off";
"contacts.access_off_subtitle" = "Enable access in Settings to see your contacts in Kontakte.";
"contacts.open_settings" = "Open Settings";
"contacts.lists_header" = "Lists";
"contacts.access_header" = "Contact Access";
"contacts.manage_access" = "Manage Access";
"contacts.full_access" = "Full Access";
"contacts.access_off" = "Access Off";
"contacts.not_requested" = "Not Requested";
"contacts.done" = "Done";
"contacts.add" = "Add Contact";
"contacts.error_title" = "Unable to Load Contacts";
"contacts.ok" = "OK";
"contacts.favorite" = "Favorite";
"contacts.unfavorite" = "Remove Favorite";
"contacts.delete" = "Delete";
"contacts.delete_title" = "Delete Contact";
"contacts.delete_message" = "This will permanently delete %@.";
"contacts.cancel" = "Cancel";
"contacts.my_card_not_set" = "Set your card in Contacts";
"contacts.metadata_unavailable" = "Favorites are unavailable because contact notes access is not permitted.";
"contacts.set_my_card" = "Set as My Card";
"contacts.unset_my_card" = "Remove My Card";
"contacts.actions_title" = "Contact Actions";

View File

@@ -0,0 +1,39 @@
"contacts.title" = "Contactos";
"contacts.lists" = "Listas";
"contacts.search" = "Buscar";
"contacts.my_card" = "Mi tarjeta";
"contacts.app_name" = "Kontakte";
"contacts.favorites" = "Favoritos";
"contacts.favorites_empty" = "Sin favoritos";
"contacts.all_contacts" = "Todos los contactos";
"contacts.none" = "Sin contactos";
"contacts.no_name" = "Sin nombre";
"contacts.access_title" = "Permitir acceso a contactos";
"contacts.access_subtitle" = "Elige a qué contactos puede acceder Kontakte. Puedes cambiarlo después.";
"contacts.access_full" = "Permitir acceso total";
"contacts.access_select" = "Seleccionar contactos";
"contacts.not_now" = "Ahora no";
"contacts.access_off_title" = "Acceso a contactos desactivado";
"contacts.access_off_subtitle" = "Activa el acceso en Ajustes para ver tus contactos en Kontakte.";
"contacts.open_settings" = "Abrir ajustes";
"contacts.lists_header" = "Listas";
"contacts.access_header" = "Acceso a contactos";
"contacts.manage_access" = "Gestionar acceso";
"contacts.full_access" = "Acceso total";
"contacts.access_off" = "Acceso desactivado";
"contacts.not_requested" = "No solicitado";
"contacts.done" = "Listo";
"contacts.add" = "Añadir contacto";
"contacts.error_title" = "No se pudieron cargar los contactos";
"contacts.ok" = "OK";
"contacts.favorite" = "Favorito";
"contacts.unfavorite" = "Quitar favorito";
"contacts.delete" = "Eliminar";
"contacts.delete_title" = "Eliminar contacto";
"contacts.delete_message" = "Esto eliminará permanentemente a %@.";
"contacts.cancel" = "Cancelar";
"contacts.my_card_not_set" = "Configura tu tarjeta en Contactos";
"contacts.metadata_unavailable" = "Los favoritos no están disponibles porque el acceso a notas de contactos no está permitido.";
"contacts.set_my_card" = "Definir como mi tarjeta";
"contacts.unset_my_card" = "Quitar mi tarjeta";
"contacts.actions_title" = "Acciones del contacto";

View File

@@ -0,0 +1,39 @@
"contacts.title" = "Contacts";
"contacts.lists" = "Listes";
"contacts.search" = "Rechercher";
"contacts.my_card" = "Ma fiche";
"contacts.app_name" = "Kontakte";
"contacts.favorites" = "Favoris";
"contacts.favorites_empty" = "Aucun favori";
"contacts.all_contacts" = "Tous les contacts";
"contacts.none" = "Aucun contact";
"contacts.no_name" = "Sans nom";
"contacts.access_title" = "Autoriser laccès aux contacts";
"contacts.access_subtitle" = "Choisissez les contacts auxquels Kontakte peut accéder. Vous pourrez changer cela plus tard.";
"contacts.access_full" = "Autoriser laccès complet";
"contacts.access_select" = "Sélectionner des contacts";
"contacts.not_now" = "Pas maintenant";
"contacts.access_off_title" = "Accès aux contacts désactivé";
"contacts.access_off_subtitle" = "Activez laccès dans Réglages pour voir vos contacts dans Kontakte.";
"contacts.open_settings" = "Ouvrir les réglages";
"contacts.lists_header" = "Listes";
"contacts.access_header" = "Accès aux contacts";
"contacts.manage_access" = "Gérer laccès";
"contacts.full_access" = "Accès complet";
"contacts.access_off" = "Accès désactivé";
"contacts.not_requested" = "Non demandé";
"contacts.done" = "OK";
"contacts.add" = "Ajouter un contact";
"contacts.error_title" = "Impossible de charger les contacts";
"contacts.ok" = "OK";
"contacts.favorite" = "Favori";
"contacts.unfavorite" = "Retirer des favoris";
"contacts.delete" = "Supprimer";
"contacts.delete_title" = "Supprimer le contact";
"contacts.delete_message" = "Cela supprimera définitivement %@.";
"contacts.cancel" = "Annuler";
"contacts.my_card_not_set" = "Définissez votre fiche dans Contacts";
"contacts.metadata_unavailable" = "Les favoris ne sont pas disponibles car laccès aux notes de contact nest pas autorisé.";
"contacts.set_my_card" = "Définir comme ma fiche";
"contacts.unset_my_card" = "Retirer ma fiche";
"contacts.actions_title" = "Actions du contact";

View File

@@ -5,13 +5,78 @@
// Created by Felix Förtsch on 09.02.26.
//
import Foundation
import Testing
@testable import Kontakte
struct KontakteTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
@Test func parseEmptyNoteReturnsDefaultMetadata() {
let metadata = ContactMetadataDSL.parse(note: "")
#expect(metadata.isFavorite == false)
#expect(metadata.isMyCard == false)
}
@Test func parseMetadataBlockDetectsFlags() {
let note = """
private note
[[kontakte]]
favorite=1
my_card=1
[[/kontakte]]
"""
let metadata = ContactMetadataDSL.parse(note: note)
#expect(metadata.isFavorite == true)
#expect(metadata.isMyCard == true)
}
@Test func updatingAddsMetadataBlockAndKeepsExistingNote() {
let updated = ContactMetadataDSL.updating(
note: "Hello",
metadata: ContactMetadata(isFavorite: true, isMyCard: false)
)
#expect(updated.contains("Hello"))
#expect(updated.contains("[[kontakte]]"))
#expect(updated.contains("favorite=1"))
#expect(updated.contains("[[/kontakte]]"))
}
@Test func updatingReplacesExistingMetadataBlock() {
let original = """
Hello
[[kontakte]]
favorite=1
[[/kontakte]]
"""
let updated = ContactMetadataDSL.updating(
note: original,
metadata: ContactMetadata(isFavorite: false, isMyCard: true)
)
#expect(updated.contains("my_card=1"))
#expect(updated.contains("favorite=1") == false)
#expect(updated.components(separatedBy: "[[kontakte]]").count == 2)
}
@Test func updatingRemovesMetadataBlockWhenNoFlagsAreSet() {
let original = """
Hello
[[kontakte]]
favorite=1
[[/kontakte]]
"""
let updated = ContactMetadataDSL.updating(
note: original,
metadata: ContactMetadata(isFavorite: false, isMyCard: false)
)
#expect(updated == "Hello")
#expect(updated.contains("[[kontakte]]") == false)
}
}

5
kontaktplus.md Normal file
View File

@@ -0,0 +1,5 @@
Contact+ 2.mp3.txt
Kontext plus, Kontext Delay. Wenn jemand immer zu spät kommt, dann nimmt man einfach den Mittelwert. Also man trägt ein, wann der Gast erwartet wurde, wann er tatsächlich kommt und dann kann man die Einladung entsprechend verschieben.
Contact+.mp3.txt
Für die Toto-App eine Fabrikliste, die sortiert nach dem letzten Anruf, sodass man...