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
|
||||
|
||||
// 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
|
||||
|
||||
struct AIConfig: Decodable {
|
||||
@@ -225,21 +293,23 @@ class AIAnalysisService {
|
||||
formatter.locale = Locale.current
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
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 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
|
||||
|
||||
return "Person: \(person.firstName)\n"
|
||||
+ birthYearContext(for: person, language: lang)
|
||||
+ interests
|
||||
+ culturalBg
|
||||
+ "\n"
|
||||
+ moments
|
||||
+ "\n"
|
||||
|
||||
@@ -26,6 +26,7 @@ struct AddMomentView: View {
|
||||
return cal.date(bySettingHour: hour + 1, minute: 0, second: 0, of: Date()) ?? Date()
|
||||
}()
|
||||
@State private var eventDuration: Double = 3600 // Sekunden; -1 = Ganztag
|
||||
@AppStorage("selectedCalendarID") private var defaultCalendarID: String = ""
|
||||
@State private var availableCalendars: [EKCalendar] = []
|
||||
@State private var selectedCalendarID: String = ""
|
||||
@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 showsCalendarSection: Bool { selectedType == .meeting }
|
||||
private var showsReminderSection: Bool { selectedType == .intention }
|
||||
private var showsCalendarSection: Bool { selectedType != .thought }
|
||||
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 {
|
||||
switch selectedType {
|
||||
case .meeting: return "Wo habt ihr euch getroffen?\nWas habt ihr unternommen?"
|
||||
case .intention: return "Was möchtest du mit dieser Person machen?"
|
||||
case .thought: return "Welcher Gedanke kam dir in den Sinn?"
|
||||
case .conversation: return "Was war der Kern des Gesprächs?\nWas möchtest du nicht vergessen?"
|
||||
case .meeting:
|
||||
return isFutureMeeting
|
||||
? "Wo trefft ihr euch?\nWas habt ihr vor?"
|
||||
: "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
|
||||
// Vorauswahl: gespeicherter Kalender oder der Standard
|
||||
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.touch()
|
||||
|
||||
// Vorhaben: Erinnerung speichern + Notification planen
|
||||
if selectedType == .intention && addReminder {
|
||||
// Erinnerung (lokale Notification) – unabhängig vom Kalendereintrag
|
||||
if addReminder {
|
||||
moment.reminderDate = reminderDate
|
||||
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 {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
@@ -335,25 +371,8 @@ struct AddMomentView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// Treffen: Callback für Rating-Flow + evtl. Kalendertermin
|
||||
if selectedType == .meeting {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
// Treffen: Callback für Rating-Flow – nur wenn das Treffen bereits stattgefunden hat
|
||||
if selectedType == .meeting && !isFutureMeeting {
|
||||
dismiss()
|
||||
onMeetingCreated?(moment)
|
||||
return
|
||||
@@ -362,6 +381,18 @@ struct AddMomentView: View {
|
||||
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
|
||||
|
||||
private func scheduleIntentionReminder(for moment: Moment) {
|
||||
@@ -403,7 +434,12 @@ struct AddMomentView: View {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if let identifier = await CalendarManager.shared.createEvent(
|
||||
|
||||
@@ -16,6 +16,7 @@ struct AddPersonView: View {
|
||||
@State private var location = ""
|
||||
@State private var interests = ""
|
||||
@State private var generalNotes = ""
|
||||
@State private var culturalBackground = ""
|
||||
@State private var hasBirthday = false
|
||||
@State private var birthday = Date()
|
||||
@State private var nudgeFrequency: NudgeFrequency = .monthly
|
||||
@@ -85,6 +86,8 @@ struct AddPersonView: View {
|
||||
RowDivider()
|
||||
inlineField("Interessen", text: $interests)
|
||||
RowDivider()
|
||||
inlineField("Herkunft", text: $culturalBackground)
|
||||
RowDivider()
|
||||
inlineField("Notizen", text: $generalNotes)
|
||||
}
|
||||
.background(theme.surfaceCard)
|
||||
@@ -383,6 +386,7 @@ struct AddPersonView: View {
|
||||
occupation = p.occupation ?? ""
|
||||
location = p.location ?? ""
|
||||
interests = p.interests ?? ""
|
||||
culturalBackground = p.culturalBackground ?? ""
|
||||
generalNotes = p.generalNotes ?? ""
|
||||
hasBirthday = p.birthday != nil
|
||||
birthday = p.birthday ?? Date()
|
||||
@@ -405,6 +409,7 @@ struct AddPersonView: View {
|
||||
p.occupation = occupation.isEmpty ? nil : occupation
|
||||
p.location = location.isEmpty ? nil : location
|
||||
p.interests = interests.isEmpty ? nil : interests
|
||||
p.culturalBackground = culturalBackground.isEmpty ? nil : culturalBackground
|
||||
p.generalNotes = generalNotes.isEmpty ? nil : generalNotes
|
||||
p.birthday = hasBirthday ? birthday : nil
|
||||
p.nudgeFrequency = nudgeFrequency
|
||||
@@ -419,6 +424,7 @@ struct AddPersonView: View {
|
||||
location: location.isEmpty ? nil : location,
|
||||
interests: interests.isEmpty ? nil : interests,
|
||||
generalNotes: generalNotes.isEmpty ? nil : generalNotes,
|
||||
culturalBackground: culturalBackground.isEmpty ? nil : culturalBackground,
|
||||
nudgeFrequency: nudgeFrequency
|
||||
)
|
||||
modelContext.insert(person)
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
}
|
||||
},
|
||||
"%lld ausgewählt" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"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" : {
|
||||
"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,
|
||||
@@ -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" : {
|
||||
"comment" : "TodayView – section title for intention moments with reminder dates",
|
||||
"comment" : "TodayView – legacy key, replaced by 'Anstehende Erinnerungen'",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"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." : {
|
||||
"comment" : "AIConsentSheet – body text",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"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?" : {
|
||||
"comment" : "AddPersonView – delete person confirmation title",
|
||||
"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: %@" : {
|
||||
"comment" : "TodayView GiftSuggestionRow – error message with description",
|
||||
"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?" : {
|
||||
"comment" : "RatingQuestion – relationship question text",
|
||||
"extractionState" : "stale",
|
||||
@@ -2245,6 +2312,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Für wen?" : {
|
||||
"comment" : "TodayPersonPickerSheet – navigation title",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "For whom?"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Ganztag" : {
|
||||
"comment" : "AddMomentView – all-day calendar event duration option",
|
||||
"localizations" : {
|
||||
@@ -2281,6 +2359,7 @@
|
||||
},
|
||||
"Geburtstage & Termine" : {
|
||||
"comment" : "SettingsView – look-ahead section row label",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -2390,7 +2469,7 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"localizations" : {
|
||||
"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" : {
|
||||
"comment" : "CallSuggestionView – conversation starter section header",
|
||||
"localizations" : {
|
||||
@@ -2791,6 +2908,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Kalender-Einstellungen" : {
|
||||
|
||||
},
|
||||
"Kauf wiederherstellen" : {
|
||||
"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." : {
|
||||
"comment" : "LogbuchView – empty state subtitle",
|
||||
"localizations" : {
|
||||
@@ -4578,6 +4709,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Termin mit %@" : {
|
||||
|
||||
},
|
||||
"Termin mit %@ — %@" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Termin mit %1$@ — %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Tief & fokussiert · ND" : {
|
||||
"comment" : "Theme tagline for Abyss",
|
||||
"localizations" : {
|
||||
@@ -4823,7 +4967,7 @@
|
||||
}
|
||||
},
|
||||
"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" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -4990,6 +5134,7 @@
|
||||
},
|
||||
"Vorausschau" : {
|
||||
"comment" : "SettingsView – section header for look-ahead settings",
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"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" : {
|
||||
"comment" : "PaywallView – header title",
|
||||
@@ -5065,6 +5219,7 @@
|
||||
}
|
||||
},
|
||||
"Wähle Menschen aus deinem Adressbuch, die dir wichtig sind." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -5075,6 +5230,7 @@
|
||||
}
|
||||
},
|
||||
"Wähle Menschen aus, die dir wichtig sind." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
|
||||
@@ -624,10 +624,11 @@ private struct DeletableLogbuchRow<Content: View>: View {
|
||||
.background(theme.surfaceCard)
|
||||
.offset(x: offset)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 10, coordinateSpace: .local)
|
||||
DragGesture(minimumDistance: 50, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
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 {
|
||||
offset = min(x, actionWidth * 2 + 16)
|
||||
} else {
|
||||
@@ -636,12 +637,17 @@ private struct DeletableLogbuchRow<Content: View>: View {
|
||||
}
|
||||
.onEnded { value in
|
||||
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 {
|
||||
onToggleImportant()
|
||||
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 }
|
||||
} else if x < -(actionWidth / 2) {
|
||||
} else if x < -(actionWidth * 1.5) {
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = -actionWidth }
|
||||
} else {
|
||||
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 meeting = "Treffen" // rawValue bleibt für Persistenz unverändert
|
||||
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.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .intention: return "Unternehmung"
|
||||
default: return rawValue
|
||||
case .conversation: return "Gespräch/Chat"
|
||||
case .intention: return "Unternehmung" // legacy
|
||||
default: return rawValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +115,7 @@ class Person {
|
||||
var location: String?
|
||||
var interests: String?
|
||||
var generalNotes: String?
|
||||
var culturalBackground: String? = nil // V6: kultureller Hintergrund
|
||||
var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue
|
||||
var nextStep: String?
|
||||
var nextStepCompleted: Bool = false
|
||||
@@ -139,6 +144,7 @@ class Person {
|
||||
location: String? = nil,
|
||||
interests: String? = nil,
|
||||
generalNotes: String? = nil,
|
||||
culturalBackground: String? = nil,
|
||||
nudgeFrequency: NudgeFrequency = .monthly
|
||||
) {
|
||||
self.id = UUID()
|
||||
@@ -149,6 +155,7 @@ class Person {
|
||||
self.location = location
|
||||
self.interests = interests
|
||||
self.generalNotes = generalNotes
|
||||
self.culturalBackground = culturalBackground
|
||||
self.nudgeFrequencyRaw = nudgeFrequency.rawValue
|
||||
self.photoData = nil
|
||||
self.photo = nil
|
||||
|
||||
@@ -289,11 +289,11 @@ enum NahbarSchemaV4: VersionedSchema {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schema V5 (aktuelles Schema)
|
||||
// Referenziert die Live-Typen aus Models.swift.
|
||||
// Beim Hinzufügen von V6 muss V5 als eingefrorener Snapshot gesichert werden.
|
||||
// MARK: - Schema V5 (eingefrorener Snapshot)
|
||||
// WICHTIG: Niemals nachträglich ändern – dieser Snapshot muss dem gespeicherten
|
||||
// Schema-Hash von V5-Datenbanken auf Nutzer-Geräten entsprechen.
|
||||
//
|
||||
// V5 fügt hinzu:
|
||||
// V5 fügte hinzu:
|
||||
// • Moment: ratings, healthSnapshot, statusRaw, aftermathNotificationScheduled,
|
||||
// aftermathCompletedAt, reminderDate, isCompleted
|
||||
// • Rating: moment-Relationship (neben legacy visit)
|
||||
@@ -301,6 +301,117 @@ enum NahbarSchemaV4: VersionedSchema {
|
||||
|
||||
enum NahbarSchemaV5: VersionedSchema {
|
||||
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] {
|
||||
[nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self,
|
||||
nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self]
|
||||
@@ -312,7 +423,7 @@ enum NahbarSchemaV5: VersionedSchema {
|
||||
enum NahbarMigrationPlan: SchemaMigrationPlan {
|
||||
static var schemas: [any VersionedSchema.Type] {
|
||||
[NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self,
|
||||
NahbarSchemaV4.self, NahbarSchemaV5.self]
|
||||
NahbarSchemaV4.self, NahbarSchemaV5.self, NahbarSchemaV6.self]
|
||||
}
|
||||
|
||||
static var stages: [MigrationStage] {
|
||||
@@ -332,7 +443,11 @@ enum NahbarMigrationPlan: SchemaMigrationPlan {
|
||||
// V4 → V5: Moment bekommt rating/intention-Felder, Rating/HealthSnapshot
|
||||
// bekommen moment-Relationship. Visit/HealthSnapshot bleiben im Schema
|
||||
// (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 showSkipConfirmation: Bool = false
|
||||
|
||||
private let maxContacts = 3
|
||||
private var atLimit: Bool { coordinator.selectedContacts.count >= maxContacts }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
|
||||
@@ -402,7 +405,7 @@ private struct OnboardingContactImportView: View {
|
||||
Text("Kontakte hinzufügen")
|
||||
.font(.title.bold())
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -492,7 +495,7 @@ private struct OnboardingContactImportView: View {
|
||||
.accessibilityHidden(true)
|
||||
Text("Noch keine Kontakte")
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -540,13 +543,17 @@ private struct OnboardingContactImportView: View {
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("\(coordinator.selectedContacts.count) ausgewählt")
|
||||
Text(atLimit
|
||||
? "\(coordinator.selectedContacts.count) von \(maxContacts) — Maximum erreicht"
|
||||
: "\(coordinator.selectedContacts.count) von \(maxContacts) ausgewählt")
|
||||
Spacer()
|
||||
Button {
|
||||
showingPicker = true
|
||||
} label: {
|
||||
Label("Weitere hinzufügen", systemImage: "plus")
|
||||
.font(.caption.weight(.medium))
|
||||
if !atLimit {
|
||||
Button {
|
||||
showingPicker = true
|
||||
} label: {
|
||||
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).
|
||||
private func mergeContacts(_ contacts: [CNContact]) {
|
||||
for contact in contacts {
|
||||
guard coordinator.selectedContacts.count < maxContacts else { break }
|
||||
let alreadySelected = coordinator.selectedContacts
|
||||
.contains { $0.cnIdentifier == contact.identifier }
|
||||
if !alreadySelected {
|
||||
|
||||
@@ -326,6 +326,7 @@ struct PersonDetailView: View {
|
||||
person.birthday != nil
|
||||
|| !(person.location ?? "").isEmpty
|
||||
|| !(person.interests ?? "").isEmpty
|
||||
|| !(person.culturalBackground ?? "").isEmpty
|
||||
|| !(person.generalNotes ?? "").isEmpty
|
||||
}
|
||||
|
||||
@@ -349,6 +350,10 @@ struct PersonDetailView: View {
|
||||
InfoRowView(label: "Interessen", value: interests)
|
||||
RowDivider()
|
||||
}
|
||||
if let bg = person.culturalBackground, !bg.isEmpty {
|
||||
InfoRowView(label: "Herkunft", value: bg)
|
||||
RowDivider()
|
||||
}
|
||||
if let notes = person.generalNotes, !notes.isEmpty {
|
||||
InfoRowView(label: "Notizen", value: notes)
|
||||
}
|
||||
@@ -518,7 +523,7 @@ private struct DeletableMomentRow: View {
|
||||
.background(theme.surfaceCard)
|
||||
.offset(x: offset)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 20, coordinateSpace: .local)
|
||||
DragGesture(minimumDistance: 50, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
let x = value.translation.width
|
||||
let y = value.translation.height
|
||||
@@ -542,13 +547,10 @@ private struct DeletableMomentRow: View {
|
||||
// Vollständiges Rechts-Wischen → Wichtig-Toggle, zurückspringen
|
||||
onToggleImportant()
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
||||
} else if x > actionWidth {
|
||||
// Mehr als eine Button-Breite → beide linken Buttons zeigen
|
||||
} else if x > actionWidth * 1.5 {
|
||||
// Bewusstes Rechts-Wischen → linke Buttons zeigen
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = actionWidth * 2 }
|
||||
} else if x > actionWidth / 2 {
|
||||
// Knapp eine Button-Breite → beide linken Buttons zeigen
|
||||
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 }
|
||||
} else {
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.8)) { offset = 0 }
|
||||
|
||||
@@ -133,7 +133,7 @@ struct AIConsentSheet: View {
|
||||
.font(.title2.bold())
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import LocalAuthentication
|
||||
import EventKit
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.nahbarTheme) var theme
|
||||
@@ -10,6 +11,8 @@ struct SettingsView: View {
|
||||
@EnvironmentObject private var cloudSyncMonitor: CloudSyncMonitor
|
||||
|
||||
@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("aftermathDelayOption") private var aftermathDelayRaw: String = AftermathDelayOption.hours36.rawValue
|
||||
@AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false
|
||||
@@ -265,14 +268,14 @@ struct SettingsView: View {
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
// Vorausschau
|
||||
// Kalender-Einstellungen
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionHeader(title: "Vorausschau", icon: "calendar")
|
||||
SectionHeader(title: "Kalender-Einstellungen", icon: "calendar")
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Geburtstage & Termine")
|
||||
Text("Vorschau Geburtstage & Termine")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
Spacer()
|
||||
@@ -286,10 +289,41 @@ struct SettingsView: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.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)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
.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
|
||||
@@ -769,10 +803,11 @@ enum AppLanguage: String, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
var momentsLabel: String { self == .english ? "Moments" : "Momente" }
|
||||
var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" }
|
||||
var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" }
|
||||
var interestsLabel: String { self == .english ? "Interests" : "Interessen" }
|
||||
var momentsLabel: String { self == .english ? "Moments" : "Momente" }
|
||||
var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" }
|
||||
var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" }
|
||||
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.
|
||||
/// 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)
|
||||
private var pendingAftermaths: [Moment]
|
||||
@State private var selectedMomentForAftermath: Moment? = nil
|
||||
@State private var showPersonPicker = false
|
||||
@State private var personForNewMoment: Person? = nil
|
||||
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
|
||||
|
||||
private var needsAttention: [Person] {
|
||||
@@ -34,18 +36,20 @@ struct TodayView: View {
|
||||
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> {
|
||||
$0.typeRaw == "Vorhaben" && !$0.isCompleted && $0.reminderDate != nil
|
||||
$0.typeRaw != "Gedanke" && !$0.isCompleted && $0.reminderDate != nil
|
||||
}, sort: \Moment.reminderDate)
|
||||
private var allIntentionsWithReminder: [Moment]
|
||||
private var allWithReminder: [Moment]
|
||||
|
||||
private var upcomingReminders: [Moment] {
|
||||
let now = Date()
|
||||
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 }
|
||||
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
|
||||
}
|
||||
|
||||
private var activePeople: [Person] {
|
||||
people.filter { !$0.isArchived }.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
private var birthdaySectionTitle: LocalizedStringKey {
|
||||
switch daysAhead {
|
||||
case 3: return "In 3 Tagen"
|
||||
@@ -135,11 +143,11 @@ struct TodayView: View {
|
||||
}
|
||||
|
||||
if !upcomingReminders.isEmpty {
|
||||
TodaySection(title: "Anstehende Unternehmungen", icon: "calendar") {
|
||||
TodaySection(title: "Anstehende Erinnerungen", icon: "bell") {
|
||||
ForEach(upcomingReminders) { moment in
|
||||
if let person = moment.person {
|
||||
NavigationLink(destination: PersonDetailView(person: person)) {
|
||||
TodayRow(person: person, hint: intentionReminderHint(for: moment))
|
||||
TodayRow(person: person, hint: reminderHint(for: moment))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -223,20 +231,58 @@ struct TodayView: View {
|
||||
.sheet(item: $selectedMomentForAftermath) { moment in
|
||||
AftermathRatingFlowView(moment: moment)
|
||||
}
|
||||
.sheet(isPresented: $showPersonPicker) {
|
||||
TodayPersonPickerSheet(people: activePeople) { person in
|
||||
personForNewMoment = person
|
||||
}
|
||||
}
|
||||
.sheet(item: $personForNewMoment) { person in
|
||||
AddMomentView(person: person)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 10) {
|
||||
Spacer().frame(height: 48)
|
||||
Text("Ein ruhiger Tag.")
|
||||
.font(.system(size: 20, weight: .light, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
Text("Oder einer, der es noch wird.")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
VStack(spacing: 24) {
|
||||
Spacer().frame(height: 32)
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Text("Ein ruhiger Tag.")
|
||||
.font(.system(size: 20, weight: .light, design: theme.displayDesign))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
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)
|
||||
}
|
||||
@@ -265,10 +311,11 @@ struct TodayView: View {
|
||||
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 }
|
||||
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 {
|
||||
@@ -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
|
||||
|
||||
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")
|
||||
func displayNameEqualsRawValueExceptIntention() {
|
||||
for type_ in MomentType.allCases where type_ != .intention {
|
||||
#expect(type_.displayName == type_.rawValue,
|
||||
"displayName sollte rawValue sein für \(type_)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test(".intention hat displayName 'Unternehmung' (entkoppelt von rawValue 'Vorhaben')")
|
||||
func intentionDisplayNameIsUnternehmung() {
|
||||
@Test("displayName ist von rawValue entkoppelt für .conversation und .intention")
|
||||
func displayNameDecoupledFromRawValue() {
|
||||
// .conversation hat eigenen Anzeigenamen
|
||||
#expect(MomentType.conversation.displayName == "Gespräch/Chat")
|
||||
#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)
|
||||
// .intention (legacy) hat ebenfalls eigenen Anzeigenamen
|
||||
#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")
|
||||
@@ -111,6 +110,21 @@ struct MomentTypeTests {
|
||||
#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
|
||||
|
||||
Reference in New Issue
Block a user