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 = {
/* 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 = "<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>"; };
26BB85BC2F924DB100889312 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = "<group>"; };
@@ -187,6 +189,7 @@
26BB85C02F92525200889312 /* AIAnalysisService.swift */,
26BB85C22F92586600889312 /* AIConfiguration.json */,
26BB85C42F926A1C00889312 /* AppGroup.swift */,
269ECE652F92B5C700444B14 /* NahbarMigration.swift */,
);
path = nahbar;
sourceTree = "<group>";
@@ -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 */,
+9 -3
View File
@@ -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")
}
+2 -1
View File
@@ -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)
}
+10
View File
@@ -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)
+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
@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 }
}
}
+1 -1
View File
@@ -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()
+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)])
}
}
+48
View File
@@ -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,21 +424,47 @@ struct MomentRowView: View {
var body: some View {
HStack(alignment: .top, spacing: 12) {
// 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)
.font(.system(size: 15, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
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
@@ -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()
}
}