Onboaridng-Flow, PersonalityQuiz, UI-Verbesserungen.

This commit is contained in:
2026-04-19 13:09:20 +02:00
parent e75141d23c
commit 1c770c42d2
34 changed files with 5255 additions and 63 deletions
BIN
View File
Binary file not shown.
+8 -1
View File
@@ -59,7 +59,14 @@ final class AftermathNotificationManager {
private func createNotification(visitID: UUID, personName: String, delay: TimeInterval) {
let content = UNMutableNotificationContent()
content.title = String.localizedStringWithFormat(String(localized: "Nachwirkung: %@"), personName)
content.body = String(localized: "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen dauert 1 Minute.")
// Persönlichkeitsgerechter Body-Text (softer für hohen Neurotizismus)
let defaultBody = String(localized: "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen dauert 1 Minute.")
if let profile = PersonalityStore.shared.profile,
case .delayed(_, let softerCopy?) = PersonalityEngine.ratingPromptTiming(for: profile) {
content.body = softerCopy
} else {
content.body = defaultBody
}
content.sound = .default
content.categoryIdentifier = Self.categoryID
content.userInfo = [
+44
View File
@@ -22,6 +22,9 @@
26B2CAED2F93C0680039BA3B /* VisitHistorySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAEC2F93C0680039BA3B /* VisitHistorySection.swift */; };
26B2CAF12F93C52C0039BA3B /* VisitEditFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */; };
26B2CAF72F93ED690039BA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */; };
26B9930C2F94B32800E9B16C /* PrivacyBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B9930B2F94B32800E9B16C /* PrivacyBadgeView.swift */; };
26B9930E2F94B33D00E9B16C /* NahbarContact.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B9930D2F94B33D00E9B16C /* NahbarContact.swift */; };
26B993102F94B34C00E9B16C /* OnboardingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B9930F2F94B34C00E9B16C /* OnboardingCoordinator.swift */; };
26BB85B92F9248BD00889312 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85B82F9248BD00889312 /* SplashView.swift */; };
26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85BA2F924D9B00889312 /* StoreManager.swift */; };
26BB85BD2F924DB100889312 /* PaywallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85BC2F924DB100889312 /* PaywallView.swift */; };
@@ -53,6 +56,14 @@
26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66482F91352D00824F91 /* AppLockSetupView.swift */; };
26EF664B2F913C8600824F91 /* LogbuchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF664A2F913C8600824F91 /* LogbuchView.swift */; };
26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF664D2F91514B00824F91 /* ThemePickerView.swift */; };
26F8B0BF2F94B47C004905B9 /* OnboardingContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0BE2F94B47C004905B9 /* OnboardingContainerView.swift */; };
26F8B0C52F94E47F004905B9 /* PersonalityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0C42F94E47F004905B9 /* PersonalityModels.swift */; };
26F8B0C72F94E499004905B9 /* NahbarInsightStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0C62F94E499004905B9 /* NahbarInsightStyle.swift */; };
26F8B0C92F94E4B0004905B9 /* PersonalityStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0C82F94E4B0004905B9 /* PersonalityStore.swift */; };
26F8B0CB2F94E4E1004905B9 /* PersonalityEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0CA2F94E4E1004905B9 /* PersonalityEngine.swift */; };
26F8B0CF2F94E7B1004905B9 /* PersonalityQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0CE2F94E7B1004905B9 /* PersonalityQuizView.swift */; };
26F8B0D12F94E7D5004905B9 /* PersonalityResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */; };
26F8B0D32F94E7ED004905B9 /* PersonalityComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -104,6 +115,9 @@
26B2CAEC2F93C0680039BA3B /* VisitHistorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitHistorySection.swift; sourceTree = "<group>"; };
26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitEditFlowView.swift; sourceTree = "<group>"; };
26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
26B9930B2F94B32800E9B16C /* PrivacyBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyBadgeView.swift; sourceTree = "<group>"; };
26B9930D2F94B33D00E9B16C /* NahbarContact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarContact.swift; sourceTree = "<group>"; };
26B9930F2F94B34C00E9B16C /* OnboardingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCoordinator.swift; sourceTree = "<group>"; };
26BB85B82F9248BD00889312 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
26BB85BA2F924D9B00889312 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = "<group>"; };
26BB85BC2F924DB100889312 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = "<group>"; };
@@ -134,6 +148,14 @@
26EF66482F91352D00824F91 /* AppLockSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupView.swift; sourceTree = "<group>"; };
26EF664A2F913C8600824F91 /* LogbuchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogbuchView.swift; sourceTree = "<group>"; };
26EF664D2F91514B00824F91 /* ThemePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePickerView.swift; sourceTree = "<group>"; };
26F8B0BE2F94B47C004905B9 /* OnboardingContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingContainerView.swift; sourceTree = "<group>"; };
26F8B0C42F94E47F004905B9 /* PersonalityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityModels.swift; sourceTree = "<group>"; };
26F8B0C62F94E499004905B9 /* NahbarInsightStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarInsightStyle.swift; sourceTree = "<group>"; };
26F8B0C82F94E4B0004905B9 /* PersonalityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityStore.swift; sourceTree = "<group>"; };
26F8B0CA2F94E4E1004905B9 /* PersonalityEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityEngine.swift; sourceTree = "<group>"; };
26F8B0CE2F94E7B1004905B9 /* PersonalityQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityQuizView.swift; sourceTree = "<group>"; };
26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityResultView.swift; sourceTree = "<group>"; };
26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityComponents.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -254,6 +276,17 @@
26B2CAB32F93B52B0039BA3B /* UserProfileStore.swift */,
26B2CAB52F93B55F0039BA3B /* IchView.swift */,
26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */,
26B9930B2F94B32800E9B16C /* PrivacyBadgeView.swift */,
26B9930D2F94B33D00E9B16C /* NahbarContact.swift */,
26B9930F2F94B34C00E9B16C /* OnboardingCoordinator.swift */,
26F8B0BE2F94B47C004905B9 /* OnboardingContainerView.swift */,
26F8B0C42F94E47F004905B9 /* PersonalityModels.swift */,
26F8B0C62F94E499004905B9 /* NahbarInsightStyle.swift */,
26F8B0C82F94E4B0004905B9 /* PersonalityStore.swift */,
26F8B0CA2F94E4E1004905B9 /* PersonalityEngine.swift */,
26F8B0CE2F94E7B1004905B9 /* PersonalityQuizView.swift */,
26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */,
26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */,
);
path = nahbar;
sourceTree = "<group>";
@@ -406,15 +439,19 @@
files = (
269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */,
26EF66322F9112E700824F91 /* Models.swift in Sources */,
26F8B0CF2F94E7B1004905B9 /* PersonalityQuizView.swift in Sources */,
26B2CAED2F93C0680039BA3B /* VisitHistorySection.swift in Sources */,
26EF66332F9112E700824F91 /* TodayView.swift in Sources */,
26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */,
26F8B0CB2F94E4E1004905B9 /* PersonalityEngine.swift in Sources */,
26B9930C2F94B32800E9B16C /* PrivacyBadgeView.swift in Sources */,
26B2CAE72F93C03F0039BA3B /* VisitRatingFlowView.swift in Sources */,
26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */,
26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */,
26EF66432F912A0000824F91 /* CallSuggestionView.swift in Sources */,
26B2CAB62F93B55F0039BA3B /* IchView.swift in Sources */,
26B2CAF12F93C52C0039BA3B /* VisitEditFlowView.swift in Sources */,
26F8B0C52F94E47F004905B9 /* PersonalityModels.swift in Sources */,
26EF66452F91350200824F91 /* AppLockManager.swift in Sources */,
26B2CAEB2F93C05A0039BA3B /* VisitSummaryView.swift in Sources */,
26B2CAE32F93C0180039BA3B /* RatingQuestionView.swift in Sources */,
@@ -427,21 +464,28 @@
26EF66392F9112E700824F91 /* AddMomentView.swift in Sources */,
26EF663A2F9112E700824F91 /* NahbarApp.swift in Sources */,
26BB85C52F926A1C00889312 /* AppGroup.swift in Sources */,
26F8B0D12F94E7D5004905B9 /* PersonalityResultView.swift in Sources */,
26EF66472F91351800824F91 /* AppLockView.swift in Sources */,
26B2CAB82F93B7570039BA3B /* NahbarLogger.swift in Sources */,
26F8B0C72F94E499004905B9 /* NahbarInsightStyle.swift in Sources */,
26B2CABA2F93B76E0039BA3B /* LogExportView.swift in Sources */,
26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */,
26B2CAE92F93C0490039BA3B /* AftermathRatingFlowView.swift in Sources */,
26F8B0D32F94E7ED004905B9 /* PersonalityComponents.swift in Sources */,
26EF663B2F9112E700824F91 /* ContentView.swift in Sources */,
26F8B0C92F94E4B0004905B9 /* PersonalityStore.swift in Sources */,
26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */,
26B993102F94B34C00E9B16C /* OnboardingCoordinator.swift in Sources */,
26BB85C12F92525200889312 /* AIAnalysisService.swift in Sources */,
26EF663C2F9112E700824F91 /* ContactPickerView.swift in Sources */,
26B2CAE12F93C0080039BA3B /* RatingDotPicker.swift in Sources */,
26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */,
26B9930E2F94B33D00E9B16C /* NahbarContact.swift in Sources */,
26B2CAE52F93C02B0039BA3B /* AftermathNotificationManager.swift in Sources */,
26EF664B2F913C8600824F91 /* LogbuchView.swift in Sources */,
26EF663F2F9129D700824F91 /* CallWindowManager.swift in Sources */,
26EF663D2F9112E700824F91 /* SharedComponents.swift in Sources */,
26F8B0BF2F94B47C004905B9 /* OnboardingContainerView.swift in Sources */,
26BB85B92F9248BD00889312 /* SplashView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
+6 -6
View File
@@ -190,12 +190,7 @@ struct AddPersonView: View {
}
}
}
.sheet(isPresented: $showingContactPicker) {
ContactPickerView { contact in
applyContact(contact)
}
.ignoresSafeArea()
}
.confirmationDialog(
"Diese Person wirklich löschen?",
isPresented: $showingDeleteConfirmation,
@@ -207,6 +202,11 @@ struct AddPersonView: View {
Text("Alle Momente und Notizen zu dieser Person werden unwiderruflich gelöscht.")
}
.onAppear { loadExisting() }
.overlay(alignment: .center) {
SingleContactPickerTrigger(isPresented: $showingContactPicker, onSelect: applyContact)
.frame(width: 0, height: 0)
.allowsHitTesting(false)
}
}
// MARK: - Photo Section
+1 -1
View File
@@ -155,7 +155,7 @@ struct PINPadView: View {
@ViewBuilder
private func pinButton(for key: String) -> some View {
if key.isEmpty {
Color.clear.frame(width: 80, height: 80)
Color.clear.frame(width: 80, height: 80).allowsHitTesting(false)
} else if key == "" {
Button { onKey(.delete) } label: {
Image(systemName: "delete.left")
+16
View File
@@ -6,6 +6,18 @@ struct CallSuggestionView: View {
@Bindable var person: Person
let onConfirm: () -> Void
/// Zeigt PersonalityBadge wenn profil vorhanden und Person schon länger nicht besucht.
private var showRecommendedBadge: Bool {
guard let profile = PersonalityStore.shared.profile,
profile.level(for: .agreeableness) == .high else { return false }
let lastVisit = person.visits?
.compactMap { $0.visitDate }
.max()
guard let lastVisit else { return true }
let days = Calendar.current.dateComponents([.day], from: lastVisit, to: Date()).day ?? 0
return days > 14
}
var body: some View {
VStack(spacing: 0) {
RoundedRectangle(cornerRadius: 2)
@@ -25,6 +37,10 @@ struct CallSuggestionView: View {
.foregroundStyle(theme.contentPrimary)
TagBadge(text: person.tag.rawValue)
}
if showRecommendedBadge {
RecommendedBadge(variant: .small)
}
}
// Gesprächseinstieg
+7 -1
View File
@@ -104,7 +104,13 @@ class CallWindowManager: ObservableObject {
for weekday in self.selectedWeekdays {
let content = UNMutableNotificationContent()
content.title = "Gesprächszeit"
content.body = "Wer freut sich heute von dir zu hören?"
// Persönlichkeitsgerechter Body-Text (wärmer bei hohem Neurotizismus)
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.categoryIdentifier = "CALL_WINDOW"
+200 -36
View File
@@ -2,34 +2,213 @@ import SwiftUI
import ContactsUI
import Contacts
// MARK: - UIViewControllerRepresentable wrapper
// MARK: - ContactPickerBridge
/// Wraps CNContactPickerViewController no NSContactsUsageDescription needed,
/// the system picker runs in its own process and manages its own access.
struct ContactPickerView: UIViewControllerRepresentable {
let onSelect: (CNContact) -> Void
/// Hält CNContactPickerViewController-Delegation am Leben.
/// Als @State in der View speichern, dann presentMulti/presentSingle direkt
/// aus dem Button-Action aufrufen kein Modifier, kein isPresented-Flag.
///
/// Findet automatisch den obersten UIViewController im App-Fenster und
/// präsentiert den Picker von dort. Funktioniert aus fullScreenCover,
/// Sheet und jedem anderen Kontext. Keine Permission nötig.
final class ContactPickerBridge: NSObject, CNContactPickerDelegate {
func makeUIViewController(context: Context) -> CNContactPickerViewController {
let picker = CNContactPickerViewController()
picker.delegate = context.coordinator
return picker
// internal (not private) damit Unit-Tests den Callback direkt setzen können
var pendingCallback: (([CNContact]) -> Void)?
// MARK: - Presentation
func presentMulti(completion: @escaping ([CNContact]) -> Void) {
pendingCallback = completion
showPicker()
}
func updateUIViewController(_ uiViewController: CNContactPickerViewController, context: Context) {}
func presentSingle(completion: @escaping (CNContact) -> Void) {
pendingCallback = { contacts in
if let first = contacts.first { completion(first) }
}
showPicker()
}
func makeCoordinator() -> Coordinator { Coordinator(onSelect: onSelect) }
private func showPicker() {
// Auf nächsten Run-Loop-Cycle warten, damit SwiftUI seinen aktuellen
// Update-Pass abgeschlossen hat bevor UIKit modal präsentiert wird.
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard let top = self.topmostViewController() else { return }
let picker = CNContactPickerViewController()
picker.delegate = self
top.present(picker, animated: true)
}
}
private func topmostViewController() -> UIViewController? {
// Aktive UIWindowScene bevorzugen, auf erste zurückfallen
let scene = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }
?? UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first
// iOS 15+: scene.keyWindow ist zuverlässiger als windows.first(where: isKeyWindow)
guard let window = scene?.keyWindow ?? scene?.windows.first,
let root = window.rootViewController else { return nil }
var top: UIViewController = root
while let presented = top.presentedViewController { top = presented }
return top
}
// MARK: - CNContactPickerDelegate
func contactPicker(_ picker: CNContactPickerViewController, didSelect contacts: [CNContact]) {
pendingCallback?(contacts)
pendingCallback = nil
}
func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
pendingCallback?([contact])
pendingCallback = nil
}
func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
pendingCallback = nil
}
}
// MARK: - MultiContactPickerTrigger
/// Invisible UIViewRepresentable that presents CNContactPickerViewController
/// for multi-select. Finds the correct UIViewController by walking the UIKit
/// responder chain from the embedded UIView more reliable than searching
/// for the globally topmost VC.
struct MultiContactPickerTrigger: UIViewRepresentable {
@Binding var isPresented: Bool
let onSelect: ([CNContact]) -> Void
func makeCoordinator() -> Coordinator { Coordinator() }
func makeUIView(context: Context) -> UIView {
let v = UIView()
v.isHidden = true
context.coordinator.hostView = v
return v
}
func updateUIView(_ uiView: UIView, context: Context) {
let c = context.coordinator
c.onSelect = onSelect
c.dismiss = { isPresented = false }
guard isPresented, !c.isPresenting else { return }
c.isPresenting = true
DispatchQueue.main.async { [weak c] in
guard let c, let vc = c.findHostingVC() else {
c?.isPresenting = false
c?.dismiss()
return
}
let picker = CNContactPickerViewController()
picker.delegate = c
vc.present(picker, animated: true)
}
}
final class Coordinator: NSObject, CNContactPickerDelegate {
let onSelect: (CNContact) -> Void
init(onSelect: @escaping (CNContact) -> Void) { self.onSelect = onSelect }
var hostView: UIView!
var onSelect: ([CNContact]) -> Void = { _ in }
var dismiss: () -> Void = {}
var isPresenting = false
func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
onSelect(contact)
func findHostingVC() -> UIViewController? {
var responder: UIResponder? = hostView
while let next = responder?.next {
if let vc = next as? UIViewController { return vc }
responder = next
}
return nil
}
// Multi-select delegate (presence of this method enables multi-select UI)
func contactPicker(_ picker: CNContactPickerViewController, didSelect contacts: [CNContact]) {
onSelect(contacts)
dismiss()
isPresenting = false
}
func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
dismiss()
isPresenting = false
}
}
}
// MARK: - Mapping helper
// MARK: - SingleContactPickerTrigger
/// Like MultiContactPickerTrigger but shows a single-selection UI.
/// Only implements contactPicker(_:didSelect:) (singular) so that
/// CNContactPickerViewController switches to single-selection mode.
struct SingleContactPickerTrigger: UIViewRepresentable {
@Binding var isPresented: Bool
let onSelect: (CNContact) -> Void
func makeCoordinator() -> Coordinator { Coordinator() }
func makeUIView(context: Context) -> UIView {
let v = UIView()
v.isHidden = true
context.coordinator.hostView = v
return v
}
func updateUIView(_ uiView: UIView, context: Context) {
let c = context.coordinator
c.onSelect = onSelect
c.dismiss = { isPresented = false }
guard isPresented, !c.isPresenting else { return }
c.isPresenting = true
DispatchQueue.main.async { [weak c] in
guard let c, let vc = c.findHostingVC() else {
c?.isPresenting = false
c?.dismiss()
return
}
let picker = CNContactPickerViewController()
picker.delegate = c
vc.present(picker, animated: true)
}
}
final class Coordinator: NSObject, CNContactPickerDelegate {
var hostView: UIView!
var onSelect: (CNContact) -> Void = { _ in }
var dismiss: () -> Void = {}
var isPresenting = false
func findHostingVC() -> UIViewController? {
var responder: UIResponder? = hostView
while let next = responder?.next {
if let vc = next as? UIViewController { return vc }
responder = next
}
return nil
}
// Single-select: only this method picker shows single-selection UI
func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
onSelect(contact)
dismiss()
isPresenting = false
}
func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
dismiss()
isPresenting = false
}
}
}
// MARK: - ContactImport (Mapping-Hilfsstruktur)
struct ContactImport {
let name: String
@@ -38,14 +217,10 @@ struct ContactImport {
let birthday: Date?
let photoData: Data?
/// Maps a CNContact to the fields used by AddPersonView.
/// All fields are best-effort; missing data yields empty strings / nil.
static func from(_ contact: CNContact) -> ContactImport {
// Full name
let parts = [contact.givenName, contact.familyName].filter { !$0.isEmpty }
let name = parts.joined(separator: " ")
// Occupation: prefer job title, fall back to org name
let occupation: String
if !contact.jobTitle.isEmpty {
occupation = contact.jobTitle
@@ -55,20 +230,15 @@ struct ContactImport {
occupation = ""
}
// Location: city (+ country if different from obvious)
let location: String
if let postal = contact.postalAddresses.first?.value {
let city = postal.city
let country = postal.country
location = [city, country].filter { !$0.isEmpty }.joined(separator: ", ")
location = [postal.city, postal.country].filter { !$0.isEmpty }.joined(separator: ", ")
} else {
location = ""
}
// Birthday
var birthdayDate: Date? = nil
if let components = contact.birthday {
// Some contacts store only month+day (year == nil or year == 1)
var resolved = components
if resolved.year == nil || resolved.year == 1 {
resolved.year = Calendar.current.component(.year, from: Date())
@@ -76,7 +246,6 @@ struct ContactImport {
birthdayDate = Calendar.current.date(from: resolved)
}
// Photo: prefer thumbnail (smaller), fall back to full image resized
let photoData: Data?
if let thumbnail = contact.thumbnailImageData {
photoData = thumbnail
@@ -87,24 +256,19 @@ struct ContactImport {
photoData = nil
}
return ContactImport(
name: name,
occupation: occupation,
location: location,
birthday: birthdayDate,
photoData: photoData
)
return ContactImport(name: name, occupation: occupation, location: location,
birthday: birthdayDate, photoData: photoData)
}
}
// MARK: - UIImage helper
extension UIImage {
/// Downscales the image so the longer side is at most `maxSide` points.
func resizedForAvatar(maxSide: CGFloat = 400) -> UIImage? {
let scale = min(maxSide / size.width, maxSide / size.height)
guard scale < 1 else { return self }
let newSize = CGSize(width: (size.width * scale).rounded(), height: (size.height * scale).rounded())
let newSize = CGSize(width: (size.width * scale).rounded(),
height: (size.height * scale).rounded())
return UIGraphicsImageRenderer(size: newSize).image { _ in
draw(in: CGRect(origin: .zero, size: newSize))
}
+12 -1
View File
@@ -5,6 +5,7 @@ import OSLog
private let logger = Logger(subsystem: "nahbar", category: "ContentView")
struct ContentView: View {
@AppStorage("nahbarOnboardingDone") private var nahbarOnboardingDone = false
@AppStorage("callWindowOnboardingDone") private var onboardingDone = false
@AppStorage("callSuggestionDate") private var suggestionDateStr = ""
@AppStorage("photoRepairPassDone") private var photoRepairPassDone = false
@@ -18,6 +19,7 @@ struct ContentView: View {
@Query private var persons: [Person]
@State private var showingNahbarOnboarding = false
@State private var showingOnboarding = false
@State private var suggestedPerson: Person? = nil
@State private var showingSuggestion = false
@@ -48,6 +50,13 @@ struct ContentView: View {
.toolbarBackground(.visible, for: .tabBar)
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
}
.fullScreenCover(isPresented: $showingNahbarOnboarding) {
OnboardingContainerView {
nahbarOnboardingDone = true
showingNahbarOnboarding = false
checkCallWindow()
}
}
.sheet(isPresented: $showingOnboarding) {
CallWindowSetupView(
manager: callWindowManager,
@@ -76,7 +85,9 @@ struct ContentView: View {
syncPeopleCache()
importPendingMoments()
runPhotoRepairPass()
if !onboardingDone {
if !nahbarOnboardingDone {
showingNahbarOnboarding = true
} else if !onboardingDone {
showingOnboarding = true
} else {
checkCallWindow()
+132 -5
View File
@@ -1,6 +1,7 @@
import SwiftUI
import PhotosUI
import Contacts
import SwiftData
private let socialStyleOptions = [
"Introvertiert",
@@ -14,10 +15,17 @@ private let socialStyleOptions = [
struct IchView: View {
@Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var profileStore: UserProfileStore
@StateObject private var personalityStore = PersonalityStore.shared
@State private var profilePhoto: UIImage? = nil
@State private var showingEdit = false
@State private var showingImportPicker = false
@State private var importFeedback: String? = nil
@State private var showingQuiz = false
@State private var showingPersonalityDetail = false
var body: some View {
NavigationStack {
@@ -26,6 +34,8 @@ struct IchView: View {
headerSection
if !profileStore.isEmpty { infoSection }
if profileStore.isEmpty { emptyState }
personalitySection
importKontakteSection
}
.padding(.horizontal, 20)
.padding(.top, 12)
@@ -39,9 +49,72 @@ struct IchView: View {
}) {
IchEditView()
}
.onAppear {
profilePhoto = profileStore.loadPhoto()
}
.overlay(alignment: .bottom) {
if let feedback = importFeedback {
Text(feedback)
.font(.subheadline.weight(.medium))
.foregroundStyle(.white)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Color.accentColor)
.clipShape(Capsule())
.padding(.bottom, 24)
.transition(.move(edge: .bottom).combined(with: .opacity))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation { importFeedback = nil }
}
}
}
}
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: importFeedback)
.overlay(alignment: .center) {
MultiContactPickerTrigger(isPresented: $showingImportPicker, onSelect: importContacts)
.frame(width: 0, height: 0)
.allowsHitTesting(false)
}
.sheet(isPresented: $showingQuiz) {
PersonalityQuizView { _ in
showingQuiz = false
}
}
.sheet(isPresented: $showingPersonalityDetail) {
if let profile = personalityStore.profile {
NavigationStack {
PersonalityResultView(profile: profile) {
showingPersonalityDetail = false
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Fertig") { showingPersonalityDetail = false }
}
}
}
}
}
}
// MARK: - Persönlichkeit
@ViewBuilder
private var personalitySection: some View {
VStack(alignment: .leading, spacing: 10) {
SectionHeader(title: "Persönlichkeit", icon: "brain")
if let profile = personalityStore.profile, profile.isComplete {
PersonalityProfileCard(
profile: profile,
onRetake: { showingQuiz = true },
onShowDetails: { showingPersonalityDetail = true }
)
} else if !personalityStore.hasSkippedQuiz {
QuizPromptCard(onStart: { showingQuiz = true })
}
}
}
// MARK: - Header
@@ -223,6 +296,59 @@ struct IchView: View {
.frame(maxWidth: .infinity)
.padding(.top, 12)
}
// MARK: - Kontakte importieren
private var importKontakteSection: some View {
VStack(alignment: .leading, spacing: 10) {
SectionHeader(title: "Kontakte importieren", icon: "person.2.badge.plus")
Button { showingImportPicker = true } label: {
HStack(spacing: 10) {
Image(systemName: "person.crop.circle.badge.plus")
.font(.system(size: 15))
Text("Aus Adressbuch hinzufügen")
.font(.system(size: 15))
}
.foregroundStyle(theme.accent)
.padding(.horizontal, 14)
.padding(.vertical, 11)
.frame(maxWidth: .infinity, alignment: .leading)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.overlay(
RoundedRectangle(cornerRadius: theme.radiusCard)
.stroke(theme.accent.opacity(0.25), lineWidth: 1)
)
}
.accessibilityLabel("Kontakte aus Adressbuch hinzufügen")
}
}
/// Importiert die gewählten Kontakte als Person-Objekte in die Datenbank.
/// Bereits vorhandene Personen werden nicht dupliziert (Name-Vergleich).
private func importContacts(_ contacts: [CNContact]) {
var imported = 0
for contact in contacts {
let name = [contact.givenName, contact.familyName]
.filter { !$0.isEmpty }
.joined(separator: " ")
guard !name.isEmpty else { continue }
let person = Person(name: name)
modelContext.insert(person)
imported += 1
}
guard imported > 0 else { return }
do {
try modelContext.save()
} catch {
// Fehler werden im nächsten App-Launch durch den Container-Fallback abgefangen
}
withAnimation {
importFeedback = imported == 1
? "1 Person hinzugefügt"
: "\(imported) Personen hinzugefügt"
}
}
}
// MARK: - IchEditView
@@ -369,11 +495,7 @@ struct IchEditView: View {
}
}
}
.sheet(isPresented: $showingContactPicker) {
ContactPickerView { contact in
applyContact(contact)
}
}
.onChange(of: photoPickerItem) { _, item in
Task {
guard let item else { return }
@@ -383,6 +505,11 @@ struct IchEditView: View {
}
}
}
.overlay(alignment: .center) {
SingleContactPickerTrigger(isPresented: $showingContactPicker, onSelect: applyContact)
.frame(width: 0, height: 0)
.allowsHitTesting(false)
}
}
// MARK: - Photo Section
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -69,7 +69,9 @@ struct LogbuchView: View {
@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
var body: some View {
ScrollView {
@@ -96,6 +98,12 @@ struct LogbuchView: View {
.navigationBarTitleDisplayMode(.inline)
.themedNavBar()
.sheet(isPresented: $showPaywall) { PaywallView(targeting: .max) }
.sheet(isPresented: $showAIConsent) {
AIConsentSheet {
aiConsentGiven = true
Task { await runAnalysis() }
}
}
.onReceive(
NotificationCenter.default.publisher(
for: Notification.Name("NSManagedObjectContextObjectsDidChangeNotification")
@@ -259,7 +267,11 @@ struct LogbuchView: View {
switch analysisState {
case .idle:
Button {
Task { await runAnalysis() }
if aiConsentGiven {
Task { await runAnalysis() }
} else {
showAIConsent = true
}
} label: {
HStack(spacing: 10) {
Image(systemName: "sparkles")
+2
View File
@@ -39,6 +39,8 @@ struct NahbarApp: App {
.environmentObject(cloudSyncMonitor)
.environmentObject(profileStore)
.environmentObject(eventLog)
// Verhindert Touch-Durchfall bei aktivem Splash- oder Lock-Screen
.allowsHitTesting(!showSplash && !appLockManager.isLocked)
if appLockManager.isLocked && !showSplash {
AppLockView()
+122
View File
@@ -0,0 +1,122 @@
import Foundation
import Contacts
import OSLog
import SwiftUI
private let contactLogger = Logger(subsystem: "nahbar", category: "ContactStore")
// MARK: - NahbarContact
/// A contact selected during onboarding, persisted locally as JSON.
/// No contact data is ever sent to any server.
struct NahbarContact: Identifiable, Codable, Equatable {
var id: UUID
var givenName: String
var familyName: String
var phoneNumbers: [String]
var notes: String
/// Original CNContact identifier for stable matching against the system address book.
var cnIdentifier: String?
init(
id: UUID = UUID(),
givenName: String,
familyName: String,
phoneNumbers: [String] = [],
notes: String = "",
cnIdentifier: String? = nil
) {
self.id = id
self.givenName = givenName
self.familyName = familyName
self.phoneNumbers = phoneNumbers
self.notes = notes
self.cnIdentifier = cnIdentifier
}
/// Initialises from a CNContact. No data is persisted at this point.
init(from contact: CNContact) {
self.id = UUID()
self.givenName = contact.givenName
self.familyName = contact.familyName
self.phoneNumbers = contact.phoneNumbers.map { $0.value.stringValue }
// CNContactNoteKey requires a special entitlement omitted intentionally.
self.notes = ""
self.cnIdentifier = contact.identifier
}
var fullName: String {
[givenName, familyName].filter { !$0.isEmpty }.joined(separator: " ")
}
var initials: String {
let g = givenName.prefix(1).uppercased()
let f = familyName.prefix(1).uppercased()
if !g.isEmpty && !f.isEmpty { return g + f }
return g.isEmpty ? (f.isEmpty ? "?" : String(f)) : String(g)
}
}
// MARK: - ContactStoring Protocol
/// Abstraction over the contact persistence layer injectable for testing.
protocol ContactStoring: AnyObject {
var contacts: [NahbarContact] { get }
func save(_ contacts: [NahbarContact]) throws
func load() throws -> [NahbarContact]
}
// MARK: - ContactStore
/// Persists onboarding contacts as a JSON file in Application Support.
/// All data stays local no network requests are made.
final class ContactStore: ContactStoring {
static let shared = ContactStore()
private(set) var contacts: [NahbarContact] = []
private var storeURL: URL {
let dir = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return dir.appendingPathComponent("NahbarContacts.json")
}
private init() {
contacts = (try? load()) ?? []
}
func save(_ newContacts: [NahbarContact]) throws {
let encoded = try JSONEncoder().encode(newContacts)
let dir = storeURL.deletingLastPathComponent()
if !FileManager.default.fileExists(atPath: dir.path) {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
try encoded.write(to: storeURL, options: .atomic)
self.contacts = newContacts
contactLogger.info("ContactStore: \(newContacts.count) Kontakt(e) gespeichert")
}
func reset() {
try? FileManager.default.removeItem(at: storeURL)
contacts = []
}
func load() throws -> [NahbarContact] {
guard FileManager.default.fileExists(atPath: storeURL.path) else { return [] }
let data = try Data(contentsOf: storeURL)
return try JSONDecoder().decode([NahbarContact].self, from: data)
}
}
// MARK: - Environment Key
private struct ContactStoreKey: EnvironmentKey {
static let defaultValue: any ContactStoring = ContactStore.shared
}
extension EnvironmentValues {
var contactStore: any ContactStoring {
get { self[ContactStoreKey.self] }
set { self[ContactStoreKey.self] = newValue }
}
}
+111
View File
@@ -0,0 +1,111 @@
import SwiftUI
// MARK: - NahbarInsightStyle
/// Gemeinsames visuelles Sprachsystem für Quiz, Besuchsbewertung und alle
/// datengestützten Empfehlungs-UIs in nahbar.
///
/// Farben sind code-definiert (kein Asset-Catalog nötig) und adaptieren sich
/// automatisch an Light/Dark-Mode via UIColor-Closure.
enum NahbarInsightStyle {
// MARK: - Farben
/// Tiefes Petrol Hauptakzent für Quiz und Empfehlungen.
/// #2E7D78 in Light Mode, etwas aufgehellt in Dark Mode.
static let accentPetrol = Color(UIColor { trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0.28, green: 0.62, blue: 0.59, alpha: 1)
: UIColor(red: 0.18, green: 0.49, blue: 0.47, alpha: 1)
})
/// Gedämpftes Salbeigrün. #6B8B68 in Light Mode.
static let accentSage = Color(UIColor { trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0.56, green: 0.70, blue: 0.54, alpha: 1)
: UIColor(red: 0.42, green: 0.55, blue: 0.41, alpha: 1)
})
/// Sanftes Korall für Energie-Akzente.
static let accentCoral = Color(UIColor { trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0.95, green: 0.62, blue: 0.54, alpha: 1)
: UIColor(red: 0.88, green: 0.45, blue: 0.38, alpha: 1)
})
/// Hintergrundtönung für "Passend für dich"-Badges (Petrol 15 % Opazität).
static var recommendedTint: Color { accentPetrol.opacity(0.15) }
/// Kartenhindergrund entspricht dem SystemGrau-Hintergrund der VisitRatingCards.
static let cardBackground = Color(.secondarySystemBackground)
/// Sekundärtext.
static let secondaryText = Color.secondary
// MARK: - Typografie
/// Situationstext im Quiz. Entspricht .title3.weight(.medium).
static let situationFont: Font = .title3.weight(.medium)
/// Antwortoptionen-Text. Entspricht .body.
static let optionFont: Font = .body
/// Kleiner Erklärungstext. Entspricht .caption.
static let captionFont: Font = .caption
/// Badge-Schrift (RecommendedBadge, TraitLevel-Chips).
static let badgeFont: Font = .caption2.weight(.semibold)
// MARK: - Layout
/// Eckenradius für Quiz-Situationskarten (etwas weicher als Visit-Karten).
static let cardCornerRadius: CGFloat = 18
/// Eckenradius für Karten im VisitRating-Stil.
static let visitCardCornerRadius: CGFloat = 12
/// Innenabstand von Karten.
static let cardPadding: CGFloat = 20
/// Seitenabstand (horizontales Padding).
static let horizontalPadding: CGFloat = 20
// MARK: - Fortschritt
/// Durchmesser der aktiven Fortschritts-Punkte.
static let progressDotActive: CGFloat = 8
/// Durchmesser der inaktiven Fortschritts-Punkte.
static let progressDotInactive: CGFloat = 6
// MARK: - Transitionen
/// Folienwechsel-Transition (neue Frage von rechts, alte nach links).
static let slideTransition: AnyTransition = .asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading)
)
// MARK: - Farb-Mapping nach Dimension
/// Farbe für eine OCEAN-Dimension im Pentagon-Chart und Badges.
static func color(for dimension: OceanDimension) -> Color {
switch dimension {
case .openness: return Color(red: 0.25, green: 0.55, blue: 0.85) // blau
case .conscientiousness: return accentPetrol // petrol
case .extraversion: return accentCoral // korall
case .agreeableness: return accentSage // salbei
case .neuroticism: return Color(red: 0.60, green: 0.45, blue: 0.80) // lavendel
}
}
// MARK: - TraitLevel-Farbe
static func color(for level: TraitLevel) -> Color {
switch level {
case .low: return .secondary
case .medium: return accentSage
case .high: return accentPetrol
}
}
}
+751
View File
@@ -0,0 +1,751 @@
import SwiftUI
import Contacts
import PhotosUI
import SwiftData
import OSLog
private let onboardingLogger = Logger(subsystem: "nahbar", category: "Onboarding")
// MARK: - OnboardingContainerView
/// Root container for the first-launch onboarding flow.
/// Shows a TabView with Phase 1 (Profile) and Phase 2 (Contacts),
/// then overlays the Phase 3 (Feature Tour) on top with a blurred background.
struct OnboardingContainerView: View {
let onComplete: () -> Void
@StateObject private var coordinator = OnboardingCoordinator()
@Environment(\.contactStore) private var contactStore
@Environment(\.modelContext) private var modelContext
/// Current tab page index (0 = profile, 1 = contacts).
@State private var tabPage: Int = 0
/// Whether the feature tour overlay is visible.
@State private var showTour: Bool = false
/// Whether the final privacy screen is visible (shown after the feature tour).
@State private var showPrivacyScreen: Bool = false
var body: some View {
ZStack {
// Background pages (blurred when tour or privacy screen is active)
TabView(selection: $tabPage) {
OnboardingProfileView(coordinator: coordinator)
.tag(0)
OnboardingQuizPromptView(coordinator: coordinator)
.tag(1)
OnboardingContactImportView(
coordinator: coordinator,
onContinue: startTour,
onSkip: startTour
)
.tag(2)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.blur(radius: (showTour || showPrivacyScreen) ? 20 : 0)
.disabled(showTour || showPrivacyScreen)
// Dark overlay
if showTour || showPrivacyScreen {
Color.black.opacity(0.45)
.ignoresSafeArea()
.transition(.opacity)
}
// Feature tour
if showTour {
FeatureTourView(onFinish: startPrivacyScreen)
.transition(.opacity)
}
// Privacy screen (final step)
if showPrivacyScreen {
OnboardingPrivacyView(onFinish: finishOnboarding)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.35), value: showTour)
.animation(.easeInOut(duration: 0.35), value: showPrivacyScreen)
.onChange(of: coordinator.currentStep) { _, step in
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, 2)
}
}
}
private func startTour() {
withAnimation { showTour = true }
}
private func startPrivacyScreen() {
withAnimation(.easeInOut(duration: 0.35)) {
showTour = false
showPrivacyScreen = true
}
}
private func finishOnboarding() {
// 1. Persist user profile
UserProfileStore.shared.update(
name: coordinator.firstName,
birthday: nil,
occupation: "",
location: "",
likes: "",
dislikes: "",
socialStyle: "",
displayName: coordinator.displayName,
aboutMe: coordinator.aboutMe
)
// 2. Persist selected contacts (local JSON no network)
do {
try contactStore.save(coordinator.selectedContacts)
} catch {
onboardingLogger.error("ContactStore.save fehlgeschlagen: \(error.localizedDescription)")
}
// 3. Import each selected contact as a Person in SwiftData
for contact in coordinator.selectedContacts {
let person = Person(name: contact.fullName)
modelContext.insert(person)
}
if !coordinator.selectedContacts.isEmpty {
do {
try modelContext.save()
onboardingLogger.info("Onboarding: \(coordinator.selectedContacts.count) Kontakt(e) als Person importiert")
} catch {
onboardingLogger.error("modelContext.save fehlgeschlagen: \(error.localizedDescription)")
}
}
coordinator.completeOnboarding()
onComplete()
}
}
// MARK: - Phase 1: OnboardingProfileView
private struct OnboardingProfileView: View {
@ObservedObject var coordinator: OnboardingCoordinator
@State private var selectedItem: PhotosPickerItem? = nil
@State private var profileImage: UIImage? = nil
@State private var showingContactPicker = false
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Header
VStack(spacing: 8) {
Text("Willkommen bei nahbar")
.font(.title.bold())
.multilineTextAlignment(.center)
Text("Erzähl uns kurz, wer du bist.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.top, 48)
// Avatar
avatarSection
// Aus Kontakten importieren
Button { showingContactPicker = true } label: {
Label("Aus Kontakten ausfüllen", systemImage: "person.crop.circle")
.font(.subheadline)
.foregroundStyle(Color.accentColor)
}
.accessibilityLabel("Eigene Kontaktdaten aus Adressbuch übernehmen")
// Form
VStack(spacing: 16) {
// First name (required)
VStack(alignment: .leading, spacing: 6) {
Text("Vorname")
.font(.caption)
.foregroundStyle(.secondary)
TextField("z. B. Max", text: $coordinator.firstName)
.textFieldStyle(.roundedBorder)
.textContentType(.givenName)
.submitLabel(.next)
.accessibilityLabel("Vorname, erforderlich")
}
// Display name (optional)
VStack(alignment: .leading, spacing: 6) {
Text("Spitzname (optional)")
.font(.caption)
.foregroundStyle(.secondary)
TextField("Wie nennen dich deine Freunde?", text: $coordinator.displayName)
.textFieldStyle(.roundedBorder)
.textContentType(.nickname)
.submitLabel(.next)
.accessibilityLabel("Spitzname, optional")
}
// About me (max 100 chars)
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Über mich (optional)")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text("\(coordinator.aboutMe.count)/100")
.font(.caption2)
.foregroundStyle(coordinator.aboutMe.count > 90 ? Color.orange : Color.secondary.opacity(0.5))
.accessibilityLabel("\(coordinator.aboutMe.count) von 100 Zeichen")
}
TextField(
"Wie kennen dich deine Freunde?",
text: $coordinator.aboutMe,
axis: .vertical
)
.textFieldStyle(.roundedBorder)
.lineLimit(3...5)
.onChange(of: coordinator.aboutMe) { _, value in
if value.count > 100 {
coordinator.aboutMe = String(value.prefix(100))
}
}
.accessibilityLabel("Über mich, maximal 100 Zeichen")
}
}
.padding(.horizontal, 24)
// Privacy badge
PrivacyBadgeView(context: .profile)
.padding(.horizontal, 24)
// Continue button
Button {
coordinator.advanceToQuiz()
} label: {
Text("Weiter")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(coordinator.isProfileValid
? Color.accentColor
: Color.secondary.opacity(0.25))
.foregroundStyle(coordinator.isProfileValid ? .white : .secondary)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(!coordinator.isProfileValid)
.padding(.horizontal, 24)
.accessibilityLabel("Weiter zum Persönlichkeitsquiz")
.accessibilityHint(coordinator.isProfileValid
? ""
: "Bitte gib zuerst deinen Vornamen ein.")
Spacer(minLength: 40)
}
}
.onChange(of: selectedItem) { _, item in
Task {
guard let item,
let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) else { return }
profileImage = image
UserProfileStore.shared.savePhoto(image)
}
}
.overlay(alignment: .center) {
SingleContactPickerTrigger(isPresented: $showingContactPicker, onSelect: applyContact)
.frame(width: 0, height: 0)
.allowsHitTesting(false)
}
}
private func applyContact(_ contact: CNContact) {
if !contact.givenName.isEmpty {
coordinator.firstName = contact.givenName
}
// Foto übernehmen, falls vorhanden
let photoData = contact.thumbnailImageData ?? contact.imageData
if let data = photoData, let image = UIImage(data: data) {
profileImage = image
UserProfileStore.shared.savePhoto(image)
}
}
// MARK: Avatar
@ViewBuilder
private var avatarSection: some View {
ZStack(alignment: .bottomTrailing) {
// Photo or initials placeholder
Group {
if let image = profileImage {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
ZStack {
Circle()
.fill(Color.accentColor.opacity(0.15))
Text(initialsPlaceholder)
.font(.largeTitle.bold())
.foregroundStyle(Color.accentColor)
}
}
}
.frame(width: 96, height: 96)
.clipShape(Circle())
// Camera button overlay
PhotosPicker(selection: $selectedItem, matching: .images) {
Image(systemName: "camera.fill")
.font(.caption.weight(.semibold))
.foregroundStyle(.white)
.padding(7)
.background(Color.accentColor)
.clipShape(Circle())
}
.accessibilityLabel("Profilfoto auswählen")
}
}
private var initialsPlaceholder: String {
let name = coordinator.firstName.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return "?" }
let parts = name.split(separator: " ")
if parts.count >= 2 {
return (parts[0].prefix(1) + parts[1].prefix(1)).uppercased()
}
return String(name.prefix(2)).uppercased()
}
}
// MARK: - Phase 2: OnboardingQuizPromptView
/// 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).
/// Multi-select is activated automatically by implementing didSelectContacts:.
private struct OnboardingContactImportView: View {
@ObservedObject var coordinator: OnboardingCoordinator
let onContinue: () -> Void
let onSkip: () -> Void
@State private var showingPicker = false
@State private var showSkipConfirmation: Bool = false
var body: some View {
VStack(spacing: 0) {
// Header
VStack(spacing: 8) {
Text("Kontakte hinzufügen")
.font(.title.bold())
.multilineTextAlignment(.center)
Text("Wähle Menschen aus, die dir wichtig sind.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.top, 36)
.padding(.horizontal, 24)
PrivacyBadgeView(context: .contacts)
.padding(.horizontal, 16)
.padding(.top, 16)
Divider().padding(.top, 16)
// Selected contacts list
if coordinator.selectedContacts.isEmpty {
emptyPickerPrompt
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
selectedContactsList
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
Divider()
// Bottom actions
VStack(spacing: 10) {
Button(action: onContinue) {
Text(coordinator.selectedContacts.isEmpty
? "Weiter"
: "Weiter (\(coordinator.selectedContacts.count) ausgewählt)")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(coordinator.selectedContacts.isEmpty
? Color.secondary.opacity(0.25)
: Color.accentColor)
.foregroundStyle(coordinator.selectedContacts.isEmpty ? Color.secondary : Color.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(coordinator.selectedContacts.isEmpty)
.accessibilityLabel(
coordinator.selectedContacts.isEmpty
? "Weiter, kein Kontakt ausgewählt"
: "\(coordinator.selectedContacts.count) Kontakte ausgewählt. Weiter."
)
Button {
showSkipConfirmation = true
} label: {
Text("Überspringen")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.accessibilityLabel("Kontakte überspringen")
.accessibilityHint("Zeigt eine Bestätigungsabfrage.")
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
.confirmationDialog(
"Kontakte überspringen?",
isPresented: $showSkipConfirmation,
titleVisibility: .visible
) {
Button("Trotzdem überspringen", role: .destructive, action: onSkip)
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Du kannst Kontakte jederzeit später in der App hinzufügen.")
}
.overlay(alignment: .center) {
// Invisible trigger finds the hosting UIViewController via
// the UIKit responder chain and presents the system contact picker.
MultiContactPickerTrigger(isPresented: $showingPicker, onSelect: mergeContacts)
.frame(width: 0, height: 0)
.allowsHitTesting(false)
}
}
// MARK: Empty prompt
private var emptyPickerPrompt: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "person.2.badge.plus")
.font(.system(size: 56))
.foregroundStyle(Color.accentColor)
.accessibilityHidden(true)
Text("Noch keine Kontakte")
.font(.title3.bold())
Text("Wähle Menschen aus deinem Adressbuch, die dir wichtig sind.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Button { showingPicker = true } label: {
Label("Kontakte auswählen", systemImage: "person.crop.circle.badge.plus")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 32)
}
.accessibilityLabel("Kontakte aus Adressbuch auswählen")
Spacer()
}
.padding()
}
// MARK: Selected contacts list
private var selectedContactsList: some View {
List {
Section {
ForEach(coordinator.selectedContacts) { contact in
HStack(spacing: 12) {
// Initials avatar
ZStack {
Circle().fill(Color.accentColor.opacity(0.15))
Text(contact.initials)
.font(.caption.bold())
.foregroundStyle(Color.accentColor)
}
.frame(width: 36, height: 36)
Text(contact.fullName)
.font(.body)
Spacer()
}
}
.onDelete { indexSet in
coordinator.selectedContacts.remove(atOffsets: indexSet)
}
} header: {
HStack {
Text("\(coordinator.selectedContacts.count) ausgewählt")
Spacer()
Button {
showingPicker = true
} label: {
Label("Weitere hinzufügen", systemImage: "plus")
.font(.caption.weight(.medium))
}
}
}
}
.listStyle(.plain)
}
// MARK: Merge helper
/// Merges newly picked contacts into the existing selection (no duplicates).
private func mergeContacts(_ contacts: [CNContact]) {
for contact in contacts {
let alreadySelected = coordinator.selectedContacts
.contains { $0.cnIdentifier == contact.identifier }
if !alreadySelected {
coordinator.selectedContacts.append(NahbarContact(from: contact))
}
}
}
}
// MARK: - Phase 4: OnboardingPrivacyView
/// Final onboarding screen. Explains the app's privacy-first approach and
/// informs users that AI features are optional and involve a third-party service.
private struct OnboardingPrivacyView: View {
let onFinish: () -> Void
var body: some View {
ZStack {
Color(.systemBackground)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 32) {
// Icon
Image(systemName: "lock.shield.fill")
.font(.system(size: 72))
.foregroundStyle(.green)
.padding(.top, 60)
.accessibilityHidden(true)
// Headline
VStack(spacing: 10) {
Text("Deine Daten gehören dir")
.font(.title.bold())
.multilineTextAlignment(.center)
Text("Alles, was du in nahbar eingibst, wird ausschließlich auf deinem iPhone gespeichert und verarbeitet.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 32)
// Detail rows
VStack(alignment: .leading, spacing: 20) {
privacyRow(
icon: "iphone",
text: "Kontakte, Besuche und Momente bleiben lokal auf deinem Gerät keine Cloud-Synchronisation."
)
privacyRow(
icon: "person.slash",
text: "Keine Registrierung, kein Account, kein Tracking."
)
privacyRow(
icon: "sparkles",
text: "KI-Funktionen sind optional. Du entscheidest, wann du sie verwendest. Erst dann werden Daten an einen Drittanbieter übertragen."
)
}
.padding(.horizontal, 24)
// CTA button
Button(action: onFinish) {
Text("Verstanden & App starten")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal, 24)
.accessibilityLabel("Onboarding abschließen und App starten")
Spacer(minLength: 40)
}
}
}
}
private func privacyRow(icon: String, text: LocalizedStringKey) -> some View {
HStack(alignment: .top, spacing: 16) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(Color.accentColor)
.frame(width: 32)
.accessibilityHidden(true)
Text(text)
.font(.body)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Phase 3: FeatureTourView
/// Data for a single coach-mark card.
struct FeatureTourStep {
let icon: String
let title: LocalizedStringKey
let description: LocalizedStringKey
let showPrivacySummary: Bool
static let all: [FeatureTourStep] = [
FeatureTourStep(
icon: "checklist",
title: "Vorhaben",
description: "Plane gemeinsame Aktivitäten und bleib mit wichtigen Menschen in Kontakt.",
showPrivacySummary: false
),
FeatureTourStep(
icon: "figure.walk.arrival",
title: "Besuche",
description: "Halte fest, wen du besucht hast und wann.",
showPrivacySummary: false
),
FeatureTourStep(
icon: "square.and.arrow.up",
title: "Nachrichten importieren",
description: "Teile WhatsApp-Nachrichten direkt in nahbar sie werden als Momente gespeichert.",
showPrivacySummary: false
),
FeatureTourStep(
icon: "bell.badge",
title: "Erinnerungen",
description: "nahbar erinnert dich, wenn du lange nichts von jemandem gehört hast.",
showPrivacySummary: true
)
]
}
private struct FeatureTourView: View {
let onFinish: () -> Void
@State private var stepIndex: Int = 0
private var steps: [FeatureTourStep] { FeatureTourStep.all }
private var step: FeatureTourStep { steps[stepIndex] }
private var isLastStep: Bool { stepIndex == steps.count - 1 }
var body: some View {
VStack {
Spacer()
coachCard
.id(stepIndex)
.transition(.scale.combined(with: .opacity))
.padding(.horizontal, 28)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var coachCard: some View {
VStack(spacing: 20) {
// Progress dots
HStack(spacing: 6) {
ForEach(0..<steps.count, id: \.self) { i in
Circle()
.fill(i == stepIndex ? Color.accentColor : Color.secondary.opacity(0.3))
.frame(width: i == stepIndex ? 8 : 6,
height: i == stepIndex ? 8 : 6)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: stepIndex)
}
}
.accessibilityLabel("Schritt \(stepIndex + 1) von \(steps.count)")
// Feature icon
Image(systemName: step.icon)
.font(.system(size: 52))
.foregroundStyle(Color.accentColor)
.accessibilityHidden(true)
// Title
Text(step.title)
.font(.title2.bold())
.multilineTextAlignment(.center)
// Description
Text(step.description)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
// Privacy summary last step only
if step.showPrivacySummary {
PrivacyBadgeView(context: .summary)
}
// Action button
Button(action: advance) {
Text(isLastStep ? "Los geht's" : "Weiter")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.accessibilityLabel(isLastStep
? "Los geht's nahbar starten"
: "Weiter zum nächsten Schritt")
}
.padding(24)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 20))
.shadow(color: .black.opacity(0.18), radius: 24, y: 10)
}
private func advance() {
if isLastStep {
onFinish()
} else {
withAnimation(.spring(response: 0.45, dampingFraction: 0.82)) {
stepIndex += 1
}
}
}
}
// MARK: - Preview
#Preview("Onboarding Container") {
OnboardingContainerView(onComplete: {})
.modelContainer(for: [Person.self, Moment.self, PersonPhoto.self], inMemory: true)
}
+81
View File
@@ -0,0 +1,81 @@
import Foundation
import Combine
// MARK: - OnboardingStep
/// Each phase of the first-launch onboarding flow.
enum OnboardingStep: Int, CaseIterable {
case profile = 0
case quiz = 1 // Persönlichkeitsquiz-Prompt
case contacts = 2 // was 1
case tour = 3 // was 2
case complete = 4 // was 3
}
// MARK: - OnboardingCoordinator
/// Observable coordinator that owns all onboarding data and drives step transitions.
@MainActor
final class OnboardingCoordinator: ObservableObject {
// MARK: Navigation state
@Published var currentStep: OnboardingStep = .profile
// MARK: Phase 1: Profile
@Published var firstName: String = ""
@Published var displayName: String = ""
@Published var aboutMe: String = ""
// MARK: Phase 2: Contacts
@Published var selectedContacts: [NahbarContact] = []
// MARK: Validation
/// `true` when at least a non-empty first name has been entered.
var isProfileValid: Bool {
!firstName.trimmingCharacters(in: .whitespaces).isEmpty
}
// 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.
func advanceToContacts() {
guard isProfileValid else { return }
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.
func completeOnboarding() {
currentStep = .complete
}
}
+80 -1
View File
@@ -24,6 +24,8 @@ struct PersonDetailView: View {
return Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: tomorrow) ?? tomorrow
}()
@StateObject private var personalityStore = PersonalityStore.shared
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
@@ -127,7 +129,12 @@ struct PersonDetailView: View {
} else if let step = person.nextStep, !person.nextStepCompleted {
nextStepDisplay(step: step)
} else {
addNextStepButton
VStack(alignment: .leading, spacing: 8) {
addNextStepButton
if let profile = personalityStore.profile, profile.isComplete {
nextStepSuggestionsView(profile: profile)
}
}
}
}
}
@@ -251,6 +258,78 @@ struct PersonDetailView: View {
.removePendingNotificationRequests(withIdentifiers: ["nextstep-\(person.id)"])
}
/// Persönlichkeitsgesteuerte Aktivitätsvorschläge für den nächsten Schritt.
/// Sortiert nach preferredActivityStyle und highlightNovelty aus PersonalityEngine.
private func nextStepSuggestionsView(profile: PersonalityProfile) -> some View {
let preferred = PersonalityEngine.preferredActivityStyle(for: profile)
let highlightNew = PersonalityEngine.highlightNovelty(for: profile)
// (text, icon, style, isNovel)
let activities: [(String, String, ActivityStyle?, Bool)] = [
("Kaffee trinken", "cup.and.saucer", .oneOnOne, false),
("Spazieren gehen", "figure.walk", .oneOnOne, false),
("Zusammen essen", "fork.knife", .group, false),
("Etwas unternehmen", "person.2", .group, false),
("Etwas Neues ausprobieren", "sparkles", nil, true),
("Anrufen", "phone", nil, false),
]
// Empfohlene Aktivitäten nach oben sortieren
let sorted = activities.sorted { a, b in
func score(_ item: (String, String, ActivityStyle?, Bool)) -> Int {
var s = 0
if item.2 == preferred { s += 2 }
if item.3 && highlightNew { s += 1 }
return s
}
return score(a) > score(b)
}
let topItems = Array(sorted.prefix(3))
return VStack(spacing: 6) {
ForEach(topItems, id: \.0) { item in
let isRecommended = (item.2 == preferred) || (item.3 && highlightNew)
Button {
nextStepText = item.0
isEditingNextStep = true
} label: {
HStack(spacing: 10) {
Image(systemName: item.1)
.font(.system(size: 14))
.foregroundStyle(isRecommended ? NahbarInsightStyle.accentPetrol : theme.contentSecondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: 3) {
Text(LocalizedStringKey(item.0))
.font(.system(size: 14))
.foregroundStyle(theme.contentPrimary)
if isRecommended {
RecommendedBadge(variant: .small)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 11))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(isRecommended ? NahbarInsightStyle.accentPetrol.opacity(0.05) : theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.overlay(
RoundedRectangle(cornerRadius: theme.radiusCard)
.stroke(
isRecommended ? NahbarInsightStyle.accentPetrol.opacity(0.25) : theme.borderSubtle,
lineWidth: 1
)
)
}
}
}
}
private func deleteMoment(_ moment: Moment) {
modelContext.delete(moment)
person.touch()
+174
View File
@@ -0,0 +1,174 @@
import SwiftUI
// MARK: - RecommendedBadge
/// Badge der anzeigt, dass ein Element zum Persönlichkeitsprofil passt.
/// Nur rendern wenn PersonalityStore.shared.hasCompletedQuiz == true.
struct RecommendedBadge: View {
enum Variant {
/// Kompakt: "Passend für dich"
case small
/// Mit individueller Begründung
case full(reason: String)
}
let variant: Variant
var body: some View {
HStack(spacing: 4) {
Image(systemName: "sparkles")
.font(NahbarInsightStyle.badgeFont)
.accessibilityHidden(true)
Text(labelText)
.font(NahbarInsightStyle.badgeFont)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(NahbarInsightStyle.recommendedTint)
.foregroundStyle(NahbarInsightStyle.accentPetrol)
.clipShape(Capsule())
.accessibilityLabel(accessibilityText)
}
private var labelText: String {
switch variant {
case .small: return "Passend für dich"
case .full(let reason): return reason
}
}
private var accessibilityText: String {
switch variant {
case .small: return "Persönlichkeitsempfehlung: Passend für dich"
case .full(let reason): return "Persönlichkeitsempfehlung: \(reason)"
}
}
}
// MARK: - QuizPromptCard
/// Karte im Ich-Tab, die das Persönlichkeitsquiz bewirbt,
/// wenn es noch nicht abgeschlossen wurde.
struct QuizPromptCard: View {
let onStart: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 12) {
Image(systemName: "person.text.rectangle")
.font(.title2)
.foregroundStyle(NahbarInsightStyle.accentPetrol)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Wie tickst du?")
.font(.headline)
Text("Personalisierte Vorschläge in 2 Minuten")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
PrivacyBadgeView(context: .localOnly)
Button(action: onStart) {
Text("Quiz starten")
.font(.subheadline.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(NahbarInsightStyle.accentPetrol)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.accessibilityLabel("Persönlichkeitsquiz starten")
}
.padding(NahbarInsightStyle.cardPadding)
.background(NahbarInsightStyle.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: NahbarInsightStyle.cardCornerRadius))
}
}
// MARK: - PersonalityProfileCard
/// Karte im Ich-Tab, die das Persönlichkeitsprofil zeigt,
/// wenn das Quiz abgeschlossen wurde.
struct PersonalityProfileCard: View {
let profile: PersonalityProfile
let onRetake: () -> Void
let onShowDetails: () -> Void
@State private var showRetakeConfirmation = false
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 16) {
// Mini-Pentagon (80 × 80 pt, ohne Achsenbeschriftungen)
PentagonChartView(profile: profile, size: 80, showLabels: false)
.frame(width: 80, height: 80)
.accessibilityLabel("Mini-Persönlichkeitsprofil-Diagramm")
VStack(alignment: .leading, spacing: 6) {
Text("Dein Profil")
.font(.headline)
Text(shortSummary)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
}
}
HStack(spacing: 10) {
Button(action: onShowDetails) {
Text("Details")
.font(.subheadline.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(NahbarInsightStyle.accentPetrol)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.accessibilityLabel("Persönlichkeitsprofil-Details anzeigen")
Button {
showRetakeConfirmation = true
} label: {
Text("Erneut")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.accessibilityLabel("Quiz erneut ausfüllen")
}
}
.padding(NahbarInsightStyle.cardPadding)
.background(NahbarInsightStyle.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: NahbarInsightStyle.cardCornerRadius))
.confirmationDialog(
"Quiz erneut ausfüllen?",
isPresented: $showRetakeConfirmation,
titleVisibility: .visible
) {
Button("Erneut ausfüllen", role: .destructive) {
PersonalityStore.shared.reset()
onRetake()
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Dein bestehendes Profil wird dabei überschrieben.")
}
}
/// Erste zwei Sätze des Zusammenfassungstexts.
private var shortSummary: String {
let sentences = profile.summaryText
.components(separatedBy: ". ")
.filter { !$0.isEmpty }
let first2 = sentences.prefix(2).joined(separator: ". ")
return first2.hasSuffix(".") ? first2 : first2 + "."
}
}
+199
View File
@@ -0,0 +1,199 @@
import Foundation
// MARK: - PersonalityEngine
/// Reine Berechnungslogik keine UI-Abhängigkeiten, vollständig testbar.
/// Mappt Quiz-Antworten auf ein PersonalityProfile und leitet daraus
/// App-Verhaltens-Parameter ab.
enum PersonalityEngine {
// MARK: - Profil berechnen
/// Berechnet ein PersonalityProfile aus den gegebenen Antworten.
///
/// - Parameter answers: Array von Tupeln (questionID, choseA).
/// `choseA = true` bedeutet Option A wurde gewählt, `false` = Option B.
/// Übersprungene Fragen können fehlen; fehlende Dimensionen erhalten 0 Punkte.
/// - Returns: Fertiges PersonalityProfile mit Zeitstempel.
static func computeProfile(from answers: [(questionID: String, choseA: Bool)]) -> PersonalityProfile {
var scores: [OceanDimension: Int] = [:]
// Alle Dimensionen mit 0 initialisieren
for dim in OceanDimension.allCases { scores[dim] = 0 }
// Antworten auswerten
let answerMap = Dictionary(uniqueKeysWithValues: answers.map { ($0.questionID, $0.choseA) })
for question in QuizQuestion.all {
guard let choseA = answerMap[question.id] else { continue }
let points = choseA ? question.optionAScore : (1 - question.optionAScore)
scores[question.dimension, default: 0] += points
}
return PersonalityProfile(scores: scores, completedAt: Date())
}
// MARK: - Zusammenfassungstext
/// Regelbasierter Zusammenfassungstext (kein API-Aufruf). Delegiert an PersonalityProfile.
static func summaryText(for profile: PersonalityProfile) -> String {
profile.summaryText
}
// MARK: - Nudge-Intervall
/// Empfohlenes Kontakt-Nudge-Intervall in Tagen basierend auf dem Profil.
/// - High Extraversion kürzeres Intervall (häufigere Vorschläge)
/// - Low Extraversion / High Neuroticism längeres Intervall
static func suggestedNudgeInterval(for profile: PersonalityProfile) -> Int {
let e = profile.level(for: .extraversion)
let n = profile.level(for: .neuroticism)
switch (e, n) {
case (.high, _): return 3 // Gesellig alle 3 Tage
case (.medium, _): return 7 // Ausgeglichen alle 7 Tage
case (.low, .high): return 14 // Introvertiert + sensibel alle 14 Tage
default: return 10 // Introvertiert alle 10 Tage
}
}
// MARK: - Push-Benachrichtigungs-Text
/// Persönlichkeitsgerechter Benachrichtigungstext für Kontakt-Erinnerungen.
/// - High Neuroticism wärmer, ermutigend
/// - Low/Medium Neuroticism direkt, casual
static func notificationCopy(contactName: String, profile: PersonalityProfile?) -> String {
guard let profile else {
return "\(contactName) wann hast du zuletzt etwas von ihm/ihr gehört?"
}
switch profile.level(for: .neuroticism) {
case .high:
return "Hey \(contactName) freut sich sicher, von dir zu hören. 🙂"
case .medium:
return "\(contactName) Zeit für ein kurzes Hallo?"
case .low:
return "\(contactName) wann hast du zuletzt was von ihm/ihr gehört?"
}
}
// MARK: - Besuchsbewertungs-Timing
/// Gibt an, ob der Besuchsfragebogen verzögert angezeigt werden soll.
/// - High Conscientiousness sofort
/// - High Neuroticism 2h Verzögerung mit weicherem Text
static func ratingPromptTiming(for profile: PersonalityProfile?) -> RatingPromptTiming {
guard let profile else { return .immediate(copy: nil) }
let c = profile.level(for: .conscientiousness)
let n = profile.level(for: .neuroticism)
if c == .high {
return .immediate(copy: nil)
} else if n == .high {
return .delayed(
seconds: 7200, // 2 Stunden
copy: "Wenn du magst, kannst du das Treffen kurz reflektieren."
)
}
return .immediate(copy: nil)
}
// MARK: - Kontakt-Reihenfolge
/// Gibt eine sortierte Liste von Kontakten zurück, wobei persönlichkeitsgesteuerte
/// Empfehlungen zuerst erscheinen und eine Begründung erhalten.
///
/// - High Agreeableness Kontakte priorisieren, die lange nicht besucht wurden
/// - Low Extraversion Gesamtanzahl der Vorschläge reduzieren
static func sortedSuggestions(
contacts: [NahbarContact],
profile: PersonalityProfile?,
lastVisitDates: [UUID: Date]
) -> [ContactSuggestion] {
guard let profile else {
return contacts.map { ContactSuggestion(contact: $0, isRecommended: false, reason: nil) }
}
let a = profile.level(for: .agreeableness)
let e = profile.level(for: .extraversion)
// Maximale Anzahl an Vorschlägen nach Extraversion
let maxCount: Int
switch e {
case .high: maxCount = contacts.count
case .medium: maxCount = min(contacts.count, 5)
case .low: maxCount = min(contacts.count, 3)
}
// Sortierstrategie nach Agreeableness
var sorted = contacts
if a == .high {
sorted = contacts.sorted { lhs, rhs in
let lDate = lastVisitDates[lhs.id] ?? .distantPast
let rDate = lastVisitDates[rhs.id] ?? .distantPast
return lDate < rDate // ältester Besuch zuerst
}
}
return sorted.prefix(maxCount).map { contact in
let longAgo: Bool
if let lastVisit = lastVisitDates[contact.id] {
let daysSince = Calendar.current.dateComponents([.day], from: lastVisit, to: Date()).day ?? 0
longAgo = daysSince > 14
} else {
longAgo = true
}
let isRecommended = a == .high && longAgo
let reason: String? = isRecommended ? "Schon länger nichts von \(contact.givenName) gehört." : nil
return ContactSuggestion(contact: contact, isRecommended: isRecommended, reason: reason)
}
}
// 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 "Etwas Neues ausprobieren" hervorgehoben werden soll.
static func highlightNovelty(for profile: PersonalityProfile?) -> Bool {
profile?.level(for: .openness) == .high
}
// MARK: - Intervall-Empfehlung für Einstellungen
/// Gibt den empfohlenen Benachrichtigungs-Intervall für das Einstellungsmenü zurück.
static func recommendedNotificationInterval(for profile: PersonalityProfile?) -> Int? {
guard let profile else { return nil }
return suggestedNudgeInterval(for: profile)
}
}
// MARK: - Supporting Types
/// Ergebnis der persönlichkeitsgesteuerten Kontakt-Sortierung.
struct ContactSuggestion: Identifiable {
var id: UUID { contact.id }
let contact: NahbarContact
/// Ob dieser Kontakt aufgrund des Profils empfohlen wird.
let isRecommended: Bool
/// Kurze Begründung für den RecommendedBadge (nil wenn nicht empfohlen).
let reason: String?
}
/// Zeitpunkt und Kontext für den Besuchsfragebogen-Prompt.
enum RatingPromptTiming {
case immediate(copy: String?)
case delayed(seconds: Int, copy: String?)
}
/// Präferierter Aktivitätsstil für Vorhaben-Vorschläge.
enum ActivityStyle {
case group
case oneOnOne
}
+292
View File
@@ -0,0 +1,292 @@
import Foundation
// MARK: - OceanDimension
/// Die fünf Dimensionen des Big-Five-Persönlichkeitsmodells (OCEAN).
enum OceanDimension: String, CaseIterable, Codable, Hashable {
case openness = "openness"
case conscientiousness = "conscientiousness"
case extraversion = "extraversion"
case agreeableness = "agreeableness"
case neuroticism = "neuroticism"
/// Kurzbezeichnung für den Pentagon-Chart (einstellig).
var shortLabel: String {
switch self {
case .openness: return "O"
case .conscientiousness: return "G"
case .extraversion: return "E"
case .agreeableness: return "V"
case .neuroticism: return "A"
}
}
/// Vollständiger Achsenbeschriftungs-Text im Pentagon-Chart.
var axisLabel: String {
switch self {
case .openness: return "Offen"
case .conscientiousness: return "Verlässlich"
case .extraversion: return "Gesellig"
case .agreeableness: return "Verträglich"
case .neuroticism: return "Ausgeglichen"
}
}
/// Anzeigename (vollständig, für Ergebnis-Screen).
var displayName: String {
switch self {
case .openness: return "Offenheit"
case .conscientiousness: return "Verlässlichkeit"
case .extraversion: return "Geselligkeit"
case .agreeableness: return "Verträglichkeit"
case .neuroticism: return "Ausgeglichenheit"
}
}
/// SF-Symbol-Name der Dimension.
var icon: String {
switch self {
case .openness: return "lightbulb.fill"
case .conscientiousness: return "checkmark.seal.fill"
case .extraversion: return "person.2.fill"
case .agreeableness: return "heart.fill"
case .neuroticism: return "leaf.fill"
}
}
}
// MARK: - TraitLevel
/// Ausprägungsstufe eines OCEAN-Merkmals (02 Punkte pro Dimension).
enum TraitLevel: String, Codable, CaseIterable {
case low = "low"
case medium = "medium"
case high = "high"
static func from(score: Int) -> TraitLevel {
switch score {
case 0: return .low
case 1: return .medium
default: return .high
}
}
}
// MARK: - QuizQuestion
/// Eine einzelne Situationsfrage im Persönlichkeitsquiz.
/// Die Wahl zwischen optionA und optionB fließt mit `optionAScore` in die Dimension ein.
struct QuizQuestion: Identifiable {
/// Stabiler String-Schlüssel, auch als Lokalisierungsschlüssel verwendet.
let id: String
let dimension: OceanDimension
/// Situationstext (Lokalisierungsschlüssel).
let situation: String
/// Option A-Text (Lokalisierungsschlüssel).
let optionA: String
/// Option B-Text (Lokalisierungsschlüssel).
let optionB: String
/// Punkte, die Dimension erhält wenn Option A gewählt wird (0 oder 1).
/// Option B ergibt automatisch `1 - optionAScore`.
let optionAScore: Int
// MARK: - 10 Fragen gemäß Big-Five-Spezifikation
static let all: [QuizQuestion] = [
// Offenheit O1
QuizQuestion(
id: "O1",
dimension: .openness,
situation: "Ein Freund schlägt spontan eine Aktivität vor, die du noch nie gemacht hast.",
optionA: "Du sagst sofort zu neue Erfahrungen reizen dich.",
optionB: "Du schlägst lieber etwas vor, das ihr beide gut kennt.",
optionAScore: 1
),
// Offenheit O2
QuizQuestion(
id: "O2",
dimension: .openness,
situation: "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.",
optionB: "Du wartest, bis ein Bekannter mitkommt.",
optionAScore: 1
),
// Verlässlichkeit C1
QuizQuestion(
id: "C1",
dimension: .conscientiousness,
situation: "Du hast einem Freund versprochen zu helfen. Am Morgen bist du müde.",
optionA: "Du erscheinst wie abgemacht dein Wort gilt.",
optionB: "Du fragst kurz nach, ob es sich verschieben lässt.",
optionAScore: 1
),
// Verlässlichkeit C2
QuizQuestion(
id: "C2",
dimension: .conscientiousness,
situation: "Nächste Woche hat eine Freundin Geburtstag.",
optionA: "Du hast es dir sofort notiert und planst etwas Besonderes.",
optionB: "Du reagierst spontan, wenn der Tag kommt.",
optionAScore: 1
),
// Geselligkeit E1
QuizQuestion(
id: "E1",
dimension: .extraversion,
situation: "Nach einer anstrengenden Woche hast du einen freien Samstag.",
optionA: "Du rufst spontan Freunde an und organisierst ein Treffen.",
optionB: "Du genießt die Ruhe und tankst alleine auf.",
optionAScore: 1
),
// Geselligkeit E2
QuizQuestion(
id: "E2",
dimension: .extraversion,
situation: "Auf einer Nachbarschaftsparty kennst du kaum jemanden.",
optionA: "Du gehst aktiv auf Fremde zu und fängst Gespräche an.",
optionB: "Du wartest, bis jemand dich anspricht.",
optionAScore: 1
),
// Verträglichkeit A1
QuizQuestion(
id: "A1",
dimension: .agreeableness,
situation: "Ein Nachbar bittet um einen Gefallen, der dir gerade ungelegen kommt.",
optionA: "Du hilfst trotzdem anderen etwas Gutes tun liegt dir.",
optionB: "Du erklärst ehrlich, dass es dir gerade nicht passt.",
optionAScore: 1
),
// Verträglichkeit A2
QuizQuestion(
id: "A2",
dimension: .agreeableness,
situation: "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.",
optionB: "Du sprichst deine Sorgen an, auch wenn es Spannung erzeugt.",
optionAScore: 1
),
// Ausgeglichenheit N1 (invertiert: A = stabil = hohes N-inverted)
QuizQuestion(
id: "N1",
dimension: .neuroticism,
situation: "Von einem guten Freund hast du zwei Wochen nichts gehört.",
optionA: "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.",
optionAScore: 0 // A = emotional stabil = 0 Neurotizismus-Punkte
),
// Ausgeglichenheit N2 (invertiert)
QuizQuestion(
id: "N2",
dimension: .neuroticism,
situation: "Verabredungen mit Freunden fallen kurzfristig aus.",
optionA: "Du zuckst die Schultern und findest schnell etwas anderes.",
optionB: "Du bist enttäuscht und brauchst Zeit, um dich neu zu sortieren.",
optionAScore: 0 // A = emotional stabil = 0 Neurotizismus-Punkte
),
]
}
// MARK: - PersonalityProfile
/// Ergebnis des OCEAN-Persönlichkeitsquiz.
/// Scores: 02 pro Dimension (Summe aus 2 Fragen à 0 oder 1 Punkt).
struct PersonalityProfile: Codable, Equatable {
// MARK: Daten
/// Rohpunkte je Dimension (0 = niedrig, 1 = mittel, 2 = hoch).
let scores: [OceanDimension: Int]
/// Zeitpunkt der letzten Quiz-Ausfüllung.
let completedAt: Date?
var isComplete: Bool { completedAt != nil }
// MARK: Hilfsmethoden
func level(for dimension: OceanDimension) -> TraitLevel {
TraitLevel.from(score: scores[dimension] ?? 1)
}
/// Normalisierter Wert 01 für Pentagon-Chart-Vertex.
func normalized(for dimension: OceanDimension) -> Double {
let score = Double(scores[dimension] ?? 1)
return score / 2.0 // 00.0, 10.5, 21.0
}
// MARK: Zusammenfassung (regelbasiert, kein API-Aufruf)
/// Kurzer, warmer Zusammenfassungstext. Kein OCEAN-Fachjargon.
var summaryText: String {
let e = level(for: .extraversion)
let o = level(for: .openness)
let c = level(for: .conscientiousness)
let a = level(for: .agreeableness)
let n = level(for: .neuroticism)
var parts: [String] = []
// Sozialverhalten
switch e {
case .high: parts.append("Du gehst offen auf Menschen zu und genießt Gesellschaft.")
case .medium: parts.append("Du schätzt Gesellschaft, brauchst aber auch Zeit für dich.")
case .low: parts.append("Du findest Energie eher in der Stille als im Trubel.")
}
// Verlässlichkeit
switch c {
case .high: parts.append("Anderen kannst du sich auf dich verlassen.")
case .medium: parts.append("Du bist zuverlässig, wenn es darauf ankommt.")
case .low: parts.append("Du lebst gerne spontan und im Moment.")
}
// Offenheit
switch o {
case .high: parts.append("Neue Erfahrungen und Ideen reizen dich.")
case .medium: parts.append("Du bist offen für Neues, schätzt aber auch das Vertraute.")
case .low: parts.append("Du schätzt Bewährtes und Verlässliches.")
}
// Ausgeglichenheit (Neurotizismus inverted)
switch n {
case .low: parts.append("Kleinen Rückschlägen begegnest du mit Gelassenheit.")
case .medium: parts.append("Du findest nach Schwierigkeiten gut wieder in deine Mitte.")
case .high: parts.append("Du nimmst Dinge nah an dir wahr das macht dich empathisch.")
}
// Verträglichkeit
if a == .high {
parts.append("Das Wohlergehen anderer liegt dir am Herzen.")
}
return parts.joined(separator: " ")
}
// MARK: Codable (Dictionary-Schlüssel als String)
enum CodingKeys: String, CodingKey {
case scores, completedAt
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
// Encode OceanDimension keys as Strings
let stringScores = Dictionary(uniqueKeysWithValues: scores.map { ($0.key.rawValue, $0.value) })
try container.encode(stringScores, forKey: .scores)
try container.encodeIfPresent(completedAt, forKey: .completedAt)
}
init(scores: [OceanDimension: Int], completedAt: Date?) {
self.scores = scores
self.completedAt = completedAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let stringScores = try container.decode([String: Int].self, forKey: .scores)
scores = Dictionary(uniqueKeysWithValues: stringScores.compactMap { key, val in
guard let dim = OceanDimension(rawValue: key) else { return nil }
return (dim, val)
})
completedAt = try container.decodeIfPresent(Date.self, forKey: .completedAt)
}
}
+285
View File
@@ -0,0 +1,285 @@
import SwiftUI
// MARK: - PersonalityQuizView
/// Sheet-tauglicher Container für den vollständigen Quiz-Flow.
/// Zeigt: Intro Fragen Ergebnis.
/// Wenn `skipIntro = true`, wird der Intro-Screen übersprungen.
struct PersonalityQuizView: View {
let onComplete: (PersonalityProfile?) -> Void
var skipIntro: Bool = false
@Environment(\.dismiss) private var dismiss
private enum Phase: Equatable {
case intro
case questions
case result(PersonalityProfile)
static func == (lhs: Phase, rhs: Phase) -> Bool {
switch (lhs, rhs) {
case (.intro, .intro), (.questions, .questions): return true
case (.result, .result): return true
default: return false
}
}
}
@State private var phase: Phase
init(onComplete: @escaping (PersonalityProfile?) -> Void, skipIntro: Bool = false) {
self.onComplete = onComplete
self.skipIntro = skipIntro
self._phase = State(initialValue: skipIntro ? .questions : .intro)
}
var body: some View {
Group {
switch phase {
case .intro:
QuizIntroScreen(
onStart: { withAnimation(.spring(response: 0.4)) { phase = .questions } },
onSkip: { onComplete(nil); dismiss() }
)
case .questions:
QuizQuestionsScreen(
onComplete: { profile in
withAnimation(.spring(response: 0.4)) { phase = .result(profile) }
},
onSkip: { onComplete(nil); dismiss() }
)
case .result(let profile):
PersonalityResultView(
profile: profile,
onContinue: { onComplete(profile); dismiss() }
)
}
}
}
}
// MARK: - QuizIntroScreen
/// Warmer Einstiegsscreen. Kann direkt in OnboardingContainerView
/// und innerhalb von PersonalityQuizView eingesetzt werden.
struct QuizIntroScreen: View {
let onStart: () -> Void
let onSkip: () -> Void
var body: some View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "brain.head.profile")
.font(.system(size: 72))
.foregroundStyle(NahbarInsightStyle.accentPetrol)
.accessibilityHidden(true)
.padding(.bottom, 32)
VStack(spacing: 12) {
Text("Wie tickst du?")
.font(.title.bold())
.multilineTextAlignment(.center)
Text("10 kurze Situationen. Keine falschen Antworten. Dauert etwa 2 Minuten.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
.padding(.bottom, 24)
PrivacyBadgeView(context: .localOnly)
.padding(.horizontal, NahbarInsightStyle.horizontalPadding)
Spacer()
VStack(spacing: 14) {
Button(action: onStart) {
Text("Quiz starten")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(NahbarInsightStyle.accentPetrol)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.accessibilityLabel("Persönlichkeitsquiz starten")
Button(action: onSkip) {
Text("Überspringen")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.accessibilityLabel("Quiz überspringen")
}
.padding(.horizontal, NahbarInsightStyle.horizontalPadding)
.padding(.bottom, 40)
}
}
}
// MARK: - QuizQuestionsScreen
private struct QuizQuestionsScreen: View {
let onComplete: (PersonalityProfile) -> Void
let onSkip: () -> Void
@State private var currentIndex = 0
@State private var answers: [String: Bool] = [:] // questionID choseA
@State private var selectedOption: OptionChoice? = nil
@State private var isAdvancing = false
private let questions = QuizQuestion.all
var body: some View {
VStack(spacing: 0) {
progressDots
.padding(.top, 24)
.padding(.bottom, 28)
// Fragen-Karten in ZStack für Slide-Transition
ZStack {
ForEach(Array(questions.enumerated()), id: \.element.id) { index, question in
if index == currentIndex {
questionContent(for: question)
.transition(NahbarInsightStyle.slideTransition)
}
}
}
.clipped()
.animation(.spring(response: 0.38, dampingFraction: 0.82), value: currentIndex)
Spacer()
Button(action: skipCurrentQuestion) {
Text("Überspringen")
.font(NahbarInsightStyle.captionFont)
.foregroundStyle(.tertiary)
}
.padding(.bottom, 32)
}
}
// MARK: Progress Dots
private var progressDots: some View {
HStack(spacing: 6) {
ForEach(0..<questions.count, id: \.self) { index in
Circle()
.fill(index <= currentIndex
? NahbarInsightStyle.accentPetrol
: Color.secondary.opacity(0.3))
.frame(
width: index == currentIndex
? NahbarInsightStyle.progressDotActive
: NahbarInsightStyle.progressDotInactive,
height: index == currentIndex
? NahbarInsightStyle.progressDotActive
: NahbarInsightStyle.progressDotInactive
)
.animation(.spring(response: 0.3), value: currentIndex)
}
}
.accessibilityLabel("Frage \(currentIndex + 1) von \(questions.count)")
}
// MARK: Question Content
@ViewBuilder
private func questionContent(for question: QuizQuestion) -> some View {
VStack(spacing: 20) {
Text(question.situation)
.font(NahbarInsightStyle.situationFont)
.multilineTextAlignment(.center)
.padding(NahbarInsightStyle.cardPadding)
.frame(maxWidth: .infinity)
.background(NahbarInsightStyle.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: NahbarInsightStyle.cardCornerRadius))
.shadow(color: .black.opacity(0.06), radius: 8, x: 0, y: 2)
.padding(.horizontal, NahbarInsightStyle.horizontalPadding)
VStack(spacing: 12) {
optionButton(text: question.optionA, choice: .a, question: question)
optionButton(text: question.optionB, choice: .b, question: question)
}
.padding(.horizontal, NahbarInsightStyle.horizontalPadding)
}
}
// MARK: Option Button
@ViewBuilder
private func optionButton(
text: String,
choice: OptionChoice,
question: QuizQuestion
) -> some View {
let isSelected = selectedOption == choice
Button {
guard !isAdvancing else { return }
recordAnswer(choice, for: question)
} label: {
Text(text)
.font(NahbarInsightStyle.optionFont)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, minHeight: 64)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isSelected
? NahbarInsightStyle.recommendedTint
: Color(.tertiarySystemBackground))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
isSelected ? NahbarInsightStyle.accentPetrol : Color.clear,
lineWidth: 2
)
)
)
}
.buttonStyle(.plain)
.animation(.easeInOut(duration: 0.15), value: isSelected)
.accessibilityLabel(text)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
// MARK: Logic
private func recordAnswer(_ choice: OptionChoice, for question: QuizQuestion) {
isAdvancing = true
selectedOption = choice
UIImpactFeedbackGenerator(style: .light).impactOccurred()
answers[question.id] = (choice == .a)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { advance() }
}
private func skipCurrentQuestion() {
guard !isAdvancing else { return }
isAdvancing = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { advance() }
}
private func advance() {
let next = currentIndex + 1
if next < questions.count {
withAnimation { selectedOption = nil; currentIndex = next }
isAdvancing = false
} else {
let profile = PersonalityEngine.computeProfile(
from: answers.map { (questionID: $0.key, choseA: $0.value) }
)
PersonalityStore.shared.save(profile: profile)
onComplete(profile)
}
}
}
// MARK: - OptionChoice
private enum OptionChoice: Equatable { case a, b }
+239
View File
@@ -0,0 +1,239 @@
import SwiftUI
// MARK: - PentagonChartView
/// Pentagon-Diagramm für das OCEAN-Persönlichkeitsprofil.
/// Verwendet SwiftUI Path (kein Charts-Framework). Animiert beim Erscheinen.
struct PentagonChartView: View {
let profile: PersonalityProfile
var size: CGFloat = 200
/// Achsenbeschriftungen anzeigen (für Miniatur-Variante deaktivieren).
var showLabels: Bool = true
@State private var scale: CGFloat = 0
/// Dimensionsreihenfolge: oben oben-rechts unten-rechts unten-links oben-links
private let dimensions: [OceanDimension] = [
.openness, .conscientiousness, .extraversion, .agreeableness, .neuroticism
]
var body: some View {
ZStack {
// Hintergrundgitter bei 50 % und 100 %
pentagonPath(factor: 1.0)
.stroke(Color.secondary.opacity(0.15), lineWidth: 1)
pentagonPath(factor: 0.5)
.stroke(Color.secondary.opacity(0.10), lineWidth: 1)
// Datenprofil (Füllung + Kontur)
dataPolygon
.fill(NahbarInsightStyle.accentPetrol.opacity(0.25))
dataPolygon
.stroke(NahbarInsightStyle.accentPetrol, lineWidth: 2)
// Achsenbeschriftungen
if showLabels {
axisLabels
}
}
.frame(width: size, height: size)
.scaleEffect(scale)
.onAppear {
withAnimation(.spring(dampingFraction: 0.7)) { scale = 1.0 }
}
}
// MARK: Pentagon-Pfade
private func pentagonPath(factor: CGFloat) -> Path {
let inset: CGFloat = showLabels ? 20 : 4
return Path { path in
let center = CGPoint(x: size / 2, y: size / 2)
let radius = (size / 2 - inset) * factor
for (i, _) in dimensions.enumerated() {
let pt = pointOnCircle(center: center, radius: radius, index: i)
if i == 0 { path.move(to: pt) } else { path.addLine(to: pt) }
}
path.closeSubpath()
}
}
private var dataPolygon: Path {
let inset: CGFloat = showLabels ? 20 : 4
return Path { path in
let center = CGPoint(x: size / 2, y: size / 2)
let maxRadius = size / 2 - inset
for (i, dim) in dimensions.enumerated() {
let radius = maxRadius * profile.normalized(for: dim)
let pt = pointOnCircle(center: center, radius: radius, index: i)
if i == 0 { path.move(to: pt) } else { path.addLine(to: pt) }
}
path.closeSubpath()
}
}
// MARK: Achsenbeschriftungen
private var axisLabels: some View {
GeometryReader { geo in
let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2)
let labelRadius = geo.size.width / 2 - 2
ForEach(Array(dimensions.enumerated()), id: \.element) { index, dim in
let angle = angleForIndex(index)
let x = center.x + labelRadius * sin(angle)
let y = center.y - labelRadius * cos(angle)
Text(dim.axisLabel)
.font(.system(size: max(8, size * 0.045)))
.foregroundStyle(.secondary)
.fixedSize()
.position(x: x, y: y)
}
}
}
// MARK: Hilfsgeometrie
private func pointOnCircle(center: CGPoint, radius: CGFloat, index: Int) -> CGPoint {
let angle = angleForIndex(index)
return CGPoint(
x: center.x + radius * sin(angle),
y: center.y - radius * cos(angle)
)
}
private func angleForIndex(_ index: Int) -> CGFloat {
2 * .pi * CGFloat(index) / CGFloat(dimensions.count)
}
}
// MARK: - PersonalityResultView
/// Ergebnis-Screen nach Abschluss des Persönlichkeitsquiz.
/// Zeigt Pentagon-Diagramm, Zusammenfassungstext und alle Dimensionszeilen.
struct PersonalityResultView: View {
let profile: PersonalityProfile
let onContinue: () -> Void
var body: some View {
ScrollView {
VStack(spacing: 28) {
// Titel
Text("Dein Profil")
.font(.title.bold())
.padding(.top, 32)
.accessibilityAddTraits(.isHeader)
// Pentagon-Diagramm
PentagonChartView(profile: profile)
.accessibilityLabel("Persönlichkeits-Pentagon-Diagramm")
// Zusammenfassungstext
Text(profile.summaryText)
.font(.body)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal, NahbarInsightStyle.horizontalPadding)
// Dimensionszeilen
VStack(spacing: 10) {
ForEach(OceanDimension.allCases, id: \.self) { dim in
dimensionRow(for: dim)
}
}
.padding(.horizontal, NahbarInsightStyle.horizontalPadding)
// Datenschutz-Badge
PrivacyBadgeView(context: .localOnly)
.padding(.horizontal, NahbarInsightStyle.horizontalPadding)
// Weiter-Button
Button(action: onContinue) {
Text("Weiter")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(NahbarInsightStyle.accentPetrol)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal, NahbarInsightStyle.horizontalPadding)
.padding(.bottom, 40)
.accessibilityLabel("Ergebnis bestätigen und fortfahren")
}
}
}
// MARK: Dimensionszeile
@ViewBuilder
private func dimensionRow(for dim: OceanDimension) -> some View {
let level = profile.level(for: dim)
HStack(spacing: 12) {
Image(systemName: dim.icon)
.font(.body)
.foregroundStyle(NahbarInsightStyle.color(for: dim))
.frame(width: 28)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(dim.displayName)
.font(.subheadline.weight(.medium))
Text(interpretationText(dim: dim, level: level))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
levelBadge(level)
}
.padding(12)
.background(NahbarInsightStyle.cardBackground)
.clipShape(RoundedRectangle(cornerRadius: 10))
.accessibilityElement(children: .combine)
.accessibilityLabel("\(dim.displayName): \(levelLabel(level)), \(interpretationText(dim: dim, level: level))")
}
@ViewBuilder
private func levelBadge(_ level: TraitLevel) -> some View {
Text(levelLabel(level))
.font(NahbarInsightStyle.badgeFont)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(NahbarInsightStyle.color(for: level).opacity(0.15))
.foregroundStyle(NahbarInsightStyle.color(for: level))
.clipShape(Capsule())
}
private func levelLabel(_ level: TraitLevel) -> String {
switch level {
case .low: return "Niedrig"
case .medium: return "Mittel"
case .high: return "Hoch"
}
}
private func interpretationText(dim: OceanDimension, level: TraitLevel) -> String {
switch (dim, level) {
case (.openness, .high): return "Neugierig und experimentierfreudig"
case (.openness, .medium): return "Ausgeglichene Offenheit"
case (.openness, .low): return "Verlässt sich auf Bewährtes"
case (.conscientiousness, .high): return "Strukturiert und verlässlich"
case (.conscientiousness, .medium): return "Flexibel und zuverlässig"
case (.conscientiousness, .low): return "Spontan und adaptiv"
case (.extraversion, .high): return "Energiegeladen im Kontakt"
case (.extraversion, .medium): return "Kontaktfreudig und reflektiert"
case (.extraversion, .low): return "Genießt die eigene Gesellschaft"
case (.agreeableness, .high): return "Empathisch und kooperativ"
case (.agreeableness, .medium): return "Ausgewogen und fair"
case (.agreeableness, .low): return "Direkt und eigenständig"
case (.neuroticism, .high): return "Feinfühlig und empathisch"
case (.neuroticism, .medium): return "Ausgewogen emotional"
case (.neuroticism, .low): return "Gelassen und stabil"
}
}
}
+91
View File
@@ -0,0 +1,91 @@
import SwiftUI
import Combine
import OSLog
private let personalityLogger = Logger(subsystem: "nahbar", category: "PersonalityStore")
// MARK: - PersonalityStoring Protocol
/// Protokoll für Testbarkeit und Dependency Injection.
protocol PersonalityStoring: AnyObject {
var profile: PersonalityProfile? { get }
var hasCompletedQuiz: Bool { get }
func save(profile: PersonalityProfile)
func reset()
}
// MARK: - PersonalityStore
/// Speichert das OCEAN-Persönlichkeitsprofil lokal in UserDefaults.
/// Folgt dem Singleton-Muster von UserProfileStore.
/// Kein SwiftData ein einzelnes Profil braucht kein relationales Modell.
final class PersonalityStore: ObservableObject, PersonalityStoring {
static let shared = PersonalityStore()
// MARK: Öffentlicher Zustand
@Published private(set) var profile: PersonalityProfile? = nil
var hasCompletedQuiz: Bool { profile?.isComplete == true }
/// Gibt an, ob der Nutzer das Quiz beim Onboarding übersprungen hat.
@AppStorage("hasSkippedPersonalityQuiz") var hasSkippedQuiz: Bool = false
// MARK: Persistence
private let defaults = UserDefaults.standard
private let storageKey = "nahbar.personalityProfile"
private init() { load() }
// MARK: Schreiben
func save(profile: PersonalityProfile) {
self.profile = profile
persist()
personalityLogger.debug("Persönlichkeitsprofil gespeichert (completedAt: \(profile.completedAt?.description ?? "nil"))")
}
/// Setzt das Profil zurück (Entwickler-Werkzeug + "Erneut ausfüllen"-Flow).
func reset() {
defaults.removeObject(forKey: storageKey)
profile = nil
hasSkippedQuiz = false
personalityLogger.info("Persönlichkeitsprofil zurückgesetzt")
}
// MARK: Private Helpers
private func persist() {
guard let profile else { return }
var dict: [String: Any] = [:]
// Scores als [String: Int] (OceanDimension.rawValue Int)
var scoresDict: [String: Int] = [:]
for (dim, val) in profile.scores {
scoresDict[dim.rawValue] = val
}
dict["scores"] = scoresDict
if let ts = profile.completedAt?.timeIntervalSince1970 {
dict["completedAt"] = ts
}
defaults.set(dict, forKey: storageKey)
}
private func load() {
guard
let dict = defaults.dictionary(forKey: storageKey),
let rawScores = dict["scores"] as? [String: Int]
else { return }
var scores: [OceanDimension: Int] = [:]
for (key, val) in rawScores {
if let dim = OceanDimension(rawValue: key) {
scores[dim] = val
}
}
let completedAt: Date? = (dict["completedAt"] as? Double).map { Date(timeIntervalSince1970: $0) }
profile = PersonalityProfile(scores: scores, completedAt: completedAt)
personalityLogger.debug("Persönlichkeitsprofil geladen")
}
}
+198
View File
@@ -0,0 +1,198 @@
import SwiftUI
// MARK: - PrivacyContext
/// Describes the data-sensitivity context for a privacy disclosure badge.
enum PrivacyContext {
/// General profile data stays on device; AI exception applies.
case profile
/// Contact data local only, never sent to any server.
case contacts
/// AI-powered feature sends data to an external service.
case aiFeature
/// Full summary shown at the final onboarding tour step.
case summary
/// Strictly local data no exceptions, no network transfer of any kind.
case localOnly
}
// MARK: - PrivacyBadgeView
/// Context-aware privacy disclosure badge.
/// Use this wherever data-sensitive actions occur throughout the app.
struct PrivacyBadgeView: View {
let context: PrivacyContext
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: iconName)
.foregroundStyle(iconColor)
.font(.subheadline.weight(.semibold))
.accessibilityHidden(true)
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 10))
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription)
}
// MARK: - Style helpers
private var iconName: String {
switch context {
case .profile, .contacts, .summary, .localOnly: "lock.fill"
case .aiFeature: "info.circle.fill"
}
}
private var iconColor: Color {
switch context {
case .profile, .contacts, .summary, .localOnly: .green
case .aiFeature: .yellow
}
}
private var backgroundColor: Color {
switch context {
case .profile, .contacts, .summary, .localOnly: Color.green.opacity(0.1)
case .aiFeature: Color.yellow.opacity(0.1)
}
}
private var message: LocalizedStringKey {
switch context {
case .profile:
"🔒 Deine Daten bleiben auf deinem iPhone. nahbar speichert nichts in der Cloud außer wenn du KI-Funktionen verwendest."
case .contacts:
"📱 Kontakte werden ausschließlich lokal gespeichert und niemals mit Servern geteilt."
case .aiFeature:
"Diese Funktion sendet Daten an einen KI-Dienst. Nur bei KI-Nutzung."
case .localOnly:
"🔒 Diese Daten bleiben ausschließlich auf deinem iPhone und werden niemals übertragen."
case .summary:
"🔒 Alle deine Daten Kontakte, Besuche, Vorhaben bleiben lokal auf deinem iPhone.\nKeine Registrierung. Kein Account. Keine Cloud.\nAusnahme: KI-Funktionen senden anonymisierte Anfragen an einen KI-Dienst."
}
}
private var accessibilityDescription: String {
switch context {
case .profile:
String(localized: "Datenschutzhinweis: Deine Daten bleiben auf deinem iPhone. Keine Cloud-Speicherung außer bei KI-Funktionen.")
case .contacts:
String(localized: "Datenschutzhinweis: Kontakte werden ausschließlich lokal gespeichert.")
case .aiFeature:
String(localized: "Datenschutzhinweis: Diese Funktion sendet Daten an einen externen KI-Dienst.")
case .localOnly:
String(localized: "Datenschutzhinweis: Diese Daten werden ausschließlich lokal auf deinem Gerät gespeichert und niemals übertragen.")
case .summary:
String(localized: "Datenschutzzusammenfassung: Alle Daten bleiben lokal auf deinem iPhone. Keine Registrierung, kein Account, keine Cloud. KI-Anfragen werden anonymisiert gesendet.")
}
}
}
// MARK: - AIConsentSheet
/// One-time consent sheet shown before the first use of any AI feature.
/// Explains that data will be sent to a third-party service (Anthropic Claude).
/// After the user accepts, `aiConsentGiven` (@AppStorage) is set to `true`
/// so the sheet is never shown again.
struct AIConsentSheet: View {
/// Called when the user taps "Verstanden, KI verwenden".
let onAccept: () -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 0) {
// Handle bar
RoundedRectangle(cornerRadius: 2)
.fill(Color.secondary.opacity(0.35))
.frame(width: 36, height: 4)
.padding(.top, 12)
ScrollView {
VStack(spacing: 28) {
// Icon
Image(systemName: "sparkles")
.font(.system(size: 56))
.foregroundStyle(.yellow)
.padding(.top, 24)
.accessibilityHidden(true)
// Title + body
VStack(spacing: 12) {
Text("KI-Funktion verwenden?")
.font(.title2.bold())
.multilineTextAlignment(.center)
Text("Diese Funktion überträgt Informationen über die ausgewählte Person an einen externen KI-Dienst (Anthropic Claude). Die Übertragung erfolgt verschlüsselt aber Daten verlassen dein Gerät.\n\nDu entscheidest jederzeit selbst, ob du KI-Funktionen nutzt. Ohne deine Bestätigung werden keine Daten gesendet.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 24)
PrivacyBadgeView(context: .aiFeature)
.padding(.horizontal, 24)
Spacer(minLength: 8)
}
}
// Buttons
VStack(spacing: 12) {
Button {
dismiss()
onAccept()
} label: {
Text("Verstanden, KI verwenden")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
Button("Abbrechen") {
dismiss()
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24)
.padding(.bottom, 32)
.padding(.top, 8)
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden) // we draw our own
}
}
#Preview {
ScrollView {
VStack(spacing: 12) {
PrivacyBadgeView(context: .profile)
PrivacyBadgeView(context: .contacts)
PrivacyBadgeView(context: .aiFeature)
PrivacyBadgeView(context: .summary)
}
.padding()
}
}
#Preview("AI Consent Sheet") {
Color.clear
.sheet(isPresented: .constant(true)) {
AIConsentSheet { }
}
}
+144 -1
View File
@@ -1,4 +1,5 @@
import SwiftUI
import SwiftData
import LocalAuthentication
struct SettingsView: View {
@@ -16,9 +17,20 @@ struct SettingsView: View {
@AppStorage("aiAPIKey") private var aiAPIKey: String = AIConfig.fallback.apiKey
@AppStorage("aiModel") private var aiModel: String = AIConfig.fallback.model
@StateObject private var store = StoreManager.shared
@StateObject private var personalityStore = PersonalityStore.shared
@Environment(\.modelContext) private var modelContext
@State private var showingPINSetup = false
@State private var showingPINDisable = false
@State private var showPaywall = false
@State private var showingResetConfirmation = false
@State private var showingQuiz = 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 {
switch appLockManager.biometricType {
@@ -413,11 +425,104 @@ struct SettingsView: View {
.animation(.easeInOut(duration: 0.2), value: cloudSyncMonitor.state == .syncing)
}
// Entwickler-Log
// 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")
@@ -465,6 +570,44 @@ struct SettingsView: View {
.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-Flags zurücksetzen
nahbarOnboardingDone = false
callWindowOnboardingDone = false
photoRepairPassDone = false
callSuggestionDate = ""
// 4. App neu starten damit alle States frisch initialisiert werden
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { exit(0) }
}
}
+1
View File
@@ -76,6 +76,7 @@ struct RowDivider: View {
.fill(theme.borderSubtle)
.frame(height: 0.5)
.padding(.leading, 16)
.allowsHitTesting(false)
}
}
+13 -1
View File
@@ -257,6 +257,8 @@ struct GiftSuggestionRow: View {
@State private var state: GiftSuggestionState = .idle
@State private var isExpanded = false
@State private var showPaywall = false
@State private var showAIConsent = false
@AppStorage("aiConsentGiven") private var aiConsentGiven = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -285,6 +287,12 @@ struct GiftSuggestionRow: View {
}
.animation(.easeInOut(duration: 0.2), value: isExpanded)
.sheet(isPresented: $showPaywall) { PaywallView(targeting: .max) }
.sheet(isPresented: $showAIConsent) {
AIConsentSheet {
aiConsentGiven = true
Task { await loadGift() }
}
}
}
private var canUseAI: Bool {
@@ -294,7 +302,11 @@ struct GiftSuggestionRow: View {
private var idleButton: some View {
Button {
guard canUseAI else { showPaywall = true; return }
Task { await loadGift() }
if aiConsentGiven {
Task { await loadGift() }
} else {
showAIConsent = true
}
} label: {
HStack(spacing: 8) {
Image(systemName: "gift")
+23 -2
View File
@@ -14,6 +14,8 @@ final class UserProfileStore: ObservableObject {
static let shared = UserProfileStore()
@Published private(set) var name: String = ""
@Published private(set) var displayName: String = ""
@Published private(set) var aboutMe: String = ""
@Published private(set) var birthday: Date? = nil
@Published private(set) var occupation: String = ""
@Published private(set) var location: String = ""
@@ -29,7 +31,7 @@ final class UserProfileStore: ObservableObject {
// MARK: - Derived
var isEmpty: Bool {
name.isEmpty && occupation.isEmpty && location.isEmpty
name.isEmpty && displayName.isEmpty && occupation.isEmpty && location.isEmpty
&& likes.isEmpty && dislikes.isEmpty && socialStyle.isEmpty
}
@@ -82,9 +84,13 @@ final class UserProfileStore: ObservableObject {
location: String,
likes: String,
dislikes: String,
socialStyle: String
socialStyle: String,
displayName: String = "",
aboutMe: String = ""
) {
self.name = name
self.displayName = displayName
self.aboutMe = aboutMe
self.birthday = birthday
self.occupation = occupation
self.location = location
@@ -99,6 +105,8 @@ final class UserProfileStore: ObservableObject {
private func save() {
var dict: [String: Any] = [
"name": name,
"displayName": displayName,
"aboutMe": aboutMe,
"occupation": occupation,
"location": location,
"likes": likes,
@@ -110,9 +118,22 @@ final class UserProfileStore: ObservableObject {
logger.debug("UserProfile gespeichert")
}
// MARK: - Reset (Entwickler-Tool)
func reset() {
defaults.removeObject(forKey: storageKey)
if let url = photoURL { try? FileManager.default.removeItem(at: url) }
name = ""; displayName = ""; aboutMe = ""
birthday = nil; occupation = ""; location = ""
likes = ""; dislikes = ""; socialStyle = ""
logger.info("UserProfile zurückgesetzt")
}
private func load() {
guard let dict = defaults.dictionary(forKey: storageKey) else { return }
name = dict["name"] as? String ?? ""
displayName = dict["displayName"] as? String ?? ""
aboutMe = dict["aboutMe"] as? String ?? ""
occupation = dict["occupation"] as? String ?? ""
location = dict["location"] as? String ?? ""
likes = dict["likes"] as? String ?? ""
+210
View File
@@ -0,0 +1,210 @@
import Testing
import Foundation
import Contacts
import ContactsUI
@testable import nahbar
// MARK: - ContactPickerBridge Mehrfachauswahl
@Suite("ContactPickerBridge Mehrfachauswahl")
struct ContactPickerBridgeMultiTests {
@Test("didSelect contacts → alle Kontakte an Callback")
func multiSelectPassesAll() {
let b = ContactPickerBridge()
var received: [CNContact] = []
b.pendingCallback = { received = $0 }
let a = CNMutableContact(); a.givenName = "Alice"
let c = CNMutableContact(); c.givenName = "Bob"
b.contactPicker(CNContactPickerViewController(), didSelect: [a, c])
#expect(received.count == 2)
#expect(received[0].givenName == "Alice")
#expect(received[1].givenName == "Bob")
}
@Test("didSelect contacts leer → leeres Array an Callback")
func emptySelectionPassesEmpty() {
let b = ContactPickerBridge()
var received: [CNContact] = [CNMutableContact()] // vorbefüllt zum Unterscheiden
b.pendingCallback = { received = $0 }
b.contactPicker(CNContactPickerViewController(), didSelect: [] as [CNContact])
#expect(received.isEmpty)
}
@Test("didSelect contact (singular) → in Array verpackt")
func singularWrappedInArray() {
let b = ContactPickerBridge()
var received: [CNContact] = []
b.pendingCallback = { received = $0 }
let contact = CNMutableContact(); contact.givenName = "Einzeln"
b.contactPicker(CNContactPickerViewController(), didSelect: contact)
#expect(received.count == 1)
#expect(received.first?.givenName == "Einzeln")
}
@Test("contactPickerDidCancel → kein Callback, pendingCallback nil")
func cancelNoCallback() {
let b = ContactPickerBridge()
var callbackFired = false
b.pendingCallback = { _ in callbackFired = true }
b.contactPickerDidCancel(CNContactPickerViewController())
#expect(!callbackFired)
#expect(b.pendingCallback == nil)
}
@Test("pendingCallback wird nach didSelect auf nil gesetzt")
func callbackClearedAfterSelect() {
let b = ContactPickerBridge()
b.pendingCallback = { _ in }
b.contactPicker(CNContactPickerViewController(), didSelect: [] as [CNContact])
#expect(b.pendingCallback == nil)
}
}
// MARK: - ContactPickerBridge Einzelauswahl (via presentSingle)
@Suite("ContactPickerBridge Einzelauswahl")
struct ContactPickerBridgeSingleTests {
@Test("presentSingle → didSelect contact → Callback mit diesem Kontakt")
func singleSelectCallback() {
let b = ContactPickerBridge()
var received: CNContact? = nil
// presentSingle setzt pendingCallback; presentPicker() schlägt in Tests stumm fehl
b.presentSingle { received = $0 }
let contact = CNMutableContact(); contact.givenName = "Anna"
b.contactPicker(CNContactPickerViewController(), didSelect: contact)
#expect(received?.givenName == "Anna")
}
@Test("presentSingle → didSelect contacts leer → kein Callback")
func emptyContactsNoSingleCallback() {
let b = ContactPickerBridge()
var callbackFired = false
b.presentSingle { _ in callbackFired = true }
b.contactPicker(CNContactPickerViewController(), didSelect: [] as [CNContact])
#expect(!callbackFired)
}
@Test("presentSingle → didSelect contacts → erstes Element an Callback")
func singularFromPluralDelegate() {
let b = ContactPickerBridge()
var received: CNContact? = nil
b.presentSingle { received = $0 }
let first = CNMutableContact(); first.givenName = "Erster"
let second = CNMutableContact(); second.givenName = "Zweiter"
b.contactPicker(CNContactPickerViewController(), didSelect: [first, second])
#expect(received?.givenName == "Erster")
}
@Test("contactPickerDidCancel → kein Callback")
func cancelNoCallback() {
let b = ContactPickerBridge()
var callbackFired = false
b.presentSingle { _ in callbackFired = true }
b.contactPickerDidCancel(CNContactPickerViewController())
#expect(!callbackFired)
}
}
// MARK: - ContactImport Mapping von CNContact
@Suite("ContactImport Mapping von CNContact")
struct ContactImportTests {
@Test("Vor- und Nachname → fullName korrekt")
func fullNameMapping() {
let contact = CNMutableContact()
contact.givenName = "Anna"; contact.familyName = "Schmidt"
#expect(ContactImport.from(contact).name == "Anna Schmidt")
}
@Test("Nur Vorname → kein Leerzeichen am Ende")
func onlyFirstName() {
let contact = CNMutableContact(); contact.givenName = "Cher"
#expect(ContactImport.from(contact).name == "Cher")
}
@Test("Nur Nachname → kein Leerzeichen am Anfang")
func onlyLastName() {
let contact = CNMutableContact(); contact.familyName = "Prince"
#expect(ContactImport.from(contact).name == "Prince")
}
@Test("Berufsbezeichnung bevorzugt gegenüber Firma")
func jobTitlePreferredOverOrg() {
let contact = CNMutableContact()
contact.jobTitle = "Designer"; contact.organizationName = "ACME GmbH"
#expect(ContactImport.from(contact).occupation == "Designer")
}
@Test("Firma als Fallback wenn kein Beruf")
func orgNameFallback() {
let contact = CNMutableContact(); contact.organizationName = "ACME GmbH"
#expect(ContactImport.from(contact).occupation == "ACME GmbH")
}
@Test("Weder Beruf noch Firma → leerer String")
func emptyOccupation() {
#expect(ContactImport.from(CNMutableContact()).occupation == "")
}
@Test("Stadt und Land → location korrekt")
func locationCityAndCountry() {
let contact = CNMutableContact()
let address = CNMutablePostalAddress()
address.city = "Berlin"; address.country = "Deutschland"
contact.postalAddresses = [CNLabeledValue(label: CNLabelHome, value: address)]
#expect(ContactImport.from(contact).location == "Berlin, Deutschland")
}
@Test("Kein Ort → leerer String")
func emptyLocation() {
#expect(ContactImport.from(CNMutableContact()).location == "")
}
@Test("Geburtstag mit vollständigem Datum → wird übernommen")
func birthdayFullDate() {
let contact = CNMutableContact()
contact.birthday = DateComponents(year: 1990, month: 6, day: 15)
let result = ContactImport.from(contact)
let cal = Calendar.current
#expect(result.birthday != nil)
#expect(cal.component(.year, from: result.birthday!) == 1990)
#expect(cal.component(.month, from: result.birthday!) == 6)
#expect(cal.component(.day, from: result.birthday!) == 15)
}
@Test("Geburtstag mit Jahr=1 → aktuelles Jahr wird verwendet")
func birthdayYearOneReplacedWithCurrentYear() {
let contact = CNMutableContact()
contact.birthday = DateComponents(year: 1, month: 3, day: 8)
let result = ContactImport.from(contact)
let currentYear = Calendar.current.component(.year, from: Date())
#expect(result.birthday != nil)
#expect(Calendar.current.component(.year, from: result.birthday!) == currentYear)
}
@Test("Kein Geburtstag → nil")
func noBirthday() {
#expect(ContactImport.from(CNMutableContact()).birthday == nil)
}
@Test("Kein Foto → photoData ist nil")
func noPhoto() {
#expect(ContactImport.from(CNMutableContact()).photoData == nil)
}
}
@@ -0,0 +1,450 @@
import Testing
import Foundation
@testable import nahbar
// MARK: - OceanDimension
@Suite("OceanDimension Enum")
struct OceanDimensionTests {
@Test("Genau 5 Dimensionen vorhanden")
func allCasesCount() {
#expect(OceanDimension.allCases.count == 5)
}
@Test("rawValues sind nicht leer")
func rawValuesNotEmpty() {
for dim in OceanDimension.allCases {
#expect(!dim.rawValue.isEmpty, "\(dim) hat leeren rawValue")
}
}
@Test("rawValue round-trip (init(rawValue:))")
func rawValueRoundTrip() {
for dim in OceanDimension.allCases {
let recovered = OceanDimension(rawValue: dim.rawValue)
#expect(recovered == dim, "\(dim.rawValue) kann nicht wiederhergestellt werden")
}
}
@Test("shortLabel hat genau 1 Zeichen")
func shortLabelLength() {
for dim in OceanDimension.allCases {
#expect(dim.shortLabel.count == 1, "\(dim) hat shortLabel '\(dim.shortLabel)' (nicht 1 Zeichen)")
}
}
@Test("shortLabels sind eindeutig")
func shortLabelsUnique() {
let labels = OceanDimension.allCases.map { $0.shortLabel }
#expect(Set(labels).count == labels.count, "Doppelte shortLabels: \(labels)")
}
@Test("Stabile rawValues Regressionswächter")
func stableRawValues() {
#expect(OceanDimension.openness.rawValue == "openness")
#expect(OceanDimension.conscientiousness.rawValue == "conscientiousness")
#expect(OceanDimension.extraversion.rawValue == "extraversion")
#expect(OceanDimension.agreeableness.rawValue == "agreeableness")
#expect(OceanDimension.neuroticism.rawValue == "neuroticism")
}
@Test("axisLabel ist nicht leer")
func axisLabelNotEmpty() {
for dim in OceanDimension.allCases {
#expect(!dim.axisLabel.isEmpty)
}
}
@Test("icon ist nicht leer")
func iconNotEmpty() {
for dim in OceanDimension.allCases {
#expect(!dim.icon.isEmpty)
}
}
}
// MARK: - TraitLevel
@Suite("TraitLevel Schwellenwerte")
struct TraitLevelTests {
@Test("Score 0 → niedrig")
func score0IsLow() { #expect(TraitLevel.from(score: 0) == .low) }
@Test("Score 1 → mittel")
func score1IsMedium() { #expect(TraitLevel.from(score: 1) == .medium) }
@Test("Score 2 → hoch")
func score2IsHigh() { #expect(TraitLevel.from(score: 2) == .high) }
@Test("Score >2 → hoch (overflow sicher)")
func scoreOverflowIsHigh() { #expect(TraitLevel.from(score: 99) == .high) }
@Test("rawValue round-trip")
func rawValueRoundTrip() {
for level in TraitLevel.allCases {
#expect(TraitLevel(rawValue: level.rawValue) == level)
}
}
@Test("Stabile rawValues Regressionswächter")
func stableRawValues() {
#expect(TraitLevel.low.rawValue == "low")
#expect(TraitLevel.medium.rawValue == "medium")
#expect(TraitLevel.high.rawValue == "high")
}
}
// MARK: - QuizQuestion
@Suite("QuizQuestion Statische Fragen")
struct QuizQuestionTests {
@Test("Genau 10 Fragen")
func totalCount() {
#expect(QuizQuestion.all.count == 10)
}
@Test("Genau 2 Fragen pro Dimension")
func twoQuestionsPerDimension() {
for dim in OceanDimension.allCases {
let count = QuizQuestion.all.filter { $0.dimension == dim }.count
#expect(count == 2, "Dimension \(dim.rawValue) hat \(count) statt 2 Fragen")
}
}
@Test("Alle IDs sind eindeutig")
func uniqueIDs() {
let ids = QuizQuestion.all.map { $0.id }
#expect(Set(ids).count == ids.count, "Doppelte Fragen-IDs: \(ids)")
}
@Test("Situationstexte sind nicht leer")
func situationTextsNotEmpty() {
for q in QuizQuestion.all {
#expect(!q.situation.isEmpty, "Frage \(q.id) hat leeren Situationstext")
}
}
@Test("Option-Texte sind nicht leer")
func optionTextsNotEmpty() {
for q in QuizQuestion.all {
#expect(!q.optionA.isEmpty, "Frage \(q.id) hat leeren optionA-Text")
#expect(!q.optionB.isEmpty, "Frage \(q.id) hat leeren optionB-Text")
}
}
@Test("optionAScore ist 0 oder 1")
func optionAScoreIsValid() {
for q in QuizQuestion.all {
#expect(q.optionAScore == 0 || q.optionAScore == 1,
"Frage \(q.id) hat ungültigen optionAScore: \(q.optionAScore)")
}
}
@Test("Neurotizismus-Fragen haben optionAScore 0 (invertiert)")
func neuroticismIsInverted() {
let nQuestions = QuizQuestion.all.filter { $0.dimension == .neuroticism }
for q in nQuestions {
#expect(q.optionAScore == 0,
"Neurotizismus-Frage \(q.id) sollte optionAScore=0 haben (Option A = stabil)")
}
}
@Test("Stabile Fragen-IDs Regressionswächter")
func stableQuestionIDs() {
let ids = QuizQuestion.all.map { $0.id }
#expect(ids == ["O1", "O2", "C1", "C2", "E1", "E2", "A1", "A2", "N1", "N2"])
}
}
// MARK: - PersonalityEngine
@Suite("PersonalityEngine Score-Berechnung")
struct PersonalityEngineTests {
@Test("Alle Option-A → korrekter Score nach optionAScore")
func allOptionAGivesCorrectScore() {
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
let profile = PersonalityEngine.computeProfile(from: allA)
for dim in OceanDimension.allCases {
let expected = QuizQuestion.all
.filter { $0.dimension == dim }
.reduce(0) { $0 + $1.optionAScore }
#expect(profile.scores[dim] == expected,
"\(dim.rawValue): erwartet \(expected), bekommen \(profile.scores[dim] ?? -1)")
}
}
@Test("Alle Option-B → invertierter Score")
func allOptionBGivesInvertedScore() {
let allB = QuizQuestion.all.map { (questionID: $0.id, choseA: false) }
let profile = PersonalityEngine.computeProfile(from: allB)
for dim in OceanDimension.allCases {
let expected = QuizQuestion.all
.filter { $0.dimension == dim }
.reduce(0) { $0 + (1 - $1.optionAScore) }
#expect(profile.scores[dim] == expected,
"\(dim.rawValue): erwartet \(expected), bekommen \(profile.scores[dim] ?? -1)")
}
}
@Test("Neurotizismus invertiertes Scoring: Option A = 0 Punkte, Option B = 1 Punkt")
func neuroticismInvertedScoring() {
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
let profileA = PersonalityEngine.computeProfile(from: allA)
#expect(profileA.scores[.neuroticism] == 0,
"N mit Option A: erwartet 0, bekommen \(profileA.scores[.neuroticism] ?? -1)")
let allB = QuizQuestion.all.map { (questionID: $0.id, choseA: false) }
let profileB = PersonalityEngine.computeProfile(from: allB)
#expect(profileB.scores[.neuroticism] == 2,
"N mit Option B: erwartet 2, bekommen \(profileB.scores[.neuroticism] ?? -1)")
}
@Test("Profil ist complete nach 10 Antworten")
func profileIsCompleteAfterTenAnswers() {
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
let profile = PersonalityEngine.computeProfile(from: allA)
#expect(profile.isComplete)
#expect(profile.completedAt != nil)
}
@Test("Scores liegen immer im Bereich 0…2")
func scoresAlwaysInValidRange() {
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
let profileA = PersonalityEngine.computeProfile(from: allA)
let allB = QuizQuestion.all.map { (questionID: $0.id, choseA: false) }
let profileB = PersonalityEngine.computeProfile(from: allB)
for dim in OceanDimension.allCases {
#expect((profileA.scores[dim] ?? -1) >= 0 && (profileA.scores[dim] ?? -1) <= 2)
#expect((profileB.scores[dim] ?? -1) >= 0 && (profileB.scores[dim] ?? -1) <= 2)
}
}
@Test("Fehlende Antworten (übersprungen) führen zu Score 0 für die Dimension")
func skippedAnswersGiveZero() {
let profile = PersonalityEngine.computeProfile(from: [])
for dim in OceanDimension.allCases {
#expect(profile.scores[dim] == 0, "\(dim.rawValue) sollte 0 sein bei leeren Antworten")
}
}
@Test("completedAt liegt zwischen before und after dem Aufruf")
func completedAtIsReasonablyNow() {
let before = Date()
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
let profile = PersonalityEngine.computeProfile(from: allA)
let after = Date()
if let ts = profile.completedAt {
#expect(ts >= before && ts <= after)
} else {
Issue.record("completedAt sollte gesetzt sein")
}
}
}
// MARK: - PersonalityProfile
@Suite("PersonalityProfile Berechnung & Codable")
struct PersonalityProfileTests {
private func makeProfile(scores: [OceanDimension: Int] = [:]) -> PersonalityProfile {
var full = Dictionary(uniqueKeysWithValues: OceanDimension.allCases.map { ($0, 1) })
for (k, v) in scores { full[k] = v }
return PersonalityProfile(scores: full, completedAt: Date())
}
@Test("level für Score 0 → niedrig")
func levelLowForScoreZero() {
let p = makeProfile(scores: [.openness: 0])
#expect(p.level(for: .openness) == .low)
}
@Test("level für Score 1 → mittel")
func levelMediumForScoreOne() {
let p = makeProfile(scores: [.openness: 1])
#expect(p.level(for: .openness) == .medium)
}
@Test("level für Score 2 → hoch")
func levelHighForScoreTwo() {
let p = makeProfile(scores: [.openness: 2])
#expect(p.level(for: .openness) == .high)
}
@Test("normalized: Score 0 → 0.0")
func normalizedMinIsZero() {
let p = makeProfile(scores: [.openness: 0])
#expect(p.normalized(for: .openness) == 0.0)
}
@Test("normalized: Score 1 → 0.5")
func normalizedMidIsFifty() {
let p = makeProfile(scores: [.openness: 1])
#expect(p.normalized(for: .openness) == 0.5)
}
@Test("normalized: Score 2 → 1.0")
func normalizedMaxIsOne() {
let p = makeProfile(scores: [.openness: 2])
#expect(p.normalized(for: .openness) == 1.0)
}
@Test("summaryText ist nicht leer für alle Kombinationen")
func summaryTextNeverEmpty() {
for scores in [[TraitLevel.low, .low, .low, .low, .low],
[.high, .high, .high, .high, .high],
[.medium, .medium, .medium, .medium, .medium]] {
let profile = PersonalityProfile(
scores: Dictionary(uniqueKeysWithValues:
OceanDimension.allCases.enumerated().map { i, dim in
(dim, scores[i] == .low ? 0 : scores[i] == .medium ? 1 : 2)
}),
completedAt: Date()
)
#expect(!profile.summaryText.isEmpty)
}
}
@Test("isComplete ist false wenn completedAt nil")
func isCompleteIsFalseWhenNilDate() {
let p = PersonalityProfile(scores: [:], completedAt: nil)
#expect(!p.isComplete)
}
@Test("isComplete ist true wenn completedAt gesetzt")
func isCompleteIsTrueWhenDateSet() {
let p = makeProfile()
#expect(p.isComplete)
}
@Test("Codable round-trip via JSONEncoder/Decoder")
func codableRoundTrip() throws {
let original = makeProfile(scores: [.openness: 2, .extraversion: 0, .neuroticism: 1])
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(PersonalityProfile.self, from: data)
#expect(decoded == original)
}
@Test("PersonalityProfile benötigt keinen Netzwerkaufruf")
func doesNotRequireNetwork() {
// Rein synchron kein await, kein async
let p = makeProfile()
let summary = p.summaryText
#expect(!summary.isEmpty)
}
}
// MARK: - PersonalityEngine Behavior Logic
@Suite("PersonalityEngine Verhaltenslogik")
struct PersonalityEngineBehaviorTests {
private func profile(e: TraitLevel = .medium, n: TraitLevel = .medium,
c: TraitLevel = .medium, a: TraitLevel = .medium,
o: TraitLevel = .medium) -> PersonalityProfile {
func score(_ l: TraitLevel) -> Int { l == .low ? 0 : l == .medium ? 1 : 2 }
return PersonalityProfile(scores: [
.extraversion: score(e), .neuroticism: score(n),
.conscientiousness: score(c), .agreeableness: score(a), .openness: score(o)
], completedAt: Date())
}
@Test("Hohe Extraversion → kürzeres Nudge-Intervall als niedrige")
func highExtraversionGivesShorterInterval() {
let highE = PersonalityEngine.suggestedNudgeInterval(for: profile(e: .high))
let lowE = PersonalityEngine.suggestedNudgeInterval(for: profile(e: .low))
#expect(highE < lowE)
}
@Test("Hoher Neurotizismus-Score + niedrige Extraversion → 14 Tage")
func lowExtraversionHighNeuroticismGives14Days() {
// N1 und N2 haben optionAScore=0, Option B gibt Punkte
// In unserem Modell: hoher Neurotizismus-Score = mehr N-Punkte
let p = profile(e: .low, n: .high)
#expect(PersonalityEngine.suggestedNudgeInterval(for: p) == 14)
}
@Test("Hohe Gewissenhaftigkeit → sofortiger Rating-Prompt")
func highConscientiousnessGivesImmediatePrompt() {
let p = profile(c: .high)
if case .immediate = PersonalityEngine.ratingPromptTiming(for: p) {
// korrekt
} else {
Issue.record("Hohe Gewissenhaftigkeit sollte immediate prompt liefern")
}
}
@Test("Hoher Neurotizismus → verzögerter Rating-Prompt (7200s)")
func highNeuroticismGivesDelayedPrompt() {
let p = profile(c: .low, n: .high)
if case .delayed(let secs, _) = PersonalityEngine.ratingPromptTiming(for: p) {
#expect(secs == 7200)
} else {
Issue.record("Hoher Neurotizismus sollte delayed prompt liefern")
}
}
@Test("Benachrichtigungstext mit hohem Neurotizismus ist wärmer")
func highNeuroticismNotificationCopyIsWarmer() {
let p = profile(n: .high)
let copy = PersonalityEngine.notificationCopy(contactName: "Alex", profile: p)
#expect(copy.contains("freut sich"))
}
@Test("Benachrichtigungstext ohne Profil liefert Fallback")
func nilProfileGivesFallback() {
let copy = PersonalityEngine.notificationCopy(contactName: "Alex", profile: nil)
#expect(!copy.isEmpty)
}
@Test("RecommendedBadge nicht angezeigt wenn Quiz übersprungen (kein Profil)")
func noBadgeWhenNoProfile() {
let suggestions = PersonalityEngine.sortedSuggestions(
contacts: [],
profile: nil,
lastVisitDates: [:]
)
for s in suggestions {
#expect(!s.isRecommended)
}
}
@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: - OnboardingStep Regressionswächter (nach Quiz-Erweiterung)
@Suite("OnboardingStep RawValues (Quiz-Erweiterung)")
struct OnboardingStepQuizTests {
@Test("RawValues sind aufsteigend 04")
@MainActor func rawValuesSequential() {
#expect(OnboardingStep.profile.rawValue == 0)
#expect(OnboardingStep.quiz.rawValue == 1)
#expect(OnboardingStep.contacts.rawValue == 2)
#expect(OnboardingStep.tour.rawValue == 3)
#expect(OnboardingStep.complete.rawValue == 4)
}
@Test("allCases enthält genau 5 Schritte")
@MainActor func allCasesCountIsFive() {
#expect(OnboardingStep.allCases.count == 5)
}
}
+237
View File
@@ -0,0 +1,237 @@
import Testing
import Foundation
@testable import nahbar
// MARK: - OnboardingCoordinator Tests
@Suite("OnboardingCoordinator Validierung")
struct OnboardingCoordinatorValidationTests {
@Test("Leerer Vorname → isProfileValid ist false")
@MainActor func emptyFirstNameIsInvalid() {
let coord = OnboardingCoordinator()
coord.firstName = ""
#expect(!coord.isProfileValid)
}
@Test("Nur Leerzeichen → isProfileValid ist false")
@MainActor func whitespaceFirstNameIsInvalid() {
let coord = OnboardingCoordinator()
coord.firstName = " "
#expect(!coord.isProfileValid)
}
@Test("Nicht-leerer Vorname → isProfileValid ist true")
@MainActor func nonEmptyFirstNameIsValid() {
let coord = OnboardingCoordinator()
coord.firstName = "Max"
#expect(coord.isProfileValid)
}
@Test("Vorname mit führenden Leerzeichen gilt als gültig")
@MainActor func firstNameWithLeadingSpaceIsValid() {
let coord = OnboardingCoordinator()
coord.firstName = " A"
// "A" bleibt nach trim valid
#expect(coord.isProfileValid)
}
}
@Suite("OnboardingCoordinator Schrittnavigation")
struct OnboardingCoordinatorNavigationTests {
@Test("Startzustand ist .profile")
@MainActor func initialStepIsProfile() {
let coord = OnboardingCoordinator()
#expect(coord.currentStep == .profile)
}
@Test("advanceToContacts ohne Vorname bleibt auf .profile")
@MainActor func advanceToContactsWithoutNameStaysOnProfile() {
let coord = OnboardingCoordinator()
coord.firstName = ""
coord.advanceToContacts()
#expect(coord.currentStep == .profile)
}
@Test("advanceToContacts mit gültigem Vorname → .contacts")
@MainActor func advanceToContactsWithNameGoesToContacts() {
let coord = OnboardingCoordinator()
coord.firstName = "Anna"
coord.advanceToContacts()
#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")
@MainActor func completeOnboardingSetsComplete() {
let coord = OnboardingCoordinator()
coord.completeOnboarding()
#expect(coord.currentStep == .complete)
}
}
// MARK: - NahbarContact Tests
@Suite("NahbarContact Initialisierung")
struct NahbarContactInitTests {
@Test("fullName kombiniert Vor- und Nachname")
func fullNameCombinesNames() {
let contact = NahbarContact(givenName: "Anna", familyName: "Schmidt")
#expect(contact.fullName == "Anna Schmidt")
}
@Test("fullName mit leerem Nachnamen gibt nur Vornamen zurück")
func fullNameWithEmptyFamilyName() {
let contact = NahbarContact(givenName: "Cher", familyName: "")
#expect(contact.fullName == "Cher")
}
@Test("fullName mit leerem Vornamen gibt nur Nachnamen zurück")
func fullNameWithEmptyGivenName() {
let contact = NahbarContact(givenName: "", familyName: "Prince")
#expect(contact.fullName == "Prince")
}
@Test("fullName mit beiden leeren Teilen ist leerer String")
func fullNameBothEmpty() {
let contact = NahbarContact(givenName: "", familyName: "")
#expect(contact.fullName == "")
}
}
@Suite("NahbarContact Initialen")
struct NahbarContactInitialsTests {
@Test("Initialen aus Vor- und Nachname")
func initialsFromBothNames() {
let contact = NahbarContact(givenName: "Anna", familyName: "Schmidt")
#expect(contact.initials == "AS")
}
@Test("Initialen nur Vorname")
func initialsFromGivenNameOnly() {
let contact = NahbarContact(givenName: "Cher", familyName: "")
#expect(contact.initials == "C")
}
@Test("Initialen nur Nachname")
func initialsFromFamilyNameOnly() {
let contact = NahbarContact(givenName: "", familyName: "Prince")
#expect(contact.initials == "P")
}
@Test("Initialen beide leer → ?")
func initialsBothEmpty() {
let contact = NahbarContact(givenName: "", familyName: "")
#expect(contact.initials == "?")
}
@Test("Initialen sind uppercase")
func initialsAreUppercase() {
let contact = NahbarContact(givenName: "anna", familyName: "bach")
#expect(contact.initials == "AB")
}
}
@Suite("NahbarContact Codable")
struct NahbarContactCodableTests {
@Test("Codable Roundtrip erhält alle Felder")
func codableRoundtrip() throws {
let original = NahbarContact(
id: UUID(),
givenName: "Max",
familyName: "Mustermann",
phoneNumbers: ["+49 123 456"],
notes: "Freund",
cnIdentifier: "abc-123"
)
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(NahbarContact.self, from: data)
#expect(decoded.id == original.id)
#expect(decoded.givenName == original.givenName)
#expect(decoded.familyName == original.familyName)
#expect(decoded.phoneNumbers == original.phoneNumbers)
#expect(decoded.notes == original.notes)
#expect(decoded.cnIdentifier == original.cnIdentifier)
}
@Test("Codable Roundtrip mit nil cnIdentifier")
func codableRoundtripNilCnIdentifier() throws {
let original = NahbarContact(givenName: "Test", familyName: "User", cnIdentifier: nil)
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(NahbarContact.self, from: data)
#expect(decoded.cnIdentifier == nil)
}
@Test("Mehrere Kontakte als Array codierbar")
func arrayOfContactsCodable() throws {
let contacts = [
NahbarContact(givenName: "Alice", familyName: "A"),
NahbarContact(givenName: "Bob", familyName: "B")
]
let data = try JSONEncoder().encode(contacts)
let decoded = try JSONDecoder().decode([NahbarContact].self, from: data)
#expect(decoded.count == 2)
#expect(decoded[0].givenName == "Alice")
#expect(decoded[1].givenName == "Bob")
}
}
// MARK: - OnboardingStep Tests
@Suite("OnboardingStep RawValue")
struct OnboardingStepTests {
@Test("RawValues sind aufsteigend 04")
func rawValuesAreSequential() {
#expect(OnboardingStep.profile.rawValue == 0)
#expect(OnboardingStep.quiz.rawValue == 1)
#expect(OnboardingStep.contacts.rawValue == 2)
#expect(OnboardingStep.tour.rawValue == 3)
#expect(OnboardingStep.complete.rawValue == 4)
}
@Test("allCases enthält genau 5 Schritte")
func allCasesCount() {
#expect(OnboardingStep.allCases.count == 5)
}
@Test("Reihenfolge von allCases stimmt mit rawValue überein")
func allCasesOrder() {
let cases = OnboardingStep.allCases
for (i, step) in cases.enumerated() {
#expect(step.rawValue == i)
}
}
}
+11 -5
View File
@@ -63,13 +63,14 @@ struct UserProfileStoreInitialsTests {
@Suite("UserProfileStore isEmpty")
struct UserProfileStoreIsEmptyTests {
// isEmpty = name.isEmpty && occupation.isEmpty && location.isEmpty
// isEmpty = name.isEmpty && displayName.isEmpty && occupation.isEmpty && location.isEmpty
// && likes.isEmpty && dislikes.isEmpty && socialStyle.isEmpty
private func isEmpty(name: String = "", occupation: String = "",
location: String = "", likes: String = "",
dislikes: String = "", socialStyle: String = "") -> Bool {
name.isEmpty && occupation.isEmpty && location.isEmpty
private func isEmpty(name: String = "", displayName: String = "",
occupation: String = "", location: String = "",
likes: String = "", dislikes: String = "",
socialStyle: String = "") -> Bool {
name.isEmpty && displayName.isEmpty && occupation.isEmpty && location.isEmpty
&& likes.isEmpty && dislikes.isEmpty && socialStyle.isEmpty
}
@@ -83,6 +84,11 @@ struct UserProfileStoreIsEmptyTests {
#expect(!isEmpty(name: "Max"))
}
@Test("Nur displayName gesetzt → isEmpty ist false")
func onlyDisplayNameSetIsFalse() {
#expect(!isEmpty(displayName: "Maxi"))
}
@Test("Nur Beruf gesetzt → isEmpty ist false")
func onlyOccupationSetIsFalse() {
#expect(!isEmpty(occupation: "Ingenieur"))