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)
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)
+64 -10
View File
@@ -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
}
}
+37 -2
View File
@@ -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 }
+19 -20
View File
@@ -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) {
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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)
+42 -2
View File
@@ -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)
+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">
<plist version="1.0">
<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>
<array>
<string>group.nahbar.shared</string>
@@ -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<Person>(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<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()
} catch {
errorMessage = "Speichern fehlgeschlagen: \(error.localizedDescription)"
}
isSaving = false
AppGroup.enqueueMoment(personName: person.name, text: trimmed, type: momentType.rawValue)
onDismiss()
}
}