Compare commits
41 Commits
9a429f11a6
..
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 | |||
| d541640c74 | |||
| 30b150a286 | |||
| 4a9bb32b5e | |||
| 2b9346c78b | |||
| 66a7b23f5a |
@@ -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() }
|
|
||||||
}
|
|
||||||
if !showSummary {
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") {
|
|
||||||
advance()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fragen-Screen
|
// MARK: - Scrollbarer Fragen-Flow
|
||||||
|
|
||||||
private var questionStep: some View {
|
private var questionFlow: some View {
|
||||||
ZStack {
|
ScrollViewReader { proxy in
|
||||||
RatingQuestionView(
|
ScrollView {
|
||||||
question: questions[currentIndex],
|
VStack(spacing: 14) {
|
||||||
index: currentIndex,
|
ForEach(0..<revealedCount, id: \.self) { i in
|
||||||
|
QuestionCard(
|
||||||
|
question: questions[i],
|
||||||
|
index: i,
|
||||||
total: questions.count,
|
total: questions.count,
|
||||||
value: $values[currentIndex]
|
isActive: i == revealedCount - 1,
|
||||||
|
value: $values[i],
|
||||||
|
onAnswer: { revealNext(after: i) },
|
||||||
|
onSkip: { revealNext(after: i) }
|
||||||
)
|
)
|
||||||
.id(currentIndex)
|
.id(i)
|
||||||
.transition(.asymmetric(
|
.transition(.asymmetric(
|
||||||
insertion: .move(edge: .trailing),
|
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
||||||
removal: .move(edge: .leading)
|
removal: .identity
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
.clipped()
|
|
||||||
|
if revealedCount == questions.count {
|
||||||
|
saveButton
|
||||||
|
.id("save")
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
||||||
|
removal: .identity
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
.background(theme.backgroundPrimary.ignoresSafeArea())
|
||||||
|
.onChange(of: revealedCount) { _, newCount in
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||||
|
withAnimation(.easeOut(duration: 0.4)) {
|
||||||
|
proxy.scrollTo(newCount - 1, anchor: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Navigation & Speichern
|
private var saveButton: some View {
|
||||||
|
Button { saveAftermath() } label: {
|
||||||
|
Text("Speichern")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 15)
|
||||||
|
.background(theme.accent)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func advance() {
|
// MARK: - Navigation
|
||||||
if currentIndex < questions.count - 1 {
|
|
||||||
withAnimation { currentIndex += 1 }
|
private func revealNext(after index: Int) {
|
||||||
} else {
|
guard index == revealedCount - 1 else { return }
|
||||||
saveAftermath()
|
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() }
|
|
||||||
}
|
|
||||||
if !showSummary {
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") {
|
|
||||||
advance()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fragen-Screen
|
// MARK: - Scrollbarer Fragen-Flow
|
||||||
|
|
||||||
private var questionStep: some View {
|
private var questionFlow: some View {
|
||||||
ZStack {
|
ScrollViewReader { proxy in
|
||||||
RatingQuestionView(
|
ScrollView {
|
||||||
question: questions[currentIndex],
|
VStack(spacing: 14) {
|
||||||
index: currentIndex,
|
ForEach(0..<revealedCount, id: \.self) { i in
|
||||||
|
QuestionCard(
|
||||||
|
question: questions[i],
|
||||||
|
index: i,
|
||||||
total: questions.count,
|
total: questions.count,
|
||||||
value: $values[currentIndex]
|
isActive: i == revealedCount - 1,
|
||||||
|
value: $values[i],
|
||||||
|
onAnswer: { revealNext(after: i) },
|
||||||
|
onSkip: { revealNext(after: i) }
|
||||||
)
|
)
|
||||||
.id(currentIndex)
|
.id(i)
|
||||||
.transition(.asymmetric(
|
.transition(.asymmetric(
|
||||||
insertion: .move(edge: .trailing),
|
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
||||||
removal: .move(edge: .leading)
|
removal: .identity
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
.clipped()
|
|
||||||
|
if revealedCount == questions.count {
|
||||||
|
saveButton
|
||||||
|
.id("save")
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
||||||
|
removal: .identity
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
.background(theme.backgroundPrimary.ignoresSafeArea())
|
||||||
|
.onChange(of: revealedCount) { _, newCount in
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||||
|
withAnimation(.easeOut(duration: 0.4)) {
|
||||||
|
proxy.scrollTo(newCount - 1, anchor: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var saveButton: some View {
|
||||||
|
Button { saveRatings() } label: {
|
||||||
|
Text("Speichern")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 15)
|
||||||
|
.background(theme.accent)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
+1181
-250
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,16 +331,18 @@ 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"
|
||||||
|
case todoCompleted = "Todo abgeschlossen"
|
||||||
|
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .nextStep: return "checkmark.circle.fill"
|
case .nextStep: return "checkmark.circle.fill"
|
||||||
case .calendarEvent: return "calendar.badge.checkmark"
|
case .calendarEvent: return "calendar.badge.checkmark"
|
||||||
case .call: return "phone.circle.fill"
|
case .call: return "phone.circle.fill"
|
||||||
|
case .todoCompleted: return "checkmark.square.fill"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +351,7 @@ enum LogEntryType: String, Codable {
|
|||||||
case .nextStep: return "green"
|
case .nextStep: return "green"
|
||||||
case .calendarEvent: return "blue"
|
case .calendarEvent: return "blue"
|
||||||
case .call: return "accent"
|
case .call: return "accent"
|
||||||
|
case .todoCompleted: return "green"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 +127,29 @@ 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 themeID = UserDefaults.standard.string(forKey: "activeThemeID")
|
||||||
|
.flatMap { ThemeID(rawValue: $0) } ?? .linen
|
||||||
|
applyTabBarAppearance(NahbarTheme.theme(for: themeID))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt UIKit-Appearance für das gegebene Theme.
|
||||||
|
/// Auf iOS 26+ übernimmt Liquid Glass die Tab-Bar automatisch –
|
||||||
|
/// eigene UITabBarAppearance-Hintergründe würden es stören.
|
||||||
|
/// Auf iOS 17–25 wird die Tab-Bar explizit in Theme-Farben eingefärbt.
|
||||||
|
static func applyTabBarAppearance(_ theme: NahbarTheme) {
|
||||||
|
let border = UIColor(theme.borderSubtle)
|
||||||
let selected = UIColor(theme.accent)
|
let selected = UIColor(theme.accent)
|
||||||
let border = UIColor(theme.borderSubtle).withAlphaComponent(0.6)
|
let navBg = UIColor(theme.backgroundPrimary)
|
||||||
|
let titleColor = UIColor(theme.contentPrimary)
|
||||||
|
|
||||||
|
// iOS 17–25: Liquid Glass existiert nicht; eigene UITabBarAppearance nötig.
|
||||||
|
// iOS 26+: Block weggelassen – Liquid Glass + .preferredColorScheme übernehmen.
|
||||||
|
if #unavailable(iOS 26) {
|
||||||
|
let bg = UIColor(theme.backgroundPrimary)
|
||||||
|
let normal = UIColor(theme.contentTertiary)
|
||||||
|
|
||||||
let item = UITabBarItemAppearance()
|
let item = UITabBarItemAppearance()
|
||||||
item.normal.iconColor = normal
|
item.normal.iconColor = normal
|
||||||
@@ -104,7 +158,7 @@ struct NahbarApp: App {
|
|||||||
item.selected.titleTextAttributes = [.foregroundColor: selected]
|
item.selected.titleTextAttributes = [.foregroundColor: selected]
|
||||||
|
|
||||||
let tabAppearance = UITabBarAppearance()
|
let tabAppearance = UITabBarAppearance()
|
||||||
tabAppearance.configureWithTransparentBackground()
|
tabAppearance.configureWithOpaqueBackground()
|
||||||
tabAppearance.backgroundColor = bg
|
tabAppearance.backgroundColor = bg
|
||||||
tabAppearance.shadowColor = border
|
tabAppearance.shadowColor = border
|
||||||
tabAppearance.stackedLayoutAppearance = item
|
tabAppearance.stackedLayoutAppearance = item
|
||||||
@@ -113,12 +167,10 @@ struct NahbarApp: App {
|
|||||||
|
|
||||||
UITabBar.appearance().standardAppearance = tabAppearance
|
UITabBar.appearance().standardAppearance = tabAppearance
|
||||||
UITabBar.appearance().scrollEdgeAppearance = tabAppearance
|
UITabBar.appearance().scrollEdgeAppearance = tabAppearance
|
||||||
|
}
|
||||||
let navBg = UIColor(theme.backgroundPrimary).withAlphaComponent(0.92)
|
|
||||||
let titleColor = UIColor(theme.contentPrimary)
|
|
||||||
|
|
||||||
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.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)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Spacer()
|
if person.nudgeStatus != .never {
|
||||||
|
nudgeChip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,76 +450,62 @@ 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
|
||||||
|
|
||||||
|
// Lokaler Hilfstyp für die gemischte Vorschau
|
||||||
|
private struct LogPreviewItem: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let typeLabel: String
|
||||||
|
let date: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mergedLogPreview: [LogPreviewItem] {
|
||||||
|
// 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,
|
||||||
|
typeLabel: $0.type.displayName, date: $0.createdAt)
|
||||||
|
}
|
||||||
|
let entryItems = person.sortedLogEntries.map {
|
||||||
|
LogPreviewItem(id: "e-\($0.id)", icon: $0.type.icon, title: $0.title,
|
||||||
|
typeLabel: $0.type.rawValue, date: $0.loggedAt)
|
||||||
|
}
|
||||||
|
return (momentItems + entryItems).sorted { $0.date > $1.date }
|
||||||
|
}
|
||||||
|
|
||||||
private var logbuchSection: some View {
|
private var logbuchSection: some View {
|
||||||
let entries = person.sortedLogEntries
|
let allItems = mergedLogPreview
|
||||||
let preview = Array(entries.prefix(logbuchPreviewLimit))
|
let preview = Array(allItems.prefix(logbuchPreviewLimit))
|
||||||
let hasMore = entries.count > logbuchPreviewLimit
|
let hasMore = allItems.count > logbuchPreviewLimit
|
||||||
|
|
||||||
return VStack(alignment: .leading, spacing: 10) {
|
return VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
SectionHeader(title: "Verlauf", icon: "book.closed")
|
SectionHeader(title: "Verlauf", icon: "clock.arrow.circlepath")
|
||||||
Spacer()
|
Spacer()
|
||||||
|
NavigationLink(destination: LogbuchView(person: person)) {
|
||||||
|
Text("Alle")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(Array(preview.enumerated()), id: \.element.id) { index, entry in
|
ForEach(Array(preview.enumerated()), id: \.element.id) { index, item in
|
||||||
logEntryPreviewRow(entry)
|
logPreviewRow(item)
|
||||||
if index < preview.count - 1 || hasMore { RowDivider() }
|
if index < preview.count - 1 || hasMore { RowDivider() }
|
||||||
}
|
}
|
||||||
if hasMore {
|
if hasMore {
|
||||||
NavigationLink(destination: LogbuchView(person: person)) {
|
NavigationLink(destination: LogbuchView(person: person)) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Alle \(entries.count) Einträge anzeigen")
|
Text("Alle \(allItems.count) Einträge anzeigen")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundStyle(theme.accent)
|
.foregroundStyle(theme.accent)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -301,27 +523,28 @@ struct PersonDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func logEntryPreviewRow(_ entry: LogEntry) -> some View {
|
private func logPreviewRow(_ item: LogPreviewItem) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: entry.type.icon)
|
Image(systemName: item.icon)
|
||||||
.font(.system(size: 14, weight: .light))
|
.font(.system(size: 14, weight: .light))
|
||||||
.foregroundStyle(theme.accent)
|
.foregroundStyle(theme.accent)
|
||||||
.frame(width: 20)
|
.frame(width: 20)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(entry.title)
|
Text(item.title)
|
||||||
.font(.system(size: 15, design: theme.displayDesign))
|
.font(.system(size: 15, design: theme.displayDesign))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
.lineLimit(2)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(LocalizedStringKey(entry.type.rawValue))
|
Text(LocalizedStringKey(item.typeLabel))
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.contentTertiary)
|
||||||
Text("·")
|
Text("·")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.contentTertiary)
|
||||||
Text(entry.loggedAt.formatted(.dateTime.day().month(.abbreviated).year()))
|
Text(item.date.formatted(.dateTime.day().month(.abbreviated).year()))
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.contentTertiary)
|
||||||
}
|
}
|
||||||
@@ -360,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 {
|
||||||
@@ -435,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 {
|
||||||
@@ -472,6 +696,11 @@ struct PersonDetailView: View {
|
|||||||
UNUserNotificationCenter.current()
|
UNUserNotificationCenter.current()
|
||||||
.removePendingNotificationRequests(withIdentifiers: ["todo-\(todo.id)"])
|
.removePendingNotificationRequests(withIdentifiers: ["todo-\(todo.id)"])
|
||||||
|
|
||||||
|
// Logbuch-Eintrag erstellen
|
||||||
|
let entry = LogEntry(type: .todoCompleted, title: todo.title, person: person)
|
||||||
|
modelContext.insert(entry)
|
||||||
|
person.logEntries?.append(entry)
|
||||||
|
|
||||||
// Nach 5 Sek. sanft ausblenden
|
// Nach 5 Sek. sanft ausblenden
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
||||||
withAnimation(.easeOut(duration: 0.35)) {
|
withAnimation(.easeOut(duration: 0.35)) {
|
||||||
@@ -1215,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
|
||||||
)
|
)
|
||||||
@@ -1230,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)
|
||||||
}
|
}
|
||||||
|
|||||||
+430
-418
@@ -21,20 +21,13 @@ struct SettingsView: View {
|
|||||||
@AppStorage("aiModel") private var aiModel: String = AIConfig.fallback.model
|
@AppStorage("aiModel") private var aiModel: String = AIConfig.fallback.model
|
||||||
@StateObject private var store = StoreManager.shared
|
@StateObject private var store = StoreManager.shared
|
||||||
@StateObject private var personalityStore = PersonalityStore.shared
|
@StateObject private var personalityStore = PersonalityStore.shared
|
||||||
@Environment(\.modelContext) private var modelContext
|
|
||||||
@State private var showingPINSetup = false
|
@State private var showingPINSetup = false
|
||||||
@State private var showingPINDisable = false
|
@State private var showingPINDisable = false
|
||||||
@State private var showPaywall = false
|
@State private var showPaywall = false
|
||||||
@State private var showingResetConfirmation = false
|
|
||||||
@State private var showingQuiz = false
|
@State private var showingQuiz = false
|
||||||
@AppStorage("hasSkippedPersonalityQuiz") private var hasSkippedPersonalityQuiz = false
|
@AppStorage("hasSkippedPersonalityQuiz") private var hasSkippedPersonalityQuiz = false
|
||||||
|
|
||||||
// Onboarding-Flags zum Zurücksetzen
|
|
||||||
@AppStorage("nahbarOnboardingDone") private var nahbarOnboardingDone = false
|
|
||||||
@AppStorage("callWindowOnboardingDone") private var callWindowOnboardingDone = false
|
|
||||||
@AppStorage("photoRepairPassDone") private var photoRepairPassDone = false
|
|
||||||
@AppStorage("callSuggestionDate") private var callSuggestionDate = ""
|
|
||||||
|
|
||||||
private var biometricLabel: String {
|
private var biometricLabel: String {
|
||||||
switch appLockManager.biometricType {
|
switch appLockManager.biometricType {
|
||||||
case .faceID: return String(localized: "Face ID aktiviert")
|
case .faceID: return String(localized: "Face ID aktiviert")
|
||||||
@@ -54,71 +47,123 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
// Header
|
// Header
|
||||||
Text("Einstellungen")
|
Text("Einstellungen")
|
||||||
.font(.system(size: 34, weight: .light, design: theme.displayDesign))
|
.font(.system(size: 32, weight: .light, design: theme.displayDesign))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|
||||||
// Abonnement (oben)
|
abonnementSection
|
||||||
|
darstellungSection
|
||||||
|
funktionenSection
|
||||||
|
systemSection
|
||||||
|
|
||||||
|
// Entwickler (versteckt, nur für Entwickler)
|
||||||
|
NavigationLink(destination: DeveloperSettingsView()) {
|
||||||
|
Text("Entwickler")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
}
|
||||||
|
.background(theme.backgroundPrimary.ignoresSafeArea())
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingPINSetup, onDismiss: { appLockManager.refreshBiometricType() }) {
|
||||||
|
AppLockSetupView(isDisabling: false).environmentObject(appLockManager)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingPINDisable) {
|
||||||
|
AppLockSetupView(isDisabling: true).environmentObject(appLockManager)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showPaywall) {
|
||||||
|
PaywallView(targeting: store.isPro ? .max : .pro)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingQuiz) {
|
||||||
|
PersonalityQuizView { _ in }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 1 · Abonnement (hervorgehoben)
|
||||||
|
|
||||||
|
private var abonnementSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
SectionHeader(title: "Abonnement", icon: "star.fill")
|
SectionHeader(title: "Abonnement", icon: "star.fill")
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
if store.isMax {
|
if store.isMax {
|
||||||
HStack {
|
HStack(spacing: 14) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Image(systemName: "checkmark.seal.fill")
|
||||||
|
.font(.system(size: 26))
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text("Max aktiv")
|
Text("Max aktiv")
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15, weight: .semibold))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
Text("Alle Features freigeschaltet")
|
Text("Alle Features freigeschaltet")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.contentTertiary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "checkmark.seal.fill")
|
|
||||||
.foregroundStyle(theme.accent)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 16)
|
||||||
.background(theme.surfaceCard)
|
.background(
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
RoundedRectangle(cornerRadius: theme.radiusCard)
|
||||||
|
.fill(theme.accent.opacity(0.07))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: theme.radiusCard)
|
||||||
|
.stroke(theme.accent.opacity(0.22), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
} else {
|
} else {
|
||||||
Button { showPaywall = true } label: {
|
Button { showPaywall = true } label: {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Pro oder Max-Abo")
|
Text(store.isPro ? "Auf Max upgraden" : "nahbar Pro oder Max")
|
||||||
.font(.system(size: 15, weight: .medium))
|
.font(.system(size: 15, weight: .semibold))
|
||||||
.foregroundStyle(theme.accent)
|
.foregroundStyle(theme.accent)
|
||||||
Text(store.isPro
|
Text(store.isPro
|
||||||
? "Auf Max upgraden – KI-Analyse freischalten"
|
? "KI Insights freischalten"
|
||||||
: "KI-Analyse, Themes & mehr")
|
: "KI Insights, Themes & mehr")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.contentTertiary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.accent.opacity(0.5))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 16)
|
||||||
.background(theme.surfaceCard)
|
.background(
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
RoundedRectangle(cornerRadius: theme.radiusCard)
|
||||||
|
.fill(theme.accent.opacity(0.07))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: theme.radiusCard)
|
||||||
|
.stroke(theme.accent.opacity(0.22), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showPaywall) { PaywallView(targeting: store.isPro ? .max : .pro) }
|
}
|
||||||
|
|
||||||
// Theme picker
|
// MARK: - 2 · Darstellung & Profil
|
||||||
|
|
||||||
|
private var darstellungSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
SectionHeader(title: "Atmosphäre", icon: "paintpalette")
|
SectionHeader(title: "Darstellung & Profil", icon: "paintpalette")
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Theme
|
||||||
NavigationLink(destination: ThemePickerView()) {
|
NavigationLink(destination: ThemePickerView()) {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
// Swatch
|
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: 8)
|
||||||
.fill(NahbarTheme.theme(for: activeThemeID).backgroundPrimary)
|
.fill(NahbarTheme.theme(for: activeThemeID).backgroundPrimary)
|
||||||
@@ -147,18 +192,235 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
RowDivider()
|
||||||
|
|
||||||
|
// Persönlichkeit
|
||||||
|
if let profile = personalityStore.profile, profile.isComplete {
|
||||||
|
let days = PersonalityEngine.suggestedNudgeInterval(for: profile)
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Persönlichkeit")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
Text("Nudge alle \(days) Tage · Quiz abgeschlossen")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
personalityStore.reset()
|
||||||
|
hasSkippedPersonalityQuiz = false
|
||||||
|
} label: {
|
||||||
|
Text("Zurücksetzen")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
hasSkippedPersonalityQuiz = false
|
||||||
|
showingQuiz = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Persönlichkeitsquiz")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.background(theme.surfaceCard)
|
.background(theme.surfaceCard)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// App-Schutz
|
// MARK: - 3 · Funktionen
|
||||||
|
|
||||||
|
private var funktionenSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
SectionHeader(title: "App-Schutz", icon: "lock")
|
SectionHeader(title: "Funktionen", icon: "slider.horizontal.3")
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
// Gesprächszeit
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Gesprächszeit")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
Text("Tägliche Erinnerung für Anrufe")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Toggle("", isOn: $callWindowManager.isEnabled)
|
||||||
|
.tint(theme.accent)
|
||||||
|
.onChange(of: callWindowManager.isEnabled) { _, enabled in
|
||||||
|
if enabled { callWindowManager.scheduleNotifications() }
|
||||||
|
else { callWindowManager.cancelNotifications() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
|
if callWindowManager.isEnabled {
|
||||||
|
RowDivider()
|
||||||
|
NavigationLink {
|
||||||
|
CallWindowSetupView(manager: callWindowManager, isOnboarding: false, onDone: {})
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Zeitfenster")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
Spacer()
|
||||||
|
Text(callWindowManager.windowDescription)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowDivider()
|
||||||
|
|
||||||
|
// Kalender
|
||||||
|
HStack {
|
||||||
|
Text("Termine & Geburtstage")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: $daysAhead) {
|
||||||
|
Text("3 Tage").tag(3)
|
||||||
|
Text("1 Woche").tag(7)
|
||||||
|
Text("2 Wochen").tag(14)
|
||||||
|
Text("1 Monat").tag(30)
|
||||||
|
}
|
||||||
|
.tint(theme.accent)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
if settingsCalendars.count > 1 {
|
||||||
|
RowDivider()
|
||||||
|
HStack {
|
||||||
|
Text("Kalender")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: $defaultCalendarID) {
|
||||||
|
ForEach(settingsCalendars, id: \.calendarIdentifier) { cal in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "circle.fill")
|
||||||
|
.foregroundStyle(Color(cal.cgColor))
|
||||||
|
Text(cal.title)
|
||||||
|
}
|
||||||
|
.tag(cal.calendarIdentifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(theme.accent)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
RowDivider()
|
||||||
|
|
||||||
|
// Treffen
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Nachwirkungs-Erinnerung")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
Text("Push nach dem Treffen")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Toggle("", isOn: $aftermathNotificationsEnabled)
|
||||||
|
.tint(theme.accent)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
|
if aftermathNotificationsEnabled {
|
||||||
|
RowDivider()
|
||||||
|
HStack {
|
||||||
|
Text("Verzögerung")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: $aftermathDelayRaw) {
|
||||||
|
ForEach(AftermathDelayOption.allCases, id: \.rawValue) { opt in
|
||||||
|
Text(opt.label).tag(opt.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(theme.accent)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
RowDivider()
|
||||||
|
|
||||||
|
// KI Modell
|
||||||
|
HStack {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("KI Modell")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
MaxBadge()
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
TextField(AIConfig.fallback.model, text: $aiModel)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(theme.contentSecondary)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(maxWidth: 180)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.background(theme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.task {
|
||||||
|
guard settingsCalendars.isEmpty else { return }
|
||||||
|
guard CalendarManager.shared.isAuthorized else { return }
|
||||||
|
let calendars = await CalendarManager.shared.availableCalendars()
|
||||||
|
settingsCalendars = calendars
|
||||||
|
if defaultCalendarID.isEmpty || !calendars.map(\.calendarIdentifier).contains(defaultCalendarID) {
|
||||||
|
defaultCalendarID = CalendarManager.shared.defaultCalendarIdentifier ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 4 · System
|
||||||
|
|
||||||
|
private var systemSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
SectionHeader(title: "System", icon: "gear")
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// App-Schutz
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Code-Schutz")
|
Text("Code-Schutz")
|
||||||
@@ -199,209 +461,18 @@ struct SettingsView: View {
|
|||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.background(theme.surfaceCard)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingPINSetup, onDismiss: { appLockManager.refreshBiometricType() }) {
|
|
||||||
AppLockSetupView(isDisabling: false)
|
|
||||||
.environmentObject(appLockManager)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingPINDisable) {
|
|
||||||
AppLockSetupView(isDisabling: true)
|
|
||||||
.environmentObject(appLockManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gesprächszeit
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
SectionHeader(title: "Gesprächszeit", icon: "phone.arrow.up.right")
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
HStack {
|
|
||||||
Text("Aktiv")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(theme.contentPrimary)
|
|
||||||
Spacer()
|
|
||||||
Toggle("", isOn: $callWindowManager.isEnabled)
|
|
||||||
.tint(theme.accent)
|
|
||||||
.onChange(of: callWindowManager.isEnabled) { _, enabled in
|
|
||||||
if enabled {
|
|
||||||
callWindowManager.scheduleNotifications()
|
|
||||||
} else {
|
|
||||||
callWindowManager.cancelNotifications()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
|
|
||||||
if callWindowManager.isEnabled {
|
|
||||||
RowDivider()
|
RowDivider()
|
||||||
NavigationLink {
|
|
||||||
CallWindowSetupView(
|
|
||||||
manager: callWindowManager,
|
|
||||||
isOnboarding: false,
|
|
||||||
onDone: {}
|
|
||||||
)
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text("Zeitfenster")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(theme.contentPrimary)
|
|
||||||
Spacer()
|
|
||||||
Text(callWindowManager.windowDescription)
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.system(size: 12, weight: .medium))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(theme.surfaceCard)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kalender-Einstellungen
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
SectionHeader(title: "Kalender-Einstellungen", icon: "calendar")
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
HStack {
|
|
||||||
Text("Vorschau Geburtstage & Termine")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(theme.contentPrimary)
|
|
||||||
Spacer()
|
|
||||||
Picker("", selection: $daysAhead) {
|
|
||||||
Text("3 Tage").tag(3)
|
|
||||||
Text("1 Woche").tag(7)
|
|
||||||
Text("2 Wochen").tag(14)
|
|
||||||
Text("1 Monat").tag(30)
|
|
||||||
}
|
|
||||||
.tint(theme.accent)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
|
|
||||||
if settingsCalendars.count > 1 {
|
|
||||||
RowDivider()
|
|
||||||
HStack {
|
|
||||||
Text("Kalender")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(theme.contentPrimary)
|
|
||||||
Spacer()
|
|
||||||
Picker("", selection: $defaultCalendarID) {
|
|
||||||
ForEach(settingsCalendars, id: \.calendarIdentifier) { cal in
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "circle.fill")
|
|
||||||
.foregroundStyle(Color(cal.cgColor))
|
|
||||||
Text(cal.title)
|
|
||||||
}
|
|
||||||
.tag(cal.calendarIdentifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.tint(theme.accent)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(theme.surfaceCard)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.task {
|
|
||||||
guard settingsCalendars.isEmpty else { return }
|
|
||||||
let calendars = await CalendarManager.shared.availableCalendars()
|
|
||||||
settingsCalendars = calendars
|
|
||||||
if defaultCalendarID.isEmpty || !calendars.map(\.calendarIdentifier).contains(defaultCalendarID) {
|
|
||||||
defaultCalendarID = CalendarManager.shared.defaultCalendarIdentifier ?? ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Treffen & Bewertungen
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
SectionHeader(title: "Treffen", icon: "star.fill")
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
HStack {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Nachwirkungs-Erinnerung")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(theme.contentPrimary)
|
|
||||||
Text("Push-Benachrichtigung nach dem Besuch")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Toggle("", isOn: $aftermathNotificationsEnabled)
|
|
||||||
.tint(theme.accent)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
|
|
||||||
if aftermathNotificationsEnabled {
|
|
||||||
RowDivider()
|
|
||||||
HStack {
|
|
||||||
Text("Verzögerung")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(theme.contentPrimary)
|
|
||||||
Spacer()
|
|
||||||
Picker("", selection: $aftermathDelayRaw) {
|
|
||||||
ForEach(AftermathDelayOption.allCases, id: \.rawValue) { opt in
|
|
||||||
Text(opt.label).tag(opt.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.tint(theme.accent)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(theme.surfaceCard)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
}
|
|
||||||
|
|
||||||
// KI-Einstellungen
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
SectionHeader(title: "KI-Analyse", icon: "sparkles")
|
|
||||||
MaxBadge()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
settingsTextField(label: "Modell", value: $aiModel, placeholder: AIConfig.fallback.model)
|
|
||||||
}
|
|
||||||
.background(theme.surfaceCard)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
}
|
|
||||||
|
|
||||||
// iCloud
|
// iCloud
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
SectionHeader(title: "iCloud", icon: "icloud")
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Toggle
|
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("iCloud-Sync")
|
Text("iCloud-Sync")
|
||||||
.font(.system(size: 15))
|
.font(.system(size: 15))
|
||||||
.foregroundStyle(theme.contentPrimary)
|
.foregroundStyle(theme.contentPrimary)
|
||||||
Text(icloudSyncEnabled
|
Text(icloudSyncEnabled
|
||||||
? "Daten werden geräteübergreifend synchronisiert"
|
? "Geräteübergreifend synchronisiert"
|
||||||
: "Daten werden nur lokal gespeichert")
|
: "Nur lokal gespeichert")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(theme.contentTertiary)
|
.foregroundStyle(theme.contentTertiary)
|
||||||
}
|
}
|
||||||
@@ -418,7 +489,6 @@ struct SettingsView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
// Live-Sync-Status (nur wenn aktiviert)
|
|
||||||
if icloudSyncEnabled {
|
if icloudSyncEnabled {
|
||||||
RowDivider()
|
RowDivider()
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
@@ -435,7 +505,6 @@ struct SettingsView: View {
|
|||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neustart-Banner wenn Toggle verändert wurde
|
|
||||||
if icloudToggleChanged {
|
if icloudToggleChanged {
|
||||||
RowDivider()
|
RowDivider()
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
@@ -446,15 +515,20 @@ struct SettingsView: View {
|
|||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundStyle(theme.contentSecondary)
|
.foregroundStyle(theme.contentSecondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Jetzt") {
|
Button("Jetzt") { exit(0) }
|
||||||
exit(0)
|
|
||||||
}
|
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
.foregroundStyle(theme.accent)
|
.foregroundStyle(theme.accent)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RowDivider()
|
||||||
|
|
||||||
|
// Über nahbar
|
||||||
|
SettingsInfoRow(label: "Version", value: "1.0 Draft")
|
||||||
|
RowDivider()
|
||||||
|
SettingsInfoRow(label: "Datenschutz", value: "Deine Daten verlassen nicht dein Gerät")
|
||||||
}
|
}
|
||||||
.background(theme.surfaceCard)
|
.background(theme.surfaceCard)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
@@ -463,192 +537,6 @@ struct SettingsView: View {
|
|||||||
.animation(.easeInOut(duration: 0.2), value: icloudToggleChanged)
|
.animation(.easeInOut(duration: 0.2), value: icloudToggleChanged)
|
||||||
.animation(.easeInOut(duration: 0.2), value: cloudSyncMonitor.state == .syncing)
|
.animation(.easeInOut(duration: 0.2), value: cloudSyncMonitor.state == .syncing)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persönlichkeit
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
SectionHeader(title: "Persönlichkeit", icon: "brain")
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
if let profile = personalityStore.profile, profile.isComplete {
|
|
||||||
// Empfohlenes Intervall
|
|
||||||
let days = PersonalityEngine.suggestedNudgeInterval(for: profile)
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Empfohlenes Nudge-Intervall")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(theme.contentPrimary)
|
|
||||||
Text("Alle \(days) Tage – basierend auf deinem Profil")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
RecommendedBadge(variant: .small)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
|
|
||||||
RowDivider()
|
|
||||||
|
|
||||||
// Quiz zurücksetzen
|
|
||||||
Button {
|
|
||||||
personalityStore.reset()
|
|
||||||
hasSkippedPersonalityQuiz = false
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text("Quiz zurücksetzen")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(theme.contentSecondary)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
} else {
|
|
||||||
Button {
|
|
||||||
hasSkippedPersonalityQuiz = false
|
|
||||||
showingQuiz = true
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text("Persönlichkeitsquiz starten")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(theme.accent)
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.system(size: 12, weight: .medium))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(theme.surfaceCard)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showingQuiz) {
|
|
||||||
PersonalityQuizView { _ in }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diagnose / Entwickler-Tools
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
SectionHeader(title: "Diagnose", icon: "list.bullet.rectangle")
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
|
|
||||||
// App zurücksetzen
|
|
||||||
Button {
|
|
||||||
showingResetConfirmation = true
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 14) {
|
|
||||||
Image(systemName: "arrow.counterclockwise")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
.frame(width: 22)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("App zurücksetzen")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
Text("Onboarding, Profil und alle Daten löschen")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(theme.surfaceCard)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink(destination: LogExportView()) {
|
|
||||||
HStack(spacing: 14) {
|
|
||||||
Image(systemName: "doc.text")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(theme.contentTertiary)
|
|
||||||
.frame(width: 22)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Entwickler-Log")
|
|
||||||
.font(.system(size: 15))
|
|
||||||
.foregroundStyle(theme.contentPrimary)
|
|
||||||
Text("\(AppEventLog.shared.entries.count) Einträge – Export als Textdatei")
|
|
||||||
.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, 12)
|
|
||||||
.background(theme.surfaceCard)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// About
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
SectionHeader(title: "Über nahbar", icon: "info.circle")
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
SettingsInfoRow(label: "Version", value: "1.0 Draft")
|
|
||||||
RowDivider()
|
|
||||||
SettingsInfoRow(label: "Datenschutz", value: "Deine Daten verlassen nicht dein Gerät")
|
|
||||||
}
|
|
||||||
.background(theme.surfaceCard)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.bottom, 40)
|
|
||||||
}
|
|
||||||
.background(theme.backgroundPrimary.ignoresSafeArea())
|
|
||||||
.navigationBarHidden(true)
|
|
||||||
}
|
|
||||||
.confirmationDialog(
|
|
||||||
"App wirklich zurücksetzen?",
|
|
||||||
isPresented: $showingResetConfirmation,
|
|
||||||
titleVisibility: .visible
|
|
||||||
) {
|
|
||||||
Button("Alles löschen und Onboarding starten", role: .destructive) {
|
|
||||||
resetApp()
|
|
||||||
}
|
|
||||||
Button("Abbrechen", role: .cancel) {}
|
|
||||||
} message: {
|
|
||||||
Text("Alle Personen, Momente, Besuche und dein Profil werden unwiderruflich gelöscht. Die App startet neu.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - App Reset (Entwickler-Tool)
|
|
||||||
|
|
||||||
private func resetApp() {
|
|
||||||
// 1. SwiftData: alle Objekte löschen
|
|
||||||
try? modelContext.delete(model: Person.self)
|
|
||||||
try? modelContext.delete(model: Moment.self)
|
|
||||||
try? modelContext.delete(model: LogEntry.self)
|
|
||||||
try? modelContext.delete(model: Visit.self)
|
|
||||||
try? modelContext.delete(model: Rating.self)
|
|
||||||
try? modelContext.delete(model: HealthSnapshot.self)
|
|
||||||
try? modelContext.delete(model: PersonPhoto.self)
|
|
||||||
|
|
||||||
// 2. Profil und Kontakte löschen
|
|
||||||
UserProfileStore.shared.reset()
|
|
||||||
ContactStore.shared.reset()
|
|
||||||
|
|
||||||
// 3. Onboarding- und Migrations-Flags zurücksetzen
|
|
||||||
nahbarOnboardingDone = false
|
|
||||||
callWindowOnboardingDone = false
|
|
||||||
photoRepairPassDone = false
|
|
||||||
callSuggestionDate = ""
|
|
||||||
UserDefaults.standard.removeObject(forKey: "visitMigrationPassDone")
|
|
||||||
UserDefaults.standard.removeObject(forKey: "nextStepMigrationPassDone")
|
|
||||||
|
|
||||||
// 4. App neu starten damit alle States frisch initialisiert werden
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { exit(0) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,6 +561,121 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Developer Settings View
|
||||||
|
|
||||||
|
private struct DeveloperSettingsView: View {
|
||||||
|
@Environment(\.nahbarTheme) var theme
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(TourCoordinator.self) private var tourCoordinator
|
||||||
|
@StateObject private var personalityStore = PersonalityStore.shared
|
||||||
|
|
||||||
|
@AppStorage("nahbarOnboardingDone") private var nahbarOnboardingDone = false
|
||||||
|
@AppStorage("callWindowOnboardingDone") private var callWindowOnboardingDone = false
|
||||||
|
@AppStorage("photoRepairPassDone") private var photoRepairPassDone = false
|
||||||
|
@AppStorage("callSuggestionDate") private var callSuggestionDate = ""
|
||||||
|
@AppStorage("hasSkippedPersonalityQuiz") private var hasSkippedPersonalityQuiz = false
|
||||||
|
|
||||||
|
@State private var showingResetConfirmation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// App zurücksetzen
|
||||||
|
Button { showingResetConfirmation = true } label: {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "arrow.counterclockwise")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.frame(width: 22)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("App zurücksetzen")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Text("Onboarding, Profil und alle Daten löschen")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
RowDivider()
|
||||||
|
|
||||||
|
// Entwickler-Log
|
||||||
|
NavigationLink(destination: LogExportView()) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "doc.text")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
.frame(width: 22)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Entwickler-Log")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
Text("\(AppEventLog.shared.entries.count) Einträge – Export als Textdatei")
|
||||||
|
.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, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(theme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
}
|
||||||
|
.background(theme.backgroundPrimary.ignoresSafeArea())
|
||||||
|
.navigationTitle("Entwickler")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.themedNavBar()
|
||||||
|
.confirmationDialog(
|
||||||
|
"App wirklich zurücksetzen?",
|
||||||
|
isPresented: $showingResetConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Alles löschen und Onboarding starten", role: .destructive) { resetApp() }
|
||||||
|
Button("Abbrechen", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Alle Personen, Momente, Besuche und dein Profil werden unwiderruflich gelöscht. Die App startet neu.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetApp() {
|
||||||
|
try? modelContext.delete(model: Person.self)
|
||||||
|
try? modelContext.delete(model: Moment.self)
|
||||||
|
try? modelContext.delete(model: LogEntry.self)
|
||||||
|
try? modelContext.delete(model: Visit.self)
|
||||||
|
try? modelContext.delete(model: Rating.self)
|
||||||
|
try? modelContext.delete(model: HealthSnapshot.self)
|
||||||
|
try? modelContext.delete(model: PersonPhoto.self)
|
||||||
|
|
||||||
|
UserProfileStore.shared.reset()
|
||||||
|
ContactStore.shared.reset()
|
||||||
|
|
||||||
|
nahbarOnboardingDone = false
|
||||||
|
callWindowOnboardingDone = false
|
||||||
|
photoRepairPassDone = false
|
||||||
|
callSuggestionDate = ""
|
||||||
|
hasSkippedPersonalityQuiz = false
|
||||||
|
UserDefaults.standard.removeObject(forKey: "visitMigrationPassDone")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "nextStepMigrationPassDone")
|
||||||
|
tourCoordinator.resetSeenTours()
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { exit(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Theme Option Row
|
// MARK: - Theme Option Row
|
||||||
|
|
||||||
struct ThemeOptionRow: View {
|
struct ThemeOptionRow: View {
|
||||||
@@ -803,11 +806,20 @@ enum AppLanguage: String, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var conversationInstruction: String {
|
||||||
|
switch self {
|
||||||
|
case .german:
|
||||||
|
return "Du bereitest mich auf ein bevorstehendes Treffen mit dieser Person vor. Halte dich STRIKT an die vorhandenen Momente und Log-Einträge. Erfinde KEINE Details, Erlebnisse oder Themen die nicht explizit in den Daten stehen. Gib mir sehr knappe Vorschläge – maximal 8 Wörter pro Punkt, nur Stichwörter oder kurze Fragen. Antworte in exakt diesem Format:\n\nTHEMEN: [2-3 Stichworte oder kurze Fragen aus den echten Daten, kommasepariert]\nGESPRAECHSRETTER: [2-3 kurze Impulse, je max. 8 Wörter, kommasepariert]\nTIEFE: [ein konkreter Tipp, max. 12 Wörter]"
|
||||||
|
case .english:
|
||||||
|
return "You are preparing me for an upcoming meeting with this person. Stick STRICTLY to the available moments and log entries. Do NOT invent details, experiences or topics that are not explicitly in the data. Give very concise suggestions – maximum 8 words per point, keywords or short questions only. Respond in exactly this format:\n\nTHEMEN: [2-3 keywords or short questions from the actual data, comma-separated]\nGESPRAECHSRETTER: [2-3 short impulses, max. 8 words each, comma-separated]\nTIEFE: [one concrete tip, max. 12 words]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var momentsLabel: String { self == .english ? "Moments" : "Momente" }
|
var momentsLabel: String { self == .english ? "Moments" : "Momente" }
|
||||||
var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" }
|
var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" }
|
||||||
var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" }
|
var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" }
|
||||||
var interestsLabel: String { self == .english ? "Interests" : "Interessen" }
|
var interestsLabel: String { self == .english ? "Interests" : "Interessen" }
|
||||||
var culturalBackgroundLabel: String { self == .english ? "Cultural background": "Kultureller Hintergrund" }
|
var culturalBackgroundLabel: String { self == .english ? "Cultural background" : "Kultureller Hintergrund" }
|
||||||
|
|
||||||
/// Leitet die KI-Antwortsprache aus der iOS-Systemsprache ab.
|
/// Leitet die KI-Antwortsprache aus der iOS-Systemsprache ab.
|
||||||
/// Unterstützte Sprachen: de, en – alle anderen fallen auf .german zurück.
|
/// Unterstützte Sprachen: de, en – alle anderen fallen auf .german zurück.
|
||||||
|
|||||||
@@ -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,12 +258,33 @@ 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 {
|
||||||
|
if !store.isMax {
|
||||||
Text("MAX")
|
Text("MAX")
|
||||||
.font(.system(size: 10, weight: .bold))
|
.font(.system(size: 10, weight: .bold))
|
||||||
.foregroundStyle(theme.accent)
|
.foregroundStyle(theme.accent)
|
||||||
@@ -59,6 +293,7 @@ struct MaxBadge: View {
|
|||||||
.background(theme.accent.opacity(0.10))
|
.background(theme.accent.opacity(0.10))
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Section Header
|
// MARK: - Section Header
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
Button {
|
Button {
|
||||||
showPersonPicker = true
|
showPersonPicker = true
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
.font(.system(size: 24))
|
.font(.system(size: 22))
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Fangen wir an")
|
Text("Moment erfassen")
|
||||||
.font(.system(size: 16, weight: .semibold, design: theme.displayDesign))
|
.font(.system(size: 15, weight: .semibold, design: theme.displayDesign))
|
||||||
Text("Momente planen und hinzufügen")
|
Text("Treffen, Gespräch oder Erlebnis")
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 12))
|
||||||
.opacity(0.8)
|
.opacity(0.8)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 12, weight: .medium))
|
||||||
.opacity(0.6)
|
.opacity(0.6)
|
||||||
}
|
}
|
||||||
.foregroundStyle(theme.backgroundPrimary)
|
.foregroundStyle(theme.backgroundPrimary)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 18)
|
.padding(.vertical, 16)
|
||||||
.background(theme.accent)
|
.background(theme.accent)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showTodoPersonPicker = true
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -372,6 +420,11 @@ struct TodayView: View {
|
|||||||
UNUserNotificationCenter.current()
|
UNUserNotificationCenter.current()
|
||||||
.removePendingNotificationRequests(withIdentifiers: ["todo-\(todo.id)"])
|
.removePendingNotificationRequests(withIdentifiers: ["todo-\(todo.id)"])
|
||||||
|
|
||||||
|
// Logbuch-Eintrag erstellen
|
||||||
|
let entry = LogEntry(type: .todoCompleted, title: todo.title, person: todo.person)
|
||||||
|
modelContext.insert(entry)
|
||||||
|
todo.person?.logEntries?.append(entry)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try modelContext.save()
|
try modelContext.save()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -471,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