Issues 1–7: Kalender, Swipe, Momente, Onboarding, Herkunft, Planung
1. Kalender-Einstellungen: Picker für Standard-Kalender in Settings 2. Swipe-Gesten: Sensitivität reduziert (minimumDistance 50, Snap ×1.5) 3. Momente: Gespräch → Gespräch/Chat; Unternehmung aus Picker entfernt (Enum-Case bleibt für Rückwärtskompatibilität) 4. Onboarding: maximal 3 Kontakte auswählbar 5. Herkunft: kultureller Hintergrund auf Personenprofil + AI-Prompt 6. Planung: Erinnerung und Kalendereintrag unabhängig für alle Typen außer Gedanke; kein Survey bei zukünftigem Treffen 7. Heute-Ansicht: Erinnerungen für Treffen und Gespräch erscheinen in "Anstehende Erinnerungen"; Platzhalter passt sich Planungsstatus an Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,73 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Payload Sanitizer
|
||||||
|
|
||||||
|
/// Bereinigt freie Texte vor dem Versand an den KI-Dienst.
|
||||||
|
/// Erkannte PII-Muster werden durch neutrale Platzhalter ersetzt,
|
||||||
|
/// damit keine eindeutig identifizierende Information das Gerät verlässt.
|
||||||
|
/// Vornamen sind bewusst erlaubt – Nachnamen und andere eindeutige
|
||||||
|
/// Identifikatoren sollen vom Nutzer gar nicht erst eingetragen werden.
|
||||||
|
enum AIPayloadSanitizer {
|
||||||
|
|
||||||
|
/// Wendet alle Anonymisierungsregeln der Reihe nach auf einen Text an.
|
||||||
|
static func sanitize(_ text: String) -> String {
|
||||||
|
var s = text
|
||||||
|
s = maskEmails(s)
|
||||||
|
s = maskURLs(s)
|
||||||
|
s = maskIBANs(s)
|
||||||
|
s = maskPhoneNumbers(s)
|
||||||
|
s = maskLongDigitSequences(s)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// E-Mail-Adressen: name@domain.tld
|
||||||
|
private static func maskEmails(_ text: String) -> String {
|
||||||
|
apply("[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}", replacement: "[E-Mail]", to: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// URLs: https://... oder www....
|
||||||
|
private static func maskURLs(_ text: String) -> String {
|
||||||
|
apply("https?://\\S+|www\\.\\S+", replacement: "[Link]", to: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IBANs: DE89 3704 0044 ... (2 Buchstaben + 2 Ziffern + mind. 11 alphanumerische Zeichen)
|
||||||
|
private static func maskIBANs(_ text: String) -> String {
|
||||||
|
apply("\\b[A-Z]{2}\\d{2}[A-Z0-9]{4}[\\dA-Z\\s]{4,28}\\b", replacement: "[IBAN]", to: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telefonnummern:
|
||||||
|
// - International: +49 30 12345678, +1-555-123-4567
|
||||||
|
// - Deutsche Mobil: 0151 12345678 (10–11 Ziffern am Stück)
|
||||||
|
// - Deutsche Festnetz mit Trenner: 030 12345678, 089/12345, 030-12345
|
||||||
|
private static func maskPhoneNumbers(_ text: String) -> String {
|
||||||
|
var s = text
|
||||||
|
// Internationale Rufnummern mit +
|
||||||
|
s = apply("\\+\\d{1,3}[\\s\\-]?[\\d\\s\\-\\.\\(\\)\\/]{7,20}", replacement: "[Telefon]", to: s)
|
||||||
|
// Deutsche Mobilnummern (z.B. 0151, 0160, 0170): 10–11 Ziffern ohne Trenner
|
||||||
|
s = apply("\\b0\\d{9,10}\\b", replacement: "[Telefon]", to: s)
|
||||||
|
// Deutsche Festnetznummern mit Trenner (Leerzeichen, Slash oder Bindestrich)
|
||||||
|
s = apply("\\b0\\d{2,4}[\\s\\/\\-]\\d{3,8}\\b", replacement: "[Telefon]", to: s)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lange Zahlenfolgen (≥ 8 Ziffern am Stück): Ausweisnummern, Kontonummern, etc.
|
||||||
|
private static func maskLongDigitSequences(_ text: String) -> String {
|
||||||
|
apply("\\b\\d{8,}\\b", replacement: "[Nummer]", to: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func apply(_ pattern: String, replacement: String, to text: String) -> String {
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return regex.stringByReplacingMatches(
|
||||||
|
in: text,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(text.startIndex..., in: text),
|
||||||
|
withTemplate: replacement
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
|
|
||||||
struct AIConfig: Decodable {
|
struct AIConfig: Decodable {
|
||||||
@@ -225,21 +293,23 @@ class AIAnalysisService {
|
|||||||
formatter.locale = Locale.current
|
formatter.locale = Locale.current
|
||||||
|
|
||||||
let momentLines = person.sortedMoments.prefix(30).map {
|
let momentLines = person.sortedMoments.prefix(30).map {
|
||||||
"- \(formatter.string(from: $0.createdAt)) [\($0.type.rawValue)]: \($0.text)"
|
"- \(formatter.string(from: $0.createdAt)) [\($0.type.rawValue)]: \(AIPayloadSanitizer.sanitize($0.text))"
|
||||||
}.joined(separator: "\n")
|
}.joined(separator: "\n")
|
||||||
|
|
||||||
let logLines = person.sortedLogEntries.prefix(20).map {
|
let logLines = person.sortedLogEntries.prefix(20).map {
|
||||||
"- \(formatter.string(from: $0.loggedAt)) [\($0.type.rawValue)]: \($0.title)"
|
"- \(formatter.string(from: $0.loggedAt)) [\($0.type.rawValue)]: \(AIPayloadSanitizer.sanitize($0.title))"
|
||||||
}.joined(separator: "\n")
|
}.joined(separator: "\n")
|
||||||
|
|
||||||
let moments = momentLines.isEmpty ? "" : "\(lang.momentsLabel) (\(person.sortedMoments.count)):\n\(momentLines)\n"
|
let moments = momentLines.isEmpty ? "" : "\(lang.momentsLabel) (\(person.sortedMoments.count)):\n\(momentLines)\n"
|
||||||
let logEntries = logLines.isEmpty ? "" : "\(lang.logEntriesLabel) (\(person.sortedLogEntries.count)):\n\(logLines)\n"
|
let logEntries = logLines.isEmpty ? "" : "\(lang.logEntriesLabel) (\(person.sortedLogEntries.count)):\n\(logLines)\n"
|
||||||
let interests = person.interests.map { "\(lang.interestsLabel): \($0)\n" } ?? ""
|
let interests = person.interests.map { "\(lang.interestsLabel): \(AIPayloadSanitizer.sanitize($0))\n" } ?? ""
|
||||||
|
let culturalBg = person.culturalBackground.map { "\(lang.culturalBackgroundLabel): \($0)\n" } ?? ""
|
||||||
let instruction = isGift ? lang.giftInstruction : lang.analysisInstruction
|
let instruction = isGift ? lang.giftInstruction : lang.analysisInstruction
|
||||||
|
|
||||||
return "Person: \(person.firstName)\n"
|
return "Person: \(person.firstName)\n"
|
||||||
+ birthYearContext(for: person, language: lang)
|
+ birthYearContext(for: person, language: lang)
|
||||||
+ interests
|
+ interests
|
||||||
|
+ culturalBg
|
||||||
+ "\n"
|
+ "\n"
|
||||||
+ moments
|
+ moments
|
||||||
+ "\n"
|
+ "\n"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ struct AddMomentView: View {
|
|||||||
return cal.date(bySettingHour: hour + 1, minute: 0, second: 0, of: Date()) ?? Date()
|
return cal.date(bySettingHour: hour + 1, minute: 0, second: 0, of: Date()) ?? Date()
|
||||||
}()
|
}()
|
||||||
@State private var eventDuration: Double = 3600 // Sekunden; -1 = Ganztag
|
@State private var eventDuration: Double = 3600 // Sekunden; -1 = Ganztag
|
||||||
|
@AppStorage("selectedCalendarID") private var defaultCalendarID: String = ""
|
||||||
@State private var availableCalendars: [EKCalendar] = []
|
@State private var availableCalendars: [EKCalendar] = []
|
||||||
@State private var selectedCalendarID: String = ""
|
@State private var selectedCalendarID: String = ""
|
||||||
@State private var eventAlarmOffset: Double = -3600 // Sekunden; 0 = keine Erinnerung
|
@State private var eventAlarmOffset: Double = -3600 // Sekunden; 0 = keine Erinnerung
|
||||||
@@ -38,15 +39,30 @@ struct AddMomentView: View {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
private var isValid: Bool { !text.trimmingCharacters(in: .whitespaces).isEmpty }
|
private var isValid: Bool { !text.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
private var showsCalendarSection: Bool { selectedType == .meeting }
|
private var showsCalendarSection: Bool { selectedType != .thought }
|
||||||
private var showsReminderSection: Bool { selectedType == .intention }
|
private var showsReminderSection: Bool { selectedType != .thought }
|
||||||
|
|
||||||
|
/// Treffen liegt in der Zukunft → kein Rating-Flow nach dem Speichern.
|
||||||
|
private var isFutureMeeting: Bool {
|
||||||
|
if addToCalendar { return eventDate > Date() }
|
||||||
|
if addReminder { return reminderDate > Date() }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private var placeholder: String {
|
private var placeholder: String {
|
||||||
switch selectedType {
|
switch selectedType {
|
||||||
case .meeting: return "Wo habt ihr euch getroffen?\nWas habt ihr unternommen?"
|
case .meeting:
|
||||||
case .intention: return "Was möchtest du mit dieser Person machen?"
|
return isFutureMeeting
|
||||||
case .thought: return "Welcher Gedanke kam dir in den Sinn?"
|
? "Wo trefft ihr euch?\nWas habt ihr vor?"
|
||||||
case .conversation: return "Was war der Kern des Gesprächs?\nWas möchtest du nicht vergessen?"
|
: "Wo habt ihr euch getroffen?\nWas habt ihr unternommen?"
|
||||||
|
case .thought:
|
||||||
|
return "Welcher Gedanke kam dir in den Sinn?"
|
||||||
|
case .intention: // legacy
|
||||||
|
return "Was möchtest du mit dieser Person machen?"
|
||||||
|
default: // .conversation
|
||||||
|
return isFutureMeeting
|
||||||
|
? "Worüber wollt ihr reden?\nWas möchtest du ansprechen?"
|
||||||
|
: "Was war der Kern des Gesprächs?\nWas möchtest du nicht vergessen?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +280,12 @@ struct AddMomentView: View {
|
|||||||
availableCalendars = calendars
|
availableCalendars = calendars
|
||||||
// Vorauswahl: gespeicherter Kalender oder der Standard
|
// Vorauswahl: gespeicherter Kalender oder der Standard
|
||||||
if selectedCalendarID.isEmpty || !calendars.map(\.calendarIdentifier).contains(selectedCalendarID) {
|
if selectedCalendarID.isEmpty || !calendars.map(\.calendarIdentifier).contains(selectedCalendarID) {
|
||||||
selectedCalendarID = CalendarManager.shared.defaultCalendarIdentifier ?? ""
|
let globalDefault = defaultCalendarID
|
||||||
|
if !globalDefault.isEmpty && calendars.map(\.calendarIdentifier).contains(globalDefault) {
|
||||||
|
selectedCalendarID = globalDefault
|
||||||
|
} else {
|
||||||
|
selectedCalendarID = CalendarManager.shared.defaultCalendarIdentifier ?? ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,12 +341,27 @@ struct AddMomentView: View {
|
|||||||
person.moments?.append(moment)
|
person.moments?.append(moment)
|
||||||
person.touch()
|
person.touch()
|
||||||
|
|
||||||
// Vorhaben: Erinnerung speichern + Notification planen
|
// Erinnerung (lokale Notification) – unabhängig vom Kalendereintrag
|
||||||
if selectedType == .intention && addReminder {
|
if addReminder {
|
||||||
moment.reminderDate = reminderDate
|
moment.reminderDate = reminderDate
|
||||||
scheduleIntentionReminder(for: moment)
|
scheduleIntentionReminder(for: moment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kalendereintrag – für alle planbaren Typen, unabhängig von der Erinnerung
|
||||||
|
if addToCalendar {
|
||||||
|
moment.createdAt = eventDate
|
||||||
|
let dateStr = eventDate.formatted(.dateTime.day().month(.abbreviated).hour().minute())
|
||||||
|
let calEntry = LogEntry(
|
||||||
|
type: .calendarEvent,
|
||||||
|
title: calendarLogTitle(date: dateStr),
|
||||||
|
person: person
|
||||||
|
)
|
||||||
|
modelContext.insert(calEntry)
|
||||||
|
person.logEntries?.append(calEntry)
|
||||||
|
let momentID = moment.id
|
||||||
|
Task { await createAndStoreCalendarEvent(for: momentID, notes: trimmed) }
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try modelContext.save()
|
try modelContext.save()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -335,25 +371,8 @@ struct AddMomentView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Treffen: Callback für Rating-Flow + evtl. Kalendertermin
|
// Treffen: Callback für Rating-Flow – nur wenn das Treffen bereits stattgefunden hat
|
||||||
if selectedType == .meeting {
|
if selectedType == .meeting && !isFutureMeeting {
|
||||||
// createdAt = Zeitpunkt des Treffens; wenn ein Zukunftstermin gewählt,
|
|
||||||
// bleibt der Moment zunächst unbewertet (kein sofortiger Rating-Flow)
|
|
||||||
if addToCalendar {
|
|
||||||
moment.createdAt = eventDate
|
|
||||||
let dateStr = eventDate.formatted(.dateTime.day().month(.abbreviated).hour().minute())
|
|
||||||
let calEntry = LogEntry(
|
|
||||||
type: .calendarEvent,
|
|
||||||
title: String.localizedStringWithFormat(String(localized: "Treffen mit %@ — %@"), person.firstName, dateStr),
|
|
||||||
person: person
|
|
||||||
)
|
|
||||||
modelContext.insert(calEntry)
|
|
||||||
person.logEntries?.append(calEntry)
|
|
||||||
let momentID = moment.id
|
|
||||||
Task {
|
|
||||||
await createAndStoreCalendarEvent(for: momentID, notes: trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dismiss()
|
dismiss()
|
||||||
onMeetingCreated?(moment)
|
onMeetingCreated?(moment)
|
||||||
return
|
return
|
||||||
@@ -362,6 +381,18 @@ struct AddMomentView: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Titel für den LogEntry-Kalender-Eintrag, abhängig vom Typ.
|
||||||
|
private func calendarLogTitle(date: String) -> String {
|
||||||
|
switch selectedType {
|
||||||
|
case .meeting:
|
||||||
|
return String.localizedStringWithFormat(String(localized: "Treffen mit %@ — %@"), person.firstName, date)
|
||||||
|
case .conversation:
|
||||||
|
return String.localizedStringWithFormat(String(localized: "Gespräch mit %@ — %@"), person.firstName, date)
|
||||||
|
default:
|
||||||
|
return String.localizedStringWithFormat(String(localized: "Termin mit %@ — %@"), person.firstName, date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Vorhaben-Erinnerung
|
// MARK: - Vorhaben-Erinnerung
|
||||||
|
|
||||||
private func scheduleIntentionReminder(for moment: Moment) {
|
private func scheduleIntentionReminder(for moment: Moment) {
|
||||||
@@ -403,7 +434,12 @@ struct AddMomentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let calendarID = selectedCalendarID.isEmpty ? nil : selectedCalendarID
|
let calendarID = selectedCalendarID.isEmpty ? nil : selectedCalendarID
|
||||||
let title = String.localizedStringWithFormat(String(localized: "Treffen mit %@"), person.firstName)
|
let title: String
|
||||||
|
switch selectedType {
|
||||||
|
case .meeting: title = String.localizedStringWithFormat(String(localized: "Treffen mit %@"), person.firstName)
|
||||||
|
case .conversation: title = String.localizedStringWithFormat(String(localized: "Gespräch mit %@"), person.firstName)
|
||||||
|
default: title = String.localizedStringWithFormat(String(localized: "Termin mit %@"), person.firstName)
|
||||||
|
}
|
||||||
let alarmOffset: TimeInterval? = eventAlarmOffset == 0 ? nil : eventAlarmOffset
|
let alarmOffset: TimeInterval? = eventAlarmOffset == 0 ? nil : eventAlarmOffset
|
||||||
|
|
||||||
if let identifier = await CalendarManager.shared.createEvent(
|
if let identifier = await CalendarManager.shared.createEvent(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct AddPersonView: View {
|
|||||||
@State private var location = ""
|
@State private var location = ""
|
||||||
@State private var interests = ""
|
@State private var interests = ""
|
||||||
@State private var generalNotes = ""
|
@State private var generalNotes = ""
|
||||||
|
@State private var culturalBackground = ""
|
||||||
@State private var hasBirthday = false
|
@State private var hasBirthday = false
|
||||||
@State private var birthday = Date()
|
@State private var birthday = Date()
|
||||||
@State private var nudgeFrequency: NudgeFrequency = .monthly
|
@State private var nudgeFrequency: NudgeFrequency = .monthly
|
||||||
@@ -85,6 +86,8 @@ struct AddPersonView: View {
|
|||||||
RowDivider()
|
RowDivider()
|
||||||
inlineField("Interessen", text: $interests)
|
inlineField("Interessen", text: $interests)
|
||||||
RowDivider()
|
RowDivider()
|
||||||
|
inlineField("Herkunft", text: $culturalBackground)
|
||||||
|
RowDivider()
|
||||||
inlineField("Notizen", text: $generalNotes)
|
inlineField("Notizen", text: $generalNotes)
|
||||||
}
|
}
|
||||||
.background(theme.surfaceCard)
|
.background(theme.surfaceCard)
|
||||||
@@ -383,6 +386,7 @@ struct AddPersonView: View {
|
|||||||
occupation = p.occupation ?? ""
|
occupation = p.occupation ?? ""
|
||||||
location = p.location ?? ""
|
location = p.location ?? ""
|
||||||
interests = p.interests ?? ""
|
interests = p.interests ?? ""
|
||||||
|
culturalBackground = p.culturalBackground ?? ""
|
||||||
generalNotes = p.generalNotes ?? ""
|
generalNotes = p.generalNotes ?? ""
|
||||||
hasBirthday = p.birthday != nil
|
hasBirthday = p.birthday != nil
|
||||||
birthday = p.birthday ?? Date()
|
birthday = p.birthday ?? Date()
|
||||||
@@ -405,6 +409,7 @@ struct AddPersonView: View {
|
|||||||
p.occupation = occupation.isEmpty ? nil : occupation
|
p.occupation = occupation.isEmpty ? nil : occupation
|
||||||
p.location = location.isEmpty ? nil : location
|
p.location = location.isEmpty ? nil : location
|
||||||
p.interests = interests.isEmpty ? nil : interests
|
p.interests = interests.isEmpty ? nil : interests
|
||||||
|
p.culturalBackground = culturalBackground.isEmpty ? nil : culturalBackground
|
||||||
p.generalNotes = generalNotes.isEmpty ? nil : generalNotes
|
p.generalNotes = generalNotes.isEmpty ? nil : generalNotes
|
||||||
p.birthday = hasBirthday ? birthday : nil
|
p.birthday = hasBirthday ? birthday : nil
|
||||||
p.nudgeFrequency = nudgeFrequency
|
p.nudgeFrequency = nudgeFrequency
|
||||||
@@ -419,6 +424,7 @@ struct AddPersonView: View {
|
|||||||
location: location.isEmpty ? nil : location,
|
location: location.isEmpty ? nil : location,
|
||||||
interests: interests.isEmpty ? nil : interests,
|
interests: interests.isEmpty ? nil : interests,
|
||||||
generalNotes: generalNotes.isEmpty ? nil : generalNotes,
|
generalNotes: generalNotes.isEmpty ? nil : generalNotes,
|
||||||
|
culturalBackground: culturalBackground.isEmpty ? nil : culturalBackground,
|
||||||
nudgeFrequency: nudgeFrequency
|
nudgeFrequency: nudgeFrequency
|
||||||
)
|
)
|
||||||
modelContext.insert(person)
|
modelContext.insert(person)
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"%lld ausgewählt" : {
|
"%lld ausgewählt" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"variations" : {
|
"variations" : {
|
||||||
@@ -217,6 +218,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"%lld von %lld — Maximum erreicht" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$lld von %2$lld — Maximum erreicht"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"%lld von %lld ausgewählt" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$lld von %2$lld ausgewählt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"%lld von %lld Kontakten – Pro für mehr" : {
|
"%lld von %lld Kontakten – Pro für mehr" : {
|
||||||
"comment" : "A text label that shows the number of contacts that can be made for free, followed by a call to action to upgrade to Pro.",
|
"comment" : "A text label that shows the number of contacts that can be made for free, followed by a call to action to upgrade to Pro.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@@ -771,8 +792,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Anstehende Erinnerungen" : {
|
||||||
|
"comment" : "TodayView – section title for all plannable moments (Treffen, Gespräch, Vorhaben) with upcoming reminder dates",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Upcoming Reminders"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Anstehende Unternehmungen" : {
|
"Anstehende Unternehmungen" : {
|
||||||
"comment" : "TodayView – section title for intention moments with reminder dates",
|
"comment" : "TodayView – legacy key, replaced by 'Anstehende Erinnerungen'",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1432,6 +1465,7 @@
|
|||||||
},
|
},
|
||||||
"Diese Funktion überträgt Informationen über die ausgewählte Person an einen externen KI-Dienst (Anthropic Claude). Die Übertragung erfolgt verschlüsselt – aber Daten verlassen dein Gerät.\n\nDu entscheidest jederzeit selbst, ob du KI-Funktionen nutzt. Ohne deine Bestätigung werden keine Daten gesendet." : {
|
"Diese Funktion überträgt Informationen über die ausgewählte Person an einen externen KI-Dienst (Anthropic Claude). Die Übertragung erfolgt verschlüsselt – aber Daten verlassen dein Gerät.\n\nDu entscheidest jederzeit selbst, ob du KI-Funktionen nutzt. Ohne deine Bestätigung werden keine Daten gesendet." : {
|
||||||
"comment" : "AIConsentSheet – body text",
|
"comment" : "AIConsentSheet – body text",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1441,6 +1475,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Diese Funktion überträgt Informationen über die ausgewählte Person an einen externen KI-Dienst (Anthropic Claude). Die Übertragung erfolgt verschlüsselt – aber Daten verlassen dein Gerät.\n\nEs werden übertragen: Vorname, Geburtsjahr (nicht das vollständige Datum), Interessen sowie gespeicherte Momente und Logbucheinträge.\n\nVor dem Versand werden alle Texte automatisch bereinigt: Telefonnummern, E-Mail-Adressen, Links, IBANs und lange Zahlenfolgen werden durch Platzhalter ersetzt.\n\nDu entscheidest jederzeit selbst, ob du KI-Funktionen nutzt. Ohne deine Bestätigung werden keine Daten gesendet." : {
|
||||||
|
"comment" : "AIConsentSheet – body text",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "This feature transfers information about the selected person to an external AI service (Anthropic Claude). The transfer is encrypted – but data leaves your device.\n\nWhat is transferred: first name, birth year (not the full date), interests, and saved moments and log entries.\n\nBefore sending, all texts are automatically cleaned: phone numbers, email addresses, links, IBANs and long digit sequences are replaced with placeholders.\n\nYou decide at any time whether to use AI features. Without your confirmation, no data is sent."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Diese Person wirklich löschen?" : {
|
"Diese Person wirklich löschen?" : {
|
||||||
"comment" : "AddPersonView – delete person confirmation title",
|
"comment" : "AddPersonView – delete person confirmation title",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2138,6 +2183,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Fangen wir an" : {
|
||||||
|
"comment" : "TodayView – empty state CTA button title",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Let's get started"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Fehler: %@" : {
|
"Fehler: %@" : {
|
||||||
"comment" : "TodayView GiftSuggestionRow – error message with description",
|
"comment" : "TodayView GiftSuggestionRow – error message with description",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2233,6 +2289,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Füge zuerst Personen im Tab „Menschen“ hinzu." : {
|
||||||
|
"comment" : "TodayPersonPickerSheet – empty state hint when no contacts exist yet",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "First add people in the “People” tab."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Fühlt sich die Beziehung gestärkt an?" : {
|
"Fühlt sich die Beziehung gestärkt an?" : {
|
||||||
"comment" : "RatingQuestion – relationship question text",
|
"comment" : "RatingQuestion – relationship question text",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -2245,6 +2312,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Für wen?" : {
|
||||||
|
"comment" : "TodayPersonPickerSheet – navigation title",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "For whom?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Ganztag" : {
|
"Ganztag" : {
|
||||||
"comment" : "AddMomentView – all-day calendar event duration option",
|
"comment" : "AddMomentView – all-day calendar event duration option",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2281,6 +2359,7 @@
|
|||||||
},
|
},
|
||||||
"Geburtstage & Termine" : {
|
"Geburtstage & Termine" : {
|
||||||
"comment" : "SettingsView – look-ahead section row label",
|
"comment" : "SettingsView – look-ahead section row label",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2390,7 +2469,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Gespräch" : {
|
"Gespräch" : {
|
||||||
"comment" : "MomentType.conversation / RatingCategory.gespraech raw value",
|
"comment" : "MomentType.conversation rawValue / RatingCategory.gespraech raw value – Persistenzschlüssel, nicht im Picker angezeigt",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -2401,6 +2480,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Gespräch mit %@" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Conversation with %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Gespräch mit %@ — %@" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Gespräch mit %1$@ — %2$@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Conversation with %1$@ — %2$@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Gespräch/Chat" : {
|
||||||
|
"comment" : "MomentType.conversation displayName – shown in type picker",
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Conversation/Chat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Gesprächseinstieg" : {
|
"Gesprächseinstieg" : {
|
||||||
"comment" : "CallSuggestionView – conversation starter section header",
|
"comment" : "CallSuggestionView – conversation starter section header",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2791,6 +2908,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Kalender-Einstellungen" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Kauf wiederherstellen" : {
|
"Kauf wiederherstellen" : {
|
||||||
"comment" : "PaywallView – restore purchases button",
|
"comment" : "PaywallView – restore purchases button",
|
||||||
@@ -3353,6 +3473,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Momente planen und hinzufügen" : {
|
||||||
|
"comment" : "TodayView – empty state CTA button subtitle",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Plan and add moments"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Momente und abgeschlossene Schritte erscheinen hier." : {
|
"Momente und abgeschlossene Schritte erscheinen hier." : {
|
||||||
"comment" : "LogbuchView – empty state subtitle",
|
"comment" : "LogbuchView – empty state subtitle",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4578,6 +4709,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Termin mit %@" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Termin mit %@ — %@" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Termin mit %1$@ — %2$@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Tief & fokussiert · ND" : {
|
"Tief & fokussiert · ND" : {
|
||||||
"comment" : "Theme tagline for Abyss",
|
"comment" : "Theme tagline for Abyss",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4823,7 +4967,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Unternehmung" : {
|
"Unternehmung" : {
|
||||||
"comment" : "MomentType.intention displayName – shown in type picker and feature tour",
|
"comment" : "MomentType.intention displayName – legacy, no longer in picker; kept for backward compat and feature tour",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -4990,6 +5134,7 @@
|
|||||||
},
|
},
|
||||||
"Vorausschau" : {
|
"Vorausschau" : {
|
||||||
"comment" : "SettingsView – section header for look-ahead settings",
|
"comment" : "SettingsView – section header for look-ahead settings",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5041,6 +5186,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Vorschau Geburtstage & Termine" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Wähle bis zu 3 Menschen aus deinem Adressbuch, die dir wichtig sind." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Wähle bis zu 3 Menschen aus, die dir wichtig sind." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Wähle deinen Plan" : {
|
"Wähle deinen Plan" : {
|
||||||
"comment" : "PaywallView – header title",
|
"comment" : "PaywallView – header title",
|
||||||
@@ -5065,6 +5219,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Wähle Menschen aus deinem Adressbuch, die dir wichtig sind." : {
|
"Wähle Menschen aus deinem Adressbuch, die dir wichtig sind." : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -5075,6 +5230,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Wähle Menschen aus, die dir wichtig sind." : {
|
"Wähle Menschen aus, die dir wichtig sind." : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|||||||
@@ -624,10 +624,11 @@ private struct DeletableLogbuchRow<Content: View>: View {
|
|||||||
.background(theme.surfaceCard)
|
.background(theme.surfaceCard)
|
||||||
.offset(x: offset)
|
.offset(x: offset)
|
||||||
.gesture(
|
.gesture(
|
||||||
DragGesture(minimumDistance: 10, coordinateSpace: .local)
|
DragGesture(minimumDistance: 50, coordinateSpace: .local)
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
let x = value.translation.width
|
let x = value.translation.width
|
||||||
guard abs(x) > abs(value.translation.height) * 0.6 else { return }
|
let y = value.translation.height
|
||||||
|
guard abs(x) > abs(y) * 2.5 else { return }
|
||||||
if x > 0 {
|
if x > 0 {
|
||||||
offset = min(x, actionWidth * 2 + 16)
|
offset = min(x, actionWidth * 2 + 16)
|
||||||
} else {
|
} else {
|
||||||
@@ -636,12 +637,17 @@ private struct DeletableLogbuchRow<Content: View>: View {
|
|||||||
}
|
}
|
||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
let x = value.translation.width
|
let x = value.translation.width
|
||||||
|
let y = value.translation.height
|
||||||
|
guard abs(x) > abs(y) * 2.5 else {
|
||||||
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
||||||
|
return
|
||||||
|
}
|
||||||
if x > actionWidth * 2 + 20 {
|
if x > actionWidth * 2 + 20 {
|
||||||
onToggleImportant()
|
onToggleImportant()
|
||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
||||||
} else if x > actionWidth / 2 {
|
} else if x > actionWidth * 1.5 {
|
||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth * 2 }
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth * 2 }
|
||||||
} else if x < -(actionWidth / 2) {
|
} else if x < -(actionWidth * 1.5) {
|
||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth }
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth }
|
||||||
} else {
|
} else {
|
||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
||||||
|
|||||||
@@ -39,17 +39,21 @@ enum NudgeFrequency: String, CaseIterable, Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MomentType: String, CaseIterable, Codable {
|
enum MomentType: String, Codable {
|
||||||
case conversation = "Gespräch"
|
case conversation = "Gespräch"
|
||||||
case meeting = "Treffen" // rawValue bleibt für Persistenz unverändert
|
case meeting = "Treffen" // rawValue bleibt für Persistenz unverändert
|
||||||
case thought = "Gedanke"
|
case thought = "Gedanke"
|
||||||
case intention = "Vorhaben"
|
case intention = "Vorhaben" // legacy — nicht mehr im Picker, aber für Persistenz erhalten
|
||||||
|
|
||||||
|
/// Typen, die im Picker angezeigt werden (.intention ist veraltet und ausgeblendet).
|
||||||
|
static var allCases: [MomentType] { [.conversation, .meeting, .thought] }
|
||||||
|
|
||||||
/// Anzeigename im UI — entkoppelt Persistenzschlüssel von der Darstellung.
|
/// Anzeigename im UI — entkoppelt Persistenzschlüssel von der Darstellung.
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .intention: return "Unternehmung"
|
case .conversation: return "Gespräch/Chat"
|
||||||
default: return rawValue
|
case .intention: return "Unternehmung" // legacy
|
||||||
|
default: return rawValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +115,7 @@ class Person {
|
|||||||
var location: String?
|
var location: String?
|
||||||
var interests: String?
|
var interests: String?
|
||||||
var generalNotes: String?
|
var generalNotes: String?
|
||||||
|
var culturalBackground: String? = nil // V6: kultureller Hintergrund
|
||||||
var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue
|
var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue
|
||||||
var nextStep: String?
|
var nextStep: String?
|
||||||
var nextStepCompleted: Bool = false
|
var nextStepCompleted: Bool = false
|
||||||
@@ -139,6 +144,7 @@ class Person {
|
|||||||
location: String? = nil,
|
location: String? = nil,
|
||||||
interests: String? = nil,
|
interests: String? = nil,
|
||||||
generalNotes: String? = nil,
|
generalNotes: String? = nil,
|
||||||
|
culturalBackground: String? = nil,
|
||||||
nudgeFrequency: NudgeFrequency = .monthly
|
nudgeFrequency: NudgeFrequency = .monthly
|
||||||
) {
|
) {
|
||||||
self.id = UUID()
|
self.id = UUID()
|
||||||
@@ -149,6 +155,7 @@ class Person {
|
|||||||
self.location = location
|
self.location = location
|
||||||
self.interests = interests
|
self.interests = interests
|
||||||
self.generalNotes = generalNotes
|
self.generalNotes = generalNotes
|
||||||
|
self.culturalBackground = culturalBackground
|
||||||
self.nudgeFrequencyRaw = nudgeFrequency.rawValue
|
self.nudgeFrequencyRaw = nudgeFrequency.rawValue
|
||||||
self.photoData = nil
|
self.photoData = nil
|
||||||
self.photo = nil
|
self.photo = nil
|
||||||
|
|||||||
@@ -289,11 +289,11 @@ enum NahbarSchemaV4: VersionedSchema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Schema V5 (aktuelles Schema)
|
// MARK: - Schema V5 (eingefrorener Snapshot)
|
||||||
// Referenziert die Live-Typen aus Models.swift.
|
// WICHTIG: Niemals nachträglich ändern – dieser Snapshot muss dem gespeicherten
|
||||||
// Beim Hinzufügen von V6 muss V5 als eingefrorener Snapshot gesichert werden.
|
// Schema-Hash von V5-Datenbanken auf Nutzer-Geräten entsprechen.
|
||||||
//
|
//
|
||||||
// V5 fügt hinzu:
|
// V5 fügte hinzu:
|
||||||
// • Moment: ratings, healthSnapshot, statusRaw, aftermathNotificationScheduled,
|
// • Moment: ratings, healthSnapshot, statusRaw, aftermathNotificationScheduled,
|
||||||
// aftermathCompletedAt, reminderDate, isCompleted
|
// aftermathCompletedAt, reminderDate, isCompleted
|
||||||
// • Rating: moment-Relationship (neben legacy visit)
|
// • Rating: moment-Relationship (neben legacy visit)
|
||||||
@@ -301,6 +301,117 @@ enum NahbarSchemaV4: VersionedSchema {
|
|||||||
|
|
||||||
enum NahbarSchemaV5: VersionedSchema {
|
enum NahbarSchemaV5: VersionedSchema {
|
||||||
static var versionIdentifier = Schema.Version(5, 0, 0)
|
static var versionIdentifier = Schema.Version(5, 0, 0)
|
||||||
|
static var models: [any PersistentModel.Type] {
|
||||||
|
[PersonPhoto.self, Person.self, Moment.self, LogEntry.self,
|
||||||
|
Visit.self, Rating.self, HealthSnapshot.self]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class PersonPhoto {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
@Attribute(.externalStorage) var imageData: Data = Data()
|
||||||
|
var createdAt: Date = Date()
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 nextStep: String? = nil
|
||||||
|
var nextStepCompleted: Bool = false
|
||||||
|
var nextStepReminderDate: Date? = nil
|
||||||
|
var lastSuggestedForCall: Date? = nil
|
||||||
|
var createdAt: Date = Date()
|
||||||
|
var updatedAt: Date = Date()
|
||||||
|
var isArchived: Bool = false
|
||||||
|
@Relationship(deleteRule: .cascade) var photo: PersonPhoto? = nil
|
||||||
|
var photoData: Data? = nil
|
||||||
|
@Relationship(deleteRule: .cascade) var moments: [Moment]? = []
|
||||||
|
@Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = []
|
||||||
|
@Relationship(deleteRule: .cascade) var visits: [Visit]? = []
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class Moment {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var text: String = ""
|
||||||
|
var typeRaw: String = "Gespräch"
|
||||||
|
var sourceRaw: String? = nil
|
||||||
|
var createdAt: Date = Date()
|
||||||
|
var updatedAt: Date = Date()
|
||||||
|
var isImportant: Bool = false
|
||||||
|
var person: Person? = nil
|
||||||
|
@Relationship(deleteRule: .cascade) var ratings: [Rating]? = []
|
||||||
|
@Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil
|
||||||
|
var statusRaw: String? = nil
|
||||||
|
var aftermathNotificationScheduled: Bool = false
|
||||||
|
var aftermathCompletedAt: Date? = nil
|
||||||
|
var reminderDate: Date? = nil
|
||||||
|
var isCompleted: Bool = false
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class LogEntry {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var typeRaw: String = "Schritt abgeschlossen"
|
||||||
|
var title: String = ""
|
||||||
|
var loggedAt: Date = Date()
|
||||||
|
var updatedAt: Date = Date()
|
||||||
|
var person: Person? = nil
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class Visit {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var visitDate: Date = Date()
|
||||||
|
var statusRaw: String = "sofort_abgeschlossen"
|
||||||
|
var note: String? = nil
|
||||||
|
var aftermathNotificationScheduled: Bool = false
|
||||||
|
var aftermathCompletedAt: Date? = nil
|
||||||
|
var person: Person? = nil
|
||||||
|
@Relationship(deleteRule: .cascade) var ratings: [Rating]? = []
|
||||||
|
@Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class Rating {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var categoryRaw: String = "Selbst"
|
||||||
|
var questionIndex: Int = 0
|
||||||
|
var value: Int? = nil
|
||||||
|
var isAftermath: Bool = false
|
||||||
|
var visit: Visit? = nil
|
||||||
|
var moment: Moment? = nil
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class HealthSnapshot {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var sleepHours: Double? = nil
|
||||||
|
var hrvMs: Double? = nil
|
||||||
|
var restingHR: Int? = nil
|
||||||
|
var steps: Int? = nil
|
||||||
|
var visit: Visit? = nil
|
||||||
|
var moment: Moment? = nil
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schema V6 (aktuelles Schema)
|
||||||
|
// Referenziert die Live-Typen aus Models.swift.
|
||||||
|
// Beim Hinzufügen von V7 muss V6 als eingefrorener Snapshot gesichert werden.
|
||||||
|
//
|
||||||
|
// V6 fügt hinzu:
|
||||||
|
// • Person: culturalBackground (optionaler Freitext für kulturellen Hintergrund)
|
||||||
|
|
||||||
|
enum NahbarSchemaV6: VersionedSchema {
|
||||||
|
static var versionIdentifier = Schema.Version(6, 0, 0)
|
||||||
static var models: [any PersistentModel.Type] {
|
static var models: [any PersistentModel.Type] {
|
||||||
[nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self,
|
[nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self,
|
||||||
nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self]
|
nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self]
|
||||||
@@ -312,7 +423,7 @@ enum NahbarSchemaV5: VersionedSchema {
|
|||||||
enum NahbarMigrationPlan: SchemaMigrationPlan {
|
enum NahbarMigrationPlan: SchemaMigrationPlan {
|
||||||
static var schemas: [any VersionedSchema.Type] {
|
static var schemas: [any VersionedSchema.Type] {
|
||||||
[NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self,
|
[NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self,
|
||||||
NahbarSchemaV4.self, NahbarSchemaV5.self]
|
NahbarSchemaV4.self, NahbarSchemaV5.self, NahbarSchemaV6.self]
|
||||||
}
|
}
|
||||||
|
|
||||||
static var stages: [MigrationStage] {
|
static var stages: [MigrationStage] {
|
||||||
@@ -332,7 +443,11 @@ enum NahbarMigrationPlan: SchemaMigrationPlan {
|
|||||||
// V4 → V5: Moment bekommt rating/intention-Felder, Rating/HealthSnapshot
|
// V4 → V5: Moment bekommt rating/intention-Felder, Rating/HealthSnapshot
|
||||||
// bekommen moment-Relationship. Visit/HealthSnapshot bleiben im Schema
|
// bekommen moment-Relationship. Visit/HealthSnapshot bleiben im Schema
|
||||||
// (CloudKit-Sicherheit). Alle neuen Felder haben Default-Werte → lightweight.
|
// (CloudKit-Sicherheit). Alle neuen Felder haben Default-Werte → lightweight.
|
||||||
.lightweight(fromVersion: NahbarSchemaV4.self, toVersion: NahbarSchemaV5.self)
|
.lightweight(fromVersion: NahbarSchemaV4.self, toVersion: NahbarSchemaV5.self),
|
||||||
|
|
||||||
|
// V5 → V6: Person bekommt culturalBackground = nil.
|
||||||
|
// Optionales Feld mit nil-Default → lightweight-Migration reicht aus.
|
||||||
|
.lightweight(fromVersion: NahbarSchemaV5.self, toVersion: NahbarSchemaV6.self)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -394,6 +394,9 @@ private struct OnboardingContactImportView: View {
|
|||||||
@State private var showingPicker = false
|
@State private var showingPicker = false
|
||||||
@State private var showSkipConfirmation: Bool = false
|
@State private var showSkipConfirmation: Bool = false
|
||||||
|
|
||||||
|
private let maxContacts = 3
|
||||||
|
private var atLimit: Bool { coordinator.selectedContacts.count >= maxContacts }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|
||||||
@@ -402,7 +405,7 @@ private struct OnboardingContactImportView: View {
|
|||||||
Text("Kontakte hinzufügen")
|
Text("Kontakte hinzufügen")
|
||||||
.font(.title.bold())
|
.font(.title.bold())
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
Text("Wähle Menschen aus, die dir wichtig sind.")
|
Text("Wähle bis zu 3 Menschen aus, die dir wichtig sind.")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -492,7 +495,7 @@ private struct OnboardingContactImportView: View {
|
|||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
Text("Noch keine Kontakte")
|
Text("Noch keine Kontakte")
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
Text("Wähle Menschen aus deinem Adressbuch, die dir wichtig sind.")
|
Text("Wähle bis zu 3 Menschen aus deinem Adressbuch, die dir wichtig sind.")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -540,13 +543,17 @@ private struct OnboardingContactImportView: View {
|
|||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
HStack {
|
HStack {
|
||||||
Text("\(coordinator.selectedContacts.count) ausgewählt")
|
Text(atLimit
|
||||||
|
? "\(coordinator.selectedContacts.count) von \(maxContacts) — Maximum erreicht"
|
||||||
|
: "\(coordinator.selectedContacts.count) von \(maxContacts) ausgewählt")
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
if !atLimit {
|
||||||
showingPicker = true
|
Button {
|
||||||
} label: {
|
showingPicker = true
|
||||||
Label("Weitere hinzufügen", systemImage: "plus")
|
} label: {
|
||||||
.font(.caption.weight(.medium))
|
Label("Weitere hinzufügen", systemImage: "plus")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -559,6 +566,7 @@ private struct OnboardingContactImportView: View {
|
|||||||
/// Merges newly picked contacts into the existing selection (no duplicates).
|
/// Merges newly picked contacts into the existing selection (no duplicates).
|
||||||
private func mergeContacts(_ contacts: [CNContact]) {
|
private func mergeContacts(_ contacts: [CNContact]) {
|
||||||
for contact in contacts {
|
for contact in contacts {
|
||||||
|
guard coordinator.selectedContacts.count < maxContacts else { break }
|
||||||
let alreadySelected = coordinator.selectedContacts
|
let alreadySelected = coordinator.selectedContacts
|
||||||
.contains { $0.cnIdentifier == contact.identifier }
|
.contains { $0.cnIdentifier == contact.identifier }
|
||||||
if !alreadySelected {
|
if !alreadySelected {
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ struct PersonDetailView: View {
|
|||||||
person.birthday != nil
|
person.birthday != nil
|
||||||
|| !(person.location ?? "").isEmpty
|
|| !(person.location ?? "").isEmpty
|
||||||
|| !(person.interests ?? "").isEmpty
|
|| !(person.interests ?? "").isEmpty
|
||||||
|
|| !(person.culturalBackground ?? "").isEmpty
|
||||||
|| !(person.generalNotes ?? "").isEmpty
|
|| !(person.generalNotes ?? "").isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +350,10 @@ struct PersonDetailView: View {
|
|||||||
InfoRowView(label: "Interessen", value: interests)
|
InfoRowView(label: "Interessen", value: interests)
|
||||||
RowDivider()
|
RowDivider()
|
||||||
}
|
}
|
||||||
|
if let bg = person.culturalBackground, !bg.isEmpty {
|
||||||
|
InfoRowView(label: "Herkunft", value: bg)
|
||||||
|
RowDivider()
|
||||||
|
}
|
||||||
if let notes = person.generalNotes, !notes.isEmpty {
|
if let notes = person.generalNotes, !notes.isEmpty {
|
||||||
InfoRowView(label: "Notizen", value: notes)
|
InfoRowView(label: "Notizen", value: notes)
|
||||||
}
|
}
|
||||||
@@ -518,7 +523,7 @@ private struct DeletableMomentRow: View {
|
|||||||
.background(theme.surfaceCard)
|
.background(theme.surfaceCard)
|
||||||
.offset(x: offset)
|
.offset(x: offset)
|
||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
DragGesture(minimumDistance: 20, coordinateSpace: .local)
|
DragGesture(minimumDistance: 50, coordinateSpace: .local)
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
let x = value.translation.width
|
let x = value.translation.width
|
||||||
let y = value.translation.height
|
let y = value.translation.height
|
||||||
@@ -542,13 +547,10 @@ private struct DeletableMomentRow: View {
|
|||||||
// Vollständiges Rechts-Wischen → Wichtig-Toggle, zurückspringen
|
// Vollständiges Rechts-Wischen → Wichtig-Toggle, zurückspringen
|
||||||
onToggleImportant()
|
onToggleImportant()
|
||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
||||||
} else if x > actionWidth {
|
} else if x > actionWidth * 1.5 {
|
||||||
// Mehr als eine Button-Breite → beide linken Buttons zeigen
|
// Bewusstes Rechts-Wischen → linke Buttons zeigen
|
||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth * 2 }
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth * 2 }
|
||||||
} else if x > actionWidth / 2 {
|
} else if x < -(actionWidth * 1.5) {
|
||||||
// Knapp eine Button-Breite → beide linken Buttons zeigen
|
|
||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth * 2 }
|
|
||||||
} else if x < -(actionWidth / 2) {
|
|
||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth }
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth }
|
||||||
} else {
|
} else {
|
||||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ struct AIConsentSheet: View {
|
|||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
Text("Diese Funktion überträgt Informationen über die ausgewählte Person an einen externen KI-Dienst (Anthropic Claude). Die Übertragung erfolgt verschlüsselt – aber Daten verlassen dein Gerät.\n\nDu entscheidest jederzeit selbst, ob du KI-Funktionen nutzt. Ohne deine Bestätigung werden keine Daten gesendet.")
|
Text("Diese Funktion überträgt Informationen über die ausgewählte Person an einen externen KI-Dienst (Anthropic Claude). Die Übertragung erfolgt verschlüsselt – aber Daten verlassen dein Gerät.\n\nEs werden übertragen: Vorname, Geburtsjahr (nicht das vollständige Datum), Interessen sowie gespeicherte Momente und Logbucheinträge.\n\nVor dem Versand werden alle Texte automatisch bereinigt: Telefonnummern, E-Mail-Adressen, Links, IBANs und lange Zahlenfolgen werden durch Platzhalter ersetzt.\n\nDu entscheidest jederzeit selbst, ob du KI-Funktionen nutzt. Ohne deine Bestätigung werden keine Daten gesendet.")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import LocalAuthentication
|
import LocalAuthentication
|
||||||
|
import EventKit
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(\.nahbarTheme) var theme
|
@Environment(\.nahbarTheme) var theme
|
||||||
@@ -10,6 +11,8 @@ struct SettingsView: View {
|
|||||||
@EnvironmentObject private var cloudSyncMonitor: CloudSyncMonitor
|
@EnvironmentObject private var cloudSyncMonitor: CloudSyncMonitor
|
||||||
|
|
||||||
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
|
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
|
||||||
|
@AppStorage("selectedCalendarID") private var defaultCalendarID: String = ""
|
||||||
|
@State private var settingsCalendars: [EKCalendar] = []
|
||||||
@AppStorage("aftermathNotificationsEnabled") private var aftermathNotificationsEnabled: Bool = true
|
@AppStorage("aftermathNotificationsEnabled") private var aftermathNotificationsEnabled: Bool = true
|
||||||
@AppStorage("aftermathDelayOption") private var aftermathDelayRaw: String = AftermathDelayOption.hours36.rawValue
|
@AppStorage("aftermathDelayOption") private var aftermathDelayRaw: String = AftermathDelayOption.hours36.rawValue
|
||||||
@AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false
|
@AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false
|
||||||
@@ -265,14 +268,14 @@ struct SettingsView: View {
|
|||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vorausschau
|
// Kalender-Einstellungen
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
SectionHeader(title: "Vorausschau", icon: "calendar")
|
SectionHeader(title: "Kalender-Einstellungen", icon: "calendar")
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Geburtstage & Termine")
|
Text("Vorschau Geburtstage & Termine")
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -286,10 +289,41 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
if settingsCalendars.count > 1 {
|
||||||
|
RowDivider()
|
||||||
|
HStack {
|
||||||
|
Text("Kalender")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: $defaultCalendarID) {
|
||||||
|
ForEach(settingsCalendars, id: \.calendarIdentifier) { cal in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "circle.fill")
|
||||||
|
.foregroundStyle(Color(cal.cgColor))
|
||||||
|
Text(cal.title)
|
||||||
|
}
|
||||||
|
.tag(cal.calendarIdentifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(theme.accent)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.background(theme.surfaceCard)
|
.background(theme.surfaceCard)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
|
.task {
|
||||||
|
guard settingsCalendars.isEmpty else { return }
|
||||||
|
let calendars = await CalendarManager.shared.availableCalendars()
|
||||||
|
settingsCalendars = calendars
|
||||||
|
if defaultCalendarID.isEmpty || !calendars.map(\.calendarIdentifier).contains(defaultCalendarID) {
|
||||||
|
defaultCalendarID = CalendarManager.shared.defaultCalendarIdentifier ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Treffen & Bewertungen
|
// Treffen & Bewertungen
|
||||||
@@ -769,10 +803,11 @@ enum AppLanguage: String, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var momentsLabel: String { self == .english ? "Moments" : "Momente" }
|
var momentsLabel: String { self == .english ? "Moments" : "Momente" }
|
||||||
var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" }
|
var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" }
|
||||||
var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" }
|
var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" }
|
||||||
var interestsLabel: String { self == .english ? "Interests" : "Interessen" }
|
var interestsLabel: String { self == .english ? "Interests" : "Interessen" }
|
||||||
|
var culturalBackgroundLabel: String { self == .english ? "Cultural background": "Kultureller Hintergrund" }
|
||||||
|
|
||||||
/// Leitet die KI-Antwortsprache aus der iOS-Systemsprache ab.
|
/// Leitet die KI-Antwortsprache aus der iOS-Systemsprache ab.
|
||||||
/// Unterstützte Sprachen: de, en – alle anderen fallen auf .german zurück.
|
/// Unterstützte Sprachen: de, en – alle anderen fallen auf .german zurück.
|
||||||
|
|||||||
+145
-17
@@ -10,6 +10,8 @@ struct TodayView: View {
|
|||||||
}, sort: \Moment.createdAt, order: .reverse)
|
}, sort: \Moment.createdAt, order: .reverse)
|
||||||
private var pendingAftermaths: [Moment]
|
private var pendingAftermaths: [Moment]
|
||||||
@State private var selectedMomentForAftermath: Moment? = nil
|
@State private var selectedMomentForAftermath: Moment? = nil
|
||||||
|
@State private var showPersonPicker = false
|
||||||
|
@State private var personForNewMoment: Person? = nil
|
||||||
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
|
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
|
||||||
|
|
||||||
private var needsAttention: [Person] {
|
private var needsAttention: [Person] {
|
||||||
@@ -34,18 +36,20 @@ struct TodayView: View {
|
|||||||
return allUnratedMeetings.filter { $0.createdAt > now && $0.createdAt <= horizon }
|
return allUnratedMeetings.filter { $0.createdAt > now && $0.createdAt <= horizon }
|
||||||
}
|
}
|
||||||
|
|
||||||
// V5: Vorhaben-Momente mit Erinnerung innerhalb des Zeitfensters
|
// Alle planbaren Momente (Treffen, Gespräch, Vorhaben) mit gesetzter Erinnerung
|
||||||
@Query(filter: #Predicate<Moment> {
|
@Query(filter: #Predicate<Moment> {
|
||||||
$0.typeRaw == "Vorhaben" && !$0.isCompleted && $0.reminderDate != nil
|
$0.typeRaw != "Gedanke" && !$0.isCompleted && $0.reminderDate != nil
|
||||||
}, sort: \Moment.reminderDate)
|
}, sort: \Moment.reminderDate)
|
||||||
private var allIntentionsWithReminder: [Moment]
|
private var allWithReminder: [Moment]
|
||||||
|
|
||||||
private var upcomingReminders: [Moment] {
|
private var upcomingReminders: [Moment] {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let horizon = Calendar.current.date(byAdding: .day, value: daysAhead, to: now) ?? now
|
let horizon = Calendar.current.date(byAdding: .day, value: daysAhead, to: now) ?? now
|
||||||
return allIntentionsWithReminder.filter { m in
|
// Momente, die bereits über das Kalenderzeiger in plannedMeetings erscheinen, ausblenden
|
||||||
|
let plannedIDs = Set(plannedMeetings.map { $0.id })
|
||||||
|
return allWithReminder.filter { m in
|
||||||
guard let r = m.reminderDate else { return false }
|
guard let r = m.reminderDate else { return false }
|
||||||
return r >= now && r <= horizon
|
return r >= now && r <= horizon && !plannedIDs.contains(m.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +64,10 @@ struct TodayView: View {
|
|||||||
&& upcomingReminders.isEmpty && pendingAftermaths.isEmpty && plannedMeetings.isEmpty
|
&& upcomingReminders.isEmpty && pendingAftermaths.isEmpty && plannedMeetings.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var activePeople: [Person] {
|
||||||
|
people.filter { !$0.isArchived }.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
|
||||||
private var birthdaySectionTitle: LocalizedStringKey {
|
private var birthdaySectionTitle: LocalizedStringKey {
|
||||||
switch daysAhead {
|
switch daysAhead {
|
||||||
case 3: return "In 3 Tagen"
|
case 3: return "In 3 Tagen"
|
||||||
@@ -135,11 +143,11 @@ struct TodayView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !upcomingReminders.isEmpty {
|
if !upcomingReminders.isEmpty {
|
||||||
TodaySection(title: "Anstehende Unternehmungen", icon: "calendar") {
|
TodaySection(title: "Anstehende Erinnerungen", icon: "bell") {
|
||||||
ForEach(upcomingReminders) { moment in
|
ForEach(upcomingReminders) { moment in
|
||||||
if let person = moment.person {
|
if let person = moment.person {
|
||||||
NavigationLink(destination: PersonDetailView(person: person)) {
|
NavigationLink(destination: PersonDetailView(person: person)) {
|
||||||
TodayRow(person: person, hint: intentionReminderHint(for: moment))
|
TodayRow(person: person, hint: reminderHint(for: moment))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -223,20 +231,58 @@ struct TodayView: View {
|
|||||||
.sheet(item: $selectedMomentForAftermath) { moment in
|
.sheet(item: $selectedMomentForAftermath) { moment in
|
||||||
AftermathRatingFlowView(moment: moment)
|
AftermathRatingFlowView(moment: moment)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showPersonPicker) {
|
||||||
|
TodayPersonPickerSheet(people: activePeople) { person in
|
||||||
|
personForNewMoment = person
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $personForNewMoment) { person in
|
||||||
|
AddMomentView(person: person)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Empty State
|
// MARK: - Empty State
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 24) {
|
||||||
Spacer().frame(height: 48)
|
Spacer().frame(height: 32)
|
||||||
Text("Ein ruhiger Tag.")
|
|
||||||
.font(.system(size: 20, weight: .light, design: theme.displayDesign))
|
VStack(spacing: 6) {
|
||||||
.foregroundStyle(theme.contentPrimary)
|
Text("Ein ruhiger Tag.")
|
||||||
Text("Oder einer, der es noch wird.")
|
.font(.system(size: 20, weight: .light, design: theme.displayDesign))
|
||||||
.font(.system(size: 15))
|
.foregroundStyle(theme.contentPrimary)
|
||||||
.foregroundStyle(theme.contentTertiary)
|
Text("Oder einer, der es noch wird.")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showPersonPicker = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.system(size: 24))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Fangen wir an")
|
||||||
|
.font(.system(size: 16, weight: .semibold, design: theme.displayDesign))
|
||||||
|
Text("Momente planen und hinzufügen")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.opacity(0.8)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.opacity(0.6)
|
||||||
|
}
|
||||||
|
.foregroundStyle(theme.backgroundPrimary)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.background(theme.accent)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
@@ -265,10 +311,11 @@ struct TodayView: View {
|
|||||||
return moment.text.isEmpty ? dateStr : "\(moment.text) · \(dateStr)"
|
return moment.text.isEmpty ? dateStr : "\(moment.text) · \(dateStr)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func intentionReminderHint(for moment: Moment) -> String {
|
private func reminderHint(for moment: Moment) -> String {
|
||||||
guard let reminder = moment.reminderDate else { return moment.text }
|
guard let reminder = moment.reminderDate else { return moment.text }
|
||||||
let dateStr = reminder.formatted(.dateTime.day().month(.abbreviated).hour().minute())
|
let dateStr = reminder.formatted(.dateTime.day().month(.abbreviated).hour().minute())
|
||||||
return "\(moment.text) · \(dateStr)"
|
let label = moment.text.isEmpty ? moment.type.displayName : moment.text
|
||||||
|
return "\(label) · \(dateStr)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func lastSeenHint(for person: Person) -> String {
|
private func lastSeenHint(for person: Person) -> String {
|
||||||
@@ -480,6 +527,87 @@ struct GiftSuggestionRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Person Picker Sheet (für Quick-Moment-Erfassung)
|
||||||
|
|
||||||
|
private struct TodayPersonPickerSheet: View {
|
||||||
|
@Environment(\.nahbarTheme) var theme
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
let people: [Person]
|
||||||
|
let onSelect: (Person) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if people.isEmpty {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "person.2")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
Text("Noch keine Kontakte")
|
||||||
|
.font(.system(size: 18, weight: .light, design: theme.displayDesign))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
Text("Füge zuerst Personen im Tab \u{201E}Menschen\u{201C} hinzu.")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(people) { person in
|
||||||
|
Button {
|
||||||
|
onSelect(person)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
PersonAvatar(person: person, size: 40)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(person.name)
|
||||||
|
.font(.system(size: 15, weight: .medium, design: theme.displayDesign))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
if !person.tag.rawValue.isEmpty {
|
||||||
|
Text(person.tag.rawValue)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
if person.id != people.last?.id {
|
||||||
|
RowDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(theme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
.background(theme.backgroundPrimary.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(theme.backgroundPrimary.ignoresSafeArea())
|
||||||
|
.navigationTitle("Für wen?")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Today Section
|
// MARK: - Today Section
|
||||||
|
|
||||||
struct TodaySection<Content: View>: View {
|
struct TodaySection<Content: View>: View {
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import nahbar
|
||||||
|
|
||||||
|
@Suite("AIPayloadSanitizer")
|
||||||
|
struct AIPayloadSanitizerTests {
|
||||||
|
|
||||||
|
// MARK: - Sauberer Text
|
||||||
|
|
||||||
|
@Test("Sauberer Text wird unverändert durchgereicht")
|
||||||
|
func cleanTextPassesThrough() {
|
||||||
|
let text = "Haben uns beim Konzert getroffen, war ein schöner Abend."
|
||||||
|
#expect(AIPayloadSanitizer.sanitize(text) == text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Leerer String bleibt leer")
|
||||||
|
func emptyStringStaysEmpty() {
|
||||||
|
#expect(AIPayloadSanitizer.sanitize("") == "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - E-Mail
|
||||||
|
|
||||||
|
@Test("E-Mail-Adresse wird maskiert")
|
||||||
|
func emailIsMasked() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("Schreib mir an max.muster@example.com bitte.")
|
||||||
|
#expect(result.contains("[E-Mail]"))
|
||||||
|
#expect(!result.contains("max.muster@example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("E-Mail mit Subdomain wird maskiert")
|
||||||
|
func emailWithSubdomainIsMasked() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("Kontakt: user@mail.company.de")
|
||||||
|
#expect(result.contains("[E-Mail]"))
|
||||||
|
#expect(!result.contains("@"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mehrere E-Mails werden alle maskiert")
|
||||||
|
func multipleEmailsAreMasked() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("a@b.de und c@d.com schreiben sich.")
|
||||||
|
#expect(!result.contains("@"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - URL
|
||||||
|
|
||||||
|
@Test("https-URL wird maskiert")
|
||||||
|
func httpsURLIsMasked() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("Schau mal: https://www.example.com/path?x=1")
|
||||||
|
#expect(result.contains("[Link]"))
|
||||||
|
#expect(!result.contains("https://"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("http-URL wird maskiert")
|
||||||
|
func httpURLIsMasked() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("Infos unter http://example.com")
|
||||||
|
#expect(result.contains("[Link]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("www-URL ohne Protokoll wird maskiert")
|
||||||
|
func wwwURLIsMasked() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("Besuche www.example.com für Details.")
|
||||||
|
#expect(result.contains("[Link]"))
|
||||||
|
#expect(!result.contains("www.example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - IBAN
|
||||||
|
|
||||||
|
@Test("Unformatierte IBAN wird maskiert")
|
||||||
|
func ibanWithoutSpacesIsMasked() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("Konto: DE89370400440532013000")
|
||||||
|
#expect(result.contains("[IBAN]"))
|
||||||
|
#expect(!result.contains("DE89"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Telefonnummern
|
||||||
|
|
||||||
|
@Test("Internationale Rufnummer (+49) wird maskiert")
|
||||||
|
func internationalPhoneIsMasked() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("Ruf mich an: +49 30 12345678")
|
||||||
|
#expect(result.contains("[Telefon]"))
|
||||||
|
#expect(!result.contains("+49"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Deutsche Mobilnummer wird maskiert")
|
||||||
|
func germanMobileIsMasked() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("Tel: 01512345678")
|
||||||
|
#expect(result.contains("[Telefon]"))
|
||||||
|
#expect(!result.contains("01512345678"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Deutsche Festnetznummer mit Trenner wird maskiert")
|
||||||
|
func germanLandlineWithSeparatorIsMasked() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("Erreichbar unter 030 12345678.")
|
||||||
|
#expect(result.contains("[Telefon]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Jahreszahl (4-stellig) wird NICHT als Telefon maskiert")
|
||||||
|
func yearIsNotMasked() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("Wir kennen uns seit 2019.")
|
||||||
|
#expect(!result.contains("[Telefon]"))
|
||||||
|
#expect(result.contains("2019"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lange Zahlenfolgen
|
||||||
|
|
||||||
|
@Test("8-stellige Zahl wird als [Nummer] maskiert")
|
||||||
|
func eightDigitNumberIsMasked() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("Ausweis: 12345678")
|
||||||
|
#expect(result.contains("[Nummer]"))
|
||||||
|
#expect(!result.contains("12345678"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("5-stellige PLZ wird NICHT als [Nummer] maskiert")
|
||||||
|
func postalCodeIsNotMasked() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("Wohnt in 80331 München.")
|
||||||
|
#expect(!result.contains("[Nummer]"))
|
||||||
|
#expect(result.contains("80331"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("7-stellige Zahl wird NICHT maskiert")
|
||||||
|
func sevenDigitNumberIsNotMasked() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("Referenz: 1234567")
|
||||||
|
#expect(!result.contains("[Nummer]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kombiniert
|
||||||
|
|
||||||
|
@Test("Mehrere PII-Typen im selben Text werden alle maskiert")
|
||||||
|
func multiplePIITypesAreMasked() {
|
||||||
|
let text = "Kontakt: hans@test.de, Tel: +49 151 12345678, IBAN: DE89370400440532013000"
|
||||||
|
let result = AIPayloadSanitizer.sanitize(text)
|
||||||
|
#expect(!result.contains("hans@test.de"))
|
||||||
|
#expect(!result.contains("+49"))
|
||||||
|
#expect(!result.contains("DE89"))
|
||||||
|
#expect(result.contains("[E-Mail]"))
|
||||||
|
#expect(result.contains("[Telefon]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Vorname bleibt erhalten")
|
||||||
|
func firstNameIsPreserved() {
|
||||||
|
let result = AIPayloadSanitizer.sanitize("Treffen mit Maria war schön.")
|
||||||
|
#expect(result.contains("Maria"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,18 +91,17 @@ struct MomentTypeTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("displayName entspricht rawValue außer für .intention")
|
@Test("displayName ist von rawValue entkoppelt für .conversation und .intention")
|
||||||
func displayNameEqualsRawValueExceptIntention() {
|
func displayNameDecoupledFromRawValue() {
|
||||||
for type_ in MomentType.allCases where type_ != .intention {
|
// .conversation hat eigenen Anzeigenamen
|
||||||
#expect(type_.displayName == type_.rawValue,
|
#expect(MomentType.conversation.displayName == "Gespräch/Chat")
|
||||||
"displayName sollte rawValue sein für \(type_)")
|
#expect(MomentType.conversation.rawValue == "Gespräch") // Persistenz-Key bleibt
|
||||||
}
|
// .meeting und .thought bleiben gleich
|
||||||
}
|
#expect(MomentType.meeting.displayName == MomentType.meeting.rawValue)
|
||||||
|
#expect(MomentType.thought.displayName == MomentType.thought.rawValue)
|
||||||
@Test(".intention hat displayName 'Unternehmung' (entkoppelt von rawValue 'Vorhaben')")
|
// .intention (legacy) hat ebenfalls eigenen Anzeigenamen
|
||||||
func intentionDisplayNameIsUnternehmung() {
|
|
||||||
#expect(MomentType.intention.displayName == "Unternehmung")
|
#expect(MomentType.intention.displayName == "Unternehmung")
|
||||||
#expect(MomentType.intention.rawValue == "Vorhaben") // Persistenz-Key bleibt
|
#expect(MomentType.intention.rawValue == "Vorhaben") // Persistenz-Key bleibt
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("alle Types haben nicht-leeres displayName")
|
@Test("alle Types haben nicht-leeres displayName")
|
||||||
@@ -111,6 +110,21 @@ struct MomentTypeTests {
|
|||||||
#expect(!type_.displayName.isEmpty)
|
#expect(!type_.displayName.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("allCases enthält genau die 3 aktiven Typen – .intention ist ausgeblendet")
|
||||||
|
func allCasesExcludesIntention() {
|
||||||
|
let cases = MomentType.allCases
|
||||||
|
#expect(cases.count == 3)
|
||||||
|
#expect(cases.contains(.conversation))
|
||||||
|
#expect(cases.contains(.meeting))
|
||||||
|
#expect(cases.contains(.thought))
|
||||||
|
#expect(!cases.contains(.intention))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(".intention rawValue round-trip – Rückwärtskompatibilität")
|
||||||
|
func intentionRawValueRoundTrip() {
|
||||||
|
#expect(MomentType(rawValue: "Vorhaben") == .intention)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - MomentSource Tests
|
// MARK: - MomentSource Tests
|
||||||
|
|||||||
Reference in New Issue
Block a user