Share aus Chat
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -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 */,
|
||||
|
||||
BIN
Binary file not shown.
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user