snapshot current state before gitea sync
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -203,7 +203,10 @@
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
de,
|
||||
en,
|
||||
es,
|
||||
fr,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 3F2BF5312F3A39EC00888D8F;
|
||||
|
||||
7
Kontakte.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Kontakte.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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>
|
||||
71
Kontakte/ContactMetadataDSL.swift
Normal file
71
Kontakte/ContactMetadataDSL.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
39
Kontakte/de.lproj/Localizable.strings
Normal file
39
Kontakte/de.lproj/Localizable.strings
Normal 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";
|
||||
39
Kontakte/en.lproj/Localizable.strings
Normal file
39
Kontakte/en.lproj/Localizable.strings
Normal 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";
|
||||
39
Kontakte/es.lproj/Localizable.strings
Normal file
39
Kontakte/es.lproj/Localizable.strings
Normal 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";
|
||||
39
Kontakte/fr.lproj/Localizable.strings
Normal file
39
Kontakte/fr.lproj/Localizable.strings
Normal 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 l’accès aux contacts";
|
||||
"contacts.access_subtitle" = "Choisissez les contacts auxquels Kontakte peut accéder. Vous pourrez changer cela plus tard.";
|
||||
"contacts.access_full" = "Autoriser l’accè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 l’accè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 l’accè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 l’accès aux notes de contact n’est pas autorisé.";
|
||||
"contacts.set_my_card" = "Définir comme ma fiche";
|
||||
"contacts.unset_my_card" = "Retirer ma fiche";
|
||||
"contacts.actions_title" = "Actions du contact";
|
||||
@@ -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
5
kontaktplus.md
Normal 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...
|
||||
Reference in New Issue
Block a user