From 1c770c42d24ece10964a327414e01bb6957d5720 Mon Sep 17 00:00:00 2001 From: Sven Date: Sun, 19 Apr 2026 13:09:20 +0200 Subject: [PATCH] Onboaridng-Flow, PersonalityQuiz, UI-Verbesserungen. --- nahbar/.DS_Store | Bin 6148 -> 6148 bytes nahbar/AftermathNotificationManager.swift | 9 +- nahbar/nahbar.xcodeproj/project.pbxproj | 44 + .../UserInterfaceState.xcuserstate | Bin 59629 -> 70662 bytes nahbar/nahbar/AddPersonView.swift | 12 +- nahbar/nahbar/AppLockView.swift | 2 +- nahbar/nahbar/CallSuggestionView.swift | 16 + nahbar/nahbar/CallWindowManager.swift | 8 +- nahbar/nahbar/ContactPickerView.swift | 236 +++- nahbar/nahbar/ContentView.swift | 13 +- nahbar/nahbar/IchView.swift | 137 +- nahbar/nahbar/Localizable.xcstrings | 1103 ++++++++++++++++- nahbar/nahbar/LogbuchView.swift | 14 +- nahbar/nahbar/NahbarApp.swift | 2 + nahbar/nahbar/NahbarContact.swift | 122 ++ nahbar/nahbar/NahbarInsightStyle.swift | 111 ++ nahbar/nahbar/OnboardingContainerView.swift | 751 +++++++++++ nahbar/nahbar/OnboardingCoordinator.swift | 81 ++ nahbar/nahbar/PersonDetailView.swift | 81 +- nahbar/nahbar/PersonalityComponents.swift | 174 +++ nahbar/nahbar/PersonalityEngine.swift | 199 +++ nahbar/nahbar/PersonalityModels.swift | 292 +++++ nahbar/nahbar/PersonalityQuizView.swift | 285 +++++ nahbar/nahbar/PersonalityResultView.swift | 239 ++++ nahbar/nahbar/PersonalityStore.swift | 91 ++ nahbar/nahbar/PrivacyBadgeView.swift | 198 +++ nahbar/nahbar/SettingsView.swift | 145 ++- nahbar/nahbar/SharedComponents.swift | 1 + nahbar/nahbar/TodayView.swift | 14 +- nahbar/nahbar/UserProfileStore.swift | 25 +- nahbar/nahbarTests/ContactPickerTests.swift | 210 ++++ .../nahbarTests/NahbarPersonalityTests.swift | 450 +++++++ nahbar/nahbarTests/OnboardingTests.swift | 237 ++++ .../nahbarTests/UserProfileStoreTests.swift | 16 +- 34 files changed, 5255 insertions(+), 63 deletions(-) create mode 100644 nahbar/nahbar/NahbarContact.swift create mode 100644 nahbar/nahbar/NahbarInsightStyle.swift create mode 100644 nahbar/nahbar/OnboardingContainerView.swift create mode 100644 nahbar/nahbar/OnboardingCoordinator.swift create mode 100644 nahbar/nahbar/PersonalityComponents.swift create mode 100644 nahbar/nahbar/PersonalityEngine.swift create mode 100644 nahbar/nahbar/PersonalityModels.swift create mode 100644 nahbar/nahbar/PersonalityQuizView.swift create mode 100644 nahbar/nahbar/PersonalityResultView.swift create mode 100644 nahbar/nahbar/PersonalityStore.swift create mode 100644 nahbar/nahbar/PrivacyBadgeView.swift create mode 100644 nahbar/nahbarTests/ContactPickerTests.swift create mode 100644 nahbar/nahbarTests/NahbarPersonalityTests.swift create mode 100644 nahbar/nahbarTests/OnboardingTests.swift diff --git a/nahbar/.DS_Store b/nahbar/.DS_Store index 16f324b44a2f12f73fb1651f720530fc06e62621..9360938c7152f99fdc89d5ac096315dab0301535 100644 GIT binary patch delta 23 fcmZoMXffDupM`0~*~t%CGA0YM>TE7$?H2+7bcP7_ delta 25 hcmZoMXffDupM`13ugMQtGFWSll!-mq?8mxI2mq1^3i|*6 diff --git a/nahbar/AftermathNotificationManager.swift b/nahbar/AftermathNotificationManager.swift index d3ce3ff..f7a67b1 100644 --- a/nahbar/AftermathNotificationManager.swift +++ b/nahbar/AftermathNotificationManager.swift @@ -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 = [ diff --git a/nahbar/nahbar.xcodeproj/project.pbxproj b/nahbar/nahbar.xcodeproj/project.pbxproj index 245d370..a2d8e4d 100644 --- a/nahbar/nahbar.xcodeproj/project.pbxproj +++ b/nahbar/nahbar.xcodeproj/project.pbxproj @@ -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 = ""; }; 26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitEditFlowView.swift; sourceTree = ""; }; 26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 26B9930B2F94B32800E9B16C /* PrivacyBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyBadgeView.swift; sourceTree = ""; }; + 26B9930D2F94B33D00E9B16C /* NahbarContact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarContact.swift; sourceTree = ""; }; + 26B9930F2F94B34C00E9B16C /* OnboardingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCoordinator.swift; sourceTree = ""; }; 26BB85B82F9248BD00889312 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = ""; }; 26BB85BA2F924D9B00889312 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = ""; }; 26BB85BC2F924DB100889312 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = ""; }; @@ -134,6 +148,14 @@ 26EF66482F91352D00824F91 /* AppLockSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupView.swift; sourceTree = ""; }; 26EF664A2F913C8600824F91 /* LogbuchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogbuchView.swift; sourceTree = ""; }; 26EF664D2F91514B00824F91 /* ThemePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePickerView.swift; sourceTree = ""; }; + 26F8B0BE2F94B47C004905B9 /* OnboardingContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingContainerView.swift; sourceTree = ""; }; + 26F8B0C42F94E47F004905B9 /* PersonalityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityModels.swift; sourceTree = ""; }; + 26F8B0C62F94E499004905B9 /* NahbarInsightStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarInsightStyle.swift; sourceTree = ""; }; + 26F8B0C82F94E4B0004905B9 /* PersonalityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityStore.swift; sourceTree = ""; }; + 26F8B0CA2F94E4E1004905B9 /* PersonalityEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityEngine.swift; sourceTree = ""; }; + 26F8B0CE2F94E7B1004905B9 /* PersonalityQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityQuizView.swift; sourceTree = ""; }; + 26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityResultView.swift; sourceTree = ""; }; + 26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityComponents.swift; sourceTree = ""; }; /* 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 = ""; @@ -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; diff --git a/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate b/nahbar/nahbar.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate index e0434c5ecd7a9745540798a49e87fa99b8398e1b..2fc75b2ea69104d02d685d8feb38f7d9afc4237c 100644 GIT binary patch literal 70662 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
h{&;RLrJxFx}@ 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 literal 59629 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 ByaWIM diff --git a/nahbar/nahbar/AddPersonView.swift b/nahbar/nahbar/AddPersonView.swift index da275c5..56c253d 100644 --- a/nahbar/nahbar/AddPersonView.swift +++ b/nahbar/nahbar/AddPersonView.swift @@ -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 diff --git a/nahbar/nahbar/AppLockView.swift b/nahbar/nahbar/AppLockView.swift index 13f7a95..4fa2221 100644 --- a/nahbar/nahbar/AppLockView.swift +++ b/nahbar/nahbar/AppLockView.swift @@ -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") diff --git a/nahbar/nahbar/CallSuggestionView.swift b/nahbar/nahbar/CallSuggestionView.swift index 2b1e3aa..6801593 100644 --- a/nahbar/nahbar/CallSuggestionView.swift +++ b/nahbar/nahbar/CallSuggestionView.swift @@ -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 diff --git a/nahbar/nahbar/CallWindowManager.swift b/nahbar/nahbar/CallWindowManager.swift index 30bf21a..a9071f1 100644 --- a/nahbar/nahbar/CallWindowManager.swift +++ b/nahbar/nahbar/CallWindowManager.swift @@ -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" diff --git a/nahbar/nahbar/ContactPickerView.swift b/nahbar/nahbar/ContactPickerView.swift index faf7175..12dd77e 100644 --- a/nahbar/nahbar/ContactPickerView.swift +++ b/nahbar/nahbar/ContactPickerView.swift @@ -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)) } diff --git a/nahbar/nahbar/ContentView.swift b/nahbar/nahbar/ContentView.swift index b08c601..da5a61f 100644 --- a/nahbar/nahbar/ContentView.swift +++ b/nahbar/nahbar/ContentView.swift @@ -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() diff --git a/nahbar/nahbar/IchView.swift b/nahbar/nahbar/IchView.swift index 507dc02..a67955c 100644 --- a/nahbar/nahbar/IchView.swift +++ b/nahbar/nahbar/IchView.swift @@ -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 diff --git a/nahbar/nahbar/Localizable.xcstrings b/nahbar/nahbar/Localizable.xcstrings index 4b2d4d0..e878099 100644 --- a/nahbar/nahbar/Localizable.xcstrings +++ b/nahbar/nahbar/Localizable.xcstrings @@ -42,6 +42,16 @@ } } }, + "%@: %@, %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@: %2$@, %3$@" + } + } + } + }, "%@%@%@" : { "comment" : "A quote with an opening quotation mark, followed by the quote text, and optionally, the author's name in quotation marks. The first argument is the string “de”, the string “„” or the string ““”. The third argument is the string “de”, the string ““” or the string “””.", "isCommentAutoGenerated" : true, @@ -53,6 +63,9 @@ } } } + }, + "%lld ausgewählt" : { + }, "%lld Einträge" : { "comment" : "A label showing the number of log entries. The argument is the number of entries.", @@ -103,6 +116,9 @@ } } } + }, + "%lld Kontakte ausgewählt. Weiter." : { + }, "%lld von %lld Kontakten – Pro für mehr" : { "comment" : "A text label that shows the number of contacts that can be made for free, followed by a call to action to upgrade to Pro.", @@ -115,6 +131,48 @@ } } } + }, + "%lld von 100 Zeichen" : { + + }, + "%lld/100" : { + + }, + "📱 Kontakte werden ausschließlich lokal gespeichert und niemals mit Servern geteilt." : { + "comment" : "PrivacyBadgeView – contacts context message", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "📱 Contacts are stored exclusively locally and are never shared with servers." + } + } + } + }, + "🔒 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." : { + "comment" : "PrivacyBadgeView – summary context message", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 All your data – contacts, visits, plans – stays local on your iPhone.\nNo registration. No account. No cloud.\nException: AI features send anonymised requests to an AI service." + } + } + } + }, + "🔒 Deine Daten bleiben auf deinem iPhone. nahbar speichert nichts in der Cloud – außer wenn du KI-Funktionen verwendest." : { + "comment" : "PrivacyBadgeView – profile context message", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "🔒 Your data stays on your iPhone. nahbar stores nothing in the cloud – except when you use AI features." + } + } + } + }, + "🔒 Diese Daten bleiben ausschließlich auf deinem iPhone und werden niemals übertragen." : { + }, "1 Monat" : { "comment" : "Settings – look-ahead / period picker option", @@ -194,6 +252,16 @@ } } }, + "10 kurze Situationen. Keine falschen Antworten. Dauert etwa 2 Minuten." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 short situations. No wrong answers. Takes about 2 minutes." + } + } + } + }, "30 Min" : { "comment" : "AddMomentView – calendar event duration option", "localizations" : { @@ -282,6 +350,9 @@ } } } + }, + "Alle %lld Tage – basierend auf deinem Profil" : { + }, "Alle Momente und Notizen zu dieser Person werden unwiderruflich gelöscht." : { "comment" : "AddPersonView – delete confirmation message", @@ -293,6 +364,9 @@ } } } + }, + "Alle Personen, Momente, Besuche und dein Profil werden unwiderruflich gelöscht. Die App startet neu." : { + }, "Alle Pro-Features freigeschaltet" : { "comment" : "SettingsView – Pro subscription active subtitle", @@ -351,6 +425,20 @@ } } }, + "Alles löschen und Onboarding starten" : { + + }, + "Alles, was du in nahbar eingibst, wird ausschließlich auf deinem iPhone gespeichert und verarbeitet." : { + "comment" : "OnboardingPrivacyView – subtitle below headline", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Everything you enter in nahbar is stored and processed exclusively on your iPhone." + } + } + } + }, "Analyse fehlgeschlagen" : { "comment" : "LogbuchView – AI analysis error label", "localizations" : { @@ -430,6 +518,18 @@ } } }, + "Anrufen" : { + "comment" : "PersonDetailView – activity suggestion: call the person", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Call" + } + } + } + }, "Anstehende Termine" : { "comment" : "TodayView – section title for upcoming reminders", "localizations" : { @@ -440,6 +540,12 @@ } } } + }, + "App wirklich zurücksetzen?" : { + + }, + "App zurücksetzen" : { + }, "App-Schutz" : { "comment" : "SettingsView – section header for app lock settings", @@ -474,6 +580,23 @@ } } } + }, + "Auf einer Nachbarschaftsparty kennst du kaum jemanden." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "At a neighborhood party, you barely know anyone." + } + } + } + }, + "Aus Adressbuch hinzufügen" : { + + }, + "Aus Kontakten ausfüllen" : { + }, "Aus Kontakten auswählen" : { "comment" : "AddPersonView – import from contacts button", @@ -636,6 +759,9 @@ } } } + }, + "Bitte gib zuerst deinen Vornamen ein." : { + }, "Chat" : { "comment" : "MomentSource.chat raw value", @@ -780,6 +906,53 @@ } } }, + "Datenschutzhinweis: Deine Daten bleiben auf deinem iPhone. Keine Cloud-Speicherung außer bei KI-Funktionen." : { + "comment" : "PrivacyBadgeView – profile context accessibility label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy notice: Your data stays on your iPhone. No cloud storage except for AI features." + } + } + } + }, + "Datenschutzhinweis: Diese Daten werden ausschließlich lokal auf deinem Gerät gespeichert und niemals übertragen." : { + + }, + "Datenschutzhinweis: Diese Funktion sendet Daten an einen externen KI-Dienst." : { + "comment" : "PrivacyBadgeView – aiFeature context accessibility label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy notice: This feature sends data to an external AI service." + } + } + } + }, + "Datenschutzhinweis: Kontakte werden ausschließlich lokal gespeichert." : { + "comment" : "PrivacyBadgeView – contacts context accessibility label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy notice: Contacts are stored exclusively locally." + } + } + } + }, + "Datenschutzzusammenfassung: Alle Daten bleiben lokal auf deinem iPhone. Keine Registrierung, kein Account, keine Cloud. KI-Anfragen werden anonymisiert gesendet." : { + "comment" : "PrivacyBadgeView – summary context accessibility label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy summary: All data stays local on your iPhone. No registration, no account, no cloud. AI requests are sent anonymised." + } + } + } + }, "Datum" : { "comment" : "AddPersonView – birthday date picker label", "localizations" : { @@ -806,6 +979,16 @@ } } }, + "Dein bestehendes Profil wird dabei überschrieben." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your existing profile will be overwritten." + } + } + } + }, "Dein nächstes Gespräch kann hier beginnen." : { "comment" : "PersonDetailView – moments empty state message", "extractionState" : "stale", @@ -842,6 +1025,27 @@ } } }, + "Dein Profil" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your profile" + } + } + } + }, + "Deine Daten gehören dir" : { + "comment" : "OnboardingPrivacyView – headline", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your data belongs to you" + } + } + } + }, "Deine Daten verlassen nicht dein Gerät" : { "comment" : "SettingsView – privacy info row value", "extractionState" : "stale", @@ -853,6 +1057,9 @@ } } } + }, + "Details" : { + }, "Diagnose" : { "comment" : "SettingsView – section header for developer diagnostics", @@ -877,6 +1084,28 @@ } } }, + "Diese Funktion sendet Daten an einen KI-Dienst. Nur bei KI-Nutzung." : { + "comment" : "PrivacyBadgeView – aiFeature context message", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This feature sends data to an AI service. Only when using AI." + } + } + } + }, + "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." : { + "comment" : "AIConsentSheet – body text", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This feature transfers information about the selected person to an external AI service (Anthropic Claude). The transfer is encrypted – but data leaves your device.\n\nYou decide at any time whether to use AI features. Without your confirmation, no data is sent." + } + } + } + }, "Diese Person wirklich löschen?" : { "comment" : "AddPersonView – delete person confirmation title", "localizations" : { @@ -911,6 +1140,240 @@ } } }, + "Du bist enttäuscht und brauchst Zeit, um dich neu zu sortieren." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You feel disappointed and need time to regroup." + } + } + } + }, + "Du erklärst ehrlich, dass es dir gerade nicht passt." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You honestly explain that it doesn't work for you right now." + } + } + } + }, + "Du erscheinst wie abgemacht – dein Wort gilt." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You show up as agreed – your word counts." + } + } + } + }, + "Du fragst dich, ob du etwas falsch gemacht hast, und das lässt dich nicht los." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You wonder if you did something wrong and it stays with you." + } + } + } + }, + "Du fragst kurz nach, ob es sich verschieben lässt." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You ask if it can be rescheduled." + } + } + } + }, + "Du gehst aktiv auf Fremde zu und fängst Gespräche an." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You actively approach strangers and start conversations." + } + } + } + }, + "Du gehst einfach hin – Neugier auf fremde Menschen treibt dich." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You just go – curiosity about new people drives you." + } + } + } + }, + "Du genießt die Ruhe und tankst alleine auf." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You enjoy the peace and recharge alone." + } + } + } + }, + "Du hast einem Freund versprochen zu helfen. Am Morgen bist du müde." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You promised to help a friend. In the morning you're tired." + } + } + } + }, + "Du hast es dir sofort notiert und planst etwas Besonderes." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You noted it immediately and plan something special." + } + } + } + }, + "Du hilfst trotzdem – anderen etwas Gutes tun liegt dir." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You help anyway – doing something good for others matters to you." + } + } + } + }, + "Du kannst Kontakte jederzeit später in der App hinzufügen." : { + + }, + "Du meldest dich locker – er ist wahrscheinlich einfach beschäftigt." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You reach out casually – they're probably just busy." + } + } + } + }, + "Du reagierst spontan, wenn der Tag kommt." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You react spontaneously when the day comes." + } + } + } + }, + "Du rufst spontan Freunde an und organisierst ein Treffen." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You spontaneously call friends and organize a meetup." + } + } + } + }, + "Du sagst sofort zu – neue Erfahrungen reizen dich." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You immediately agree – new experiences appeal to you." + } + } + } + }, + "Du schlägt lieber etwas vor, das ihr beide gut kennt." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You'd rather suggest something you both know well." + } + } + } + }, + "Du sprichst deine Sorgen an, auch wenn es Spannung erzeugt." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You voice your concerns, even if it creates tension." + } + } + } + }, + "Du unterstützt ihn und behältst deine Bedenken für dich." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You support them and keep your concerns to yourself." + } + } + } + }, + "Du wartest, bis ein Bekannter mitkommt." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You wait until someone you know joins." + } + } + } + }, + "Du wartest, bis jemand dich anspricht." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You wait for someone to approach you." + } + } + } + }, + "Du zuckst die Schultern und findest schnell etwas anderes." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You shrug it off and quickly find something else to do." + } + } + } + }, "Dunkel" : { "comment" : "ThemePickerView – dark themes group header", "localizations" : { @@ -992,6 +1455,42 @@ } } }, + "Eigene Kontaktdaten aus Adressbuch übernehmen" : { + + }, + "Ein Freund erzählt von einem Plan, den du für einen Fehler hältst." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A friend tells you about a plan you think is a mistake." + } + } + } + }, + "Ein Freund schlägt spontan eine Aktivität vor, die du noch nie gemacht hast." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A friend spontaneously suggests an activity you've never done before." + } + } + } + }, + "Ein Nachbar bittet um einen Gefallen, der dir gerade ungelegen kommt." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A neighbor asks for a favor that's inconvenient for you right now." + } + } + } + }, "Ein ruhiger Tag." : { "comment" : "TodayView – empty state title", "localizations" : { @@ -1037,6 +1536,16 @@ } } }, + "Empfohlenes Nudge-Intervall" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommended nudge interval" + } + } + } + }, "Energiegeladen" : { "comment" : "RatingQuestion – positive pole for energy level question", "extractionState" : "stale", @@ -1070,6 +1579,9 @@ } } } + }, + "Ergebnis bestätigen und fortfahren" : { + }, "Erinnern" : { "comment" : "PersonDetailView – set reminder confirmation button", @@ -1093,6 +1605,29 @@ } } }, + "Erinnerungen" : { + + }, + "Erneut" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retake" + } + } + } + }, + "Erneut ausfüllen" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retake" + } + } + } + }, "Erneut versuchen" : { "comment" : "Universal retry button", "localizations" : { @@ -1116,6 +1651,33 @@ } } }, + "Erzähl uns kurz, wer du bist." : { + + }, + "Etwas Neues ausprobieren" : { + "comment" : "PersonDetailView – activity suggestion: try something new", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try something new" + } + } + } + }, + "Etwas unternehmen" : { + "comment" : "PersonDetailView – activity suggestion: do something together (group)", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do something together" + } + } + } + }, "Extrovertiert" : { "comment" : "IchView – social style option", "extractionState" : "stale", @@ -1217,6 +1779,16 @@ } } }, + "Frage %lld von %lld" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "new", + "value" : "Frage %1$lld von %2$lld" + } + } + } + }, "Freunde" : { "comment" : "PersonTag.friends raw value", "extractionState" : "stale", @@ -1302,6 +1874,17 @@ } } }, + "Gelassen und stabil" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calm and stable" + } + } + } + }, "Geschenkidee anzeigen" : { "comment" : "TodayView GiftSuggestionRow – collapsed state button", "localizations" : { @@ -1336,6 +1919,17 @@ } } }, + "Gesellig" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sociable" + } + } + } + }, "Gespräch" : { "comment" : "MomentType.conversation / RatingCategory.gespraech raw value", "extractionState" : "stale", @@ -1446,6 +2040,9 @@ } } } + }, + "Halte fest, wen du besucht hast – und wann." : { + }, "Hat sich deine Sicht auf die Person verändert?" : { "comment" : "RatingQuestion – aftermath question text", @@ -1515,6 +2112,17 @@ } } }, + "Hoch" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "High" + } + } + } + }, "Ich" : { "comment" : "Tab label for user profile (Me)", "localizations" : { @@ -1593,6 +2201,17 @@ } } }, + "In deinem Viertel gibt es ein neues Treffen – niemand, den du kennst, ist dabei." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There's a new gathering in your neighborhood – nobody you know is there." + } + } + } + }, "In nahbar speichern" : { "comment" : "ShareExtensionView – navigation title", "extractionState" : "stale", @@ -1685,6 +2304,18 @@ } } }, + "Kaffee trinken" : { + "comment" : "PersonDetailView – activity suggestion: have coffee (1-on-1)", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Have coffee" + } + } + } + }, "Kauf wiederherstellen" : { "comment" : "PaywallView – restore purchases button", "localizations" : { @@ -1719,6 +2350,17 @@ } } }, + "Keine Registrierung, kein Account, kein Tracking." : { + "comment" : "OnboardingPrivacyView – no-account privacy row text", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No registration, no account, no tracking." + } + } + } + }, "Keine Treffer." : { "comment" : "A label displayed when there are no search results.", "isCommentAutoGenerated" : true @@ -1768,6 +2410,28 @@ } } }, + "KI-Funktion verwenden?" : { + "comment" : "AIConsentSheet – sheet title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use AI Feature?" + } + } + } + }, + "KI-Funktionen sind optional. Du entscheidest, wann du sie verwendest. Erst dann werden Daten an einen Drittanbieter übertragen." : { + "comment" : "OnboardingPrivacyView – AI privacy row text", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI features are optional. You decide when to use them. Only then is data transferred to a third party." + } + } + } + }, "Klar & fokussiert" : { "comment" : "Theme tagline for Slate", "localizations" : { @@ -1791,6 +2455,38 @@ } } }, + "Kontakte aus Adressbuch auswählen" : { + + }, + "Kontakte aus Adressbuch hinzufügen" : { + + }, + "Kontakte auswählen" : { + + }, + "Kontakte hinzufügen" : { + + }, + "Kontakte importieren" : { + + }, + "Kontakte überspringen" : { + + }, + "Kontakte überspringen?" : { + + }, + "Kontakte, Besuche und Momente bleiben lokal auf deinem Gerät – keine Cloud-Synchronisation." : { + "comment" : "OnboardingPrivacyView – local storage privacy row text", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contacts, visits, and moments stay local on your device – no cloud synchronisation." + } + } + } + }, "Kontakte, Teilen-Funktion, Themes" : { "comment" : "PaywallView – Pro tier short feature summary", "localizations" : { @@ -1857,6 +2553,9 @@ } } } + }, + "Los geht's – nahbar starten" : { + }, "Löschen" : { "comment" : "Universal delete button / swipe action", @@ -1893,6 +2592,17 @@ } } }, + "Magst du heute jemanden kurz anschreiben? Das kann viel bedeuten. 🙂" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Want to reach out to someone today? It can mean a lot. 🙂" + } + } + } + }, "MAX" : { "comment" : "Badge label for Max tier", "localizations" : { @@ -1927,6 +2637,27 @@ } } }, + "Mini-Persönlichkeitsprofil-Diagramm" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mini personality profile chart" + } + } + } + }, + "Mittel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium" + } + } + } + }, "Mittwoch, 16. April" : { "comment" : "A label that displays the date.", "isCommentAutoGenerated" : true @@ -2067,6 +2798,17 @@ } } }, + "Nach einer anstrengenden Woche hast du einen freien Samstag." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "After a tiring week, you have a free Saturday." + } + } + } + }, "Nachricht" : { "comment" : "ShareExtensionView – message text section header", "extractionState" : "stale", @@ -2079,6 +2821,17 @@ } } }, + "Nachrichten importieren" : { + "comment" : "FeatureTourStep title – WhatsApp share feature", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import Messages" + } + } + } + }, "Nächste %lld Tage" : { "comment" : "TodayView – birthday section title for custom look-ahead days", "localizations" : { @@ -2113,6 +2866,17 @@ } } }, + "Nächste Woche hat eine Freundin Geburtstag." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A friend has their birthday next week." + } + } + } + }, "Nächster Monat" : { "comment" : "TodayView – birthday section title for 30 days look-ahead", "localizations" : { @@ -2216,6 +2980,9 @@ } } } + }, + "nahbar erinnert dich, wenn du lange nichts von jemandem gehört hast." : { + }, "nahbar Max freischalten für KI-Analyse" : { "comment" : "LogbuchView – upsell button for AI analysis", @@ -2352,6 +3119,17 @@ } } }, + "Niedrig" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Low" + } + } + } + }, "Noch keine Besuche bewertet" : { "comment" : "VisitHistorySection – empty state title", "localizations" : { @@ -2373,6 +3151,9 @@ } } } + }, + "Noch keine Kontakte" : { + }, "Noch keine Menschen hier." : { "comment" : "A description of the empty state when there are no people in the list.", @@ -2446,6 +3227,17 @@ } } }, + "Offen" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open" + } + } + } + }, "Offene Schritte" : { "comment" : "TodayView – section title for open next steps", "localizations" : { @@ -2467,6 +3259,20 @@ } } } + }, + "Onboarding abschließen und App starten" : { + "comment" : "OnboardingPrivacyView – CTA button accessibility label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Complete onboarding and start app" + } + } + } + }, + "Onboarding, Profil und alle Daten löschen" : { + }, "Optional" : { "comment" : "AddPersonView – optional field placeholder hint", @@ -2491,6 +3297,17 @@ } } }, + "Passend für dich" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suited for you" + } + } + } + }, "Perfekt ausgeglichen" : { "comment" : "RatingQuestion – positive pole for give/take balance question", "extractionState" : "stale", @@ -2506,6 +3323,56 @@ "Person hinzufügen" : { "comment" : "A button that adds a new person.", "isCommentAutoGenerated" : true + }, + "Personalisierte Vorschläge in 2 Minuten" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personalized suggestions in 2 minutes" + } + } + } + }, + "Persönlichkeit" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personality" + } + } + } + }, + "Persönlichkeits-Pentagon-Diagramm" : { + + }, + "Persönlichkeitsempfehlung: Passend für dich" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personality recommendation: Suited for you" + } + } + } + }, + "Persönlichkeitsprofil-Details anzeigen" : { + + }, + "Persönlichkeitsquiz starten" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start personality quiz" + } + } + } + }, + "Plane gemeinsame Aktivitäten und bleib mit wichtigen Menschen in Kontakt." : { + }, "PRO" : { "comment" : "Badge label for Pro tier", @@ -2525,6 +3392,9 @@ "Profil einrichten" : { "comment" : "A button to create a user's profile.", "isCommentAutoGenerated" : true + }, + "Profilfoto auswählen" : { + }, "Push-Benachrichtigung nach dem Besuch" : { "comment" : "SettingsView – aftermath notification toggle subtitle", @@ -2549,6 +3419,49 @@ } } }, + "Quiz erneut ausfüllen" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retake" + } + } + } + }, + "Quiz erneut ausfüllen?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retake quiz?" + } + } + } + }, + "Quiz starten" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start quiz" + } + } + } + }, + "Quiz überspringen" : { + + }, + "Quiz zurücksetzen" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset quiz" + } + } + } + }, "Ruhig & warm" : { "comment" : "Theme tagline for Linen", "localizations" : { @@ -2582,6 +3495,16 @@ } } }, + "Schritt %lld von %lld" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "new", + "value" : "Schritt %1$lld von %2$lld" + } + } + } + }, "Schritt abgeschlossen" : { "comment" : "LogEntryType.nextStep raw value", "extractionState" : "stale", @@ -2734,6 +3657,18 @@ } } }, + "Spazieren gehen" : { + "comment" : "PersonDetailView – activity suggestion: go for a walk (1-on-1)", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Go for a walk" + } + } + } + }, "Speichern" : { "comment" : "Universal save button", "localizations" : { @@ -2744,6 +3679,12 @@ } } } + }, + "Spitzname (optional)" : { + + }, + "Spitzname, optional" : { + }, "Suchen…" : { "comment" : "ShareExtensionView – contact search placeholder", @@ -2767,6 +3708,17 @@ } } }, + "Teile WhatsApp-Nachrichten direkt in nahbar – sie werden als Momente gespeichert." : { + "comment" : "FeatureTourStep description – WhatsApp share feature", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share WhatsApp messages directly into nahbar – they'll be saved as moments." + } + } + } + }, "Teilen-Funktion: Momente aus anderen Apps importieren" : { "comment" : "PaywallView – Pro feature list item", "extractionState" : "stale", @@ -2895,6 +3847,9 @@ } } } + }, + "Trotzdem überspringen" : { + }, "Typ" : { "comment" : "ShareExtensionView – moment type section header", @@ -2929,6 +3884,12 @@ } } } + }, + "Über mich (optional)" : { + + }, + "Über mich, maximal 100 Zeichen" : { + }, "Über nahbar" : { "comment" : "SettingsView – about section header", @@ -3012,6 +3973,28 @@ } } }, + "Verabredungen mit Freunden fallen kurzfristig aus." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plans with friends fall through at the last minute." + } + } + } + }, + "Verlässlich" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reliable" + } + } + } + }, "Version" : { "comment" : "SettingsView – version info row label", "extractionState" : "stale", @@ -3024,6 +4007,39 @@ } } }, + "Verstanden & App starten" : { + "comment" : "OnboardingPrivacyView – CTA button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Got it & Start App" + } + } + } + }, + "Verstanden, KI verwenden" : { + "comment" : "AIConsentSheet – accept button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Got it, use AI" + } + } + } + }, + "Verträglich" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agreeable" + } + } + } + }, "Verzögerung" : { "comment" : "SettingsView – aftermath notification delay picker label", "localizations" : { @@ -3069,6 +4085,17 @@ } } }, + "Von einem guten Freund hast du zwei Wochen nichts gehört." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You haven't heard from a good friend for two weeks." + } + } + } + }, "Vorausschau" : { "comment" : "SettingsView – section header for look-ahead settings", "localizations" : { @@ -3082,7 +4109,6 @@ }, "Vorhaben" : { "comment" : "MomentType.intention raw value", - "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3102,6 +4128,12 @@ } } } + }, + "Vorname" : { + + }, + "Vorname, erforderlich" : { + }, "Wähle deinen Plan" : { "comment" : "PaywallView – header title", @@ -3124,6 +4156,12 @@ } } } + }, + "Wähle Menschen aus deinem Adressbuch, die dir wichtig sind." : { + + }, + "Wähle Menschen aus, die dir wichtig sind." : { + }, "Wann?" : { "comment" : "AddMomentView – calendar event date picker label", @@ -3203,6 +4241,32 @@ } } }, + "Weiter (%lld ausgewählt)" : { + + }, + "Weiter zum nächsten Schritt" : { + + }, + "Weiter zum Persönlichkeitsquiz" : { + + }, + "Weiter, kein Kontakt ausgewählt" : { + + }, + "Weitere hinzufügen" : { + + }, + "Wenn du magst, kannst du das Treffen kurz reflektieren." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If you'd like, you can briefly reflect on the meeting." + } + } + } + }, "Wer bist du?" : { "comment" : "A title for the empty state view.", "isCommentAutoGenerated" : true @@ -3279,6 +4343,12 @@ } } } + }, + "Wie kennen dich deine Freunde?" : { + + }, + "Wie nennen dich deine Freunde?" : { + }, "Wie oft erinnern?" : { "comment" : "AddPersonView – nudge frequency picker label", @@ -3292,6 +4362,16 @@ } } }, + "Wie tickst du?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How do you tick?" + } + } + } + }, "Wie tiefgehend waren die Gespräche?" : { "comment" : "RatingQuestion – conversation depth question text", "extractionState" : "stale", @@ -3314,6 +4394,9 @@ } } } + }, + "Willkommen bei nahbar" : { + }, "Wir erinnern dich an die Nachwirkung." : { "comment" : "VisitSummaryView – aftermath reminder subtitle", @@ -3383,6 +4466,12 @@ } } } + }, + "z. B. Max" : { + + }, + "Zeigt eine Bestätigungsabfrage." : { + }, "Zeitfenster" : { "comment" : "SettingsView / CallWindowSetupView – time window section header and row label", @@ -3445,6 +4534,18 @@ } } } + }, + "Zusammen essen" : { + "comment" : "PersonDetailView – activity suggestion: have a meal together (group)", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eat together" + } + } + } } }, "version" : "1.1" diff --git a/nahbar/nahbar/LogbuchView.swift b/nahbar/nahbar/LogbuchView.swift index 52a1fa6..c432cf9 100644 --- a/nahbar/nahbar/LogbuchView.swift +++ b/nahbar/nahbar/LogbuchView.swift @@ -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") diff --git a/nahbar/nahbar/NahbarApp.swift b/nahbar/nahbar/NahbarApp.swift index 405548b..dc41f22 100644 --- a/nahbar/nahbar/NahbarApp.swift +++ b/nahbar/nahbar/NahbarApp.swift @@ -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() diff --git a/nahbar/nahbar/NahbarContact.swift b/nahbar/nahbar/NahbarContact.swift new file mode 100644 index 0000000..9e728c9 --- /dev/null +++ b/nahbar/nahbar/NahbarContact.swift @@ -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 } + } +} diff --git a/nahbar/nahbar/NahbarInsightStyle.swift b/nahbar/nahbar/NahbarInsightStyle.swift new file mode 100644 index 0000000..225e480 --- /dev/null +++ b/nahbar/nahbar/NahbarInsightStyle.swift @@ -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 + } + } +} diff --git a/nahbar/nahbar/OnboardingContainerView.swift b/nahbar/nahbar/OnboardingContainerView.swift new file mode 100644 index 0000000..785e72c --- /dev/null +++ b/nahbar/nahbar/OnboardingContainerView.swift @@ -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.. 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() diff --git a/nahbar/nahbar/PersonalityComponents.swift b/nahbar/nahbar/PersonalityComponents.swift new file mode 100644 index 0000000..ade75c4 --- /dev/null +++ b/nahbar/nahbar/PersonalityComponents.swift @@ -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 + "." + } +} diff --git a/nahbar/nahbar/PersonalityEngine.swift b/nahbar/nahbar/PersonalityEngine.swift new file mode 100644 index 0000000..6ced43f --- /dev/null +++ b/nahbar/nahbar/PersonalityEngine.swift @@ -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 +} diff --git a/nahbar/nahbar/PersonalityModels.swift b/nahbar/nahbar/PersonalityModels.swift new file mode 100644 index 0000000..31da3ed --- /dev/null +++ b/nahbar/nahbar/PersonalityModels.swift @@ -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 (0–2 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: 0–2 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 0…1 für Pentagon-Chart-Vertex. + func normalized(for dimension: OceanDimension) -> Double { + let score = Double(scores[dimension] ?? 1) + return score / 2.0 // 0→0.0, 1→0.5, 2→1.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) + } +} diff --git a/nahbar/nahbar/PersonalityQuizView.swift b/nahbar/nahbar/PersonalityQuizView.swift new file mode 100644 index 0000000..4824dcf --- /dev/null +++ b/nahbar/nahbar/PersonalityQuizView.swift @@ -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.. 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 } diff --git a/nahbar/nahbar/PersonalityResultView.swift b/nahbar/nahbar/PersonalityResultView.swift new file mode 100644 index 0000000..a3bb49d --- /dev/null +++ b/nahbar/nahbar/PersonalityResultView.swift @@ -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" + } + } +} diff --git a/nahbar/nahbar/PersonalityStore.swift b/nahbar/nahbar/PersonalityStore.swift new file mode 100644 index 0000000..181da58 --- /dev/null +++ b/nahbar/nahbar/PersonalityStore.swift @@ -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") + } +} diff --git a/nahbar/nahbar/PrivacyBadgeView.swift b/nahbar/nahbar/PrivacyBadgeView.swift new file mode 100644 index 0000000..12a4b4e --- /dev/null +++ b/nahbar/nahbar/PrivacyBadgeView.swift @@ -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 { } + } +} diff --git a/nahbar/nahbar/SettingsView.swift b/nahbar/nahbar/SettingsView.swift index 2a49ecd..7fc5824 100644 --- a/nahbar/nahbar/SettingsView.swift +++ b/nahbar/nahbar/SettingsView.swift @@ -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) } } } diff --git a/nahbar/nahbar/SharedComponents.swift b/nahbar/nahbar/SharedComponents.swift index 1326b85..71a94b8 100644 --- a/nahbar/nahbar/SharedComponents.swift +++ b/nahbar/nahbar/SharedComponents.swift @@ -76,6 +76,7 @@ struct RowDivider: View { .fill(theme.borderSubtle) .frame(height: 0.5) .padding(.leading, 16) + .allowsHitTesting(false) } } diff --git a/nahbar/nahbar/TodayView.swift b/nahbar/nahbar/TodayView.swift index 95e85be..88f8e61 100644 --- a/nahbar/nahbar/TodayView.swift +++ b/nahbar/nahbar/TodayView.swift @@ -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") diff --git a/nahbar/nahbar/UserProfileStore.swift b/nahbar/nahbar/UserProfileStore.swift index 1a023f5..0d1c6df 100644 --- a/nahbar/nahbar/UserProfileStore.swift +++ b/nahbar/nahbar/UserProfileStore.swift @@ -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 ?? "" diff --git a/nahbar/nahbarTests/ContactPickerTests.swift b/nahbar/nahbarTests/ContactPickerTests.swift new file mode 100644 index 0000000..5ab09dd --- /dev/null +++ b/nahbar/nahbarTests/ContactPickerTests.swift @@ -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) + } +} diff --git a/nahbar/nahbarTests/NahbarPersonalityTests.swift b/nahbar/nahbarTests/NahbarPersonalityTests.swift new file mode 100644 index 0000000..a80016f --- /dev/null +++ b/nahbar/nahbarTests/NahbarPersonalityTests.swift @@ -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 0–4") + @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) + } +} diff --git a/nahbar/nahbarTests/OnboardingTests.swift b/nahbar/nahbarTests/OnboardingTests.swift new file mode 100644 index 0000000..319e5d3 --- /dev/null +++ b/nahbar/nahbarTests/OnboardingTests.swift @@ -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 0–4") + 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) + } + } +} diff --git a/nahbar/nahbarTests/UserProfileStoreTests.swift b/nahbar/nahbarTests/UserProfileStoreTests.swift index 4cccf3f..60c42fb 100644 --- a/nahbar/nahbarTests/UserProfileStoreTests.swift +++ b/nahbar/nahbarTests/UserProfileStoreTests.swift @@ -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"))