Guter Zwischenstand

This commit is contained in:
2026-04-17 16:50:52 +02:00
parent 49c1825c0f
commit c359caab72
11 changed files with 202 additions and 84 deletions
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -187,7 +187,7 @@ struct AddMomentView: View {
let moment = Moment(text: trimmed, type: selectedType, person: person) let moment = Moment(text: trimmed, type: selectedType, person: person)
modelContext.insert(moment) modelContext.insert(moment)
person.moments.append(moment) person.moments?.append(moment)
guard addToCalendar else { guard addToCalendar else {
dismiss() dismiss()
@@ -201,7 +201,7 @@ struct AddMomentView: View {
person: person person: person
) )
modelContext.insert(calEntry) modelContext.insert(calEntry)
person.logEntries.append(calEntry) person.logEntries?.append(calEntry)
// Kein async/await Callback-API vermeidet "unsafeForcedSync" // Kein async/await Callback-API vermeidet "unsafeForcedSync"
createCalendarEvent(notes: trimmed) createCalendarEvent(notes: trimmed)
+64 -10
View File
@@ -2,21 +2,75 @@ import Foundation
import SwiftData import SwiftData
/// Gemeinsame App-Group-Konfiguration für Hauptapp und Share Extension. /// Gemeinsame App-Group-Konfiguration für Hauptapp und Share Extension.
/// Die App-Group-ID muss in beiden Targets als Capability eingetragen sein.
enum AppGroup { enum AppGroup {
static let identifier = "group.nahbar.shared" static let identifier = "group.nahbar.shared"
/// URL des geteilten Containers. Fällt auf das Documents-Verzeichnis zurück, /// Shared UserDefaults für die Kommunikation zwischen Hauptapp und Extension.
/// falls die App Group noch nicht eingerichtet ist (z.B. in frühen Dev-Builds). static var userDefaults: UserDefaults {
static var containerURL: URL { UserDefaults(suiteName: identifier) ?? .standard
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
?? FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
} }
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 schema = Schema([Person.self, Moment.self, LogEntry.self])
let storeURL = containerURL.appendingPathComponent("nahbar.store") let icloudEnabled = UserDefaults.standard.bool(forKey: icloudSyncKey)
let config = ModelConfiguration(schema: schema, url: storeURL) let cloudKit: ModelConfiguration.CloudKitDatabase = icloudEnabled ? .automatic : .none
return try ModelContainer(for: schema, configurations: [config]) 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
} }
} }
+37 -2
View File
@@ -54,11 +54,13 @@ struct ContentView: View {
suggestionDateStr = ISO8601DateFormatter().string(from: Date()) suggestionDateStr = ISO8601DateFormatter().string(from: Date())
let entry = LogEntry(type: .call, title: "Anruf mit \(person.firstName)", person: person) let entry = LogEntry(type: .call, title: "Anruf mit \(person.firstName)", person: person)
modelContext.insert(entry) modelContext.insert(entry)
person.logEntries.append(entry) person.logEntries?.append(entry)
} }
} }
} }
.onAppear { .onAppear {
syncPeopleCache()
importPendingMoments()
if !onboardingDone { if !onboardingDone {
showingOnboarding = true showingOnboarding = true
} else { } else {
@@ -66,7 +68,14 @@ struct ContentView: View {
} }
} }
.onChange(of: scenePhase) { _, phase in .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 { private var suggestionShownToday: Bool {
guard !suggestionDateStr.isEmpty, guard !suggestionDateStr.isEmpty,
let date = ISO8601DateFormatter().date(from: suggestionDateStr) else { return false } let date = ISO8601DateFormatter().date(from: suggestionDateStr) else { return false }
+19 -20
View File
@@ -59,23 +59,23 @@ enum MomentType: String, CaseIterable, Codable {
@Model @Model
class Person { class Person {
var id: UUID var id: UUID = UUID()
var name: String var name: String = ""
var tagRaw: String var tagRaw: String = PersonTag.other.rawValue
var birthday: Date? var birthday: Date?
var occupation: String? var occupation: String?
var location: String? var location: String?
var interests: String? var interests: String?
var generalNotes: String? var generalNotes: String?
var nudgeFrequencyRaw: String var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue
var photoData: Data? var photoData: Data?
var nextStep: String? var nextStep: String?
var nextStepCompleted: Bool var nextStepCompleted: Bool = false
var nextStepReminderDate: Date? var nextStepReminderDate: Date?
var lastSuggestedForCall: Date? var lastSuggestedForCall: Date?
var createdAt: Date var createdAt: Date = Date()
@Relationship(deleteRule: .cascade) var moments: [Moment] @Relationship(deleteRule: .cascade) var moments: [Moment]? = []
@Relationship(deleteRule: .cascade) var logEntries: [LogEntry] @Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = []
init( init(
name: String, name: String,
@@ -117,7 +117,7 @@ class Person {
} }
var lastMomentDate: Date? { var lastMomentDate: Date? {
moments.sorted { $0.createdAt > $1.createdAt }.first?.createdAt (moments ?? []).sorted { $0.createdAt > $1.createdAt }.first?.createdAt
} }
var needsAttention: Bool { var needsAttention: Bool {
@@ -125,7 +125,6 @@ class Person {
if let last = lastMomentDate { if let last = lastMomentDate {
return Date().timeIntervalSince(last) > Double(days * 86400) 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) return Date().timeIntervalSince(createdAt) > Double(days * 86400)
} }
@@ -156,11 +155,11 @@ class Person {
} }
var sortedMoments: [Moment] { var sortedMoments: [Moment] {
moments.sorted { $0.createdAt > $1.createdAt } (moments ?? []).sorted { $0.createdAt > $1.createdAt }
} }
var sortedLogEntries: [LogEntry] { var sortedLogEntries: [LogEntry] {
logEntries.sorted { $0.loggedAt > $1.loggedAt } (logEntries ?? []).sorted { $0.loggedAt > $1.loggedAt }
} }
} }
@@ -192,10 +191,10 @@ enum LogEntryType: String, Codable {
@Model @Model
class LogEntry { class LogEntry {
var id: UUID var id: UUID = UUID()
var typeRaw: String var typeRaw: String = LogEntryType.nextStep.rawValue
var title: String var title: String = ""
var loggedAt: Date var loggedAt: Date = Date()
var person: Person? var person: Person?
init(type: LogEntryType, title: String, person: Person? = nil) { init(type: LogEntryType, title: String, person: Person? = nil) {
@@ -216,10 +215,10 @@ class LogEntry {
@Model @Model
class Moment { class Moment {
var id: UUID var id: UUID = UUID()
var text: String var text: String = ""
var typeRaw: String var typeRaw: String = MomentType.conversation.rawValue
var createdAt: Date var createdAt: Date = Date()
var person: Person? var person: Person?
init(text: String, type: MomentType = .conversation, person: Person? = nil) { init(text: String, type: MomentType = .conversation, person: Person? = nil) {
+1 -1
View File
@@ -43,7 +43,7 @@ struct NahbarApp: App {
.onAppear { applyTabBarAppearance(activeTheme) } .onAppear { applyTabBarAppearance(activeTheme) }
.onChange(of: activeThemeIDRaw) { _, _ in applyTabBarAppearance(activeTheme) } .onChange(of: activeThemeIDRaw) { _, _ in applyTabBarAppearance(activeTheme) }
} }
.modelContainer(try! AppGroup.makeModelContainer()) .modelContainer(AppGroup.makeMainContainer())
.onChange(of: scenePhase) { _, phase in .onChange(of: scenePhase) { _, phase in
if phase == .background { if phase == .background {
appLockManager.lockIfEnabled() appLockManager.lockIfEnabled()
+1 -1
View File
@@ -164,7 +164,7 @@ struct PersonDetailView: View {
if let step = person.nextStep { if let step = person.nextStep {
let entry = LogEntry(type: .nextStep, title: step, person: person) let entry = LogEntry(type: .nextStep, title: step, person: person)
modelContext.insert(entry) modelContext.insert(entry)
person.logEntries.append(entry) person.logEntries?.append(entry)
} }
person.nextStepCompleted = true person.nextStepCompleted = true
cancelReminder(for: person) cancelReminder(for: person)
+42 -2
View File
@@ -8,6 +8,7 @@ struct SettingsView: View {
@EnvironmentObject private var appLockManager: AppLockManager @EnvironmentObject private var appLockManager: AppLockManager
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7 @AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
@AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false
@AppStorage("aiBaseURL") private var aiBaseURL: String = AIConfig.fallback.baseURL @AppStorage("aiBaseURL") private var aiBaseURL: String = AIConfig.fallback.baseURL
@AppStorage("aiAPIKey") private var aiAPIKey: String = AIConfig.fallback.apiKey @AppStorage("aiAPIKey") private var aiAPIKey: String = AIConfig.fallback.apiKey
@AppStorage("aiModel") private var aiModel: String = AIConfig.fallback.model @AppStorage("aiModel") private var aiModel: String = AIConfig.fallback.model
@@ -289,6 +290,47 @@ struct SettingsView: View {
.padding(.horizontal, 20) .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 // About
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Über nahbar", icon: "info.circle") SectionHeader(title: "Über nahbar", icon: "info.circle")
@@ -297,8 +339,6 @@ struct SettingsView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
SettingsInfoRow(label: "Version", value: "1.0 Draft") SettingsInfoRow(label: "Version", value: "1.0 Draft")
RowDivider() RowDivider()
SettingsInfoRow(label: "Daten", value: "Lokal + iCloud")
RowDivider()
SettingsInfoRow(label: "Datenschutz", value: "Deine Daten verlassen nicht dein Gerät") SettingsInfoRow(label: "Datenschutz", value: "Deine Daten verlassen nicht dein Gerät")
} }
.background(theme.surfaceCard) .background(theme.surfaceCard)
+10
View File
@@ -2,6 +2,16 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.Team.nahbar</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.nahbar.shared</string> <string>group.nahbar.shared</string>
@@ -1,5 +1,11 @@
import SwiftUI 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 { struct ShareExtensionView: View {
let sharedText: String let sharedText: String
@@ -7,8 +13,8 @@ struct ShareExtensionView: View {
@State private var text: String @State private var text: String
@State private var momentType: MomentType = .conversation @State private var momentType: MomentType = .conversation
@State private var people: [Person] = [] @State private var people: [CachedPerson] = []
@State private var selectedPerson: Person? @State private var selectedPerson: CachedPerson?
@State private var searchText = "" @State private var searchText = ""
@State private var isSaving = false @State private var isSaving = false
@State private var errorMessage: String? @State private var errorMessage: String?
@@ -19,7 +25,7 @@ struct ShareExtensionView: View {
self._text = State(initialValue: sharedText) self._text = State(initialValue: sharedText)
} }
private var filteredPeople: [Person] { private var filteredPeople: [CachedPerson] {
searchText.isEmpty ? people : people.filter { searchText.isEmpty ? people : people.filter {
$0.name.localizedCaseInsensitiveContains(searchText) $0.name.localizedCaseInsensitiveContains(searchText)
} }
@@ -46,14 +52,15 @@ struct ShareExtensionView: View {
Section("Kontakt") { Section("Kontakt") {
if people.isEmpty { if people.isEmpty {
Text("Keine Kontakte gefunden") Text("Keine Kontakte gefunden. Öffne nahbar einmal, damit die Kontakte hier erscheinen.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.font(.system(size: 14)) .font(.system(size: 14))
} else { } else {
TextField("Suchen…", text: $searchText) TextField("Suchen…", text: $searchText)
ForEach(filteredPeople) { person in ForEach(filteredPeople) { person in
PersonPickerRow( PersonPickerRow(
person: person, name: person.name,
tag: person.tag,
isSelected: selectedPerson?.id == person.id isSelected: selectedPerson?.id == person.id
) { ) {
selectedPerson = (selectedPerson?.id == person.id) ? nil : person selectedPerson = (selectedPerson?.id == person.id) ? nil : person
@@ -88,7 +95,8 @@ struct ShareExtensionView: View {
// MARK: - Subviews // MARK: - Subviews
struct PersonPickerRow: View { struct PersonPickerRow: View {
let person: Person let name: String
let tag: String
let isSelected: Bool let isSelected: Bool
let onTap: () -> Void let onTap: () -> Void
@@ -96,18 +104,12 @@ struct ShareExtensionView: View {
Button(action: onTap) { Button(action: onTap) {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(person.name) Text(name).foregroundStyle(.primary)
.foregroundStyle(.primary) Text(tag).font(.caption).foregroundStyle(.secondary)
if let tag = PersonTag(rawValue: person.tagRaw) {
Text(tag.rawValue)
.font(.caption)
.foregroundStyle(.secondary)
}
} }
Spacer() Spacer()
if isSelected { if isSelected {
Image(systemName: "checkmark") Image(systemName: "checkmark").foregroundStyle(Color.accentColor)
.foregroundStyle(Color.accentColor)
} }
} }
} }
@@ -118,41 +120,19 @@ struct ShareExtensionView: View {
// MARK: - Data // MARK: - Data
private func loadPeople() { private func loadPeople() {
do { people = AppGroup.cachedPeople.compactMap { dict in
let container = try AppGroup.makeModelContainer() guard let idString = dict["id"], let id = UUID(uuidString: idString),
let context = ModelContext(container) let name = dict["name"], let tag = dict["tag"] else { return nil }
let descriptor = FetchDescriptor<Person>(sortBy: [SortDescriptor(\.name)]) return CachedPerson(id: id, name: name, tag: tag)
people = (try? context.fetch(descriptor)) ?? []
} catch {
errorMessage = "Kontakte konnten nicht geladen werden."
} }
} }
private func save() { private func save() {
guard let person = selectedPerson else { return } guard let person = selectedPerson else { return }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
isSaving = true isSaving = true
errorMessage = nil AppGroup.enqueueMoment(personName: person.name, text: trimmed, type: momentType.rawValue)
do {
let container = try AppGroup.makeModelContainer()
let context = ModelContext(container)
let personID = person.id
let descriptor = FetchDescriptor<Person>(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() onDismiss()
} catch {
errorMessage = "Speichern fehlgeschlagen: \(error.localizedDescription)"
}
isSaving = false
} }
} }