diff --git a/nahbar/.DS_Store b/nahbar/.DS_Store index 4ff7e18..e23afc6 100644 Binary files a/nahbar/.DS_Store and b/nahbar/.DS_Store differ diff --git a/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate b/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate index 5c19e1d..c64b839 100644 Binary files a/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate and b/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/nahbar/nahbar/AddMomentView.swift b/nahbar/nahbar/AddMomentView.swift index e12e64d..83c94be 100644 --- a/nahbar/nahbar/AddMomentView.swift +++ b/nahbar/nahbar/AddMomentView.swift @@ -187,7 +187,7 @@ struct AddMomentView: View { let moment = Moment(text: trimmed, type: selectedType, person: person) modelContext.insert(moment) - person.moments.append(moment) + person.moments?.append(moment) guard addToCalendar else { dismiss() @@ -201,7 +201,7 @@ struct AddMomentView: View { person: person ) modelContext.insert(calEntry) - person.logEntries.append(calEntry) + person.logEntries?.append(calEntry) // Kein async/await — Callback-API vermeidet "unsafeForcedSync" createCalendarEvent(notes: trimmed) diff --git a/nahbar/nahbar/AppGroup.swift b/nahbar/nahbar/AppGroup.swift index 0074081..de4e55d 100644 --- a/nahbar/nahbar/AppGroup.swift +++ b/nahbar/nahbar/AppGroup.swift @@ -2,21 +2,75 @@ import Foundation import SwiftData /// Gemeinsame App-Group-Konfiguration für Hauptapp und Share Extension. -/// Die App-Group-ID muss in beiden Targets als Capability eingetragen sein. enum AppGroup { static let identifier = "group.nahbar.shared" - /// URL des geteilten Containers. Fällt auf das Documents-Verzeichnis zurück, - /// falls die App Group noch nicht eingerichtet ist (z.B. in frühen Dev-Builds). - static var containerURL: URL { - FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier) - ?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + /// Shared UserDefaults für die Kommunikation zwischen Hauptapp und Extension. + static var userDefaults: UserDefaults { + UserDefaults(suiteName: identifier) ?? .standard } - static func makeModelContainer() throws -> ModelContainer { + // MARK: - Hauptapp: Standard-Store (Daten bleiben erhalten) + + /// ModelContainer für die Hauptapp – nutzt Standard-Speicherort (Daten-kompatibel). + /// Versucht CloudKit; fällt bei Bedarf auf lokalen Store zurück. + static let icloudSyncKey = "icloudSyncEnabled" + + static func makeMainContainer() -> ModelContainer { let schema = Schema([Person.self, Moment.self, LogEntry.self]) - let storeURL = containerURL.appendingPathComponent("nahbar.store") - let config = ModelConfiguration(schema: schema, url: storeURL) - return try ModelContainer(for: schema, configurations: [config]) + let icloudEnabled = UserDefaults.standard.bool(forKey: icloudSyncKey) + let cloudKit: ModelConfiguration.CloudKitDatabase = icloudEnabled ? .automatic : .none + let config = ModelConfiguration(schema: schema, cloudKitDatabase: cloudKit) + if let container = try? ModelContainer(for: schema, configurations: [config]) { + return container + } + // Fallback: lokal ohne CloudKit + let localConfig = ModelConfiguration(schema: schema, cloudKitDatabase: .none) + if let container = try? ModelContainer(for: schema, configurations: [localConfig]) { + return container + } + return try! ModelContainer(for: schema, configurations: [ModelConfiguration(isStoredInMemoryOnly: true)]) + } + + // MARK: - Share Extension: App-Group-Store (nur für Extension) + + /// Speichert eine Nachricht als ausstehenden Moment in der App Group. + /// Die Hauptapp importiert diese beim nächsten Start. + static func enqueueMoment(personName: String, text: String, type: String) { + var queue = pendingMoments + queue.append(["personName": personName, "text": text, "type": type, + "createdAt": ISO8601DateFormatter().string(from: Date())]) + if let data = try? JSONSerialization.data(withJSONObject: queue) { + userDefaults.set(data, forKey: "pendingMoments") + } + } + + static var pendingMoments: [[String: String]] { + guard let data = userDefaults.data(forKey: "pendingMoments"), + let array = try? JSONSerialization.jsonObject(with: data) as? [[String: String]] + else { return [] } + return array + } + + static func clearPendingMoments() { + userDefaults.removeObject(forKey: "pendingMoments") + } + + // MARK: - Personenliste für Extension + + /// Hauptapp schreibt beim Start die Personenliste in UserDefaults, + /// damit die Extension sie ohne Store-Zugriff lesen kann. + static func savePeopleList(_ people: [Person]) { + let list = people.map { ["id": $0.id.uuidString, "name": $0.name, "tag": $0.tagRaw] } + if let data = try? JSONSerialization.data(withJSONObject: list) { + userDefaults.set(data, forKey: "cachedPeople") + } + } + + static var cachedPeople: [[String: String]] { + guard let data = userDefaults.data(forKey: "cachedPeople"), + let array = try? JSONSerialization.jsonObject(with: data) as? [[String: String]] + else { return [] } + return array } } diff --git a/nahbar/nahbar/ContentView.swift b/nahbar/nahbar/ContentView.swift index 2887551..ff40e88 100644 --- a/nahbar/nahbar/ContentView.swift +++ b/nahbar/nahbar/ContentView.swift @@ -54,11 +54,13 @@ struct ContentView: View { suggestionDateStr = ISO8601DateFormatter().string(from: Date()) let entry = LogEntry(type: .call, title: "Anruf mit \(person.firstName)", person: person) modelContext.insert(entry) - person.logEntries.append(entry) + person.logEntries?.append(entry) } } } .onAppear { + syncPeopleCache() + importPendingMoments() if !onboardingDone { showingOnboarding = true } else { @@ -66,7 +68,14 @@ struct ContentView: View { } } .onChange(of: scenePhase) { _, phase in - if phase == .active { checkCallWindow() } + if phase == .active { + syncPeopleCache() + importPendingMoments() + checkCallWindow() + } + } + .onChange(of: persons) { _, _ in + syncPeopleCache() } } @@ -83,6 +92,32 @@ struct ContentView: View { } } + /// Schreibt die aktuelle Personenliste in den App-Group-Cache für die Share Extension. + private func syncPeopleCache() { + AppGroup.savePeopleList(persons) + } + + /// Importiert Momente, die über die Share Extension eingereiht wurden. + private func importPendingMoments() { + let pending = AppGroup.pendingMoments + guard !pending.isEmpty else { return } + for entry in pending { + guard let name = entry["personName"], + let text = entry["text"], + let typeRaw = entry["type"] else { continue } + let type_ = MomentType(rawValue: typeRaw) ?? .conversation + if let person = persons.first(where: { $0.name == name }) { + let moment = Moment(text: text, type: type_, person: person) + modelContext.insert(moment) + person.moments?.append(moment) + } + } + if !pending.isEmpty { + try? modelContext.save() + AppGroup.clearPendingMoments() + } + } + private var suggestionShownToday: Bool { guard !suggestionDateStr.isEmpty, let date = ISO8601DateFormatter().date(from: suggestionDateStr) else { return false } diff --git a/nahbar/nahbar/Models.swift b/nahbar/nahbar/Models.swift index 9c0a765..d2f3ef8 100644 --- a/nahbar/nahbar/Models.swift +++ b/nahbar/nahbar/Models.swift @@ -59,23 +59,23 @@ enum MomentType: String, CaseIterable, Codable { @Model class Person { - var id: UUID - var name: String - var tagRaw: String + var id: UUID = UUID() + var name: String = "" + var tagRaw: String = PersonTag.other.rawValue var birthday: Date? var occupation: String? var location: String? var interests: String? var generalNotes: String? - var nudgeFrequencyRaw: String + var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue var photoData: Data? var nextStep: String? - var nextStepCompleted: Bool + var nextStepCompleted: Bool = false var nextStepReminderDate: Date? var lastSuggestedForCall: Date? - var createdAt: Date - @Relationship(deleteRule: .cascade) var moments: [Moment] - @Relationship(deleteRule: .cascade) var logEntries: [LogEntry] + var createdAt: Date = Date() + @Relationship(deleteRule: .cascade) var moments: [Moment]? = [] + @Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = [] init( name: String, @@ -117,7 +117,7 @@ class Person { } var lastMomentDate: Date? { - moments.sorted { $0.createdAt > $1.createdAt }.first?.createdAt + (moments ?? []).sorted { $0.createdAt > $1.createdAt }.first?.createdAt } var needsAttention: Bool { @@ -125,7 +125,6 @@ class Person { if let last = lastMomentDate { return Date().timeIntervalSince(last) > Double(days * 86400) } - // Never had a moment – show if added more than `days` ago return Date().timeIntervalSince(createdAt) > Double(days * 86400) } @@ -156,11 +155,11 @@ class Person { } var sortedMoments: [Moment] { - moments.sorted { $0.createdAt > $1.createdAt } + (moments ?? []).sorted { $0.createdAt > $1.createdAt } } var sortedLogEntries: [LogEntry] { - logEntries.sorted { $0.loggedAt > $1.loggedAt } + (logEntries ?? []).sorted { $0.loggedAt > $1.loggedAt } } } @@ -192,10 +191,10 @@ enum LogEntryType: String, Codable { @Model class LogEntry { - var id: UUID - var typeRaw: String - var title: String - var loggedAt: Date + var id: UUID = UUID() + var typeRaw: String = LogEntryType.nextStep.rawValue + var title: String = "" + var loggedAt: Date = Date() var person: Person? init(type: LogEntryType, title: String, person: Person? = nil) { @@ -216,10 +215,10 @@ class LogEntry { @Model class Moment { - var id: UUID - var text: String - var typeRaw: String - var createdAt: Date + var id: UUID = UUID() + var text: String = "" + var typeRaw: String = MomentType.conversation.rawValue + var createdAt: Date = Date() var person: Person? init(text: String, type: MomentType = .conversation, person: Person? = nil) { diff --git a/nahbar/nahbar/NahbarApp.swift b/nahbar/nahbar/NahbarApp.swift index e373dcd..7becc21 100644 --- a/nahbar/nahbar/NahbarApp.swift +++ b/nahbar/nahbar/NahbarApp.swift @@ -43,7 +43,7 @@ struct NahbarApp: App { .onAppear { applyTabBarAppearance(activeTheme) } .onChange(of: activeThemeIDRaw) { _, _ in applyTabBarAppearance(activeTheme) } } - .modelContainer(try! AppGroup.makeModelContainer()) + .modelContainer(AppGroup.makeMainContainer()) .onChange(of: scenePhase) { _, phase in if phase == .background { appLockManager.lockIfEnabled() diff --git a/nahbar/nahbar/PersonDetailView.swift b/nahbar/nahbar/PersonDetailView.swift index 638eab4..9801e6a 100644 --- a/nahbar/nahbar/PersonDetailView.swift +++ b/nahbar/nahbar/PersonDetailView.swift @@ -164,7 +164,7 @@ struct PersonDetailView: View { if let step = person.nextStep { let entry = LogEntry(type: .nextStep, title: step, person: person) modelContext.insert(entry) - person.logEntries.append(entry) + person.logEntries?.append(entry) } person.nextStepCompleted = true cancelReminder(for: person) diff --git a/nahbar/nahbar/SettingsView.swift b/nahbar/nahbar/SettingsView.swift index 47f8fbc..730b3bd 100644 --- a/nahbar/nahbar/SettingsView.swift +++ b/nahbar/nahbar/SettingsView.swift @@ -8,6 +8,7 @@ struct SettingsView: View { @EnvironmentObject private var appLockManager: AppLockManager @AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7 + @AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false @AppStorage("aiBaseURL") private var aiBaseURL: String = AIConfig.fallback.baseURL @AppStorage("aiAPIKey") private var aiAPIKey: String = AIConfig.fallback.apiKey @AppStorage("aiModel") private var aiModel: String = AIConfig.fallback.model @@ -289,6 +290,47 @@ struct SettingsView: View { .padding(.horizontal, 20) } + // iCloud + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "iCloud", icon: "icloud") + .padding(.horizontal, 20) + + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("iCloud-Backup") + .font(.system(size: 15)) + .foregroundStyle(theme.contentPrimary) + Text(icloudSyncEnabled + ? "Daten werden mit iCloud synchronisiert" + : "Daten werden nur lokal gespeichert") + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + } + Spacer() + Toggle("", isOn: $icloudSyncEnabled) + .tint(theme.accent) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + RowDivider() + HStack(spacing: 8) { + Image(systemName: "info.circle") + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + Text("Änderung wird beim nächsten App-Start übernommen.") + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .background(theme.surfaceCard) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + .padding(.horizontal, 20) + } + // About VStack(alignment: .leading, spacing: 12) { SectionHeader(title: "Über nahbar", icon: "info.circle") @@ -297,8 +339,6 @@ struct SettingsView: View { VStack(spacing: 0) { SettingsInfoRow(label: "Version", value: "1.0 Draft") RowDivider() - SettingsInfoRow(label: "Daten", value: "Lokal + iCloud") - RowDivider() SettingsInfoRow(label: "Datenschutz", value: "Deine Daten verlassen nicht dein Gerät") } .background(theme.surfaceCard) diff --git a/nahbar/nahbar/nahbar.entitlements b/nahbar/nahbar/nahbar.entitlements index b75ec07..7e62b33 100644 --- a/nahbar/nahbar/nahbar.entitlements +++ b/nahbar/nahbar/nahbar.entitlements @@ -2,6 +2,16 @@ + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.Team.nahbar + + com.apple.developer.icloud-services + + CloudKit + com.apple.security.application-groups group.nahbar.shared diff --git a/nahbar/nahbarShareExtension/ShareExtensionView.swift b/nahbar/nahbarShareExtension/ShareExtensionView.swift index 75bbbbc..b18600c 100644 --- a/nahbar/nahbarShareExtension/ShareExtensionView.swift +++ b/nahbar/nahbarShareExtension/ShareExtensionView.swift @@ -1,5 +1,11 @@ import SwiftUI -import SwiftData + +/// Einfache Personen-Darstellung für die Share Extension (aus UserDefaults-Cache). +struct CachedPerson: Identifiable, Equatable { + let id: UUID + let name: String + let tag: String +} struct ShareExtensionView: View { let sharedText: String @@ -7,8 +13,8 @@ struct ShareExtensionView: View { @State private var text: String @State private var momentType: MomentType = .conversation - @State private var people: [Person] = [] - @State private var selectedPerson: Person? + @State private var people: [CachedPerson] = [] + @State private var selectedPerson: CachedPerson? @State private var searchText = "" @State private var isSaving = false @State private var errorMessage: String? @@ -19,7 +25,7 @@ struct ShareExtensionView: View { self._text = State(initialValue: sharedText) } - private var filteredPeople: [Person] { + private var filteredPeople: [CachedPerson] { searchText.isEmpty ? people : people.filter { $0.name.localizedCaseInsensitiveContains(searchText) } @@ -46,14 +52,15 @@ struct ShareExtensionView: View { Section("Kontakt") { if people.isEmpty { - Text("Keine Kontakte gefunden") + Text("Keine Kontakte gefunden. Öffne nahbar einmal, damit die Kontakte hier erscheinen.") .foregroundStyle(.secondary) .font(.system(size: 14)) } else { TextField("Suchen…", text: $searchText) ForEach(filteredPeople) { person in PersonPickerRow( - person: person, + name: person.name, + tag: person.tag, isSelected: selectedPerson?.id == person.id ) { selectedPerson = (selectedPerson?.id == person.id) ? nil : person @@ -88,7 +95,8 @@ struct ShareExtensionView: View { // MARK: - Subviews struct PersonPickerRow: View { - let person: Person + let name: String + let tag: String let isSelected: Bool let onTap: () -> Void @@ -96,18 +104,12 @@ struct ShareExtensionView: View { Button(action: onTap) { HStack { VStack(alignment: .leading, spacing: 2) { - Text(person.name) - .foregroundStyle(.primary) - if let tag = PersonTag(rawValue: person.tagRaw) { - Text(tag.rawValue) - .font(.caption) - .foregroundStyle(.secondary) - } + Text(name).foregroundStyle(.primary) + Text(tag).font(.caption).foregroundStyle(.secondary) } Spacer() if isSelected { - Image(systemName: "checkmark") - .foregroundStyle(Color.accentColor) + Image(systemName: "checkmark").foregroundStyle(Color.accentColor) } } } @@ -118,41 +120,19 @@ struct ShareExtensionView: View { // MARK: - Data private func loadPeople() { - do { - let container = try AppGroup.makeModelContainer() - let context = ModelContext(container) - let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.name)]) - people = (try? context.fetch(descriptor)) ?? [] - } catch { - errorMessage = "Kontakte konnten nicht geladen werden." + people = AppGroup.cachedPeople.compactMap { dict in + guard let idString = dict["id"], let id = UUID(uuidString: idString), + let name = dict["name"], let tag = dict["tag"] else { return nil } + return CachedPerson(id: id, name: name, tag: tag) } } private func save() { guard let person = selectedPerson else { return } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } isSaving = true - errorMessage = nil - do { - let container = try AppGroup.makeModelContainer() - let context = ModelContext(container) - let personID = person.id - let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == personID }) - guard let target = try context.fetch(descriptor).first else { - errorMessage = "Kontakt nicht gefunden." - isSaving = false - return - } - let moment = Moment( - text: text.trimmingCharacters(in: .whitespacesAndNewlines), - type: momentType, - person: target - ) - context.insert(moment) - try context.save() - onDismiss() - } catch { - errorMessage = "Speichern fehlgeschlagen: \(error.localizedDescription)" - } - isSaving = false + AppGroup.enqueueMoment(personName: person.name, text: trimmed, type: momentType.rawValue) + onDismiss() } }