Compare commits
36 Commits
d541640c74
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ba0802a29 | |||
| e4c2293bec | |||
| 2c274ff4ae | |||
| 0c8e78f49d | |||
| 8180fcbcbc | |||
| 8af0309580 | |||
| 8427f55f21 | |||
| 31a7a2d5df | |||
| 9e932f0f2c | |||
| ace6801d01 | |||
| 8a13962055 | |||
| 5f14649112 | |||
| e365400537 | |||
| 801095d9b9 | |||
| bf1b49697b | |||
| ec0dc68db9 | |||
| 86ddc10e7b | |||
| c647553eb7 | |||
| 1e75f357ba | |||
| a0741ba608 | |||
| b214bb6c50 | |||
| 55991808cf | |||
| 9ca54e6a82 | |||
| 17f4dbd3ab | |||
| 7605a2d30c | |||
| c4202cbf2f | |||
| 1ecc44a625 | |||
| f1de4bfd30 | |||
| 3ac221a049 | |||
| 22e1d68217 | |||
| 74bd53407d | |||
| 7057ccb607 | |||
| a3ae925a10 | |||
| 319b59c12e | |||
| 18112cb52c | |||
| b477a3e04b |
@@ -58,15 +58,10 @@ final class AftermathNotificationManager {
|
|||||||
|
|
||||||
private func createNotification(momentID: UUID, personName: String, delay: TimeInterval) {
|
private func createNotification(momentID: UUID, personName: String, delay: TimeInterval) {
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = String.localizedStringWithFormat(String(localized: "Nachwirkung: %@"), personName)
|
// Wärmerer, gesprächigerer Titel statt klinischem "Nachwirkung: [Name]"
|
||||||
// Persönlichkeitsgerechter Body-Text (softer für hohen Neurotizismus)
|
content.title = String.localizedStringWithFormat(String(localized: "Wie war's mit %@?"), personName)
|
||||||
let defaultBody = String(localized: "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen – dauert 1 Minute.")
|
// Persönlichkeitsgerechter Body-Text via PersonalityEngine
|
||||||
if let profile = PersonalityStore.shared.profile,
|
content.body = PersonalityEngine.aftermathCopy(profile: PersonalityStore.shared.profile)
|
||||||
case .delayed(_, let softerCopy?) = PersonalityEngine.ratingPromptTiming(for: profile) {
|
|
||||||
content.body = softerCopy
|
|
||||||
} else {
|
|
||||||
content.body = defaultBody
|
|
||||||
}
|
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
content.categoryIdentifier = Self.categoryID
|
content.categoryIdentifier = Self.categoryID
|
||||||
content.userInfo = [
|
content.userInfo = [
|
||||||
|
|||||||
@@ -2,18 +2,19 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
// MARK: - AftermathRatingFlowView
|
// MARK: - AftermathRatingFlowView
|
||||||
// Sheet-basierter Bewertungs-Flow für die Nachwirkungs-Bewertung (4 Fragen).
|
// Scrollbarer Bewertungs-Flow für die Nachwirkungs-Bewertung (4 Fragen).
|
||||||
// Wird aus einer Push-Notification heraus oder aus der Momente-Liste geöffnet.
|
// Gleiche Interaktion wie MeetingRatingFlowView – Fragen blenden nacheinander ein.
|
||||||
|
|
||||||
struct AftermathRatingFlowView: View {
|
struct AftermathRatingFlowView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.nahbarTheme) var theme
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
let moment: Moment
|
let moment: Moment
|
||||||
|
|
||||||
private let questions = RatingQuestion.aftermath // 4 Fragen
|
private let questions = RatingQuestion.aftermath // 4 Fragen
|
||||||
@State private var currentIndex: Int = 0
|
|
||||||
@State private var values: [Int?]
|
@State private var values: [Int?]
|
||||||
|
@State private var revealedCount: Int = 1
|
||||||
@State private var showSummary: Bool = false
|
@State private var showSummary: Bool = false
|
||||||
|
|
||||||
init(moment: Moment) {
|
init(moment: Moment) {
|
||||||
@@ -27,55 +28,85 @@ struct AftermathRatingFlowView: View {
|
|||||||
if showSummary {
|
if showSummary {
|
||||||
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
|
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
|
||||||
} else {
|
} else {
|
||||||
questionStep
|
questionFlow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Nachwirkung")
|
.navigationTitle("Nachwirkung")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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 {
|
.padding(16)
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
.padding(.bottom, 32)
|
||||||
Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") {
|
}
|
||||||
advance()
|
.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 saveButton: some View {
|
||||||
|
Button { saveAftermath() } label: {
|
||||||
private var questionStep: some View {
|
Text("Speichern")
|
||||||
ZStack {
|
.font(.system(size: 16, weight: .semibold))
|
||||||
RatingQuestionView(
|
.foregroundStyle(.white)
|
||||||
question: questions[currentIndex],
|
.frame(maxWidth: .infinity)
|
||||||
index: currentIndex,
|
.padding(.vertical, 15)
|
||||||
total: questions.count,
|
.background(theme.accent)
|
||||||
value: $values[currentIndex]
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
)
|
|
||||||
.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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
private func saveAftermath() {
|
||||||
for (i, q) in questions.enumerated() {
|
for (i, q) in questions.enumerated() {
|
||||||
let rating = Rating(
|
let rating = Rating(
|
||||||
@@ -91,7 +122,6 @@ struct AftermathRatingFlowView: View {
|
|||||||
moment.meetingStatus = .completed
|
moment.meetingStatus = .completed
|
||||||
moment.aftermathCompletedAt = Date()
|
moment.aftermathCompletedAt = Date()
|
||||||
|
|
||||||
// Evtl. geplante Notification abbrechen (falls Nutzer selbst geöffnet hat)
|
|
||||||
AftermathNotificationManager.shared.cancelAftermath(momentID: moment.id)
|
AftermathNotificationManager.shared.cancelAftermath(momentID: moment.id)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -1,5 +1,73 @@
|
|||||||
import SwiftUI
|
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
|
// MARK: - RatingQuestionView
|
||||||
// Zeigt eine einzelne Bewertungsfrage mit Kategorie-Badge, Fragetext,
|
// Zeigt eine einzelne Bewertungsfrage mit Kategorie-Badge, Fragetext,
|
||||||
// RatingDotPicker und "Überspringen"-Button.
|
// RatingDotPicker und "Überspringen"-Button.
|
||||||
|
|||||||
@@ -2,24 +2,21 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
// MARK: - MeetingRatingFlowView
|
// MARK: - MeetingRatingFlowView
|
||||||
// Sheet-basierter Bewertungs-Flow für die Sofort-Bewertung eines Treffen-Moments.
|
// Scrollbarer Bewertungs-Flow für die Sofort-Bewertung eines Treffens.
|
||||||
// Erwartet einen bereits gespeicherten Moment vom Typ .meeting und ergänzt ihn
|
// Fragen blenden nacheinander von unten ein, sobald die vorherige beantwortet wurde.
|
||||||
// um Ratings sowie den Nachwirkungs-Status.
|
// Nach der letzten Frage erscheint ein "Speichern"-Button.
|
||||||
|
|
||||||
struct MeetingRatingFlowView: View {
|
struct MeetingRatingFlowView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.nahbarTheme) var theme
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
let moment: Moment
|
let moment: Moment
|
||||||
|
|
||||||
// Nachwirkungs-Verzögerung (aus App-Einstellungen übergeben)
|
|
||||||
var aftermathDelay: TimeInterval = 36 * 3600
|
var aftermathDelay: TimeInterval = 36 * 3600
|
||||||
|
|
||||||
// MARK: State
|
|
||||||
|
|
||||||
private let questions = RatingQuestion.immediate // 5 Fragen
|
private let questions = RatingQuestion.immediate // 5 Fragen
|
||||||
@State private var currentIndex: Int = 0
|
@State private var values: [Int?]
|
||||||
@State private var values: [Int?] // [nil] × 5
|
@State private var revealedCount: Int = 1
|
||||||
@State private var showSummary: Bool = false
|
@State private var showSummary: Bool = false
|
||||||
|
|
||||||
init(moment: Moment, aftermathDelay: TimeInterval = 36 * 3600) {
|
init(moment: Moment, aftermathDelay: TimeInterval = 36 * 3600) {
|
||||||
@@ -34,52 +31,80 @@ struct MeetingRatingFlowView: View {
|
|||||||
if showSummary {
|
if showSummary {
|
||||||
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
|
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
|
||||||
} else {
|
} else {
|
||||||
questionStep
|
questionFlow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Treffen bewerten")
|
.navigationTitle("Treffen bewerten")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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 {
|
.padding(16)
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
.padding(.bottom, 32)
|
||||||
Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") {
|
}
|
||||||
advance()
|
.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 saveButton: some View {
|
||||||
|
Button { saveRatings() } label: {
|
||||||
private var questionStep: some View {
|
Text("Speichern")
|
||||||
ZStack {
|
.font(.system(size: 16, weight: .semibold))
|
||||||
RatingQuestionView(
|
.foregroundStyle(.white)
|
||||||
question: questions[currentIndex],
|
.frame(maxWidth: .infinity)
|
||||||
index: currentIndex,
|
.padding(.vertical, 15)
|
||||||
total: questions.count,
|
.background(theme.accent)
|
||||||
value: $values[currentIndex]
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
)
|
|
||||||
.id(currentIndex)
|
|
||||||
.transition(.asymmetric(
|
|
||||||
insertion: .move(edge: .trailing),
|
|
||||||
removal: .move(edge: .leading)
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
.clipped()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Navigation
|
// MARK: - Navigation
|
||||||
|
|
||||||
private func advance() {
|
private func revealNext(after index: Int) {
|
||||||
if currentIndex < questions.count - 1 {
|
guard index == revealedCount - 1 else { return }
|
||||||
withAnimation { currentIndex += 1 }
|
guard revealedCount < questions.count else { return }
|
||||||
} else {
|
withAnimation(.easeOut(duration: 0.35)) {
|
||||||
saveRatings()
|
revealedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +137,6 @@ struct MeetingRatingFlowView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nachwirkungs-Notification planen
|
|
||||||
AftermathNotificationManager.shared.scheduleAftermath(
|
AftermathNotificationManager.shared.scheduleAftermath(
|
||||||
momentID: moment.id,
|
momentID: moment.id,
|
||||||
personName: moment.person?.firstName ?? "",
|
personName: moment.person?.firstName ?? "",
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
263FF44A2F99356A00C1957C /* TourID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF4492F99356A00C1957C /* TourID.swift */; };
|
||||||
|
263FF44C2F99356E00C1957C /* TourTargetID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF44B2F99356E00C1957C /* TourTargetID.swift */; };
|
||||||
|
263FF44E2F99357500C1957C /* TourStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF44D2F99357500C1957C /* TourStep.swift */; };
|
||||||
|
263FF4502F99357F00C1957C /* Tour.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF44F2F99357F00C1957C /* Tour.swift */; };
|
||||||
|
263FF4522F99358800C1957C /* TourCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF4512F99358800C1957C /* TourCatalog.swift */; };
|
||||||
|
263FF4542F99359600C1957C /* TourSeenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF4532F99359600C1957C /* TourSeenStore.swift */; };
|
||||||
|
263FF4562F9935AC00C1957C /* TourCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF4552F9935AC00C1957C /* TourCoordinator.swift */; };
|
||||||
|
263FF4582F9935BC00C1957C /* SpotlightShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF4572F9935BC00C1957C /* SpotlightShape.swift */; };
|
||||||
|
263FF45A2F9935CD00C1957C /* TourCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF4592F9935CD00C1957C /* TourCardView.swift */; };
|
||||||
|
263FF45C2F9935E400C1957C /* TourOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF45B2F9935E400C1957C /* TourOverlayView.swift */; };
|
||||||
|
263FF45E2F9935EF00C1957C /* TourViewModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF45D2F9935EF00C1957C /* TourViewModifiers.swift */; };
|
||||||
2670595C2F96640E00956084 /* CalendarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2670595B2F96640E00956084 /* CalendarManager.swift */; };
|
2670595C2F96640E00956084 /* CalendarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2670595B2F96640E00956084 /* CalendarManager.swift */; };
|
||||||
269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269ECE652F92B5C700444B14 /* NahbarMigration.swift */; };
|
269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269ECE652F92B5C700444B14 /* NahbarMigration.swift */; };
|
||||||
26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B1E2DA2F93985A009CF58B /* CloudSyncMonitor.swift */; };
|
26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B1E2DA2F93985A009CF58B /* CloudSyncMonitor.swift */; };
|
||||||
@@ -99,6 +110,17 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
263FF4492F99356A00C1957C /* TourID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourID.swift; sourceTree = "<group>"; };
|
||||||
|
263FF44B2F99356E00C1957C /* TourTargetID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourTargetID.swift; sourceTree = "<group>"; };
|
||||||
|
263FF44D2F99357500C1957C /* TourStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourStep.swift; sourceTree = "<group>"; };
|
||||||
|
263FF44F2F99357F00C1957C /* Tour.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tour.swift; sourceTree = "<group>"; };
|
||||||
|
263FF4512F99358800C1957C /* TourCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourCatalog.swift; sourceTree = "<group>"; };
|
||||||
|
263FF4532F99359600C1957C /* TourSeenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourSeenStore.swift; sourceTree = "<group>"; };
|
||||||
|
263FF4552F9935AC00C1957C /* TourCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourCoordinator.swift; sourceTree = "<group>"; };
|
||||||
|
263FF4572F9935BC00C1957C /* SpotlightShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightShape.swift; sourceTree = "<group>"; };
|
||||||
|
263FF4592F9935CD00C1957C /* TourCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourCardView.swift; sourceTree = "<group>"; };
|
||||||
|
263FF45B2F9935E400C1957C /* TourOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourOverlayView.swift; sourceTree = "<group>"; };
|
||||||
|
263FF45D2F9935EF00C1957C /* TourViewModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourViewModifiers.swift; sourceTree = "<group>"; };
|
||||||
265F92202F9109B500CE0A5C /* nahbar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nahbar.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
265F92202F9109B500CE0A5C /* nahbar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nahbar.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
2670595B2F96640E00956084 /* CalendarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManager.swift; sourceTree = "<group>"; };
|
2670595B2F96640E00956084 /* CalendarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManager.swift; sourceTree = "<group>"; };
|
||||||
269ECE652F92B5C700444B14 /* NahbarMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarMigration.swift; sourceTree = "<group>"; };
|
269ECE652F92B5C700444B14 /* NahbarMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarMigration.swift; sourceTree = "<group>"; };
|
||||||
@@ -211,6 +233,24 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
263FF4482F99356600C1957C /* Tour */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
263FF4492F99356A00C1957C /* TourID.swift */,
|
||||||
|
263FF44B2F99356E00C1957C /* TourTargetID.swift */,
|
||||||
|
263FF44D2F99357500C1957C /* TourStep.swift */,
|
||||||
|
263FF44F2F99357F00C1957C /* Tour.swift */,
|
||||||
|
263FF4512F99358800C1957C /* TourCatalog.swift */,
|
||||||
|
263FF4532F99359600C1957C /* TourSeenStore.swift */,
|
||||||
|
263FF4552F9935AC00C1957C /* TourCoordinator.swift */,
|
||||||
|
263FF4572F9935BC00C1957C /* SpotlightShape.swift */,
|
||||||
|
263FF4592F9935CD00C1957C /* TourCardView.swift */,
|
||||||
|
263FF45B2F9935E400C1957C /* TourOverlayView.swift */,
|
||||||
|
263FF45D2F9935EF00C1957C /* TourViewModifiers.swift */,
|
||||||
|
);
|
||||||
|
path = Tour;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
265F92172F9109B500CE0A5C = {
|
265F92172F9109B500CE0A5C = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -290,6 +330,7 @@
|
|||||||
26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */,
|
26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */,
|
||||||
2670595B2F96640E00956084 /* CalendarManager.swift */,
|
2670595B2F96640E00956084 /* CalendarManager.swift */,
|
||||||
26D07C682F9866DE001D3F98 /* AddTodoView.swift */,
|
26D07C682F9866DE001D3F98 /* AddTodoView.swift */,
|
||||||
|
263FF4482F99356600C1957C /* Tour */,
|
||||||
);
|
);
|
||||||
path = nahbar;
|
path = nahbar;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -441,7 +482,9 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */,
|
269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */,
|
||||||
|
263FF4522F99358800C1957C /* TourCatalog.swift in Sources */,
|
||||||
26EF66322F9112E700824F91 /* Models.swift in Sources */,
|
26EF66322F9112E700824F91 /* Models.swift in Sources */,
|
||||||
|
263FF44A2F99356A00C1957C /* TourID.swift in Sources */,
|
||||||
26F8B0CF2F94E7B1004905B9 /* PersonalityQuizView.swift in Sources */,
|
26F8B0CF2F94E7B1004905B9 /* PersonalityQuizView.swift in Sources */,
|
||||||
26EF66332F9112E700824F91 /* TodayView.swift in Sources */,
|
26EF66332F9112E700824F91 /* TodayView.swift in Sources */,
|
||||||
26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */,
|
26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */,
|
||||||
@@ -449,14 +492,18 @@
|
|||||||
26B9930C2F94B32800E9B16C /* PrivacyBadgeView.swift in Sources */,
|
26B9930C2F94B32800E9B16C /* PrivacyBadgeView.swift in Sources */,
|
||||||
26B2CAE72F93C03F0039BA3B /* VisitRatingFlowView.swift in Sources */,
|
26B2CAE72F93C03F0039BA3B /* VisitRatingFlowView.swift in Sources */,
|
||||||
26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */,
|
26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */,
|
||||||
|
263FF45E2F9935EF00C1957C /* TourViewModifiers.swift in Sources */,
|
||||||
26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */,
|
26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */,
|
||||||
26EF66432F912A0000824F91 /* CallSuggestionView.swift in Sources */,
|
26EF66432F912A0000824F91 /* CallSuggestionView.swift in Sources */,
|
||||||
|
263FF4542F99359600C1957C /* TourSeenStore.swift in Sources */,
|
||||||
26B2CAB62F93B55F0039BA3B /* IchView.swift in Sources */,
|
26B2CAB62F93B55F0039BA3B /* IchView.swift in Sources */,
|
||||||
26B2CAF12F93C52C0039BA3B /* VisitEditFlowView.swift in Sources */,
|
26B2CAF12F93C52C0039BA3B /* VisitEditFlowView.swift in Sources */,
|
||||||
26F8B0C52F94E47F004905B9 /* PersonalityModels.swift in Sources */,
|
26F8B0C52F94E47F004905B9 /* PersonalityModels.swift in Sources */,
|
||||||
26EF66452F91350200824F91 /* AppLockManager.swift in Sources */,
|
26EF66452F91350200824F91 /* AppLockManager.swift in Sources */,
|
||||||
26B2CAEB2F93C05A0039BA3B /* VisitSummaryView.swift in Sources */,
|
26B2CAEB2F93C05A0039BA3B /* VisitSummaryView.swift in Sources */,
|
||||||
26B2CAE32F93C0180039BA3B /* RatingQuestionView.swift in Sources */,
|
26B2CAE32F93C0180039BA3B /* RatingQuestionView.swift in Sources */,
|
||||||
|
263FF45A2F9935CD00C1957C /* TourCardView.swift in Sources */,
|
||||||
|
263FF44C2F99356E00C1957C /* TourTargetID.swift in Sources */,
|
||||||
26EF66342F9112E700824F91 /* PersonDetailView.swift in Sources */,
|
26EF66342F9112E700824F91 /* PersonDetailView.swift in Sources */,
|
||||||
26EF66352F9112E700824F91 /* ThemeSystem.swift in Sources */,
|
26EF66352F9112E700824F91 /* ThemeSystem.swift in Sources */,
|
||||||
26EF66362F9112E700824F91 /* PeopleListView.swift in Sources */,
|
26EF66362F9112E700824F91 /* PeopleListView.swift in Sources */,
|
||||||
@@ -466,11 +513,15 @@
|
|||||||
26EF66382F9112E700824F91 /* SettingsView.swift in Sources */,
|
26EF66382F9112E700824F91 /* SettingsView.swift in Sources */,
|
||||||
26EF66392F9112E700824F91 /* AddMomentView.swift in Sources */,
|
26EF66392F9112E700824F91 /* AddMomentView.swift in Sources */,
|
||||||
26EF663A2F9112E700824F91 /* NahbarApp.swift in Sources */,
|
26EF663A2F9112E700824F91 /* NahbarApp.swift in Sources */,
|
||||||
|
263FF4562F9935AC00C1957C /* TourCoordinator.swift in Sources */,
|
||||||
26BB85C52F926A1C00889312 /* AppGroup.swift in Sources */,
|
26BB85C52F926A1C00889312 /* AppGroup.swift in Sources */,
|
||||||
26F8B0D12F94E7D5004905B9 /* PersonalityResultView.swift in Sources */,
|
26F8B0D12F94E7D5004905B9 /* PersonalityResultView.swift in Sources */,
|
||||||
26EF66472F91351800824F91 /* AppLockView.swift in Sources */,
|
26EF66472F91351800824F91 /* AppLockView.swift in Sources */,
|
||||||
26B2CAB82F93B7570039BA3B /* NahbarLogger.swift in Sources */,
|
26B2CAB82F93B7570039BA3B /* NahbarLogger.swift in Sources */,
|
||||||
2670595C2F96640E00956084 /* CalendarManager.swift in Sources */,
|
2670595C2F96640E00956084 /* CalendarManager.swift in Sources */,
|
||||||
|
263FF45C2F9935E400C1957C /* TourOverlayView.swift in Sources */,
|
||||||
|
263FF4502F99357F00C1957C /* Tour.swift in Sources */,
|
||||||
|
263FF44E2F99357500C1957C /* TourStep.swift in Sources */,
|
||||||
26F8B0C72F94E499004905B9 /* NahbarInsightStyle.swift in Sources */,
|
26F8B0C72F94E499004905B9 /* NahbarInsightStyle.swift in Sources */,
|
||||||
26B2CABA2F93B76E0039BA3B /* LogExportView.swift in Sources */,
|
26B2CABA2F93B76E0039BA3B /* LogExportView.swift in Sources */,
|
||||||
26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */,
|
26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */,
|
||||||
@@ -481,6 +532,7 @@
|
|||||||
26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */,
|
26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */,
|
||||||
26B993102F94B34C00E9B16C /* OnboardingCoordinator.swift in Sources */,
|
26B993102F94B34C00E9B16C /* OnboardingCoordinator.swift in Sources */,
|
||||||
26BB85C12F92525200889312 /* AIAnalysisService.swift in Sources */,
|
26BB85C12F92525200889312 /* AIAnalysisService.swift in Sources */,
|
||||||
|
263FF4582F9935BC00C1957C /* SpotlightShape.swift in Sources */,
|
||||||
26EF663C2F9112E700824F91 /* ContactPickerView.swift in Sources */,
|
26EF663C2F9112E700824F91 /* ContactPickerView.swift in Sources */,
|
||||||
26B2CAE12F93C0080039BA3B /* RatingDotPicker.swift in Sources */,
|
26B2CAE12F93C0080039BA3B /* RatingDotPicker.swift in Sources */,
|
||||||
26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */,
|
26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */,
|
||||||
@@ -663,6 +715,8 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = nahbar;
|
INFOPLIST_KEY_CFBundleDisplayName = nahbar;
|
||||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "nahbar liest deine Kalender, damit du beim Erstellen eines Termins den richtigen Kalender wählen kannst.";
|
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "nahbar liest deine Kalender, damit du beim Erstellen eines Termins den richtigen Kalender wählen kannst.";
|
||||||
INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt Kalendereinträge für geplante Treffen";
|
INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt Kalendereinträge für geplante Treffen";
|
||||||
|
INFOPLIST_KEY_NSContactsUsageDescription = "nahbar öffnet den systemseitigen Kontakte-Picker, damit du Personen schnell aus deinem Adressbuch hinzufügen kannst. Die App liest deine Kontakte nicht selbstständig aus.";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "nahbar öffnet den systemseitigen Foto-Picker, damit du ein Profilbild für eine Person auswählen kannst.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -704,6 +758,8 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = nahbar;
|
INFOPLIST_KEY_CFBundleDisplayName = nahbar;
|
||||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "nahbar liest deine Kalender, damit du beim Erstellen eines Termins den richtigen Kalender wählen kannst.";
|
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "nahbar liest deine Kalender, damit du beim Erstellen eines Termins den richtigen Kalender wählen kannst.";
|
||||||
INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt Kalendereinträge für geplante Treffen";
|
INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt Kalendereinträge für geplante Treffen";
|
||||||
|
INFOPLIST_KEY_NSContactsUsageDescription = "nahbar öffnet den systemseitigen Kontakte-Picker, damit du Personen schnell aus deinem Adressbuch hinzufügen kannst. Die App liest deine Kontakte nicht selbstständig aus.";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "nahbar öffnet den systemseitigen Foto-Picker, damit du ein Profilbild für eine Person auswählen kannst.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|||||||
@@ -159,6 +159,34 @@ struct CachedGiftSuggestion: Codable {
|
|||||||
let generatedAt: Date
|
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
|
// MARK: - Service
|
||||||
|
|
||||||
class AIAnalysisService {
|
class AIAnalysisService {
|
||||||
@@ -283,9 +311,12 @@ class AIAnalysisService {
|
|||||||
|
|
||||||
// MARK: - Prompt Builder
|
// MARK: - Prompt Builder
|
||||||
|
|
||||||
|
private enum PromptType {
|
||||||
|
case analysis, gift, conversation
|
||||||
|
}
|
||||||
|
|
||||||
/// Baut den vollständigen User-Prompt sprachabhängig auf.
|
/// Baut den vollständigen User-Prompt sprachabhängig auf.
|
||||||
/// - `isGift`: true → Geschenkideen-Instruktion, false → Analyse-Instruktion
|
private func buildPrompt(for person: Person, promptType: PromptType = .analysis) -> String {
|
||||||
private func buildPrompt(for person: Person, isGift: Bool = false) -> String {
|
|
||||||
let lang = AppLanguage.current
|
let lang = AppLanguage.current
|
||||||
|
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
@@ -304,7 +335,13 @@ class AIAnalysisService {
|
|||||||
let logEntries = logLines.isEmpty ? "" : "\(lang.logEntriesLabel) (\(person.sortedLogEntries.count)):\n\(logLines)\n"
|
let logEntries = logLines.isEmpty ? "" : "\(lang.logEntriesLabel) (\(person.sortedLogEntries.count)):\n\(logLines)\n"
|
||||||
let interests = person.interests.map { "\(lang.interestsLabel): \(AIPayloadSanitizer.sanitize($0))\n" } ?? ""
|
let interests = person.interests.map { "\(lang.interestsLabel): \(AIPayloadSanitizer.sanitize($0))\n" } ?? ""
|
||||||
let culturalBg = person.culturalBackground.map { "\(lang.culturalBackgroundLabel): \($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"
|
return "Person: \(person.firstName)\n"
|
||||||
+ birthYearContext(for: person, language: lang)
|
+ birthYearContext(for: person, language: lang)
|
||||||
@@ -351,7 +388,7 @@ class AIAnalysisService {
|
|||||||
throw URLError(.badURL)
|
throw URLError(.badURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
let prompt = buildPrompt(for: person, isGift: true)
|
let prompt = buildPrompt(for: person, promptType: .gift)
|
||||||
|
|
||||||
let body: [String: Any] = [
|
let body: [String: Any] = [
|
||||||
"model": config.model,
|
"model": config.model,
|
||||||
@@ -397,6 +434,72 @@ class AIAnalysisService {
|
|||||||
return normalized
|
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
|
// MARK: - Result Parser
|
||||||
|
|
||||||
private func parseResult(_ text: String) -> AIAnalysisResult {
|
private func parseResult(_ text: String) -> AIAnalysisResult {
|
||||||
@@ -426,4 +529,35 @@ class AIAnalysisService {
|
|||||||
recommendation: extract("EMPFEHLUNG")
|
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")
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
import EventKit
|
import EventKit
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let intentionNotificationLogger = Logger(subsystem: "nahbar", category: "IntentionNotification")
|
||||||
|
|
||||||
struct AddMomentView: View {
|
struct AddMomentView: View {
|
||||||
@Environment(\.nahbarTheme) var theme
|
@Environment(\.nahbarTheme) var theme
|
||||||
@@ -31,6 +34,14 @@ struct AddMomentView: View {
|
|||||||
@State private var selectedCalendarID: String = ""
|
@State private var selectedCalendarID: String = ""
|
||||||
@State private var eventAlarmOffset: Double = -3600 // Sekunden; 0 = keine Erinnerung
|
@State private var eventAlarmOffset: Double = -3600 // Sekunden; 0 = keine Erinnerung
|
||||||
|
|
||||||
|
// 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
|
// Vorhaben: Erinnerung
|
||||||
@State private var addReminder = false
|
@State private var addReminder = false
|
||||||
@State private var reminderDate: Date = {
|
@State private var reminderDate: Date = {
|
||||||
@@ -68,6 +79,7 @@ struct AddMomentView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
|
||||||
// Person-Kontext-Chip
|
// Person-Kontext-Chip
|
||||||
@@ -127,11 +139,17 @@ struct AddMomentView: View {
|
|||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.focused($isFocused)
|
.focused($isFocused)
|
||||||
}
|
}
|
||||||
.frame(minHeight: 180)
|
.frame(minHeight: 120)
|
||||||
.background(theme.surfaceCard)
|
.background(theme.surfaceCard)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
.padding(.horizontal, 20)
|
.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
|
// Treffen: Kalendertermin
|
||||||
if showsCalendarSection {
|
if showsCalendarSection {
|
||||||
calendarSection
|
calendarSection
|
||||||
@@ -144,12 +162,14 @@ struct AddMomentView: View {
|
|||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.2), value: showsCalendarSection)
|
.animation(.easeInOut(duration: 0.2), value: showsCalendarSection)
|
||||||
.animation(.easeInOut(duration: 0.2), value: showsReminderSection)
|
.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: addToCalendar)
|
||||||
.animation(.easeInOut(duration: 0.2), value: addReminder)
|
.animation(.easeInOut(duration: 0.2), value: addReminder)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
} // ScrollView
|
||||||
.background(theme.backgroundPrimary.ignoresSafeArea())
|
.background(theme.backgroundPrimary.ignoresSafeArea())
|
||||||
.navigationTitle("Moment festhalten")
|
.navigationTitle("Moment festhalten")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -167,7 +187,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)
|
// MARK: - Kalender-Sektion (Treffen)
|
||||||
@@ -397,13 +422,21 @@ struct AddMomentView: View {
|
|||||||
|
|
||||||
private func scheduleIntentionReminder(for moment: Moment) {
|
private func scheduleIntentionReminder(for moment: Moment) {
|
||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
center.requestAuthorization(options: [.alert, .sound]) { granted, error in
|
||||||
guard granted else { return }
|
if let error {
|
||||||
|
intentionNotificationLogger.error("Berechtigung-Fehler: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
guard granted else {
|
||||||
|
intentionNotificationLogger.warning("Notification-Berechtigung abgelehnt – keine Vorhaben-Erinnerung.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = person.firstName
|
content.title = person.firstName
|
||||||
|
content.subtitle = String(localized: "Geplanter Moment")
|
||||||
content.body = moment.text
|
content.body = moment.text
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
|
content.userInfo = ["momentID": moment.id.uuidString]
|
||||||
|
|
||||||
let components = Calendar.current.dateComponents(
|
let components = Calendar.current.dateComponents(
|
||||||
[.year, .month, .day, .hour, .minute], from: reminderDate
|
[.year, .month, .day, .hour, .minute], from: reminderDate
|
||||||
@@ -414,7 +447,13 @@ struct AddMomentView: View {
|
|||||||
content: content,
|
content: content,
|
||||||
trigger: trigger
|
trigger: trigger
|
||||||
)
|
)
|
||||||
center.add(request)
|
center.add(request) { error in
|
||||||
|
if let error {
|
||||||
|
intentionNotificationLogger.error("Vorhaben-Erinnerung konnte nicht geplant werden: \(error.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
intentionNotificationLogger.info("Vorhaben-Erinnerung geplant: \(moment.id.uuidString)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,4 +493,230 @@ struct AddMomentView: View {
|
|||||||
CalendarEventStore.save(momentID: momentID, eventIdentifier: identifier)
|
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 && 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ struct AddPersonView: View {
|
|||||||
|
|
||||||
var existingPerson: Person? = nil
|
var existingPerson: Person? = nil
|
||||||
|
|
||||||
|
@Query private var allPeople: [Person]
|
||||||
|
|
||||||
@State private var name = ""
|
@State private var name = ""
|
||||||
@State private var selectedTag: PersonTag = .other
|
@State private var selectedTag: PersonTag = .other
|
||||||
@State private var occupation = ""
|
@State private var occupation = ""
|
||||||
@@ -17,12 +19,15 @@ struct AddPersonView: View {
|
|||||||
@State private var interests = ""
|
@State private var interests = ""
|
||||||
@State private var generalNotes = ""
|
@State private var generalNotes = ""
|
||||||
@State private var culturalBackground = ""
|
@State private var culturalBackground = ""
|
||||||
|
@State private var phoneNumber = ""
|
||||||
|
@State private var emailAddress = ""
|
||||||
@State private var hasBirthday = false
|
@State private var hasBirthday = false
|
||||||
@State private var birthday = Date()
|
@State private var birthday = Date()
|
||||||
@State private var nudgeFrequency: NudgeFrequency = .monthly
|
@State private var nudgeFrequency: NudgeFrequency = .monthly
|
||||||
|
|
||||||
@State private var showingContactPicker = false
|
@State private var showingContactPicker = false
|
||||||
@State private var importedName: String? = nil // tracks whether fields were pre-filled
|
@State private var importedName: String? = nil // tracks whether fields were pre-filled
|
||||||
|
@State private var pendingCnIdentifier: String? = nil
|
||||||
@State private var showingDeleteConfirmation = false
|
@State private var showingDeleteConfirmation = false
|
||||||
|
|
||||||
@State private var selectedPhoto: UIImage? = nil
|
@State private var selectedPhoto: UIImage? = nil
|
||||||
@@ -84,7 +89,12 @@ struct AddPersonView: View {
|
|||||||
RowDivider()
|
RowDivider()
|
||||||
inlineField("Wohnort", text: $location)
|
inlineField("Wohnort", text: $location)
|
||||||
RowDivider()
|
RowDivider()
|
||||||
inlineField("Interessen", text: $interests)
|
InterestTagEditor(
|
||||||
|
label: "Interessen",
|
||||||
|
text: $interests,
|
||||||
|
suggestions: interestSuggestions,
|
||||||
|
tagColor: .green
|
||||||
|
)
|
||||||
RowDivider()
|
RowDivider()
|
||||||
inlineField("Herkunft", text: $culturalBackground)
|
inlineField("Herkunft", text: $culturalBackground)
|
||||||
RowDivider()
|
RowDivider()
|
||||||
@@ -94,6 +104,25 @@ struct AddPersonView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kontaktdaten (Telefon + E-Mail)
|
||||||
|
formSection("Kontakt") {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
inlineField("Telefon", text: $phoneNumber)
|
||||||
|
.keyboardType(.phonePad)
|
||||||
|
RowDivider()
|
||||||
|
inlineField("E-Mail", text: $emailAddress)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
}
|
||||||
|
.background(theme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kontakt aktualisieren (nur im Bearbeiten-Modus)
|
||||||
|
if isEditing {
|
||||||
|
refreshContactButton
|
||||||
|
}
|
||||||
|
|
||||||
// Birthday
|
// Birthday
|
||||||
formSection("Geburtstag") {
|
formSection("Geburtstag") {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -326,6 +355,39 @@ struct AddPersonView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Vom Kontakt aktualisieren (Bearbeiten-Modus)
|
||||||
|
|
||||||
|
private var refreshContactButton: some View {
|
||||||
|
Button {
|
||||||
|
showingContactPicker = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "arrow.clockwise.circle")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text("Vom Kontakt aktualisieren")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
Text("Leere Felder werden aus dem Adressbuch ergänzt")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 13)
|
||||||
|
.background(theme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Contact Mapping
|
// MARK: - Contact Mapping
|
||||||
|
|
||||||
private func applyContact(_ contact: CNContact) {
|
private func applyContact(_ contact: CNContact) {
|
||||||
@@ -348,6 +410,15 @@ struct AddPersonView: View {
|
|||||||
if let data = imported.photoData {
|
if let data = imported.photoData {
|
||||||
selectedPhoto = UIImage(data: data)
|
selectedPhoto = UIImage(data: data)
|
||||||
}
|
}
|
||||||
|
// Telefon und E-Mail: im Hinzufügen-Modus immer übernehmen,
|
||||||
|
// im Bearbeiten-Modus nur wenn noch leer (nicht-destruktiv)
|
||||||
|
if let phone = imported.phoneNumber, !phone.isEmpty {
|
||||||
|
if phoneNumber.isEmpty { phoneNumber = phone }
|
||||||
|
}
|
||||||
|
if let email = imported.emailAddress, !email.isEmpty {
|
||||||
|
if emailAddress.isEmpty { emailAddress = email }
|
||||||
|
}
|
||||||
|
pendingCnIdentifier = imported.cnIdentifier
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
@@ -379,6 +450,14 @@ struct AddPersonView: View {
|
|||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var interestSuggestions: [String] {
|
||||||
|
InterestTagHelper.allSuggestions(
|
||||||
|
from: allPeople,
|
||||||
|
likes: UserProfileStore.shared.likes,
|
||||||
|
dislikes: UserProfileStore.shared.dislikes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func loadExisting() {
|
private func loadExisting() {
|
||||||
guard let p = existingPerson else { return }
|
guard let p = existingPerson else { return }
|
||||||
name = p.name
|
name = p.name
|
||||||
@@ -388,6 +467,8 @@ struct AddPersonView: View {
|
|||||||
interests = p.interests ?? ""
|
interests = p.interests ?? ""
|
||||||
culturalBackground = p.culturalBackground ?? ""
|
culturalBackground = p.culturalBackground ?? ""
|
||||||
generalNotes = p.generalNotes ?? ""
|
generalNotes = p.generalNotes ?? ""
|
||||||
|
phoneNumber = p.phoneNumber ?? ""
|
||||||
|
emailAddress = p.emailAddress ?? ""
|
||||||
hasBirthday = p.birthday != nil
|
hasBirthday = p.birthday != nil
|
||||||
birthday = p.birthday ?? Date()
|
birthday = p.birthday ?? Date()
|
||||||
nudgeFrequency = p.nudgeFrequency
|
nudgeFrequency = p.nudgeFrequency
|
||||||
@@ -413,6 +494,9 @@ struct AddPersonView: View {
|
|||||||
p.generalNotes = generalNotes.isEmpty ? nil : generalNotes
|
p.generalNotes = generalNotes.isEmpty ? nil : generalNotes
|
||||||
p.birthday = hasBirthday ? birthday : nil
|
p.birthday = hasBirthday ? birthday : nil
|
||||||
p.nudgeFrequency = nudgeFrequency
|
p.nudgeFrequency = nudgeFrequency
|
||||||
|
p.phoneNumber = phoneNumber.isEmpty ? nil : phoneNumber
|
||||||
|
p.emailAddress = emailAddress.isEmpty ? nil : emailAddress
|
||||||
|
if let cn = pendingCnIdentifier { p.cnIdentifier = cn }
|
||||||
p.touch()
|
p.touch()
|
||||||
applyPhoto(newPhotoData, to: p)
|
applyPhoto(newPhotoData, to: p)
|
||||||
} else {
|
} else {
|
||||||
@@ -427,6 +511,9 @@ struct AddPersonView: View {
|
|||||||
culturalBackground: culturalBackground.isEmpty ? nil : culturalBackground,
|
culturalBackground: culturalBackground.isEmpty ? nil : culturalBackground,
|
||||||
nudgeFrequency: nudgeFrequency
|
nudgeFrequency: nudgeFrequency
|
||||||
)
|
)
|
||||||
|
person.phoneNumber = phoneNumber.isEmpty ? nil : phoneNumber
|
||||||
|
person.emailAddress = emailAddress.isEmpty ? nil : emailAddress
|
||||||
|
person.cnIdentifier = pendingCnIdentifier
|
||||||
modelContext.insert(person)
|
modelContext.insert(person)
|
||||||
applyPhoto(newPhotoData, to: person)
|
applyPhoto(newPhotoData, to: person)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "nahbar", category: "TodoNotification")
|
||||||
|
|
||||||
struct AddTodoView: View {
|
struct AddTodoView: View {
|
||||||
@Environment(\.nahbarTheme) var theme
|
@Environment(\.nahbarTheme) var theme
|
||||||
@@ -168,12 +171,20 @@ struct AddTodoView: View {
|
|||||||
|
|
||||||
private func scheduleReminder(for todo: Todo) {
|
private func scheduleReminder(for todo: Todo) {
|
||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
center.requestAuthorization(options: [.alert, .sound]) { granted, error in
|
||||||
guard granted else { return }
|
if let error {
|
||||||
|
logger.error("Berechtigung-Fehler: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
guard granted else {
|
||||||
|
logger.warning("Notification-Berechtigung abgelehnt – keine Todo-Erinnerung.")
|
||||||
|
return
|
||||||
|
}
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = person.firstName
|
content.title = person.firstName
|
||||||
|
content.subtitle = String(localized: "Dein Todo")
|
||||||
content.body = todo.title
|
content.body = todo.title
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
|
content.userInfo = ["todoID": todo.id.uuidString]
|
||||||
let components = Calendar.current.dateComponents(
|
let components = Calendar.current.dateComponents(
|
||||||
[.year, .month, .day, .hour, .minute], from: reminderDate
|
[.year, .month, .day, .hour, .minute], from: reminderDate
|
||||||
)
|
)
|
||||||
@@ -183,7 +194,13 @@ struct AddTodoView: View {
|
|||||||
content: content,
|
content: content,
|
||||||
trigger: trigger
|
trigger: trigger
|
||||||
)
|
)
|
||||||
center.add(request)
|
center.add(request) { error in
|
||||||
|
if let error {
|
||||||
|
logger.error("Todo-Erinnerung konnte nicht geplant werden: \(error.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
logger.info("Todo-Erinnerung geplant: \(todo.id.uuidString)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ struct AppLockSetupView: View {
|
|||||||
|
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 24, weight: .light, design: theme.displayDesign))
|
.font(.system(size: 22, weight: .light, design: theme.displayDesign))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ struct AppLockView: View {
|
|||||||
// Title
|
// Title
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
Text("nahbar")
|
Text("nahbar")
|
||||||
.font(.system(size: 34, weight: .light, design: theme.displayDesign))
|
.font(.system(size: 32, weight: .light, design: theme.displayDesign))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
Text("Code eingeben")
|
Text("Code eingeben")
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
@@ -166,7 +166,7 @@ struct PINPadView: View {
|
|||||||
} else {
|
} else {
|
||||||
Button { onKey(.digit(key.first!)) } label: {
|
Button { onKey(.digit(key.first!)) } label: {
|
||||||
Text(key)
|
Text(key)
|
||||||
.font(.system(size: 28, weight: .light, design: theme.displayDesign))
|
.font(.system(size: 26, weight: .light, design: theme.displayDesign))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
.frame(width: 80, height: 80)
|
.frame(width: 80, height: 80)
|
||||||
.background(theme.surfaceCard)
|
.background(theme.surfaceCard)
|
||||||
|
|||||||
@@ -159,6 +159,15 @@ final class CalendarManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `true` wenn Kalender-Vollzugriff bereits erteilt wurde (fragt nicht neu nach).
|
||||||
|
var isAuthorized: Bool {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
return EKEventStore.authorizationStatus(for: .event) == .fullAccess
|
||||||
|
} else {
|
||||||
|
return EKEventStore.authorizationStatus(for: .event) == .authorized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Gibt alle Benutzer-Kalender zurück (sortiert nach Titel).
|
/// Gibt alle Benutzer-Kalender zurück (sortiert nach Titel).
|
||||||
func availableCalendars() async -> [EKCalendar] {
|
func availableCalendars() async -> [EKCalendar] {
|
||||||
let granted = await requestFullAccess()
|
let granted = await requestFullAccess()
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "nahbar", category: "CallWindowNotification")
|
||||||
|
|
||||||
class CallWindowManager: ObservableObject {
|
class CallWindowManager: ObservableObject {
|
||||||
static let shared = CallWindowManager()
|
static let shared = CallWindowManager()
|
||||||
@@ -99,18 +102,20 @@ class CallWindowManager: ObservableObject {
|
|||||||
cancelNotifications()
|
cancelNotifications()
|
||||||
guard isEnabled else { return }
|
guard isEnabled else { return }
|
||||||
|
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in
|
||||||
guard granted else { return }
|
if let error {
|
||||||
|
logger.error("Berechtigung-Fehler: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
guard granted else {
|
||||||
|
logger.warning("Notification-Berechtigung abgelehnt – keine Gesprächsfenster-Erinnerung.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Persönlichkeitsgerechter Body-Text via PersonalityEngine
|
||||||
|
let body = PersonalityEngine.callWindowCopy(profile: PersonalityStore.shared.profile)
|
||||||
for weekday in self.selectedWeekdays {
|
for weekday in self.selectedWeekdays {
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = "Gesprächszeit"
|
content.title = String(localized: "Gesprächszeit")
|
||||||
// Persönlichkeitsgerechter Body-Text (wärmer bei hohem Neurotizismus)
|
content.body = body
|
||||||
let profile = PersonalityStore.shared.profile
|
|
||||||
if let profile, profile.level(for: .neuroticism) == .high {
|
|
||||||
content.body = "Magst du heute jemanden kurz anschreiben? Das kann viel bedeuten. 🙂"
|
|
||||||
} else {
|
|
||||||
content.body = "Wer freut sich heute von dir zu hören?"
|
|
||||||
}
|
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
content.categoryIdentifier = "CALL_WINDOW"
|
content.categoryIdentifier = "CALL_WINDOW"
|
||||||
|
|
||||||
@@ -124,7 +129,11 @@ class CallWindowManager: ObservableObject {
|
|||||||
content: content,
|
content: content,
|
||||||
trigger: UNCalendarNotificationTrigger(dateMatching: dc, repeats: true)
|
trigger: UNCalendarNotificationTrigger(dateMatching: dc, repeats: true)
|
||||||
)
|
)
|
||||||
UNUserNotificationCenter.current().add(request)
|
UNUserNotificationCenter.current().add(request) { error in
|
||||||
|
if let error {
|
||||||
|
logger.error("Gesprächsfenster-Notification konnte nicht geplant werden (Wochentag \(weekday)): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ struct CallWindowSetupView: View {
|
|||||||
.foregroundStyle(theme.accent)
|
.foregroundStyle(theme.accent)
|
||||||
|
|
||||||
Text("Gesprächszeit")
|
Text("Gesprächszeit")
|
||||||
.font(.system(size: 26, weight: .light, design: theme.displayDesign))
|
.font(.system(size: 24, weight: .light, design: theme.displayDesign))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
|
||||||
Text("nahbar erinnert dich täglich in deinem Zeitfenster und schlägt einen Kontakt vor — mit Notizen, damit du vorbereitet bist.")
|
Text("nahbar erinnert dich täglich in deinem Zeitfenster und schlägt einen Kontakt vor — mit Notizen, damit du vorbereitet bist.")
|
||||||
|
|||||||
@@ -216,6 +216,9 @@ struct ContactImport {
|
|||||||
let location: String
|
let location: String
|
||||||
let birthday: Date?
|
let birthday: Date?
|
||||||
let photoData: Data?
|
let photoData: Data?
|
||||||
|
let phoneNumber: String? // primäre Telefonnummer (bevorzugt Mobil/iPhone)
|
||||||
|
let emailAddress: String? // erste verfügbare E-Mail-Adresse
|
||||||
|
let cnIdentifier: String? // stabile Apple Contacts-ID für spätere Aktualisierung
|
||||||
|
|
||||||
static func from(_ contact: CNContact) -> ContactImport {
|
static func from(_ contact: CNContact) -> ContactImport {
|
||||||
// Mittelname einbeziehen, falls vorhanden
|
// Mittelname einbeziehen, falls vorhanden
|
||||||
@@ -271,8 +274,22 @@ struct ContactImport {
|
|||||||
photoData = nil
|
photoData = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Telefon: Mobil/iPhone bevorzugen, dann erste verfügbare Nummer
|
||||||
|
let mobileLabels = ["iPhone", "_$!<Mobile>!$_", "_$!<Main>!$_"]
|
||||||
|
let phoneNumber: String?
|
||||||
|
if let labeled = contact.phoneNumbers.first(where: { mobileLabels.contains($0.label ?? "") }) {
|
||||||
|
phoneNumber = labeled.value.stringValue
|
||||||
|
} else {
|
||||||
|
phoneNumber = contact.phoneNumbers.first?.value.stringValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// E-Mail: erste verfügbare Adresse
|
||||||
|
let emailAddress = contact.emailAddresses.first.map { $0.value as String }
|
||||||
|
|
||||||
return ContactImport(name: name, occupation: occupation, location: location,
|
return ContactImport(name: name, occupation: occupation, location: location,
|
||||||
birthday: birthdayDate, photoData: photoData)
|
birthday: birthdayDate, photoData: photoData,
|
||||||
|
phoneNumber: phoneNumber, emailAddress: emailAddress,
|
||||||
|
cnIdentifier: contact.identifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import OSLog
|
|||||||
private let logger = Logger(subsystem: "nahbar", category: "ContentView")
|
private let logger = Logger(subsystem: "nahbar", category: "ContentView")
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
|
/// Wird von NahbarApp auf `true` gesetzt, solange der Splash-Screen sichtbar ist.
|
||||||
|
/// UI-Präsentation (Sheets, Onboarding) startet erst, wenn dieser Wert auf `false` wechselt.
|
||||||
|
var splashVisible: Bool = false
|
||||||
|
|
||||||
@AppStorage("nahbarOnboardingDone") private var nahbarOnboardingDone = false
|
@AppStorage("nahbarOnboardingDone") private var nahbarOnboardingDone = false
|
||||||
@AppStorage("callWindowOnboardingDone") private var onboardingDone = false
|
@AppStorage("callWindowOnboardingDone") private var onboardingDone = false
|
||||||
@AppStorage("callSuggestionDate") private var suggestionDateStr = ""
|
@AppStorage("callSuggestionDate") private var suggestionDateStr = ""
|
||||||
@@ -19,44 +23,41 @@ struct ContentView: View {
|
|||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Environment(\.nahbarTheme) private var theme
|
@Environment(\.nahbarTheme) private var theme
|
||||||
|
|
||||||
|
@Environment(TourCoordinator.self) private var tourCoordinator
|
||||||
|
|
||||||
@Query private var persons: [Person]
|
@Query private var persons: [Person]
|
||||||
|
|
||||||
@State private var showingNahbarOnboarding = false
|
@State private var showingNahbarOnboarding = false
|
||||||
@State private var showingOnboarding = false
|
@State private var showingOnboarding = false
|
||||||
@State private var suggestedPerson: Person? = nil
|
@State private var suggestedPerson: Person? = nil
|
||||||
@State private var showingSuggestion = false
|
@State private var showingSuggestion = false
|
||||||
|
/// Steuert den aktiven Tab; nötig damit die Tour auf dem Menschen-Tab starten kann.
|
||||||
|
@State private var selectedTab: Int = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
TabView(selection: $selectedTab) {
|
||||||
TodayView()
|
TodayView()
|
||||||
.tabItem { Label("Heute", systemImage: "sun.max") }
|
.tabItem { Label("Heute", systemImage: "sun.max") }
|
||||||
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar)
|
.tag(0)
|
||||||
.toolbarBackground(.visible, for: .tabBar)
|
|
||||||
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
|
|
||||||
|
|
||||||
PeopleListView()
|
PeopleListView()
|
||||||
.tabItem { Label("Menschen", systemImage: "person.2") }
|
.tabItem { Label("Menschen", systemImage: "person.2") }
|
||||||
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar)
|
.tag(1)
|
||||||
.toolbarBackground(.visible, for: .tabBar)
|
|
||||||
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
|
|
||||||
|
|
||||||
IchView()
|
IchView()
|
||||||
.tabItem { Label("Ich", systemImage: "person.circle") }
|
.tabItem { Label("Ich", systemImage: "person.circle") }
|
||||||
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar)
|
.tag(2)
|
||||||
.toolbarBackground(.visible, for: .tabBar)
|
|
||||||
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
|
|
||||||
|
|
||||||
SettingsView()
|
SettingsView()
|
||||||
.tabItem { Label("Einstellungen", systemImage: "gearshape") }
|
.tabItem { Label("Einstellungen", systemImage: "gearshape") }
|
||||||
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar)
|
.tag(3)
|
||||||
.toolbarBackground(.visible, for: .tabBar)
|
|
||||||
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
|
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showingNahbarOnboarding) {
|
.fullScreenCover(isPresented: $showingNahbarOnboarding) {
|
||||||
OnboardingContainerView {
|
OnboardingContainerView {
|
||||||
nahbarOnboardingDone = true
|
nahbarOnboardingDone = true
|
||||||
showingNahbarOnboarding = false
|
showingNahbarOnboarding = false
|
||||||
checkCallWindow()
|
checkCallWindow()
|
||||||
|
scheduleTourIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingOnboarding) {
|
.sheet(isPresented: $showingOnboarding) {
|
||||||
@@ -83,25 +84,30 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.tourPresenter(coordinator: tourCoordinator)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
// Datenoperationen sofort starten (unabhängig vom Splash-Screen)
|
||||||
syncPeopleCache()
|
syncPeopleCache()
|
||||||
importPendingMoments()
|
importPendingMoments()
|
||||||
runPhotoRepairPass()
|
runPhotoRepairPass()
|
||||||
runVisitMigrationPass()
|
runVisitMigrationPass()
|
||||||
runNextStepMigrationPass()
|
runNextStepMigrationPass()
|
||||||
if !nahbarOnboardingDone {
|
// UI-Präsentation erst nach dem Splash
|
||||||
showingNahbarOnboarding = true
|
if !splashVisible {
|
||||||
} else if !onboardingDone {
|
showPendingUI()
|
||||||
showingOnboarding = true
|
}
|
||||||
} else {
|
}
|
||||||
checkCallWindow()
|
.onChange(of: splashVisible) { _, visible in
|
||||||
|
// Sobald der Splash verschwindet, UI-Logik starten
|
||||||
|
if !visible {
|
||||||
|
showPendingUI()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
if phase == .active {
|
if phase == .active {
|
||||||
syncPeopleCache()
|
syncPeopleCache()
|
||||||
importPendingMoments()
|
importPendingMoments()
|
||||||
checkCallWindow()
|
if !splashVisible { checkCallWindow() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: persons) { _, _ in
|
.onChange(of: persons) { _, _ in
|
||||||
@@ -109,6 +115,36 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Pending UI
|
||||||
|
|
||||||
|
/// Startet die UI-Präsentation nach dem Splash-Screen:
|
||||||
|
/// Onboarding, Call-Window-Setup oder Gesprächsvorschlag.
|
||||||
|
private func showPendingUI() {
|
||||||
|
if !nahbarOnboardingDone {
|
||||||
|
showingNahbarOnboarding = true
|
||||||
|
} else if !onboardingDone {
|
||||||
|
showingOnboarding = true
|
||||||
|
} else {
|
||||||
|
checkCallWindow()
|
||||||
|
tourCoordinator.checkForPendingTours()
|
||||||
|
scheduleTourIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tour
|
||||||
|
|
||||||
|
/// Wechselt zum Menschen-Tab (damit PeopleListView gerendert wird und seine
|
||||||
|
/// anchorPreference-Frames sammeln kann), wartet 700 ms und startet dann die Tour.
|
||||||
|
/// Die Verzögerung deckt die fullScreenCover-Dismiss-Animation (~350 ms) ab.
|
||||||
|
private func scheduleTourIfNeeded() {
|
||||||
|
guard !tourCoordinator.hasSeenOnboardingTour else { return }
|
||||||
|
selectedTab = 1
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(700))
|
||||||
|
tourCoordinator.startOnboardingTourIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Call Window
|
// MARK: - Call Window
|
||||||
|
|
||||||
private func checkCallWindow() {
|
private func checkCallWindow() {
|
||||||
@@ -347,4 +383,5 @@ struct ContentView: View {
|
|||||||
.environmentObject(AppLockManager.shared)
|
.environmentObject(AppLockManager.shared)
|
||||||
.environmentObject(CloudSyncMonitor())
|
.environmentObject(CloudSyncMonitor())
|
||||||
.environmentObject(UserProfileStore.shared)
|
.environmentObject(UserProfileStore.shared)
|
||||||
|
.environment(TourCoordinator())
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-37
@@ -95,7 +95,7 @@ struct IchView: View {
|
|||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Ich")
|
Text("Ich")
|
||||||
.font(.system(size: 34, weight: .light, design: theme.displayDesign))
|
.font(.system(size: 32, weight: .light, design: theme.displayDesign))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button { showingEdit = true } label: {
|
Button { showingEdit = true } label: {
|
||||||
@@ -169,14 +169,10 @@ struct IchView: View {
|
|||||||
private var infoSection: some View {
|
private var infoSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
// Über mich
|
// Über mich
|
||||||
let hasUeberMich = !profileStore.gender.isEmpty || !profileStore.location.isEmpty || !profileStore.socialStyle.isEmpty
|
let hasUeberMich = !profileStore.location.isEmpty || !profileStore.socialStyle.isEmpty
|
||||||
if hasUeberMich {
|
if hasUeberMich {
|
||||||
SectionHeader(title: "Über mich", icon: "person")
|
SectionHeader(title: "Über mich", icon: "person")
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if !profileStore.gender.isEmpty {
|
|
||||||
infoRow(label: "Geschlecht", value: profileStore.gender)
|
|
||||||
if !profileStore.location.isEmpty || !profileStore.socialStyle.isEmpty { RowDivider() }
|
|
||||||
}
|
|
||||||
if !profileStore.location.isEmpty {
|
if !profileStore.location.isEmpty {
|
||||||
infoRow(label: "Wohnort", value: profileStore.location)
|
infoRow(label: "Wohnort", value: profileStore.location)
|
||||||
if !profileStore.socialStyle.isEmpty { RowDivider() }
|
if !profileStore.socialStyle.isEmpty { RowDivider() }
|
||||||
@@ -220,11 +216,11 @@ struct IchView: View {
|
|||||||
} else {
|
} else {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if !profileStore.likes.isEmpty {
|
if !profileStore.likes.isEmpty {
|
||||||
preferenceRow(label: "Mag ich", text: profileStore.likes, color: .green)
|
InterestChipRow(label: "Mag ich", text: profileStore.likes, color: .green)
|
||||||
if !profileStore.dislikes.isEmpty { RowDivider() }
|
if !profileStore.dislikes.isEmpty { RowDivider() }
|
||||||
}
|
}
|
||||||
if !profileStore.dislikes.isEmpty {
|
if !profileStore.dislikes.isEmpty {
|
||||||
preferenceRow(label: "Mag ich nicht", text: profileStore.dislikes, color: .red)
|
InterestChipRow(label: "Mag ich nicht", text: profileStore.dislikes, color: .red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(theme.surfaceCard)
|
.background(theme.surfaceCard)
|
||||||
@@ -233,32 +229,6 @@ struct IchView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func preferenceRow(label: String, text: String, color: Color) -> some View {
|
|
||||||
let items = text.split(separator: ",")
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
return VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(label)
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
ForEach(items, id: \.self) { item in
|
|
||||||
Text(item)
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundStyle(color)
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 5)
|
|
||||||
.background(color.opacity(0.12))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func infoRow(label: String, value: String) -> some View {
|
private func infoRow(label: String, value: String) -> some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Text(label)
|
Text(label)
|
||||||
@@ -309,9 +279,12 @@ struct IchEditView: View {
|
|||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@EnvironmentObject var profileStore: UserProfileStore
|
@EnvironmentObject var profileStore: UserProfileStore
|
||||||
|
|
||||||
|
@Query private var allPeople: [Person]
|
||||||
|
|
||||||
@State private var name: String
|
@State private var name: String
|
||||||
@State private var hasBirthday: Bool
|
@State private var hasBirthday: Bool
|
||||||
@State private var birthday: Date
|
@State private var birthday: Date
|
||||||
|
|
||||||
@State private var gender: String
|
@State private var gender: String
|
||||||
@State private var occupation: String
|
@State private var occupation: String
|
||||||
@State private var location: String
|
@State private var location: String
|
||||||
@@ -402,9 +375,19 @@ struct IchEditView: View {
|
|||||||
// Vorlieben
|
// Vorlieben
|
||||||
formSection("Vorlieben") {
|
formSection("Vorlieben") {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
inlineField("Mag ich", text: $likes)
|
InterestTagEditor(
|
||||||
|
label: "Mag ich",
|
||||||
|
text: $likes,
|
||||||
|
suggestions: preferenceSuggestions,
|
||||||
|
tagColor: .green
|
||||||
|
)
|
||||||
Divider().padding(.leading, 16)
|
Divider().padding(.leading, 16)
|
||||||
inlineField("Mag ich nicht", text: $dislikes)
|
InterestTagEditor(
|
||||||
|
label: "Mag nicht",
|
||||||
|
text: $dislikes,
|
||||||
|
suggestions: preferenceSuggestions,
|
||||||
|
tagColor: .red
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.background(theme.surfaceCard)
|
.background(theme.surfaceCard)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
@@ -522,9 +505,11 @@ struct IchEditView: View {
|
|||||||
return name.isEmpty ? "?" : String(name.prefix(2)).uppercased()
|
return name.isEmpty ? "?" : String(name.prefix(2)).uppercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Gender Picker
|
// MARK: - Gender Picker
|
||||||
|
|
||||||
private let genderOptions = ["Männlich", "Weiblich", "Divers", "Keine Angabe"]
|
private let genderOptions = ["Männlich", "Weiblich", "Divers"]
|
||||||
|
|
||||||
private var genderPickerRow: some View {
|
private var genderPickerRow: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -562,6 +547,16 @@ struct IchEditView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Suggestion Pool
|
||||||
|
|
||||||
|
private var preferenceSuggestions: [String] {
|
||||||
|
InterestTagHelper.allSuggestions(
|
||||||
|
from: allPeople,
|
||||||
|
likes: profileStore.likes,
|
||||||
|
dislikes: profileStore.dislikes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
+1024
-75
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,6 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
import CoreData
|
import CoreData
|
||||||
|
|
||||||
// MARK: - AI Analysis State
|
|
||||||
|
|
||||||
private enum AnalysisState {
|
|
||||||
case idle
|
|
||||||
case loading
|
|
||||||
case result(AIAnalysisResult, Date)
|
|
||||||
case error(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Timeline Item
|
// MARK: - Timeline Item
|
||||||
|
|
||||||
private enum LogbuchItem: Identifiable {
|
private enum LogbuchItem: Identifiable {
|
||||||
@@ -64,15 +55,8 @@ struct LogbuchView: View {
|
|||||||
@Environment(\.nahbarTheme) var theme
|
@Environment(\.nahbarTheme) var theme
|
||||||
@Environment(\.modelContext) var modelContext
|
@Environment(\.modelContext) var modelContext
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@StateObject private var store = StoreManager.shared
|
|
||||||
let person: Person
|
let person: Person
|
||||||
|
|
||||||
@State private var analysisState: AnalysisState = .idle
|
|
||||||
@State private var showPaywall = false
|
|
||||||
@State private var showAIConsent = false
|
|
||||||
@State private var remainingRequests: Int = AIAnalysisService.shared.remainingRequests
|
|
||||||
@AppStorage("aiConsentGiven") private var aiConsentGiven = false
|
|
||||||
|
|
||||||
// Kalender-Lösch-Bestätigung
|
// Kalender-Lösch-Bestätigung
|
||||||
@State private var momentPendingDelete: Moment? = nil
|
@State private var momentPendingDelete: Moment? = nil
|
||||||
@State private var showCalendarDeleteDialog = false
|
@State private var showCalendarDeleteDialog = false
|
||||||
@@ -96,8 +80,6 @@ struct LogbuchView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRO: KI-Analyse
|
|
||||||
aiAnalysisCard
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.top, 16)
|
.padding(.top, 16)
|
||||||
@@ -107,13 +89,6 @@ struct LogbuchView: View {
|
|||||||
.navigationTitle("Logbuch")
|
.navigationTitle("Logbuch")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.themedNavBar()
|
.themedNavBar()
|
||||||
.sheet(isPresented: $showPaywall) { PaywallView(targeting: .max) }
|
|
||||||
.sheet(isPresented: $showAIConsent) {
|
|
||||||
AIConsentSheet {
|
|
||||||
aiConsentGiven = true
|
|
||||||
Task { await runAnalysis() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(item: $momentForTextEdit) { moment in
|
.sheet(item: $momentForTextEdit) { moment in
|
||||||
EditMomentView(moment: moment)
|
EditMomentView(moment: moment)
|
||||||
}
|
}
|
||||||
@@ -147,12 +122,6 @@ struct LogbuchView: View {
|
|||||||
guard notification.userInfo?[NSInvalidatedAllObjectsKey] != nil else { return }
|
guard notification.userInfo?[NSInvalidatedAllObjectsKey] != nil else { return }
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.onAppear {
|
|
||||||
if let cached = AIAnalysisService.shared.loadCached(for: person) {
|
|
||||||
analysisState = .result(cached.asResult, cached.analyzedAt)
|
|
||||||
}
|
|
||||||
remainingRequests = AIAnalysisService.shared.remainingRequests
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Month Section
|
// MARK: - Month Section
|
||||||
@@ -320,197 +289,6 @@ struct LogbuchView: View {
|
|||||||
.padding(.vertical, 48)
|
.padding(.vertical, 48)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - MAX: KI-Analyse
|
|
||||||
|
|
||||||
private var canUseAI: Bool {
|
|
||||||
store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
|
|
||||||
}
|
|
||||||
|
|
||||||
private var aiAnalysisCard: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
SectionHeader(title: "KI-Auswertung", icon: "sparkles")
|
|
||||||
MaxBadge()
|
|
||||||
if !store.isMax && canUseAI {
|
|
||||||
Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis")
|
|
||||||
.font(.system(size: 10, weight: .bold))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
.padding(.horizontal, 7)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(theme.backgroundSecondary)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !canUseAI {
|
|
||||||
// Gesperrt: alle Freiabfragen verbraucht
|
|
||||||
Button { showPaywall = true } label: {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Image(systemName: "sparkles")
|
|
||||||
.foregroundStyle(theme.accent)
|
|
||||||
Text("nahbar Max freischalten für KI-Analyse")
|
|
||||||
.font(.system(size: 14, weight: .medium))
|
|
||||||
.foregroundStyle(theme.accent)
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
.background(theme.surfaceCard)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Active state
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
switch analysisState {
|
|
||||||
case .idle:
|
|
||||||
Button {
|
|
||||||
if aiConsentGiven {
|
|
||||||
Task { await runAnalysis() }
|
|
||||||
} else {
|
|
||||||
showAIConsent = true
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Image(systemName: "sparkles")
|
|
||||||
.foregroundStyle(theme.accent)
|
|
||||||
Text("\(person.firstName) analysieren")
|
|
||||||
.font(.system(size: 15, weight: .medium))
|
|
||||||
.foregroundStyle(theme.contentPrimary)
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .loading:
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
ProgressView().tint(theme.accent)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Analysiere Logbuch…")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundStyle(theme.contentSecondary)
|
|
||||||
Text("Das kann bis zu einer Minute dauern.")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
|
|
||||||
case .result(let result, let date):
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
analysisSection(icon: "waveform.path", title: "Muster & Themen", text: result.patterns)
|
|
||||||
RowDivider()
|
|
||||||
analysisSection(icon: "person.2", title: "Beziehungsqualität", text: result.relationship)
|
|
||||||
RowDivider()
|
|
||||||
analysisSection(icon: "arrow.right.circle", title: "Empfehlung", text: result.recommendation)
|
|
||||||
RowDivider()
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
// Zeitstempel
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
|
||||||
Text("Analysiert")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
Text(date.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale(identifier: "de_DE"))))
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
}
|
|
||||||
.padding(.leading, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Aktualisieren
|
|
||||||
Button {
|
|
||||||
Task { await runAnalysis() }
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "arrow.clockwise")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
Text(remainingRequests > 0 ? "Aktualisieren (\(remainingRequests))" : "Limit erreicht")
|
|
||||||
.font(.system(size: 13))
|
|
||||||
}
|
|
||||||
.foregroundStyle(remainingRequests > 0 ? theme.accent : theme.contentTertiary)
|
|
||||||
}
|
|
||||||
.disabled(remainingRequests == 0 || isPurchasing)
|
|
||||||
.padding(.trailing, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .error(let msg):
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Label("Analyse fehlgeschlagen", systemImage: "exclamationmark.triangle")
|
|
||||||
.font(.system(size: 14, weight: .medium))
|
|
||||||
.foregroundStyle(theme.contentSecondary)
|
|
||||||
Text(msg)
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
Button {
|
|
||||||
Task { await runAnalysis() }
|
|
||||||
} label: {
|
|
||||||
Text("Erneut versuchen")
|
|
||||||
.font(.system(size: 13, weight: .medium))
|
|
||||||
.foregroundStyle(theme.accent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(theme.surfaceCard)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func analysisSection(icon: String, title: String, text: 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(title)
|
|
||||||
.font(.system(size: 13, weight: .semibold))
|
|
||||||
.foregroundStyle(theme.contentSecondary)
|
|
||||||
Text(LocalizedStringKey(text))
|
|
||||||
.font(.system(size: 14, design: theme.displayDesign))
|
|
||||||
.foregroundStyle(theme.contentPrimary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isPurchasing: Bool {
|
|
||||||
if case .loading = analysisState { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func runAnalysis() async {
|
|
||||||
guard !mergedItems.isEmpty else { return }
|
|
||||||
guard !AIAnalysisService.shared.isRateLimited else { return }
|
|
||||||
analysisState = .loading
|
|
||||||
do {
|
|
||||||
let result = try await AIAnalysisService.shared.analyze(person: person)
|
|
||||||
remainingRequests = AIAnalysisService.shared.remainingRequests
|
|
||||||
if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() }
|
|
||||||
analysisState = .result(result, Date())
|
|
||||||
} catch {
|
|
||||||
// Bei Fehler alten Cache wiederherstellen falls vorhanden
|
|
||||||
if let cached = AIAnalysisService.shared.loadCached(for: person) {
|
|
||||||
analysisState = .result(cached.asResult, cached.analyzedAt)
|
|
||||||
} else {
|
|
||||||
analysisState = .error(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Data
|
// MARK: - Data
|
||||||
|
|
||||||
private var mergedItems: [LogbuchItem] {
|
private var mergedItems: [LogbuchItem] {
|
||||||
|
|||||||
@@ -38,6 +38,25 @@ enum NudgeFrequency: String, CaseIterable, Codable {
|
|||||||
case .quarterly: return 90
|
case .quarterly: return 90
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lesbares Label für den Nudge-Chip im Header
|
||||||
|
var displayLabel: String {
|
||||||
|
switch self {
|
||||||
|
case .never: return "Nie"
|
||||||
|
case .weekly: return "Wöchentlich"
|
||||||
|
case .biweekly: return "Alle 2 Wochen"
|
||||||
|
case .monthly: return "Monatlich"
|
||||||
|
case .quarterly: return "Quartalsweise"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ampelstatus des Nudge-Intervalls einer Person
|
||||||
|
enum NudgeStatus: Equatable {
|
||||||
|
case never // kein Intervall gesetzt
|
||||||
|
case ok // < 75 % des Intervalls verstrichen
|
||||||
|
case soon // 75–100 % verstrichen
|
||||||
|
case overdue // > 100 % verstrichen
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MomentType: String, Codable {
|
enum MomentType: String, Codable {
|
||||||
@@ -117,6 +136,9 @@ class Person {
|
|||||||
var interests: String?
|
var interests: String?
|
||||||
var generalNotes: String?
|
var generalNotes: String?
|
||||||
var culturalBackground: String? = nil // V6: kultureller Hintergrund
|
var culturalBackground: String? = nil // V6: kultureller Hintergrund
|
||||||
|
var phoneNumber: String? = nil // V9: primäre Telefonnummer
|
||||||
|
var emailAddress: String? = nil // V9: primäre E-Mail-Adresse
|
||||||
|
var cnIdentifier: String? = nil // V9: Apple Contacts-ID für "Vom Kontakt aktualisieren"
|
||||||
var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue
|
var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue
|
||||||
var nextStep: String?
|
var nextStep: String?
|
||||||
var nextStepCompleted: Bool = false
|
var nextStepCompleted: Bool = false
|
||||||
@@ -158,6 +180,9 @@ class Person {
|
|||||||
self.interests = interests
|
self.interests = interests
|
||||||
self.generalNotes = generalNotes
|
self.generalNotes = generalNotes
|
||||||
self.culturalBackground = culturalBackground
|
self.culturalBackground = culturalBackground
|
||||||
|
self.phoneNumber = nil
|
||||||
|
self.emailAddress = nil
|
||||||
|
self.cnIdentifier = nil
|
||||||
self.nudgeFrequencyRaw = nudgeFrequency.rawValue
|
self.nudgeFrequencyRaw = nudgeFrequency.rawValue
|
||||||
self.photoData = nil
|
self.photoData = nil
|
||||||
self.photo = nil
|
self.photo = nil
|
||||||
@@ -196,6 +221,17 @@ class Person {
|
|||||||
return Date().timeIntervalSince(createdAt) > Double(days * 86400)
|
return Date().timeIntervalSince(createdAt) > Double(days * 86400)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dreistufiger Ampelstatus basierend auf verstrichener Zeit vs. Nudge-Intervall
|
||||||
|
var nudgeStatus: NudgeStatus {
|
||||||
|
guard nudgeFrequency != .never, let days = nudgeFrequency.days else { return .never }
|
||||||
|
let interval = Double(days * 86400)
|
||||||
|
let reference = lastMomentDate ?? createdAt
|
||||||
|
let elapsed = Date().timeIntervalSince(reference)
|
||||||
|
if elapsed >= interval { return .overdue }
|
||||||
|
if elapsed >= interval * 0.75 { return .soon }
|
||||||
|
return .ok
|
||||||
|
}
|
||||||
|
|
||||||
func hasBirthdayWithin(days: Int) -> Bool {
|
func hasBirthdayWithin(days: Int) -> Bool {
|
||||||
guard let birthday else { return false }
|
guard let birthday else { return false }
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
@@ -295,7 +331,7 @@ class Person {
|
|||||||
|
|
||||||
// MARK: - LogEntryType
|
// MARK: - LogEntryType
|
||||||
|
|
||||||
enum LogEntryType: String, Codable {
|
enum LogEntryType: String, Codable, CaseIterable {
|
||||||
case nextStep = "Schritt abgeschlossen"
|
case nextStep = "Schritt abgeschlossen"
|
||||||
case calendarEvent = "Termin geplant"
|
case calendarEvent = "Termin geplant"
|
||||||
case call = "Anruf"
|
case call = "Anruf"
|
||||||
|
|||||||
@@ -1,12 +1,41 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import UserNotifications
|
||||||
|
import UIKit
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "nahbar", category: "App")
|
private let logger = Logger(subsystem: "nahbar", category: "App")
|
||||||
|
private let notificationLogger = Logger(subsystem: "nahbar", category: "Notification")
|
||||||
|
|
||||||
|
// MARK: - App Delegate
|
||||||
|
// Setzt den UNUserNotificationCenterDelegate damit Benachrichtigungen auch im
|
||||||
|
// Vordergrund als Banner angezeigt werden.
|
||||||
|
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
|
) -> Bool {
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
// UIKit-Appearance VOR der View-Erstellung setzen – verhindert weißes Aufblitzen
|
||||||
|
NahbarApp.applyInitialTabBarAppearance()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zeigt Benachrichtigungen auch an, wenn die App im Vordergrund läuft.
|
||||||
|
func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler([.banner, .sound])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct NahbarApp: App {
|
struct NahbarApp: App {
|
||||||
|
|
||||||
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
// Static let stellt sicher, dass der Container exakt einmal erstellt wird –
|
// Static let stellt sicher, dass der Container exakt einmal erstellt wird –
|
||||||
// unabhängig davon, wie oft body ausgewertet wird.
|
// unabhängig davon, wie oft body ausgewertet wird.
|
||||||
private static let containerBuild = AppGroup.makeMainContainerWithMigration()
|
private static let containerBuild = AppGroup.makeMainContainerWithMigration()
|
||||||
@@ -19,6 +48,9 @@ struct NahbarApp: App {
|
|||||||
@StateObject private var profileStore = UserProfileStore.shared
|
@StateObject private var profileStore = UserProfileStore.shared
|
||||||
@StateObject private var eventLog = AppEventLog.shared
|
@StateObject private var eventLog = AppEventLog.shared
|
||||||
|
|
||||||
|
/// Shared tour coordinator — passed via environment to all views.
|
||||||
|
@State private var tourCoordinator = TourCoordinator()
|
||||||
|
|
||||||
@AppStorage("activeThemeID") private var activeThemeIDRaw: String = ThemeID.linen.rawValue
|
@AppStorage("activeThemeID") private var activeThemeIDRaw: String = ThemeID.linen.rawValue
|
||||||
@AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false
|
@AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false
|
||||||
|
|
||||||
@@ -33,12 +65,13 @@ struct NahbarApp: App {
|
|||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ZStack {
|
ZStack {
|
||||||
ContentView()
|
ContentView(splashVisible: showSplash)
|
||||||
.environmentObject(callWindowManager)
|
.environmentObject(callWindowManager)
|
||||||
.environmentObject(appLockManager)
|
.environmentObject(appLockManager)
|
||||||
.environmentObject(cloudSyncMonitor)
|
.environmentObject(cloudSyncMonitor)
|
||||||
.environmentObject(profileStore)
|
.environmentObject(profileStore)
|
||||||
.environmentObject(eventLog)
|
.environmentObject(eventLog)
|
||||||
|
.environment(tourCoordinator)
|
||||||
// Verhindert Touch-Durchfall bei aktivem Splash- oder Lock-Screen
|
// Verhindert Touch-Durchfall bei aktivem Splash- oder Lock-Screen
|
||||||
.allowsHitTesting(!showSplash && !appLockManager.isLocked)
|
.allowsHitTesting(!showSplash && !appLockManager.isLocked)
|
||||||
|
|
||||||
@@ -71,14 +104,17 @@ struct NahbarApp: App {
|
|||||||
.animation(.easeInOut(duration: 0.40), value: showSplash)
|
.animation(.easeInOut(duration: 0.40), value: showSplash)
|
||||||
.environment(\.nahbarTheme, activeTheme)
|
.environment(\.nahbarTheme, activeTheme)
|
||||||
.tint(activeTheme.accent)
|
.tint(activeTheme.accent)
|
||||||
|
// Zwingt das System in den richtigen Light/Dark-Modus für das aktive Theme –
|
||||||
|
// dadurch passt Liquid Glass (Tab-Bar, Sheets, Alerts) automatisch korrekt an.
|
||||||
|
.preferredColorScheme(activeTheme.id.isDark ? .dark : .light)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
applyTabBarAppearance(activeTheme)
|
NahbarApp.applyTabBarAppearance(activeTheme)
|
||||||
cloudSyncMonitor.startMonitoring(iCloudEnabled: icloudSyncEnabled)
|
cloudSyncMonitor.startMonitoring(iCloudEnabled: icloudSyncEnabled)
|
||||||
AftermathNotificationManager.shared.registerCategory()
|
AftermathNotificationManager.shared.registerCategory()
|
||||||
logger.info("App gestartet. Container-Modus: \(String(describing: NahbarApp.containerFallback))")
|
logger.info("App gestartet. Container-Modus: \(String(describing: NahbarApp.containerFallback))")
|
||||||
AppEventLog.shared.record("Container-Modus: \(NahbarApp.containerFallback)", level: .info, category: "Lifecycle")
|
AppEventLog.shared.record("Container-Modus: \(NahbarApp.containerFallback)", level: .info, category: "Lifecycle")
|
||||||
}
|
}
|
||||||
.onChange(of: activeThemeIDRaw) { _, _ in applyTabBarAppearance(activeTheme) }
|
.onChange(of: activeThemeIDRaw) { _, _ in NahbarApp.applyTabBarAppearance(activeTheme) }
|
||||||
.onChange(of: icloudSyncEnabled) { _, enabled in
|
.onChange(of: icloudSyncEnabled) { _, enabled in
|
||||||
cloudSyncMonitor.startMonitoring(iCloudEnabled: enabled)
|
cloudSyncMonitor.startMonitoring(iCloudEnabled: enabled)
|
||||||
}
|
}
|
||||||
@@ -91,34 +127,50 @@ struct NahbarApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyTabBarAppearance(_ theme: NahbarTheme) {
|
/// Liest das aktive Theme aus UserDefaults und setzt UITabBar-Appearance
|
||||||
let bg = UIColor(theme.backgroundPrimary).withAlphaComponent(0.88)
|
/// vor der ersten View-Erstellung (AppDelegate-Kontext).
|
||||||
let normal = UIColor(theme.contentTertiary)
|
static func applyInitialTabBarAppearance() {
|
||||||
let selected = UIColor(theme.accent)
|
let themeID = UserDefaults.standard.string(forKey: "activeThemeID")
|
||||||
let border = UIColor(theme.borderSubtle).withAlphaComponent(0.6)
|
.flatMap { ThemeID(rawValue: $0) } ?? .linen
|
||||||
|
applyTabBarAppearance(NahbarTheme.theme(for: themeID))
|
||||||
|
}
|
||||||
|
|
||||||
let item = UITabBarItemAppearance()
|
/// Setzt UIKit-Appearance für das gegebene Theme.
|
||||||
item.normal.iconColor = normal
|
/// Auf iOS 26+ übernimmt Liquid Glass die Tab-Bar automatisch –
|
||||||
item.normal.titleTextAttributes = [.foregroundColor: normal]
|
/// eigene UITabBarAppearance-Hintergründe würden es stören.
|
||||||
item.selected.iconColor = selected
|
/// Auf iOS 17–25 wird die Tab-Bar explizit in Theme-Farben eingefärbt.
|
||||||
item.selected.titleTextAttributes = [.foregroundColor: selected]
|
static func applyTabBarAppearance(_ theme: NahbarTheme) {
|
||||||
|
let border = UIColor(theme.borderSubtle)
|
||||||
|
let selected = UIColor(theme.accent)
|
||||||
|
let navBg = UIColor(theme.backgroundPrimary)
|
||||||
|
let titleColor = UIColor(theme.contentPrimary)
|
||||||
|
|
||||||
let tabAppearance = UITabBarAppearance()
|
// iOS 17–25: Liquid Glass existiert nicht; eigene UITabBarAppearance nötig.
|
||||||
tabAppearance.configureWithTransparentBackground()
|
// iOS 26+: Block weggelassen – Liquid Glass + .preferredColorScheme übernehmen.
|
||||||
tabAppearance.backgroundColor = bg
|
if #unavailable(iOS 26) {
|
||||||
tabAppearance.shadowColor = border
|
let bg = UIColor(theme.backgroundPrimary)
|
||||||
tabAppearance.stackedLayoutAppearance = item
|
let normal = UIColor(theme.contentTertiary)
|
||||||
tabAppearance.inlineLayoutAppearance = item
|
|
||||||
tabAppearance.compactInlineLayoutAppearance = item
|
|
||||||
|
|
||||||
UITabBar.appearance().standardAppearance = tabAppearance
|
let item = UITabBarItemAppearance()
|
||||||
UITabBar.appearance().scrollEdgeAppearance = tabAppearance
|
item.normal.iconColor = normal
|
||||||
|
item.normal.titleTextAttributes = [.foregroundColor: normal]
|
||||||
|
item.selected.iconColor = selected
|
||||||
|
item.selected.titleTextAttributes = [.foregroundColor: selected]
|
||||||
|
|
||||||
let navBg = UIColor(theme.backgroundPrimary).withAlphaComponent(0.92)
|
let tabAppearance = UITabBarAppearance()
|
||||||
let titleColor = UIColor(theme.contentPrimary)
|
tabAppearance.configureWithOpaqueBackground()
|
||||||
|
tabAppearance.backgroundColor = bg
|
||||||
|
tabAppearance.shadowColor = border
|
||||||
|
tabAppearance.stackedLayoutAppearance = item
|
||||||
|
tabAppearance.inlineLayoutAppearance = item
|
||||||
|
tabAppearance.compactInlineLayoutAppearance = item
|
||||||
|
|
||||||
|
UITabBar.appearance().standardAppearance = tabAppearance
|
||||||
|
UITabBar.appearance().scrollEdgeAppearance = tabAppearance
|
||||||
|
}
|
||||||
|
|
||||||
let navAppearance = UINavigationBarAppearance()
|
let navAppearance = UINavigationBarAppearance()
|
||||||
navAppearance.configureWithTransparentBackground()
|
navAppearance.configureWithOpaqueBackground()
|
||||||
navAppearance.backgroundColor = navBg
|
navAppearance.backgroundColor = navBg
|
||||||
navAppearance.shadowColor = border
|
navAppearance.shadowColor = border
|
||||||
navAppearance.titleTextAttributes = [.foregroundColor: titleColor]
|
navAppearance.titleTextAttributes = [.foregroundColor: titleColor]
|
||||||
|
|||||||
@@ -640,15 +640,141 @@ enum NahbarSchemaV7: VersionedSchema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Schema V8 (aktuelles Schema)
|
// MARK: - Schema V8 (eingefrorener Snapshot)
|
||||||
// Referenziert die Live-Typen aus Models.swift.
|
// Exakter Zustand aller Modelle zum Zeitpunkt des V8-Deployments.
|
||||||
// Beim Hinzufügen von V9 muss V8 als eingefrorener Snapshot gesichert werden.
|
// WICHTIG: Niemals nachträglich ändern – dieser Snapshot muss dem gespeicherten
|
||||||
|
// Schema-Hash von V8-Datenbanken auf Nutzer-Geräten entsprechen.
|
||||||
//
|
//
|
||||||
// V8 fügt hinzu:
|
// V8 fügte hinzu:
|
||||||
// • Todo: reminderDate (optionale Push-Benachrichtigung)
|
// • Todo: reminderDate (optionale Push-Benachrichtigung)
|
||||||
|
|
||||||
enum NahbarSchemaV8: VersionedSchema {
|
enum NahbarSchemaV8: VersionedSchema {
|
||||||
static var versionIdentifier = Schema.Version(8, 0, 0)
|
static var versionIdentifier = Schema.Version(8, 0, 0)
|
||||||
|
static var models: [any PersistentModel.Type] {
|
||||||
|
[PersonPhoto.self, Person.self, Moment.self, LogEntry.self,
|
||||||
|
Visit.self, Rating.self, HealthSnapshot.self, Todo.self]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class PersonPhoto {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
@Attribute(.externalStorage) var imageData: Data = Data()
|
||||||
|
var createdAt: Date = Date()
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class Person {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var name: String = ""
|
||||||
|
var tagRaw: String = "Andere"
|
||||||
|
var birthday: Date? = nil
|
||||||
|
var occupation: String? = nil
|
||||||
|
var location: String? = nil
|
||||||
|
var interests: String? = nil
|
||||||
|
var generalNotes: String? = nil
|
||||||
|
var culturalBackground: String? = nil
|
||||||
|
var nudgeFrequencyRaw: String = "Monatlich"
|
||||||
|
var nextStep: String? = nil
|
||||||
|
var nextStepCompleted: Bool = false
|
||||||
|
var nextStepReminderDate: Date? = nil
|
||||||
|
var lastSuggestedForCall: Date? = nil
|
||||||
|
var createdAt: Date = Date()
|
||||||
|
var updatedAt: Date = Date()
|
||||||
|
var isArchived: Bool = false
|
||||||
|
@Relationship(deleteRule: .cascade) var photo: PersonPhoto? = nil
|
||||||
|
var photoData: Data? = nil
|
||||||
|
@Relationship(deleteRule: .cascade) var moments: [Moment]? = []
|
||||||
|
@Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = []
|
||||||
|
@Relationship(deleteRule: .cascade) var visits: [Visit]? = []
|
||||||
|
@Relationship(deleteRule: .cascade) var todos: [Todo]? = []
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class Moment {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var text: String = ""
|
||||||
|
var typeRaw: String = "Gespräch"
|
||||||
|
var sourceRaw: String? = nil
|
||||||
|
var createdAt: Date = Date()
|
||||||
|
var updatedAt: Date = Date()
|
||||||
|
var isImportant: Bool = false
|
||||||
|
var person: Person? = nil
|
||||||
|
@Relationship(deleteRule: .cascade) var ratings: [Rating]? = []
|
||||||
|
@Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil
|
||||||
|
var statusRaw: String? = nil
|
||||||
|
var aftermathNotificationScheduled: Bool = false
|
||||||
|
var aftermathCompletedAt: Date? = nil
|
||||||
|
var reminderDate: Date? = nil
|
||||||
|
var isCompleted: Bool = false
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class LogEntry {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var typeRaw: String = "Schritt abgeschlossen"
|
||||||
|
var title: String = ""
|
||||||
|
var loggedAt: Date = Date()
|
||||||
|
var updatedAt: Date = Date()
|
||||||
|
var person: Person? = nil
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class Visit {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var visitDate: Date = Date()
|
||||||
|
var statusRaw: String = "sofort_abgeschlossen"
|
||||||
|
var note: String? = nil
|
||||||
|
var aftermathNotificationScheduled: Bool = false
|
||||||
|
var aftermathCompletedAt: Date? = nil
|
||||||
|
var person: Person? = nil
|
||||||
|
@Relationship(deleteRule: .cascade) var ratings: [Rating]? = []
|
||||||
|
@Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class Rating {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var categoryRaw: String = "Selbst"
|
||||||
|
var questionIndex: Int = 0
|
||||||
|
var value: Int? = nil
|
||||||
|
var isAftermath: Bool = false
|
||||||
|
var visit: Visit? = nil
|
||||||
|
var moment: Moment? = nil
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class HealthSnapshot {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var sleepHours: Double? = nil
|
||||||
|
var hrvMs: Double? = nil
|
||||||
|
var restingHR: Int? = nil
|
||||||
|
var steps: Int? = nil
|
||||||
|
var visit: Visit? = nil
|
||||||
|
var moment: Moment? = nil
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model final class Todo {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var title: String = ""
|
||||||
|
var dueDate: Date = Date()
|
||||||
|
var isCompleted: Bool = false
|
||||||
|
var completedAt: Date? = nil
|
||||||
|
var reminderDate: Date? = nil // V8-Feld
|
||||||
|
var person: Person? = nil
|
||||||
|
var createdAt: Date = Date()
|
||||||
|
init() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schema V9 (aktuelles Schema)
|
||||||
|
// Referenziert die Live-Typen aus Models.swift.
|
||||||
|
// Beim Hinzufügen von V10 muss V9 als eingefrorener Snapshot gesichert werden.
|
||||||
|
//
|
||||||
|
// V9 fügt hinzu:
|
||||||
|
// • Person: phoneNumber, emailAddress, cnIdentifier (Kontaktdaten für direkte Aktionen)
|
||||||
|
|
||||||
|
enum NahbarSchemaV9: VersionedSchema {
|
||||||
|
static var versionIdentifier = Schema.Version(9, 0, 0)
|
||||||
static var models: [any PersistentModel.Type] {
|
static var models: [any PersistentModel.Type] {
|
||||||
[nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self,
|
[nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self,
|
||||||
nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self, nahbar.Todo.self]
|
nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self, nahbar.Todo.self]
|
||||||
@@ -661,7 +787,7 @@ enum NahbarMigrationPlan: SchemaMigrationPlan {
|
|||||||
static var schemas: [any VersionedSchema.Type] {
|
static var schemas: [any VersionedSchema.Type] {
|
||||||
[NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self,
|
[NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self,
|
||||||
NahbarSchemaV4.self, NahbarSchemaV5.self, NahbarSchemaV6.self,
|
NahbarSchemaV4.self, NahbarSchemaV5.self, NahbarSchemaV6.self,
|
||||||
NahbarSchemaV7.self, NahbarSchemaV8.self]
|
NahbarSchemaV7.self, NahbarSchemaV8.self, NahbarSchemaV9.self]
|
||||||
}
|
}
|
||||||
|
|
||||||
static var stages: [MigrationStage] {
|
static var stages: [MigrationStage] {
|
||||||
@@ -693,7 +819,11 @@ enum NahbarMigrationPlan: SchemaMigrationPlan {
|
|||||||
|
|
||||||
// V7 → V8: Todo bekommt reminderDate = nil.
|
// V7 → V8: Todo bekommt reminderDate = nil.
|
||||||
// Optionales Feld mit nil-Default → lightweight-Migration reicht aus.
|
// Optionales Feld mit nil-Default → lightweight-Migration reicht aus.
|
||||||
.lightweight(fromVersion: NahbarSchemaV7.self, toVersion: NahbarSchemaV8.self)
|
.lightweight(fromVersion: NahbarSchemaV7.self, toVersion: NahbarSchemaV8.self),
|
||||||
|
|
||||||
|
// V8 → V9: Person bekommt phoneNumber, emailAddress, cnIdentifier = nil.
|
||||||
|
// Alle drei Felder sind optional mit nil-Default → lightweight-Migration reicht aus.
|
||||||
|
.lightweight(fromVersion: NahbarSchemaV8.self, toVersion: NahbarSchemaV9.self)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ private let onboardingLogger = Logger(subsystem: "nahbar", category: "Onboarding
|
|||||||
// MARK: - OnboardingContainerView
|
// MARK: - OnboardingContainerView
|
||||||
|
|
||||||
/// Root container for the first-launch onboarding flow.
|
/// Root container for the first-launch onboarding flow.
|
||||||
/// Shows a TabView with Phase 1 (Profile) and Phase 2 (Contacts),
|
/// Phase 1: Profil, Phase 2: Kontakte, Phase 3: Datenschutz.
|
||||||
/// then overlays the Phase 3 (Feature Tour) on top with a blurred background.
|
|
||||||
struct OnboardingContainerView: View {
|
struct OnboardingContainerView: View {
|
||||||
let onComplete: () -> Void
|
let onComplete: () -> Void
|
||||||
|
|
||||||
@@ -20,68 +19,49 @@ struct OnboardingContainerView: View {
|
|||||||
|
|
||||||
/// Current tab page index (0 = profile, 1 = contacts).
|
/// Current tab page index (0 = profile, 1 = contacts).
|
||||||
@State private var tabPage: Int = 0
|
@State private var tabPage: Int = 0
|
||||||
/// Whether the feature tour overlay is visible.
|
/// Whether the final privacy screen is visible (shown after contacts).
|
||||||
@State private var showTour: Bool = false
|
|
||||||
/// Whether the final privacy screen is visible (shown after the feature tour).
|
|
||||||
@State private var showPrivacyScreen: Bool = false
|
@State private var showPrivacyScreen: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// ── Background pages (blurred when tour or privacy screen is active) ──
|
// ── Background pages (blurred when privacy screen is active) ──────
|
||||||
TabView(selection: $tabPage) {
|
TabView(selection: $tabPage) {
|
||||||
OnboardingProfileView(coordinator: coordinator)
|
OnboardingProfileView(coordinator: coordinator)
|
||||||
.tag(0)
|
.tag(0)
|
||||||
|
|
||||||
OnboardingQuizPromptView(coordinator: coordinator)
|
|
||||||
.tag(1)
|
|
||||||
|
|
||||||
OnboardingContactImportView(
|
OnboardingContactImportView(
|
||||||
coordinator: coordinator,
|
coordinator: coordinator,
|
||||||
onContinue: startTour,
|
onContinue: startPrivacyScreen
|
||||||
onSkip: startTour
|
|
||||||
)
|
)
|
||||||
.tag(2)
|
.tag(1)
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
.blur(radius: (showTour || showPrivacyScreen) ? 20 : 0)
|
.blur(radius: showPrivacyScreen ? 20 : 0)
|
||||||
.disabled(showTour || showPrivacyScreen)
|
.disabled(showPrivacyScreen)
|
||||||
|
|
||||||
// ── Dark overlay ─────────────────────────────────────────────────
|
// ── Dark overlay when privacy screen shown ────────────────────────
|
||||||
if showTour || showPrivacyScreen {
|
if showPrivacyScreen {
|
||||||
Color.black.opacity(0.45)
|
Color.black.opacity(0.45)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Feature tour ─────────────────────────────────────────────────
|
|
||||||
if showTour {
|
|
||||||
FeatureTourView(onFinish: startPrivacyScreen)
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Privacy screen (final step) ───────────────────────────────────
|
// ── Privacy screen (final step) ───────────────────────────────────
|
||||||
if showPrivacyScreen {
|
if showPrivacyScreen {
|
||||||
OnboardingPrivacyView(onFinish: finishOnboarding)
|
OnboardingPrivacyView(onFinish: finishOnboarding)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.35), value: showTour)
|
|
||||||
.animation(.easeInOut(duration: 0.35), value: showPrivacyScreen)
|
.animation(.easeInOut(duration: 0.35), value: showPrivacyScreen)
|
||||||
.onChange(of: coordinator.currentStep) { _, step in
|
.onChange(of: coordinator.currentStep) { _, step in
|
||||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||||
// Cap tab index at 2 (contacts is the last real page after quiz)
|
tabPage = min(step.rawValue, 1)
|
||||||
tabPage = min(step.rawValue, 2)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startTour() {
|
|
||||||
withAnimation { showTour = true }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startPrivacyScreen() {
|
private func startPrivacyScreen() {
|
||||||
withAnimation(.easeInOut(duration: 0.35)) {
|
withAnimation(.easeInOut(duration: 0.35)) {
|
||||||
showTour = false
|
|
||||||
showPrivacyScreen = true
|
showPrivacyScreen = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,7 +207,7 @@ private struct OnboardingProfileView: View {
|
|||||||
Button {
|
Button {
|
||||||
let newValue = selected ? "" : option
|
let newValue = selected ? "" : option
|
||||||
coordinator.gender = newValue
|
coordinator.gender = newValue
|
||||||
// Sofort persistieren, damit der Quiz-Schritt es lesen kann
|
// Sofort persistieren
|
||||||
UserProfileStore.shared.updateGender(newValue)
|
UserProfileStore.shared.updateGender(newValue)
|
||||||
} label: {
|
} label: {
|
||||||
Text(option)
|
Text(option)
|
||||||
@@ -259,7 +239,7 @@ private struct OnboardingProfileView: View {
|
|||||||
|
|
||||||
// ── Continue button ──────────────────────────────────────────
|
// ── Continue button ──────────────────────────────────────────
|
||||||
Button {
|
Button {
|
||||||
coordinator.advanceToQuiz()
|
coordinator.advanceToContacts()
|
||||||
} label: {
|
} label: {
|
||||||
Text("Weiter")
|
Text("Weiter")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -273,7 +253,7 @@ private struct OnboardingProfileView: View {
|
|||||||
}
|
}
|
||||||
.disabled(!coordinator.isProfileValid)
|
.disabled(!coordinator.isProfileValid)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.accessibilityLabel("Weiter zum Persönlichkeitsquiz")
|
.accessibilityLabel("Weiter zu Kontakten")
|
||||||
.accessibilityHint(coordinator.isProfileValid
|
.accessibilityHint(coordinator.isProfileValid
|
||||||
? ""
|
? ""
|
||||||
: "Bitte gib zuerst deinen Vornamen ein.")
|
: "Bitte gib zuerst deinen Vornamen ein.")
|
||||||
@@ -357,42 +337,17 @@ private struct OnboardingProfileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Phase 2: OnboardingQuizPromptView
|
// MARK: - Phase 2: OnboardingContactImportView
|
||||||
|
|
||||||
/// Onboarding-Seite für das Persönlichkeitsquiz.
|
|
||||||
/// Zeigt die Quiz-Intro-UI und präsentiert PersonalityQuizView als Sheet.
|
|
||||||
private struct OnboardingQuizPromptView: View {
|
|
||||||
@ObservedObject var coordinator: OnboardingCoordinator
|
|
||||||
@AppStorage("hasSkippedPersonalityQuiz") private var hasSkippedQuiz: Bool = false
|
|
||||||
@State private var showingQuiz = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
QuizIntroScreen(
|
|
||||||
onStart: { showingQuiz = true },
|
|
||||||
onSkip: {
|
|
||||||
hasSkippedQuiz = true
|
|
||||||
coordinator.skipQuiz()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.sheet(isPresented: $showingQuiz) {
|
|
||||||
PersonalityQuizView(skipIntro: true) { _ in
|
|
||||||
coordinator.advanceFromQuizToContacts()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Phase 3: OnboardingContactImportView
|
|
||||||
|
|
||||||
/// Uses CNContactPickerViewController (system picker, no permission needed).
|
/// Uses CNContactPickerViewController (system picker, no permission needed).
|
||||||
/// Multi-select is activated automatically by implementing didSelectContacts:.
|
/// Multi-select is activated automatically by implementing didSelectContacts:.
|
||||||
private struct OnboardingContactImportView: View {
|
private struct OnboardingContactImportView: View {
|
||||||
@ObservedObject var coordinator: OnboardingCoordinator
|
@ObservedObject var coordinator: OnboardingCoordinator
|
||||||
let onContinue: () -> Void
|
let onContinue: () -> Void
|
||||||
let onSkip: () -> Void
|
|
||||||
|
|
||||||
@State private var showingPicker = false
|
@State private var showingPicker = false
|
||||||
@State private var showSkipConfirmation: Bool = false
|
@State private var showingLimitAlert = false
|
||||||
|
@State private var droppedByLimit = 0
|
||||||
|
|
||||||
private let maxContacts = 3
|
private let maxContacts = 3
|
||||||
private var atLimit: Bool { coordinator.selectedContacts.count >= maxContacts }
|
private var atLimit: Bool { coordinator.selectedContacts.count >= maxContacts }
|
||||||
@@ -451,29 +406,21 @@ private struct OnboardingContactImportView: View {
|
|||||||
: "\(coordinator.selectedContacts.count) Kontakte ausgewählt. Weiter."
|
: "\(coordinator.selectedContacts.count) Kontakte ausgewählt. Weiter."
|
||||||
)
|
)
|
||||||
|
|
||||||
Button {
|
if coordinator.selectedContacts.isEmpty {
|
||||||
showSkipConfirmation = true
|
Text("Füge mindestens eine Person hinzu, um fortzufahren – sonst macht nahbar leider keinen Sinn.")
|
||||||
} label: {
|
.font(.caption)
|
||||||
Text("Überspringen")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Kontakte überspringen")
|
|
||||||
.accessibilityHint("Zeigt eine Bestätigungsabfrage.")
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirmationDialog(
|
.alert("Limit erreicht", isPresented: $showingLimitAlert) {
|
||||||
"Kontakte überspringen?",
|
Button("OK", role: .cancel) {}
|
||||||
isPresented: $showSkipConfirmation,
|
|
||||||
titleVisibility: .visible
|
|
||||||
) {
|
|
||||||
Button("Trotzdem überspringen", role: .destructive, action: onSkip)
|
|
||||||
Button("Abbrechen", role: .cancel) {}
|
|
||||||
} message: {
|
} message: {
|
||||||
Text("Du kannst Kontakte jederzeit später in der App hinzufügen.")
|
Text(limitAlertMessage)
|
||||||
}
|
}
|
||||||
.overlay(alignment: .center) {
|
.overlay(alignment: .center) {
|
||||||
// Invisible trigger — finds the hosting UIViewController via
|
// Invisible trigger — finds the hosting UIViewController via
|
||||||
@@ -563,20 +510,38 @@ private struct OnboardingContactImportView: View {
|
|||||||
|
|
||||||
// MARK: Merge helper
|
// MARK: Merge helper
|
||||||
|
|
||||||
|
private var limitAlertMessage: String {
|
||||||
|
if droppedByLimit == 1 {
|
||||||
|
return String(localized: "1 Kontakt wurde nicht hinzugefügt. Im Free-Tier kannst du beim Onboarding bis zu 3 Personen auswählen.")
|
||||||
|
}
|
||||||
|
return String.localizedStringWithFormat(
|
||||||
|
String(localized: "%lld Kontakte wurden nicht hinzugefügt. Im Free-Tier kannst du beim Onboarding bis zu 3 Personen auswählen."),
|
||||||
|
droppedByLimit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Merges newly picked contacts into the existing selection (no duplicates).
|
/// Merges newly picked contacts into the existing selection (no duplicates).
|
||||||
private func mergeContacts(_ contacts: [CNContact]) {
|
private func mergeContacts(_ contacts: [CNContact]) {
|
||||||
|
var dropped = 0
|
||||||
for contact in contacts {
|
for contact in contacts {
|
||||||
guard coordinator.selectedContacts.count < maxContacts else { break }
|
if coordinator.selectedContacts.count >= maxContacts {
|
||||||
|
dropped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
let alreadySelected = coordinator.selectedContacts
|
let alreadySelected = coordinator.selectedContacts
|
||||||
.contains { $0.cnIdentifier == contact.identifier }
|
.contains { $0.cnIdentifier == contact.identifier }
|
||||||
if !alreadySelected {
|
if !alreadySelected {
|
||||||
coordinator.selectedContacts.append(NahbarContact(from: contact))
|
coordinator.selectedContacts.append(NahbarContact(from: contact))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if dropped > 0 {
|
||||||
|
droppedByLimit = dropped
|
||||||
|
showingLimitAlert = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Phase 4: OnboardingPrivacyView
|
// MARK: - Phase 3: OnboardingPrivacyView
|
||||||
|
|
||||||
/// Final onboarding screen. Explains the app's privacy-first approach and
|
/// Final onboarding screen. Explains the app's privacy-first approach and
|
||||||
/// informs users that AI features are optional and involve a third-party service.
|
/// informs users that AI features are optional and involve a third-party service.
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import Combine
|
|||||||
|
|
||||||
/// Each phase of the first-launch onboarding flow.
|
/// Each phase of the first-launch onboarding flow.
|
||||||
enum OnboardingStep: Int, CaseIterable {
|
enum OnboardingStep: Int, CaseIterable {
|
||||||
case profile = 0
|
case profile = 0 // Profilangaben
|
||||||
case quiz = 1 // Persönlichkeitsquiz-Prompt
|
case contacts = 1 // Kontakte importieren
|
||||||
case contacts = 2 // was 1
|
case complete = 2
|
||||||
case tour = 3 // was 2
|
|
||||||
case complete = 4 // was 3
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - OnboardingCoordinator
|
// MARK: - OnboardingCoordinator
|
||||||
@@ -42,39 +40,12 @@ final class OnboardingCoordinator: ObservableObject {
|
|||||||
|
|
||||||
// MARK: – Navigation actions
|
// MARK: – Navigation actions
|
||||||
|
|
||||||
/// Advances to the personality quiz prompt if the profile is valid.
|
|
||||||
func advanceToQuiz() {
|
|
||||||
guard isProfileValid else { return }
|
|
||||||
currentStep = .quiz
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Skips the personality quiz and goes directly to contact import.
|
|
||||||
func skipQuiz() {
|
|
||||||
currentStep = .contacts
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called after the personality quiz completes or is dismissed; advances to contact import.
|
|
||||||
func advanceFromQuizToContacts() {
|
|
||||||
currentStep = .contacts
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Advances to the contact import phase. Validates profile first.
|
/// Advances to the contact import phase. Validates profile first.
|
||||||
func advanceToContacts() {
|
func advanceToContacts() {
|
||||||
guard isProfileValid else { return }
|
guard isProfileValid else { return }
|
||||||
currentStep = .contacts
|
currentStep = .contacts
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Advances to the feature tour if at least one contact has been selected.
|
|
||||||
func advanceToTour() {
|
|
||||||
guard !selectedContacts.isEmpty else { return }
|
|
||||||
currentStep = .tour
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Skips the contact selection and goes directly to the feature tour.
|
|
||||||
func skipToTour() {
|
|
||||||
currentStep = .tour
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marks onboarding as fully complete.
|
/// Marks onboarding as fully complete.
|
||||||
func completeOnboarding() {
|
func completeOnboarding() {
|
||||||
currentStep = .complete
|
currentStep = .complete
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ struct PaywallView: View {
|
|||||||
private let maxExtraFeatures: [(icon: String, text: String)] = [
|
private let maxExtraFeatures: [(icon: String, text: String)] = [
|
||||||
("brain.head.profile", "KI-Analyse: Muster, Beziehungsqualität & Empfehlungen"),
|
("brain.head.profile", "KI-Analyse: Muster, Beziehungsqualität & Empfehlungen"),
|
||||||
("gift.fill", "Geschenkideen: KI-Vorschläge bei Geburtstagen"),
|
("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"),
|
("infinity", "Unbegrenzte KI-Abfragen ohne Limit"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ struct PaywallView: View {
|
|||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
|
|
||||||
Text("nahbar")
|
Text("nahbar")
|
||||||
.font(.system(size: 28, weight: .light, design: theme.displayDesign))
|
.font(.system(size: 26, weight: .light, design: theme.displayDesign))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
|
||||||
Text("Wähle deinen Plan")
|
Text("Wähle deinen Plan")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import SwiftData
|
|||||||
struct PeopleListView: View {
|
struct PeopleListView: View {
|
||||||
@Environment(\.nahbarTheme) var theme
|
@Environment(\.nahbarTheme) var theme
|
||||||
@Environment(\.modelContext) var modelContext
|
@Environment(\.modelContext) var modelContext
|
||||||
|
@Environment(TourCoordinator.self) private var tourCoordinator
|
||||||
@Query(sort: \Person.name) private var people: [Person]
|
@Query(sort: \Person.name) private var people: [Person]
|
||||||
@StateObject private var store = StoreManager.shared
|
@StateObject private var store = StoreManager.shared
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ struct PeopleListView: View {
|
|||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Menschen")
|
Text("Menschen")
|
||||||
.font(.system(size: 34, weight: .light, design: theme.displayDesign))
|
.font(.system(size: 32, weight: .light, design: theme.displayDesign))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
// Kontaktlimit-Hinweis für Free-Nutzer
|
// Kontaktlimit-Hinweis für Free-Nutzer
|
||||||
if !store.isPro {
|
if !store.isPro {
|
||||||
@@ -61,6 +62,7 @@ struct PeopleListView: View {
|
|||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Person hinzufügen")
|
.accessibilityLabel("Person hinzufügen")
|
||||||
|
.tourTarget(.addContactButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search bar
|
// Search bar
|
||||||
@@ -98,6 +100,7 @@ struct PeopleListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.tourTarget(.filterChips)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
@@ -120,6 +123,7 @@ struct PeopleListView: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.tourTarget(index == 0 ? TourTargetID.contactCardFirst : nil)
|
||||||
if index < filteredPeople.count - 1 {
|
if index < filteredPeople.count - 1 {
|
||||||
RowDivider()
|
RowDivider()
|
||||||
}
|
}
|
||||||
@@ -146,6 +150,14 @@ struct PeopleListView: View {
|
|||||||
.sheet(isPresented: $showingPaywall) {
|
.sheet(isPresented: $showingPaywall) {
|
||||||
PaywallView(targeting: .pro)
|
PaywallView(targeting: .pro)
|
||||||
}
|
}
|
||||||
|
// Automatisch zum ersten Kontakt navigieren, wenn die Tour
|
||||||
|
// den +Moment- oder +Todo-Button spotlighten möchte.
|
||||||
|
.onChange(of: tourCoordinator.currentStep?.target) { _, target in
|
||||||
|
guard target == .addMomentButton || target == .addTodoButton else { return }
|
||||||
|
if selectedPerson == nil, let first = filteredPeople.first {
|
||||||
|
selectedPerson = first
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
|
|||||||
@@ -2,11 +2,24 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
import CoreData
|
import CoreData
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import OSLog
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
private let todoNotificationLogger = Logger(subsystem: "nahbar", category: "TodoNotification")
|
||||||
|
|
||||||
|
// Wiederverwendet in AIAnalysisSheet (scoped auf diese Datei)
|
||||||
|
private enum AnalysisState {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case result(AIAnalysisResult, Date)
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
struct PersonDetailView: View {
|
struct PersonDetailView: View {
|
||||||
@Environment(\.nahbarTheme) var theme
|
@Environment(\.nahbarTheme) var theme
|
||||||
@Environment(\.modelContext) var modelContext
|
@Environment(\.modelContext) var modelContext
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@Environment(\.openURL) var openURL
|
||||||
@Bindable var person: Person
|
@Bindable var person: Person
|
||||||
|
|
||||||
@State private var showingAddMoment = false
|
@State private var showingAddMoment = false
|
||||||
@@ -26,20 +39,42 @@ struct PersonDetailView: View {
|
|||||||
@State private var todoForEdit: Todo? = nil
|
@State private var todoForEdit: Todo? = nil
|
||||||
@State private var fadingOutTodos: [Todo] = []
|
@State private var fadingOutTodos: [Todo] = []
|
||||||
|
|
||||||
|
// Neu hinzugefügte Logbuch-Momente – 5 s in Momente sichtbar, dann in Verlauf
|
||||||
|
@State private var fadingOutMoments: [Moment] = []
|
||||||
|
@State private var seenMomentIDs: Set<UUID> = []
|
||||||
|
// Treffen-Momente warten auf Rating-Survey-Abschluss bevor der 5-s-Timer startet
|
||||||
|
@State private var pendingFadeAfterSurvey: [Moment] = []
|
||||||
|
|
||||||
// Kalender-Lösch-Bestätigung
|
// Kalender-Lösch-Bestätigung
|
||||||
@State private var momentPendingDelete: Moment? = nil
|
@State private var momentPendingDelete: Moment? = nil
|
||||||
@State private var showCalendarDeleteDialog = false
|
@State private var showCalendarDeleteDialog = false
|
||||||
|
|
||||||
@StateObject private var personalityStore = PersonalityStore.shared
|
// Kontakt-Aktionsblatt (Telefon)
|
||||||
@State private var activityHint: String = ""
|
@State private var showingPhoneActionSheet = false
|
||||||
|
|
||||||
|
// Fallback wenn keine Mail-App installiert
|
||||||
|
@State private var showingEmailFallback = false
|
||||||
|
|
||||||
|
@StateObject private var storeManager = StoreManager.shared
|
||||||
|
|
||||||
|
// KI-Analyse
|
||||||
|
@State private var showingAIAnalysis = false
|
||||||
|
@State private var showingAIPaywall = false
|
||||||
|
|
||||||
|
private var canUseAI: Bool {
|
||||||
|
storeManager.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 28) {
|
VStack(alignment: .leading, spacing: 28) {
|
||||||
personHeader
|
personHeader
|
||||||
|
if person.phoneNumber != nil || person.emailAddress != nil {
|
||||||
|
kontaktSection
|
||||||
|
}
|
||||||
momentsSection
|
momentsSection
|
||||||
todosSection
|
todosSection
|
||||||
if !person.sortedMoments.isEmpty || !person.sortedLogEntries.isEmpty { logbuchSection }
|
if !mergedLogPreview.isEmpty { logbuchSection }
|
||||||
if hasInfoContent { infoSection }
|
if hasInfoContent { infoSection }
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -55,6 +90,7 @@ struct PersonDetailView: View {
|
|||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundStyle(theme.accent)
|
.foregroundStyle(theme.accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAddTodo) {
|
.sheet(isPresented: $showingAddTodo) {
|
||||||
AddTodoView(person: person)
|
AddTodoView(person: person)
|
||||||
@@ -68,6 +104,34 @@ struct PersonDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: showingAddMoment) { _, isShowing in
|
||||||
|
if isShowing {
|
||||||
|
seenMomentIDs = Set(person.sortedMoments.map(\.id))
|
||||||
|
} else {
|
||||||
|
// Neu gespeicherte Logbuch-Momente (keine Vorhaben, kein Zukunftstreffen) kurz anzeigen
|
||||||
|
let newLogbuchMoments = person.sortedMoments.filter { moment in
|
||||||
|
guard !seenMomentIDs.contains(moment.id) else { return false }
|
||||||
|
let isActive = moment.isOpen || (moment.isMeeting && moment.createdAt > Date())
|
||||||
|
return !isActive
|
||||||
|
}
|
||||||
|
// Sofort visuell in Momente einblenden
|
||||||
|
for moment in newLogbuchMoments {
|
||||||
|
withAnimation { fadingOutMoments.append(moment) }
|
||||||
|
}
|
||||||
|
// Treffen-Momente: Timer erst nach Rating-Survey starten (survey läuft noch)
|
||||||
|
let meetingMoments = newLogbuchMoments.filter { $0.isMeeting }
|
||||||
|
let otherMoments = newLogbuchMoments.filter { !$0.isMeeting }
|
||||||
|
scheduleFadeOut(otherMoments)
|
||||||
|
pendingFadeAfterSurvey.append(contentsOf: meetingMoments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: momentForRating) { old, new in
|
||||||
|
// Survey geschlossen (war gesetzt, ist jetzt nil) → Timer für wartende Treffen starten
|
||||||
|
guard old != nil && new == nil, !pendingFadeAfterSurvey.isEmpty else { return }
|
||||||
|
let toFade = pendingFadeAfterSurvey
|
||||||
|
pendingFadeAfterSurvey = []
|
||||||
|
scheduleFadeOut(toFade)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingEditPerson) {
|
.sheet(isPresented: $showingEditPerson) {
|
||||||
AddPersonView(existingPerson: person)
|
AddPersonView(existingPerson: person)
|
||||||
}
|
}
|
||||||
@@ -101,6 +165,12 @@ struct PersonDetailView: View {
|
|||||||
.sheet(item: $todoForEdit) { todo in
|
.sheet(item: $todoForEdit) { todo in
|
||||||
EditTodoView(todo: todo)
|
EditTodoView(todo: todo)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingAIAnalysis) {
|
||||||
|
AIAnalysisSheet(person: person)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingAIPaywall) {
|
||||||
|
PaywallView(targeting: .max)
|
||||||
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Moment löschen",
|
"Moment löschen",
|
||||||
isPresented: $showCalendarDeleteDialog,
|
isPresented: $showCalendarDeleteDialog,
|
||||||
@@ -120,6 +190,36 @@ struct PersonDetailView: View {
|
|||||||
} message: { _ in
|
} message: { _ in
|
||||||
Text("Dieser Moment hat einen verknüpften Kalendereintrag. Soll dieser ebenfalls gelöscht werden?")
|
Text("Dieser Moment hat einen verknüpften Kalendereintrag. Soll dieser ebenfalls gelöscht werden?")
|
||||||
}
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Telefon",
|
||||||
|
isPresented: $showingPhoneActionSheet,
|
||||||
|
titleVisibility: .hidden
|
||||||
|
) {
|
||||||
|
if let phone = person.phoneNumber {
|
||||||
|
let sanitized = phone.components(separatedBy: .init(charactersIn: " -()")).joined()
|
||||||
|
if let url = URL(string: "tel://\(sanitized)") {
|
||||||
|
Button("Anrufen") { openURL(url) }
|
||||||
|
}
|
||||||
|
if let url = URL(string: "sms://\(sanitized)") {
|
||||||
|
Button("Nachricht") { openURL(url) }
|
||||||
|
}
|
||||||
|
if let url = URL(string: "facetime://\(sanitized)") {
|
||||||
|
Button("FaceTime") { openURL(url) }
|
||||||
|
}
|
||||||
|
let waNumber = phone.filter { $0.isNumber }
|
||||||
|
if !waNumber.isEmpty, let url = URL(string: "https://wa.me/\(waNumber)") {
|
||||||
|
Button("WhatsApp") { openURL(url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Keine Mail-App gefunden", isPresented: $showingEmailFallback) {
|
||||||
|
Button("Kopieren") {
|
||||||
|
UIPasteboard.general.string = person.emailAddress
|
||||||
|
}
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(person.emailAddress ?? "")
|
||||||
|
}
|
||||||
// Schützt vor Crash wenn der ModelContext durch Migration oder
|
// Schützt vor Crash wenn der ModelContext durch Migration oder
|
||||||
// CloudKit-Sync intern reset() aufruft und Person-Objekte ungültig werden.
|
// CloudKit-Sync intern reset() aufruft und Person-Objekte ungültig werden.
|
||||||
.onReceive(
|
.onReceive(
|
||||||
@@ -135,12 +235,12 @@ struct PersonDetailView: View {
|
|||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
|
|
||||||
private var personHeader: some View {
|
private var personHeader: some View {
|
||||||
HStack(spacing: 16) {
|
HStack(alignment: .top, spacing: 16) {
|
||||||
PersonAvatar(person: person, size: 64)
|
PersonAvatar(person: person, size: 64)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
Text(person.name)
|
Text(person.name)
|
||||||
.font(.system(size: 26, weight: .light, design: theme.displayDesign))
|
.font(.system(size: 24, weight: .light, design: theme.displayDesign))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
|
||||||
TagBadge(text: person.tag.rawValue)
|
TagBadge(text: person.tag.rawValue)
|
||||||
@@ -150,13 +250,153 @@ struct PersonDetailView: View {
|
|||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(theme.contentSecondary)
|
.foregroundStyle(theme.contentSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if person.nudgeStatus != .never {
|
||||||
|
nudgeChip
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if canUseAI {
|
||||||
|
showingAIAnalysis = true
|
||||||
|
} else {
|
||||||
|
showingAIPaywall = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
MaxBadge()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var nudgeChip: some View {
|
||||||
|
let status = person.nudgeStatus
|
||||||
|
let dotColor: Color = switch status {
|
||||||
|
case .overdue: .red
|
||||||
|
case .soon: .orange
|
||||||
|
default: .green
|
||||||
|
}
|
||||||
|
let reference = person.lastMomentDate ?? person.createdAt
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .full
|
||||||
|
let relativeTime = formatter.localizedString(for: reference, relativeTo: Date())
|
||||||
|
|
||||||
|
return Menu {
|
||||||
|
ForEach(NudgeFrequency.allCases, id: \.self) { freq in
|
||||||
|
Button {
|
||||||
|
person.nudgeFrequency = freq
|
||||||
|
person.touch()
|
||||||
|
try? modelContext.save()
|
||||||
|
} label: {
|
||||||
|
if freq == person.nudgeFrequency {
|
||||||
|
Label(freq.displayLabel, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(freq.displayLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Circle()
|
||||||
|
.fill(dotColor)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
Text(person.nudgeFrequency.displayLabel)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
Text("·")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
Text(relativeTime)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kontakt
|
||||||
|
|
||||||
|
private var kontaktSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
SectionHeader(title: "Kontakt", icon: "phone")
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if let phone = person.phoneNumber {
|
||||||
|
Button { showingPhoneActionSheet = true } label: {
|
||||||
|
kontaktRow(label: "Telefon", value: phone, icon: "phone.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
if person.emailAddress != nil { RowDivider() }
|
||||||
|
}
|
||||||
|
if let email = person.emailAddress {
|
||||||
|
Button {
|
||||||
|
if let url = URL(string: "mailto:\(email)") {
|
||||||
|
openURL(url) { accepted in
|
||||||
|
if !accepted { showingEmailFallback = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
kontaktRow(label: "E-Mail", value: email, icon: "envelope.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(theme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kontaktRow(label: String, value: String, icon: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
.frame(width: 88, alignment: .leading)
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Momente
|
// MARK: - Momente
|
||||||
|
|
||||||
|
/// Aktive Momente: offene Vorhaben + noch ausstehende Treffen in der Zukunft.
|
||||||
|
private var activeMoments: [Moment] {
|
||||||
|
person.sortedMoments.filter { $0.isOpen || ($0.isMeeting && $0.createdAt > Date()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was in der Momente-Sektion angezeigt wird: aktive + kurzzeitig sichtbare neue Logbuch-Momente.
|
||||||
|
private var visibleMoments: [Moment] {
|
||||||
|
let fadingIDs = Set(fadingOutMoments.map(\.id))
|
||||||
|
let active = activeMoments.filter { !fadingIDs.contains($0.id) }
|
||||||
|
return active + fadingOutMoments
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Startet den 5-s-Ausblend-Timer für die angegebenen Momente.
|
||||||
|
private func scheduleFadeOut(_ moments: [Moment]) {
|
||||||
|
for moment in moments {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
||||||
|
withAnimation(.easeOut(duration: 0.35)) {
|
||||||
|
fadingOutMoments.removeAll { $0.id == moment.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var momentsSection: some View {
|
private var momentsSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -177,12 +417,7 @@ struct PersonDetailView: View {
|
|||||||
.background(theme.accent.opacity(0.10))
|
.background(theme.accent.opacity(0.10))
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
}
|
.tourTarget(.addMomentButton)
|
||||||
|
|
||||||
// Persönlichkeitsbasierte Vorhaben-Vorschläge (ersetzt nextStepSection)
|
|
||||||
if person.openIntentions.isEmpty,
|
|
||||||
let profile = personalityStore.profile, profile.isComplete {
|
|
||||||
intentionSuggestionButton(profile: profile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if person.sortedMoments.isEmpty {
|
if person.sortedMoments.isEmpty {
|
||||||
@@ -190,9 +425,9 @@ struct PersonDetailView: View {
|
|||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.contentTertiary)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
} else {
|
} else if !visibleMoments.isEmpty {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(Array(person.sortedMoments.enumerated()), id: \.element.id) { index, moment in
|
ForEach(Array(visibleMoments.enumerated()), id: \.element.id) { index, moment in
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
MomentRowView(
|
MomentRowView(
|
||||||
moment: moment,
|
moment: moment,
|
||||||
@@ -204,7 +439,8 @@ struct PersonDetailView: View {
|
|||||||
onEdit: { momentForTextEdit = moment },
|
onEdit: { momentForTextEdit = moment },
|
||||||
onToggleImportant: { toggleImportant(moment) }
|
onToggleImportant: { toggleImportant(moment) }
|
||||||
)
|
)
|
||||||
if index < person.sortedMoments.count - 1 { RowDivider() }
|
.opacity(fadingOutMoments.contains(where: { $0.id == moment.id }) ? 0.45 : 1.0)
|
||||||
|
if index < visibleMoments.count - 1 { RowDivider() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,52 +450,6 @@ struct PersonDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Vorhaben-Vorschlag
|
|
||||||
|
|
||||||
private func intentionSuggestionButton(profile: PersonalityProfile) -> some View {
|
|
||||||
let hint = activityHint.isEmpty ? refreshActivityHint(profile: profile) : activityHint
|
|
||||||
|
|
||||||
return HStack(spacing: 0) {
|
|
||||||
Button {
|
|
||||||
showingAddMoment = true
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Image(systemName: "brain")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(NahbarInsightStyle.accentPetrol)
|
|
||||||
Text("Idee: \(hint)")
|
|
||||||
.font(.system(size: 13))
|
|
||||||
.foregroundStyle(theme.contentSecondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
.padding(.leading, 14)
|
|
||||||
.padding(.vertical, 7)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neue Idee würfeln
|
|
||||||
Button {
|
|
||||||
activityHint = refreshActivityHint(profile: profile)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "arrow.clockwise")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 7)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
private func refreshActivityHint(profile: PersonalityProfile) -> String {
|
|
||||||
let suggestions = PersonalityEngine.suggestedActivities(
|
|
||||||
for: profile, tag: person.tag, count: 2
|
|
||||||
)
|
|
||||||
let hint = suggestions.joined(separator: " oder ")
|
|
||||||
activityHint = hint
|
|
||||||
return hint
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Logbuch Vorschau
|
// MARK: - Logbuch Vorschau
|
||||||
|
|
||||||
private let logbuchPreviewLimit = 5
|
private let logbuchPreviewLimit = 5
|
||||||
@@ -274,7 +464,13 @@ struct PersonDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var mergedLogPreview: [LogPreviewItem] {
|
private var mergedLogPreview: [LogPreviewItem] {
|
||||||
let momentItems = person.sortedMoments.map {
|
// Nur Momente die weder aktiv (offene Vorhaben / Zukunftstreffen) noch gerade sichtbar ausklingend sind
|
||||||
|
let activeIDs = Set(activeMoments.map(\.id))
|
||||||
|
let fadingIDs = Set(fadingOutMoments.map(\.id))
|
||||||
|
let logbuchMoments = person.sortedMoments.filter {
|
||||||
|
!activeIDs.contains($0.id) && !fadingIDs.contains($0.id)
|
||||||
|
}
|
||||||
|
let momentItems = logbuchMoments.map {
|
||||||
LogPreviewItem(id: "m-\($0.id)", icon: $0.type.icon, title: $0.text,
|
LogPreviewItem(id: "m-\($0.id)", icon: $0.type.icon, title: $0.text,
|
||||||
typeLabel: $0.type.displayName, date: $0.createdAt)
|
typeLabel: $0.type.displayName, date: $0.createdAt)
|
||||||
}
|
}
|
||||||
@@ -292,7 +488,7 @@ struct PersonDetailView: View {
|
|||||||
|
|
||||||
return VStack(alignment: .leading, spacing: 10) {
|
return VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
SectionHeader(title: "Verlauf & KI-Analyse", icon: "sparkles")
|
SectionHeader(title: "Verlauf", icon: "clock.arrow.circlepath")
|
||||||
Spacer()
|
Spacer()
|
||||||
NavigationLink(destination: LogbuchView(person: person)) {
|
NavigationLink(destination: LogbuchView(person: person)) {
|
||||||
Text("Alle")
|
Text("Alle")
|
||||||
@@ -387,7 +583,7 @@ struct PersonDetailView: View {
|
|||||||
RowDivider()
|
RowDivider()
|
||||||
}
|
}
|
||||||
if let interests = person.interests, !interests.isEmpty {
|
if let interests = person.interests, !interests.isEmpty {
|
||||||
InfoRowView(label: "Interessen", value: interests)
|
InterestChipRow(label: "Interessen", text: interests, color: .green)
|
||||||
RowDivider()
|
RowDivider()
|
||||||
}
|
}
|
||||||
if let bg = person.culturalBackground, !bg.isEmpty {
|
if let bg = person.culturalBackground, !bg.isEmpty {
|
||||||
@@ -462,6 +658,7 @@ struct PersonDetailView: View {
|
|||||||
.background(theme.accent.opacity(0.10))
|
.background(theme.accent.opacity(0.10))
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
|
.tourTarget(.addTodoButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
if visibleTodos.isEmpty {
|
if visibleTodos.isEmpty {
|
||||||
@@ -1247,12 +1444,20 @@ struct EditTodoView: View {
|
|||||||
|
|
||||||
private func scheduleReminder() {
|
private func scheduleReminder() {
|
||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
center.requestAuthorization(options: [.alert, .sound]) { granted, error in
|
||||||
guard granted else { return }
|
if let error {
|
||||||
|
todoNotificationLogger.error("Berechtigung-Fehler: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
guard granted else {
|
||||||
|
todoNotificationLogger.warning("Notification-Berechtigung abgelehnt – keine Todo-Erinnerung.")
|
||||||
|
return
|
||||||
|
}
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = todo.person?.firstName ?? ""
|
content.title = todo.person?.firstName ?? ""
|
||||||
|
content.subtitle = String(localized: "Dein Todo")
|
||||||
content.body = todo.title
|
content.body = todo.title
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
|
content.userInfo = ["todoID": todo.id.uuidString]
|
||||||
let components = Calendar.current.dateComponents(
|
let components = Calendar.current.dateComponents(
|
||||||
[.year, .month, .day, .hour, .minute], from: reminderDate
|
[.year, .month, .day, .hour, .minute], from: reminderDate
|
||||||
)
|
)
|
||||||
@@ -1262,7 +1467,236 @@ struct EditTodoView: View {
|
|||||||
content: content,
|
content: content,
|
||||||
trigger: trigger
|
trigger: trigger
|
||||||
)
|
)
|
||||||
center.add(request)
|
center.add(request) { error in
|
||||||
|
if let error {
|
||||||
|
todoNotificationLogger.error("Todo-Erinnerung konnte nicht geplant werden: \(error.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
todoNotificationLogger.info("Todo-Erinnerung geplant: \(todo.id.uuidString)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AI Analysis Sheet
|
||||||
|
|
||||||
|
private struct AIAnalysisSheet: View {
|
||||||
|
@Environment(\.nahbarTheme) var theme
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@StateObject private var store = StoreManager.shared
|
||||||
|
let person: Person
|
||||||
|
|
||||||
|
@State private var analysisState: AnalysisState = .idle
|
||||||
|
@State private var showAIConsent = false
|
||||||
|
@State private var showPaywall = false
|
||||||
|
@State private var remainingRequests: Int = AIAnalysisService.shared.remainingRequests
|
||||||
|
@AppStorage("aiConsentGiven") private var aiConsentGiven = false
|
||||||
|
|
||||||
|
private var canUseAI: Bool {
|
||||||
|
store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
// Header mit MAX-Badge
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
SectionHeader(title: "KI-Auswertung", icon: "sparkles")
|
||||||
|
MaxBadge()
|
||||||
|
if !store.isMax && canUseAI {
|
||||||
|
Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(theme.backgroundSecondary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inhalt
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
switch analysisState {
|
||||||
|
case .idle:
|
||||||
|
Button {
|
||||||
|
if aiConsentGiven {
|
||||||
|
Task { await runAnalysis() }
|
||||||
|
} else {
|
||||||
|
showAIConsent = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
Text("\(person.firstName) analysieren")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .loading:
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ProgressView().tint(theme.accent)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Analysiere Logbuch…")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
Text("Das kann bis zu einer Minute dauern.")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
|
||||||
|
case .result(let result, let date):
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
analysisSection(icon: "waveform.path", title: "Muster & Themen", text: result.patterns)
|
||||||
|
RowDivider()
|
||||||
|
analysisSection(icon: "person.2", title: "Beziehungsqualität", text: result.relationship)
|
||||||
|
RowDivider()
|
||||||
|
analysisSection(icon: "arrow.right.circle", title: "Empfehlung", text: result.recommendation)
|
||||||
|
RowDivider()
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text("Analysiert")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
Text(date.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale.current)))
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await runAnalysis() }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Text(remainingRequests > 0
|
||||||
|
? "Aktualisieren (\(remainingRequests))"
|
||||||
|
: "Limit erreicht")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
}
|
||||||
|
.foregroundStyle(remainingRequests > 0 ? theme.accent : theme.contentTertiary)
|
||||||
|
}
|
||||||
|
.disabled(remainingRequests == 0 || isAnalyzing)
|
||||||
|
.padding(.trailing, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .error(let msg):
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label("Analyse fehlgeschlagen", systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
Text(msg)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
Button {
|
||||||
|
Task { await runAnalysis() }
|
||||||
|
} label: {
|
||||||
|
Text("Erneut versuchen")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(theme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
.background(theme.backgroundPrimary.ignoresSafeArea())
|
||||||
|
.navigationTitle("KI Insights zu \(person.firstName)")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.themedNavBar()
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Schließen") { dismiss() }
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAIConsent) {
|
||||||
|
AIConsentSheet {
|
||||||
|
aiConsentGiven = true
|
||||||
|
Task { await runAnalysis() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showPaywall) {
|
||||||
|
PaywallView(targeting: .max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Cache laden
|
||||||
|
if let cached = AIAnalysisService.shared.loadCached(for: person) {
|
||||||
|
analysisState = .result(cached.asResult, cached.analyzedAt)
|
||||||
|
}
|
||||||
|
remainingRequests = AIAnalysisService.shared.remainingRequests
|
||||||
|
|
||||||
|
// Auto-start: kein Cache → direkt starten wenn möglich
|
||||||
|
if case .idle = analysisState {
|
||||||
|
if canUseAI && aiConsentGiven {
|
||||||
|
Task { await runAnalysis() }
|
||||||
|
} else if canUseAI && !aiConsentGiven {
|
||||||
|
showAIConsent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func analysisSection(icon: String, title: String, text: 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(title)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
Text(LocalizedStringKey(text))
|
||||||
|
.font(.system(size: 14, design: theme.displayDesign))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isAnalyzing: Bool {
|
||||||
|
if case .loading = analysisState { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runAnalysis() async {
|
||||||
|
guard !AIAnalysisService.shared.isRateLimited else { return }
|
||||||
|
analysisState = .loading
|
||||||
|
do {
|
||||||
|
let result = try await AIAnalysisService.shared.analyze(person: person)
|
||||||
|
remainingRequests = AIAnalysisService.shared.remainingRequests
|
||||||
|
if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() }
|
||||||
|
analysisState = .result(result, Date())
|
||||||
|
} catch {
|
||||||
|
if let cached = AIAnalysisService.shared.loadCached(for: person) {
|
||||||
|
analysisState = .result(cached.asResult, cached.analyzedAt)
|
||||||
|
} else {
|
||||||
|
analysisState = .error(error.localizedDescription)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,43 @@ enum PersonalityEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Notification-Texte
|
||||||
|
|
||||||
|
/// Body-Text für Gesprächsfenster-Notification (allgemeine Kontakt-Erinnerung, kein spezifischer Name).
|
||||||
|
/// Zentralisiert die bisher in CallWindowManager inline definierte Persönlichkeitslogik.
|
||||||
|
/// - High Extraversion → direkt, motivierend
|
||||||
|
/// - High Neuroticism (nicht high E) → weich, ermutigend
|
||||||
|
/// - Default → freundlich-neutral
|
||||||
|
static func callWindowCopy(profile: PersonalityProfile?) -> String {
|
||||||
|
guard let profile else {
|
||||||
|
return String(localized: "Wer freut sich heute von dir zu hören?")
|
||||||
|
}
|
||||||
|
switch (profile.level(for: .extraversion), profile.level(for: .neuroticism)) {
|
||||||
|
case (.high, _):
|
||||||
|
return String(localized: "Wer freut sich heute von dir zu hören?")
|
||||||
|
case (_, .high):
|
||||||
|
return String(localized: "Magst du heute jemanden kurz anschreiben? Das kann viel bedeuten. 🙂")
|
||||||
|
default:
|
||||||
|
return String(localized: "Zeit, dich bei jemandem zu melden?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body-Text für Nachwirkungs-Notification nach einem Treffen.
|
||||||
|
/// Zentralisiert die bisher in AftermathNotificationManager inline verwendete Persönlichkeitslogik.
|
||||||
|
/// - High Neuroticism → weich, optional einladend
|
||||||
|
/// - Default → direkt, warm
|
||||||
|
static func aftermathCopy(profile: PersonalityProfile?) -> String {
|
||||||
|
guard let profile else {
|
||||||
|
return String(localized: "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen – dauert 1 Minute.")
|
||||||
|
}
|
||||||
|
switch profile.level(for: .neuroticism) {
|
||||||
|
case .high:
|
||||||
|
return String(localized: "Wenn du magst, kannst du das Treffen kurz reflektieren.")
|
||||||
|
default:
|
||||||
|
return String(localized: "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen – dauert 1 Minute.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Besuchsbewertungs-Timing
|
// MARK: - Besuchsbewertungs-Timing
|
||||||
|
|
||||||
/// Gibt an, ob der Besuchsfragebogen verzögert angezeigt werden soll.
|
/// Gibt an, ob der Besuchsfragebogen verzögert angezeigt werden soll.
|
||||||
@@ -148,95 +185,6 @@ enum PersonalityEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Vorhaben-Priorisierung
|
|
||||||
|
|
||||||
/// Gibt an, welche Art von Aktivität zuerst angezeigt werden soll.
|
|
||||||
static func preferredActivityStyle(for profile: PersonalityProfile?) -> ActivityStyle {
|
|
||||||
guard let profile else { return .oneOnOne }
|
|
||||||
switch profile.level(for: .extraversion) {
|
|
||||||
case .high: return .group
|
|
||||||
case .medium: return .oneOnOne
|
|
||||||
case .low: return .oneOnOne
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gibt an, ob Erlebnis-Aktivitäten hervorgehoben werden sollen.
|
|
||||||
static func highlightNovelty(for profile: PersonalityProfile?) -> Bool {
|
|
||||||
profile?.level(for: .openness) == .high
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gibt `count` Aktivitätsvorschläge zurück, gewichtet nach Persönlichkeit und Kontakt-Tag.
|
|
||||||
/// Innerhalb gleicher Scores wird zufällig variiert – jeder Aufruf kann andere Ergebnisse liefern.
|
|
||||||
static func suggestedActivities(
|
|
||||||
for profile: PersonalityProfile?,
|
|
||||||
tag: PersonTag?,
|
|
||||||
count: Int = 2
|
|
||||||
) -> [String] {
|
|
||||||
let preferred = preferredActivityStyle(for: profile)
|
|
||||||
let highlightNew = highlightNovelty(for: profile)
|
|
||||||
|
|
||||||
func score(_ s: ActivitySuggestion) -> Int {
|
|
||||||
var p = 0
|
|
||||||
if s.style == preferred { p += 2 }
|
|
||||||
if s.isNovelty && highlightNew { p += 1 }
|
|
||||||
if let t = s.preferredTag, t == tag { p += 1 }
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nach Score gruppieren, innerhalb jeder Gruppe mischen → Abwechslung
|
|
||||||
let grouped = Dictionary(grouping: activityPool) { score($0) }
|
|
||||||
var result: [String] = []
|
|
||||||
for key in grouped.keys.sorted(by: >) {
|
|
||||||
guard result.count < count else { break }
|
|
||||||
let bucket = (grouped[key] ?? []).shuffled()
|
|
||||||
for item in bucket {
|
|
||||||
guard result.count < count else { break }
|
|
||||||
result.append(item.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Aktivitäts-Pool (intern, für Tests zugänglich via suggestedActivities)
|
|
||||||
|
|
||||||
static let activityPool: [ActivitySuggestion] = [
|
|
||||||
// ── 1:1 ──────────────────────────────────────────────────────────────
|
|
||||||
ActivitySuggestion("Kaffee trinken", style: .oneOnOne, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Spazieren gehen", style: .oneOnOne, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Zusammen frühstücken", style: .oneOnOne, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Mittagessen", style: .oneOnOne, isNovelty: false, preferredTag: .work),
|
|
||||||
ActivitySuggestion("Auf ein Getränk treffen", style: .oneOnOne, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Zusammen kochen", style: .oneOnOne, isNovelty: false, preferredTag: .family),
|
|
||||||
ActivitySuggestion("Bummeln gehen", style: .oneOnOne, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Rad fahren", style: .oneOnOne, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Joggen gehen", style: .oneOnOne, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Picknick", style: .oneOnOne, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Besuch machen", style: .oneOnOne, isNovelty: false, preferredTag: .family),
|
|
||||||
ActivitySuggestion("Gemeinsam lesen", style: .oneOnOne, isNovelty: false, preferredTag: nil),
|
|
||||||
// ── Gruppe ───────────────────────────────────────────────────────────
|
|
||||||
ActivitySuggestion("Abendessen", style: .group, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Spieleabend", style: .group, isNovelty: false, preferredTag: .friends),
|
|
||||||
ActivitySuggestion("Kino", style: .group, isNovelty: false, preferredTag: .friends),
|
|
||||||
ActivitySuggestion("Konzert oder Show", style: .group, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Museum besuchen", style: .group, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Wandern", style: .group, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Grillabend", style: .group, isNovelty: false, preferredTag: .friends),
|
|
||||||
ActivitySuggestion("Sportevent", style: .group, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Veranstaltung besuchen", style: .group, isNovelty: false, preferredTag: .community),
|
|
||||||
// ── Erlebnis ─────────────────────────────────────────────────────────
|
|
||||||
ActivitySuggestion("Etwas Neues ausprobieren", style: nil, isNovelty: true, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Escape Room", style: nil, isNovelty: true, preferredTag: .friends),
|
|
||||||
ActivitySuggestion("Kochkurs", style: nil, isNovelty: true, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Weinprobe oder Tasting", style: nil, isNovelty: true, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Kletterpark", style: nil, isNovelty: true, preferredTag: .friends),
|
|
||||||
ActivitySuggestion("Workshop besuchen", style: nil, isNovelty: true, preferredTag: .community),
|
|
||||||
ActivitySuggestion("Karaoke", style: nil, isNovelty: true, preferredTag: .friends),
|
|
||||||
// ── Einfach / Remote ─────────────────────────────────────────────────
|
|
||||||
ActivitySuggestion("Anrufen", style: nil, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Nachricht schicken", style: nil, isNovelty: false, preferredTag: nil),
|
|
||||||
ActivitySuggestion("Artikel oder Tipp teilen", style: nil, isNovelty: false, preferredTag: nil),
|
|
||||||
]
|
|
||||||
|
|
||||||
// MARK: - Intervall-Empfehlung für Einstellungen
|
// MARK: - Intervall-Empfehlung für Einstellungen
|
||||||
|
|
||||||
/// Gibt den empfohlenen Benachrichtigungs-Intervall für das Einstellungsmenü zurück.
|
/// Gibt den empfohlenen Benachrichtigungs-Intervall für das Einstellungsmenü zurück.
|
||||||
@@ -264,23 +212,4 @@ enum RatingPromptTiming {
|
|||||||
case delayed(seconds: Int, copy: String?)
|
case delayed(seconds: Int, copy: String?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Präferierter Aktivitätsstil für Vorhaben-Vorschläge.
|
|
||||||
enum ActivityStyle {
|
|
||||||
case group
|
|
||||||
case oneOnOne
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ein einzelner Aktivitätsvorschlag aus dem Pool.
|
|
||||||
struct ActivitySuggestion {
|
|
||||||
let text: String
|
|
||||||
let style: ActivityStyle?
|
|
||||||
let isNovelty: Bool
|
|
||||||
let preferredTag: PersonTag?
|
|
||||||
|
|
||||||
init(_ text: String, style: ActivityStyle?, isNovelty: Bool, preferredTag: PersonTag?) {
|
|
||||||
self.text = text
|
|
||||||
self.style = style
|
|
||||||
self.isNovelty = isNovelty
|
|
||||||
self.preferredTag = preferredTag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -97,90 +97,90 @@ struct QuizQuestion: Identifiable {
|
|||||||
QuizQuestion(
|
QuizQuestion(
|
||||||
id: "O1",
|
id: "O1",
|
||||||
dimension: .openness,
|
dimension: .openness,
|
||||||
situation: "Ein Freund schlägt spontan eine Aktivität vor, die du noch nie gemacht hast.",
|
situation: String(localized: "Ein Freund schlägt spontan eine Aktivität vor, die du noch nie gemacht hast."),
|
||||||
optionA: "Du sagst sofort zu – neue Erfahrungen reizen dich.",
|
optionA: String(localized: "Du sagst sofort zu – neue Erfahrungen reizen dich."),
|
||||||
optionB: "Du schlägst lieber etwas vor, das ihr beide gut kennt.",
|
optionB: String(localized: "Du schlägst lieber etwas vor, das ihr beide gut kennt."),
|
||||||
optionAScore: 1
|
optionAScore: 1
|
||||||
),
|
),
|
||||||
// Offenheit O2
|
// Offenheit O2
|
||||||
QuizQuestion(
|
QuizQuestion(
|
||||||
id: "O2",
|
id: "O2",
|
||||||
dimension: .openness,
|
dimension: .openness,
|
||||||
situation: "In deinem Viertel gibt es ein neues Treffen – niemand, den du kennst, ist dabei.",
|
situation: String(localized: "In deinem Viertel gibt es ein neues Treffen – niemand, den du kennst, ist dabei."),
|
||||||
optionA: "Du gehst einfach hin – Neugier auf fremde Menschen treibt dich.",
|
optionA: String(localized: "Du gehst einfach hin – Neugier auf fremde Menschen treibt dich."),
|
||||||
optionB: "Du wartest, bis ein Bekannter mitkommt.",
|
optionB: String(localized: "Du wartest, bis ein Bekannter mitkommt."),
|
||||||
optionAScore: 1
|
optionAScore: 1
|
||||||
),
|
),
|
||||||
// Verlässlichkeit C1
|
// Verlässlichkeit C1
|
||||||
QuizQuestion(
|
QuizQuestion(
|
||||||
id: "C1",
|
id: "C1",
|
||||||
dimension: .conscientiousness,
|
dimension: .conscientiousness,
|
||||||
situation: "Du hast einem Freund versprochen zu helfen. Am Morgen bist du müde.",
|
situation: String(localized: "Du hast einem Freund versprochen zu helfen. Am Morgen bist du müde."),
|
||||||
optionA: "Du erscheinst wie abgemacht – dein Wort gilt.",
|
optionA: String(localized: "Du erscheinst wie abgemacht – dein Wort gilt."),
|
||||||
optionB: "Du fragst kurz nach, ob es sich verschieben lässt.",
|
optionB: String(localized: "Du fragst kurz nach, ob es sich verschieben lässt."),
|
||||||
optionAScore: 1
|
optionAScore: 1
|
||||||
),
|
),
|
||||||
// Verlässlichkeit C2
|
// Verlässlichkeit C2
|
||||||
QuizQuestion(
|
QuizQuestion(
|
||||||
id: "C2",
|
id: "C2",
|
||||||
dimension: .conscientiousness,
|
dimension: .conscientiousness,
|
||||||
situation: "Nächste Woche hat eine Freundin Geburtstag.",
|
situation: String(localized: "Nächste Woche hat eine Freundin Geburtstag."),
|
||||||
optionA: "Du hast es dir sofort notiert und planst etwas Besonderes.",
|
optionA: String(localized: "Du hast es dir sofort notiert und planst etwas Besonderes."),
|
||||||
optionB: "Du reagierst spontan, wenn der Tag kommt.",
|
optionB: String(localized: "Du reagierst spontan, wenn der Tag kommt."),
|
||||||
optionAScore: 1
|
optionAScore: 1
|
||||||
),
|
),
|
||||||
// Geselligkeit E1
|
// Geselligkeit E1
|
||||||
QuizQuestion(
|
QuizQuestion(
|
||||||
id: "E1",
|
id: "E1",
|
||||||
dimension: .extraversion,
|
dimension: .extraversion,
|
||||||
situation: "Nach einer anstrengenden Woche hast du einen freien Samstag.",
|
situation: String(localized: "Nach einer anstrengenden Woche hast du einen freien Samstag."),
|
||||||
optionA: "Du rufst spontan Freunde an und organisierst ein Treffen.",
|
optionA: String(localized: "Du rufst spontan Freunde an und organisierst ein Treffen."),
|
||||||
optionB: "Du genießt die Ruhe und tankst alleine auf.",
|
optionB: String(localized: "Du genießt die Ruhe und tankst alleine auf."),
|
||||||
optionAScore: 1
|
optionAScore: 1
|
||||||
),
|
),
|
||||||
// Geselligkeit E2
|
// Geselligkeit E2
|
||||||
QuizQuestion(
|
QuizQuestion(
|
||||||
id: "E2",
|
id: "E2",
|
||||||
dimension: .extraversion,
|
dimension: .extraversion,
|
||||||
situation: "Auf einer Nachbarschaftsparty kennst du kaum jemanden.",
|
situation: String(localized: "Auf einer Nachbarschaftsparty kennst du kaum jemanden."),
|
||||||
optionA: "Du gehst aktiv auf Fremde zu und fängst Gespräche an.",
|
optionA: String(localized: "Du gehst aktiv auf Fremde zu und fängst Gespräche an."),
|
||||||
optionB: "Du wartest, bis jemand dich anspricht.",
|
optionB: String(localized: "Du wartest, bis jemand dich anspricht."),
|
||||||
optionAScore: 1
|
optionAScore: 1
|
||||||
),
|
),
|
||||||
// Verträglichkeit A1
|
// Verträglichkeit A1
|
||||||
QuizQuestion(
|
QuizQuestion(
|
||||||
id: "A1",
|
id: "A1",
|
||||||
dimension: .agreeableness,
|
dimension: .agreeableness,
|
||||||
situation: "Ein Nachbar bittet um einen Gefallen, der dir gerade ungelegen kommt.",
|
situation: String(localized: "Ein Nachbar bittet um einen Gefallen, der dir gerade ungelegen kommt."),
|
||||||
optionA: "Du hilfst trotzdem – anderen etwas Gutes tun liegt dir.",
|
optionA: String(localized: "Du hilfst trotzdem – anderen etwas Gutes tun liegt dir."),
|
||||||
optionB: "Du erklärst ehrlich, dass es dir gerade nicht passt.",
|
optionB: String(localized: "Du erklärst ehrlich, dass es dir gerade nicht passt."),
|
||||||
optionAScore: 1
|
optionAScore: 1
|
||||||
),
|
),
|
||||||
// Verträglichkeit A2
|
// Verträglichkeit A2
|
||||||
QuizQuestion(
|
QuizQuestion(
|
||||||
id: "A2",
|
id: "A2",
|
||||||
dimension: .agreeableness,
|
dimension: .agreeableness,
|
||||||
situation: "Ein Freund erzählt von einem Plan, den du für einen Fehler hältst.",
|
situation: String(localized: "Ein Freund erzählt von einem Plan, den du für einen Fehler hältst."),
|
||||||
optionA: "Du unterstützt ihn und behältst deine Bedenken für dich.",
|
optionA: String(localized: "Du unterstützt ihn und behältst deine Bedenken für dich."),
|
||||||
optionB: "Du sprichst deine Sorgen an, auch wenn es Spannung erzeugt.",
|
optionB: String(localized: "Du sprichst deine Sorgen an, auch wenn es Spannung erzeugt."),
|
||||||
optionAScore: 1
|
optionAScore: 1
|
||||||
),
|
),
|
||||||
// Ausgeglichenheit N1 (invertiert: A = stabil = hohes N-inverted)
|
// Ausgeglichenheit N1 (invertiert: A = stabil = hohes N-inverted)
|
||||||
QuizQuestion(
|
QuizQuestion(
|
||||||
id: "N1",
|
id: "N1",
|
||||||
dimension: .neuroticism,
|
dimension: .neuroticism,
|
||||||
situation: "Von einem guten Freund hast du zwei Wochen nichts gehört.",
|
situation: String(localized: "Von einem guten Freund hast du zwei Wochen nichts gehört."),
|
||||||
optionA: "Du meldest dich locker – er ist wahrscheinlich einfach beschäftigt.",
|
optionA: String(localized: "Du meldest dich locker – er ist wahrscheinlich einfach beschäftigt."),
|
||||||
optionB: "Du fragst dich, ob du etwas falsch gemacht hast, und das lässt dich nicht los.",
|
optionB: String(localized: "Du fragst dich, ob du etwas falsch gemacht hast, und das lässt dich nicht los."),
|
||||||
optionAScore: 0 // A = emotional stabil = 0 Neurotizismus-Punkte
|
optionAScore: 0 // A = emotional stabil = 0 Neurotizismus-Punkte
|
||||||
),
|
),
|
||||||
// Ausgeglichenheit N2 (invertiert)
|
// Ausgeglichenheit N2 (invertiert)
|
||||||
QuizQuestion(
|
QuizQuestion(
|
||||||
id: "N2",
|
id: "N2",
|
||||||
dimension: .neuroticism,
|
dimension: .neuroticism,
|
||||||
situation: "Verabredungen mit Freunden fallen kurzfristig aus.",
|
situation: String(localized: "Verabredungen mit Freunden fallen kurzfristig aus."),
|
||||||
optionA: "Du zuckst die Schultern und findest schnell etwas anderes.",
|
optionA: String(localized: "Du zuckst die Schultern und findest schnell etwas anderes."),
|
||||||
optionB: "Du bist enttäuscht und brauchst Zeit, um dich neu zu sortieren.",
|
optionB: String(localized: "Du bist enttäuscht und brauchst Zeit, um dich neu zu sortieren."),
|
||||||
optionAScore: 0 // A = emotional stabil = 0 Neurotizismus-Punkte
|
optionAScore: 0 // A = emotional stabil = 0 Neurotizismus-Punkte
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ struct QuizIntroScreen: View {
|
|||||||
|
|
||||||
Button(action: onSkip) {
|
Button(action: onSkip) {
|
||||||
Text("Überspringen")
|
Text("Überspringen")
|
||||||
.font(.subheadline)
|
.font(.subheadline.weight(.medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Quiz überspringen")
|
.accessibilityLabel("Quiz überspringen")
|
||||||
@@ -222,7 +222,7 @@ private struct GenderSelectionScreen: View {
|
|||||||
|
|
||||||
Button(action: onSkip) {
|
Button(action: onSkip) {
|
||||||
Text("Überspringen")
|
Text("Überspringen")
|
||||||
.font(.subheadline)
|
.font(.subheadline.weight(.medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Quiz überspringen")
|
.accessibilityLabel("Quiz überspringen")
|
||||||
@@ -273,8 +273,8 @@ private struct QuizQuestionsScreen: View {
|
|||||||
|
|
||||||
Button(action: skipCurrentQuestion) {
|
Button(action: skipCurrentQuestion) {
|
||||||
Text("Überspringen")
|
Text("Überspringen")
|
||||||
.font(NahbarInsightStyle.captionFont)
|
.font(.subheadline.weight(.medium))
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 32)
|
.padding(.bottom, 32)
|
||||||
}
|
}
|
||||||
|
|||||||
+602
-590
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,217 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
// MARK: - Interest Tag Helper
|
||||||
|
|
||||||
|
/// Rein-statische Hilfsfunktionen für kommaseparierte Interessen-Tags.
|
||||||
|
enum InterestTagHelper {
|
||||||
|
|
||||||
|
/// Zerlegt einen kommaseparierten String in bereinigte, nicht-leere Tags.
|
||||||
|
static func parse(_ text: String) -> [String] {
|
||||||
|
text.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sortiert Tags alphabetisch und verbindet sie zu einem kommaseparierten String.
|
||||||
|
static func join(_ tags: [String]) -> String {
|
||||||
|
tags.sorted(by: { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending })
|
||||||
|
.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fügt einen Tag hinzu (ignoriert Duplikate, Groß-/Kleinschreibung irrelevant).
|
||||||
|
/// Ergebnis ist alphabetisch sortiert.
|
||||||
|
static func addTag(_ tag: String, to text: String) -> String {
|
||||||
|
let trimmed = tag.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty else { return text }
|
||||||
|
var current = parse(text)
|
||||||
|
let alreadyExists = current.contains { $0.localizedCaseInsensitiveCompare(trimmed) == .orderedSame }
|
||||||
|
guard !alreadyExists else { return text }
|
||||||
|
current.append(trimmed)
|
||||||
|
return join(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entfernt einen Tag aus dem kommaseparierten String.
|
||||||
|
static func removeTag(_ tag: String, from text: String) -> String {
|
||||||
|
let updated = parse(text).filter { $0.localizedCaseInsensitiveCompare(tag) != .orderedSame }
|
||||||
|
return join(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sammelt alle vorhandenen Tags aus Personen-Interessen, User-Likes und Dislikes.
|
||||||
|
/// Dedupliziert und alphabetisch sortiert.
|
||||||
|
static func allSuggestions(from people: [Person], likes: String, dislikes: String) -> [String] {
|
||||||
|
let personTags = people.flatMap { parse($0.interests ?? "") }
|
||||||
|
let userTags = parse(likes) + parse(dislikes)
|
||||||
|
let combined = Set(personTags + userTags)
|
||||||
|
return combined.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Interest Chip Row (Display-only)
|
||||||
|
|
||||||
|
/// Zeigt kommaseparierte Interessen-Tags als horizontale Chip-Reihe an.
|
||||||
|
/// Verwendung in PersonDetailView und IchView (Display-Modus).
|
||||||
|
struct InterestChipRow: View {
|
||||||
|
@Environment(\.nahbarTheme) private var theme
|
||||||
|
let label: String
|
||||||
|
let text: String
|
||||||
|
let color: Color
|
||||||
|
|
||||||
|
private var tags: [String] { InterestTagHelper.parse(text) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(tags, id: \.self) { tag in
|
||||||
|
Text(tag)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(color.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Interest Tag Editor
|
||||||
|
|
||||||
|
/// Bearbeitbares Tag-Eingabefeld mit Autocomplete.
|
||||||
|
/// Bestehendes Tags werden als entfernbare farbige Chips gezeigt.
|
||||||
|
/// Beim Tippen erscheint eine horizontale Chip-Reihe passender Vorschläge.
|
||||||
|
struct InterestTagEditor: View {
|
||||||
|
@Environment(\.nahbarTheme) private var theme
|
||||||
|
let label: String
|
||||||
|
@Binding var text: String
|
||||||
|
let suggestions: [String]
|
||||||
|
let tagColor: Color
|
||||||
|
|
||||||
|
@State private var inputText = ""
|
||||||
|
@FocusState private var inputFocused: Bool
|
||||||
|
|
||||||
|
private var tags: [String] { InterestTagHelper.parse(text) }
|
||||||
|
|
||||||
|
private var filteredSuggestions: [String] {
|
||||||
|
let q = inputText.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !q.isEmpty else { return [] }
|
||||||
|
return suggestions.filter { suggestion in
|
||||||
|
suggestion.localizedCaseInsensitiveContains(q) &&
|
||||||
|
!tags.contains { $0.localizedCaseInsensitiveCompare(suggestion) == .orderedSame }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addTag(_ tag: String) {
|
||||||
|
text = InterestTagHelper.addTag(tag, to: text)
|
||||||
|
inputText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeTag(_ tag: String) {
|
||||||
|
text = InterestTagHelper.removeTag(tag, from: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
|
||||||
|
// Zeile 1: bestehende Tags + ggf. Platzhalter
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
.frame(width: 80, alignment: .leading)
|
||||||
|
.padding(.top, tags.isEmpty ? 0 : 2)
|
||||||
|
|
||||||
|
if tags.isEmpty {
|
||||||
|
Text("noch keine")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentTertiary.opacity(0.45))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { inputFocused = true }
|
||||||
|
} else {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(tags, id: \.self) { tag in
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Text(tag)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
Button {
|
||||||
|
removeTag(tag)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(tagColor)
|
||||||
|
.padding(.leading, 10)
|
||||||
|
.padding(.trailing, 7)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(tagColor.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeile 2: Texteingabe
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Spacer().frame(width: 80)
|
||||||
|
TextField("Tag hinzufügen…", text: $inputText)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
.tint(theme.accent)
|
||||||
|
.focused($inputFocused)
|
||||||
|
.submitLabel(.done)
|
||||||
|
.onSubmit {
|
||||||
|
addTag(inputText)
|
||||||
|
}
|
||||||
|
.onChange(of: inputText) { _, new in
|
||||||
|
// Komma-Eingabe als Trennzeichen
|
||||||
|
if new.hasSuffix(",") {
|
||||||
|
let candidate = String(new.dropLast())
|
||||||
|
addTag(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeile 3: Vorschlags-Chips (nur während Eingabe)
|
||||||
|
if !filteredSuggestions.isEmpty {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Spacer().frame(width: 80)
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(filteredSuggestions, id: \.self) { suggestion in
|
||||||
|
Button { addTag(suggestion) } label: {
|
||||||
|
Text(suggestion)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(theme.backgroundSecondary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.overlay(
|
||||||
|
Capsule().stroke(theme.borderSubtle, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Person Avatar
|
// MARK: - Person Avatar
|
||||||
|
|
||||||
@@ -45,19 +258,41 @@ struct TagBadge: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Pro Badge
|
||||||
|
|
||||||
|
struct ProBadge: View {
|
||||||
|
@Environment(\.nahbarTheme) var theme
|
||||||
|
@StateObject private var store = StoreManager.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !store.isPro {
|
||||||
|
Text("PRO")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(theme.accent.opacity(0.10))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Max Badge
|
// MARK: - Max Badge
|
||||||
|
|
||||||
struct MaxBadge: View {
|
struct MaxBadge: View {
|
||||||
@Environment(\.nahbarTheme) var theme
|
@Environment(\.nahbarTheme) var theme
|
||||||
|
@StateObject private var store = StoreManager.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("MAX")
|
if !store.isMax {
|
||||||
.font(.system(size: 10, weight: .bold))
|
Text("MAX")
|
||||||
.foregroundStyle(theme.accent)
|
.font(.system(size: 10, weight: .bold))
|
||||||
.padding(.horizontal, 7)
|
.foregroundStyle(theme.accent)
|
||||||
.padding(.vertical, 3)
|
.padding(.horizontal, 7)
|
||||||
.background(theme.accent.opacity(0.10))
|
.padding(.vertical, 3)
|
||||||
.clipShape(Capsule())
|
.background(theme.accent.opacity(0.10))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,13 +306,13 @@ struct SectionHeader: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: theme.sectionHeaderSize, weight: .medium))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.sectionHeaderColor)
|
||||||
Text(title)
|
Text(title)
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.system(size: theme.sectionHeaderSize, weight: .semibold))
|
||||||
.tracking(0.8)
|
.tracking(0.8)
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.sectionHeaderColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,49 @@ import StoreKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Feature Gate
|
||||||
|
|
||||||
|
/// Reiner Value-Type, der den Zugriff auf alle abonnierten Features kapselt.
|
||||||
|
/// Kann ohne StoreKit in Unit-Tests instanziiert werden.
|
||||||
|
struct FeatureGate: Equatable {
|
||||||
|
let isPro: Bool
|
||||||
|
let isMax: Bool
|
||||||
|
|
||||||
|
// MARK: Pro-Features (freigeschaltet durch Pro ODER Max)
|
||||||
|
var unlimitedContacts: Bool { isPro }
|
||||||
|
var premiumThemes: Bool { isPro }
|
||||||
|
var shareExtension: Bool { isPro }
|
||||||
|
|
||||||
|
// MARK: Max-Features (nur durch Max)
|
||||||
|
var aiAnalysis: Bool { isMax }
|
||||||
|
var giftSuggestions: Bool { isMax }
|
||||||
|
var conversationTopics: Bool { isMax }
|
||||||
|
var unlimitedAIQueries: Bool { isMax }
|
||||||
|
|
||||||
|
/// Invariante: Max schließt immer alle Pro-Features ein.
|
||||||
|
var isConsistent: Bool { !isMax || isPro }
|
||||||
|
|
||||||
|
/// Erstellt FeatureGate aus dem Ergebnis eines Transaktions-Scans.
|
||||||
|
/// Max setzt automatisch auch isPro = true.
|
||||||
|
static func from(foundPro: Bool, foundMax: Bool) -> FeatureGate {
|
||||||
|
FeatureGate(isPro: foundPro || foundMax, isMax: foundMax)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let free = FeatureGate(isPro: false, isMax: false)
|
||||||
|
|
||||||
|
// MARK: - Hilfsfunktionen (Pure Logic, testbar)
|
||||||
|
|
||||||
|
/// Darf die KI genutzt werden? Max-Abonnenten immer, sonst nur wenn noch Gratis-Abfragen übrig.
|
||||||
|
static func canUseAI(isMax: Bool, hasFreeQueriesLeft: Bool) -> Bool {
|
||||||
|
isMax || hasFreeQueriesLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soll eine Gratis-Abfrage verbraucht werden? Nur bei Nicht-Max-Abonnenten.
|
||||||
|
static func shouldConsumeFreeQuery(isMax: Bool) -> Bool {
|
||||||
|
!isMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Subscription Tier
|
// MARK: - Subscription Tier
|
||||||
|
|
||||||
enum SubscriptionTier: CaseIterable {
|
enum SubscriptionTier: CaseIterable {
|
||||||
@@ -40,6 +83,9 @@ class StoreManager: ObservableObject {
|
|||||||
/// Rückwärtskompatibilität für bestehende Aufrufstellen
|
/// Rückwärtskompatibilität für bestehende Aufrufstellen
|
||||||
var product: Product? { proProduct }
|
var product: Product? { proProduct }
|
||||||
|
|
||||||
|
/// Aktueller Feature-Zustand als testbarer Value-Type.
|
||||||
|
var features: FeatureGate { FeatureGate(isPro: isPro, isMax: isMax) }
|
||||||
|
|
||||||
private var transactionListenerTask: Task<Void, Never>? = nil
|
private var transactionListenerTask: Task<Void, Never>? = nil
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
@@ -119,8 +165,9 @@ class StoreManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
isMax = foundMax
|
let gate = FeatureGate.from(foundPro: foundPro, foundMax: foundMax)
|
||||||
isPro = foundPro || foundMax // Max schließt alle Pro-Features ein
|
isMax = gate.isMax
|
||||||
|
isPro = gate.isPro
|
||||||
AppGroup.saveProStatus(isPro)
|
AppGroup.saveProStatus(isPro)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,13 +132,7 @@ struct ThemePickerView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if id.isPremium && !isActive {
|
if id.isPremium && !isActive {
|
||||||
Text("PRO")
|
ProBadge()
|
||||||
.font(.system(size: 10, weight: .bold))
|
|
||||||
.foregroundStyle(theme.accent)
|
|
||||||
.padding(.horizontal, 7)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(theme.accent.opacity(0.10))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if isActive {
|
if isActive {
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ import SwiftUI
|
|||||||
enum ThemeID: String, CaseIterable, Codable {
|
enum ThemeID: String, CaseIterable, Codable {
|
||||||
case linen, slate, mist, grove, ink, copper
|
case linen, slate, mist, grove, ink, copper
|
||||||
case abyss, dusk, basalt
|
case abyss, dusk, basalt
|
||||||
|
case chalk, flint
|
||||||
|
case onyx, ember, birch, vapor
|
||||||
|
|
||||||
var isPremium: Bool {
|
var isPremium: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .linen, .slate, .mist: return false
|
case .linen, .slate, .mist, .chalk, .flint: return false
|
||||||
default: return true
|
default: return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isDark: Bool {
|
var isDark: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .copper, .abyss, .dusk, .basalt: return true
|
case .copper, .abyss, .dusk, .basalt, .flint, .onyx, .ember: return true
|
||||||
default: return false
|
default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,6 +40,12 @@ enum ThemeID: String, CaseIterable, Codable {
|
|||||||
case .abyss: return "Abyss"
|
case .abyss: return "Abyss"
|
||||||
case .dusk: return "Dusk"
|
case .dusk: return "Dusk"
|
||||||
case .basalt: return "Basalt"
|
case .basalt: return "Basalt"
|
||||||
|
case .chalk: return "Chalk"
|
||||||
|
case .flint: return "Flint"
|
||||||
|
case .onyx: return "Onyx"
|
||||||
|
case .ember: return "Ember"
|
||||||
|
case .birch: return "Birch"
|
||||||
|
case .vapor: return "Vapor"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +60,12 @@ enum ThemeID: String, CaseIterable, Codable {
|
|||||||
case .abyss: return "Tief & fokussiert · ND"
|
case .abyss: return "Tief & fokussiert · ND"
|
||||||
case .dusk: return "Warm & augenschonend · ND"
|
case .dusk: return "Warm & augenschonend · ND"
|
||||||
case .basalt: return "Neutral & reizarm · ND"
|
case .basalt: return "Neutral & reizarm · ND"
|
||||||
|
case .chalk: return "Klar & kontrastreich"
|
||||||
|
case .flint: return "Scharf & dunkel"
|
||||||
|
case .onyx: return "Edel & tiefgründig"
|
||||||
|
case .ember: return "Glühend & intensiv"
|
||||||
|
case .birch: return "Natürlich & klar"
|
||||||
|
case .vapor: return "Kühl & präzise"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +90,10 @@ struct NahbarTheme {
|
|||||||
// Typography
|
// Typography
|
||||||
let displayDesign: Font.Design
|
let displayDesign: Font.Design
|
||||||
|
|
||||||
|
// Section Headers
|
||||||
|
let sectionHeaderSize: CGFloat
|
||||||
|
let sectionHeaderColor: Color
|
||||||
|
|
||||||
// Shape
|
// Shape
|
||||||
let radiusCard: CGFloat
|
let radiusCard: CGFloat
|
||||||
let radiusTag: CGFloat
|
let radiusTag: CGFloat
|
||||||
@@ -99,6 +117,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.620, green: 0.561, blue: 0.494),
|
contentTertiary: Color(red: 0.620, green: 0.561, blue: 0.494),
|
||||||
accent: Color(red: 0.710, green: 0.443, blue: 0.290),
|
accent: Color(red: 0.710, green: 0.443, blue: 0.290),
|
||||||
displayDesign: .default,
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.447, green: 0.384, blue: 0.318),
|
||||||
radiusCard: 16,
|
radiusCard: 16,
|
||||||
radiusTag: 8,
|
radiusTag: 8,
|
||||||
reducedMotion: false
|
reducedMotion: false
|
||||||
@@ -115,6 +135,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.557, green: 0.596, blue: 0.643),
|
contentTertiary: Color(red: 0.557, green: 0.596, blue: 0.643),
|
||||||
accent: Color(red: 0.239, green: 0.353, blue: 0.945),
|
accent: Color(red: 0.239, green: 0.353, blue: 0.945),
|
||||||
displayDesign: .default,
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.353, green: 0.388, blue: 0.431),
|
||||||
radiusCard: 12,
|
radiusCard: 12,
|
||||||
radiusTag: 6,
|
radiusTag: 6,
|
||||||
reducedMotion: false
|
reducedMotion: false
|
||||||
@@ -131,6 +153,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.651, green: 0.651, blue: 0.671),
|
contentTertiary: Color(red: 0.651, green: 0.651, blue: 0.671),
|
||||||
accent: Color(red: 0.569, green: 0.541, blue: 0.745),
|
accent: Color(red: 0.569, green: 0.541, blue: 0.745),
|
||||||
displayDesign: .default,
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.455, green: 0.455, blue: 0.475),
|
||||||
radiusCard: 20,
|
radiusCard: 20,
|
||||||
radiusTag: 10,
|
radiusTag: 10,
|
||||||
reducedMotion: true
|
reducedMotion: true
|
||||||
@@ -147,6 +171,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.467, green: 0.573, blue: 0.455),
|
contentTertiary: Color(red: 0.467, green: 0.573, blue: 0.455),
|
||||||
accent: Color(red: 0.220, green: 0.412, blue: 0.227),
|
accent: Color(red: 0.220, green: 0.412, blue: 0.227),
|
||||||
displayDesign: .serif,
|
displayDesign: .serif,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.298, green: 0.408, blue: 0.298),
|
||||||
radiusCard: 16,
|
radiusCard: 16,
|
||||||
radiusTag: 8,
|
radiusTag: 8,
|
||||||
reducedMotion: false
|
reducedMotion: false
|
||||||
@@ -163,6 +189,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.541, green: 0.541, blue: 0.541),
|
contentTertiary: Color(red: 0.541, green: 0.541, blue: 0.541),
|
||||||
accent: Color(red: 0.749, green: 0.220, blue: 0.165),
|
accent: Color(red: 0.749, green: 0.220, blue: 0.165),
|
||||||
displayDesign: .serif,
|
displayDesign: .serif,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.310, green: 0.310, blue: 0.310),
|
||||||
radiusCard: 8,
|
radiusCard: 8,
|
||||||
radiusTag: 4,
|
radiusTag: 4,
|
||||||
reducedMotion: true
|
reducedMotion: true
|
||||||
@@ -179,6 +207,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.502, green: 0.443, blue: 0.373),
|
contentTertiary: Color(red: 0.502, green: 0.443, blue: 0.373),
|
||||||
accent: Color(red: 0.784, green: 0.514, blue: 0.227),
|
accent: Color(red: 0.784, green: 0.514, blue: 0.227),
|
||||||
displayDesign: .default,
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.714, green: 0.659, blue: 0.588),
|
||||||
radiusCard: 16,
|
radiusCard: 16,
|
||||||
radiusTag: 8,
|
radiusTag: 8,
|
||||||
reducedMotion: false
|
reducedMotion: false
|
||||||
@@ -196,6 +226,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.349, green: 0.408, blue: 0.502),
|
contentTertiary: Color(red: 0.349, green: 0.408, blue: 0.502),
|
||||||
accent: Color(red: 0.357, green: 0.553, blue: 0.937),
|
accent: Color(red: 0.357, green: 0.553, blue: 0.937),
|
||||||
displayDesign: .default,
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.541, green: 0.612, blue: 0.710),
|
||||||
radiusCard: 14,
|
radiusCard: 14,
|
||||||
radiusTag: 7,
|
radiusTag: 7,
|
||||||
reducedMotion: true
|
reducedMotion: true
|
||||||
@@ -213,6 +245,8 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.431, green: 0.357, blue: 0.271),
|
contentTertiary: Color(red: 0.431, green: 0.357, blue: 0.271),
|
||||||
accent: Color(red: 0.831, green: 0.573, blue: 0.271),
|
accent: Color(red: 0.831, green: 0.573, blue: 0.271),
|
||||||
displayDesign: .default,
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.651, green: 0.565, blue: 0.451),
|
||||||
radiusCard: 18,
|
radiusCard: 18,
|
||||||
radiusTag: 9,
|
radiusTag: 9,
|
||||||
reducedMotion: true
|
reducedMotion: true
|
||||||
@@ -230,11 +264,127 @@ extension NahbarTheme {
|
|||||||
contentTertiary: Color(red: 0.365, green: 0.365, blue: 0.365),
|
contentTertiary: Color(red: 0.365, green: 0.365, blue: 0.365),
|
||||||
accent: Color(red: 0.376, green: 0.725, blue: 0.545),
|
accent: Color(red: 0.376, green: 0.725, blue: 0.545),
|
||||||
displayDesign: .monospaced,
|
displayDesign: .monospaced,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.561, green: 0.561, blue: 0.561),
|
||||||
radiusCard: 10,
|
radiusCard: 10,
|
||||||
radiusTag: 5,
|
radiusTag: 5,
|
||||||
reducedMotion: true
|
reducedMotion: true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MARK: - Chalk (Hochkontrast Hell, kostenlos)
|
||||||
|
static let chalk = NahbarTheme(
|
||||||
|
id: .chalk,
|
||||||
|
backgroundPrimary: Color(red: 0.976, green: 0.976, blue: 0.976),
|
||||||
|
backgroundSecondary: Color(red: 0.945, green: 0.945, blue: 0.945),
|
||||||
|
surfaceCard: Color(red: 1.000, green: 1.000, blue: 1.000),
|
||||||
|
borderSubtle: Color(red: 0.690, green: 0.690, blue: 0.690).opacity(0.50),
|
||||||
|
contentPrimary: Color(red: 0.059, green: 0.059, blue: 0.059),
|
||||||
|
contentSecondary: Color(red: 0.267, green: 0.267, blue: 0.267),
|
||||||
|
contentTertiary: Color(red: 0.482, green: 0.482, blue: 0.482),
|
||||||
|
accent: Color(red: 0.196, green: 0.392, blue: 0.902),
|
||||||
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.267, green: 0.267, blue: 0.267),
|
||||||
|
radiusCard: 12,
|
||||||
|
radiusTag: 6,
|
||||||
|
reducedMotion: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - Flint (Hochkontrast Dunkel, kostenlos)
|
||||||
|
static let flint = NahbarTheme(
|
||||||
|
id: .flint,
|
||||||
|
backgroundPrimary: Color(red: 0.102, green: 0.102, blue: 0.102),
|
||||||
|
backgroundSecondary: Color(red: 0.137, green: 0.137, blue: 0.137),
|
||||||
|
surfaceCard: Color(red: 0.173, green: 0.173, blue: 0.173),
|
||||||
|
borderSubtle: Color(red: 0.376, green: 0.376, blue: 0.376).opacity(0.50),
|
||||||
|
contentPrimary: Color(red: 0.941, green: 0.941, blue: 0.941),
|
||||||
|
contentSecondary: Color(red: 0.651, green: 0.651, blue: 0.651),
|
||||||
|
contentTertiary: Color(red: 0.416, green: 0.416, blue: 0.416),
|
||||||
|
accent: Color(red: 0.220, green: 0.820, blue: 0.796),
|
||||||
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.651, green: 0.651, blue: 0.651),
|
||||||
|
radiusCard: 12,
|
||||||
|
radiusTag: 6,
|
||||||
|
reducedMotion: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - Onyx (Tiefes Schwarz, Gold-Serif, bezahlt)
|
||||||
|
static let onyx = NahbarTheme(
|
||||||
|
id: .onyx,
|
||||||
|
backgroundPrimary: Color(red: 0.063, green: 0.063, blue: 0.063),
|
||||||
|
backgroundSecondary: Color(red: 0.094, green: 0.094, blue: 0.094),
|
||||||
|
surfaceCard: Color(red: 0.125, green: 0.125, blue: 0.125),
|
||||||
|
borderSubtle: Color(red: 0.310, green: 0.255, blue: 0.176).opacity(0.50),
|
||||||
|
contentPrimary: Color(red: 0.965, green: 0.949, blue: 0.922),
|
||||||
|
contentSecondary: Color(red: 0.647, green: 0.612, blue: 0.557),
|
||||||
|
contentTertiary: Color(red: 0.412, green: 0.380, blue: 0.333),
|
||||||
|
accent: Color(red: 0.835, green: 0.682, blue: 0.286),
|
||||||
|
displayDesign: .serif,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.647, green: 0.612, blue: 0.557),
|
||||||
|
radiusCard: 14,
|
||||||
|
radiusTag: 7,
|
||||||
|
reducedMotion: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - Ember (Warmes Dunkel, Orangerot, bezahlt)
|
||||||
|
static let ember = NahbarTheme(
|
||||||
|
id: .ember,
|
||||||
|
backgroundPrimary: Color(red: 0.110, green: 0.086, blue: 0.078),
|
||||||
|
backgroundSecondary: Color(red: 0.145, green: 0.114, blue: 0.102),
|
||||||
|
surfaceCard: Color(red: 0.184, green: 0.149, blue: 0.133),
|
||||||
|
borderSubtle: Color(red: 0.392, green: 0.263, blue: 0.212).opacity(0.50),
|
||||||
|
contentPrimary: Color(red: 0.957, green: 0.918, blue: 0.882),
|
||||||
|
contentSecondary: Color(red: 0.671, green: 0.561, blue: 0.494),
|
||||||
|
contentTertiary: Color(red: 0.435, green: 0.349, blue: 0.298),
|
||||||
|
accent: Color(red: 0.910, green: 0.388, blue: 0.192),
|
||||||
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.671, green: 0.561, blue: 0.494),
|
||||||
|
radiusCard: 16,
|
||||||
|
radiusTag: 8,
|
||||||
|
reducedMotion: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - Birch (Helles Naturcreme, Waldgrün-Serif, bezahlt)
|
||||||
|
static let birch = NahbarTheme(
|
||||||
|
id: .birch,
|
||||||
|
backgroundPrimary: Color(red: 0.969, green: 0.961, blue: 0.945),
|
||||||
|
backgroundSecondary: Color(red: 0.937, green: 0.925, blue: 0.906),
|
||||||
|
surfaceCard: Color(red: 0.988, green: 0.984, blue: 0.969),
|
||||||
|
borderSubtle: Color(red: 0.682, green: 0.659, blue: 0.612).opacity(0.40),
|
||||||
|
contentPrimary: Color(red: 0.067, green: 0.133, blue: 0.071),
|
||||||
|
contentSecondary: Color(red: 0.227, green: 0.349, blue: 0.224),
|
||||||
|
contentTertiary: Color(red: 0.408, green: 0.502, blue: 0.396),
|
||||||
|
accent: Color(red: 0.118, green: 0.392, blue: 0.153),
|
||||||
|
displayDesign: .serif,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.227, green: 0.349, blue: 0.224),
|
||||||
|
radiusCard: 16,
|
||||||
|
radiusTag: 8,
|
||||||
|
reducedMotion: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - Vapor (Kühles Weiß, Tintenblau, Violett, bezahlt)
|
||||||
|
static let vapor = NahbarTheme(
|
||||||
|
id: .vapor,
|
||||||
|
backgroundPrimary: Color(red: 0.961, green: 0.965, blue: 0.976),
|
||||||
|
backgroundSecondary: Color(red: 0.925, green: 0.933, blue: 0.953),
|
||||||
|
surfaceCard: Color(red: 0.988, green: 0.988, blue: 1.000),
|
||||||
|
borderSubtle: Color(red: 0.647, green: 0.671, blue: 0.737).opacity(0.45),
|
||||||
|
contentPrimary: Color(red: 0.047, green: 0.063, blue: 0.145),
|
||||||
|
contentSecondary: Color(red: 0.275, green: 0.306, blue: 0.447),
|
||||||
|
contentTertiary: Color(red: 0.478, green: 0.506, blue: 0.616),
|
||||||
|
accent: Color(red: 0.455, green: 0.255, blue: 0.855),
|
||||||
|
displayDesign: .default,
|
||||||
|
sectionHeaderSize: 13,
|
||||||
|
sectionHeaderColor: Color(red: 0.275, green: 0.306, blue: 0.447),
|
||||||
|
radiusCard: 14,
|
||||||
|
radiusTag: 7,
|
||||||
|
reducedMotion: false
|
||||||
|
)
|
||||||
|
|
||||||
static func theme(for id: ThemeID) -> NahbarTheme {
|
static func theme(for id: ThemeID) -> NahbarTheme {
|
||||||
switch id {
|
switch id {
|
||||||
case .linen: return .linen
|
case .linen: return .linen
|
||||||
@@ -246,6 +396,12 @@ extension NahbarTheme {
|
|||||||
case .abyss: return .abyss
|
case .abyss: return .abyss
|
||||||
case .dusk: return .dusk
|
case .dusk: return .dusk
|
||||||
case .basalt: return .basalt
|
case .basalt: return .basalt
|
||||||
|
case .chalk: return .chalk
|
||||||
|
case .flint: return .flint
|
||||||
|
case .onyx: return .onyx
|
||||||
|
case .ember: return .ember
|
||||||
|
case .birch: return .birch
|
||||||
|
case .vapor: return .vapor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import UserNotifications
|
|||||||
struct TodayView: View {
|
struct TodayView: View {
|
||||||
@Environment(\.nahbarTheme) var theme
|
@Environment(\.nahbarTheme) var theme
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@EnvironmentObject private var profileStore: UserProfileStore
|
||||||
@Query private var people: [Person]
|
@Query private var people: [Person]
|
||||||
// V5: Nachwirkungen sind jetzt Treffen-Momente mit Status "warte_nachwirkung"
|
// V5: Nachwirkungen sind jetzt Treffen-Momente mit Status "warte_nachwirkung"
|
||||||
@Query(filter: #Predicate<Moment> {
|
@Query(filter: #Predicate<Moment> {
|
||||||
@@ -14,6 +15,8 @@ struct TodayView: View {
|
|||||||
@State private var selectedMomentForAftermath: Moment? = nil
|
@State private var selectedMomentForAftermath: Moment? = nil
|
||||||
@State private var showPersonPicker = false
|
@State private var showPersonPicker = false
|
||||||
@State private var personForNewMoment: Person? = nil
|
@State private var personForNewMoment: Person? = nil
|
||||||
|
@State private var showTodoPersonPicker = false
|
||||||
|
@State private var personForNewTodo: Person? = nil
|
||||||
@State private var todoForEdit: Todo? = nil
|
@State private var todoForEdit: Todo? = nil
|
||||||
@State private var fadingOutTodos: [Todo] = []
|
@State private var fadingOutTodos: [Todo] = []
|
||||||
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
|
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
|
||||||
@@ -100,11 +103,16 @@ struct TodayView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var greeting: LocalizedStringKey {
|
private var greeting: String {
|
||||||
let hour = Calendar.current.component(.hour, from: Date())
|
let hour = Calendar.current.component(.hour, from: Date())
|
||||||
if hour < 12 { return "Guten Morgen." }
|
let base: String
|
||||||
if hour < 18 { return "Guten Tag." }
|
if hour < 12 { base = String(localized: "Guten Morgen") }
|
||||||
return "Guten Abend."
|
else if hour < 18 { base = String(localized: "Guten Tag") }
|
||||||
|
else { base = String(localized: "Guten Abend") }
|
||||||
|
|
||||||
|
let firstName = profileStore.name.split(separator: " ").first.map(String.init) ?? ""
|
||||||
|
if firstName.isEmpty { return "\(base)." }
|
||||||
|
return "\(base), \(firstName)."
|
||||||
}
|
}
|
||||||
|
|
||||||
private var formattedToday: String {
|
private var formattedToday: String {
|
||||||
@@ -118,7 +126,7 @@ struct TodayView: View {
|
|||||||
// Header
|
// Header
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(greeting)
|
Text(greeting)
|
||||||
.font(.system(size: 34, weight: .light, design: theme.displayDesign))
|
.font(.system(size: 32, weight: .light, design: theme.displayDesign))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
Text(formattedToday)
|
Text(formattedToday)
|
||||||
.font(.system(size: 15, design: theme.displayDesign))
|
.font(.system(size: 15, design: theme.displayDesign))
|
||||||
@@ -279,6 +287,14 @@ struct TodayView: View {
|
|||||||
.sheet(item: $personForNewMoment) { person in
|
.sheet(item: $personForNewMoment) { person in
|
||||||
AddMomentView(person: person)
|
AddMomentView(person: person)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showTodoPersonPicker) {
|
||||||
|
TodayPersonPickerSheet(people: activePeople) { person in
|
||||||
|
personForNewTodo = person
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $personForNewTodo) { person in
|
||||||
|
AddTodoView(person: person)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,36 +308,68 @@ struct TodayView: View {
|
|||||||
Text("Ein ruhiger Tag.")
|
Text("Ein ruhiger Tag.")
|
||||||
.font(.system(size: 20, weight: .light, design: theme.displayDesign))
|
.font(.system(size: 20, weight: .light, design: theme.displayDesign))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
Text("Oder einer, der es noch wird.")
|
Text("Lass uns mit der Beziehungspflege starten.")
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.contentTertiary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
VStack(spacing: 12) {
|
||||||
showPersonPicker = true
|
Button {
|
||||||
} label: {
|
showPersonPicker = true
|
||||||
HStack(spacing: 14) {
|
} label: {
|
||||||
Image(systemName: "plus.circle.fill")
|
HStack(spacing: 14) {
|
||||||
.font(.system(size: 24))
|
Image(systemName: "plus.circle.fill")
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
.font(.system(size: 22))
|
||||||
Text("Fangen wir an")
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
.font(.system(size: 16, weight: .semibold, design: theme.displayDesign))
|
Text("Moment erfassen")
|
||||||
Text("Momente planen und hinzufügen")
|
.font(.system(size: 15, weight: .semibold, design: theme.displayDesign))
|
||||||
.font(.system(size: 13))
|
Text("Treffen, Gespräch oder Erlebnis")
|
||||||
.opacity(0.8)
|
.font(.system(size: 12))
|
||||||
|
.opacity(0.8)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.opacity(0.6)
|
||||||
}
|
}
|
||||||
Spacer()
|
.foregroundStyle(theme.backgroundPrimary)
|
||||||
Image(systemName: "chevron.right")
|
.padding(.horizontal, 20)
|
||||||
.font(.system(size: 13, weight: .medium))
|
.padding(.vertical, 16)
|
||||||
.opacity(0.6)
|
.background(theme.accent)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
}
|
}
|
||||||
.foregroundStyle(theme.backgroundPrimary)
|
.buttonStyle(.plain)
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.vertical, 18)
|
Button {
|
||||||
.background(theme.accent)
|
showTodoPersonPicker = true
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
} label: {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "checkmark.circle")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Todo hinzufügen")
|
||||||
|
.font(.system(size: 15, weight: .semibold, design: theme.displayDesign))
|
||||||
|
Text("Aufgabe für eine Person anlegen")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.opacity(0.7)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.opacity(0.5)
|
||||||
|
}
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(theme.accent.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: theme.radiusCard)
|
||||||
|
.strokeBorder(theme.accent.opacity(0.25), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -476,9 +524,7 @@ struct GiftSuggestionRow: View {
|
|||||||
Text("Geschenkidee vorschlagen")
|
Text("Geschenkidee vorschlagen")
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
Spacer()
|
Spacer()
|
||||||
if store.isMax {
|
if !store.isMax && canUseAI {
|
||||||
MaxBadge()
|
|
||||||
} else if canUseAI {
|
|
||||||
Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis")
|
Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis")
|
||||||
.font(.system(size: 10, weight: .bold))
|
.font(.system(size: 10, weight: .bold))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - SpotlightShape
|
||||||
|
|
||||||
|
/// A Shape that fills the entire rect with an even-odd "hole" cut out for the spotlight.
|
||||||
|
/// The filled region is everything OUTSIDE `spotlight`; the cutout is transparent.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```swift
|
||||||
|
/// SpotlightShape(spotlight: frame, cornerRadius: 18)
|
||||||
|
/// .fill(Color.black.opacity(0.45), style: FillStyle(eoFill: true))
|
||||||
|
/// ```
|
||||||
|
struct SpotlightShape: Shape {
|
||||||
|
|
||||||
|
var spotlight: CGRect
|
||||||
|
var cornerRadius: CGFloat
|
||||||
|
|
||||||
|
// MARK: Animatable
|
||||||
|
|
||||||
|
var animatableData: AnimatablePair<
|
||||||
|
AnimatablePair<CGFloat, CGFloat>,
|
||||||
|
AnimatablePair<AnimatablePair<CGFloat, CGFloat>, CGFloat>
|
||||||
|
> {
|
||||||
|
get {
|
||||||
|
AnimatablePair(
|
||||||
|
AnimatablePair(spotlight.origin.x, spotlight.origin.y),
|
||||||
|
AnimatablePair(
|
||||||
|
AnimatablePair(spotlight.size.width, spotlight.size.height),
|
||||||
|
cornerRadius
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
spotlight = CGRect(
|
||||||
|
x: newValue.first.first,
|
||||||
|
y: newValue.first.second,
|
||||||
|
width: newValue.second.first.first,
|
||||||
|
height: newValue.second.first.second
|
||||||
|
)
|
||||||
|
cornerRadius = newValue.second.second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Path
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
// Outer rect (full screen)
|
||||||
|
path.addRect(rect)
|
||||||
|
// Spotlight cutout (rounded rectangle)
|
||||||
|
path.addRoundedRect(
|
||||||
|
in: spotlight,
|
||||||
|
cornerSize: CGSize(width: cornerRadius, height: cornerRadius)
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - TriggerMode
|
||||||
|
|
||||||
|
/// Controls when a tour is automatically started.
|
||||||
|
enum TriggerMode: Hashable {
|
||||||
|
/// Started explicitly by app code; never auto-started by checkForPendingTours().
|
||||||
|
case manualOrFirstLaunch
|
||||||
|
/// Auto-started when the app version advances past `minAppVersion` and tour hasn't been seen.
|
||||||
|
case autoOnUpdate
|
||||||
|
/// Never auto-started; only via explicit `TourCoordinator.start(_:)` call.
|
||||||
|
case manualOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tour
|
||||||
|
|
||||||
|
/// Metadata and steps for a single guided tour. Max 6 steps is enforced by precondition.
|
||||||
|
struct Tour: Identifiable, Hashable {
|
||||||
|
let id: TourID
|
||||||
|
let title: LocalizedStringResource
|
||||||
|
let steps: [TourStep]
|
||||||
|
/// Semantic version string, e.g. "1.0". Used for update-tour gating.
|
||||||
|
let minAppVersion: String
|
||||||
|
let triggerMode: TriggerMode
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: TourID,
|
||||||
|
title: LocalizedStringResource,
|
||||||
|
steps: [TourStep],
|
||||||
|
minAppVersion: String,
|
||||||
|
triggerMode: TriggerMode
|
||||||
|
) {
|
||||||
|
precondition(!steps.isEmpty, "A tour must have at least 1 step.")
|
||||||
|
precondition(steps.count <= 8, "A tour must not exceed 8 steps. Got \(steps.count).")
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.steps = steps
|
||||||
|
self.minAppVersion = minAppVersion
|
||||||
|
self.triggerMode = triggerMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equatable / Hashable based on id
|
||||||
|
static func == (lhs: Tour, rhs: Tour) -> Bool { lhs.id == rhs.id }
|
||||||
|
func hash(into hasher: inout Hasher) { hasher.combine(id) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - TourCardView
|
||||||
|
|
||||||
|
/// The info card shown during a tour step. Contains progress dots, title, body, and navigation buttons.
|
||||||
|
struct TourCardView: View {
|
||||||
|
|
||||||
|
let coordinator: TourCoordinator
|
||||||
|
let totalSteps: Int
|
||||||
|
let currentIndex: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
|
||||||
|
// ── Header: progress dots + close button ──────────────────────────
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
progressDots
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
coordinator.close()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.secondary.opacity(0.12), in: Circle())
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Tour schließen")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 18)
|
||||||
|
|
||||||
|
// ── Content: title + body ─────────────────────────────────────────
|
||||||
|
if let step = coordinator.currentStep {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text(step.title)
|
||||||
|
.font(.title3.bold())
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Text(step.body)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 18)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer: skip + back/next buttons ─────────────────────────────
|
||||||
|
HStack {
|
||||||
|
// Step counter + skip
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Button {
|
||||||
|
coordinator.skip()
|
||||||
|
} label: {
|
||||||
|
Text("Tour überspringen")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(verbatim: String.localizedStringWithFormat(
|
||||||
|
String(localized: "%lld von %lld"),
|
||||||
|
Int64(currentIndex + 1),
|
||||||
|
Int64(totalSteps)
|
||||||
|
))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.quaternary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Back button (hidden on first step)
|
||||||
|
if !coordinator.isFirstStep {
|
||||||
|
Button {
|
||||||
|
coordinator.previous()
|
||||||
|
} label: {
|
||||||
|
Text("Zurück")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next / Finish button — explicit LocalizedStringKey to ensure lookup
|
||||||
|
let nextLabel: LocalizedStringKey = coordinator.isLastStep ? "Loslegen" : "Weiter"
|
||||||
|
Button {
|
||||||
|
coordinator.next()
|
||||||
|
} label: {
|
||||||
|
Text(nextLabel)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(NahbarInsightStyle.accentPetrol, in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.18), radius: 24, y: 10)
|
||||||
|
.sensoryFeedback(.selection, trigger: currentIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Progress Dots
|
||||||
|
|
||||||
|
private var progressDots: some View {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
ForEach(0..<totalSteps, id: \.self) { i in
|
||||||
|
Capsule()
|
||||||
|
.fill(i == currentIndex ? NahbarInsightStyle.accentPetrol : Color.secondary.opacity(0.3))
|
||||||
|
.frame(
|
||||||
|
width: i == currentIndex ? 20 : 6,
|
||||||
|
height: 6
|
||||||
|
)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: currentIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Schritt \(currentIndex + 1) von \(totalSteps)")
|
||||||
|
.accessibilityHidden(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - TourCatalog
|
||||||
|
|
||||||
|
/// Static registry of all tours defined in the app.
|
||||||
|
/// Strings use German text as keys (consistent with project xcstrings convention).
|
||||||
|
/// New tours are added as static properties and included in `all`.
|
||||||
|
enum TourCatalog {
|
||||||
|
|
||||||
|
// MARK: Onboarding Tour
|
||||||
|
|
||||||
|
static let onboarding = Tour(
|
||||||
|
id: .onboarding,
|
||||||
|
title: "App-Einführung",
|
||||||
|
steps: [
|
||||||
|
TourStep(
|
||||||
|
title: "Willkommen bei nahbar",
|
||||||
|
body: "nahbar hilft dir, echte Verbindungen zu pflegen – ohne Stress, ohne Algorithmen.",
|
||||||
|
target: nil,
|
||||||
|
preferredCardPosition: .center
|
||||||
|
),
|
||||||
|
TourStep(
|
||||||
|
title: "Deine Menschen im Mittelpunkt",
|
||||||
|
body: "Füge Personen hinzu, die dir wichtig sind. Notiere Interessen, Gesprächsthemen und was euch verbindet.",
|
||||||
|
target: .addContactButton,
|
||||||
|
preferredCardPosition: .below
|
||||||
|
),
|
||||||
|
TourStep(
|
||||||
|
title: "Momente festhalten",
|
||||||
|
body: "Tippe auf eine Person und erfasse Treffen, Gespräche oder Erlebnisse – so weißt du immer, worüber ihr das letzte Mal geredet habt.",
|
||||||
|
target: .contactCardFirst,
|
||||||
|
preferredCardPosition: .below
|
||||||
|
),
|
||||||
|
TourStep(
|
||||||
|
title: "Plane das Nächste",
|
||||||
|
body: "Tippe auf '+ Moment', um Treffen oder Gespräche festzuhalten – so weißt du immer, worüber ihr das letzte Mal geredet habt.",
|
||||||
|
target: .addMomentButton,
|
||||||
|
preferredCardPosition: .below
|
||||||
|
),
|
||||||
|
TourStep(
|
||||||
|
title: "Todos anlegen",
|
||||||
|
body: "Mit '+ Todo' planst du konkrete Aufgaben für diese Person – mit optionaler Erinnerung, damit nichts vergessen wird.",
|
||||||
|
target: .addTodoButton,
|
||||||
|
preferredCardPosition: .below
|
||||||
|
),
|
||||||
|
TourStep(
|
||||||
|
title: "Sanfte Erinnerungen",
|
||||||
|
body: "nahbar erinnert dich, wenn du lange nichts von jemandem gehört hast. Du entscheidest, wie oft.",
|
||||||
|
target: nil,
|
||||||
|
preferredCardPosition: .center
|
||||||
|
),
|
||||||
|
TourStep(
|
||||||
|
title: "Einblicke, wenn du willst",
|
||||||
|
body: "Optionale KI-Analyse zeigt Muster in deinen Verbindungen. Den Persönlichkeitstest findest du in den Einstellungen – er macht nahbar noch persönlicher.",
|
||||||
|
target: nil,
|
||||||
|
preferredCardPosition: .center
|
||||||
|
),
|
||||||
|
],
|
||||||
|
minAppVersion: "1.0",
|
||||||
|
triggerMode: .manualOrFirstLaunch
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: Registry
|
||||||
|
|
||||||
|
/// All tours known to the app. Order matters for display in Settings.
|
||||||
|
static let all: [Tour] = [onboarding]
|
||||||
|
|
||||||
|
/// Looks up a tour by its ID.
|
||||||
|
static func tour(for id: TourID) -> Tour? {
|
||||||
|
all.first { $0.id == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "nahbar", category: "TourCoordinator")
|
||||||
|
|
||||||
|
// MARK: - TourCoordinator
|
||||||
|
|
||||||
|
/// Observable coordinator that drives the guided tour flow.
|
||||||
|
/// Passed as an environment object from NahbarApp.
|
||||||
|
@Observable
|
||||||
|
final class TourCoordinator {
|
||||||
|
|
||||||
|
// MARK: Observed State (trigger SwiftUI re-renders)
|
||||||
|
|
||||||
|
private(set) var activeTour: Tour?
|
||||||
|
private(set) var currentStepIndex: Int = 0
|
||||||
|
|
||||||
|
// MARK: Non-Observed Internal State
|
||||||
|
|
||||||
|
@ObservationIgnored private var pendingQueue: [TourID] = []
|
||||||
|
@ObservationIgnored private var onTourComplete: (() -> Void)?
|
||||||
|
@ObservationIgnored private let tours: [Tour]
|
||||||
|
@ObservationIgnored private let seenStore: TourSeenStore
|
||||||
|
@ObservationIgnored private let appVersionProvider: () -> String
|
||||||
|
|
||||||
|
// MARK: Init
|
||||||
|
|
||||||
|
init(
|
||||||
|
tours: [Tour] = TourCatalog.all,
|
||||||
|
seenStore: TourSeenStore = TourSeenStore(),
|
||||||
|
appVersionProvider: @escaping () -> String = {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0"
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
self.tours = tours
|
||||||
|
self.seenStore = seenStore
|
||||||
|
self.appVersionProvider = appVersionProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
var currentStep: TourStep? {
|
||||||
|
guard let tour = activeTour, currentStepIndex < tour.steps.count else { return nil }
|
||||||
|
return tour.steps[currentStepIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
var isActive: Bool { activeTour != nil }
|
||||||
|
|
||||||
|
var isFirstStep: Bool { currentStepIndex == 0 }
|
||||||
|
|
||||||
|
var isLastStep: Bool {
|
||||||
|
guard let tour = activeTour else { return false }
|
||||||
|
return currentStepIndex == tour.steps.count - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var stepCount: Int { activeTour?.steps.count ?? 0 }
|
||||||
|
|
||||||
|
// MARK: Auto-Start
|
||||||
|
|
||||||
|
/// Checks for update tours that should be started automatically.
|
||||||
|
/// Call this from ContentView.onAppear or NahbarApp.task.
|
||||||
|
func checkForPendingTours() {
|
||||||
|
let currentVersion = appVersionProvider()
|
||||||
|
let lastSeen = seenStore.lastSeenAppVersion ?? ""
|
||||||
|
|
||||||
|
let pending = tours.filter { tour in
|
||||||
|
guard tour.triggerMode == .autoOnUpdate else { return false }
|
||||||
|
return !seenStore.hasSeen(tour.id) && versionIsNewer(tour.minAppVersion, than: lastSeen)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !pending.isEmpty {
|
||||||
|
logger.info("Ausstehende Tours gefunden: \(pending.map { $0.id.rawValue })")
|
||||||
|
pendingQueue = pending.map { $0.id }
|
||||||
|
startNextInQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Tour Control
|
||||||
|
|
||||||
|
/// Starts a tour by ID. An optional `onComplete` closure is called when the tour finishes
|
||||||
|
/// (whether by completing all steps, skipping, or closing).
|
||||||
|
func start(_ id: TourID, onComplete: (() -> Void)? = nil) {
|
||||||
|
guard let tour = tours.first(where: { $0.id == id }) else {
|
||||||
|
logger.warning("Tour nicht gefunden: \(id.rawValue)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.info("Tour gestartet: \(id.rawValue)")
|
||||||
|
onTourComplete = onComplete
|
||||||
|
currentStepIndex = 0
|
||||||
|
activeTour = tour
|
||||||
|
}
|
||||||
|
|
||||||
|
func next() {
|
||||||
|
guard let tour = activeTour else { return }
|
||||||
|
if currentStepIndex < tour.steps.count - 1 {
|
||||||
|
currentStepIndex += 1
|
||||||
|
} else {
|
||||||
|
completeTour()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func previous() {
|
||||||
|
guard currentStepIndex > 0 else { return }
|
||||||
|
currentStepIndex -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skips the tour and marks it as seen.
|
||||||
|
func skip() { completeTour() }
|
||||||
|
|
||||||
|
/// Closes the tour (semantically identical to skip in v1) and marks it as seen.
|
||||||
|
func close() { completeTour() }
|
||||||
|
|
||||||
|
/// `true` when the onboarding tour has already been completed or skipped.
|
||||||
|
var hasSeenOnboardingTour: Bool {
|
||||||
|
seenStore.hasSeen(.onboarding)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the onboarding tour if it hasn't been seen yet.
|
||||||
|
/// Call this after first-launch onboarding completes and the main app is visible.
|
||||||
|
func startOnboardingTourIfNeeded() {
|
||||||
|
guard !seenStore.hasSeen(.onboarding) else { return }
|
||||||
|
start(.onboarding)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets the "seen" state for all tours. Call this when the app data is reset.
|
||||||
|
func resetSeenTours() {
|
||||||
|
seenStore.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Target Frames (populated by tourPresenter overlay)
|
||||||
|
|
||||||
|
/// Not observed — updated by the tourPresenter via preference keys.
|
||||||
|
@ObservationIgnored var targetFrames: [TourTargetID: CGRect] = [:]
|
||||||
|
|
||||||
|
// MARK: Private
|
||||||
|
|
||||||
|
private func completeTour() {
|
||||||
|
guard let tour = activeTour else { return }
|
||||||
|
seenStore.markSeen(tour.id)
|
||||||
|
|
||||||
|
// Capture and clear callbacks before resetting state
|
||||||
|
let completion = onTourComplete
|
||||||
|
onTourComplete = nil
|
||||||
|
activeTour = nil
|
||||||
|
currentStepIndex = 0
|
||||||
|
|
||||||
|
logger.info("Tour abgeschlossen: \(tour.id.rawValue)")
|
||||||
|
completion?()
|
||||||
|
|
||||||
|
// If pending queue is now empty, update the stored version
|
||||||
|
if pendingQueue.isEmpty {
|
||||||
|
seenStore.lastSeenAppVersion = appVersionProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
startNextInQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startNextInQueue() {
|
||||||
|
guard !pendingQueue.isEmpty, activeTour == nil else { return }
|
||||||
|
let nextID = pendingQueue.removeFirst()
|
||||||
|
start(nextID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func versionIsNewer(_ version: String, than other: String) -> Bool {
|
||||||
|
guard !version.isEmpty else { return false }
|
||||||
|
if other.isEmpty { return true }
|
||||||
|
return version.compare(other, options: .numeric) == .orderedDescending
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - TourID
|
||||||
|
|
||||||
|
/// Identifies a concrete tour. New tours are added here.
|
||||||
|
enum TourID: String, CaseIterable, Codable, Hashable {
|
||||||
|
case onboarding
|
||||||
|
case v1_2_besuchsfragebogen
|
||||||
|
case v1_3_personality
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - TourOverlayView
|
||||||
|
|
||||||
|
/// Full-screen overlay that renders the spotlight cutout and tour card.
|
||||||
|
/// Inserted by the `tourPresenter()` modifier.
|
||||||
|
struct TourOverlayView: View {
|
||||||
|
|
||||||
|
let coordinator: TourCoordinator
|
||||||
|
/// Frames of all registered tour targets in the overlay's coordinate space.
|
||||||
|
let targetFrames: [TourTargetID: CGRect]
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
private var spotlightFrame: CGRect {
|
||||||
|
guard let target = coordinator.currentStep?.target else { return .zero }
|
||||||
|
return targetFrames[target] ?? .zero
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasSpotlight: Bool { spotlightFrame != .zero }
|
||||||
|
|
||||||
|
private var paddedSpotlight: CGRect {
|
||||||
|
guard hasSpotlight, let step = coordinator.currentStep else { return .zero }
|
||||||
|
return spotlightFrame.insetBy(dx: -step.spotlightPadding, dy: -step.spotlightPadding)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cornerRadius: CGFloat {
|
||||||
|
coordinator.currentStep?.spotlightCornerRadius ?? 18
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Body
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if coordinator.isActive {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
// ── Backdrop ───────────────────────────────────────────────
|
||||||
|
backdrop(in: geo)
|
||||||
|
|
||||||
|
// ── Spotlight glow ring ────────────────────────────────────
|
||||||
|
if hasSpotlight {
|
||||||
|
spotlightGlow
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tour card ──────────────────────────────────────────────
|
||||||
|
tourCard(in: geo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.transition(.opacity)
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: coordinator.isActive)
|
||||||
|
// Success haptic at tour end is triggered by TourCardView step change
|
||||||
|
.sensoryFeedback(.success, trigger: !coordinator.isActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Backdrop
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func backdrop(in geo: GeometryProxy) -> some View {
|
||||||
|
let fullRect = CGRect(origin: .zero, size: geo.size)
|
||||||
|
|
||||||
|
if hasSpotlight {
|
||||||
|
// Subtle dark tint — only slightly dims the non-spotlight area
|
||||||
|
// so the user can still see and orient themselves in the UI
|
||||||
|
SpotlightShape(spotlight: paddedSpotlight, cornerRadius: cornerRadius)
|
||||||
|
.fill(Color.black.opacity(0.18), style: FillStyle(eoFill: true))
|
||||||
|
.frame(width: fullRect.width, height: fullRect.height)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: paddedSpotlight)
|
||||||
|
} else {
|
||||||
|
// No spotlight: very subtle tint so the screen stays readable
|
||||||
|
Color.black.opacity(0.15)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Spotlight Glow
|
||||||
|
|
||||||
|
private var spotlightGlow: some View {
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||||
|
.strokeBorder(NahbarInsightStyle.accentCoral.opacity(0.6), lineWidth: 1.5)
|
||||||
|
.shadow(color: NahbarInsightStyle.accentCoral.opacity(0.45), radius: 12)
|
||||||
|
.frame(width: paddedSpotlight.width, height: paddedSpotlight.height)
|
||||||
|
.position(x: paddedSpotlight.midX, y: paddedSpotlight.midY)
|
||||||
|
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: paddedSpotlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Tour Card
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func tourCard(in geo: GeometryProxy) -> some View {
|
||||||
|
let cardWidth: CGFloat = min(geo.size.width - 48, 380)
|
||||||
|
let cardY = cardYPosition(in: geo)
|
||||||
|
|
||||||
|
TourCardView(
|
||||||
|
coordinator: coordinator,
|
||||||
|
totalSteps: coordinator.stepCount,
|
||||||
|
currentIndex: coordinator.currentStepIndex
|
||||||
|
)
|
||||||
|
.frame(width: cardWidth)
|
||||||
|
.position(x: geo.size.width / 2, y: cardY)
|
||||||
|
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: coordinator.currentStepIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cardYPosition(in geo: GeometryProxy) -> CGFloat {
|
||||||
|
let screenHeight = geo.size.height
|
||||||
|
let cardHeight: CGFloat = 260 // Approximate card height
|
||||||
|
let margin: CGFloat = 24
|
||||||
|
let safeTop = geo.safeAreaInsets.top
|
||||||
|
let safeBottom = geo.safeAreaInsets.bottom
|
||||||
|
|
||||||
|
guard hasSpotlight else {
|
||||||
|
// No spotlight → center the card
|
||||||
|
return screenHeight / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
let step = coordinator.currentStep
|
||||||
|
let position = step?.preferredCardPosition ?? .auto
|
||||||
|
|
||||||
|
// Space above and below spotlight
|
||||||
|
let spaceAbove = paddedSpotlight.minY - safeTop - margin
|
||||||
|
let spaceBelow = screenHeight - paddedSpotlight.maxY - safeBottom - margin
|
||||||
|
|
||||||
|
switch position {
|
||||||
|
case .above:
|
||||||
|
return paddedSpotlight.minY - cardHeight / 2 - margin
|
||||||
|
case .below:
|
||||||
|
return paddedSpotlight.maxY + cardHeight / 2 + margin
|
||||||
|
case .center:
|
||||||
|
return screenHeight / 2
|
||||||
|
case .auto:
|
||||||
|
if spaceBelow >= cardHeight {
|
||||||
|
return paddedSpotlight.maxY + cardHeight / 2 + margin
|
||||||
|
} else if spaceAbove >= cardHeight {
|
||||||
|
return paddedSpotlight.minY - cardHeight / 2 - margin
|
||||||
|
} else {
|
||||||
|
return screenHeight / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "nahbar", category: "TourSeenStore")
|
||||||
|
|
||||||
|
// MARK: - TourSeenStore
|
||||||
|
|
||||||
|
/// Persists which tour IDs have been completed and the last-seen app version.
|
||||||
|
/// Injected into TourCoordinator to keep it testable.
|
||||||
|
final class TourSeenStore {
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let seenIDsKey = "nahbar.tour.seenIDs"
|
||||||
|
private let lastSeenVersionKey = "nahbar.tour.lastSeenAppVersion"
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Seen IDs
|
||||||
|
|
||||||
|
func hasSeen(_ id: TourID) -> Bool {
|
||||||
|
seenIDs.contains(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markSeen(_ id: TourID) {
|
||||||
|
var ids = seenIDs
|
||||||
|
ids.insert(id)
|
||||||
|
seenIDs = ids
|
||||||
|
logger.info("Tour als gesehen markiert: \(id.rawValue)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
defaults.removeObject(forKey: seenIDsKey)
|
||||||
|
defaults.removeObject(forKey: lastSeenVersionKey)
|
||||||
|
logger.info("TourSeenStore zurückgesetzt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Last Seen App Version
|
||||||
|
|
||||||
|
var lastSeenAppVersion: String? {
|
||||||
|
get { defaults.string(forKey: lastSeenVersionKey) }
|
||||||
|
set {
|
||||||
|
if let newValue {
|
||||||
|
defaults.set(newValue, forKey: lastSeenVersionKey)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: lastSeenVersionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Private
|
||||||
|
|
||||||
|
private var seenIDs: Set<TourID> {
|
||||||
|
get {
|
||||||
|
guard let data = defaults.data(forKey: seenIDsKey),
|
||||||
|
let ids = try? JSONDecoder().decode(Set<TourID>.self, from: data)
|
||||||
|
else { return [] }
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard let data = try? JSONEncoder().encode(newValue) else { return }
|
||||||
|
defaults.set(data, forKey: seenIDsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - CardPosition
|
||||||
|
|
||||||
|
/// Controls where the info card is placed relative to the spotlight.
|
||||||
|
enum CardPosition: Hashable {
|
||||||
|
case auto
|
||||||
|
case above
|
||||||
|
case below
|
||||||
|
case center
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TourStep
|
||||||
|
|
||||||
|
/// A single step in a guided tour.
|
||||||
|
struct TourStep: Identifiable, Hashable {
|
||||||
|
static func == (lhs: TourStep, rhs: TourStep) -> Bool { lhs.id == rhs.id }
|
||||||
|
func hash(into hasher: inout Hasher) { hasher.combine(id) }
|
||||||
|
let id: UUID
|
||||||
|
let title: LocalizedStringResource
|
||||||
|
let body: LocalizedStringResource
|
||||||
|
/// `nil` means a centered intro card with no spotlight.
|
||||||
|
let target: TourTargetID?
|
||||||
|
let preferredCardPosition: CardPosition
|
||||||
|
let spotlightPadding: CGFloat
|
||||||
|
let spotlightCornerRadius: CGFloat?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
title: LocalizedStringResource,
|
||||||
|
body: LocalizedStringResource,
|
||||||
|
target: TourTargetID? = nil,
|
||||||
|
preferredCardPosition: CardPosition = .auto,
|
||||||
|
spotlightPadding: CGFloat = 12,
|
||||||
|
spotlightCornerRadius: CGFloat? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.body = body
|
||||||
|
self.target = target
|
||||||
|
self.preferredCardPosition = preferredCardPosition
|
||||||
|
self.spotlightPadding = spotlightPadding
|
||||||
|
self.spotlightCornerRadius = spotlightCornerRadius
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - TourTargetID
|
||||||
|
|
||||||
|
/// Identifies a spotlightable UI element. Views mark themselves with `.tourTarget(_:)`.
|
||||||
|
enum TourTargetID: String, Hashable, CaseIterable {
|
||||||
|
case addContactButton
|
||||||
|
case filterChips
|
||||||
|
case relationshipStrengthBadge
|
||||||
|
case contactCardFirst
|
||||||
|
case addMomentButton
|
||||||
|
case addTodoButton
|
||||||
|
case personalityTab
|
||||||
|
case insightsTab
|
||||||
|
case settingsEntry
|
||||||
|
case todayTab
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - TourTargetPreferenceKey
|
||||||
|
|
||||||
|
/// Collects Anchor<CGRect> values for each registered TourTargetID.
|
||||||
|
struct TourTargetPreferenceKey: PreferenceKey {
|
||||||
|
static var defaultValue: [TourTargetID: Anchor<CGRect>] = [:]
|
||||||
|
|
||||||
|
static func reduce(
|
||||||
|
value: inout [TourTargetID: Anchor<CGRect>],
|
||||||
|
nextValue: () -> [TourTargetID: Anchor<CGRect>]
|
||||||
|
) {
|
||||||
|
value.merge(nextValue()) { _, new in new }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View Modifiers
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
/// Marks this view as a spotlightable tour target.
|
||||||
|
/// The frame is collected via a preference key and forwarded to TourCoordinator.
|
||||||
|
func tourTarget(_ id: TourTargetID) -> some View {
|
||||||
|
anchorPreference(key: TourTargetPreferenceKey.self, value: .bounds) { anchor in
|
||||||
|
[id: anchor]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Conditional variant — applies `tourTarget` only when `id` is non-nil.
|
||||||
|
/// Use `index == 0 ? .someTarget : nil` patterns for list-based targets.
|
||||||
|
@ViewBuilder
|
||||||
|
func tourTarget(_ id: TourTargetID?) -> some View {
|
||||||
|
if let id {
|
||||||
|
tourTarget(id)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds the tour overlay to this view. Place at the root of a view hierarchy
|
||||||
|
/// (ContentView for main-app tours, OnboardingContainerView for onboarding).
|
||||||
|
///
|
||||||
|
/// - Parameter coordinator: The shared TourCoordinator instance.
|
||||||
|
func tourPresenter(coordinator: TourCoordinator) -> some View {
|
||||||
|
overlayPreferenceValue(TourTargetPreferenceKey.self) { anchors in
|
||||||
|
GeometryReader { geo in
|
||||||
|
// Convert preference anchors to CGRect in this coordinate space
|
||||||
|
let frames: [TourTargetID: CGRect] = anchors.reduce(into: [:]) { result, pair in
|
||||||
|
result[pair.key] = geo[pair.value]
|
||||||
|
}
|
||||||
|
TourOverlayView(coordinator: coordinator, targetFrames: frames)
|
||||||
|
.frame(width: geo.size.width, height: geo.size.height)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,14 +155,14 @@ struct SchemaRegressionTests {
|
|||||||
#expect(NahbarSchemaV3.versionIdentifier.patch == 0)
|
#expect(NahbarSchemaV3.versionIdentifier.patch == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Migrationsplan enthält genau 8 Schemas (V1–V8)")
|
@Test("Migrationsplan enthält genau 9 Schemas (V1–V9)")
|
||||||
func migrationPlanHasEightSchemas() {
|
func migrationPlanHasNineSchemas() {
|
||||||
#expect(NahbarMigrationPlan.schemas.count == 8)
|
#expect(NahbarMigrationPlan.schemas.count == 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Migrationsplan enthält genau 7 Stages (V1→V2 bis V7→V8)")
|
@Test("Migrationsplan enthält genau 8 Stages (V1→V2 bis V8→V9)")
|
||||||
func migrationPlanHasSevenStages() {
|
func migrationPlanHasEightStages() {
|
||||||
#expect(NahbarMigrationPlan.stages.count == 7)
|
#expect(NahbarMigrationPlan.stages.count == 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("ContainerFallback-Gleichheit funktioniert korrekt")
|
@Test("ContainerFallback-Gleichheit funktioniert korrekt")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,80 @@ struct NudgeFrequencyTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("displayLabel ist nicht leer für alle Fälle")
|
||||||
|
func displayLabelNotEmpty() {
|
||||||
|
for freq in NudgeFrequency.allCases {
|
||||||
|
#expect(!freq.displayLabel.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("biweekly displayLabel enthält '2 Wochen'")
|
||||||
|
func biweeklyDisplayLabel() {
|
||||||
|
#expect(NudgeFrequency.biweekly.displayLabel.contains("2 Wochen"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NudgeStatus Tests
|
||||||
|
|
||||||
|
@Suite("NudgeStatus")
|
||||||
|
struct NudgeStatusTests {
|
||||||
|
|
||||||
|
private func makePerson(frequency: NudgeFrequency, lastContact: Date?) -> Person {
|
||||||
|
let p = Person(name: "Test", tag: .friends)
|
||||||
|
p.nudgeFrequency = frequency
|
||||||
|
// lastMomentDate ist computed aus moments — wir simulieren via createdAt
|
||||||
|
// Wir setzen createdAt auf den gewünschten Referenzzeitpunkt
|
||||||
|
if let date = lastContact {
|
||||||
|
p.createdAt = date
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("never → .never Status")
|
||||||
|
func neverFrequencyReturnsNever() {
|
||||||
|
let p = makePerson(frequency: .never, lastContact: nil)
|
||||||
|
#expect(p.nudgeStatus == .never)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("kürzlicher Kontakt → .ok")
|
||||||
|
func recentContactReturnsOk() {
|
||||||
|
// Letzte Aktivität: gestern → weit unter 75 % des monatlichen Intervalls
|
||||||
|
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date())!
|
||||||
|
let p = makePerson(frequency: .monthly, lastContact: yesterday)
|
||||||
|
#expect(p.nudgeStatus == .ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("75–100 % des Intervalls → .soon")
|
||||||
|
func approachingDeadlineReturnsSoon() {
|
||||||
|
// 25 Tage her bei monatlichem Intervall (30 Tage) = 83 %
|
||||||
|
let almostDue = Calendar.current.date(byAdding: .day, value: -25, to: Date())!
|
||||||
|
let p = makePerson(frequency: .monthly, lastContact: almostDue)
|
||||||
|
#expect(p.nudgeStatus == .soon)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("über 100 % des Intervalls → .overdue")
|
||||||
|
func overdueReturnsOverdue() {
|
||||||
|
// 40 Tage her bei monatlichem Intervall (30 Tage)
|
||||||
|
let tooLong = Calendar.current.date(byAdding: .day, value: -40, to: Date())!
|
||||||
|
let p = makePerson(frequency: .monthly, lastContact: tooLong)
|
||||||
|
#expect(p.nudgeStatus == .overdue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("wöchentlich + 8 Tage her → .overdue")
|
||||||
|
func weeklyOverdue() {
|
||||||
|
let eightDaysAgo = Calendar.current.date(byAdding: .day, value: -8, to: Date())!
|
||||||
|
let p = makePerson(frequency: .weekly, lastContact: eightDaysAgo)
|
||||||
|
#expect(p.nudgeStatus == .overdue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("nudgeStatus stimmt mit needsAttention überein wenn overdue")
|
||||||
|
func nudgeStatusConsistentWithNeedsAttention() {
|
||||||
|
let tooLong = Calendar.current.date(byAdding: .day, value: -40, to: Date())!
|
||||||
|
let p = makePerson(frequency: .monthly, lastContact: tooLong)
|
||||||
|
#expect(p.nudgeStatus == .overdue)
|
||||||
|
#expect(p.needsAttention == true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PersonTag Tests
|
// MARK: - PersonTag Tests
|
||||||
@@ -437,10 +511,23 @@ struct LogEntryComputedPropertyTests {
|
|||||||
|
|
||||||
@Test("alle LogEntryTypes haben ein nicht-leeres Icon und color")
|
@Test("alle LogEntryTypes haben ein nicht-leeres Icon und color")
|
||||||
func allTypesHaveIconAndColor() {
|
func allTypesHaveIconAndColor() {
|
||||||
let types: [LogEntryType] = [.nextStep, .calendarEvent, .call]
|
for type_ in LogEntryType.allCases {
|
||||||
for type_ in types {
|
#expect(!type_.icon.isEmpty, "\(type_.rawValue) hat leeres icon")
|
||||||
#expect(!type_.icon.isEmpty)
|
#expect(!type_.color.isEmpty, "\(type_.rawValue) hat leere color")
|
||||||
#expect(!type_.color.isEmpty)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test(".todoCompleted ist in allCases enthalten – Regressionswächter")
|
||||||
|
func todoCompletedInAllCases() {
|
||||||
|
#expect(LogEntryType.allCases.contains(.todoCompleted))
|
||||||
|
#expect(LogEntryType.allCases.count == 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Stabile rawValues – Regressionswächter")
|
||||||
|
func stableRawValues() {
|
||||||
|
#expect(LogEntryType.nextStep.rawValue == "Schritt abgeschlossen")
|
||||||
|
#expect(LogEntryType.calendarEvent.rawValue == "Termin geplant")
|
||||||
|
#expect(LogEntryType.call.rawValue == "Anruf")
|
||||||
|
#expect(LogEntryType.todoCompleted.rawValue == "Todo abgeschlossen")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -416,79 +416,6 @@ struct PersonalityEngineBehaviorTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Hohe Offenheit → highlightNovelty true")
|
|
||||||
func highOpennessHighlightsNovelty() {
|
|
||||||
let p = profile(o: .high)
|
|
||||||
#expect(PersonalityEngine.highlightNovelty(for: p))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Niedrige Offenheit → highlightNovelty false")
|
|
||||||
func lowOpennessDoesNotHighlightNovelty() {
|
|
||||||
let p = profile(o: .low)
|
|
||||||
#expect(!PersonalityEngine.highlightNovelty(for: p))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - suggestedActivities Tests
|
|
||||||
|
|
||||||
@Suite("PersonalityEngine – suggestedActivities")
|
|
||||||
struct SuggestedActivitiesTests {
|
|
||||||
|
|
||||||
@Test("Gibt genau count Elemente zurück")
|
|
||||||
func returnsRequestedCount() {
|
|
||||||
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 2)
|
|
||||||
#expect(result.count == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("count: 1 → genau ein Vorschlag")
|
|
||||||
func countOne() {
|
|
||||||
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 1)
|
|
||||||
#expect(result.count == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Alle zurückgegebenen Texte stammen aus dem Pool")
|
|
||||||
func resultsAreFromPool() {
|
|
||||||
let poolTexts = Set(PersonalityEngine.activityPool.map(\.text))
|
|
||||||
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 5)
|
|
||||||
for text in result {
|
|
||||||
#expect(poolTexts.contains(text), "'\(text)' nicht im Pool")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Pool hat mindestens 20 Einträge")
|
|
||||||
func poolIsSufficient() {
|
|
||||||
#expect(PersonalityEngine.activityPool.count >= 20)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Keine Duplikate in einem Ergebnis")
|
|
||||||
func noDuplicates() {
|
|
||||||
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 5)
|
|
||||||
#expect(result.count == Set(result).count)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Ergebnis ist nicht leer wenn Pool vorhanden")
|
|
||||||
func notEmptyWhenPoolExists() {
|
|
||||||
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 2)
|
|
||||||
#expect(!result.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Pool enthält Erlebnis-Aktivitäten (isNovelty)")
|
|
||||||
func poolContainsNoveltyActivities() {
|
|
||||||
#expect(PersonalityEngine.activityPool.contains { $0.isNovelty })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Pool enthält 1:1 und Gruppen-Aktivitäten")
|
|
||||||
func poolContainsBothStyles() {
|
|
||||||
#expect(PersonalityEngine.activityPool.contains { $0.style == .oneOnOne })
|
|
||||||
#expect(PersonalityEngine.activityPool.contains { $0.style == .group })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Pool enthält Tag-spezifische Aktivitäten")
|
|
||||||
func poolContainsTagSpecificActivities() {
|
|
||||||
#expect(PersonalityEngine.activityPool.contains { $0.preferredTag == .friends })
|
|
||||||
#expect(PersonalityEngine.activityPool.contains { $0.preferredTag == .family })
|
|
||||||
#expect(PersonalityEngine.activityPool.contains { $0.preferredTag == .work })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - GenderSelectionScreen Skip-Logik
|
// MARK: - GenderSelectionScreen Skip-Logik
|
||||||
@@ -519,22 +446,99 @@ struct PersonalityQuizGenderSkipTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - OnboardingStep – Regressionswächter (nach Quiz-Erweiterung)
|
// MARK: - Notification Copy Tests
|
||||||
|
|
||||||
@Suite("OnboardingStep – RawValues (Quiz-Erweiterung)")
|
@Suite("PersonalityEngine – Notification Copy")
|
||||||
struct OnboardingStepQuizTests {
|
struct NotificationCopyTests {
|
||||||
|
|
||||||
@Test("RawValues sind aufsteigend 0–4")
|
private func profile(e: TraitLevel = .medium, n: TraitLevel = .medium,
|
||||||
@MainActor func rawValuesSequential() {
|
c: TraitLevel = .medium) -> PersonalityProfile {
|
||||||
#expect(OnboardingStep.profile.rawValue == 0)
|
func score(_ l: TraitLevel) -> Int { l == .low ? 0 : l == .medium ? 1 : 2 }
|
||||||
#expect(OnboardingStep.quiz.rawValue == 1)
|
return PersonalityProfile(scores: [
|
||||||
#expect(OnboardingStep.contacts.rawValue == 2)
|
.extraversion: score(e), .neuroticism: score(n),
|
||||||
#expect(OnboardingStep.tour.rawValue == 3)
|
.conscientiousness: score(c), .agreeableness: 1, .openness: 1
|
||||||
#expect(OnboardingStep.complete.rawValue == 4)
|
], completedAt: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("allCases enthält genau 5 Schritte")
|
// MARK: callWindowCopy
|
||||||
@MainActor func allCasesCountIsFive() {
|
|
||||||
#expect(OnboardingStep.allCases.count == 5)
|
@Test("callWindowCopy: nil-Profil liefert nicht-leeren Fallback")
|
||||||
|
func callWindowCopyNilProfile() {
|
||||||
|
let copy = PersonalityEngine.callWindowCopy(profile: nil)
|
||||||
|
#expect(!copy.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("callWindowCopy: hohe Extraversion → direkter Text")
|
||||||
|
func callWindowCopyHighExtraversion() {
|
||||||
|
let copy = PersonalityEngine.callWindowCopy(profile: profile(e: .high))
|
||||||
|
#expect(copy.contains("freut sich"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("callWindowCopy: hoher Neurotizismus (nicht high E) → weicherer, ermutigender Text")
|
||||||
|
func callWindowCopyHighNeuroticism() {
|
||||||
|
let copy = PersonalityEngine.callWindowCopy(profile: profile(e: .low, n: .high))
|
||||||
|
#expect(copy.contains("Magst du"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("callWindowCopy: Medium-Profil → Default-Text")
|
||||||
|
func callWindowCopyDefault() {
|
||||||
|
let copy = PersonalityEngine.callWindowCopy(profile: profile(e: .medium, n: .medium))
|
||||||
|
#expect(!copy.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("callWindowCopy: alle Varianten sind nicht leer")
|
||||||
|
func callWindowCopyAllVariantsNonEmpty() {
|
||||||
|
for e in TraitLevel.allCases {
|
||||||
|
for n in TraitLevel.allCases {
|
||||||
|
let copy = PersonalityEngine.callWindowCopy(profile: profile(e: e, n: n))
|
||||||
|
#expect(!copy.isEmpty, "callWindowCopy leer für e=\(e), n=\(n)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: aftermathCopy
|
||||||
|
|
||||||
|
@Test("aftermathCopy: nil-Profil liefert nicht-leeren Fallback")
|
||||||
|
func aftermathCopyNilProfile() {
|
||||||
|
let copy = PersonalityEngine.aftermathCopy(profile: nil)
|
||||||
|
#expect(!copy.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("aftermathCopy: hoher Neurotizismus → weicher, einladender Text")
|
||||||
|
func aftermathCopyHighNeuroticism() {
|
||||||
|
let copy = PersonalityEngine.aftermathCopy(profile: profile(n: .high))
|
||||||
|
#expect(copy.contains("Wenn du magst"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("aftermathCopy: niedriger Neurotizismus → direkter Text")
|
||||||
|
func aftermathCopyLowNeuroticism() {
|
||||||
|
let copy = PersonalityEngine.aftermathCopy(profile: profile(n: .low))
|
||||||
|
#expect(copy.contains("Wie wirkt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("aftermathCopy: alle Varianten sind nicht leer")
|
||||||
|
func aftermathCopyAllVariantsNonEmpty() {
|
||||||
|
for n in TraitLevel.allCases {
|
||||||
|
let copy = PersonalityEngine.aftermathCopy(profile: profile(n: n))
|
||||||
|
#expect(!copy.isEmpty, "aftermathCopy leer für n=\(n)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OnboardingStep – Regressionswächter
|
||||||
|
|
||||||
|
@Suite("OnboardingStep – RawValues")
|
||||||
|
struct OnboardingStepQuizTests {
|
||||||
|
|
||||||
|
@Test("RawValues sind aufsteigend 0–2")
|
||||||
|
@MainActor func rawValuesSequential() {
|
||||||
|
#expect(OnboardingStep.profile.rawValue == 0)
|
||||||
|
#expect(OnboardingStep.contacts.rawValue == 1)
|
||||||
|
#expect(OnboardingStep.complete.rawValue == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("allCases enthält genau 3 Schritte")
|
||||||
|
@MainActor func allCasesCountIsThree() {
|
||||||
|
#expect(OnboardingStep.allCases.count == 3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,40 +69,6 @@ struct OnboardingCoordinatorNavigationTests {
|
|||||||
#expect(coord.currentStep == .profile)
|
#expect(coord.currentStep == .profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("advanceToQuiz ohne Vorname bleibt auf .profile")
|
|
||||||
@MainActor func advanceToQuizWithoutNameStaysOnProfile() {
|
|
||||||
let coord = OnboardingCoordinator()
|
|
||||||
coord.firstName = ""
|
|
||||||
coord.advanceToQuiz()
|
|
||||||
#expect(coord.currentStep == .profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("advanceToQuiz mit gültigem Vorname → .quiz")
|
|
||||||
@MainActor func advanceToQuizWithNameGoesToQuiz() {
|
|
||||||
let coord = OnboardingCoordinator()
|
|
||||||
coord.firstName = "Anna"
|
|
||||||
coord.advanceToQuiz()
|
|
||||||
#expect(coord.currentStep == .quiz)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("skipQuiz überspring Quiz und geht zu .contacts")
|
|
||||||
@MainActor func skipQuizGoesToContacts() {
|
|
||||||
let coord = OnboardingCoordinator()
|
|
||||||
coord.firstName = "Anna"
|
|
||||||
coord.advanceToQuiz()
|
|
||||||
coord.skipQuiz()
|
|
||||||
#expect(coord.currentStep == .contacts)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("advanceFromQuizToContacts → .contacts")
|
|
||||||
@MainActor func advanceFromQuizToContacts() {
|
|
||||||
let coord = OnboardingCoordinator()
|
|
||||||
coord.firstName = "Anna"
|
|
||||||
coord.advanceToQuiz()
|
|
||||||
coord.advanceFromQuizToContacts()
|
|
||||||
#expect(coord.currentStep == .contacts)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("advanceToContacts ohne Vorname bleibt auf .profile")
|
@Test("advanceToContacts ohne Vorname bleibt auf .profile")
|
||||||
@MainActor func advanceToContactsWithoutNameStaysOnProfile() {
|
@MainActor func advanceToContactsWithoutNameStaysOnProfile() {
|
||||||
let coord = OnboardingCoordinator()
|
let coord = OnboardingCoordinator()
|
||||||
@@ -119,34 +85,6 @@ struct OnboardingCoordinatorNavigationTests {
|
|||||||
#expect(coord.currentStep == .contacts)
|
#expect(coord.currentStep == .contacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("advanceToTour ohne Kontakte bleibt auf .contacts")
|
|
||||||
@MainActor func advanceToTourWithoutContactsStaysOnContacts() {
|
|
||||||
let coord = OnboardingCoordinator()
|
|
||||||
coord.firstName = "Anna"
|
|
||||||
coord.advanceToContacts()
|
|
||||||
coord.advanceToTour() // keine Kontakte ausgewählt
|
|
||||||
#expect(coord.currentStep == .contacts)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("advanceToTour mit Kontakt → .tour")
|
|
||||||
@MainActor func advanceToTourWithContactGoesToTour() {
|
|
||||||
let coord = OnboardingCoordinator()
|
|
||||||
coord.firstName = "Anna"
|
|
||||||
coord.advanceToContacts()
|
|
||||||
coord.selectedContacts = [NahbarContact(givenName: "Kai", familyName: "Müller")]
|
|
||||||
coord.advanceToTour()
|
|
||||||
#expect(coord.currentStep == .tour)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("skipToTour überspringt Kontakt-Schritt")
|
|
||||||
@MainActor func skipToTourSkipsContacts() {
|
|
||||||
let coord = OnboardingCoordinator()
|
|
||||||
coord.firstName = "Anna"
|
|
||||||
coord.advanceToContacts()
|
|
||||||
coord.skipToTour()
|
|
||||||
#expect(coord.currentStep == .tour)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("completeOnboarding setzt Schritt auf .complete")
|
@Test("completeOnboarding setzt Schritt auf .complete")
|
||||||
@MainActor func completeOnboardingSetsComplete() {
|
@MainActor func completeOnboardingSetsComplete() {
|
||||||
let coord = OnboardingCoordinator()
|
let coord = OnboardingCoordinator()
|
||||||
@@ -270,18 +208,16 @@ struct NahbarContactCodableTests {
|
|||||||
@Suite("OnboardingStep – RawValue")
|
@Suite("OnboardingStep – RawValue")
|
||||||
struct OnboardingStepTests {
|
struct OnboardingStepTests {
|
||||||
|
|
||||||
@Test("RawValues sind aufsteigend 0–4")
|
@Test("RawValues sind aufsteigend 0–2")
|
||||||
func rawValuesAreSequential() {
|
func rawValuesAreSequential() {
|
||||||
#expect(OnboardingStep.profile.rawValue == 0)
|
#expect(OnboardingStep.profile.rawValue == 0)
|
||||||
#expect(OnboardingStep.quiz.rawValue == 1)
|
#expect(OnboardingStep.contacts.rawValue == 1)
|
||||||
#expect(OnboardingStep.contacts.rawValue == 2)
|
#expect(OnboardingStep.complete.rawValue == 2)
|
||||||
#expect(OnboardingStep.tour.rawValue == 3)
|
|
||||||
#expect(OnboardingStep.complete.rawValue == 4)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("allCases enthält genau 5 Schritte")
|
@Test("allCases enthält genau 3 Schritte")
|
||||||
func allCasesCount() {
|
func allCasesCount() {
|
||||||
#expect(OnboardingStep.allCases.count == 5)
|
#expect(OnboardingStep.allCases.count == 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Reihenfolge von allCases stimmt mit rawValue überein")
|
@Test("Reihenfolge von allCases stimmt mit rawValue überein")
|
||||||
|
|||||||
@@ -162,6 +162,165 @@ struct AppGroupProStatusTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - FeatureGate Consistency Tests
|
||||||
|
|
||||||
|
/// Stellt sicher, dass die Invariante "Max implies Pro" unter allen Transaktions-Kombinationen gilt.
|
||||||
|
@Suite("FeatureGate – Konsistenz")
|
||||||
|
struct FeatureGateConsistencyTests {
|
||||||
|
|
||||||
|
@Test("free: isPro und isMax beide false")
|
||||||
|
func freeHasNoAccess() {
|
||||||
|
let gate = FeatureGate.from(foundPro: false, foundMax: false)
|
||||||
|
#expect(gate.isPro == false)
|
||||||
|
#expect(gate.isMax == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Pro-only: isPro true, isMax false")
|
||||||
|
func proOnlySetsPro() {
|
||||||
|
let gate = FeatureGate.from(foundPro: true, foundMax: false)
|
||||||
|
#expect(gate.isPro == true)
|
||||||
|
#expect(gate.isMax == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Max-only: isMax true setzt isPro automatisch true (Max implies Pro)")
|
||||||
|
func maxOnlyImpliesPro() {
|
||||||
|
let gate = FeatureGate.from(foundPro: false, foundMax: true)
|
||||||
|
#expect(gate.isMax == true)
|
||||||
|
#expect(gate.isPro == true, "Max-Abonnenten müssen auch Pro-Features erhalten")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Pro + Max: beide true")
|
||||||
|
func proAndMaxBothTrue() {
|
||||||
|
let gate = FeatureGate.from(foundPro: true, foundMax: true)
|
||||||
|
#expect(gate.isPro == true)
|
||||||
|
#expect(gate.isMax == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isConsistent gilt für alle 4 Transaktions-Kombinationen")
|
||||||
|
func consistencyHoldsForAllCombinations() {
|
||||||
|
let combinations = [(false, false), (true, false), (false, true), (true, true)]
|
||||||
|
for (foundPro, foundMax) in combinations {
|
||||||
|
let gate = FeatureGate.from(foundPro: foundPro, foundMax: foundMax)
|
||||||
|
#expect(gate.isConsistent, "Invariante verletzt für foundPro=\(foundPro), foundMax=\(foundMax)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("FeatureGate.free entspricht from(false, false)")
|
||||||
|
func freeStaticMatchesFromFactory() {
|
||||||
|
#expect(FeatureGate.free == FeatureGate.from(foundPro: false, foundMax: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FeatureGate Access Tests
|
||||||
|
|
||||||
|
/// Verifiziert, dass jedes Feature genau für den richtigen Tier freigeschaltet wird.
|
||||||
|
@Suite("FeatureGate – Feature-Zugriff")
|
||||||
|
struct FeatureGateAccessTests {
|
||||||
|
|
||||||
|
// MARK: Free-User
|
||||||
|
|
||||||
|
@Test("Free-User: keine Pro-Features")
|
||||||
|
func freeUserHasNoProFeatures() {
|
||||||
|
let gate = FeatureGate.free
|
||||||
|
#expect(!gate.unlimitedContacts)
|
||||||
|
#expect(!gate.premiumThemes)
|
||||||
|
#expect(!gate.shareExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Free-User: keine Max-Features")
|
||||||
|
func freeUserHasNoMaxFeatures() {
|
||||||
|
let gate = FeatureGate.free
|
||||||
|
#expect(!gate.aiAnalysis)
|
||||||
|
#expect(!gate.giftSuggestions)
|
||||||
|
#expect(!gate.conversationTopics)
|
||||||
|
#expect(!gate.unlimitedAIQueries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Pro-only
|
||||||
|
|
||||||
|
@Test("Pro-User: alle Pro-Features aktiv")
|
||||||
|
func proUserHasAllProFeatures() {
|
||||||
|
let gate = FeatureGate.from(foundPro: true, foundMax: false)
|
||||||
|
#expect(gate.unlimitedContacts)
|
||||||
|
#expect(gate.premiumThemes)
|
||||||
|
#expect(gate.shareExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Pro-User: keine Max-Features")
|
||||||
|
func proUserHasNoMaxFeatures() {
|
||||||
|
let gate = FeatureGate.from(foundPro: true, foundMax: false)
|
||||||
|
#expect(!gate.aiAnalysis)
|
||||||
|
#expect(!gate.giftSuggestions)
|
||||||
|
#expect(!gate.conversationTopics)
|
||||||
|
#expect(!gate.unlimitedAIQueries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Max-User
|
||||||
|
|
||||||
|
@Test("Max-User: alle Pro-Features aktiv (Max implies Pro)")
|
||||||
|
func maxUserHasAllProFeatures() {
|
||||||
|
let gate = FeatureGate.from(foundPro: false, foundMax: true)
|
||||||
|
#expect(gate.unlimitedContacts, "Max-Abonnenten müssen unbegrenzte Kontakte haben")
|
||||||
|
#expect(gate.premiumThemes, "Max-Abonnenten müssen Premium-Themes haben")
|
||||||
|
#expect(gate.shareExtension, "Max-Abonnenten müssen die Share-Extension nutzen können")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Max-User: alle Max-Features aktiv")
|
||||||
|
func maxUserHasAllMaxFeatures() {
|
||||||
|
let gate = FeatureGate.from(foundPro: false, foundMax: true)
|
||||||
|
#expect(gate.aiAnalysis, "Max-Abonnenten müssen KI-Analyse nutzen können")
|
||||||
|
#expect(gate.giftSuggestions, "Max-Abonnenten müssen Geschenkideen nutzen können")
|
||||||
|
#expect(gate.conversationTopics, "Max-Abonnenten müssen Gesprächsthemen nutzen können")
|
||||||
|
#expect(gate.unlimitedAIQueries, "Max-Abonnenten müssen unbegrenzte KI-Abfragen haben")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FeatureGate canUseAI Tests
|
||||||
|
|
||||||
|
/// Verifiziert die canUseAI-Logik, die in AddMomentView, TodayView und LogbuchView identisch ist:
|
||||||
|
/// store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
|
||||||
|
@Suite("FeatureGate – canUseAI")
|
||||||
|
struct FeatureGateCanUseAITests {
|
||||||
|
|
||||||
|
@Test("Max + keine Gratis-Abfragen: KI verfügbar (unbegrenzt)")
|
||||||
|
func maxUserAlwaysCanUseAI() {
|
||||||
|
#expect(FeatureGate.canUseAI(isMax: true, hasFreeQueriesLeft: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Max + Gratis-Abfragen vorhanden: KI verfügbar")
|
||||||
|
func maxUserWithQueriesCanUseAI() {
|
||||||
|
#expect(FeatureGate.canUseAI(isMax: true, hasFreeQueriesLeft: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nicht-Max + Gratis-Abfragen vorhanden: KI verfügbar")
|
||||||
|
func nonMaxWithQueriesCanUseAI() {
|
||||||
|
#expect(FeatureGate.canUseAI(isMax: false, hasFreeQueriesLeft: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nicht-Max + keine Gratis-Abfragen: KI gesperrt")
|
||||||
|
func nonMaxWithoutQueriesCannotUseAI() {
|
||||||
|
#expect(!FeatureGate.canUseAI(isMax: false, hasFreeQueriesLeft: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FeatureGate shouldConsumeFreeQuery Tests
|
||||||
|
|
||||||
|
/// Verifiziert die Verbrauchs-Logik:
|
||||||
|
/// if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() }
|
||||||
|
@Suite("FeatureGate – shouldConsumeFreeQuery")
|
||||||
|
struct FeatureGateFreeQueryConsumptionTests {
|
||||||
|
|
||||||
|
@Test("Max-Abonnent: Gratis-Abfrage wird NICHT verbraucht")
|
||||||
|
func maxUserDoesNotConsumeFreeQuery() {
|
||||||
|
#expect(!FeatureGate.shouldConsumeFreeQuery(isMax: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nicht-Max-Abonnent: Gratis-Abfrage wird verbraucht")
|
||||||
|
func nonMaxUserConsumesQuery() {
|
||||||
|
#expect(FeatureGate.shouldConsumeFreeQuery(isMax: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Paywall-Targeting Tests
|
// MARK: - Paywall-Targeting Tests
|
||||||
|
|
||||||
/// Dokumentiert die Logik aus SettingsView:
|
/// Dokumentiert die Logik aus SettingsView:
|
||||||
@@ -190,3 +349,141 @@ struct PaywallTargetingTests {
|
|||||||
#expect(target(isPro: false) != target(isPro: true))
|
#expect(target(isPro: false) != target(isPro: true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - InterestTagHelper Tests
|
||||||
|
|
||||||
|
@Suite("InterestTagHelper – parse")
|
||||||
|
struct InterestTagHelperParseTests {
|
||||||
|
|
||||||
|
@Test("Leerer String → leeres Array")
|
||||||
|
func emptyStringReturnsEmpty() {
|
||||||
|
#expect(InterestTagHelper.parse("") == [])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Einzelner Tag ohne Komma")
|
||||||
|
func singleTag() {
|
||||||
|
#expect(InterestTagHelper.parse("Fußball") == ["Fußball"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mehrere Tags kommasepariert")
|
||||||
|
func multipleTags() {
|
||||||
|
let result = InterestTagHelper.parse("Fußball, Musik, Lesen")
|
||||||
|
#expect(result == ["Fußball", "Musik", "Lesen"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Whitespace wird getrimmt")
|
||||||
|
func whitespaceTrimmed() {
|
||||||
|
let result = InterestTagHelper.parse(" Kino , Sport ")
|
||||||
|
#expect(result == ["Kino", "Sport"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Leere Segmente werden gefiltert")
|
||||||
|
func emptySegmentsFiltered() {
|
||||||
|
let result = InterestTagHelper.parse("Kino,,Musik,")
|
||||||
|
#expect(result == ["Kino", "Musik"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("InterestTagHelper – join")
|
||||||
|
struct InterestTagHelperJoinTests {
|
||||||
|
|
||||||
|
@Test("Leeres Array → leerer String")
|
||||||
|
func emptyArrayReturnsEmpty() {
|
||||||
|
#expect(InterestTagHelper.join([]) == "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Einzelner Tag bleibt unverändert")
|
||||||
|
func singleTagUnchanged() {
|
||||||
|
#expect(InterestTagHelper.join(["Fußball"]) == "Fußball")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Tags werden alphabetisch sortiert")
|
||||||
|
func tagsSortedAlphabetically() {
|
||||||
|
let result = InterestTagHelper.join(["Musik", "Fußball", "Lesen"])
|
||||||
|
#expect(result == "Fußball, Lesen, Musik")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Sortierung ist case-insensitive")
|
||||||
|
func sortingCaseInsensitive() {
|
||||||
|
let result = InterestTagHelper.join(["bier", "Apfel", "Chips"])
|
||||||
|
#expect(result == "Apfel, bier, Chips")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("InterestTagHelper – addTag")
|
||||||
|
struct InterestTagHelperAddTagTests {
|
||||||
|
|
||||||
|
@Test("Tag zu leerem String hinzufügen")
|
||||||
|
func addToEmpty() {
|
||||||
|
#expect(InterestTagHelper.addTag("Kino", to: "") == "Kino")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Tag zu bestehendem String – alphabetisch einsortiert")
|
||||||
|
func addSortsAlphabetically() {
|
||||||
|
let result = InterestTagHelper.addTag("Musik", to: "Fußball")
|
||||||
|
#expect(result == "Fußball, Musik")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Duplikat wird ignoriert")
|
||||||
|
func duplicateIgnored() {
|
||||||
|
let result = InterestTagHelper.addTag("Kino", to: "Kino, Sport")
|
||||||
|
#expect(result == "Kino, Sport")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Duplikat ignoriert (Groß-/Kleinschreibung)")
|
||||||
|
func duplicateCaseInsensitive() {
|
||||||
|
let result = InterestTagHelper.addTag("kino", to: "Kino")
|
||||||
|
#expect(result == "Kino")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Leerer String wird ignoriert")
|
||||||
|
func emptyTagIgnored() {
|
||||||
|
let result = InterestTagHelper.addTag("", to: "Kino")
|
||||||
|
#expect(result == "Kino")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("InterestTagHelper – removeTag")
|
||||||
|
struct InterestTagHelperRemoveTagTests {
|
||||||
|
|
||||||
|
@Test("Tag aus String entfernen")
|
||||||
|
func removeExistingTag() {
|
||||||
|
let result = InterestTagHelper.removeTag("Musik", from: "Fußball, Musik, Sport")
|
||||||
|
#expect(result == "Fußball, Sport")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nicht vorhandener Tag → unveränderter String")
|
||||||
|
func removeNonExistentTag() {
|
||||||
|
let result = InterestTagHelper.removeTag("Kino", from: "Fußball, Sport")
|
||||||
|
#expect(result == "Fußball, Sport")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Letzten Tag entfernen → leerer String")
|
||||||
|
func removeLastTagReturnsEmpty() {
|
||||||
|
let result = InterestTagHelper.removeTag("Kino", from: "Kino")
|
||||||
|
#expect(result == "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("InterestTagHelper – allSuggestions")
|
||||||
|
struct InterestTagHelperSuggestionsTests {
|
||||||
|
|
||||||
|
@Test("Keine Personen + leere Vorlieben → leere Vorschläge")
|
||||||
|
func emptyInputsReturnEmpty() {
|
||||||
|
let result = InterestTagHelper.allSuggestions(from: [], likes: "", dislikes: "")
|
||||||
|
#expect(result.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Vorschläge aus likes und dislikes kombiniert und sortiert")
|
||||||
|
func combinesLikesAndDislikes() {
|
||||||
|
let result = InterestTagHelper.allSuggestions(from: [], likes: "Musik, Kino", dislikes: "Sport")
|
||||||
|
#expect(result == ["Kino", "Musik", "Sport"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Duplikate werden dedupliziert")
|
||||||
|
func deduplicates() {
|
||||||
|
let result = InterestTagHelper.allSuggestions(from: [], likes: "Kino, Musik", dislikes: "Kino")
|
||||||
|
#expect(!result.contains { _ in result.filter { $0 == "Kino" }.count > 1 })
|
||||||
|
#expect(result.filter { $0 == "Kino" }.count == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import nahbar
|
||||||
|
|
||||||
|
// MARK: - ThemeID Enum Tests
|
||||||
|
|
||||||
|
@Suite("ThemeID – Enum")
|
||||||
|
struct ThemeIDTests {
|
||||||
|
|
||||||
|
@Test("Genau 15 Themes vorhanden")
|
||||||
|
func allCasesCount() {
|
||||||
|
#expect(ThemeID.allCases.count == 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("rawValues sind einzigartig")
|
||||||
|
func rawValuesAreUnique() {
|
||||||
|
let values = ThemeID.allCases.map { $0.rawValue }
|
||||||
|
#expect(Set(values).count == ThemeID.allCases.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("displayNames sind einzigartig")
|
||||||
|
func displayNamesAreUnique() {
|
||||||
|
let names = ThemeID.allCases.map { $0.displayName }
|
||||||
|
#expect(Set(names).count == ThemeID.allCases.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("displayNames sind nicht leer")
|
||||||
|
func displayNamesNotEmpty() {
|
||||||
|
for id in ThemeID.allCases {
|
||||||
|
#expect(!id.displayName.isEmpty, "displayName für \(id.rawValue) ist leer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Genau 5 kostenlose Themes")
|
||||||
|
func freeThemesCount() {
|
||||||
|
let free = ThemeID.allCases.filter { !$0.isPremium }
|
||||||
|
#expect(free.count == 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Kostenlose Themes: linen, slate, mist, chalk, flint")
|
||||||
|
func freeThemeIdentities() {
|
||||||
|
let free = Set(ThemeID.allCases.filter { !$0.isPremium })
|
||||||
|
#expect(free == [.linen, .slate, .mist, .chalk, .flint])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Genau 10 bezahlte Themes")
|
||||||
|
func premiumThemesCount() {
|
||||||
|
let premium = ThemeID.allCases.filter { $0.isPremium }
|
||||||
|
#expect(premium.count == 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isDark: flint, copper, onyx, ember, abyss, dusk, basalt sind dunkel")
|
||||||
|
func darkThemes() {
|
||||||
|
let dark = Set(ThemeID.allCases.filter { $0.isDark })
|
||||||
|
#expect(dark == [.flint, .copper, .onyx, .ember, .abyss, .dusk, .basalt])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isNeurodiverseFocused: nur abyss, dusk, basalt")
|
||||||
|
func ndThemes() {
|
||||||
|
let nd = Set(ThemeID.allCases.filter { $0.isNeurodiverseFocused })
|
||||||
|
#expect(nd == [.abyss, .dusk, .basalt])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ND-Themes sind alle dunkel")
|
||||||
|
func ndThemesAreDark() {
|
||||||
|
for id in ThemeID.allCases where id.isNeurodiverseFocused {
|
||||||
|
#expect(id.isDark, "\(id.rawValue) ist ND aber nicht dunkel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Neue Hochkontrast-Themes sind nicht ND-fokussiert")
|
||||||
|
func highContrastThemesNotND() {
|
||||||
|
let highContrast: [ThemeID] = [.chalk, .flint, .onyx, .ember, .birch, .vapor]
|
||||||
|
for id in highContrast {
|
||||||
|
#expect(!id.isNeurodiverseFocused, "\(id.rawValue) sollte nicht ND-fokussiert sein")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NahbarTheme Token Tests
|
||||||
|
|
||||||
|
@Suite("NahbarTheme – Tokens")
|
||||||
|
struct NahbarThemeTokenTests {
|
||||||
|
|
||||||
|
@Test("theme(for:) gibt für jeden ThemeID ein Theme zurück")
|
||||||
|
func themeForAllIDs() {
|
||||||
|
for id in ThemeID.allCases {
|
||||||
|
let t = NahbarTheme.theme(for: id)
|
||||||
|
#expect(t.id == id, "theme(for: \(id.rawValue)).id stimmt nicht überein")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("sectionHeaderSize ist positiv für alle Themes")
|
||||||
|
func sectionHeaderSizePositive() {
|
||||||
|
for id in ThemeID.allCases {
|
||||||
|
let t = NahbarTheme.theme(for: id)
|
||||||
|
#expect(t.sectionHeaderSize > 0, "sectionHeaderSize für \(id.rawValue) ist nicht positiv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Alle Themes haben sectionHeaderSize 13")
|
||||||
|
func allThemesHeaderSize() {
|
||||||
|
for id in ThemeID.allCases {
|
||||||
|
let t = NahbarTheme.theme(for: id)
|
||||||
|
#expect(t.sectionHeaderSize == 13, "\(id.rawValue) sectionHeaderSize sollte 13 sein")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("radiusCard ist positiv für alle Themes")
|
||||||
|
func radiusCardPositive() {
|
||||||
|
for id in ThemeID.allCases {
|
||||||
|
let t = NahbarTheme.theme(for: id)
|
||||||
|
#expect(t.radiusCard > 0, "radiusCard für \(id.rawValue) ist nicht positiv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("radiusTag ist positiv für alle Themes")
|
||||||
|
func radiusTagPositive() {
|
||||||
|
for id in ThemeID.allCases {
|
||||||
|
let t = NahbarTheme.theme(for: id)
|
||||||
|
#expect(t.radiusTag > 0, "radiusTag für \(id.rawValue) ist nicht positiv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import nahbar
|
||||||
|
|
||||||
|
// MARK: - AutoStart Logic Tests
|
||||||
|
|
||||||
|
/// Helper: builds a coordinator with controlled version and tour list.
|
||||||
|
private func makeAutoCoordinator(
|
||||||
|
tours: [Tour],
|
||||||
|
currentVersion: String,
|
||||||
|
lastSeenVersion: String? = nil
|
||||||
|
) -> (TourCoordinator, TourSeenStore) {
|
||||||
|
let suiteName = "test.autostart.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
let store = TourSeenStore(defaults: defaults)
|
||||||
|
store.lastSeenAppVersion = lastSeenVersion
|
||||||
|
let coordinator = TourCoordinator(tours: tours, seenStore: store, appVersionProvider: { currentVersion })
|
||||||
|
return (coordinator, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a minimal test tour with autoOnUpdate trigger.
|
||||||
|
private func makeUpdateTour(id: TourID, minVersion: String) -> Tour {
|
||||||
|
Tour(
|
||||||
|
id: id,
|
||||||
|
title: "Test Tour",
|
||||||
|
steps: [TourStep(title: "Step", body: "Body")],
|
||||||
|
minAppVersion: minVersion,
|
||||||
|
triggerMode: .autoOnUpdate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a minimal test tour with manualOnly trigger.
|
||||||
|
private func makeManualTour(id: TourID) -> Tour {
|
||||||
|
Tour(
|
||||||
|
id: id,
|
||||||
|
title: "Manual Tour",
|
||||||
|
steps: [TourStep(title: "Step", body: "Body")],
|
||||||
|
minAppVersion: "1.0",
|
||||||
|
triggerMode: .manualOnly
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("AutoStart – checkForPendingTours")
|
||||||
|
struct AutoStartLogicTests {
|
||||||
|
|
||||||
|
@Test("Frische Installation (kein lastSeenVersion): autoOnUpdate-Tour wird gestartet")
|
||||||
|
func freshInstallStartsUpdateTour() {
|
||||||
|
let updateTour = makeUpdateTour(id: .v1_2_besuchsfragebogen, minVersion: "1.2")
|
||||||
|
let (coordinator, _) = makeAutoCoordinator(
|
||||||
|
tours: [updateTour],
|
||||||
|
currentVersion: "1.2",
|
||||||
|
lastSeenVersion: nil
|
||||||
|
)
|
||||||
|
coordinator.checkForPendingTours()
|
||||||
|
#expect(coordinator.activeTour?.id == .v1_2_besuchsfragebogen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Bereits gesehene Tour wird nicht erneut gestartet")
|
||||||
|
func seenTourNotStartedAgain() {
|
||||||
|
let updateTour = makeUpdateTour(id: .v1_2_besuchsfragebogen, minVersion: "1.2")
|
||||||
|
let (coordinator, store) = makeAutoCoordinator(
|
||||||
|
tours: [updateTour],
|
||||||
|
currentVersion: "1.2",
|
||||||
|
lastSeenVersion: nil
|
||||||
|
)
|
||||||
|
store.markSeen(.v1_2_besuchsfragebogen)
|
||||||
|
coordinator.checkForPendingTours()
|
||||||
|
#expect(!coordinator.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Tour mit minVersion == currentVersion wird gestartet (wenn lastSeen kleiner)")
|
||||||
|
func tourWithCurrentVersionStarted() {
|
||||||
|
let updateTour = makeUpdateTour(id: .v1_2_besuchsfragebogen, minVersion: "1.2")
|
||||||
|
let (coordinator, _) = makeAutoCoordinator(
|
||||||
|
tours: [updateTour],
|
||||||
|
currentVersion: "1.2",
|
||||||
|
lastSeenVersion: "1.0"
|
||||||
|
)
|
||||||
|
coordinator.checkForPendingTours()
|
||||||
|
#expect(coordinator.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Tour mit minVersion < lastSeenVersion wird NICHT gestartet")
|
||||||
|
func oldTourNotStartedAfterUpdate() {
|
||||||
|
let updateTour = makeUpdateTour(id: .v1_2_besuchsfragebogen, minVersion: "1.2")
|
||||||
|
let (coordinator, _) = makeAutoCoordinator(
|
||||||
|
tours: [updateTour],
|
||||||
|
currentVersion: "1.3",
|
||||||
|
lastSeenVersion: "1.3"
|
||||||
|
)
|
||||||
|
coordinator.checkForPendingTours()
|
||||||
|
#expect(!coordinator.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("manualOnly-Tour wird von checkForPendingTours NIE gestartet")
|
||||||
|
func manualOnlyTourNeverAutoStarts() {
|
||||||
|
let manualTour = makeManualTour(id: .v1_3_personality)
|
||||||
|
let (coordinator, _) = makeAutoCoordinator(
|
||||||
|
tours: [manualTour],
|
||||||
|
currentVersion: "1.3",
|
||||||
|
lastSeenVersion: nil
|
||||||
|
)
|
||||||
|
coordinator.checkForPendingTours()
|
||||||
|
#expect(!coordinator.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("manualOrFirstLaunch-Tour wird von checkForPendingTours NIE gestartet")
|
||||||
|
func manualOrFirstLaunchTourNeverAutoStarts() {
|
||||||
|
// TourCatalog.onboarding is manualOrFirstLaunch
|
||||||
|
let (coordinator, _) = makeAutoCoordinator(
|
||||||
|
tours: [TourCatalog.onboarding],
|
||||||
|
currentVersion: "1.0",
|
||||||
|
lastSeenVersion: nil
|
||||||
|
)
|
||||||
|
coordinator.checkForPendingTours()
|
||||||
|
#expect(!coordinator.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mehrere pending Tours werden nacheinander abgearbeitet")
|
||||||
|
func multiplePendingToursQueuedSequentially() {
|
||||||
|
let tour1 = makeUpdateTour(id: .v1_2_besuchsfragebogen, minVersion: "1.2")
|
||||||
|
let tour2 = makeUpdateTour(id: .v1_3_personality, minVersion: "1.3")
|
||||||
|
let (coordinator, _) = makeAutoCoordinator(
|
||||||
|
tours: [tour1, tour2],
|
||||||
|
currentVersion: "1.3",
|
||||||
|
lastSeenVersion: "1.0"
|
||||||
|
)
|
||||||
|
coordinator.checkForPendingTours()
|
||||||
|
// First tour should be active
|
||||||
|
#expect(coordinator.activeTour?.id == .v1_2_besuchsfragebogen)
|
||||||
|
// Skip first, second should start
|
||||||
|
coordinator.skip()
|
||||||
|
#expect(coordinator.activeTour?.id == .v1_3_personality)
|
||||||
|
// Skip second, no more tours
|
||||||
|
coordinator.skip()
|
||||||
|
#expect(!coordinator.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nach Abschluss aller Pending-Touren wird lastSeenAppVersion auf currentVersion gesetzt")
|
||||||
|
func lastSeenVersionUpdatedAfterAllToursComplete() {
|
||||||
|
let updateTour = makeUpdateTour(id: .v1_2_besuchsfragebogen, minVersion: "1.2")
|
||||||
|
let (coordinator, store) = makeAutoCoordinator(
|
||||||
|
tours: [updateTour],
|
||||||
|
currentVersion: "1.2",
|
||||||
|
lastSeenVersion: "1.0"
|
||||||
|
)
|
||||||
|
coordinator.checkForPendingTours()
|
||||||
|
coordinator.skip()
|
||||||
|
#expect(store.lastSeenAppVersion == "1.2")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Manuelles start(.onboarding) ist unabhängig von checkForPendingTours")
|
||||||
|
func manualStartIgnoresPendingLogic() {
|
||||||
|
let (coordinator, _) = makeAutoCoordinator(
|
||||||
|
tours: [TourCatalog.onboarding],
|
||||||
|
currentVersion: "1.0",
|
||||||
|
lastSeenVersion: nil
|
||||||
|
)
|
||||||
|
// checkForPendingTours should not start it
|
||||||
|
coordinator.checkForPendingTours()
|
||||||
|
#expect(!coordinator.isActive)
|
||||||
|
// Manual start should work
|
||||||
|
coordinator.start(.onboarding)
|
||||||
|
#expect(coordinator.isActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import nahbar
|
||||||
|
|
||||||
|
// MARK: - TourCatalog Tests
|
||||||
|
|
||||||
|
@Suite("TourCatalog – Validierung")
|
||||||
|
struct TourCatalogTests {
|
||||||
|
|
||||||
|
@Test("Alle Touren haben mindestens 1 und höchstens 7 Steps")
|
||||||
|
func allToursHaveValidStepCount() {
|
||||||
|
for tour in TourCatalog.all {
|
||||||
|
#expect(!tour.steps.isEmpty, "Tour \(tour.id.rawValue) hat keine Steps")
|
||||||
|
#expect(tour.steps.count <= 7, "Tour \(tour.id.rawValue) hat mehr als 7 Steps")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("TourCatalog.tour(for:) gibt für jede bekannte TourID die richtige Tour zurück")
|
||||||
|
func tourLookupByID() {
|
||||||
|
// Only test IDs that are actually in TourCatalog.all
|
||||||
|
for tour in TourCatalog.all {
|
||||||
|
let found = TourCatalog.tour(for: tour.id)
|
||||||
|
#expect(found != nil, "Keine Tour für ID \(tour.id.rawValue) gefunden")
|
||||||
|
#expect(found?.id == tour.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Onboarding-Tour hat genau 7 Steps")
|
||||||
|
func onboardingTourHasSevenSteps() {
|
||||||
|
#expect(TourCatalog.onboarding.steps.count == 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Onboarding-Tour hat triggerMode .manualOrFirstLaunch")
|
||||||
|
func onboardingTourTriggerMode() {
|
||||||
|
#expect(TourCatalog.onboarding.triggerMode == .manualOrFirstLaunch)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Onboarding-Tour hat minAppVersion '1.0'")
|
||||||
|
func onboardingTourMinVersion() {
|
||||||
|
#expect(TourCatalog.onboarding.minAppVersion == "1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("TourCatalog.all enthält mindestens eine Tour")
|
||||||
|
func catalogIsNotEmpty() {
|
||||||
|
#expect(!TourCatalog.all.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Alle Tour-IDs im Catalog sind eindeutig")
|
||||||
|
func catalogIDsAreUnique() {
|
||||||
|
let ids = TourCatalog.all.map { $0.id }
|
||||||
|
let uniqueIDs = Set(ids)
|
||||||
|
#expect(ids.count == uniqueIDs.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Alle Steps einer Tour haben eindeutige IDs")
|
||||||
|
func stepIDsAreUniquePerTour() {
|
||||||
|
for tour in TourCatalog.all {
|
||||||
|
let stepIDs = tour.steps.map { $0.id }
|
||||||
|
let uniqueIDs = Set(stepIDs)
|
||||||
|
#expect(stepIDs.count == uniqueIDs.count, "Tour \(tour.id.rawValue) hat doppelte Step-IDs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TourStep Target Tests
|
||||||
|
|
||||||
|
@Suite("TourCatalog – Step-Targets")
|
||||||
|
struct TourStepTargetTests {
|
||||||
|
|
||||||
|
@Test("Onboarding-Step 4 (Index 3) targetet .addMomentButton")
|
||||||
|
func step4TargetsAddMomentButton() {
|
||||||
|
let steps = TourCatalog.onboarding.steps
|
||||||
|
#expect(steps.count > 3)
|
||||||
|
#expect(steps[3].target == .addMomentButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Onboarding-Step 5 (Index 4) targetet .addTodoButton")
|
||||||
|
func step5TargetsAddTodoButton() {
|
||||||
|
let steps = TourCatalog.onboarding.steps
|
||||||
|
#expect(steps.count > 4)
|
||||||
|
#expect(steps[4].target == .addTodoButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Schritte mit addMomentButton und addTodoButton haben CardPosition .below")
|
||||||
|
func momentAndTodoStepsAreBelow() {
|
||||||
|
let steps = TourCatalog.onboarding.steps
|
||||||
|
let momentStep = steps.first { $0.target == .addMomentButton }
|
||||||
|
let todoStep = steps.first { $0.target == .addTodoButton }
|
||||||
|
#expect(momentStep?.preferredCardPosition == .below)
|
||||||
|
#expect(todoStep?.preferredCardPosition == .below)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Genau ein Step targetet .addMomentButton")
|
||||||
|
func exactlyOneAddMomentStep() {
|
||||||
|
let count = TourCatalog.onboarding.steps.filter { $0.target == .addMomentButton }.count
|
||||||
|
#expect(count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Genau ein Step targetet .addTodoButton")
|
||||||
|
func exactlyOneAddTodoStep() {
|
||||||
|
let count = TourCatalog.onboarding.steps.filter { $0.target == .addTodoButton }.count
|
||||||
|
#expect(count == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tour Model Tests
|
||||||
|
|
||||||
|
@Suite("Tour – Preconditions")
|
||||||
|
struct TourModelTests {
|
||||||
|
|
||||||
|
@Test("Tour mit 6 Steps kann erstellt werden")
|
||||||
|
func tourWithSixStepsIsValid() {
|
||||||
|
let steps = (0..<6).map { _ in
|
||||||
|
TourStep(title: "test", body: "body")
|
||||||
|
}
|
||||||
|
let tour = Tour(
|
||||||
|
id: .onboarding,
|
||||||
|
title: "test",
|
||||||
|
steps: steps,
|
||||||
|
minAppVersion: "1.0",
|
||||||
|
triggerMode: .manualOrFirstLaunch
|
||||||
|
)
|
||||||
|
#expect(tour.steps.count == 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Tour-Gleichheit basiert auf ID")
|
||||||
|
func tourEqualityBasedOnID() {
|
||||||
|
let step = TourStep(title: "t", body: "b")
|
||||||
|
let tourA = Tour(id: .onboarding, title: "A", steps: [step], minAppVersion: "1.0", triggerMode: .manualOrFirstLaunch)
|
||||||
|
let tourB = Tour(id: .onboarding, title: "B", steps: [step], minAppVersion: "1.1", triggerMode: .autoOnUpdate)
|
||||||
|
#expect(tourA == tourB)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import CoreGraphics
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import nahbar
|
||||||
|
|
||||||
|
// MARK: - TourCoordinator Tests
|
||||||
|
|
||||||
|
/// Builds an isolated coordinator with a known tour set and fresh UserDefaults.
|
||||||
|
private func makeCoordinator(
|
||||||
|
tours: [Tour] = [TourCatalog.onboarding],
|
||||||
|
version: String = "1.0"
|
||||||
|
) -> TourCoordinator {
|
||||||
|
let suiteName = "test.coordinator.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
let store = TourSeenStore(defaults: defaults)
|
||||||
|
return TourCoordinator(tours: tours, seenStore: store, appVersionProvider: { version })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("TourCoordinator – Starten & Navigieren")
|
||||||
|
struct TourCoordinatorNavigationTests {
|
||||||
|
|
||||||
|
@Test("start setzt activeTour und currentStepIndex = 0")
|
||||||
|
func startSetsTourAndIndex() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
coordinator.start(.onboarding)
|
||||||
|
#expect(coordinator.activeTour?.id == .onboarding)
|
||||||
|
#expect(coordinator.currentStepIndex == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isActive ist false vor dem Start")
|
||||||
|
func isActiveIsFalseBeforeStart() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
#expect(!coordinator.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isActive ist true nach dem Start")
|
||||||
|
func isActiveIsTrueAfterStart() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
coordinator.start(.onboarding)
|
||||||
|
#expect(coordinator.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("next erhöht currentStepIndex")
|
||||||
|
func nextIncrementsIndex() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
coordinator.start(.onboarding)
|
||||||
|
coordinator.next()
|
||||||
|
#expect(coordinator.currentStepIndex == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("previous verringert currentStepIndex")
|
||||||
|
func previousDecrementsIndex() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
coordinator.start(.onboarding)
|
||||||
|
coordinator.next()
|
||||||
|
coordinator.previous()
|
||||||
|
#expect(coordinator.currentStepIndex == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("previous am ersten Step tut nichts")
|
||||||
|
func previousOnFirstStepDoesNothing() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
coordinator.start(.onboarding)
|
||||||
|
coordinator.previous()
|
||||||
|
#expect(coordinator.currentStepIndex == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("next am letzten Step schließt die Tour")
|
||||||
|
func nextOnLastStepClosesTour() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
coordinator.start(.onboarding)
|
||||||
|
let stepCount = TourCatalog.onboarding.steps.count
|
||||||
|
for _ in 0..<(stepCount - 1) {
|
||||||
|
coordinator.next()
|
||||||
|
}
|
||||||
|
#expect(coordinator.isLastStep)
|
||||||
|
coordinator.next()
|
||||||
|
#expect(!coordinator.isActive)
|
||||||
|
#expect(coordinator.activeTour == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isFirstStep ist true nur bei Index 0")
|
||||||
|
func isFirstStepOnlyAtIndexZero() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
coordinator.start(.onboarding)
|
||||||
|
#expect(coordinator.isFirstStep)
|
||||||
|
coordinator.next()
|
||||||
|
#expect(!coordinator.isFirstStep)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("isLastStep ist true nur am letzten Step")
|
||||||
|
func isLastStepOnlyAtEnd() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
coordinator.start(.onboarding)
|
||||||
|
#expect(!coordinator.isLastStep)
|
||||||
|
let stepCount = TourCatalog.onboarding.steps.count
|
||||||
|
for _ in 0..<(stepCount - 1) {
|
||||||
|
coordinator.next()
|
||||||
|
}
|
||||||
|
#expect(coordinator.isLastStep)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("currentStep gibt den korrekten Step zurück")
|
||||||
|
func currentStepMatchesIndex() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
coordinator.start(.onboarding)
|
||||||
|
let firstStep = coordinator.currentStep
|
||||||
|
#expect(firstStep != nil)
|
||||||
|
#expect(firstStep?.id == TourCatalog.onboarding.steps[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("TourCoordinator – Beenden & Seen-Status")
|
||||||
|
struct TourCoordinatorCompletionTests {
|
||||||
|
|
||||||
|
@Test("skip markiert Tour als gesehen und schließt sie")
|
||||||
|
func skipMarksSeen() {
|
||||||
|
let suiteName = "test.coordinator.skip.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
let store = TourSeenStore(defaults: defaults)
|
||||||
|
let coordinator = TourCoordinator(tours: [TourCatalog.onboarding], seenStore: store, appVersionProvider: { "1.0" })
|
||||||
|
|
||||||
|
coordinator.start(.onboarding)
|
||||||
|
coordinator.skip()
|
||||||
|
|
||||||
|
#expect(!coordinator.isActive)
|
||||||
|
#expect(store.hasSeen(.onboarding))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("close markiert Tour als gesehen und schließt sie")
|
||||||
|
func closeMarksSeen() {
|
||||||
|
let suiteName = "test.coordinator.close.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
let store = TourSeenStore(defaults: defaults)
|
||||||
|
let coordinator = TourCoordinator(tours: [TourCatalog.onboarding], seenStore: store, appVersionProvider: { "1.0" })
|
||||||
|
|
||||||
|
coordinator.start(.onboarding)
|
||||||
|
coordinator.close()
|
||||||
|
|
||||||
|
#expect(!coordinator.isActive)
|
||||||
|
#expect(store.hasSeen(.onboarding))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("onComplete-Callback wird nach Tour-Ende aufgerufen")
|
||||||
|
func onCompleteCalledAfterTourEnds() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
var callbackCalled = false
|
||||||
|
coordinator.start(.onboarding) { callbackCalled = true }
|
||||||
|
coordinator.skip()
|
||||||
|
#expect(callbackCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("onComplete-Callback wird nur einmal aufgerufen")
|
||||||
|
func onCompleteCalledOnce() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
var callCount = 0
|
||||||
|
coordinator.start(.onboarding) { callCount += 1 }
|
||||||
|
coordinator.skip()
|
||||||
|
// Attempting to skip again on an inactive tour does nothing
|
||||||
|
coordinator.skip()
|
||||||
|
#expect(callCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nach Tour-Ende ist currentStepIndex wieder 0")
|
||||||
|
func indexResetAfterCompletion() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
coordinator.start(.onboarding)
|
||||||
|
coordinator.next()
|
||||||
|
coordinator.skip()
|
||||||
|
#expect(coordinator.currentStepIndex == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("TourCoordinator – Target Frames")
|
||||||
|
struct TourCoordinatorTargetFrameTests {
|
||||||
|
|
||||||
|
@Test("updateTargetFrame speichert Frame korrekt")
|
||||||
|
func updateTargetFrameStoresFrame() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
let frame = CGRect(x: 10, y: 20, width: 100, height: 50)
|
||||||
|
coordinator.targetFrames[.addContactButton] = frame
|
||||||
|
#expect(coordinator.targetFrames[.addContactButton] == frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("clearTargetFrame entfernt Frame")
|
||||||
|
func clearTargetFrameRemovesFrame() {
|
||||||
|
let coordinator = makeCoordinator()
|
||||||
|
coordinator.targetFrames[.addContactButton] = CGRect(x: 0, y: 0, width: 44, height: 44)
|
||||||
|
coordinator.targetFrames.removeValue(forKey: .addContactButton)
|
||||||
|
#expect(coordinator.targetFrames[.addContactButton] == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import nahbar
|
||||||
|
|
||||||
|
// MARK: - TourSeenStore Tests
|
||||||
|
|
||||||
|
@Suite("TourSeenStore – Grundfunktionen")
|
||||||
|
struct TourSeenStoreTests {
|
||||||
|
|
||||||
|
/// Creates an isolated UserDefaults for each test to avoid state leakage.
|
||||||
|
private func makeStore() -> TourSeenStore {
|
||||||
|
let suiteName = "test.tour.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
return TourSeenStore(defaults: defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("hasSeen gibt false zurück bevor markSeen aufgerufen wurde")
|
||||||
|
func hasSeenFalseInitially() {
|
||||||
|
let store = makeStore()
|
||||||
|
#expect(!store.hasSeen(.onboarding))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("markSeen setzt hasSeen auf true")
|
||||||
|
func markSeenSetsHasSeen() {
|
||||||
|
let store = makeStore()
|
||||||
|
store.markSeen(.onboarding)
|
||||||
|
#expect(store.hasSeen(.onboarding))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("markSeen für eine ID beeinflusst andere IDs nicht")
|
||||||
|
func markSeenDoesNotAffectOtherIDs() {
|
||||||
|
let store = makeStore()
|
||||||
|
store.markSeen(.onboarding)
|
||||||
|
#expect(!store.hasSeen(.v1_2_besuchsfragebogen))
|
||||||
|
#expect(!store.hasSeen(.v1_3_personality))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mehrfaches markSeen ist idempotent")
|
||||||
|
func markSeenIsIdempotent() {
|
||||||
|
let store = makeStore()
|
||||||
|
store.markSeen(.onboarding)
|
||||||
|
store.markSeen(.onboarding)
|
||||||
|
#expect(store.hasSeen(.onboarding))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("reset setzt hasSeen zurück auf false")
|
||||||
|
func resetClearsSeenIDs() {
|
||||||
|
let store = makeStore()
|
||||||
|
store.markSeen(.onboarding)
|
||||||
|
store.reset()
|
||||||
|
#expect(!store.hasSeen(.onboarding))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("reset löscht auch lastSeenAppVersion")
|
||||||
|
func resetClearsLastSeenVersion() {
|
||||||
|
let store = makeStore()
|
||||||
|
store.lastSeenAppVersion = "1.2"
|
||||||
|
store.reset()
|
||||||
|
#expect(store.lastSeenAppVersion == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("lastSeenAppVersion ist initial nil")
|
||||||
|
func lastSeenVersionIsInitiallyNil() {
|
||||||
|
let store = makeStore()
|
||||||
|
#expect(store.lastSeenAppVersion == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("lastSeenAppVersion wird korrekt geschrieben und gelesen")
|
||||||
|
func lastSeenVersionPersists() {
|
||||||
|
let store = makeStore()
|
||||||
|
store.lastSeenAppVersion = "1.3"
|
||||||
|
#expect(store.lastSeenAppVersion == "1.3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("TourSeenStore – Persistenz")
|
||||||
|
struct TourSeenStorePersistenceTests {
|
||||||
|
|
||||||
|
@Test("markSeen persistiert über Store-Neuinstanzierung")
|
||||||
|
func persistenceAcrossInstances() {
|
||||||
|
let suiteName = "test.tour.persist.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
|
||||||
|
let store1 = TourSeenStore(defaults: defaults)
|
||||||
|
store1.markSeen(.onboarding)
|
||||||
|
|
||||||
|
// New instance, same defaults
|
||||||
|
let store2 = TourSeenStore(defaults: defaults)
|
||||||
|
#expect(store2.hasSeen(.onboarding))
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("lastSeenAppVersion persistiert über Store-Neuinstanzierung")
|
||||||
|
func versionPersistenceAcrossInstances() {
|
||||||
|
let suiteName = "test.tour.version.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
|
||||||
|
let store1 = TourSeenStore(defaults: defaults)
|
||||||
|
store1.lastSeenAppVersion = "2.0"
|
||||||
|
|
||||||
|
let store2 = TourSeenStore(defaults: defaults)
|
||||||
|
#expect(store2.lastSeenAppVersion == "2.0")
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import nahbar
|
||||||
|
|
||||||
|
// MARK: - TourTargetID Tests
|
||||||
|
|
||||||
|
@Suite("TourTargetID – Vollständigkeit")
|
||||||
|
struct TourTargetIDTests {
|
||||||
|
|
||||||
|
@Test("Alle 10 erwarteten Cases sind vorhanden")
|
||||||
|
func allCasesCount() {
|
||||||
|
#expect(TourTargetID.allCases.count == 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Alle rawValues sind nicht leer")
|
||||||
|
func allRawValuesNonEmpty() {
|
||||||
|
for target in TourTargetID.allCases {
|
||||||
|
#expect(!target.rawValue.isEmpty, "rawValue für \(target) ist leer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Alle rawValues sind eindeutig")
|
||||||
|
func allRawValuesUnique() {
|
||||||
|
let rawValues = TourTargetID.allCases.map { $0.rawValue }
|
||||||
|
#expect(Set(rawValues).count == rawValues.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("addMomentButton hat den korrekten rawValue")
|
||||||
|
func addMomentButtonRawValue() {
|
||||||
|
#expect(TourTargetID.addMomentButton.rawValue == "addMomentButton")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("addTodoButton hat den korrekten rawValue")
|
||||||
|
func addTodoButtonRawValue() {
|
||||||
|
#expect(TourTargetID.addTodoButton.rawValue == "addTodoButton")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("addContactButton ist im CaseIterable enthalten")
|
||||||
|
func addContactButtonPresent() {
|
||||||
|
#expect(TourTargetID.allCases.contains(.addContactButton))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("addMomentButton ist im CaseIterable enthalten")
|
||||||
|
func addMomentButtonPresent() {
|
||||||
|
#expect(TourTargetID.allCases.contains(.addMomentButton))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("addTodoButton ist im CaseIterable enthalten")
|
||||||
|
func addTodoButtonPresent() {
|
||||||
|
#expect(TourTargetID.allCases.contains(.addTodoButton))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -186,6 +186,57 @@ struct UserProfileStoreNewFieldsTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Vorname-Extraktion (Begrüßungslogik)
|
||||||
|
|
||||||
|
@Suite("TodayView – Vorname-Extraktion")
|
||||||
|
struct GreetingFirstNameTests {
|
||||||
|
|
||||||
|
// Spiegelt die Logik aus TodayView.greeting wider:
|
||||||
|
// profileStore.name.split(separator: " ").first.map(String.init) ?? ""
|
||||||
|
private func firstName(from name: String) -> String {
|
||||||
|
name.split(separator: " ").first.map(String.init) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Vorname aus vollem Namen")
|
||||||
|
func firstNameFromFullName() {
|
||||||
|
#expect(firstName(from: "Max Mustermann") == "Max")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Vorname aus dreiteiligem Namen")
|
||||||
|
func firstNameFromThreeWordName() {
|
||||||
|
#expect(firstName(from: "Anna Maria Schmidt") == "Anna")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Einzelner Name → dieser selbst als Vorname")
|
||||||
|
func firstNameFromSingleWord() {
|
||||||
|
#expect(firstName(from: "Max") == "Max")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Leerer Name → leerer Vorname")
|
||||||
|
func firstNameFromEmptyString() {
|
||||||
|
#expect(firstName(from: "").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Nur Leerzeichen → leerer Vorname")
|
||||||
|
func firstNameFromWhitespaceOnly() {
|
||||||
|
#expect(firstName(from: " ").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Begrüßung mit Vorname enthält Komma und Punkt")
|
||||||
|
func greetingFormatWithName() {
|
||||||
|
let first = firstName(from: "Max Mustermann")
|
||||||
|
let greeting = "Guten Tag, \(first)."
|
||||||
|
#expect(greeting == "Guten Tag, Max.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Begrüßung ohne Name endet mit Punkt (kein Komma)")
|
||||||
|
func greetingFormatWithoutName() {
|
||||||
|
let first = firstName(from: "")
|
||||||
|
let greeting = first.isEmpty ? "Guten Tag." : "Guten Tag, \(first)."
|
||||||
|
#expect(greeting == "Guten Tag.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Vorlieben-Nudge Anzeigelogik
|
// MARK: - Vorlieben-Nudge Anzeigelogik
|
||||||
|
|
||||||
@Suite("IchView – Vorlieben-Nudge Sichtbarkeit")
|
@Suite("IchView – Vorlieben-Nudge Sichtbarkeit")
|
||||||
|
|||||||
@@ -359,13 +359,13 @@ struct SchemaV5RegressionTests {
|
|||||||
#expect(NahbarSchemaV5.versionIdentifier.patch == 0)
|
#expect(NahbarSchemaV5.versionIdentifier.patch == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Migrationsplan enthält genau 8 Schemas (V1–V8)")
|
@Test("Migrationsplan enthält genau 9 Schemas (V1–V9)")
|
||||||
func migrationPlanHasEightSchemas() {
|
func migrationPlanHasNineSchemas() {
|
||||||
#expect(NahbarMigrationPlan.schemas.count == 8)
|
#expect(NahbarMigrationPlan.schemas.count == 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Migrationsplan enthält genau 7 Stages (V1→V2 bis V7→V8)")
|
@Test("Migrationsplan enthält genau 8 Stages (V1→V2 bis V8→V9)")
|
||||||
func migrationPlanHasSevenStages() {
|
func migrationPlanHasEightStages() {
|
||||||
#expect(NahbarMigrationPlan.stages.count == 7)
|
#expect(NahbarMigrationPlan.stages.count == 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user