diff --git a/nahbar/.DS_Store b/nahbar/.DS_Store index e23afc6..f7aed53 100644 Binary files a/nahbar/.DS_Store and b/nahbar/.DS_Store differ diff --git a/nahbar/nahbar.xcodeproj/project.pbxproj b/nahbar/nahbar.xcodeproj/project.pbxproj index ff4ed7e..9a48322 100644 --- a/nahbar/nahbar.xcodeproj/project.pbxproj +++ b/nahbar/nahbar.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269ECE652F92B5C700444B14 /* NahbarMigration.swift */; }; 26BB85B92F9248BD00889312 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85B82F9248BD00889312 /* SplashView.swift */; }; 26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85BA2F924D9B00889312 /* StoreManager.swift */; }; 26BB85BD2F924DB100889312 /* PaywallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85BC2F924DB100889312 /* PaywallView.swift */; }; @@ -66,6 +67,7 @@ /* Begin PBXFileReference section */ 265F92202F9109B500CE0A5C /* nahbar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nahbar.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 269ECE652F92B5C700444B14 /* NahbarMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarMigration.swift; sourceTree = ""; }; 26BB85B82F9248BD00889312 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = ""; }; 26BB85BA2F924D9B00889312 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = ""; }; 26BB85BC2F924DB100889312 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = ""; }; @@ -187,6 +189,7 @@ 26BB85C02F92525200889312 /* AIAnalysisService.swift */, 26BB85C22F92586600889312 /* AIConfiguration.json */, 26BB85C42F926A1C00889312 /* AppGroup.swift */, + 269ECE652F92B5C700444B14 /* NahbarMigration.swift */, ); path = nahbar; sourceTree = ""; @@ -300,6 +303,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */, 26EF66322F9112E700824F91 /* Models.swift in Sources */, 26EF66332F9112E700824F91 /* TodayView.swift in Sources */, 26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */, 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 c64b839..5901e43 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/AppGroup.swift b/nahbar/nahbar/AppGroup.swift index de4e55d..8e1d4ed 100644 --- a/nahbar/nahbar/AppGroup.swift +++ b/nahbar/nahbar/AppGroup.swift @@ -36,10 +36,16 @@ enum AppGroup { /// 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) { + static func enqueueMoment(personName: String, text: String, type: String, source: String? = nil) { var queue = pendingMoments - queue.append(["personName": personName, "text": text, "type": type, - "createdAt": ISO8601DateFormatter().string(from: Date())]) + var entry: [String: String] = [ + "personName": personName, + "text": text, + "type": type, + "createdAt": ISO8601DateFormatter().string(from: Date()) + ] + if let source { entry["source"] = source } + queue.append(entry) if let data = try? JSONSerialization.data(withJSONObject: queue) { userDefaults.set(data, forKey: "pendingMoments") } diff --git a/nahbar/nahbar/ContentView.swift b/nahbar/nahbar/ContentView.swift index ff40e88..f67b5fb 100644 --- a/nahbar/nahbar/ContentView.swift +++ b/nahbar/nahbar/ContentView.swift @@ -106,8 +106,9 @@ struct ContentView: View { let text = entry["text"], let typeRaw = entry["type"] else { continue } let type_ = MomentType(rawValue: typeRaw) ?? .conversation + let source_ = entry["source"].flatMap { MomentSource(rawValue: $0) } if let person = persons.first(where: { $0.name == name }) { - let moment = Moment(text: text, type: type_, person: person) + let moment = Moment(text: text, type: type_, source: source_, person: person) modelContext.insert(moment) person.moments?.append(moment) } diff --git a/nahbar/nahbar/LogbuchView.swift b/nahbar/nahbar/LogbuchView.swift index 83dedba..c924c5d 100644 --- a/nahbar/nahbar/LogbuchView.swift +++ b/nahbar/nahbar/LogbuchView.swift @@ -1,4 +1,5 @@ import SwiftUI +import CoreData // MARK: - AI Analysis State @@ -60,6 +61,7 @@ private enum LogbuchItem: Identifiable { struct LogbuchView: View { @Environment(\.nahbarTheme) var theme + @Environment(\.dismiss) var dismiss @StateObject private var store = StoreManager.shared let person: Person @@ -92,6 +94,14 @@ struct LogbuchView: View { .navigationBarTitleDisplayMode(.inline) .themedNavBar() .sheet(isPresented: $showPaywall) { PaywallView() } + .onReceive( + NotificationCenter.default.publisher( + for: Notification.Name("NSManagedObjectContextObjectsDidChangeNotification") + ) + ) { notification in + guard notification.userInfo?[NSInvalidatedAllObjectsKey] != nil else { return } + dismiss() + } .onAppear { if let cached = AIAnalysisService.shared.loadCached(for: person) { analysisState = .result(cached.asResult, cached.analyzedAt) diff --git a/nahbar/nahbar/Models.swift b/nahbar/nahbar/Models.swift index d2f3ef8..cf75ec3 100644 --- a/nahbar/nahbar/Models.swift +++ b/nahbar/nahbar/Models.swift @@ -55,6 +55,24 @@ enum MomentType: String, CaseIterable, Codable { } } +enum MomentSource: String, CaseIterable, Codable { + case whatsapp = "WhatsApp" + case imessage = "iMessage" + case telegram = "Telegram" + case signal = "Signal" + case other = "Chat" + + var icon: String { + switch self { + case .whatsapp: return "message.fill" + case .imessage: return "message.fill" + case .telegram: return "paperplane.fill" + case .signal: return "lock.circle.fill" + case .other: return "bubble.left.fill" + } + } +} + // MARK: - Person @Model @@ -218,13 +236,15 @@ class Moment { var id: UUID = UUID() var text: String = "" var typeRaw: String = MomentType.conversation.rawValue + var sourceRaw: String? = nil var createdAt: Date = Date() var person: Person? - init(text: String, type: MomentType = .conversation, person: Person? = nil) { + init(text: String, type: MomentType = .conversation, source: MomentSource? = nil, person: Person? = nil) { self.id = UUID() self.text = text self.typeRaw = type.rawValue + self.sourceRaw = source?.rawValue self.createdAt = Date() self.person = person } @@ -233,4 +253,9 @@ class Moment { get { MomentType(rawValue: typeRaw) ?? .conversation } set { typeRaw = newValue.rawValue } } + + var source: MomentSource? { + get { sourceRaw.flatMap { MomentSource(rawValue: $0) } } + set { sourceRaw = newValue?.rawValue } + } } diff --git a/nahbar/nahbar/NahbarApp.swift b/nahbar/nahbar/NahbarApp.swift index 7becc21..6f45d03 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(AppGroup.makeMainContainer()) + .modelContainer(AppGroup.makeMainContainerWithMigration()) .onChange(of: scenePhase) { _, phase in if phase == .background { appLockManager.lockIfEnabled() diff --git a/nahbar/nahbar/NahbarMigration.swift b/nahbar/nahbar/NahbarMigration.swift new file mode 100644 index 0000000..0e7abd7 --- /dev/null +++ b/nahbar/nahbar/NahbarMigration.swift @@ -0,0 +1,113 @@ +import SwiftUI +import SwiftData + +// MARK: - Schema V1 (Originalschema – Moment ohne sourceRaw) +// +// Diese Typen spiegeln exakt das ursprüngliche Schema wider, bevor +// Moment.sourceRaw hinzugefügt wurde. SwiftData vergleicht das +// gespeicherte Schema-Hash mit dieser Definition und führt bei +// Übereinstimmung die Lightweight-Migration zu V2 durch. + +enum NahbarSchemaV1: VersionedSchema { + static var versionIdentifier = Schema.Version(1, 0, 0) + static var models: [any PersistentModel.Type] { + [Person.self, Moment.self, LogEntry.self] + } + + @Model final class Person { + var id: UUID = UUID() + var name: String = "" + var tagRaw: String = "Andere" + var birthday: Date? = nil + var occupation: String? = nil + var location: String? = nil + var interests: String? = nil + var generalNotes: String? = nil + var nudgeFrequencyRaw: String = "Monatlich" + var photoData: Data? = nil + var nextStep: String? = nil + var nextStepCompleted: Bool = false + var nextStepReminderDate: Date? = nil + var lastSuggestedForCall: Date? = nil + var createdAt: Date = Date() + @Relationship(deleteRule: .cascade) var moments: [Moment]? = [] + @Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = [] + + init() {} + } + + @Model final class Moment { + var id: UUID = UUID() + var text: String = "" + var typeRaw: String = "Gespräch" + var createdAt: Date = Date() + var person: Person? = nil + + init() {} + } + + @Model final class LogEntry { + var id: UUID = UUID() + var typeRaw: String = "Schritt abgeschlossen" + var title: String = "" + var loggedAt: Date = Date() + var person: Person? = nil + + init() {} + } +} + +// MARK: - Schema V2 (aktuell – Moment mit sourceRaw) + +enum NahbarSchemaV2: VersionedSchema { + static var versionIdentifier = Schema.Version(2, 0, 0) + static var models: [any PersistentModel.Type] { + // Verweist auf die aktuellen Top-Level-Modeltypen in Models.swift + [nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self] + } +} + +// MARK: - Migrationsplan V1 → V2 + +enum NahbarMigrationPlan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { + [NahbarSchemaV1.self, NahbarSchemaV2.self] + } + + /// Lightweight: SwiftData ergänzt sourceRaw mit nil für alle bestehenden Momente. + static var stages: [MigrationStage] { + [.lightweight(fromVersion: NahbarSchemaV1.self, toVersion: NahbarSchemaV2.self)] + } +} + +// MARK: - Container-Erstellung (nur Hauptapp, nicht Share Extension) + +extension AppGroup { + /// Erstellt den ModelContainer mit automatischer Schemamigration. + /// Bei Nutzern, die bereits Daten haben, ergänzt SwiftData das neue + /// Feld sourceRaw = nil für alle vorhandenen Momente – kein Datenverlust. + static func makeMainContainerWithMigration() -> ModelContainer { + let schema = Schema([nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self]) + let icloudEnabled = UserDefaults.standard.bool(forKey: icloudSyncKey) + let cloudKit: ModelConfiguration.CloudKitDatabase = icloudEnabled ? .automatic : .none + + // Versuch 1: gewünschte Konfiguration mit Migrationsplan + let config = ModelConfiguration(schema: schema, cloudKitDatabase: cloudKit) + if let container = try? ModelContainer( + for: schema, + migrationPlan: NahbarMigrationPlan.self, + configurations: [config] + ) { return container } + + // Versuch 2: lokal ohne CloudKit mit Migrationsplan + let localConfig = ModelConfiguration(schema: schema, cloudKitDatabase: .none) + if let container = try? ModelContainer( + for: schema, + migrationPlan: NahbarMigrationPlan.self, + configurations: [localConfig] + ) { return container } + + // Letzter Ausweg: nur im Speicher (sollte nie eintreten) + return try! ModelContainer(for: schema, configurations: [ModelConfiguration(isStoredInMemoryOnly: true)]) + } +} diff --git a/nahbar/nahbar/PersonDetailView.swift b/nahbar/nahbar/PersonDetailView.swift index 9801e6a..93d7814 100644 --- a/nahbar/nahbar/PersonDetailView.swift +++ b/nahbar/nahbar/PersonDetailView.swift @@ -1,10 +1,12 @@ import SwiftUI import SwiftData +import CoreData import UserNotifications struct PersonDetailView: View { @Environment(\.nahbarTheme) var theme @Environment(\.modelContext) var modelContext + @Environment(\.dismiss) var dismiss @Bindable var person: Person @State private var showingAddMoment = false @@ -51,6 +53,16 @@ struct PersonDetailView: View { .onAppear { nextStepText = person.nextStep ?? "" } + // Schützt vor Crash wenn der ModelContext durch Migration oder + // CloudKit-Sync intern reset() aufruft und Person-Objekte ungültig werden. + .onReceive( + NotificationCenter.default.publisher( + for: Notification.Name("NSManagedObjectContextObjectsDidChangeNotification") + ) + ) { notification in + guard notification.userInfo?[NSInvalidatedAllObjectsKey] != nil else { return } + dismiss() + } } // MARK: - Header @@ -412,11 +424,26 @@ struct MomentRowView: View { var body: some View { HStack(alignment: .top, spacing: 12) { - Image(systemName: moment.type.icon) - .font(.system(size: 13, weight: .light)) - .foregroundStyle(theme.contentTertiary) - .frame(width: 18) - .padding(.top, 2) + // Type icon with optional source badge overlay + ZStack(alignment: .bottomTrailing) { + Image(systemName: moment.type.icon) + .font(.system(size: 13, weight: .light)) + .foregroundStyle(theme.contentTertiary) + .frame(width: 18) + .padding(.top, 2) + + if let source = moment.source { + Image(systemName: source.icon) + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.white) + .padding(2) + .background(sourceColor(source)) + .clipShape(Circle()) + .offset(x: 5, y: 4) + } + } + .frame(width: 18) + .padding(.top, 2) VStack(alignment: .leading, spacing: 4) { Text(moment.text) @@ -424,9 +451,20 @@ struct MomentRowView: View { .foregroundStyle(theme.contentPrimary) .fixedSize(horizontal: false, vertical: true) - Text(moment.createdAt, format: .dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE"))) - .font(.system(size: 12)) - .foregroundStyle(theme.contentTertiary) + HStack(spacing: 6) { + Text(moment.createdAt, format: .dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE"))) + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + + if let source = moment.source { + Text("·") + .font(.system(size: 12)) + .foregroundStyle(theme.contentTertiary) + Text(source.rawValue) + .font(.system(size: 12)) + .foregroundStyle(sourceColor(source).opacity(0.8)) + } + } } Spacer() @@ -434,6 +472,16 @@ struct MomentRowView: View { .padding(.horizontal, 16) .padding(.vertical, 12) } + + private func sourceColor(_ source: MomentSource) -> Color { + switch source { + case .whatsapp: return Color(red: 0.15, green: 0.80, blue: 0.33) + case .imessage: return Color(red: 0.0, green: 0.48, blue: 1.0) + case .telegram: return Color(red: 0.17, green: 0.67, blue: 0.94) + case .signal: return Color(red: 0.23, green: 0.47, blue: 0.95) + case .other: return Color.gray + } + } } // MARK: - Info Row diff --git a/nahbar/nahbarShareExtension/ShareExtensionView.swift b/nahbar/nahbarShareExtension/ShareExtensionView.swift index b18600c..8d4baca 100644 --- a/nahbar/nahbarShareExtension/ShareExtensionView.swift +++ b/nahbar/nahbarShareExtension/ShareExtensionView.swift @@ -13,6 +13,7 @@ struct ShareExtensionView: View { @State private var text: String @State private var momentType: MomentType = .conversation + @State private var momentSource: MomentSource = .other @State private var people: [CachedPerson] = [] @State private var selectedPerson: CachedPerson? @State private var searchText = "" @@ -50,6 +51,15 @@ struct ShareExtensionView: View { .labelsHidden() } + Section("Herkunft") { + Picker("Messenger", selection: $momentSource) { + ForEach(MomentSource.allCases, id: \.self) { source in + Label(source.rawValue, systemImage: source.icon).tag(source) + } + } + .pickerStyle(.menu) + } + Section("Kontakt") { if people.isEmpty { Text("Keine Kontakte gefunden. Öffne nahbar einmal, damit die Kontakte hier erscheinen.") @@ -132,7 +142,7 @@ struct ShareExtensionView: View { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } isSaving = true - AppGroup.enqueueMoment(personName: person.name, text: trimmed, type: momentType.rawValue) + AppGroup.enqueueMoment(personName: person.name, text: trimmed, type: momentType.rawValue, source: momentSource.rawValue) onDismiss() } }