Share aus Chat

This commit is contained in:
2026-04-17 20:59:33 +02:00
parent c359caab72
commit 0b35403096
11 changed files with 232 additions and 15 deletions
BIN
View File
Binary file not shown.
+4
View File
@@ -7,6 +7,7 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; 26BB85B92F9248BD00889312 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85B82F9248BD00889312 /* SplashView.swift */; };
26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85BA2F924D9B00889312 /* StoreManager.swift */; }; 26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85BA2F924D9B00889312 /* StoreManager.swift */; };
26BB85BD2F924DB100889312 /* PaywallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85BC2F924DB100889312 /* PaywallView.swift */; }; 26BB85BD2F924DB100889312 /* PaywallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85BC2F924DB100889312 /* PaywallView.swift */; };
@@ -66,6 +67,7 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
265F92202F9109B500CE0A5C /* nahbar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nahbar.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; };
26BB85B82F9248BD00889312 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; }; 26BB85B82F9248BD00889312 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
26BB85BA2F924D9B00889312 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = "<group>"; }; 26BB85BA2F924D9B00889312 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = "<group>"; };
26BB85BC2F924DB100889312 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = "<group>"; }; 26BB85BC2F924DB100889312 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = "<group>"; };
@@ -187,6 +189,7 @@
26BB85C02F92525200889312 /* AIAnalysisService.swift */, 26BB85C02F92525200889312 /* AIAnalysisService.swift */,
26BB85C22F92586600889312 /* AIConfiguration.json */, 26BB85C22F92586600889312 /* AIConfiguration.json */,
26BB85C42F926A1C00889312 /* AppGroup.swift */, 26BB85C42F926A1C00889312 /* AppGroup.swift */,
269ECE652F92B5C700444B14 /* NahbarMigration.swift */,
); );
path = nahbar; path = nahbar;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -300,6 +303,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */,
26EF66322F9112E700824F91 /* Models.swift in Sources */, 26EF66322F9112E700824F91 /* Models.swift in Sources */,
26EF66332F9112E700824F91 /* TodayView.swift in Sources */, 26EF66332F9112E700824F91 /* TodayView.swift in Sources */,
26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */, 26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */,
+9 -3
View File
@@ -36,10 +36,16 @@ enum AppGroup {
/// Speichert eine Nachricht als ausstehenden Moment in der App Group. /// Speichert eine Nachricht als ausstehenden Moment in der App Group.
/// Die Hauptapp importiert diese beim nächsten Start. /// 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 var queue = pendingMoments
queue.append(["personName": personName, "text": text, "type": type, var entry: [String: String] = [
"createdAt": ISO8601DateFormatter().string(from: Date())]) "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) { if let data = try? JSONSerialization.data(withJSONObject: queue) {
userDefaults.set(data, forKey: "pendingMoments") userDefaults.set(data, forKey: "pendingMoments")
} }
+2 -1
View File
@@ -106,8 +106,9 @@ struct ContentView: View {
let text = entry["text"], let text = entry["text"],
let typeRaw = entry["type"] else { continue } let typeRaw = entry["type"] else { continue }
let type_ = MomentType(rawValue: typeRaw) ?? .conversation let type_ = MomentType(rawValue: typeRaw) ?? .conversation
let source_ = entry["source"].flatMap { MomentSource(rawValue: $0) }
if let person = persons.first(where: { $0.name == name }) { 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) modelContext.insert(moment)
person.moments?.append(moment) person.moments?.append(moment)
} }
+10
View File
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import CoreData
// MARK: - AI Analysis State // MARK: - AI Analysis State
@@ -60,6 +61,7 @@ private enum LogbuchItem: Identifiable {
struct LogbuchView: View { struct LogbuchView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@Environment(\.dismiss) var dismiss
@StateObject private var store = StoreManager.shared @StateObject private var store = StoreManager.shared
let person: Person let person: Person
@@ -92,6 +94,14 @@ struct LogbuchView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.themedNavBar() .themedNavBar()
.sheet(isPresented: $showPaywall) { PaywallView() } .sheet(isPresented: $showPaywall) { PaywallView() }
.onReceive(
NotificationCenter.default.publisher(
for: Notification.Name("NSManagedObjectContextObjectsDidChangeNotification")
)
) { notification in
guard notification.userInfo?[NSInvalidatedAllObjectsKey] != nil else { return }
dismiss()
}
.onAppear { .onAppear {
if let cached = AIAnalysisService.shared.loadCached(for: person) { if let cached = AIAnalysisService.shared.loadCached(for: person) {
analysisState = .result(cached.asResult, cached.analyzedAt) analysisState = .result(cached.asResult, cached.analyzedAt)
+26 -1
View File
@@ -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 // MARK: - Person
@Model @Model
@@ -218,13 +236,15 @@ class Moment {
var id: UUID = UUID() var id: UUID = UUID()
var text: String = "" var text: String = ""
var typeRaw: String = MomentType.conversation.rawValue var typeRaw: String = MomentType.conversation.rawValue
var sourceRaw: String? = nil
var createdAt: Date = 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, source: MomentSource? = nil, person: Person? = nil) {
self.id = UUID() self.id = UUID()
self.text = text self.text = text
self.typeRaw = type.rawValue self.typeRaw = type.rawValue
self.sourceRaw = source?.rawValue
self.createdAt = Date() self.createdAt = Date()
self.person = person self.person = person
} }
@@ -233,4 +253,9 @@ class Moment {
get { MomentType(rawValue: typeRaw) ?? .conversation } get { MomentType(rawValue: typeRaw) ?? .conversation }
set { typeRaw = newValue.rawValue } set { typeRaw = newValue.rawValue }
} }
var source: MomentSource? {
get { sourceRaw.flatMap { MomentSource(rawValue: $0) } }
set { sourceRaw = newValue?.rawValue }
}
} }
+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(AppGroup.makeMainContainer()) .modelContainer(AppGroup.makeMainContainerWithMigration())
.onChange(of: scenePhase) { _, phase in .onChange(of: scenePhase) { _, phase in
if phase == .background { if phase == .background {
appLockManager.lockIfEnabled() appLockManager.lockIfEnabled()
+113
View File
@@ -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)])
}
}
+56 -8
View File
@@ -1,10 +1,12 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import CoreData
import UserNotifications import UserNotifications
struct PersonDetailView: View { struct PersonDetailView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) var modelContext @Environment(\.modelContext) var modelContext
@Environment(\.dismiss) var dismiss
@Bindable var person: Person @Bindable var person: Person
@State private var showingAddMoment = false @State private var showingAddMoment = false
@@ -51,6 +53,16 @@ struct PersonDetailView: View {
.onAppear { .onAppear {
nextStepText = person.nextStep ?? "" 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 // MARK: - Header
@@ -412,11 +424,26 @@ struct MomentRowView: View {
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Image(systemName: moment.type.icon) // Type icon with optional source badge overlay
.font(.system(size: 13, weight: .light)) ZStack(alignment: .bottomTrailing) {
.foregroundStyle(theme.contentTertiary) Image(systemName: moment.type.icon)
.frame(width: 18) .font(.system(size: 13, weight: .light))
.padding(.top, 2) .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) { VStack(alignment: .leading, spacing: 4) {
Text(moment.text) Text(moment.text)
@@ -424,9 +451,20 @@ struct MomentRowView: View {
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Text(moment.createdAt, format: .dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE"))) HStack(spacing: 6) {
.font(.system(size: 12)) Text(moment.createdAt, format: .dateTime.day().month(.abbreviated).year().locale(Locale(identifier: "de_DE")))
.foregroundStyle(theme.contentTertiary) .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() Spacer()
@@ -434,6 +472,16 @@ struct MomentRowView: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .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 // MARK: - Info Row
@@ -13,6 +13,7 @@ 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 momentSource: MomentSource = .other
@State private var people: [CachedPerson] = [] @State private var people: [CachedPerson] = []
@State private var selectedPerson: CachedPerson? @State private var selectedPerson: CachedPerson?
@State private var searchText = "" @State private var searchText = ""
@@ -50,6 +51,15 @@ struct ShareExtensionView: View {
.labelsHidden() .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") { Section("Kontakt") {
if people.isEmpty { if people.isEmpty {
Text("Keine Kontakte gefunden. Öffne nahbar einmal, damit die Kontakte hier erscheinen.") 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) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return } guard !trimmed.isEmpty else { return }
isSaving = true 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() onDismiss()
} }
} }