Onboaridng-Flow, PersonalityQuiz, UI-Verbesserungen.
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -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 = [
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
26B2CAED2F93C0680039BA3B /* VisitHistorySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAEC2F93C0680039BA3B /* VisitHistorySection.swift */; };
|
||||
26B2CAF12F93C52C0039BA3B /* VisitEditFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */; };
|
||||
26B2CAF72F93ED690039BA3B /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */; };
|
||||
26B9930C2F94B32800E9B16C /* PrivacyBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B9930B2F94B32800E9B16C /* PrivacyBadgeView.swift */; };
|
||||
26B9930E2F94B33D00E9B16C /* NahbarContact.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B9930D2F94B33D00E9B16C /* NahbarContact.swift */; };
|
||||
26B993102F94B34C00E9B16C /* OnboardingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B9930F2F94B34C00E9B16C /* OnboardingCoordinator.swift */; };
|
||||
26BB85B92F9248BD00889312 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85B82F9248BD00889312 /* SplashView.swift */; };
|
||||
26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85BA2F924D9B00889312 /* StoreManager.swift */; };
|
||||
26BB85BD2F924DB100889312 /* PaywallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BB85BC2F924DB100889312 /* PaywallView.swift */; };
|
||||
@@ -53,6 +56,14 @@
|
||||
26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF66482F91352D00824F91 /* AppLockSetupView.swift */; };
|
||||
26EF664B2F913C8600824F91 /* LogbuchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF664A2F913C8600824F91 /* LogbuchView.swift */; };
|
||||
26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF664D2F91514B00824F91 /* ThemePickerView.swift */; };
|
||||
26F8B0BF2F94B47C004905B9 /* OnboardingContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0BE2F94B47C004905B9 /* OnboardingContainerView.swift */; };
|
||||
26F8B0C52F94E47F004905B9 /* PersonalityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0C42F94E47F004905B9 /* PersonalityModels.swift */; };
|
||||
26F8B0C72F94E499004905B9 /* NahbarInsightStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0C62F94E499004905B9 /* NahbarInsightStyle.swift */; };
|
||||
26F8B0C92F94E4B0004905B9 /* PersonalityStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0C82F94E4B0004905B9 /* PersonalityStore.swift */; };
|
||||
26F8B0CB2F94E4E1004905B9 /* PersonalityEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0CA2F94E4E1004905B9 /* PersonalityEngine.swift */; };
|
||||
26F8B0CF2F94E7B1004905B9 /* PersonalityQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0CE2F94E7B1004905B9 /* PersonalityQuizView.swift */; };
|
||||
26F8B0D12F94E7D5004905B9 /* PersonalityResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */; };
|
||||
26F8B0D32F94E7ED004905B9 /* PersonalityComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -104,6 +115,9 @@
|
||||
26B2CAEC2F93C0680039BA3B /* VisitHistorySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitHistorySection.swift; sourceTree = "<group>"; };
|
||||
26B2CAF02F93C52C0039BA3B /* VisitEditFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitEditFlowView.swift; sourceTree = "<group>"; };
|
||||
26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
26B9930B2F94B32800E9B16C /* PrivacyBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyBadgeView.swift; sourceTree = "<group>"; };
|
||||
26B9930D2F94B33D00E9B16C /* NahbarContact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarContact.swift; sourceTree = "<group>"; };
|
||||
26B9930F2F94B34C00E9B16C /* OnboardingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCoordinator.swift; sourceTree = "<group>"; };
|
||||
26BB85B82F9248BD00889312 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
|
||||
26BB85BA2F924D9B00889312 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = "<group>"; };
|
||||
26BB85BC2F924DB100889312 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = "<group>"; };
|
||||
@@ -134,6 +148,14 @@
|
||||
26EF66482F91352D00824F91 /* AppLockSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupView.swift; sourceTree = "<group>"; };
|
||||
26EF664A2F913C8600824F91 /* LogbuchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogbuchView.swift; sourceTree = "<group>"; };
|
||||
26EF664D2F91514B00824F91 /* ThemePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePickerView.swift; sourceTree = "<group>"; };
|
||||
26F8B0BE2F94B47C004905B9 /* OnboardingContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingContainerView.swift; sourceTree = "<group>"; };
|
||||
26F8B0C42F94E47F004905B9 /* PersonalityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityModels.swift; sourceTree = "<group>"; };
|
||||
26F8B0C62F94E499004905B9 /* NahbarInsightStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarInsightStyle.swift; sourceTree = "<group>"; };
|
||||
26F8B0C82F94E4B0004905B9 /* PersonalityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityStore.swift; sourceTree = "<group>"; };
|
||||
26F8B0CA2F94E4E1004905B9 /* PersonalityEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityEngine.swift; sourceTree = "<group>"; };
|
||||
26F8B0CE2F94E7B1004905B9 /* PersonalityQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityQuizView.swift; sourceTree = "<group>"; };
|
||||
26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityResultView.swift; sourceTree = "<group>"; };
|
||||
26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalityComponents.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -254,6 +276,17 @@
|
||||
26B2CAB32F93B52B0039BA3B /* UserProfileStore.swift */,
|
||||
26B2CAB52F93B55F0039BA3B /* IchView.swift */,
|
||||
26B2CAF62F93ED690039BA3B /* Localizable.xcstrings */,
|
||||
26B9930B2F94B32800E9B16C /* PrivacyBadgeView.swift */,
|
||||
26B9930D2F94B33D00E9B16C /* NahbarContact.swift */,
|
||||
26B9930F2F94B34C00E9B16C /* OnboardingCoordinator.swift */,
|
||||
26F8B0BE2F94B47C004905B9 /* OnboardingContainerView.swift */,
|
||||
26F8B0C42F94E47F004905B9 /* PersonalityModels.swift */,
|
||||
26F8B0C62F94E499004905B9 /* NahbarInsightStyle.swift */,
|
||||
26F8B0C82F94E4B0004905B9 /* PersonalityStore.swift */,
|
||||
26F8B0CA2F94E4E1004905B9 /* PersonalityEngine.swift */,
|
||||
26F8B0CE2F94E7B1004905B9 /* PersonalityQuizView.swift */,
|
||||
26F8B0D02F94E7D5004905B9 /* PersonalityResultView.swift */,
|
||||
26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */,
|
||||
);
|
||||
path = nahbar;
|
||||
sourceTree = "<group>";
|
||||
@@ -406,15 +439,19 @@
|
||||
files = (
|
||||
269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */,
|
||||
26EF66322F9112E700824F91 /* Models.swift in Sources */,
|
||||
26F8B0CF2F94E7B1004905B9 /* PersonalityQuizView.swift in Sources */,
|
||||
26B2CAED2F93C0680039BA3B /* VisitHistorySection.swift in Sources */,
|
||||
26EF66332F9112E700824F91 /* TodayView.swift in Sources */,
|
||||
26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */,
|
||||
26F8B0CB2F94E4E1004905B9 /* PersonalityEngine.swift in Sources */,
|
||||
26B9930C2F94B32800E9B16C /* PrivacyBadgeView.swift in Sources */,
|
||||
26B2CAE72F93C03F0039BA3B /* VisitRatingFlowView.swift in Sources */,
|
||||
26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */,
|
||||
26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */,
|
||||
26EF66432F912A0000824F91 /* CallSuggestionView.swift in Sources */,
|
||||
26B2CAB62F93B55F0039BA3B /* IchView.swift in Sources */,
|
||||
26B2CAF12F93C52C0039BA3B /* VisitEditFlowView.swift in Sources */,
|
||||
26F8B0C52F94E47F004905B9 /* PersonalityModels.swift in Sources */,
|
||||
26EF66452F91350200824F91 /* AppLockManager.swift in Sources */,
|
||||
26B2CAEB2F93C05A0039BA3B /* VisitSummaryView.swift in Sources */,
|
||||
26B2CAE32F93C0180039BA3B /* RatingQuestionView.swift in Sources */,
|
||||
@@ -427,21 +464,28 @@
|
||||
26EF66392F9112E700824F91 /* AddMomentView.swift in Sources */,
|
||||
26EF663A2F9112E700824F91 /* NahbarApp.swift in Sources */,
|
||||
26BB85C52F926A1C00889312 /* AppGroup.swift in Sources */,
|
||||
26F8B0D12F94E7D5004905B9 /* PersonalityResultView.swift in Sources */,
|
||||
26EF66472F91351800824F91 /* AppLockView.swift in Sources */,
|
||||
26B2CAB82F93B7570039BA3B /* NahbarLogger.swift in Sources */,
|
||||
26F8B0C72F94E499004905B9 /* NahbarInsightStyle.swift in Sources */,
|
||||
26B2CABA2F93B76E0039BA3B /* LogExportView.swift in Sources */,
|
||||
26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */,
|
||||
26B2CAE92F93C0490039BA3B /* AftermathRatingFlowView.swift in Sources */,
|
||||
26F8B0D32F94E7ED004905B9 /* PersonalityComponents.swift in Sources */,
|
||||
26EF663B2F9112E700824F91 /* ContentView.swift in Sources */,
|
||||
26F8B0C92F94E4B0004905B9 /* PersonalityStore.swift in Sources */,
|
||||
26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */,
|
||||
26B993102F94B34C00E9B16C /* OnboardingCoordinator.swift in Sources */,
|
||||
26BB85C12F92525200889312 /* AIAnalysisService.swift in Sources */,
|
||||
26EF663C2F9112E700824F91 /* ContactPickerView.swift in Sources */,
|
||||
26B2CAE12F93C0080039BA3B /* RatingDotPicker.swift in Sources */,
|
||||
26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */,
|
||||
26B9930E2F94B33D00E9B16C /* NahbarContact.swift in Sources */,
|
||||
26B2CAE52F93C02B0039BA3B /* AftermathNotificationManager.swift in Sources */,
|
||||
26EF664B2F913C8600824F91 /* LogbuchView.swift in Sources */,
|
||||
26EF663F2F9129D700824F91 /* CallWindowManager.swift in Sources */,
|
||||
26EF663D2F9112E700824F91 /* SharedComponents.swift in Sources */,
|
||||
26F8B0BF2F94B47C004905B9 /* OnboardingContainerView.swift in Sources */,
|
||||
26BB85B92F9248BD00889312 /* SplashView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import OSLog
|
||||
private let logger = Logger(subsystem: "nahbar", category: "ContentView")
|
||||
|
||||
struct ContentView: View {
|
||||
@AppStorage("nahbarOnboardingDone") private var nahbarOnboardingDone = false
|
||||
@AppStorage("callWindowOnboardingDone") private var onboardingDone = false
|
||||
@AppStorage("callSuggestionDate") private var suggestionDateStr = ""
|
||||
@AppStorage("photoRepairPassDone") private var photoRepairPassDone = false
|
||||
@@ -18,6 +19,7 @@ struct ContentView: View {
|
||||
|
||||
@Query private var persons: [Person]
|
||||
|
||||
@State private var showingNahbarOnboarding = false
|
||||
@State private var showingOnboarding = false
|
||||
@State private var suggestedPerson: Person? = nil
|
||||
@State private var showingSuggestion = false
|
||||
@@ -48,6 +50,13 @@ struct ContentView: View {
|
||||
.toolbarBackground(.visible, for: .tabBar)
|
||||
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showingNahbarOnboarding) {
|
||||
OnboardingContainerView {
|
||||
nahbarOnboardingDone = true
|
||||
showingNahbarOnboarding = false
|
||||
checkCallWindow()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingOnboarding) {
|
||||
CallWindowSetupView(
|
||||
manager: callWindowManager,
|
||||
@@ -76,7 +85,9 @@ struct ContentView: View {
|
||||
syncPeopleCache()
|
||||
importPendingMoments()
|
||||
runPhotoRepairPass()
|
||||
if !onboardingDone {
|
||||
if !nahbarOnboardingDone {
|
||||
showingNahbarOnboarding = true
|
||||
} else if !onboardingDone {
|
||||
showingOnboarding = true
|
||||
} else {
|
||||
checkCallWindow()
|
||||
|
||||
+132
-5
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import Contacts
|
||||
import SwiftData
|
||||
|
||||
private let socialStyleOptions = [
|
||||
"Introvertiert",
|
||||
@@ -14,10 +15,17 @@ private let socialStyleOptions = [
|
||||
|
||||
struct IchView: View {
|
||||
@Environment(\.nahbarTheme) var theme
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@EnvironmentObject var profileStore: UserProfileStore
|
||||
|
||||
@StateObject private var personalityStore = PersonalityStore.shared
|
||||
|
||||
@State private var profilePhoto: UIImage? = nil
|
||||
@State private var showingEdit = false
|
||||
@State private var showingImportPicker = false
|
||||
@State private var importFeedback: String? = nil
|
||||
@State private var showingQuiz = false
|
||||
@State private var showingPersonalityDetail = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -26,6 +34,8 @@ struct IchView: View {
|
||||
headerSection
|
||||
if !profileStore.isEmpty { infoSection }
|
||||
if profileStore.isEmpty { emptyState }
|
||||
personalitySection
|
||||
importKontakteSection
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 12)
|
||||
@@ -39,9 +49,72 @@ struct IchView: View {
|
||||
}) {
|
||||
IchEditView()
|
||||
}
|
||||
|
||||
.onAppear {
|
||||
profilePhoto = profileStore.loadPhoto()
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if let feedback = importFeedback {
|
||||
Text(feedback)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.accentColor)
|
||||
.clipShape(Capsule())
|
||||
.padding(.bottom, 24)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
|
||||
withAnimation { importFeedback = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: importFeedback)
|
||||
.overlay(alignment: .center) {
|
||||
MultiContactPickerTrigger(isPresented: $showingImportPicker, onSelect: importContacts)
|
||||
.frame(width: 0, height: 0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.sheet(isPresented: $showingQuiz) {
|
||||
PersonalityQuizView { _ in
|
||||
showingQuiz = false
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingPersonalityDetail) {
|
||||
if let profile = personalityStore.profile {
|
||||
NavigationStack {
|
||||
PersonalityResultView(profile: profile) {
|
||||
showingPersonalityDetail = false
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Fertig") { showingPersonalityDetail = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Persönlichkeit
|
||||
|
||||
@ViewBuilder
|
||||
private var personalitySection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
SectionHeader(title: "Persönlichkeit", icon: "brain")
|
||||
if let profile = personalityStore.profile, profile.isComplete {
|
||||
PersonalityProfileCard(
|
||||
profile: profile,
|
||||
onRetake: { showingQuiz = true },
|
||||
onShowDetails: { showingPersonalityDetail = true }
|
||||
)
|
||||
} else if !personalityStore.hasSkippedQuiz {
|
||||
QuizPromptCard(onStart: { showingQuiz = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -223,6 +296,59 @@ struct IchView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
|
||||
// MARK: - Kontakte importieren
|
||||
|
||||
private var importKontakteSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
SectionHeader(title: "Kontakte importieren", icon: "person.2.badge.plus")
|
||||
Button { showingImportPicker = true } label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "person.crop.circle.badge.plus")
|
||||
.font(.system(size: 15))
|
||||
Text("Aus Adressbuch hinzufügen")
|
||||
.font(.system(size: 15))
|
||||
}
|
||||
.foregroundStyle(theme.accent)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(theme.surfaceCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: theme.radiusCard)
|
||||
.stroke(theme.accent.opacity(0.25), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.accessibilityLabel("Kontakte aus Adressbuch hinzufügen")
|
||||
}
|
||||
}
|
||||
|
||||
/// Importiert die gewählten Kontakte als Person-Objekte in die Datenbank.
|
||||
/// Bereits vorhandene Personen werden nicht dupliziert (Name-Vergleich).
|
||||
private func importContacts(_ contacts: [CNContact]) {
|
||||
var imported = 0
|
||||
for contact in contacts {
|
||||
let name = [contact.givenName, contact.familyName]
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
guard !name.isEmpty else { continue }
|
||||
let person = Person(name: name)
|
||||
modelContext.insert(person)
|
||||
imported += 1
|
||||
}
|
||||
guard imported > 0 else { return }
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
// Fehler werden im nächsten App-Launch durch den Container-Fallback abgefangen
|
||||
}
|
||||
withAnimation {
|
||||
importFeedback = imported == 1
|
||||
? "1 Person hinzugefügt"
|
||||
: "\(imported) Personen hinzugefügt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - IchEditView
|
||||
@@ -369,11 +495,7 @@ struct IchEditView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingContactPicker) {
|
||||
ContactPickerView { contact in
|
||||
applyContact(contact)
|
||||
}
|
||||
}
|
||||
|
||||
.onChange(of: photoPickerItem) { _, item in
|
||||
Task {
|
||||
guard let item else { return }
|
||||
@@ -383,6 +505,11 @@ struct IchEditView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
SingleContactPickerTrigger(isPresented: $showingContactPicker, onSelect: applyContact)
|
||||
.frame(width: 0, height: 0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Photo Section
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
import SwiftUI
|
||||
import Contacts
|
||||
import PhotosUI
|
||||
import SwiftData
|
||||
import OSLog
|
||||
|
||||
private let onboardingLogger = Logger(subsystem: "nahbar", category: "Onboarding")
|
||||
|
||||
// MARK: - OnboardingContainerView
|
||||
|
||||
/// Root container for the first-launch onboarding flow.
|
||||
/// Shows a TabView with Phase 1 (Profile) and Phase 2 (Contacts),
|
||||
/// then overlays the Phase 3 (Feature Tour) on top with a blurred background.
|
||||
struct OnboardingContainerView: View {
|
||||
let onComplete: () -> Void
|
||||
|
||||
@StateObject private var coordinator = OnboardingCoordinator()
|
||||
@Environment(\.contactStore) private var contactStore
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
/// Current tab page index (0 = profile, 1 = contacts).
|
||||
@State private var tabPage: Int = 0
|
||||
/// Whether the feature tour overlay is visible.
|
||||
@State private var showTour: Bool = false
|
||||
/// Whether the final privacy screen is visible (shown after the feature tour).
|
||||
@State private var showPrivacyScreen: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// ── Background pages (blurred when tour or privacy screen is active) ──
|
||||
TabView(selection: $tabPage) {
|
||||
OnboardingProfileView(coordinator: coordinator)
|
||||
.tag(0)
|
||||
|
||||
OnboardingQuizPromptView(coordinator: coordinator)
|
||||
.tag(1)
|
||||
|
||||
OnboardingContactImportView(
|
||||
coordinator: coordinator,
|
||||
onContinue: startTour,
|
||||
onSkip: startTour
|
||||
)
|
||||
.tag(2)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.blur(radius: (showTour || showPrivacyScreen) ? 20 : 0)
|
||||
.disabled(showTour || showPrivacyScreen)
|
||||
|
||||
// ── Dark overlay ─────────────────────────────────────────────────
|
||||
if showTour || showPrivacyScreen {
|
||||
Color.black.opacity(0.45)
|
||||
.ignoresSafeArea()
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
// ── Feature tour ─────────────────────────────────────────────────
|
||||
if showTour {
|
||||
FeatureTourView(onFinish: startPrivacyScreen)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
// ── Privacy screen (final step) ───────────────────────────────────
|
||||
if showPrivacyScreen {
|
||||
OnboardingPrivacyView(onFinish: finishOnboarding)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.35), value: showTour)
|
||||
.animation(.easeInOut(duration: 0.35), value: showPrivacyScreen)
|
||||
.onChange(of: coordinator.currentStep) { _, step in
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||
// Cap tab index at 2 (contacts is the last real page after quiz)
|
||||
tabPage = min(step.rawValue, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startTour() {
|
||||
withAnimation { showTour = true }
|
||||
}
|
||||
|
||||
private func startPrivacyScreen() {
|
||||
withAnimation(.easeInOut(duration: 0.35)) {
|
||||
showTour = false
|
||||
showPrivacyScreen = true
|
||||
}
|
||||
}
|
||||
|
||||
private func finishOnboarding() {
|
||||
// 1. Persist user profile
|
||||
UserProfileStore.shared.update(
|
||||
name: coordinator.firstName,
|
||||
birthday: nil,
|
||||
occupation: "",
|
||||
location: "",
|
||||
likes: "",
|
||||
dislikes: "",
|
||||
socialStyle: "",
|
||||
displayName: coordinator.displayName,
|
||||
aboutMe: coordinator.aboutMe
|
||||
)
|
||||
|
||||
// 2. Persist selected contacts (local JSON – no network)
|
||||
do {
|
||||
try contactStore.save(coordinator.selectedContacts)
|
||||
} catch {
|
||||
onboardingLogger.error("ContactStore.save fehlgeschlagen: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// 3. Import each selected contact as a Person in SwiftData
|
||||
for contact in coordinator.selectedContacts {
|
||||
let person = Person(name: contact.fullName)
|
||||
modelContext.insert(person)
|
||||
}
|
||||
if !coordinator.selectedContacts.isEmpty {
|
||||
do {
|
||||
try modelContext.save()
|
||||
onboardingLogger.info("Onboarding: \(coordinator.selectedContacts.count) Kontakt(e) als Person importiert")
|
||||
} catch {
|
||||
onboardingLogger.error("modelContext.save fehlgeschlagen: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
coordinator.completeOnboarding()
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 1: OnboardingProfileView
|
||||
|
||||
private struct OnboardingProfileView: View {
|
||||
@ObservedObject var coordinator: OnboardingCoordinator
|
||||
|
||||
@State private var selectedItem: PhotosPickerItem? = nil
|
||||
@State private var profileImage: UIImage? = nil
|
||||
@State private var showingContactPicker = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
|
||||
// ── Header ───────────────────────────────────────────────────
|
||||
VStack(spacing: 8) {
|
||||
Text("Willkommen bei nahbar")
|
||||
.font(.title.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Erzähl uns kurz, wer du bist.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 48)
|
||||
|
||||
// ── Avatar ───────────────────────────────────────────────────
|
||||
avatarSection
|
||||
|
||||
// ── Aus Kontakten importieren ─────────────────────────────────
|
||||
Button { showingContactPicker = true } label: {
|
||||
Label("Aus Kontakten ausfüllen", systemImage: "person.crop.circle")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
.accessibilityLabel("Eigene Kontaktdaten aus Adressbuch übernehmen")
|
||||
|
||||
// ── Form ─────────────────────────────────────────────────────
|
||||
VStack(spacing: 16) {
|
||||
// First name (required)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Vorname")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("z. B. Max", text: $coordinator.firstName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textContentType(.givenName)
|
||||
.submitLabel(.next)
|
||||
.accessibilityLabel("Vorname, erforderlich")
|
||||
}
|
||||
|
||||
// Display name (optional)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Spitzname (optional)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Wie nennen dich deine Freunde?", text: $coordinator.displayName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textContentType(.nickname)
|
||||
.submitLabel(.next)
|
||||
.accessibilityLabel("Spitzname, optional")
|
||||
}
|
||||
|
||||
// About me (max 100 chars)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("Über mich (optional)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(coordinator.aboutMe.count)/100")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(coordinator.aboutMe.count > 90 ? Color.orange : Color.secondary.opacity(0.5))
|
||||
.accessibilityLabel("\(coordinator.aboutMe.count) von 100 Zeichen")
|
||||
}
|
||||
TextField(
|
||||
"Wie kennen dich deine Freunde?",
|
||||
text: $coordinator.aboutMe,
|
||||
axis: .vertical
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(3...5)
|
||||
.onChange(of: coordinator.aboutMe) { _, value in
|
||||
if value.count > 100 {
|
||||
coordinator.aboutMe = String(value.prefix(100))
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Über mich, maximal 100 Zeichen")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// ── Privacy badge ────────────────────────────────────────────
|
||||
PrivacyBadgeView(context: .profile)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// ── Continue button ──────────────────────────────────────────
|
||||
Button {
|
||||
coordinator.advanceToQuiz()
|
||||
} label: {
|
||||
Text("Weiter")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(coordinator.isProfileValid
|
||||
? Color.accentColor
|
||||
: Color.secondary.opacity(0.25))
|
||||
.foregroundStyle(coordinator.isProfileValid ? .white : .secondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.disabled(!coordinator.isProfileValid)
|
||||
.padding(.horizontal, 24)
|
||||
.accessibilityLabel("Weiter zum Persönlichkeitsquiz")
|
||||
.accessibilityHint(coordinator.isProfileValid
|
||||
? ""
|
||||
: "Bitte gib zuerst deinen Vornamen ein.")
|
||||
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedItem) { _, item in
|
||||
Task {
|
||||
guard let item,
|
||||
let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) else { return }
|
||||
profileImage = image
|
||||
UserProfileStore.shared.savePhoto(image)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
SingleContactPickerTrigger(isPresented: $showingContactPicker, onSelect: applyContact)
|
||||
.frame(width: 0, height: 0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyContact(_ contact: CNContact) {
|
||||
if !contact.givenName.isEmpty {
|
||||
coordinator.firstName = contact.givenName
|
||||
}
|
||||
// Foto übernehmen, falls vorhanden
|
||||
let photoData = contact.thumbnailImageData ?? contact.imageData
|
||||
if let data = photoData, let image = UIImage(data: data) {
|
||||
profileImage = image
|
||||
UserProfileStore.shared.savePhoto(image)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Avatar
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarSection: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
// Photo or initials placeholder
|
||||
Group {
|
||||
if let image = profileImage {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.accentColor.opacity(0.15))
|
||||
Text(initialsPlaceholder)
|
||||
.font(.largeTitle.bold())
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 96, height: 96)
|
||||
.clipShape(Circle())
|
||||
|
||||
// Camera button overlay
|
||||
PhotosPicker(selection: $selectedItem, matching: .images) {
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(7)
|
||||
.background(Color.accentColor)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.accessibilityLabel("Profilfoto auswählen")
|
||||
}
|
||||
}
|
||||
|
||||
private var initialsPlaceholder: String {
|
||||
let name = coordinator.firstName.trimmingCharacters(in: .whitespaces)
|
||||
guard !name.isEmpty else { return "?" }
|
||||
let parts = name.split(separator: " ")
|
||||
if parts.count >= 2 {
|
||||
return (parts[0].prefix(1) + parts[1].prefix(1)).uppercased()
|
||||
}
|
||||
return String(name.prefix(2)).uppercased()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 2: OnboardingQuizPromptView
|
||||
|
||||
/// Onboarding-Seite für das Persönlichkeitsquiz.
|
||||
/// Zeigt die Quiz-Intro-UI und präsentiert PersonalityQuizView als Sheet.
|
||||
private struct OnboardingQuizPromptView: View {
|
||||
@ObservedObject var coordinator: OnboardingCoordinator
|
||||
@AppStorage("hasSkippedPersonalityQuiz") private var hasSkippedQuiz: Bool = false
|
||||
@State private var showingQuiz = false
|
||||
|
||||
var body: some View {
|
||||
QuizIntroScreen(
|
||||
onStart: { showingQuiz = true },
|
||||
onSkip: {
|
||||
hasSkippedQuiz = true
|
||||
coordinator.skipQuiz()
|
||||
}
|
||||
)
|
||||
.sheet(isPresented: $showingQuiz) {
|
||||
PersonalityQuizView(skipIntro: true) { _ in
|
||||
coordinator.advanceFromQuizToContacts()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 3: OnboardingContactImportView
|
||||
|
||||
/// Uses CNContactPickerViewController (system picker, no permission needed).
|
||||
/// Multi-select is activated automatically by implementing didSelectContacts:.
|
||||
private struct OnboardingContactImportView: View {
|
||||
@ObservedObject var coordinator: OnboardingCoordinator
|
||||
let onContinue: () -> Void
|
||||
let onSkip: () -> Void
|
||||
|
||||
@State private var showingPicker = false
|
||||
@State private var showSkipConfirmation: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
|
||||
// ── Header ───────────────────────────────────────────────────────
|
||||
VStack(spacing: 8) {
|
||||
Text("Kontakte hinzufügen")
|
||||
.font(.title.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Wähle Menschen aus, die dir wichtig sind.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 36)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
PrivacyBadgeView(context: .contacts)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
|
||||
Divider().padding(.top, 16)
|
||||
|
||||
// ── Selected contacts list ────────────────────────────────────────
|
||||
if coordinator.selectedContacts.isEmpty {
|
||||
emptyPickerPrompt
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
selectedContactsList
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// ── Bottom actions ───────────────────────────────────────────────
|
||||
VStack(spacing: 10) {
|
||||
Button(action: onContinue) {
|
||||
Text(coordinator.selectedContacts.isEmpty
|
||||
? "Weiter"
|
||||
: "Weiter (\(coordinator.selectedContacts.count) ausgewählt)")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(coordinator.selectedContacts.isEmpty
|
||||
? Color.secondary.opacity(0.25)
|
||||
: Color.accentColor)
|
||||
.foregroundStyle(coordinator.selectedContacts.isEmpty ? Color.secondary : Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.disabled(coordinator.selectedContacts.isEmpty)
|
||||
.accessibilityLabel(
|
||||
coordinator.selectedContacts.isEmpty
|
||||
? "Weiter, kein Kontakt ausgewählt"
|
||||
: "\(coordinator.selectedContacts.count) Kontakte ausgewählt. Weiter."
|
||||
)
|
||||
|
||||
Button {
|
||||
showSkipConfirmation = true
|
||||
} label: {
|
||||
Text("Überspringen")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityLabel("Kontakte überspringen")
|
||||
.accessibilityHint("Zeigt eine Bestätigungsabfrage.")
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
.confirmationDialog(
|
||||
"Kontakte überspringen?",
|
||||
isPresented: $showSkipConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Trotzdem überspringen", role: .destructive, action: onSkip)
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Du kannst Kontakte jederzeit später in der App hinzufügen.")
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
// Invisible trigger — finds the hosting UIViewController via
|
||||
// the UIKit responder chain and presents the system contact picker.
|
||||
MultiContactPickerTrigger(isPresented: $showingPicker, onSelect: mergeContacts)
|
||||
.frame(width: 0, height: 0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Empty prompt
|
||||
|
||||
private var emptyPickerPrompt: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
Image(systemName: "person.2.badge.plus")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.accessibilityHidden(true)
|
||||
Text("Noch keine Kontakte")
|
||||
.font(.title3.bold())
|
||||
Text("Wähle Menschen aus deinem Adressbuch, die dir wichtig sind.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
Button { showingPicker = true } label: {
|
||||
Label("Kontakte auswählen", systemImage: "person.crop.circle.badge.plus")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.accentColor)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.accessibilityLabel("Kontakte aus Adressbuch auswählen")
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: Selected contacts list
|
||||
|
||||
private var selectedContactsList: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(coordinator.selectedContacts) { contact in
|
||||
HStack(spacing: 12) {
|
||||
// Initials avatar
|
||||
ZStack {
|
||||
Circle().fill(Color.accentColor.opacity(0.15))
|
||||
Text(contact.initials)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Text(contact.fullName)
|
||||
.font(.body)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
coordinator.selectedContacts.remove(atOffsets: indexSet)
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("\(coordinator.selectedContacts.count) ausgewählt")
|
||||
Spacer()
|
||||
Button {
|
||||
showingPicker = true
|
||||
} label: {
|
||||
Label("Weitere hinzufügen", systemImage: "plus")
|
||||
.font(.caption.weight(.medium))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: Merge helper
|
||||
|
||||
/// Merges newly picked contacts into the existing selection (no duplicates).
|
||||
private func mergeContacts(_ contacts: [CNContact]) {
|
||||
for contact in contacts {
|
||||
let alreadySelected = coordinator.selectedContacts
|
||||
.contains { $0.cnIdentifier == contact.identifier }
|
||||
if !alreadySelected {
|
||||
coordinator.selectedContacts.append(NahbarContact(from: contact))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 4: OnboardingPrivacyView
|
||||
|
||||
/// Final onboarding screen. Explains the app's privacy-first approach and
|
||||
/// informs users that AI features are optional and involve a third-party service.
|
||||
private struct OnboardingPrivacyView: View {
|
||||
let onFinish: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(.systemBackground)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 32) {
|
||||
|
||||
// ── Icon ───────────────────────────────────────────────
|
||||
Image(systemName: "lock.shield.fill")
|
||||
.font(.system(size: 72))
|
||||
.foregroundStyle(.green)
|
||||
.padding(.top, 60)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
// ── Headline ──────────────────────────────────────────
|
||||
VStack(spacing: 10) {
|
||||
Text("Deine Daten gehören dir")
|
||||
.font(.title.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Alles, was du in nahbar eingibst, wird ausschließlich auf deinem iPhone gespeichert und verarbeitet.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
// ── Detail rows ───────────────────────────────────────
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
privacyRow(
|
||||
icon: "iphone",
|
||||
text: "Kontakte, Besuche und Momente bleiben lokal auf deinem Gerät – keine Cloud-Synchronisation."
|
||||
)
|
||||
privacyRow(
|
||||
icon: "person.slash",
|
||||
text: "Keine Registrierung, kein Account, kein Tracking."
|
||||
)
|
||||
privacyRow(
|
||||
icon: "sparkles",
|
||||
text: "KI-Funktionen sind optional. Du entscheidest, wann du sie verwendest. Erst dann werden Daten an einen Drittanbieter übertragen."
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// ── CTA button ────────────────────────────────────────
|
||||
Button(action: onFinish) {
|
||||
Text("Verstanden & App starten")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.accentColor)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.accessibilityLabel("Onboarding abschließen und App starten")
|
||||
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func privacyRow(icon: String, text: LocalizedStringKey) -> some View {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 32)
|
||||
.accessibilityHidden(true)
|
||||
Text(text)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase 3: FeatureTourView
|
||||
|
||||
/// Data for a single coach-mark card.
|
||||
struct FeatureTourStep {
|
||||
let icon: String
|
||||
let title: LocalizedStringKey
|
||||
let description: LocalizedStringKey
|
||||
let showPrivacySummary: Bool
|
||||
|
||||
static let all: [FeatureTourStep] = [
|
||||
FeatureTourStep(
|
||||
icon: "checklist",
|
||||
title: "Vorhaben",
|
||||
description: "Plane gemeinsame Aktivitäten und bleib mit wichtigen Menschen in Kontakt.",
|
||||
showPrivacySummary: false
|
||||
),
|
||||
FeatureTourStep(
|
||||
icon: "figure.walk.arrival",
|
||||
title: "Besuche",
|
||||
description: "Halte fest, wen du besucht hast – und wann.",
|
||||
showPrivacySummary: false
|
||||
),
|
||||
FeatureTourStep(
|
||||
icon: "square.and.arrow.up",
|
||||
title: "Nachrichten importieren",
|
||||
description: "Teile WhatsApp-Nachrichten direkt in nahbar – sie werden als Momente gespeichert.",
|
||||
showPrivacySummary: false
|
||||
),
|
||||
FeatureTourStep(
|
||||
icon: "bell.badge",
|
||||
title: "Erinnerungen",
|
||||
description: "nahbar erinnert dich, wenn du lange nichts von jemandem gehört hast.",
|
||||
showPrivacySummary: true
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private struct FeatureTourView: View {
|
||||
let onFinish: () -> Void
|
||||
|
||||
@State private var stepIndex: Int = 0
|
||||
|
||||
private var steps: [FeatureTourStep] { FeatureTourStep.all }
|
||||
private var step: FeatureTourStep { steps[stepIndex] }
|
||||
private var isLastStep: Bool { stepIndex == steps.count - 1 }
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
coachCard
|
||||
.id(stepIndex)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
.padding(.horizontal, 28)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var coachCard: some View {
|
||||
VStack(spacing: 20) {
|
||||
|
||||
// Progress dots
|
||||
HStack(spacing: 6) {
|
||||
ForEach(0..<steps.count, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i == stepIndex ? Color.accentColor : Color.secondary.opacity(0.3))
|
||||
.frame(width: i == stepIndex ? 8 : 6,
|
||||
height: i == stepIndex ? 8 : 6)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: stepIndex)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Schritt \(stepIndex + 1) von \(steps.count)")
|
||||
|
||||
// Feature icon
|
||||
Image(systemName: step.icon)
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
// Title
|
||||
Text(step.title)
|
||||
.font(.title2.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Description
|
||||
Text(step.description)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Privacy summary – last step only
|
||||
if step.showPrivacySummary {
|
||||
PrivacyBadgeView(context: .summary)
|
||||
}
|
||||
|
||||
// Action button
|
||||
Button(action: advance) {
|
||||
Text(isLastStep ? "Los geht's" : "Weiter")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.accentColor)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.accessibilityLabel(isLastStep
|
||||
? "Los geht's – nahbar starten"
|
||||
: "Weiter zum nächsten Schritt")
|
||||
}
|
||||
.padding(24)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.shadow(color: .black.opacity(0.18), radius: 24, y: 10)
|
||||
}
|
||||
|
||||
private func advance() {
|
||||
if isLastStep {
|
||||
onFinish()
|
||||
} else {
|
||||
withAnimation(.spring(response: 0.45, dampingFraction: 0.82)) {
|
||||
stepIndex += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Onboarding Container") {
|
||||
OnboardingContainerView(onComplete: {})
|
||||
.modelContainer(for: [Person.self, Moment.self, PersonPhoto.self], inMemory: true)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// MARK: - OnboardingStep
|
||||
|
||||
/// Each phase of the first-launch onboarding flow.
|
||||
enum OnboardingStep: Int, CaseIterable {
|
||||
case profile = 0
|
||||
case quiz = 1 // Persönlichkeitsquiz-Prompt
|
||||
case contacts = 2 // was 1
|
||||
case tour = 3 // was 2
|
||||
case complete = 4 // was 3
|
||||
}
|
||||
|
||||
// MARK: - OnboardingCoordinator
|
||||
|
||||
/// Observable coordinator that owns all onboarding data and drives step transitions.
|
||||
@MainActor
|
||||
final class OnboardingCoordinator: ObservableObject {
|
||||
|
||||
// MARK: – Navigation state
|
||||
|
||||
@Published var currentStep: OnboardingStep = .profile
|
||||
|
||||
// MARK: – Phase 1: Profile
|
||||
|
||||
@Published var firstName: String = ""
|
||||
@Published var displayName: String = ""
|
||||
@Published var aboutMe: String = ""
|
||||
|
||||
// MARK: – Phase 2: Contacts
|
||||
|
||||
@Published var selectedContacts: [NahbarContact] = []
|
||||
|
||||
// MARK: – Validation
|
||||
|
||||
/// `true` when at least a non-empty first name has been entered.
|
||||
var isProfileValid: Bool {
|
||||
!firstName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
// MARK: – Navigation actions
|
||||
|
||||
/// Advances to the personality quiz prompt if the profile is valid.
|
||||
func advanceToQuiz() {
|
||||
guard isProfileValid else { return }
|
||||
currentStep = .quiz
|
||||
}
|
||||
|
||||
/// Skips the personality quiz and goes directly to contact import.
|
||||
func skipQuiz() {
|
||||
currentStep = .contacts
|
||||
}
|
||||
|
||||
/// Called after the personality quiz completes or is dismissed; advances to contact import.
|
||||
func advanceFromQuizToContacts() {
|
||||
currentStep = .contacts
|
||||
}
|
||||
|
||||
/// Advances to the contact import phase. Validates profile first.
|
||||
func advanceToContacts() {
|
||||
guard isProfileValid else { return }
|
||||
currentStep = .contacts
|
||||
}
|
||||
|
||||
/// Advances to the feature tour if at least one contact has been selected.
|
||||
func advanceToTour() {
|
||||
guard !selectedContacts.isEmpty else { return }
|
||||
currentStep = .tour
|
||||
}
|
||||
|
||||
/// Skips the contact selection and goes directly to the feature tour.
|
||||
func skipToTour() {
|
||||
currentStep = .tour
|
||||
}
|
||||
|
||||
/// Marks onboarding as fully complete.
|
||||
func completeOnboarding() {
|
||||
currentStep = .complete
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ struct PersonDetailView: View {
|
||||
return Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: tomorrow) ?? tomorrow
|
||||
}()
|
||||
|
||||
@StateObject private var personalityStore = PersonalityStore.shared
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
@@ -127,7 +129,12 @@ struct PersonDetailView: View {
|
||||
} else if let step = person.nextStep, !person.nextStepCompleted {
|
||||
nextStepDisplay(step: step)
|
||||
} else {
|
||||
addNextStepButton
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
addNextStepButton
|
||||
if let profile = personalityStore.profile, profile.isComplete {
|
||||
nextStepSuggestionsView(profile: profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,6 +258,78 @@ struct PersonDetailView: View {
|
||||
.removePendingNotificationRequests(withIdentifiers: ["nextstep-\(person.id)"])
|
||||
}
|
||||
|
||||
/// Persönlichkeitsgesteuerte Aktivitätsvorschläge für den nächsten Schritt.
|
||||
/// Sortiert nach preferredActivityStyle und highlightNovelty aus PersonalityEngine.
|
||||
private func nextStepSuggestionsView(profile: PersonalityProfile) -> some View {
|
||||
let preferred = PersonalityEngine.preferredActivityStyle(for: profile)
|
||||
let highlightNew = PersonalityEngine.highlightNovelty(for: profile)
|
||||
|
||||
// (text, icon, style, isNovel)
|
||||
let activities: [(String, String, ActivityStyle?, Bool)] = [
|
||||
("Kaffee trinken", "cup.and.saucer", .oneOnOne, false),
|
||||
("Spazieren gehen", "figure.walk", .oneOnOne, false),
|
||||
("Zusammen essen", "fork.knife", .group, false),
|
||||
("Etwas unternehmen", "person.2", .group, false),
|
||||
("Etwas Neues ausprobieren", "sparkles", nil, true),
|
||||
("Anrufen", "phone", nil, false),
|
||||
]
|
||||
|
||||
// Empfohlene Aktivitäten nach oben sortieren
|
||||
let sorted = activities.sorted { a, b in
|
||||
func score(_ item: (String, String, ActivityStyle?, Bool)) -> Int {
|
||||
var s = 0
|
||||
if item.2 == preferred { s += 2 }
|
||||
if item.3 && highlightNew { s += 1 }
|
||||
return s
|
||||
}
|
||||
return score(a) > score(b)
|
||||
}
|
||||
let topItems = Array(sorted.prefix(3))
|
||||
|
||||
return VStack(spacing: 6) {
|
||||
ForEach(topItems, id: \.0) { item in
|
||||
let isRecommended = (item.2 == preferred) || (item.3 && highlightNew)
|
||||
Button {
|
||||
nextStepText = item.0
|
||||
isEditingNextStep = true
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: item.1)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(isRecommended ? NahbarInsightStyle.accentPetrol : theme.contentSecondary)
|
||||
.frame(width: 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(LocalizedStringKey(item.0))
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(theme.contentPrimary)
|
||||
if isRecommended {
|
||||
RecommendedBadge(variant: .small)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(theme.contentTertiary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(isRecommended ? NahbarInsightStyle.accentPetrol.opacity(0.05) : theme.surfaceCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: theme.radiusCard)
|
||||
.stroke(
|
||||
isRecommended ? NahbarInsightStyle.accentPetrol.opacity(0.25) : theme.borderSubtle,
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteMoment(_ moment: Moment) {
|
||||
modelContext.delete(moment)
|
||||
person.touch()
|
||||
|
||||
@@ -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 + "."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - PersonalityQuizView
|
||||
|
||||
/// Sheet-tauglicher Container für den vollständigen Quiz-Flow.
|
||||
/// Zeigt: Intro → Fragen → Ergebnis.
|
||||
/// Wenn `skipIntro = true`, wird der Intro-Screen übersprungen.
|
||||
struct PersonalityQuizView: View {
|
||||
let onComplete: (PersonalityProfile?) -> Void
|
||||
var skipIntro: Bool = false
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private enum Phase: Equatable {
|
||||
case intro
|
||||
case questions
|
||||
case result(PersonalityProfile)
|
||||
|
||||
static func == (lhs: Phase, rhs: Phase) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.intro, .intro), (.questions, .questions): return true
|
||||
case (.result, .result): return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var phase: Phase
|
||||
|
||||
init(onComplete: @escaping (PersonalityProfile?) -> Void, skipIntro: Bool = false) {
|
||||
self.onComplete = onComplete
|
||||
self.skipIntro = skipIntro
|
||||
self._phase = State(initialValue: skipIntro ? .questions : .intro)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch phase {
|
||||
case .intro:
|
||||
QuizIntroScreen(
|
||||
onStart: { withAnimation(.spring(response: 0.4)) { phase = .questions } },
|
||||
onSkip: { onComplete(nil); dismiss() }
|
||||
)
|
||||
|
||||
case .questions:
|
||||
QuizQuestionsScreen(
|
||||
onComplete: { profile in
|
||||
withAnimation(.spring(response: 0.4)) { phase = .result(profile) }
|
||||
},
|
||||
onSkip: { onComplete(nil); dismiss() }
|
||||
)
|
||||
|
||||
case .result(let profile):
|
||||
PersonalityResultView(
|
||||
profile: profile,
|
||||
onContinue: { onComplete(profile); dismiss() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - QuizIntroScreen
|
||||
|
||||
/// Warmer Einstiegsscreen. Kann direkt in OnboardingContainerView
|
||||
/// und innerhalb von PersonalityQuizView eingesetzt werden.
|
||||
struct QuizIntroScreen: View {
|
||||
let onStart: () -> Void
|
||||
let onSkip: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "brain.head.profile")
|
||||
.font(.system(size: 72))
|
||||
.foregroundStyle(NahbarInsightStyle.accentPetrol)
|
||||
.accessibilityHidden(true)
|
||||
.padding(.bottom, 32)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text("Wie tickst du?")
|
||||
.font(.title.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("10 kurze Situationen. Keine falschen Antworten. Dauert etwa 2 Minuten.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
|
||||
PrivacyBadgeView(context: .localOnly)
|
||||
.padding(.horizontal, NahbarInsightStyle.horizontalPadding)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 14) {
|
||||
Button(action: onStart) {
|
||||
Text("Quiz starten")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(NahbarInsightStyle.accentPetrol)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.accessibilityLabel("Persönlichkeitsquiz starten")
|
||||
|
||||
Button(action: onSkip) {
|
||||
Text("Überspringen")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityLabel("Quiz überspringen")
|
||||
}
|
||||
.padding(.horizontal, NahbarInsightStyle.horizontalPadding)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - QuizQuestionsScreen
|
||||
|
||||
private struct QuizQuestionsScreen: View {
|
||||
let onComplete: (PersonalityProfile) -> Void
|
||||
let onSkip: () -> Void
|
||||
|
||||
@State private var currentIndex = 0
|
||||
@State private var answers: [String: Bool] = [:] // questionID → choseA
|
||||
@State private var selectedOption: OptionChoice? = nil
|
||||
@State private var isAdvancing = false
|
||||
|
||||
private let questions = QuizQuestion.all
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
progressDots
|
||||
.padding(.top, 24)
|
||||
.padding(.bottom, 28)
|
||||
|
||||
// Fragen-Karten in ZStack für Slide-Transition
|
||||
ZStack {
|
||||
ForEach(Array(questions.enumerated()), id: \.element.id) { index, question in
|
||||
if index == currentIndex {
|
||||
questionContent(for: question)
|
||||
.transition(NahbarInsightStyle.slideTransition)
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
.animation(.spring(response: 0.38, dampingFraction: 0.82), value: currentIndex)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: skipCurrentQuestion) {
|
||||
Text("Überspringen")
|
||||
.font(NahbarInsightStyle.captionFont)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Progress Dots
|
||||
|
||||
private var progressDots: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(0..<questions.count, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index <= currentIndex
|
||||
? NahbarInsightStyle.accentPetrol
|
||||
: Color.secondary.opacity(0.3))
|
||||
.frame(
|
||||
width: index == currentIndex
|
||||
? NahbarInsightStyle.progressDotActive
|
||||
: NahbarInsightStyle.progressDotInactive,
|
||||
height: index == currentIndex
|
||||
? NahbarInsightStyle.progressDotActive
|
||||
: NahbarInsightStyle.progressDotInactive
|
||||
)
|
||||
.animation(.spring(response: 0.3), value: currentIndex)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Frage \(currentIndex + 1) von \(questions.count)")
|
||||
}
|
||||
|
||||
// MARK: Question Content
|
||||
|
||||
@ViewBuilder
|
||||
private func questionContent(for question: QuizQuestion) -> some View {
|
||||
VStack(spacing: 20) {
|
||||
Text(question.situation)
|
||||
.font(NahbarInsightStyle.situationFont)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(NahbarInsightStyle.cardPadding)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(NahbarInsightStyle.cardBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: NahbarInsightStyle.cardCornerRadius))
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, x: 0, y: 2)
|
||||
.padding(.horizontal, NahbarInsightStyle.horizontalPadding)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
optionButton(text: question.optionA, choice: .a, question: question)
|
||||
optionButton(text: question.optionB, choice: .b, question: question)
|
||||
}
|
||||
.padding(.horizontal, NahbarInsightStyle.horizontalPadding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Option Button
|
||||
|
||||
@ViewBuilder
|
||||
private func optionButton(
|
||||
text: String,
|
||||
choice: OptionChoice,
|
||||
question: QuizQuestion
|
||||
) -> some View {
|
||||
let isSelected = selectedOption == choice
|
||||
|
||||
Button {
|
||||
guard !isAdvancing else { return }
|
||||
recordAnswer(choice, for: question)
|
||||
} label: {
|
||||
Text(text)
|
||||
.font(NahbarInsightStyle.optionFont)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, minHeight: 64)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isSelected
|
||||
? NahbarInsightStyle.recommendedTint
|
||||
: Color(.tertiarySystemBackground))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
isSelected ? NahbarInsightStyle.accentPetrol : Color.clear,
|
||||
lineWidth: 2
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.animation(.easeInOut(duration: 0.15), value: isSelected)
|
||||
.accessibilityLabel(text)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
|
||||
// MARK: Logic
|
||||
|
||||
private func recordAnswer(_ choice: OptionChoice, for question: QuizQuestion) {
|
||||
isAdvancing = true
|
||||
selectedOption = choice
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
answers[question.id] = (choice == .a)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { advance() }
|
||||
}
|
||||
|
||||
private func skipCurrentQuestion() {
|
||||
guard !isAdvancing else { return }
|
||||
isAdvancing = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { advance() }
|
||||
}
|
||||
|
||||
private func advance() {
|
||||
let next = currentIndex + 1
|
||||
if next < questions.count {
|
||||
withAnimation { selectedOption = nil; currentIndex = next }
|
||||
isAdvancing = false
|
||||
} else {
|
||||
let profile = PersonalityEngine.computeProfile(
|
||||
from: answers.map { (questionID: $0.key, choseA: $0.value) }
|
||||
)
|
||||
PersonalityStore.shared.save(profile: profile)
|
||||
onComplete(profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OptionChoice
|
||||
|
||||
private enum OptionChoice: Equatable { case a, b }
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ struct RowDivider: View {
|
||||
.fill(theme.borderSubtle)
|
||||
.frame(height: 0.5)
|
||||
.padding(.leading, 16)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 ?? ""
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import Contacts
|
||||
import ContactsUI
|
||||
@testable import nahbar
|
||||
|
||||
// MARK: - ContactPickerBridge – Mehrfachauswahl
|
||||
|
||||
@Suite("ContactPickerBridge – Mehrfachauswahl")
|
||||
struct ContactPickerBridgeMultiTests {
|
||||
|
||||
@Test("didSelect contacts → alle Kontakte an Callback")
|
||||
func multiSelectPassesAll() {
|
||||
let b = ContactPickerBridge()
|
||||
var received: [CNContact] = []
|
||||
b.pendingCallback = { received = $0 }
|
||||
|
||||
let a = CNMutableContact(); a.givenName = "Alice"
|
||||
let c = CNMutableContact(); c.givenName = "Bob"
|
||||
b.contactPicker(CNContactPickerViewController(), didSelect: [a, c])
|
||||
|
||||
#expect(received.count == 2)
|
||||
#expect(received[0].givenName == "Alice")
|
||||
#expect(received[1].givenName == "Bob")
|
||||
}
|
||||
|
||||
@Test("didSelect contacts leer → leeres Array an Callback")
|
||||
func emptySelectionPassesEmpty() {
|
||||
let b = ContactPickerBridge()
|
||||
var received: [CNContact] = [CNMutableContact()] // vorbefüllt zum Unterscheiden
|
||||
b.pendingCallback = { received = $0 }
|
||||
b.contactPicker(CNContactPickerViewController(), didSelect: [] as [CNContact])
|
||||
|
||||
#expect(received.isEmpty)
|
||||
}
|
||||
|
||||
@Test("didSelect contact (singular) → in Array verpackt")
|
||||
func singularWrappedInArray() {
|
||||
let b = ContactPickerBridge()
|
||||
var received: [CNContact] = []
|
||||
b.pendingCallback = { received = $0 }
|
||||
|
||||
let contact = CNMutableContact(); contact.givenName = "Einzeln"
|
||||
b.contactPicker(CNContactPickerViewController(), didSelect: contact)
|
||||
|
||||
#expect(received.count == 1)
|
||||
#expect(received.first?.givenName == "Einzeln")
|
||||
}
|
||||
|
||||
@Test("contactPickerDidCancel → kein Callback, pendingCallback nil")
|
||||
func cancelNoCallback() {
|
||||
let b = ContactPickerBridge()
|
||||
var callbackFired = false
|
||||
b.pendingCallback = { _ in callbackFired = true }
|
||||
b.contactPickerDidCancel(CNContactPickerViewController())
|
||||
|
||||
#expect(!callbackFired)
|
||||
#expect(b.pendingCallback == nil)
|
||||
}
|
||||
|
||||
@Test("pendingCallback wird nach didSelect auf nil gesetzt")
|
||||
func callbackClearedAfterSelect() {
|
||||
let b = ContactPickerBridge()
|
||||
b.pendingCallback = { _ in }
|
||||
b.contactPicker(CNContactPickerViewController(), didSelect: [] as [CNContact])
|
||||
|
||||
#expect(b.pendingCallback == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ContactPickerBridge – Einzelauswahl (via presentSingle)
|
||||
|
||||
@Suite("ContactPickerBridge – Einzelauswahl")
|
||||
struct ContactPickerBridgeSingleTests {
|
||||
|
||||
@Test("presentSingle → didSelect contact → Callback mit diesem Kontakt")
|
||||
func singleSelectCallback() {
|
||||
let b = ContactPickerBridge()
|
||||
var received: CNContact? = nil
|
||||
// presentSingle setzt pendingCallback; presentPicker() schlägt in Tests stumm fehl
|
||||
b.presentSingle { received = $0 }
|
||||
|
||||
let contact = CNMutableContact(); contact.givenName = "Anna"
|
||||
b.contactPicker(CNContactPickerViewController(), didSelect: contact)
|
||||
|
||||
#expect(received?.givenName == "Anna")
|
||||
}
|
||||
|
||||
@Test("presentSingle → didSelect contacts leer → kein Callback")
|
||||
func emptyContactsNoSingleCallback() {
|
||||
let b = ContactPickerBridge()
|
||||
var callbackFired = false
|
||||
b.presentSingle { _ in callbackFired = true }
|
||||
b.contactPicker(CNContactPickerViewController(), didSelect: [] as [CNContact])
|
||||
|
||||
#expect(!callbackFired)
|
||||
}
|
||||
|
||||
@Test("presentSingle → didSelect contacts → erstes Element an Callback")
|
||||
func singularFromPluralDelegate() {
|
||||
let b = ContactPickerBridge()
|
||||
var received: CNContact? = nil
|
||||
b.presentSingle { received = $0 }
|
||||
|
||||
let first = CNMutableContact(); first.givenName = "Erster"
|
||||
let second = CNMutableContact(); second.givenName = "Zweiter"
|
||||
b.contactPicker(CNContactPickerViewController(), didSelect: [first, second])
|
||||
|
||||
#expect(received?.givenName == "Erster")
|
||||
}
|
||||
|
||||
@Test("contactPickerDidCancel → kein Callback")
|
||||
func cancelNoCallback() {
|
||||
let b = ContactPickerBridge()
|
||||
var callbackFired = false
|
||||
b.presentSingle { _ in callbackFired = true }
|
||||
b.contactPickerDidCancel(CNContactPickerViewController())
|
||||
|
||||
#expect(!callbackFired)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ContactImport – Mapping von CNContact
|
||||
|
||||
@Suite("ContactImport – Mapping von CNContact")
|
||||
struct ContactImportTests {
|
||||
|
||||
@Test("Vor- und Nachname → fullName korrekt")
|
||||
func fullNameMapping() {
|
||||
let contact = CNMutableContact()
|
||||
contact.givenName = "Anna"; contact.familyName = "Schmidt"
|
||||
#expect(ContactImport.from(contact).name == "Anna Schmidt")
|
||||
}
|
||||
|
||||
@Test("Nur Vorname → kein Leerzeichen am Ende")
|
||||
func onlyFirstName() {
|
||||
let contact = CNMutableContact(); contact.givenName = "Cher"
|
||||
#expect(ContactImport.from(contact).name == "Cher")
|
||||
}
|
||||
|
||||
@Test("Nur Nachname → kein Leerzeichen am Anfang")
|
||||
func onlyLastName() {
|
||||
let contact = CNMutableContact(); contact.familyName = "Prince"
|
||||
#expect(ContactImport.from(contact).name == "Prince")
|
||||
}
|
||||
|
||||
@Test("Berufsbezeichnung bevorzugt gegenüber Firma")
|
||||
func jobTitlePreferredOverOrg() {
|
||||
let contact = CNMutableContact()
|
||||
contact.jobTitle = "Designer"; contact.organizationName = "ACME GmbH"
|
||||
#expect(ContactImport.from(contact).occupation == "Designer")
|
||||
}
|
||||
|
||||
@Test("Firma als Fallback wenn kein Beruf")
|
||||
func orgNameFallback() {
|
||||
let contact = CNMutableContact(); contact.organizationName = "ACME GmbH"
|
||||
#expect(ContactImport.from(contact).occupation == "ACME GmbH")
|
||||
}
|
||||
|
||||
@Test("Weder Beruf noch Firma → leerer String")
|
||||
func emptyOccupation() {
|
||||
#expect(ContactImport.from(CNMutableContact()).occupation == "")
|
||||
}
|
||||
|
||||
@Test("Stadt und Land → location korrekt")
|
||||
func locationCityAndCountry() {
|
||||
let contact = CNMutableContact()
|
||||
let address = CNMutablePostalAddress()
|
||||
address.city = "Berlin"; address.country = "Deutschland"
|
||||
contact.postalAddresses = [CNLabeledValue(label: CNLabelHome, value: address)]
|
||||
#expect(ContactImport.from(contact).location == "Berlin, Deutschland")
|
||||
}
|
||||
|
||||
@Test("Kein Ort → leerer String")
|
||||
func emptyLocation() {
|
||||
#expect(ContactImport.from(CNMutableContact()).location == "")
|
||||
}
|
||||
|
||||
@Test("Geburtstag mit vollständigem Datum → wird übernommen")
|
||||
func birthdayFullDate() {
|
||||
let contact = CNMutableContact()
|
||||
contact.birthday = DateComponents(year: 1990, month: 6, day: 15)
|
||||
let result = ContactImport.from(contact)
|
||||
let cal = Calendar.current
|
||||
#expect(result.birthday != nil)
|
||||
#expect(cal.component(.year, from: result.birthday!) == 1990)
|
||||
#expect(cal.component(.month, from: result.birthday!) == 6)
|
||||
#expect(cal.component(.day, from: result.birthday!) == 15)
|
||||
}
|
||||
|
||||
@Test("Geburtstag mit Jahr=1 → aktuelles Jahr wird verwendet")
|
||||
func birthdayYearOneReplacedWithCurrentYear() {
|
||||
let contact = CNMutableContact()
|
||||
contact.birthday = DateComponents(year: 1, month: 3, day: 8)
|
||||
let result = ContactImport.from(contact)
|
||||
let currentYear = Calendar.current.component(.year, from: Date())
|
||||
#expect(result.birthday != nil)
|
||||
#expect(Calendar.current.component(.year, from: result.birthday!) == currentYear)
|
||||
}
|
||||
|
||||
@Test("Kein Geburtstag → nil")
|
||||
func noBirthday() {
|
||||
#expect(ContactImport.from(CNMutableContact()).birthday == nil)
|
||||
}
|
||||
|
||||
@Test("Kein Foto → photoData ist nil")
|
||||
func noPhoto() {
|
||||
#expect(ContactImport.from(CNMutableContact()).photoData == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import nahbar
|
||||
|
||||
// MARK: - OceanDimension
|
||||
|
||||
@Suite("OceanDimension – Enum")
|
||||
struct OceanDimensionTests {
|
||||
|
||||
@Test("Genau 5 Dimensionen vorhanden")
|
||||
func allCasesCount() {
|
||||
#expect(OceanDimension.allCases.count == 5)
|
||||
}
|
||||
|
||||
@Test("rawValues sind nicht leer")
|
||||
func rawValuesNotEmpty() {
|
||||
for dim in OceanDimension.allCases {
|
||||
#expect(!dim.rawValue.isEmpty, "\(dim) hat leeren rawValue")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("rawValue round-trip (init(rawValue:))")
|
||||
func rawValueRoundTrip() {
|
||||
for dim in OceanDimension.allCases {
|
||||
let recovered = OceanDimension(rawValue: dim.rawValue)
|
||||
#expect(recovered == dim, "\(dim.rawValue) kann nicht wiederhergestellt werden")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("shortLabel hat genau 1 Zeichen")
|
||||
func shortLabelLength() {
|
||||
for dim in OceanDimension.allCases {
|
||||
#expect(dim.shortLabel.count == 1, "\(dim) hat shortLabel '\(dim.shortLabel)' (nicht 1 Zeichen)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("shortLabels sind eindeutig")
|
||||
func shortLabelsUnique() {
|
||||
let labels = OceanDimension.allCases.map { $0.shortLabel }
|
||||
#expect(Set(labels).count == labels.count, "Doppelte shortLabels: \(labels)")
|
||||
}
|
||||
|
||||
@Test("Stabile rawValues – Regressionswächter")
|
||||
func stableRawValues() {
|
||||
#expect(OceanDimension.openness.rawValue == "openness")
|
||||
#expect(OceanDimension.conscientiousness.rawValue == "conscientiousness")
|
||||
#expect(OceanDimension.extraversion.rawValue == "extraversion")
|
||||
#expect(OceanDimension.agreeableness.rawValue == "agreeableness")
|
||||
#expect(OceanDimension.neuroticism.rawValue == "neuroticism")
|
||||
}
|
||||
|
||||
@Test("axisLabel ist nicht leer")
|
||||
func axisLabelNotEmpty() {
|
||||
for dim in OceanDimension.allCases {
|
||||
#expect(!dim.axisLabel.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("icon ist nicht leer")
|
||||
func iconNotEmpty() {
|
||||
for dim in OceanDimension.allCases {
|
||||
#expect(!dim.icon.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TraitLevel
|
||||
|
||||
@Suite("TraitLevel – Schwellenwerte")
|
||||
struct TraitLevelTests {
|
||||
|
||||
@Test("Score 0 → niedrig")
|
||||
func score0IsLow() { #expect(TraitLevel.from(score: 0) == .low) }
|
||||
|
||||
@Test("Score 1 → mittel")
|
||||
func score1IsMedium() { #expect(TraitLevel.from(score: 1) == .medium) }
|
||||
|
||||
@Test("Score 2 → hoch")
|
||||
func score2IsHigh() { #expect(TraitLevel.from(score: 2) == .high) }
|
||||
|
||||
@Test("Score >2 → hoch (overflow sicher)")
|
||||
func scoreOverflowIsHigh() { #expect(TraitLevel.from(score: 99) == .high) }
|
||||
|
||||
@Test("rawValue round-trip")
|
||||
func rawValueRoundTrip() {
|
||||
for level in TraitLevel.allCases {
|
||||
#expect(TraitLevel(rawValue: level.rawValue) == level)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Stabile rawValues – Regressionswächter")
|
||||
func stableRawValues() {
|
||||
#expect(TraitLevel.low.rawValue == "low")
|
||||
#expect(TraitLevel.medium.rawValue == "medium")
|
||||
#expect(TraitLevel.high.rawValue == "high")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - QuizQuestion
|
||||
|
||||
@Suite("QuizQuestion – Statische Fragen")
|
||||
struct QuizQuestionTests {
|
||||
|
||||
@Test("Genau 10 Fragen")
|
||||
func totalCount() {
|
||||
#expect(QuizQuestion.all.count == 10)
|
||||
}
|
||||
|
||||
@Test("Genau 2 Fragen pro Dimension")
|
||||
func twoQuestionsPerDimension() {
|
||||
for dim in OceanDimension.allCases {
|
||||
let count = QuizQuestion.all.filter { $0.dimension == dim }.count
|
||||
#expect(count == 2, "Dimension \(dim.rawValue) hat \(count) statt 2 Fragen")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Alle IDs sind eindeutig")
|
||||
func uniqueIDs() {
|
||||
let ids = QuizQuestion.all.map { $0.id }
|
||||
#expect(Set(ids).count == ids.count, "Doppelte Fragen-IDs: \(ids)")
|
||||
}
|
||||
|
||||
@Test("Situationstexte sind nicht leer")
|
||||
func situationTextsNotEmpty() {
|
||||
for q in QuizQuestion.all {
|
||||
#expect(!q.situation.isEmpty, "Frage \(q.id) hat leeren Situationstext")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Option-Texte sind nicht leer")
|
||||
func optionTextsNotEmpty() {
|
||||
for q in QuizQuestion.all {
|
||||
#expect(!q.optionA.isEmpty, "Frage \(q.id) hat leeren optionA-Text")
|
||||
#expect(!q.optionB.isEmpty, "Frage \(q.id) hat leeren optionB-Text")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("optionAScore ist 0 oder 1")
|
||||
func optionAScoreIsValid() {
|
||||
for q in QuizQuestion.all {
|
||||
#expect(q.optionAScore == 0 || q.optionAScore == 1,
|
||||
"Frage \(q.id) hat ungültigen optionAScore: \(q.optionAScore)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Neurotizismus-Fragen haben optionAScore 0 (invertiert)")
|
||||
func neuroticismIsInverted() {
|
||||
let nQuestions = QuizQuestion.all.filter { $0.dimension == .neuroticism }
|
||||
for q in nQuestions {
|
||||
#expect(q.optionAScore == 0,
|
||||
"Neurotizismus-Frage \(q.id) sollte optionAScore=0 haben (Option A = stabil)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Stabile Fragen-IDs – Regressionswächter")
|
||||
func stableQuestionIDs() {
|
||||
let ids = QuizQuestion.all.map { $0.id }
|
||||
#expect(ids == ["O1", "O2", "C1", "C2", "E1", "E2", "A1", "A2", "N1", "N2"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PersonalityEngine
|
||||
|
||||
@Suite("PersonalityEngine – Score-Berechnung")
|
||||
struct PersonalityEngineTests {
|
||||
|
||||
@Test("Alle Option-A → korrekter Score nach optionAScore")
|
||||
func allOptionAGivesCorrectScore() {
|
||||
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
|
||||
let profile = PersonalityEngine.computeProfile(from: allA)
|
||||
|
||||
for dim in OceanDimension.allCases {
|
||||
let expected = QuizQuestion.all
|
||||
.filter { $0.dimension == dim }
|
||||
.reduce(0) { $0 + $1.optionAScore }
|
||||
#expect(profile.scores[dim] == expected,
|
||||
"\(dim.rawValue): erwartet \(expected), bekommen \(profile.scores[dim] ?? -1)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Alle Option-B → invertierter Score")
|
||||
func allOptionBGivesInvertedScore() {
|
||||
let allB = QuizQuestion.all.map { (questionID: $0.id, choseA: false) }
|
||||
let profile = PersonalityEngine.computeProfile(from: allB)
|
||||
|
||||
for dim in OceanDimension.allCases {
|
||||
let expected = QuizQuestion.all
|
||||
.filter { $0.dimension == dim }
|
||||
.reduce(0) { $0 + (1 - $1.optionAScore) }
|
||||
#expect(profile.scores[dim] == expected,
|
||||
"\(dim.rawValue): erwartet \(expected), bekommen \(profile.scores[dim] ?? -1)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Neurotizismus invertiertes Scoring: Option A = 0 Punkte, Option B = 1 Punkt")
|
||||
func neuroticismInvertedScoring() {
|
||||
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
|
||||
let profileA = PersonalityEngine.computeProfile(from: allA)
|
||||
#expect(profileA.scores[.neuroticism] == 0,
|
||||
"N mit Option A: erwartet 0, bekommen \(profileA.scores[.neuroticism] ?? -1)")
|
||||
|
||||
let allB = QuizQuestion.all.map { (questionID: $0.id, choseA: false) }
|
||||
let profileB = PersonalityEngine.computeProfile(from: allB)
|
||||
#expect(profileB.scores[.neuroticism] == 2,
|
||||
"N mit Option B: erwartet 2, bekommen \(profileB.scores[.neuroticism] ?? -1)")
|
||||
}
|
||||
|
||||
@Test("Profil ist complete nach 10 Antworten")
|
||||
func profileIsCompleteAfterTenAnswers() {
|
||||
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
|
||||
let profile = PersonalityEngine.computeProfile(from: allA)
|
||||
#expect(profile.isComplete)
|
||||
#expect(profile.completedAt != nil)
|
||||
}
|
||||
|
||||
@Test("Scores liegen immer im Bereich 0…2")
|
||||
func scoresAlwaysInValidRange() {
|
||||
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
|
||||
let profileA = PersonalityEngine.computeProfile(from: allA)
|
||||
let allB = QuizQuestion.all.map { (questionID: $0.id, choseA: false) }
|
||||
let profileB = PersonalityEngine.computeProfile(from: allB)
|
||||
|
||||
for dim in OceanDimension.allCases {
|
||||
#expect((profileA.scores[dim] ?? -1) >= 0 && (profileA.scores[dim] ?? -1) <= 2)
|
||||
#expect((profileB.scores[dim] ?? -1) >= 0 && (profileB.scores[dim] ?? -1) <= 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Fehlende Antworten (übersprungen) führen zu Score 0 für die Dimension")
|
||||
func skippedAnswersGiveZero() {
|
||||
let profile = PersonalityEngine.computeProfile(from: [])
|
||||
for dim in OceanDimension.allCases {
|
||||
#expect(profile.scores[dim] == 0, "\(dim.rawValue) sollte 0 sein bei leeren Antworten")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("completedAt liegt zwischen before und after dem Aufruf")
|
||||
func completedAtIsReasonablyNow() {
|
||||
let before = Date()
|
||||
let allA = QuizQuestion.all.map { (questionID: $0.id, choseA: true) }
|
||||
let profile = PersonalityEngine.computeProfile(from: allA)
|
||||
let after = Date()
|
||||
|
||||
if let ts = profile.completedAt {
|
||||
#expect(ts >= before && ts <= after)
|
||||
} else {
|
||||
Issue.record("completedAt sollte gesetzt sein")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PersonalityProfile
|
||||
|
||||
@Suite("PersonalityProfile – Berechnung & Codable")
|
||||
struct PersonalityProfileTests {
|
||||
|
||||
private func makeProfile(scores: [OceanDimension: Int] = [:]) -> PersonalityProfile {
|
||||
var full = Dictionary(uniqueKeysWithValues: OceanDimension.allCases.map { ($0, 1) })
|
||||
for (k, v) in scores { full[k] = v }
|
||||
return PersonalityProfile(scores: full, completedAt: Date())
|
||||
}
|
||||
|
||||
@Test("level für Score 0 → niedrig")
|
||||
func levelLowForScoreZero() {
|
||||
let p = makeProfile(scores: [.openness: 0])
|
||||
#expect(p.level(for: .openness) == .low)
|
||||
}
|
||||
|
||||
@Test("level für Score 1 → mittel")
|
||||
func levelMediumForScoreOne() {
|
||||
let p = makeProfile(scores: [.openness: 1])
|
||||
#expect(p.level(for: .openness) == .medium)
|
||||
}
|
||||
|
||||
@Test("level für Score 2 → hoch")
|
||||
func levelHighForScoreTwo() {
|
||||
let p = makeProfile(scores: [.openness: 2])
|
||||
#expect(p.level(for: .openness) == .high)
|
||||
}
|
||||
|
||||
@Test("normalized: Score 0 → 0.0")
|
||||
func normalizedMinIsZero() {
|
||||
let p = makeProfile(scores: [.openness: 0])
|
||||
#expect(p.normalized(for: .openness) == 0.0)
|
||||
}
|
||||
|
||||
@Test("normalized: Score 1 → 0.5")
|
||||
func normalizedMidIsFifty() {
|
||||
let p = makeProfile(scores: [.openness: 1])
|
||||
#expect(p.normalized(for: .openness) == 0.5)
|
||||
}
|
||||
|
||||
@Test("normalized: Score 2 → 1.0")
|
||||
func normalizedMaxIsOne() {
|
||||
let p = makeProfile(scores: [.openness: 2])
|
||||
#expect(p.normalized(for: .openness) == 1.0)
|
||||
}
|
||||
|
||||
@Test("summaryText ist nicht leer für alle Kombinationen")
|
||||
func summaryTextNeverEmpty() {
|
||||
for scores in [[TraitLevel.low, .low, .low, .low, .low],
|
||||
[.high, .high, .high, .high, .high],
|
||||
[.medium, .medium, .medium, .medium, .medium]] {
|
||||
let profile = PersonalityProfile(
|
||||
scores: Dictionary(uniqueKeysWithValues:
|
||||
OceanDimension.allCases.enumerated().map { i, dim in
|
||||
(dim, scores[i] == .low ? 0 : scores[i] == .medium ? 1 : 2)
|
||||
}),
|
||||
completedAt: Date()
|
||||
)
|
||||
#expect(!profile.summaryText.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("isComplete ist false wenn completedAt nil")
|
||||
func isCompleteIsFalseWhenNilDate() {
|
||||
let p = PersonalityProfile(scores: [:], completedAt: nil)
|
||||
#expect(!p.isComplete)
|
||||
}
|
||||
|
||||
@Test("isComplete ist true wenn completedAt gesetzt")
|
||||
func isCompleteIsTrueWhenDateSet() {
|
||||
let p = makeProfile()
|
||||
#expect(p.isComplete)
|
||||
}
|
||||
|
||||
@Test("Codable round-trip via JSONEncoder/Decoder")
|
||||
func codableRoundTrip() throws {
|
||||
let original = makeProfile(scores: [.openness: 2, .extraversion: 0, .neuroticism: 1])
|
||||
let data = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(PersonalityProfile.self, from: data)
|
||||
#expect(decoded == original)
|
||||
}
|
||||
|
||||
@Test("PersonalityProfile benötigt keinen Netzwerkaufruf")
|
||||
func doesNotRequireNetwork() {
|
||||
// Rein synchron – kein await, kein async
|
||||
let p = makeProfile()
|
||||
let summary = p.summaryText
|
||||
#expect(!summary.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PersonalityEngine – Behavior Logic
|
||||
|
||||
@Suite("PersonalityEngine – Verhaltenslogik")
|
||||
struct PersonalityEngineBehaviorTests {
|
||||
|
||||
private func profile(e: TraitLevel = .medium, n: TraitLevel = .medium,
|
||||
c: TraitLevel = .medium, a: TraitLevel = .medium,
|
||||
o: TraitLevel = .medium) -> PersonalityProfile {
|
||||
func score(_ l: TraitLevel) -> Int { l == .low ? 0 : l == .medium ? 1 : 2 }
|
||||
return PersonalityProfile(scores: [
|
||||
.extraversion: score(e), .neuroticism: score(n),
|
||||
.conscientiousness: score(c), .agreeableness: score(a), .openness: score(o)
|
||||
], completedAt: Date())
|
||||
}
|
||||
|
||||
@Test("Hohe Extraversion → kürzeres Nudge-Intervall als niedrige")
|
||||
func highExtraversionGivesShorterInterval() {
|
||||
let highE = PersonalityEngine.suggestedNudgeInterval(for: profile(e: .high))
|
||||
let lowE = PersonalityEngine.suggestedNudgeInterval(for: profile(e: .low))
|
||||
#expect(highE < lowE)
|
||||
}
|
||||
|
||||
@Test("Hoher Neurotizismus-Score + niedrige Extraversion → 14 Tage")
|
||||
func lowExtraversionHighNeuroticismGives14Days() {
|
||||
// N1 und N2 haben optionAScore=0, Option B gibt Punkte
|
||||
// In unserem Modell: hoher Neurotizismus-Score = mehr N-Punkte
|
||||
let p = profile(e: .low, n: .high)
|
||||
#expect(PersonalityEngine.suggestedNudgeInterval(for: p) == 14)
|
||||
}
|
||||
|
||||
@Test("Hohe Gewissenhaftigkeit → sofortiger Rating-Prompt")
|
||||
func highConscientiousnessGivesImmediatePrompt() {
|
||||
let p = profile(c: .high)
|
||||
if case .immediate = PersonalityEngine.ratingPromptTiming(for: p) {
|
||||
// korrekt
|
||||
} else {
|
||||
Issue.record("Hohe Gewissenhaftigkeit sollte immediate prompt liefern")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Hoher Neurotizismus → verzögerter Rating-Prompt (7200s)")
|
||||
func highNeuroticismGivesDelayedPrompt() {
|
||||
let p = profile(c: .low, n: .high)
|
||||
if case .delayed(let secs, _) = PersonalityEngine.ratingPromptTiming(for: p) {
|
||||
#expect(secs == 7200)
|
||||
} else {
|
||||
Issue.record("Hoher Neurotizismus sollte delayed prompt liefern")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Benachrichtigungstext mit hohem Neurotizismus ist wärmer")
|
||||
func highNeuroticismNotificationCopyIsWarmer() {
|
||||
let p = profile(n: .high)
|
||||
let copy = PersonalityEngine.notificationCopy(contactName: "Alex", profile: p)
|
||||
#expect(copy.contains("freut sich"))
|
||||
}
|
||||
|
||||
@Test("Benachrichtigungstext ohne Profil liefert Fallback")
|
||||
func nilProfileGivesFallback() {
|
||||
let copy = PersonalityEngine.notificationCopy(contactName: "Alex", profile: nil)
|
||||
#expect(!copy.isEmpty)
|
||||
}
|
||||
|
||||
@Test("RecommendedBadge nicht angezeigt wenn Quiz übersprungen (kein Profil)")
|
||||
func noBadgeWhenNoProfile() {
|
||||
let suggestions = PersonalityEngine.sortedSuggestions(
|
||||
contacts: [],
|
||||
profile: nil,
|
||||
lastVisitDates: [:]
|
||||
)
|
||||
for s in suggestions {
|
||||
#expect(!s.isRecommended)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Hohe Offenheit → highlightNovelty true")
|
||||
func highOpennessHighlightsNovelty() {
|
||||
let p = profile(o: .high)
|
||||
#expect(PersonalityEngine.highlightNovelty(for: p))
|
||||
}
|
||||
|
||||
@Test("Niedrige Offenheit → highlightNovelty false")
|
||||
func lowOpennessDoesNotHighlightNovelty() {
|
||||
let p = profile(o: .low)
|
||||
#expect(!PersonalityEngine.highlightNovelty(for: p))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OnboardingStep – Regressionswächter (nach Quiz-Erweiterung)
|
||||
|
||||
@Suite("OnboardingStep – RawValues (Quiz-Erweiterung)")
|
||||
struct OnboardingStepQuizTests {
|
||||
|
||||
@Test("RawValues sind aufsteigend 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user