Compare commits
15 Commits
9a429f11a6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ecc44a625 | |||
| f1de4bfd30 | |||
| 3ac221a049 | |||
| 22e1d68217 | |||
| 74bd53407d | |||
| 7057ccb607 | |||
| a3ae925a10 | |||
| 319b59c12e | |||
| 18112cb52c | |||
| b477a3e04b | |||
| d541640c74 | |||
| 30b150a286 | |||
| 4a9bb32b5e | |||
| 2b9346c78b | |||
| 66a7b23f5a |
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user