Compare commits

...

15 Commits

Author SHA1 Message Date
sven 1ecc44a625 Gesprächsthemen: Halluzinations-Schutz + Datenmangel-Hinweis
- Code-seitiger Guard: < 2 Momente/Einträge → .insufficientData-State
  statt API-Call (verhindert Halluzinationen bei leeren Profilen)
- UI: Info-Hinweis "Noch zu wenig Verlauf für persönliche Vorschläge"
- Prompt: STRIKT-Anweisung, nur vorhandene Daten zu verwenden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:31:46 +02:00
sven f1de4bfd30 Prompt: Gesprächsthemen-Vorschläge kürzer und knapper
Max. 8 Wörter pro Punkt, Stichwörter statt Sätze.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:29:47 +02:00
sven 3ac221a049 AddMomentView: ScrollView + kleineres Textfeld für KI-Vorschläge
TextEditor auf minHeight 120 verkleinert, VStack in ScrollView
eingebettet – KI-Vorschläge schieben den Editierbereich nicht
mehr aus dem Sichtfeld.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:27:45 +02:00
sven 22e1d68217 Umbenennung: Gesprächsvorschläge → Gesprächsthemen vorschlagen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:25:29 +02:00
sven 74bd53407d Gesprächsvorschläge auch für Gespräch-Typ verfügbar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:22:47 +02:00
sven 7057ccb607 Gesprächsvorschläge: Übernehmen-Button pro Sektion
Jede der drei Vorschlags-Sektionen hat jetzt einen kleinen Button
(Pfeil-Icon), der den jeweiligen Text ins Notizfeld übernimmt.
Kurzes Checkmark-Feedback zeigt die erfolgreiche Übernahme an.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:22:02 +02:00
sven a3ae925a10 Fix Gesprächsvorschläge: ** Markdown-Marker aus Fließtext entfernen
Die KI gibt gelegentlich **fett** formatierten Text zurück. Da die
Vorschläge als Plain Text dargestellt werden, werden verbleibende
** nach der Sektion-Extraktion jetzt bereinigt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:20:41 +02:00
sven 319b59c12e Resolves #18 KI-Gesprächsvorschläge beim Anlegen eines Treffens
Neues Max-Plan-Feature: Beim Anlegen eines Treffens können KI-gestützte
Gesprächsvorschläge abgerufen werden. Die Vorschläge umfassen Themen-
vorschläge, Gesprächsretter (bei Stockungen) und Tipps um Smalltalk
in bedeutsame Gespräche zu verwandeln.

- AIAnalysisService: ConversationSuggestionResult, CachedConversationSuggestion,
  suggestConversation(person:), parseConversationResult (internal),
  buildPrompt auf PromptType-Enum umgestellt
- SettingsView: AppLanguage.conversationInstruction (DE + EN)
- AddMomentView: KI-Sektion (idle/loading/result/error) nur bei Treffen-Typ
- PaywallView: Gesprächsvorschläge in Max-Feature-Liste
- Localizable.xcstrings: 10 neue DE/EN-Strings
- Tests: 8 neue Unit-Tests für Parsing und Codable Round-Trip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:17:25 +02:00
sven 18112cb52c Resolves #11 Resolves #13 Fragebogen: Abbrechen-Button entfernt
Der Speichern-Button am Ende des Flows ersetzt den Abbrechen-Button
in der Toolbar für beide Fragebögen (Meeting + Nachwirkung).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:04:34 +02:00
sven b477a3e04b Resolves #13 Fragebogen: scrollbarer Einblend-Flow
Statt Fragen einzeln zu ersetzen, werden sie jetzt nacheinander von
unten eingeblendet und bleiben sichtbar:
- Tippen auf einen Dot zeigt die nächste Frage darunter an
- "Überspringen" blendet ebenfalls die nächste Frage ein
- Beantwortete Fragen bleiben sichtbar und können angepasst werden
- Nach der letzten Frage erscheint ein "Speichern"-Button
- Gilt für Sofort-Bewertung (MeetingRatingFlowView) und
  Nachwirkung (AftermathRatingFlowView)
- Neuer QuestionCard-Component in RatingQuestionView.swift

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 05:47:58 +02:00
sven d541640c74 Logbuch-Button aus Toolbar entfernt (Zugang über Verlauf-Sektion)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 05:40:36 +02:00
sven 30b150a286 Umbenennung: Verlauf → Verlauf & KI-Analyse (sparkles-Icon)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 05:38:21 +02:00
sven 4a9bb32b5e Logbuch: Todos + alle Momente im Verlauf
- LogEntryType.todoCompleted hinzugefügt (checkmark.square.fill, grün)
- Abgehakte Todos erzeugen jetzt automatisch einen Logbuch-Eintrag
  (sowohl aus PersonDetailView als auch TodayView)
- Verlauf-Vorschau in PersonDetailView zeigt jetzt eine gemischte
  Timeline aus Momenten + Logeinträgen statt nur LogEntries
- Verlauf-Abschnitt erscheint sobald Momente oder Logeinträge vorhanden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 05:34:50 +02:00
sven 2b9346c78b Fix #16 Logbuch-Button in Toolbar ergänzt
Buchsymbol in der NavBar öffnet LogbuchView unabhängig davon, ob
bereits Logeinträge vorhanden sind (vorheriger Fix griff nur wenn der
Verlauf-Abschnitt sichtbar war).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 05:29:19 +02:00
sven 66a7b23f5a Resolves #16 Logbuch-Button immer sichtbar in PersonDetailView
"Alle"-Link im Verlauf-Header zeigt LogbuchView unabhängig von der
Eintragsanzahl (vorher nur bei >5 Einträgen sichtbar).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 05:27:24 +02:00
12 changed files with 1057 additions and 277 deletions
+70 -40
View File
@@ -2,18 +2,19 @@ import SwiftUI
import SwiftData
// MARK: - AftermathRatingFlowView
// Sheet-basierter Bewertungs-Flow für die Nachwirkungs-Bewertung (4 Fragen).
// Wird aus einer Push-Notification heraus oder aus der Momente-Liste geöffnet.
// Scrollbarer Bewertungs-Flow für die Nachwirkungs-Bewertung (4 Fragen).
// Gleiche Interaktion wie MeetingRatingFlowView Fragen blenden nacheinander ein.
struct AftermathRatingFlowView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.nahbarTheme) var theme
@Environment(\.dismiss) private var dismiss
let moment: Moment
private let questions = RatingQuestion.aftermath // 4 Fragen
@State private var currentIndex: Int = 0
private let questions = RatingQuestion.aftermath // 4 Fragen
@State private var values: [Int?]
@State private var revealedCount: Int = 1
@State private var showSummary: Bool = false
init(moment: Moment) {
@@ -27,55 +28,85 @@ struct AftermathRatingFlowView: View {
if showSummary {
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
} else {
questionStep
questionFlow
}
}
.navigationTitle("Nachwirkung")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
}
// MARK: - Scrollbarer Fragen-Flow
private var questionFlow: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 14) {
ForEach(0..<revealedCount, id: \.self) { i in
QuestionCard(
question: questions[i],
index: i,
total: questions.count,
isActive: i == revealedCount - 1,
value: $values[i],
onAnswer: { revealNext(after: i) },
onSkip: { revealNext(after: i) }
)
.id(i)
.transition(.asymmetric(
insertion: .opacity.combined(with: .move(edge: .bottom)),
removal: .identity
))
}
if revealedCount == questions.count {
saveButton
.id("save")
.transition(.asymmetric(
insertion: .opacity.combined(with: .move(edge: .bottom)),
removal: .identity
))
}
}
if !showSummary {
ToolbarItem(placement: .confirmationAction) {
Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") {
advance()
}
.padding(16)
.padding(.bottom, 32)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.onChange(of: revealedCount) { _, newCount in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.easeOut(duration: 0.4)) {
proxy.scrollTo(newCount - 1, anchor: .top)
}
}
}
}
}
// MARK: - Fragen-Screen
private var questionStep: some View {
ZStack {
RatingQuestionView(
question: questions[currentIndex],
index: currentIndex,
total: questions.count,
value: $values[currentIndex]
)
.id(currentIndex)
.transition(.asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading)
))
}
.clipped()
}
// MARK: - Navigation & Speichern
private func advance() {
if currentIndex < questions.count - 1 {
withAnimation { currentIndex += 1 }
} else {
saveAftermath()
private var saveButton: some View {
Button { saveAftermath() } label: {
Text("Speichern")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(theme.accent)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
}
// MARK: - Navigation
private func revealNext(after index: Int) {
guard index == revealedCount - 1 else { return }
guard revealedCount < questions.count else { return }
withAnimation(.easeOut(duration: 0.35)) {
revealedCount += 1
}
}
// MARK: - Speichern
private func saveAftermath() {
for (i, q) in questions.enumerated() {
let rating = Rating(
@@ -91,7 +122,6 @@ struct AftermathRatingFlowView: View {
moment.meetingStatus = .completed
moment.aftermathCompletedAt = Date()
// Evtl. geplante Notification abbrechen (falls Nutzer selbst geöffnet hat)
AftermathNotificationManager.shared.cancelAftermath(momentID: moment.id)
do {
+68
View File
@@ -1,5 +1,73 @@
import SwiftUI
// MARK: - QuestionCard
// Einzelne Bewertungsfrage als Card für den scrollbaren Flow.
// isActive = letzte sichtbare Frage (noch nicht bestätigt).
struct QuestionCard: View {
@Environment(\.nahbarTheme) var theme
let question: RatingQuestion
let index: Int
let total: Int
let isActive: Bool
@Binding var value: Int?
let onAnswer: () -> Void // Dot ausgewählt (nur wenn isActive)
let onSkip: () -> Void // Überspringen getippt (nur wenn isActive)
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("\(index + 1) / \(total)")
.font(.caption.weight(.medium))
.foregroundStyle(theme.contentTertiary)
Spacer()
HStack(spacing: 5) {
Image(systemName: question.category.icon)
.font(.caption.bold())
Text(LocalizedStringKey(question.category.rawValue))
.font(.caption.bold())
}
.foregroundStyle(question.category.color)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(question.category.color.opacity(0.12), in: Capsule())
}
Text(LocalizedStringKey(question.text))
.font(.system(size: 16, weight: .semibold, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
RatingDotPicker(
value: $value,
negativePole: question.negativePole,
positivePole: question.positivePole
)
.onChange(of: value) { _, newValue in
if isActive, newValue != nil {
onAnswer()
}
}
if isActive {
Button {
value = nil
onSkip()
} label: {
Text("Überspringen")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
.padding(16)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.opacity(isActive ? 1.0 : 0.75)
}
}
// MARK: - RatingQuestionView
// Zeigt eine einzelne Bewertungsfrage mit Kategorie-Badge, Fragetext,
// RatingDotPicker und "Überspringen"-Button.
+64 -40
View File
@@ -2,24 +2,21 @@ import SwiftUI
import SwiftData
// MARK: - MeetingRatingFlowView
// Sheet-basierter Bewertungs-Flow für die Sofort-Bewertung eines Treffen-Moments.
// Erwartet einen bereits gespeicherten Moment vom Typ .meeting und ergänzt ihn
// um Ratings sowie den Nachwirkungs-Status.
// Scrollbarer Bewertungs-Flow für die Sofort-Bewertung eines Treffens.
// Fragen blenden nacheinander von unten ein, sobald die vorherige beantwortet wurde.
// Nach der letzten Frage erscheint ein "Speichern"-Button.
struct MeetingRatingFlowView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.nahbarTheme) var theme
@Environment(\.dismiss) private var dismiss
let moment: Moment
// Nachwirkungs-Verzögerung (aus App-Einstellungen übergeben)
var aftermathDelay: TimeInterval = 36 * 3600
// MARK: State
private let questions = RatingQuestion.immediate // 5 Fragen
@State private var currentIndex: Int = 0
@State private var values: [Int?] // [nil] × 5
@State private var values: [Int?]
@State private var revealedCount: Int = 1
@State private var showSummary: Bool = false
init(moment: Moment, aftermathDelay: TimeInterval = 36 * 3600) {
@@ -34,52 +31,80 @@ struct MeetingRatingFlowView: View {
if showSummary {
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
} else {
questionStep
questionFlow
}
}
.navigationTitle("Treffen bewerten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
}
// MARK: - Scrollbarer Fragen-Flow
private var questionFlow: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 14) {
ForEach(0..<revealedCount, id: \.self) { i in
QuestionCard(
question: questions[i],
index: i,
total: questions.count,
isActive: i == revealedCount - 1,
value: $values[i],
onAnswer: { revealNext(after: i) },
onSkip: { revealNext(after: i) }
)
.id(i)
.transition(.asymmetric(
insertion: .opacity.combined(with: .move(edge: .bottom)),
removal: .identity
))
}
if revealedCount == questions.count {
saveButton
.id("save")
.transition(.asymmetric(
insertion: .opacity.combined(with: .move(edge: .bottom)),
removal: .identity
))
}
}
if !showSummary {
ToolbarItem(placement: .confirmationAction) {
Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") {
advance()
}
.padding(16)
.padding(.bottom, 32)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.onChange(of: revealedCount) { _, newCount in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.easeOut(duration: 0.4)) {
proxy.scrollTo(newCount - 1, anchor: .top)
}
}
}
}
}
// MARK: - Fragen-Screen
private var questionStep: some View {
ZStack {
RatingQuestionView(
question: questions[currentIndex],
index: currentIndex,
total: questions.count,
value: $values[currentIndex]
)
.id(currentIndex)
.transition(.asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading)
))
private var saveButton: some View {
Button { saveRatings() } label: {
Text("Speichern")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(theme.accent)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
.clipped()
}
// MARK: - Navigation
private func advance() {
if currentIndex < questions.count - 1 {
withAnimation { currentIndex += 1 }
} else {
saveRatings()
private func revealNext(after index: Int) {
guard index == revealedCount - 1 else { return }
guard revealedCount < questions.count else { return }
withAnimation(.easeOut(duration: 0.35)) {
revealedCount += 1
}
}
@@ -112,7 +137,6 @@ struct MeetingRatingFlowView: View {
)
}
// Nachwirkungs-Notification planen
AftermathNotificationManager.shared.scheduleAftermath(
momentID: moment.id,
personName: moment.person?.firstName ?? "",
+138 -4
View File
@@ -159,6 +159,34 @@ struct CachedGiftSuggestion: Codable {
let generatedAt: Date
}
// MARK: - Conversation Suggestion Result
struct ConversationSuggestionResult {
let topics: String // THEMEN:
let rescue: String // GESPRAECHSRETTER:
let depth: String // TIEFE:
}
// MARK: - Cached Conversation Suggestion
struct CachedConversationSuggestion: Codable {
let topics: String
let rescue: String
let depth: String
let generatedAt: Date
var asResult: ConversationSuggestionResult {
ConversationSuggestionResult(topics: topics, rescue: rescue, depth: depth)
}
init(result: ConversationSuggestionResult, date: Date = Date()) {
self.topics = result.topics
self.rescue = result.rescue
self.depth = result.depth
self.generatedAt = date
}
}
// MARK: - Service
class AIAnalysisService {
@@ -283,9 +311,12 @@ class AIAnalysisService {
// MARK: - Prompt Builder
private enum PromptType {
case analysis, gift, conversation
}
/// Baut den vollständigen User-Prompt sprachabhängig auf.
/// - `isGift`: true Geschenkideen-Instruktion, false Analyse-Instruktion
private func buildPrompt(for person: Person, isGift: Bool = false) -> String {
private func buildPrompt(for person: Person, promptType: PromptType = .analysis) -> String {
let lang = AppLanguage.current
let formatter = DateFormatter()
@@ -304,7 +335,13 @@ class AIAnalysisService {
let logEntries = logLines.isEmpty ? "" : "\(lang.logEntriesLabel) (\(person.sortedLogEntries.count)):\n\(logLines)\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: String
switch promptType {
case .analysis: instruction = lang.analysisInstruction
case .gift: instruction = lang.giftInstruction
case .conversation: instruction = lang.conversationInstruction
}
return "Person: \(person.firstName)\n"
+ birthYearContext(for: person, language: lang)
@@ -351,7 +388,7 @@ class AIAnalysisService {
throw URLError(.badURL)
}
let prompt = buildPrompt(for: person, isGift: true)
let prompt = buildPrompt(for: person, promptType: .gift)
let body: [String: Any] = [
"model": config.model,
@@ -397,6 +434,72 @@ class AIAnalysisService {
return normalized
}
// MARK: - Conversation Cache
private func conversationCacheKey(for person: Person) -> String { "ai_conversation_\(person.id.uuidString)" }
func loadCachedConversation(for person: Person) -> CachedConversationSuggestion? {
guard let data = UserDefaults.standard.data(forKey: conversationCacheKey(for: person)),
let cached = try? JSONDecoder().decode(CachedConversationSuggestion.self, from: data)
else { return nil }
return cached
}
private func saveConversationCache(_ result: ConversationSuggestionResult, for person: Person) {
let cached = CachedConversationSuggestion(result: result)
if let data = try? JSONEncoder().encode(cached) {
UserDefaults.standard.set(data, forKey: conversationCacheKey(for: person))
}
}
// MARK: - Conversation Suggestion
func suggestConversation(person: Person) async throws -> ConversationSuggestionResult {
let config = AIConfig.load()
guard let url = URL(string: config.completionsURL) else {
throw URLError(.badURL)
}
let prompt = buildPrompt(for: person, promptType: .conversation)
let body: [String: Any] = [
"model": config.model,
"stream": false,
"messages": [
["role": "system", "content": AppLanguage.current.systemPrompt],
["role": "user", "content": prompt]
]
]
var request = URLRequest(url: url, timeoutInterval: config.timeoutSeconds)
request.httpMethod = "POST"
request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
guard
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let choices = json["choices"] as? [[String: Any]],
let first = choices.first,
let message = first["message"] as? [String: Any],
let content = message["content"] as? String
else {
throw URLError(.cannotParseResponse)
}
let result = parseConversationResult(content)
recordRequest()
saveConversationCache(result, for: person)
return result
}
// MARK: - Result Parser
private func parseResult(_ text: String) -> AIAnalysisResult {
@@ -426,4 +529,35 @@ class AIAnalysisService {
recommendation: extract("EMPFEHLUNG")
)
}
/// Extrahiert die drei Gesprächsvorschlag-Sektionen aus der KI-Antwort.
/// Internal (nicht private) damit Unit-Tests direkten Zugriff haben.
func parseConversationResult(_ text: String) -> ConversationSuggestionResult {
var normalized = text
for label in ["THEMEN", "GESPRAECHSRETTER", "TIEFE"] {
normalized = normalized
.replacingOccurrences(of: "**\(label):**", with: "\(label):")
.replacingOccurrences(of: "**\(label)**:", with: "\(label):")
.replacingOccurrences(of: "**\(label)** :", with: "\(label):")
}
func extract(_ label: String) -> String {
let pattern = "\(label):\\s*(.+?)(?=\\n(?:THEMEN|GESPRAECHSRETTER|TIEFE):|\\z)"
guard
let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]),
let match = regex.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)),
let range = Range(match.range(at: 1), in: normalized)
else { return "" }
// Verbleibende ** im Fließtext entfernen (KI-Markdown in Plain Text umwandeln)
return String(normalized[range])
.replacingOccurrences(of: "**", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return ConversationSuggestionResult(
topics: extract("THEMEN"),
rescue: extract("GESPRAECHSRETTER"),
depth: extract("TIEFE")
)
}
}
+253 -3
View File
@@ -31,6 +31,14 @@ struct AddMomentView: View {
@State private var selectedCalendarID: String = ""
@State private var eventAlarmOffset: Double = -3600 // Sekunden; 0 = keine Erinnerung
// KI-Gesprächsvorschläge
@StateObject private var store = StoreManager.shared
@AppStorage("aiConsentGiven") private var aiConsentGiven = false
@State private var conversationState: ConversationSuggestionUIState = .idle
@State private var showConversationPaywall = false
@State private var showConversationConsent = false
@State private var insertedSectionKey: String? = nil // für kurzes Checkmark-Feedback
// Vorhaben: Erinnerung
@State private var addReminder = false
@State private var reminderDate: Date = {
@@ -68,6 +76,7 @@ struct AddMomentView: View {
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Person-Kontext-Chip
@@ -127,11 +136,17 @@ struct AddMomentView: View {
.padding(.vertical, 10)
.focused($isFocused)
}
.frame(minHeight: 180)
.frame(minHeight: 120)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
// KI-Gesprächsvorschläge (nur bei Treffen)
if selectedType == .meeting || selectedType == .conversation {
conversationSuggestionsSection
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Treffen: Kalendertermin
if showsCalendarSection {
calendarSection
@@ -144,12 +159,14 @@ struct AddMomentView: View {
.transition(.opacity.combined(with: .move(edge: .top)))
}
Spacer()
}
.animation(.easeInOut(duration: 0.2), value: showsCalendarSection)
.animation(.easeInOut(duration: 0.2), value: showsReminderSection)
.animation(.easeInOut(duration: 0.2), value: selectedType)
.animation(.easeInOut(duration: 0.2), value: addToCalendar)
.animation(.easeInOut(duration: 0.2), value: addReminder)
.padding(.bottom, 24)
} // ScrollView
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationTitle("Moment festhalten")
.navigationBarTitleDisplayMode(.inline)
@@ -167,7 +184,12 @@ struct AddMomentView: View {
}
}
}
.onAppear { isFocused = true }
.onAppear {
isFocused = true
if let cached = AIAnalysisService.shared.loadCachedConversation(for: person) {
conversationState = .result(cached.asResult, cached.generatedAt)
}
}
}
// MARK: - Kalender-Sektion (Treffen)
@@ -454,4 +476,232 @@ struct AddMomentView: View {
CalendarEventStore.save(momentID: momentID, eventIdentifier: identifier)
}
}
// MARK: - KI-Gesprächsvorschläge
private var canUseAI: Bool {
store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
}
private var conversationSuggestionsSection: some View {
Group {
switch conversationState {
case .idle:
conversationIdleButton
case .loading:
conversationLoadingView
case .result(let result, _):
conversationResultView(result: result)
case .error(let message):
conversationErrorView(message: message)
case .insufficientData:
conversationInsufficientDataView
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
.sheet(isPresented: $showConversationPaywall) { PaywallView(targeting: .max) }
.sheet(isPresented: $showConversationConsent) {
AIConsentSheet {
aiConsentGiven = true
Task { await loadConversationSuggestions() }
}
}
}
private var conversationIdleButton: some View {
Button {
guard canUseAI else { showConversationPaywall = true; return }
if aiConsentGiven {
Task { await loadConversationSuggestions() }
} else {
showConversationConsent = true
}
} label: {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.system(size: 13))
Text("Gesprächsthemen vorschlagen")
.font(.system(size: 14, weight: .medium))
Spacer()
if store.isMax {
MaxBadge()
} else if canUseAI {
Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(theme.contentTertiary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(theme.backgroundSecondary)
.clipShape(Capsule())
} else {
MaxBadge()
}
}
.foregroundStyle(canUseAI ? theme.accent : theme.contentSecondary)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.buttonStyle(.plain)
}
private var conversationLoadingView: some View {
HStack(spacing: 10) {
ProgressView().tint(theme.accent)
VStack(alignment: .leading, spacing: 2) {
Text("Vorschläge werden generiert…")
.font(.system(size: 14))
.foregroundStyle(theme.contentSecondary)
Text("Das kann einen Moment dauern.")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
}
.padding(16)
}
private func conversationResultView(result: ConversationSuggestionResult) -> some View {
VStack(alignment: .leading, spacing: 0) {
conversationSection(icon: "text.bubble", title: "Themenvorschläge", sectionKey: "topics", content: result.topics)
RowDivider()
conversationSection(icon: "lifepreserver", title: "Gesprächsretter", sectionKey: "rescue", content: result.rescue)
RowDivider()
conversationSection(icon: "arrow.down.heart", title: "Tiefe erreichen", sectionKey: "depth", content: result.depth)
RowDivider()
Button {
Task { await loadConversationSuggestions() }
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.clockwise").font(.system(size: 12))
Text("Neue Vorschläge").font(.system(size: 13))
}
.foregroundStyle(theme.accent)
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
}
private func conversationSection(icon: String, title: String, sectionKey: String, content: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 13))
.foregroundStyle(theme.accent)
.frame(width: 20)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 6) {
Text(LocalizedStringKey(title))
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(theme.contentSecondary)
Text(content)
.font(.system(size: 14, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
// Übernehmen-Button
Button {
appendSuggestion(content, key: sectionKey)
} label: {
Image(systemName: insertedSectionKey == sectionKey ? "checkmark" : "arrow.up.doc")
.font(.system(size: 13))
.foregroundStyle(insertedSectionKey == sectionKey ? theme.accent : theme.contentTertiary)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
.animation(.easeInOut(duration: 0.2), value: insertedSectionKey)
}
.buttonStyle(.plain)
.padding(.top, 2)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private func appendSuggestion(_ content: String, key: String) {
let prefix = text.isEmpty ? "" : "\n\n"
text += prefix + content
withAnimation { insertedSectionKey = key }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { insertedSectionKey = nil }
}
}
private var conversationInsufficientDataView: some View {
HStack(spacing: 10) {
Image(systemName: "info.circle")
.font(.system(size: 14))
.foregroundStyle(theme.contentTertiary)
Text("Noch zu wenig Verlauf für persönliche Vorschläge.")
.font(.system(size: 14))
.foregroundStyle(theme.contentSecondary)
}
.padding(16)
}
private func conversationErrorView(message: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Label("Vorschläge fehlgeschlagen", systemImage: "exclamationmark.triangle")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(theme.contentSecondary)
Text(message)
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
Button {
Task { await loadConversationSuggestions() }
} label: {
Text("Erneut versuchen")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(theme.accent)
}
}
.padding(16)
}
/// Mindestanzahl an Momenten + Log-Einträgen für sinnvolle KI-Vorschläge.
private var hasEnoughHistory: Bool {
let momentCount = person.sortedMoments.count
let logCount = person.sortedLogEntries.count
return momentCount + logCount >= 2
}
private func loadConversationSuggestions() async {
guard hasEnoughHistory else {
conversationState = .insufficientData
return
}
guard !AIAnalysisService.shared.isRateLimited else { return }
conversationState = .loading
do {
let result = try await AIAnalysisService.shared.suggestConversation(person: person)
if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() }
conversationState = .result(result, Date())
} catch {
if let cached = AIAnalysisService.shared.loadCachedConversation(for: person) {
conversationState = .result(cached.asResult, cached.generatedAt)
} else {
conversationState = .error(error.localizedDescription)
}
}
}
}
// MARK: - Conversation Suggestion UI State
private enum ConversationSuggestionUIState {
case idle
case loading
case result(ConversationSuggestionResult, Date)
case error(String)
case insufficientData
}
extension ConversationSuggestionUIState: Equatable {
static func == (lhs: ConversationSuggestionUIState, rhs: ConversationSuggestionUIState) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle), (.loading, .loading), (.insufficientData, .insufficientData): return true
case (.error(let a), .error(let b)): return a == b
default: return false
}
}
}
+284 -177
View File
@@ -115,6 +115,16 @@
}
}
},
"%lld / %lld" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lld / %2$lld"
}
}
}
},
"%lld ausgewählt" : {
"extractionState" : "stale",
"localizations" : {
@@ -523,6 +533,9 @@
}
}
}
},
"Alle" : {
},
"Alle %lld Einträge anzeigen" : {
"localizations" : {
@@ -780,18 +793,6 @@
}
}
},
"Anstehende Termine" : {
"comment" : "TodayView section title for upcoming reminders",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Upcoming Events"
}
}
}
},
"Anstehende Erinnerungen" : {
"comment" : "TodayView replaced by 'Anstehende Unternehmungen'",
"extractionState" : "stale",
@@ -815,6 +816,18 @@
}
}
},
"Anstehende Termine" : {
"comment" : "TodayView section title for upcoming reminders",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Upcoming Events"
}
}
}
},
"Anstehende Unternehmungen" : {
"comment" : "TodayView section title for plannable moments (Treffen, Gespräch, Vorhaben) with upcoming reminder dates",
"localizations" : {
@@ -1195,6 +1208,17 @@
}
}
},
"Das kann einen Moment dauern." : {
"comment" : "AddMomentView conversation suggestions loading subtitle",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This may take a moment."
}
}
}
},
"Daten werden geräteübergreifend synchronisiert" : {
"comment" : "SettingsView iCloud sync enabled subtitle",
"localizations" : {
@@ -1529,6 +1553,17 @@
}
}
},
"Dieser Moment wird unwiderruflich gelöscht." : {
"comment" : "EditMomentView delete confirmation message",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This moment will be permanently deleted."
}
}
}
},
"Distanzierter" : {
"comment" : "RatingQuestion negative pole for relationship closeness question",
"extractionState" : "stale",
@@ -2171,6 +2206,28 @@
}
}
},
"Fällig am" : {
"comment" : "AddTodoView label for due date picker",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Due on"
}
}
}
},
"Fällige Todos" : {
"comment" : "TodayView section header for due todos",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Due Todos"
}
}
}
},
"Falscher Code" : {
"comment" : "AppLockView / AppLockSetupView wrong PIN error",
"localizations" : {
@@ -2540,6 +2597,41 @@
}
}
},
"Gesprächsretter" : {
"comment" : "AddMomentView conversation suggestions section title",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Conversation Rescue"
}
}
}
},
"Gesprächsthemen vorschlagen" : {
"comment" : "AddMomentView AI conversation suggestions button label",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Suggest Conversation Topics"
}
}
}
},
"Gesprächsthemen vorschlagen: KI-Impulse für bessere Treffen" : {
"comment" : "PaywallView Max feature list item",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Suggest Conversation Topics: AI impulses for better meetings"
}
}
}
},
"Gesprächszeit" : {
"comment" : "SettingsView section header / CallWindowSetupView nav title",
"localizations" : {
@@ -3463,6 +3555,28 @@
}
}
},
"Moment löschen?" : {
"comment" : "EditMomentView delete confirmation dialog title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Delete Moment?"
}
}
}
},
"Moment mit Kalendereintrag löschen?" : {
"comment" : "EditMomentView calendar delete confirmation dialog title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Delete Moment with Calendar Event?"
}
}
}
},
"Moment…" : {
"localizations" : {
"en" : {
@@ -3834,6 +3948,17 @@
}
}
},
"Neue Vorschläge" : {
"comment" : "AddMomentView conversation suggestions reload button",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "New Suggestions"
}
}
}
},
"Neurodivers" : {
"comment" : "ThemePickerView neurodiverse themes group header",
"localizations" : {
@@ -3925,6 +4050,17 @@
}
}
},
"Noch zu wenig Verlauf für persönliche Vorschläge." : {
"comment" : "AddMomentView conversation suggestions insufficient data message",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Not enough history yet for personal suggestions."
}
}
}
},
"Noch keine Einträge" : {
"comment" : "LogbuchView empty state title",
"localizations" : {
@@ -3969,6 +4105,17 @@
}
}
},
"Noch keine Todos." : {
"comment" : "PersonDetailView empty state message when person has no todos",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No todos yet."
}
}
}
},
"Noch keine Treffen bewertet" : {
"comment" : "VisitHistorySection empty state title",
"extractionState" : "stale",
@@ -4733,6 +4880,18 @@
}
}
},
"Themenvorschläge" : {
"comment" : "AddMomentView conversation suggestions section title",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Topic Suggestions"
}
}
}
},
"Tief & fokussiert · ND" : {
"comment" : "Theme tagline for Abyss",
"localizations" : {
@@ -4744,6 +4903,18 @@
}
}
},
"Tiefe erreichen" : {
"comment" : "AddMomentView conversation suggestions section title",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Going Deeper"
}
}
}
},
"Tippe auf + um jemanden hinzuzufügen." : {
"comment" : "A description of how to add a new contact.",
"isCommentAutoGenerated" : true,
@@ -4768,6 +4939,62 @@
}
}
},
"Todo" : {
"comment" : "PersonDetailView button label to add a new Todo",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Todo"
}
}
}
},
"Todo abgeschlossen" : {
"comment" : "LogEntryType.todoCompleted raw value label",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Todo completed"
}
}
}
},
"Todo anlegen" : {
"comment" : "AddTodoView navigation title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add Todo"
}
}
}
},
"Todo bearbeiten" : {
"comment" : "EditTodoView navigation title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Edit Todo"
}
}
}
},
"Todos" : {
"comment" : "PersonDetailView section header for todos",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Todos"
}
}
}
},
"Touch ID aktiviert" : {
"comment" : "SettingsView biometric label when Touch ID is active",
"localizations" : {
@@ -5023,6 +5250,7 @@
}
},
"Verlauf" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -5032,6 +5260,17 @@
}
}
},
"Verlauf & KI-Analyse" : {
"comment" : "PersonDetailView logbuch section header",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "History & AI Analysis"
}
}
}
},
"Version" : {
"comment" : "SettingsView version info row label",
"extractionState" : "stale",
@@ -5200,6 +5439,28 @@
},
"Vorschau Geburtstage & Termine" : {
},
"Vorschläge fehlgeschlagen" : {
"comment" : "AddMomentView conversation suggestions error title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Suggestions Failed"
}
}
}
},
"Vorschläge werden generiert…" : {
"comment" : "AddMomentView conversation suggestions loading state",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Generating suggestions…"
}
}
}
},
"Wähle bis zu 3 Menschen aus deinem Adressbuch, die dir wichtig sind." : {
@@ -5308,6 +5569,17 @@
}
}
},
"Was möchtest du erledigen?" : {
"comment" : "AddTodoView placeholder text for todo title input",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "What do you want to do?"
}
}
}
},
"Was war der Kern des Gesprächs?\nWas möchtest du nicht vergessen?" : {
"comment" : "AddMomentView text editor placeholder",
"extractionState" : "stale",
@@ -5750,171 +6022,6 @@
}
}
}
},
"Todo" : {
"comment" : "PersonDetailView button label to add a new Todo",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Todo"
}
}
}
},
"Todo anlegen" : {
"comment" : "AddTodoView navigation title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add Todo"
}
}
}
},
"Dieser Moment wird unwiderruflich gelöscht." : {
"comment" : "EditMomentView delete confirmation message",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This moment will be permanently deleted."
}
}
}
},
"Moment löschen" : {
"comment" : "EditMomentView delete button label",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Delete Moment"
}
}
}
},
"Moment löschen?" : {
"comment" : "EditMomentView delete confirmation dialog title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Delete Moment?"
}
}
}
},
"Moment + Kalendereintrag löschen" : {
"comment" : "EditMomentView delete moment + calendar event option",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Delete Moment + Calendar Event"
}
}
}
},
"Moment mit Kalendereintrag löschen?" : {
"comment" : "EditMomentView calendar delete confirmation dialog title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Delete Moment with Calendar Event?"
}
}
}
},
"Nur Moment löschen" : {
"comment" : "EditMomentView delete only the moment, keep calendar event",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Delete Moment Only"
}
}
}
},
"Todo bearbeiten" : {
"comment" : "EditTodoView navigation title",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Edit Todo"
}
}
}
},
"Todos" : {
"comment" : "PersonDetailView section header for todos",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Todos"
}
}
}
},
"Was möchtest du erledigen?" : {
"comment" : "AddTodoView placeholder text for todo title input",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "What do you want to do?"
}
}
}
},
"Fällig am" : {
"comment" : "AddTodoView label for due date picker",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Due on"
}
}
}
},
"Fällige Todos" : {
"comment" : "TodayView section header for due todos",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Due Todos"
}
}
}
},
"Noch keine Todos." : {
"comment" : "PersonDetailView empty state message when person has no todos",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No todos yet."
}
}
}
},
"Speichern" : {
"comment" : "AddTodoView save button",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Save"
}
}
}
}
},
"version" : "1.1"
+3
View File
@@ -299,12 +299,14 @@ enum LogEntryType: String, Codable {
case nextStep = "Schritt abgeschlossen"
case calendarEvent = "Termin geplant"
case call = "Anruf"
case todoCompleted = "Todo abgeschlossen"
var icon: String {
switch self {
case .nextStep: return "checkmark.circle.fill"
case .calendarEvent: return "calendar.badge.checkmark"
case .call: return "phone.circle.fill"
case .todoCompleted: return "checkmark.square.fill"
}
}
@@ -313,6 +315,7 @@ enum LogEntryType: String, Codable {
case .nextStep: return "green"
case .calendarEvent: return "blue"
case .call: return "accent"
case .todoCompleted: return "green"
}
}
}
+1
View File
@@ -27,6 +27,7 @@ struct PaywallView: View {
private let maxExtraFeatures: [(icon: String, text: String)] = [
("brain.head.profile", "KI-Analyse: Muster, Beziehungsqualität & Empfehlungen"),
("gift.fill", "Geschenkideen: KI-Vorschläge bei Geburtstagen"),
("text.bubble.fill", "Gesprächsthemen vorschlagen: KI-Impulse für bessere Treffen"),
("infinity", "Unbegrenzte KI-Abfragen ohne Limit"),
]
+45 -13
View File
@@ -39,7 +39,7 @@ struct PersonDetailView: View {
personHeader
momentsSection
todosSection
if !person.sortedLogEntries.isEmpty { logbuchSection }
if !person.sortedMoments.isEmpty || !person.sortedLogEntries.isEmpty { logbuchSection }
if hasInfoContent { infoSection }
}
.padding(.horizontal, 20)
@@ -264,26 +264,52 @@ struct PersonDetailView: View {
private let logbuchPreviewLimit = 5
// Lokaler Hilfstyp für die gemischte Vorschau
private struct LogPreviewItem: Identifiable {
let id: String
let icon: String
let title: String
let typeLabel: String
let date: Date
}
private var mergedLogPreview: [LogPreviewItem] {
let momentItems = person.sortedMoments.map {
LogPreviewItem(id: "m-\($0.id)", icon: $0.type.icon, title: $0.text,
typeLabel: $0.type.displayName, date: $0.createdAt)
}
let entryItems = person.sortedLogEntries.map {
LogPreviewItem(id: "e-\($0.id)", icon: $0.type.icon, title: $0.title,
typeLabel: $0.type.rawValue, date: $0.loggedAt)
}
return (momentItems + entryItems).sorted { $0.date > $1.date }
}
private var logbuchSection: some View {
let entries = person.sortedLogEntries
let preview = Array(entries.prefix(logbuchPreviewLimit))
let hasMore = entries.count > logbuchPreviewLimit
let allItems = mergedLogPreview
let preview = Array(allItems.prefix(logbuchPreviewLimit))
let hasMore = allItems.count > logbuchPreviewLimit
return VStack(alignment: .leading, spacing: 10) {
HStack {
SectionHeader(title: "Verlauf", icon: "book.closed")
SectionHeader(title: "Verlauf & KI-Analyse", icon: "sparkles")
Spacer()
NavigationLink(destination: LogbuchView(person: person)) {
Text("Alle")
.font(.system(size: 13))
.foregroundStyle(theme.accent)
}
}
VStack(spacing: 0) {
ForEach(Array(preview.enumerated()), id: \.element.id) { index, entry in
logEntryPreviewRow(entry)
ForEach(Array(preview.enumerated()), id: \.element.id) { index, item in
logPreviewRow(item)
if index < preview.count - 1 || hasMore { RowDivider() }
}
if hasMore {
NavigationLink(destination: LogbuchView(person: person)) {
HStack {
Text("Alle \(entries.count) Einträge anzeigen")
Text("Alle \(allItems.count) Einträge anzeigen")
.font(.system(size: 14))
.foregroundStyle(theme.accent)
Spacer()
@@ -301,27 +327,28 @@ struct PersonDetailView: View {
}
}
private func logEntryPreviewRow(_ entry: LogEntry) -> some View {
private func logPreviewRow(_ item: LogPreviewItem) -> some View {
HStack(spacing: 12) {
Image(systemName: entry.type.icon)
Image(systemName: item.icon)
.font(.system(size: 14, weight: .light))
.foregroundStyle(theme.accent)
.frame(width: 20)
VStack(alignment: .leading, spacing: 3) {
Text(entry.title)
Text(item.title)
.font(.system(size: 15, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 6) {
Text(LocalizedStringKey(entry.type.rawValue))
Text(LocalizedStringKey(item.typeLabel))
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
Text("·")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
Text(entry.loggedAt.formatted(.dateTime.day().month(.abbreviated).year()))
Text(item.date.formatted(.dateTime.day().month(.abbreviated).year()))
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
@@ -472,6 +499,11 @@ struct PersonDetailView: View {
UNUserNotificationCenter.current()
.removePendingNotificationRequests(withIdentifiers: ["todo-\(todo.id)"])
// Logbuch-Eintrag erstellen
let entry = LogEntry(type: .todoCompleted, title: todo.title, person: person)
modelContext.insert(entry)
person.logEntries?.append(entry)
// Nach 5 Sek. sanft ausblenden
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
withAnimation(.easeOut(duration: 0.35)) {
+9
View File
@@ -803,6 +803,15 @@ enum AppLanguage: String, CaseIterable {
}
}
var conversationInstruction: String {
switch self {
case .german:
return "Du bereitest mich auf ein bevorstehendes Treffen mit dieser Person vor. Halte dich STRIKT an die vorhandenen Momente und Log-Einträge. Erfinde KEINE Details, Erlebnisse oder Themen die nicht explizit in den Daten stehen. Gib mir sehr knappe Vorschläge maximal 8 Wörter pro Punkt, nur Stichwörter oder kurze Fragen. Antworte in exakt diesem Format:\n\nTHEMEN: [2-3 Stichworte oder kurze Fragen aus den echten Daten, kommasepariert]\nGESPRAECHSRETTER: [2-3 kurze Impulse, je max. 8 Wörter, kommasepariert]\nTIEFE: [ein konkreter Tipp, max. 12 Wörter]"
case .english:
return "You are preparing me for an upcoming meeting with this person. Stick STRICTLY to the available moments and log entries. Do NOT invent details, experiences or topics that are not explicitly in the data. Give very concise suggestions maximum 8 words per point, keywords or short questions only. Respond in exactly this format:\n\nTHEMEN: [2-3 keywords or short questions from the actual data, comma-separated]\nGESPRAECHSRETTER: [2-3 short impulses, max. 8 words each, comma-separated]\nTIEFE: [one concrete tip, max. 12 words]"
}
}
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" }
+5
View File
@@ -372,6 +372,11 @@ struct TodayView: View {
UNUserNotificationCenter.current()
.removePendingNotificationRequests(withIdentifiers: ["todo-\(todo.id)"])
// Logbuch-Eintrag erstellen
let entry = LogEntry(type: .todoCompleted, title: todo.title, person: todo.person)
modelContext.insert(entry)
todo.person?.logEntries?.append(entry)
do {
try modelContext.save()
} catch {
@@ -0,0 +1,117 @@
import Testing
import Foundation
@testable import nahbar
@Suite("ConversationSuggestionResult")
struct ConversationSuggestionTests {
let service = AIAnalysisService.shared
// MARK: - Parsing
@Test("Alle drei Sektionen werden korrekt extrahiert")
func parsesAllThreeSections() {
let input = """
THEMEN: Erinnerst du dich an euren Wandertrip letzten Herbst? Frag nach den Fotos.
GESPRAECHSRETTER: Stell eine offene Frage zu ihrer Arbeit. Erzähl von deiner letzten Begegnung mit X.
TIEFE: Frag sie, was ihr aktuell am meisten Energie gibt das öffnet meist viele Türen.
"""
let result = service.parseConversationResult(input)
#expect(result.topics.contains("Wandertrip"))
#expect(result.rescue.contains("offene Frage"))
#expect(result.depth.contains("Energie"))
}
@Test("Fettdruck-Markierungen werden vor dem Parsing normalisiert")
func normalizesBoldMarkers() {
let input = """
**THEMEN:** Letzte Reise gemeinsam besprechen.
**GESPRAECHSRETTER:** Nach dem Haustier fragen.
**TIEFE:** Frag nach einem Traum oder Ziel.
"""
let result = service.parseConversationResult(input)
#expect(!result.topics.isEmpty)
#expect(!result.rescue.isEmpty)
#expect(!result.depth.isEmpty)
}
@Test("Alternativer Fettdruck-Stil wird ebenfalls normalisiert")
func normalizesAlternativeBoldStyle() {
let input = """
**THEMEN**: Thema eins.
**GESPRAECHSRETTER**: Rettungsanker.
**TIEFE**: Tiefer gehen.
"""
let result = service.parseConversationResult(input)
#expect(!result.topics.isEmpty)
#expect(!result.rescue.isEmpty)
#expect(!result.depth.isEmpty)
}
@Test("Fehlende Sektion gibt leeren String zurück (kein Crash)")
func missingRescueSectionReturnsEmpty() {
let input = """
THEMEN: Nur dieses Thema.
TIEFE: Nur diese Tiefe.
"""
let result = service.parseConversationResult(input)
#expect(!result.topics.isEmpty)
#expect(result.rescue.isEmpty)
#expect(!result.depth.isEmpty)
}
@Test("Vollständig leere Eingabe gibt drei leere Strings zurück")
func emptyInputReturnsAllEmpty() {
let result = service.parseConversationResult("")
#expect(result.topics.isEmpty)
#expect(result.rescue.isEmpty)
#expect(result.depth.isEmpty)
}
@Test("Mehrzeiliger Sektionsinhalt bleibt vollständig erhalten")
func multilineTopicsPreserved() {
let input = """
THEMEN: Zeile 1
Zeile 2
Zeile 3
GESPRAECHSRETTER: Rettung.
TIEFE: Tiefe.
"""
let result = service.parseConversationResult(input)
#expect(result.topics.contains("Zeile 1"))
#expect(result.topics.contains("Zeile 2"))
}
// MARK: - Codable Round-Trip
@Test("CachedConversationSuggestion Codable Round-Trip")
func cacheRoundTrip() throws {
let original = ConversationSuggestionResult(
topics: "Reise besprechen",
rescue: "Nach Hobbys fragen",
depth: "Was gibt dir Energie?"
)
let cached = CachedConversationSuggestion(result: original, date: Date(timeIntervalSince1970: 1_000_000))
let data = try JSONEncoder().encode(cached)
let decoded = try JSONDecoder().decode(CachedConversationSuggestion.self, from: data)
#expect(decoded.topics == original.topics)
#expect(decoded.rescue == original.rescue)
#expect(decoded.depth == original.depth)
#expect(decoded.generatedAt.timeIntervalSince1970 == 1_000_000)
}
@Test("asResult gibt korrekte ConversationSuggestionResult zurück")
func asResultRoundTrip() {
let original = ConversationSuggestionResult(
topics: "Thema A",
rescue: "Rettung B",
depth: "Tiefe C"
)
let cached = CachedConversationSuggestion(result: original)
let result = cached.asResult
#expect(result.topics == "Thema A")
#expect(result.rescue == "Rettung B")
#expect(result.depth == "Tiefe C")
}
}