Compare commits

..

34 Commits

Author SHA1 Message Date
sven 2ba0802a29 Fix: Kalender-Permission nur laden wenn bereits erteilt
SettingsView fordert Kalender-Zugriff nicht mehr beim bloßen Öffnen
der Einstellungen an. CalendarManager.isAuthorized prüft den
bestehenden Status ohne requestFullAccess() aufzurufen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 10:33:01 +02:00
sven e4c2293bec Fix #37: Privacy-Compliance + Onboarding-Kontaktpflicht
- NSContactsUsageDescription + NSPhotoLibraryUsageDescription in Info.plist
  ergänzt (App-Store-Review-Compliance für import Contacts/PhotosUI)
- Onboarding: Überspringen-Button entfernt, mindestens 1 Kontakt erforderlich
- Hinweistext wenn Kontaktliste leer: erklärt warum Weiter gesperrt ist
- Alert wenn Picker-Auswahl das Free-Tier-Limit von 3 überschreitet
- Lokalisierung (DE+EN) für alle neuen Strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 09:16:00 +02:00
sven 2c274ff4ae Fix #36: iOS-17-Kompatibilität – UITabBarAppearance per #unavailable(iOS 26)
Auf iOS 17–25 setzt UITabBarAppearance weiterhin die Theme-Farben
(kein Liquid Glass → kein Konflikt, kein Flicker).
Auf iOS 26+ bleibt der Block weg, Liquid Glass + .preferredColorScheme
übernehmen die Tab-Bar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:31:59 +02:00
sven 0c8e78f49d Fix #36: Tab-Bar Liquid Glass – keine eigene UITabBarAppearance mehr
iOS 26 rendert die Tab-Bar mit Liquid Glass und passt Hell/Dunkel
automatisch an den Hintergrund an. Eigene UITabBarAppearance-
Konfigurationen (insbes. configureWithOpaqueBackground) blockieren
diesen Mechanismus und verursachen den weißen Flicker.

Änderungen:
- UITabBarAppearance vollständig entfernt; Liquid Glass übernimmt
- .preferredColorScheme(isDark ? .dark : .light) gesetzt – informiert
  das System über den Modus des aktiven Themes, sodass Liquid Glass,
  Sheets und Alerts korrekt hell/dunkel erscheinen
- UINavigationBarAppearance bleibt als Fallback für Views ohne themedNavBar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:09:29 +02:00
sven 8180fcbcbc Fix #36: Tab-Bar-Flicker durch UIKit/SwiftUI-Konflikt behoben
- SwiftUI .toolbarBackground/.toolbarColorScheme-Modifier für .tabBar
  aus ContentView entfernt – Konflikt mit UITabBar.appearance() beseitigt
- applyTabBarAppearance in statische Methode umgewandelt
- applyInitialTabBarAppearance() in AppDelegate.didFinishLaunchingWithOptions
  aufgerufen – setzt UIKit-Appearance VOR der View-Erstellung
- configureWithTransparentBackground → configureWithOpaqueBackground:
  verhindert weißes Bleed-through bei dunklen Themes
- Alpha-Komponente aus Tab-Bar-Hintergrundfarbe entfernt (vollständig opak)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:56:49 +02:00
sven 8af0309580 Fix #36: Tab-Bar-Flicker in dunklen Themes behoben
toolbarBackground/toolbarColorScheme von jedem einzelnen Tab auf das
TabView selbst verschoben. Per-Tab-Modifikatoren verursachten beim
Tab-Wechsel ein weißes Aufblitzen, da die Styles nur für den jeweils
aktiven Tab galten und beim Wechsel kurz verloren gingen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:45:00 +02:00
sven 8427f55f21 Tests & Lokalisierung: Abschluss-Audit v1.0
- LogEntryType: CaseIterable-Konformität hinzugefügt
- ModelTests: todoCompleted in allTypesHaveIconAndColor; 2 neue Regressionstests (allCases count=4, stabile rawValues)
- Localizable.xcstrings: 30 fehlende EN-Übersetzungen ergänzt (SettingsView-Sektionen, KI Insights, Todos, Onboarding-Texte u.a.)

502 Tests, 0 Fehler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:22:42 +02:00
sven 31a7a2d5df Fix #20: Aktivitätsvorschläge-Feature entfernt
Funktion war zu unspezifisch und wenig nützlich. Komplett entfernt:
- PersonalityEngine: suggestedActivities, ActivityStyle, ActivitySuggestion, preferredActivityStyle, highlightNovelty
- PersonDetailView: activityHint, personalityStore, intentionSuggestionButton(), refreshActivityHint()
- NahbarPersonalityTests: highlightNovelty-Tests + SuggestedActivitiesTests-Suite

500 Tests, 0 Fehler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:03:18 +02:00
sven 9e932f0f2c Fix #33: SettingsView in 4 klare Sektionen umstrukturiert
Vorher: 11 lose Sektionen ohne erkennbare Ordnung.
Nachher:
  1. Abonnement – visuell hervorgehoben (accent-getönte Karte mit Border)
  2. Darstellung & Profil – Theme + Persönlichkeitsquiz
  3. Funktionen – Gesprächszeit, Kalender, Treffen, KI-Modell
  4. System – App-Schutz, iCloud, Über nahbar
  + Entwickler-Link am Fuß → eigene SubView mit Reset & Dev-Log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:47:58 +02:00
sven ace6801d01 Refactor: KI-Auswertung aus Logbuch entfernt
Die KI-Karte (aiAnalysisCard) wurde vollständig aus LogbuchView ausgebaut.
KI Insights sind weiterhin über den Sparkles-Button im Kontakt-Header
zugänglich. Sektionsüberschrift in PersonDetailView von
"Verlauf & KI Insights zu [Name]" auf "Verlauf" vereinfacht.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:38:31 +02:00
sven 8a13962055 UX: KI-Analyse → KI Insights zu [Name] in PersonDetailView
Sektionsüberschrift und Sheet-Titel enthalten jetzt den Vornamen der Person
für mehr Kontext. Englische Format-Keys in xcstrings ergänzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:35:18 +02:00
sven 5f14649112 Fix #30: Fade-Timer für Treffen erst nach Rating-Survey starten
Treffen-Momente warten in pendingFadeAfterSurvey bis momentForRating
wieder auf nil wechselt (Survey abgeschlossen oder geskipped). Erst dann
startet der 5-s-Timer. Notizen und Vorhaben starten den Timer weiterhin
sofort. Eingeblendet werden alle neuen Logbuch-Momente bereits beim
Schließen des Sheets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:31:09 +02:00
sven e365400537 Fix #30: Verlaufsansicht – neue Momente 5 s in Momente, dann in Verlauf
Neue Logbuch-Momente (vergangene Treffen, Notizen) erscheinen nach dem
Speichern 5 Sekunden mit 45 % Deckkraft in der Momente-Sektion und wandern
dann animiert in den Verlauf. Aktive Momente (offene Vorhaben, Zukunfts-
treffen) bleiben dauerhaft in der Momente-Sektion. Der Verlauf zeigt nur
noch abgeschlossene/vergangene Einträge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:26:56 +02:00
sven 801095d9b9 Fix #34: Abo-Badges selbst-versteckend + IchView-Bereinigung
- ProBadge und MaxBadge verstecken sich intern via StoreManager (eiserne Regel: nie extern mit !isPro/!isMax wrappen)
- AddMomentView und TodayView: MaxBadge nicht mehr bei isMax anzeigen
- ThemePickerView: Inline-PRO-Text durch ProBadge() ersetzt
- IchView: Geschlecht aus Leseansicht entfernt, Picker im Bearbeitungsformular erhalten
- Geschlecht-Picker: Duplikat "Keine Angabe" entfernt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:20:05 +02:00
sven bf1b49697b Fix #22, #28, #31: Kontakt-Sektion, Nudge-Chip, KI-Analyse-Button
- #22: Dedizierte Kontakt-Sektion mit Telefon (Action Sheet) und E-Mail (mailto + Fallback-Alert mit Kopieren)
- #28: Nudge-Intervall-Chip im Header mit Farb-Dot, relativem Zeitstempel und direktem Menu zur Anpassung; NudgeStatus-Enum + Tests
- #31: KI-Analyse-Button im Kontakt-Header (oben rechts) mit MaxBadge; AIAnalysisSheet mit Auto-Start, Consent-Flow und allen Zuständen (idle/loading/result/error)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:07:04 +02:00
sven ec0dc68db9 Notifications: \"Vorhaben\" durch passende Begriffe ersetzen
- Todo-Erinnerungen (AddTodoView, PersonDetailView): Subtitle \"Dein Todo\"
- Moment-Erinnerungen (AddMomentView): Subtitle \"Geplanter Moment\"
- Localizable.xcstrings: \"Dein Vorhaben\" entfernt, neue Strings ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:02:29 +02:00
sven 86ddc10e7b Tests: TourTargetID, Step-Targets, Vorname-Extraktion, OnboardingStep-Fix
- Neu: TourTargetIDTests – allCases-Anzahl, rawValues, addMomentButton/addTodoButton
- Neu: TourStepTargetTests – Onboarding-Steps 3/4 targeten .addMomentButton/.addTodoButton
- Neu: GreetingFirstNameTests – Vorname-Extraktion aus Begrüßungslogik
- Fix: OnboardingStepQuizTests – an tatsächliche Enum-Cases angepasst (3 statt 5)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 20:56:48 +02:00
sven c647553eb7 Fix #26: Vorname im Gruß + Tour-Verbesserungen + UI-Feinschliff
- TodayView: Begrüßung zeigt Vornamen aus UserProfileStore
- TodayView: Leerer Zustand mit zwei CTA-Buttons (Moment / Todo)
- TodayView: Untertitel auf "Lass uns mit der Beziehungspflege starten." geändert
- Tour: Neue Schritte highlighten +Moment und +Todo in PersonDetailView
- Tour: PeopleListView navigiert automatisch zum ersten Kontakt beim Tour-Schritt
- Tour: App-Touren-Sektion in Einstellungen deaktiviert
- Schriftgrößen: Alle Überschriften um 2pt verkleinert (34→32, etc.)
- ContentView Preview: TourCoordinator-Environment ergänzt
- Lokalisierung: Neue Strings für Gruß, Leerzustand und Tour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 20:50:00 +02:00
sven 1e75f357ba Fix #27: Onboarding-Tour im Haupt-Kontext + UI-Verbesserungen
- Tour-Spotlight-System: Overlay transparent, Hintergrund bleibt sichtbar
- Tour startet nach Splash-Screen im ContentView-Kontext (nicht mehr im fullScreenCover)
- Splash-Screen: UI-Präsentation (Onboarding, CallWindow-Setup) wartet auf Splash-Ende
- TodayView: Leerer Zustand mit zwei separaten CTAs (Moment erfassen / Todo hinzufügen)
- OnboardingCoordinator: 3 Schritte (profile, contacts, complete), Tour separat
- PeopleListView: .tourTarget() für addContactButton und contactCardFirst
- SettingsView: resetSeenTours() bei App-Reset

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 20:20:17 +02:00
sven a0741ba608 Fix #23: Tour-Übersetzung – deutsche Strings als Keys, Lokalisierungen ergänzt
- TourCatalog.swift: Technische Keys (tour.onboarding.*) durch deutschen
  Klartext ersetzt (konform mit Projekt-xcstrings-Konvention)
- TourCardView.swift: Ternary-Ausdrucks-Bug behoben (String statt
  LocalizedStringKey); Button-Labels mit deutschen Strings
- SettingsView.swift: settings.tours.* durch deutsche Keys ersetzt
- Localizable.xcstrings: Technische Keys entfernt, alle Tour-Strings als
  deutsche Keys mit EN-Übersetzungen hinzugefügt (19 neue Einträge)
- TourCatalogTests: import Foundation ergänzt (LocalizedStringResource)
- TourCoordinatorTests: import CoreGraphics ergänzt (CGRect)
- StoreTests: Closure-Argument-Fehler behoben (_ in)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 19:33:04 +02:00
sven b214bb6c50 Fix #23: Spotlight-Tour-System – Onboarding + Update-Touren
Neue wiederverwendbare Tour-Komponente (11 Swift-Dateien):

Model:
- TourID, TourTargetID, TourStep, Tour (max 6 Steps per precondition)
- TourCatalog: statische Registry; initiale Onboarding-Tour mit 6 Steps

State:
- TourSeenStore: UserDefaults-backed, injizierbar für Tests
- TourCoordinator: @Observable, Pending-Queue, Auto-Start-Logik
  · checkForPendingTours() startet .autoOnUpdate-Touren bei App-Update
  · .manualOrFirstLaunch-Touren werden explizit per start(_:) gestartet

UI:
- SpotlightShape: Even-Odd-Fill-Shape mit animatableData
- TourCardView: Progress-Dots, Navigation, Haptic-Feedback
- TourOverlayView: Spotlight-Cutout via thinMaterial + SpotlightShape eoFill,
  Coral-Glow-Ring, automatische Card-Positionierung (above/below/center)
- TourViewModifiers: .tourTarget() + .tourPresenter()

Integration:
- NahbarApp: TourCoordinator via @State + .environment()
- OnboardingContainerView: FeatureTourView ersetzt durch TourCoordinator.start(.onboarding);
  .tourPresenter() im fullScreenCover-Kontext; OnboardingPrivacyView bleibt erhalten
- ContentView: .tourPresenter() für main-app Update-Touren + checkForPendingTours()
- SettingsView: neue "App-Touren"-Sektion zum manuellen Neu-Starten

Tests (40 Tests in 4 Dateien):
- TourCatalogTests, TourSeenStoreTests, TourCoordinatorTests, AutoStartLogicTests

Lokalisierung: DE + EN für alle Tour-Strings in Localizable.xcstrings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 19:06:56 +02:00
sven 55991808cf Fix #21: Onboarding-Fragebogen lokalisiert + Skip-Button besser sichtbar
- PersonalityModels: Alle 30 QuizQuestion-Strings in String(localized:) gewrappt
  → Quiz nutzt jetzt korrekt die Lokaliserungsstrings (vorher: stale in xcstrings)
- PersonalityQuizView: "Überspringen"-Button in allen 3 Screens vereinheitlicht
  → .subheadline.weight(.medium) + .secondary statt caption/tertiary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 18:48:01 +02:00
sven 9ca54e6a82 Fix #15: Push-Nachrichten – warmer Ton + Persönlichkeitsanpassung
- PersonalityEngine: callWindowCopy() + aftermathCopy() zentralisieren
  alle Notification-Texte (bisher verstreut und teils unlokalisiert)
- CallWindowManager: 3 Varianten nach Profil (high E / high N / default),
  String(localized:) + Error-Logging ergänzt
- AftermathNotificationManager: Titel "Nachwirkung: %@" → "Wie war's mit %@?",
  Body via PersonalityEngine.aftermathCopy()
- Todo- und Vorhaben-Erinnerungen: subtitle "Dein Vorhaben" für
  persönlicheren Kontext (AddTodoView, EditTodoView, AddMomentView)
- AddMomentView: Logging + Error-Callback nachgezogen (wie AddTodoView)
- 9 neue Tests in NahbarPersonalityTests (NotificationCopyTests)
- Lokalisierung: 4 neue Strings inkl. bisher unlokalisierter Texte

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 18:29:51 +02:00
sven 17f4dbd3ab Fix: Push-Benachrichtigungen für Todo-Erinnerungen im Vordergrund
- AppDelegate mit UNUserNotificationCenterDelegate: Notifications werden
  jetzt auch angezeigt wenn die App im Vordergrund läuft (.banner + .sound)
- scheduleReminder: Fehler-Logging bei abgelehnter Berechtigung und
  center.add-Fehler hinzugefügt (analog zu AftermathNotificationManager)
- userInfo mit todoID in Notification-Content aufgenommen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 18:17:57 +02:00
sven 7605a2d30c Fix #12: Interessen als farbige Chips mit Autocomplete
- InterestTagHelper: parse/join/add/remove/suggestions (alphabetisch sortiert)
- InterestChipRow: wiederverwendbare Display-Komponente (grün/rot)
- InterestTagEditor: Chip-Editor mit × + Tipp-Autocomplete
- AddPersonView, PersonDetailView, IchView auf neue Komponenten umgestellt
- 20 InterestTagHelper-Tests in StoreTests
- Lokalisierung: "Tag hinzufügen…" → "Add tag…"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 18:17:50 +02:00
sven c4202cbf2f Fix #19: FeatureGate-Abstraktion + umfassende Abo-Tests
Kapselt die gesamte Abo-Logik in einem testbaren `FeatureGate` Value-Type.
`refreshStatus()` nutzt nun `FeatureGate.from(foundPro:foundMax:)` als
zentrale Factory — die "Max implies Pro"-Invariante ist so nicht mehr
implizit im StoreManager vergraben, sondern explizit und durch 18 neue
Unit-Tests abgesichert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 17:52:04 +02:00
sven 1ecc44a625 Gesprächsthemen: Halluzinations-Schutz + Datenmangel-Hinweis
- Code-seitiger Guard: < 2 Momente/Einträge → .insufficientData-State
  statt API-Call (verhindert Halluzinationen bei leeren Profilen)
- UI: Info-Hinweis "Noch zu wenig Verlauf für persönliche Vorschläge"
- Prompt: STRIKT-Anweisung, nur vorhandene Daten zu verwenden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:31:46 +02:00
sven f1de4bfd30 Prompt: Gesprächsthemen-Vorschläge kürzer und knapper
Max. 8 Wörter pro Punkt, Stichwörter statt Sätze.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:29:47 +02:00
sven 3ac221a049 AddMomentView: ScrollView + kleineres Textfeld für KI-Vorschläge
TextEditor auf minHeight 120 verkleinert, VStack in ScrollView
eingebettet – KI-Vorschläge schieben den Editierbereich nicht
mehr aus dem Sichtfeld.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:27:45 +02:00
sven 22e1d68217 Umbenennung: Gesprächsvorschläge → Gesprächsthemen vorschlagen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:25:29 +02:00
sven 74bd53407d Gesprächsvorschläge auch für Gespräch-Typ verfügbar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:22:47 +02:00
sven 7057ccb607 Gesprächsvorschläge: Übernehmen-Button pro Sektion
Jede der drei Vorschlags-Sektionen hat jetzt einen kleinen Button
(Pfeil-Icon), der den jeweiligen Text ins Notizfeld übernimmt.
Kurzes Checkmark-Feedback zeigt die erfolgreiche Übernahme an.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:22:02 +02:00
sven a3ae925a10 Fix Gesprächsvorschläge: ** Markdown-Marker aus Fließtext entfernen
Die KI gibt gelegentlich **fett** formatierten Text zurück. Da die
Vorschläge als Plain Text dargestellt werden, werden verbleibende
** nach der Sektion-Extraktion jetzt bereinigt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:20:41 +02:00
sven 319b59c12e Resolves #18 KI-Gesprächsvorschläge beim Anlegen eines Treffens
Neues Max-Plan-Feature: Beim Anlegen eines Treffens können KI-gestützte
Gesprächsvorschläge abgerufen werden. Die Vorschläge umfassen Themen-
vorschläge, Gesprächsretter (bei Stockungen) und Tipps um Smalltalk
in bedeutsame Gespräche zu verwandeln.

- AIAnalysisService: ConversationSuggestionResult, CachedConversationSuggestion,
  suggestConversation(person:), parseConversationResult (internal),
  buildPrompt auf PromptType-Enum umgestellt
- SettingsView: AppLanguage.conversationInstruction (DE + EN)
- AddMomentView: KI-Sektion (idle/loading/result/error) nur bei Treffen-Typ
- PaywallView: Gesprächsvorschläge in Max-Feature-Liste
- Localizable.xcstrings: 10 neue DE/EN-Strings
- Tests: 8 neue Unit-Tests für Parsing und Codable Round-Trip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:17:25 +02:00
58 changed files with 5991 additions and 1560 deletions
+4 -9
View File
@@ -58,15 +58,10 @@ final class AftermathNotificationManager {
private func createNotification(momentID: UUID, personName: String, delay: TimeInterval) { private func createNotification(momentID: UUID, personName: String, delay: TimeInterval) {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = String.localizedStringWithFormat(String(localized: "Nachwirkung: %@"), personName) // Wärmerer, gesprächigerer Titel statt klinischem "Nachwirkung: [Name]"
// Persönlichkeitsgerechter Body-Text (softer für hohen Neurotizismus) content.title = String.localizedStringWithFormat(String(localized: "Wie war's mit %@?"), personName)
let defaultBody = String(localized: "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen dauert 1 Minute.") // Persönlichkeitsgerechter Body-Text via PersonalityEngine
if let profile = PersonalityStore.shared.profile, content.body = PersonalityEngine.aftermathCopy(profile: PersonalityStore.shared.profile)
case .delayed(_, let softerCopy?) = PersonalityEngine.ratingPromptTiming(for: profile) {
content.body = softerCopy
} else {
content.body = defaultBody
}
content.sound = .default content.sound = .default
content.categoryIdentifier = Self.categoryID content.categoryIdentifier = Self.categoryID
content.userInfo = [ content.userInfo = [
+56
View File
@@ -7,6 +7,17 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
263FF44A2F99356A00C1957C /* TourID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF4492F99356A00C1957C /* TourID.swift */; };
263FF44C2F99356E00C1957C /* TourTargetID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF44B2F99356E00C1957C /* TourTargetID.swift */; };
263FF44E2F99357500C1957C /* TourStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF44D2F99357500C1957C /* TourStep.swift */; };
263FF4502F99357F00C1957C /* Tour.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF44F2F99357F00C1957C /* Tour.swift */; };
263FF4522F99358800C1957C /* TourCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF4512F99358800C1957C /* TourCatalog.swift */; };
263FF4542F99359600C1957C /* TourSeenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF4532F99359600C1957C /* TourSeenStore.swift */; };
263FF4562F9935AC00C1957C /* TourCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF4552F9935AC00C1957C /* TourCoordinator.swift */; };
263FF4582F9935BC00C1957C /* SpotlightShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF4572F9935BC00C1957C /* SpotlightShape.swift */; };
263FF45A2F9935CD00C1957C /* TourCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF4592F9935CD00C1957C /* TourCardView.swift */; };
263FF45C2F9935E400C1957C /* TourOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF45B2F9935E400C1957C /* TourOverlayView.swift */; };
263FF45E2F9935EF00C1957C /* TourViewModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 263FF45D2F9935EF00C1957C /* TourViewModifiers.swift */; };
2670595C2F96640E00956084 /* CalendarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2670595B2F96640E00956084 /* CalendarManager.swift */; }; 2670595C2F96640E00956084 /* CalendarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2670595B2F96640E00956084 /* CalendarManager.swift */; };
269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269ECE652F92B5C700444B14 /* NahbarMigration.swift */; }; 269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269ECE652F92B5C700444B14 /* NahbarMigration.swift */; };
26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B1E2DA2F93985A009CF58B /* CloudSyncMonitor.swift */; }; 26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B1E2DA2F93985A009CF58B /* CloudSyncMonitor.swift */; };
@@ -99,6 +110,17 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
263FF4492F99356A00C1957C /* TourID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourID.swift; sourceTree = "<group>"; };
263FF44B2F99356E00C1957C /* TourTargetID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourTargetID.swift; sourceTree = "<group>"; };
263FF44D2F99357500C1957C /* TourStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourStep.swift; sourceTree = "<group>"; };
263FF44F2F99357F00C1957C /* Tour.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tour.swift; sourceTree = "<group>"; };
263FF4512F99358800C1957C /* TourCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourCatalog.swift; sourceTree = "<group>"; };
263FF4532F99359600C1957C /* TourSeenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourSeenStore.swift; sourceTree = "<group>"; };
263FF4552F9935AC00C1957C /* TourCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourCoordinator.swift; sourceTree = "<group>"; };
263FF4572F9935BC00C1957C /* SpotlightShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightShape.swift; sourceTree = "<group>"; };
263FF4592F9935CD00C1957C /* TourCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourCardView.swift; sourceTree = "<group>"; };
263FF45B2F9935E400C1957C /* TourOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourOverlayView.swift; sourceTree = "<group>"; };
263FF45D2F9935EF00C1957C /* TourViewModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourViewModifiers.swift; sourceTree = "<group>"; };
265F92202F9109B500CE0A5C /* nahbar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nahbar.app; sourceTree = BUILT_PRODUCTS_DIR; }; 265F92202F9109B500CE0A5C /* nahbar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = nahbar.app; sourceTree = BUILT_PRODUCTS_DIR; };
2670595B2F96640E00956084 /* CalendarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManager.swift; sourceTree = "<group>"; }; 2670595B2F96640E00956084 /* CalendarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManager.swift; sourceTree = "<group>"; };
269ECE652F92B5C700444B14 /* NahbarMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarMigration.swift; sourceTree = "<group>"; }; 269ECE652F92B5C700444B14 /* NahbarMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarMigration.swift; sourceTree = "<group>"; };
@@ -211,6 +233,24 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
263FF4482F99356600C1957C /* Tour */ = {
isa = PBXGroup;
children = (
263FF4492F99356A00C1957C /* TourID.swift */,
263FF44B2F99356E00C1957C /* TourTargetID.swift */,
263FF44D2F99357500C1957C /* TourStep.swift */,
263FF44F2F99357F00C1957C /* Tour.swift */,
263FF4512F99358800C1957C /* TourCatalog.swift */,
263FF4532F99359600C1957C /* TourSeenStore.swift */,
263FF4552F9935AC00C1957C /* TourCoordinator.swift */,
263FF4572F9935BC00C1957C /* SpotlightShape.swift */,
263FF4592F9935CD00C1957C /* TourCardView.swift */,
263FF45B2F9935E400C1957C /* TourOverlayView.swift */,
263FF45D2F9935EF00C1957C /* TourViewModifiers.swift */,
);
path = Tour;
sourceTree = "<group>";
};
265F92172F9109B500CE0A5C = { 265F92172F9109B500CE0A5C = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -290,6 +330,7 @@
26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */, 26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */,
2670595B2F96640E00956084 /* CalendarManager.swift */, 2670595B2F96640E00956084 /* CalendarManager.swift */,
26D07C682F9866DE001D3F98 /* AddTodoView.swift */, 26D07C682F9866DE001D3F98 /* AddTodoView.swift */,
263FF4482F99356600C1957C /* Tour */,
); );
path = nahbar; path = nahbar;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -441,7 +482,9 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */, 269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */,
263FF4522F99358800C1957C /* TourCatalog.swift in Sources */,
26EF66322F9112E700824F91 /* Models.swift in Sources */, 26EF66322F9112E700824F91 /* Models.swift in Sources */,
263FF44A2F99356A00C1957C /* TourID.swift in Sources */,
26F8B0CF2F94E7B1004905B9 /* PersonalityQuizView.swift in Sources */, 26F8B0CF2F94E7B1004905B9 /* PersonalityQuizView.swift in Sources */,
26EF66332F9112E700824F91 /* TodayView.swift in Sources */, 26EF66332F9112E700824F91 /* TodayView.swift in Sources */,
26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */, 26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */,
@@ -449,14 +492,18 @@
26B9930C2F94B32800E9B16C /* PrivacyBadgeView.swift in Sources */, 26B9930C2F94B32800E9B16C /* PrivacyBadgeView.swift in Sources */,
26B2CAE72F93C03F0039BA3B /* VisitRatingFlowView.swift in Sources */, 26B2CAE72F93C03F0039BA3B /* VisitRatingFlowView.swift in Sources */,
26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */, 26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */,
263FF45E2F9935EF00C1957C /* TourViewModifiers.swift in Sources */,
26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */, 26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */,
26EF66432F912A0000824F91 /* CallSuggestionView.swift in Sources */, 26EF66432F912A0000824F91 /* CallSuggestionView.swift in Sources */,
263FF4542F99359600C1957C /* TourSeenStore.swift in Sources */,
26B2CAB62F93B55F0039BA3B /* IchView.swift in Sources */, 26B2CAB62F93B55F0039BA3B /* IchView.swift in Sources */,
26B2CAF12F93C52C0039BA3B /* VisitEditFlowView.swift in Sources */, 26B2CAF12F93C52C0039BA3B /* VisitEditFlowView.swift in Sources */,
26F8B0C52F94E47F004905B9 /* PersonalityModels.swift in Sources */, 26F8B0C52F94E47F004905B9 /* PersonalityModels.swift in Sources */,
26EF66452F91350200824F91 /* AppLockManager.swift in Sources */, 26EF66452F91350200824F91 /* AppLockManager.swift in Sources */,
26B2CAEB2F93C05A0039BA3B /* VisitSummaryView.swift in Sources */, 26B2CAEB2F93C05A0039BA3B /* VisitSummaryView.swift in Sources */,
26B2CAE32F93C0180039BA3B /* RatingQuestionView.swift in Sources */, 26B2CAE32F93C0180039BA3B /* RatingQuestionView.swift in Sources */,
263FF45A2F9935CD00C1957C /* TourCardView.swift in Sources */,
263FF44C2F99356E00C1957C /* TourTargetID.swift in Sources */,
26EF66342F9112E700824F91 /* PersonDetailView.swift in Sources */, 26EF66342F9112E700824F91 /* PersonDetailView.swift in Sources */,
26EF66352F9112E700824F91 /* ThemeSystem.swift in Sources */, 26EF66352F9112E700824F91 /* ThemeSystem.swift in Sources */,
26EF66362F9112E700824F91 /* PeopleListView.swift in Sources */, 26EF66362F9112E700824F91 /* PeopleListView.swift in Sources */,
@@ -466,11 +513,15 @@
26EF66382F9112E700824F91 /* SettingsView.swift in Sources */, 26EF66382F9112E700824F91 /* SettingsView.swift in Sources */,
26EF66392F9112E700824F91 /* AddMomentView.swift in Sources */, 26EF66392F9112E700824F91 /* AddMomentView.swift in Sources */,
26EF663A2F9112E700824F91 /* NahbarApp.swift in Sources */, 26EF663A2F9112E700824F91 /* NahbarApp.swift in Sources */,
263FF4562F9935AC00C1957C /* TourCoordinator.swift in Sources */,
26BB85C52F926A1C00889312 /* AppGroup.swift in Sources */, 26BB85C52F926A1C00889312 /* AppGroup.swift in Sources */,
26F8B0D12F94E7D5004905B9 /* PersonalityResultView.swift in Sources */, 26F8B0D12F94E7D5004905B9 /* PersonalityResultView.swift in Sources */,
26EF66472F91351800824F91 /* AppLockView.swift in Sources */, 26EF66472F91351800824F91 /* AppLockView.swift in Sources */,
26B2CAB82F93B7570039BA3B /* NahbarLogger.swift in Sources */, 26B2CAB82F93B7570039BA3B /* NahbarLogger.swift in Sources */,
2670595C2F96640E00956084 /* CalendarManager.swift in Sources */, 2670595C2F96640E00956084 /* CalendarManager.swift in Sources */,
263FF45C2F9935E400C1957C /* TourOverlayView.swift in Sources */,
263FF4502F99357F00C1957C /* Tour.swift in Sources */,
263FF44E2F99357500C1957C /* TourStep.swift in Sources */,
26F8B0C72F94E499004905B9 /* NahbarInsightStyle.swift in Sources */, 26F8B0C72F94E499004905B9 /* NahbarInsightStyle.swift in Sources */,
26B2CABA2F93B76E0039BA3B /* LogExportView.swift in Sources */, 26B2CABA2F93B76E0039BA3B /* LogExportView.swift in Sources */,
26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */, 26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */,
@@ -481,6 +532,7 @@
26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */, 26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */,
26B993102F94B34C00E9B16C /* OnboardingCoordinator.swift in Sources */, 26B993102F94B34C00E9B16C /* OnboardingCoordinator.swift in Sources */,
26BB85C12F92525200889312 /* AIAnalysisService.swift in Sources */, 26BB85C12F92525200889312 /* AIAnalysisService.swift in Sources */,
263FF4582F9935BC00C1957C /* SpotlightShape.swift in Sources */,
26EF663C2F9112E700824F91 /* ContactPickerView.swift in Sources */, 26EF663C2F9112E700824F91 /* ContactPickerView.swift in Sources */,
26B2CAE12F93C0080039BA3B /* RatingDotPicker.swift in Sources */, 26B2CAE12F93C0080039BA3B /* RatingDotPicker.swift in Sources */,
26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */, 26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */,
@@ -663,6 +715,8 @@
INFOPLIST_KEY_CFBundleDisplayName = nahbar; INFOPLIST_KEY_CFBundleDisplayName = nahbar;
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "nahbar liest deine Kalender, damit du beim Erstellen eines Termins den richtigen Kalender wählen kannst."; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "nahbar liest deine Kalender, damit du beim Erstellen eines Termins den richtigen Kalender wählen kannst.";
INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt Kalendereinträge für geplante Treffen"; INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt Kalendereinträge für geplante Treffen";
INFOPLIST_KEY_NSContactsUsageDescription = "nahbar öffnet den systemseitigen Kontakte-Picker, damit du Personen schnell aus deinem Adressbuch hinzufügen kannst. Die App liest deine Kontakte nicht selbstständig aus.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "nahbar öffnet den systemseitigen Foto-Picker, damit du ein Profilbild für eine Person auswählen kannst.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -704,6 +758,8 @@
INFOPLIST_KEY_CFBundleDisplayName = nahbar; INFOPLIST_KEY_CFBundleDisplayName = nahbar;
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "nahbar liest deine Kalender, damit du beim Erstellen eines Termins den richtigen Kalender wählen kannst."; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "nahbar liest deine Kalender, damit du beim Erstellen eines Termins den richtigen Kalender wählen kannst.";
INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt Kalendereinträge für geplante Treffen"; INFOPLIST_KEY_NSCalendarsWriteOnlyAccessUsageDescription = "nahbar erstellt Kalendereinträge für geplante Treffen";
INFOPLIST_KEY_NSContactsUsageDescription = "nahbar öffnet den systemseitigen Kontakte-Picker, damit du Personen schnell aus deinem Adressbuch hinzufügen kannst. Die App liest deine Kontakte nicht selbstständig aus.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "nahbar öffnet den systemseitigen Foto-Picker, damit du ein Profilbild für eine Person auswählen kannst.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+138 -4
View File
@@ -159,6 +159,34 @@ struct CachedGiftSuggestion: Codable {
let generatedAt: Date let generatedAt: Date
} }
// MARK: - Conversation Suggestion Result
struct ConversationSuggestionResult {
let topics: String // THEMEN:
let rescue: String // GESPRAECHSRETTER:
let depth: String // TIEFE:
}
// MARK: - Cached Conversation Suggestion
struct CachedConversationSuggestion: Codable {
let topics: String
let rescue: String
let depth: String
let generatedAt: Date
var asResult: ConversationSuggestionResult {
ConversationSuggestionResult(topics: topics, rescue: rescue, depth: depth)
}
init(result: ConversationSuggestionResult, date: Date = Date()) {
self.topics = result.topics
self.rescue = result.rescue
self.depth = result.depth
self.generatedAt = date
}
}
// MARK: - Service // MARK: - Service
class AIAnalysisService { class AIAnalysisService {
@@ -283,9 +311,12 @@ class AIAnalysisService {
// MARK: - Prompt Builder // MARK: - Prompt Builder
private enum PromptType {
case analysis, gift, conversation
}
/// Baut den vollständigen User-Prompt sprachabhängig auf. /// Baut den vollständigen User-Prompt sprachabhängig auf.
/// - `isGift`: true Geschenkideen-Instruktion, false Analyse-Instruktion private func buildPrompt(for person: Person, promptType: PromptType = .analysis) -> String {
private func buildPrompt(for person: Person, isGift: Bool = false) -> String {
let lang = AppLanguage.current let lang = AppLanguage.current
let formatter = DateFormatter() let formatter = DateFormatter()
@@ -304,7 +335,13 @@ class AIAnalysisService {
let logEntries = logLines.isEmpty ? "" : "\(lang.logEntriesLabel) (\(person.sortedLogEntries.count)):\n\(logLines)\n" let logEntries = logLines.isEmpty ? "" : "\(lang.logEntriesLabel) (\(person.sortedLogEntries.count)):\n\(logLines)\n"
let interests = person.interests.map { "\(lang.interestsLabel): \(AIPayloadSanitizer.sanitize($0))\n" } ?? "" let interests = person.interests.map { "\(lang.interestsLabel): \(AIPayloadSanitizer.sanitize($0))\n" } ?? ""
let culturalBg = person.culturalBackground.map { "\(lang.culturalBackgroundLabel): \($0)\n" } ?? "" let culturalBg = person.culturalBackground.map { "\(lang.culturalBackgroundLabel): \($0)\n" } ?? ""
let instruction = isGift ? lang.giftInstruction : lang.analysisInstruction
let instruction: String
switch promptType {
case .analysis: instruction = lang.analysisInstruction
case .gift: instruction = lang.giftInstruction
case .conversation: instruction = lang.conversationInstruction
}
return "Person: \(person.firstName)\n" return "Person: \(person.firstName)\n"
+ birthYearContext(for: person, language: lang) + birthYearContext(for: person, language: lang)
@@ -351,7 +388,7 @@ class AIAnalysisService {
throw URLError(.badURL) throw URLError(.badURL)
} }
let prompt = buildPrompt(for: person, isGift: true) let prompt = buildPrompt(for: person, promptType: .gift)
let body: [String: Any] = [ let body: [String: Any] = [
"model": config.model, "model": config.model,
@@ -397,6 +434,72 @@ class AIAnalysisService {
return normalized return normalized
} }
// MARK: - Conversation Cache
private func conversationCacheKey(for person: Person) -> String { "ai_conversation_\(person.id.uuidString)" }
func loadCachedConversation(for person: Person) -> CachedConversationSuggestion? {
guard let data = UserDefaults.standard.data(forKey: conversationCacheKey(for: person)),
let cached = try? JSONDecoder().decode(CachedConversationSuggestion.self, from: data)
else { return nil }
return cached
}
private func saveConversationCache(_ result: ConversationSuggestionResult, for person: Person) {
let cached = CachedConversationSuggestion(result: result)
if let data = try? JSONEncoder().encode(cached) {
UserDefaults.standard.set(data, forKey: conversationCacheKey(for: person))
}
}
// MARK: - Conversation Suggestion
func suggestConversation(person: Person) async throws -> ConversationSuggestionResult {
let config = AIConfig.load()
guard let url = URL(string: config.completionsURL) else {
throw URLError(.badURL)
}
let prompt = buildPrompt(for: person, promptType: .conversation)
let body: [String: Any] = [
"model": config.model,
"stream": false,
"messages": [
["role": "system", "content": AppLanguage.current.systemPrompt],
["role": "user", "content": prompt]
]
]
var request = URLRequest(url: url, timeoutInterval: config.timeoutSeconds)
request.httpMethod = "POST"
request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
guard
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let choices = json["choices"] as? [[String: Any]],
let first = choices.first,
let message = first["message"] as? [String: Any],
let content = message["content"] as? String
else {
throw URLError(.cannotParseResponse)
}
let result = parseConversationResult(content)
recordRequest()
saveConversationCache(result, for: person)
return result
}
// MARK: - Result Parser // MARK: - Result Parser
private func parseResult(_ text: String) -> AIAnalysisResult { private func parseResult(_ text: String) -> AIAnalysisResult {
@@ -426,4 +529,35 @@ class AIAnalysisService {
recommendation: extract("EMPFEHLUNG") recommendation: extract("EMPFEHLUNG")
) )
} }
/// Extrahiert die drei Gesprächsvorschlag-Sektionen aus der KI-Antwort.
/// Internal (nicht private) damit Unit-Tests direkten Zugriff haben.
func parseConversationResult(_ text: String) -> ConversationSuggestionResult {
var normalized = text
for label in ["THEMEN", "GESPRAECHSRETTER", "TIEFE"] {
normalized = normalized
.replacingOccurrences(of: "**\(label):**", with: "\(label):")
.replacingOccurrences(of: "**\(label)**:", with: "\(label):")
.replacingOccurrences(of: "**\(label)** :", with: "\(label):")
}
func extract(_ label: String) -> String {
let pattern = "\(label):\\s*(.+?)(?=\\n(?:THEMEN|GESPRAECHSRETTER|TIEFE):|\\z)"
guard
let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]),
let match = regex.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)),
let range = Range(match.range(at: 1), in: normalized)
else { return "" }
// Verbleibende ** im Fließtext entfernen (KI-Markdown in Plain Text umwandeln)
return String(normalized[range])
.replacingOccurrences(of: "**", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
return ConversationSuggestionResult(
topics: extract("THEMEN"),
rescue: extract("GESPRAECHSRETTER"),
depth: extract("TIEFE")
)
}
} }
+271 -6
View File
@@ -2,6 +2,9 @@ import SwiftUI
import SwiftData import SwiftData
import EventKit import EventKit
import UserNotifications import UserNotifications
import OSLog
private let intentionNotificationLogger = Logger(subsystem: "nahbar", category: "IntentionNotification")
struct AddMomentView: View { struct AddMomentView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@@ -31,6 +34,14 @@ struct AddMomentView: View {
@State private var selectedCalendarID: String = "" @State private var selectedCalendarID: String = ""
@State private var eventAlarmOffset: Double = -3600 // Sekunden; 0 = keine Erinnerung @State private var eventAlarmOffset: Double = -3600 // Sekunden; 0 = keine Erinnerung
// KI-Gesprächsvorschläge
@StateObject private var store = StoreManager.shared
@AppStorage("aiConsentGiven") private var aiConsentGiven = false
@State private var conversationState: ConversationSuggestionUIState = .idle
@State private var showConversationPaywall = false
@State private var showConversationConsent = false
@State private var insertedSectionKey: String? = nil // für kurzes Checkmark-Feedback
// Vorhaben: Erinnerung // Vorhaben: Erinnerung
@State private var addReminder = false @State private var addReminder = false
@State private var reminderDate: Date = { @State private var reminderDate: Date = {
@@ -68,6 +79,7 @@ struct AddMomentView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
// Person-Kontext-Chip // Person-Kontext-Chip
@@ -127,11 +139,17 @@ struct AddMomentView: View {
.padding(.vertical, 10) .padding(.vertical, 10)
.focused($isFocused) .focused($isFocused)
} }
.frame(minHeight: 180) .frame(minHeight: 120)
.background(theme.surfaceCard) .background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20) .padding(.horizontal, 20)
// KI-Gesprächsvorschläge (nur bei Treffen)
if selectedType == .meeting || selectedType == .conversation {
conversationSuggestionsSection
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Treffen: Kalendertermin // Treffen: Kalendertermin
if showsCalendarSection { if showsCalendarSection {
calendarSection calendarSection
@@ -144,12 +162,14 @@ struct AddMomentView: View {
.transition(.opacity.combined(with: .move(edge: .top))) .transition(.opacity.combined(with: .move(edge: .top)))
} }
Spacer()
} }
.animation(.easeInOut(duration: 0.2), value: showsCalendarSection) .animation(.easeInOut(duration: 0.2), value: showsCalendarSection)
.animation(.easeInOut(duration: 0.2), value: showsReminderSection) .animation(.easeInOut(duration: 0.2), value: showsReminderSection)
.animation(.easeInOut(duration: 0.2), value: selectedType)
.animation(.easeInOut(duration: 0.2), value: addToCalendar) .animation(.easeInOut(duration: 0.2), value: addToCalendar)
.animation(.easeInOut(duration: 0.2), value: addReminder) .animation(.easeInOut(duration: 0.2), value: addReminder)
.padding(.bottom, 24)
} // ScrollView
.background(theme.backgroundPrimary.ignoresSafeArea()) .background(theme.backgroundPrimary.ignoresSafeArea())
.navigationTitle("Moment festhalten") .navigationTitle("Moment festhalten")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -167,7 +187,12 @@ struct AddMomentView: View {
} }
} }
} }
.onAppear { isFocused = true } .onAppear {
isFocused = true
if let cached = AIAnalysisService.shared.loadCachedConversation(for: person) {
conversationState = .result(cached.asResult, cached.generatedAt)
}
}
} }
// MARK: - Kalender-Sektion (Treffen) // MARK: - Kalender-Sektion (Treffen)
@@ -397,13 +422,21 @@ struct AddMomentView: View {
private func scheduleIntentionReminder(for moment: Moment) { private func scheduleIntentionReminder(for moment: Moment) {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { granted, _ in center.requestAuthorization(options: [.alert, .sound]) { granted, error in
guard granted else { return } if let error {
intentionNotificationLogger.error("Berechtigung-Fehler: \(error.localizedDescription)")
}
guard granted else {
intentionNotificationLogger.warning("Notification-Berechtigung abgelehnt keine Vorhaben-Erinnerung.")
return
}
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = person.firstName content.title = person.firstName
content.subtitle = String(localized: "Geplanter Moment")
content.body = moment.text content.body = moment.text
content.sound = .default content.sound = .default
content.userInfo = ["momentID": moment.id.uuidString]
let components = Calendar.current.dateComponents( let components = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute], from: reminderDate [.year, .month, .day, .hour, .minute], from: reminderDate
@@ -414,7 +447,13 @@ struct AddMomentView: View {
content: content, content: content,
trigger: trigger trigger: trigger
) )
center.add(request) center.add(request) { error in
if let error {
intentionNotificationLogger.error("Vorhaben-Erinnerung konnte nicht geplant werden: \(error.localizedDescription)")
} else {
intentionNotificationLogger.info("Vorhaben-Erinnerung geplant: \(moment.id.uuidString)")
}
}
} }
} }
@@ -454,4 +493,230 @@ struct AddMomentView: View {
CalendarEventStore.save(momentID: momentID, eventIdentifier: identifier) CalendarEventStore.save(momentID: momentID, eventIdentifier: identifier)
} }
} }
// MARK: - KI-Gesprächsvorschläge
private var canUseAI: Bool {
store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
}
private var conversationSuggestionsSection: some View {
Group {
switch conversationState {
case .idle:
conversationIdleButton
case .loading:
conversationLoadingView
case .result(let result, _):
conversationResultView(result: result)
case .error(let message):
conversationErrorView(message: message)
case .insufficientData:
conversationInsufficientDataView
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
.sheet(isPresented: $showConversationPaywall) { PaywallView(targeting: .max) }
.sheet(isPresented: $showConversationConsent) {
AIConsentSheet {
aiConsentGiven = true
Task { await loadConversationSuggestions() }
}
}
}
private var conversationIdleButton: some View {
Button {
guard canUseAI else { showConversationPaywall = true; return }
if aiConsentGiven {
Task { await loadConversationSuggestions() }
} else {
showConversationConsent = true
}
} label: {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.system(size: 13))
Text("Gesprächsthemen vorschlagen")
.font(.system(size: 14, weight: .medium))
Spacer()
if !store.isMax && canUseAI {
Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(theme.contentTertiary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(theme.backgroundSecondary)
.clipShape(Capsule())
} else {
MaxBadge()
}
}
.foregroundStyle(canUseAI ? theme.accent : theme.contentSecondary)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.buttonStyle(.plain)
}
private var conversationLoadingView: some View {
HStack(spacing: 10) {
ProgressView().tint(theme.accent)
VStack(alignment: .leading, spacing: 2) {
Text("Vorschläge werden generiert…")
.font(.system(size: 14))
.foregroundStyle(theme.contentSecondary)
Text("Das kann einen Moment dauern.")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
}
.padding(16)
}
private func conversationResultView(result: ConversationSuggestionResult) -> some View {
VStack(alignment: .leading, spacing: 0) {
conversationSection(icon: "text.bubble", title: "Themenvorschläge", sectionKey: "topics", content: result.topics)
RowDivider()
conversationSection(icon: "lifepreserver", title: "Gesprächsretter", sectionKey: "rescue", content: result.rescue)
RowDivider()
conversationSection(icon: "arrow.down.heart", title: "Tiefe erreichen", sectionKey: "depth", content: result.depth)
RowDivider()
Button {
Task { await loadConversationSuggestions() }
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.clockwise").font(.system(size: 12))
Text("Neue Vorschläge").font(.system(size: 13))
}
.foregroundStyle(theme.accent)
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
}
}
private func conversationSection(icon: String, title: String, sectionKey: String, content: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 13))
.foregroundStyle(theme.accent)
.frame(width: 20)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 6) {
Text(LocalizedStringKey(title))
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(theme.contentSecondary)
Text(content)
.font(.system(size: 14, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
// Übernehmen-Button
Button {
appendSuggestion(content, key: sectionKey)
} label: {
Image(systemName: insertedSectionKey == sectionKey ? "checkmark" : "arrow.up.doc")
.font(.system(size: 13))
.foregroundStyle(insertedSectionKey == sectionKey ? theme.accent : theme.contentTertiary)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
.animation(.easeInOut(duration: 0.2), value: insertedSectionKey)
}
.buttonStyle(.plain)
.padding(.top, 2)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private func appendSuggestion(_ content: String, key: String) {
let prefix = text.isEmpty ? "" : "\n\n"
text += prefix + content
withAnimation { insertedSectionKey = key }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { insertedSectionKey = nil }
}
}
private var conversationInsufficientDataView: some View {
HStack(spacing: 10) {
Image(systemName: "info.circle")
.font(.system(size: 14))
.foregroundStyle(theme.contentTertiary)
Text("Noch zu wenig Verlauf für persönliche Vorschläge.")
.font(.system(size: 14))
.foregroundStyle(theme.contentSecondary)
}
.padding(16)
}
private func conversationErrorView(message: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Label("Vorschläge fehlgeschlagen", systemImage: "exclamationmark.triangle")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(theme.contentSecondary)
Text(message)
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
Button {
Task { await loadConversationSuggestions() }
} label: {
Text("Erneut versuchen")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(theme.accent)
}
}
.padding(16)
}
/// Mindestanzahl an Momenten + Log-Einträgen für sinnvolle KI-Vorschläge.
private var hasEnoughHistory: Bool {
let momentCount = person.sortedMoments.count
let logCount = person.sortedLogEntries.count
return momentCount + logCount >= 2
}
private func loadConversationSuggestions() async {
guard hasEnoughHistory else {
conversationState = .insufficientData
return
}
guard !AIAnalysisService.shared.isRateLimited else { return }
conversationState = .loading
do {
let result = try await AIAnalysisService.shared.suggestConversation(person: person)
if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() }
conversationState = .result(result, Date())
} catch {
if let cached = AIAnalysisService.shared.loadCachedConversation(for: person) {
conversationState = .result(cached.asResult, cached.generatedAt)
} else {
conversationState = .error(error.localizedDescription)
}
}
}
}
// MARK: - Conversation Suggestion UI State
private enum ConversationSuggestionUIState {
case idle
case loading
case result(ConversationSuggestionResult, Date)
case error(String)
case insufficientData
}
extension ConversationSuggestionUIState: Equatable {
static func == (lhs: ConversationSuggestionUIState, rhs: ConversationSuggestionUIState) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle), (.loading, .loading), (.insufficientData, .insufficientData): return true
case (.error(let a), .error(let b)): return a == b
default: return false
}
}
} }
+88 -1
View File
@@ -10,6 +10,8 @@ struct AddPersonView: View {
var existingPerson: Person? = nil var existingPerson: Person? = nil
@Query private var allPeople: [Person]
@State private var name = "" @State private var name = ""
@State private var selectedTag: PersonTag = .other @State private var selectedTag: PersonTag = .other
@State private var occupation = "" @State private var occupation = ""
@@ -17,12 +19,15 @@ struct AddPersonView: View {
@State private var interests = "" @State private var interests = ""
@State private var generalNotes = "" @State private var generalNotes = ""
@State private var culturalBackground = "" @State private var culturalBackground = ""
@State private var phoneNumber = ""
@State private var emailAddress = ""
@State private var hasBirthday = false @State private var hasBirthday = false
@State private var birthday = Date() @State private var birthday = Date()
@State private var nudgeFrequency: NudgeFrequency = .monthly @State private var nudgeFrequency: NudgeFrequency = .monthly
@State private var showingContactPicker = false @State private var showingContactPicker = false
@State private var importedName: String? = nil // tracks whether fields were pre-filled @State private var importedName: String? = nil // tracks whether fields were pre-filled
@State private var pendingCnIdentifier: String? = nil
@State private var showingDeleteConfirmation = false @State private var showingDeleteConfirmation = false
@State private var selectedPhoto: UIImage? = nil @State private var selectedPhoto: UIImage? = nil
@@ -84,7 +89,12 @@ struct AddPersonView: View {
RowDivider() RowDivider()
inlineField("Wohnort", text: $location) inlineField("Wohnort", text: $location)
RowDivider() RowDivider()
inlineField("Interessen", text: $interests) InterestTagEditor(
label: "Interessen",
text: $interests,
suggestions: interestSuggestions,
tagColor: .green
)
RowDivider() RowDivider()
inlineField("Herkunft", text: $culturalBackground) inlineField("Herkunft", text: $culturalBackground)
RowDivider() RowDivider()
@@ -94,6 +104,25 @@ struct AddPersonView: View {
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
} }
// Kontaktdaten (Telefon + E-Mail)
formSection("Kontakt") {
VStack(spacing: 0) {
inlineField("Telefon", text: $phoneNumber)
.keyboardType(.phonePad)
RowDivider()
inlineField("E-Mail", text: $emailAddress)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
// Kontakt aktualisieren (nur im Bearbeiten-Modus)
if isEditing {
refreshContactButton
}
// Birthday // Birthday
formSection("Geburtstag") { formSection("Geburtstag") {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -326,6 +355,39 @@ struct AddPersonView: View {
} }
} }
// MARK: - Vom Kontakt aktualisieren (Bearbeiten-Modus)
private var refreshContactButton: some View {
Button {
showingContactPicker = true
} label: {
HStack(spacing: 10) {
Image(systemName: "arrow.clockwise.circle")
.font(.system(size: 16))
.foregroundStyle(theme.contentSecondary)
VStack(alignment: .leading, spacing: 1) {
Text("Vom Kontakt aktualisieren")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(theme.contentSecondary)
Text("Leere Felder werden aus dem Adressbuch ergänzt")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 13)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
}
// MARK: - Contact Mapping // MARK: - Contact Mapping
private func applyContact(_ contact: CNContact) { private func applyContact(_ contact: CNContact) {
@@ -348,6 +410,15 @@ struct AddPersonView: View {
if let data = imported.photoData { if let data = imported.photoData {
selectedPhoto = UIImage(data: data) selectedPhoto = UIImage(data: data)
} }
// Telefon und E-Mail: im Hinzufügen-Modus immer übernehmen,
// im Bearbeiten-Modus nur wenn noch leer (nicht-destruktiv)
if let phone = imported.phoneNumber, !phone.isEmpty {
if phoneNumber.isEmpty { phoneNumber = phone }
}
if let email = imported.emailAddress, !email.isEmpty {
if emailAddress.isEmpty { emailAddress = email }
}
pendingCnIdentifier = imported.cnIdentifier
} }
// MARK: - Helpers // MARK: - Helpers
@@ -379,6 +450,14 @@ struct AddPersonView: View {
.padding(.vertical, 12) .padding(.vertical, 12)
} }
private var interestSuggestions: [String] {
InterestTagHelper.allSuggestions(
from: allPeople,
likes: UserProfileStore.shared.likes,
dislikes: UserProfileStore.shared.dislikes
)
}
private func loadExisting() { private func loadExisting() {
guard let p = existingPerson else { return } guard let p = existingPerson else { return }
name = p.name name = p.name
@@ -388,6 +467,8 @@ struct AddPersonView: View {
interests = p.interests ?? "" interests = p.interests ?? ""
culturalBackground = p.culturalBackground ?? "" culturalBackground = p.culturalBackground ?? ""
generalNotes = p.generalNotes ?? "" generalNotes = p.generalNotes ?? ""
phoneNumber = p.phoneNumber ?? ""
emailAddress = p.emailAddress ?? ""
hasBirthday = p.birthday != nil hasBirthday = p.birthday != nil
birthday = p.birthday ?? Date() birthday = p.birthday ?? Date()
nudgeFrequency = p.nudgeFrequency nudgeFrequency = p.nudgeFrequency
@@ -413,6 +494,9 @@ struct AddPersonView: View {
p.generalNotes = generalNotes.isEmpty ? nil : generalNotes p.generalNotes = generalNotes.isEmpty ? nil : generalNotes
p.birthday = hasBirthday ? birthday : nil p.birthday = hasBirthday ? birthday : nil
p.nudgeFrequency = nudgeFrequency p.nudgeFrequency = nudgeFrequency
p.phoneNumber = phoneNumber.isEmpty ? nil : phoneNumber
p.emailAddress = emailAddress.isEmpty ? nil : emailAddress
if let cn = pendingCnIdentifier { p.cnIdentifier = cn }
p.touch() p.touch()
applyPhoto(newPhotoData, to: p) applyPhoto(newPhotoData, to: p)
} else { } else {
@@ -427,6 +511,9 @@ struct AddPersonView: View {
culturalBackground: culturalBackground.isEmpty ? nil : culturalBackground, culturalBackground: culturalBackground.isEmpty ? nil : culturalBackground,
nudgeFrequency: nudgeFrequency nudgeFrequency: nudgeFrequency
) )
person.phoneNumber = phoneNumber.isEmpty ? nil : phoneNumber
person.emailAddress = emailAddress.isEmpty ? nil : emailAddress
person.cnIdentifier = pendingCnIdentifier
modelContext.insert(person) modelContext.insert(person)
applyPhoto(newPhotoData, to: person) applyPhoto(newPhotoData, to: person)
} }
+20 -3
View File
@@ -1,6 +1,9 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import UserNotifications import UserNotifications
import OSLog
private let logger = Logger(subsystem: "nahbar", category: "TodoNotification")
struct AddTodoView: View { struct AddTodoView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@@ -168,12 +171,20 @@ struct AddTodoView: View {
private func scheduleReminder(for todo: Todo) { private func scheduleReminder(for todo: Todo) {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { granted, _ in center.requestAuthorization(options: [.alert, .sound]) { granted, error in
guard granted else { return } if let error {
logger.error("Berechtigung-Fehler: \(error.localizedDescription)")
}
guard granted else {
logger.warning("Notification-Berechtigung abgelehnt keine Todo-Erinnerung.")
return
}
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = person.firstName content.title = person.firstName
content.subtitle = String(localized: "Dein Todo")
content.body = todo.title content.body = todo.title
content.sound = .default content.sound = .default
content.userInfo = ["todoID": todo.id.uuidString]
let components = Calendar.current.dateComponents( let components = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute], from: reminderDate [.year, .month, .day, .hour, .minute], from: reminderDate
) )
@@ -183,7 +194,13 @@ struct AddTodoView: View {
content: content, content: content,
trigger: trigger trigger: trigger
) )
center.add(request) center.add(request) { error in
if let error {
logger.error("Todo-Erinnerung konnte nicht geplant werden: \(error.localizedDescription)")
} else {
logger.info("Todo-Erinnerung geplant: \(todo.id.uuidString)")
}
}
} }
} }
} }
+1 -1
View File
@@ -43,7 +43,7 @@ struct AppLockSetupView: View {
VStack(spacing: 8) { VStack(spacing: 8) {
Text(title) Text(title)
.font(.system(size: 24, weight: .light, design: theme.displayDesign)) .font(.system(size: 22, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text(subtitle) Text(subtitle)
.font(.system(size: 15)) .font(.system(size: 15))
+2 -2
View File
@@ -23,7 +23,7 @@ struct AppLockView: View {
// Title // Title
VStack(spacing: 6) { VStack(spacing: 6) {
Text("nahbar") Text("nahbar")
.font(.system(size: 34, weight: .light, design: theme.displayDesign)) .font(.system(size: 32, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text("Code eingeben") Text("Code eingeben")
.font(.system(size: 15)) .font(.system(size: 15))
@@ -166,7 +166,7 @@ struct PINPadView: View {
} else { } else {
Button { onKey(.digit(key.first!)) } label: { Button { onKey(.digit(key.first!)) } label: {
Text(key) Text(key)
.font(.system(size: 28, weight: .light, design: theme.displayDesign)) .font(.system(size: 26, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
.frame(width: 80, height: 80) .frame(width: 80, height: 80)
.background(theme.surfaceCard) .background(theme.surfaceCard)
+9
View File
@@ -159,6 +159,15 @@ final class CalendarManager {
} }
} }
/// `true` wenn Kalender-Vollzugriff bereits erteilt wurde (fragt nicht neu nach).
var isAuthorized: Bool {
if #available(iOS 17.0, *) {
return EKEventStore.authorizationStatus(for: .event) == .fullAccess
} else {
return EKEventStore.authorizationStatus(for: .event) == .authorized
}
}
/// Gibt alle Benutzer-Kalender zurück (sortiert nach Titel). /// Gibt alle Benutzer-Kalender zurück (sortiert nach Titel).
func availableCalendars() async -> [EKCalendar] { func availableCalendars() async -> [EKCalendar] {
let granted = await requestFullAccess() let granted = await requestFullAccess()
+20 -11
View File
@@ -1,6 +1,9 @@
import Foundation import Foundation
import Combine import Combine
import UserNotifications import UserNotifications
import OSLog
private let logger = Logger(subsystem: "nahbar", category: "CallWindowNotification")
class CallWindowManager: ObservableObject { class CallWindowManager: ObservableObject {
static let shared = CallWindowManager() static let shared = CallWindowManager()
@@ -99,18 +102,20 @@ class CallWindowManager: ObservableObject {
cancelNotifications() cancelNotifications()
guard isEnabled else { return } guard isEnabled else { return }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in
guard granted else { return } if let error {
logger.error("Berechtigung-Fehler: \(error.localizedDescription)")
}
guard granted else {
logger.warning("Notification-Berechtigung abgelehnt keine Gesprächsfenster-Erinnerung.")
return
}
// Persönlichkeitsgerechter Body-Text via PersonalityEngine
let body = PersonalityEngine.callWindowCopy(profile: PersonalityStore.shared.profile)
for weekday in self.selectedWeekdays { for weekday in self.selectedWeekdays {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = "Gesprächszeit" content.title = String(localized: "Gesprächszeit")
// Persönlichkeitsgerechter Body-Text (wärmer bei hohem Neurotizismus) content.body = body
let profile = PersonalityStore.shared.profile
if let profile, profile.level(for: .neuroticism) == .high {
content.body = "Magst du heute jemanden kurz anschreiben? Das kann viel bedeuten. 🙂"
} else {
content.body = "Wer freut sich heute von dir zu hören?"
}
content.sound = .default content.sound = .default
content.categoryIdentifier = "CALL_WINDOW" content.categoryIdentifier = "CALL_WINDOW"
@@ -124,7 +129,11 @@ class CallWindowManager: ObservableObject {
content: content, content: content,
trigger: UNCalendarNotificationTrigger(dateMatching: dc, repeats: true) trigger: UNCalendarNotificationTrigger(dateMatching: dc, repeats: true)
) )
UNUserNotificationCenter.current().add(request) UNUserNotificationCenter.current().add(request) { error in
if let error {
logger.error("Gesprächsfenster-Notification konnte nicht geplant werden (Wochentag \(weekday)): \(error.localizedDescription)")
}
}
} }
} }
} }
+1 -1
View File
@@ -29,7 +29,7 @@ struct CallWindowSetupView: View {
.foregroundStyle(theme.accent) .foregroundStyle(theme.accent)
Text("Gesprächszeit") Text("Gesprächszeit")
.font(.system(size: 26, weight: .light, design: theme.displayDesign)) .font(.system(size: 24, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text("nahbar erinnert dich täglich in deinem Zeitfenster und schlägt einen Kontakt vor — mit Notizen, damit du vorbereitet bist.") Text("nahbar erinnert dich täglich in deinem Zeitfenster und schlägt einen Kontakt vor — mit Notizen, damit du vorbereitet bist.")
+18 -1
View File
@@ -216,6 +216,9 @@ struct ContactImport {
let location: String let location: String
let birthday: Date? let birthday: Date?
let photoData: Data? let photoData: Data?
let phoneNumber: String? // primäre Telefonnummer (bevorzugt Mobil/iPhone)
let emailAddress: String? // erste verfügbare E-Mail-Adresse
let cnIdentifier: String? // stabile Apple Contacts-ID für spätere Aktualisierung
static func from(_ contact: CNContact) -> ContactImport { static func from(_ contact: CNContact) -> ContactImport {
// Mittelname einbeziehen, falls vorhanden // Mittelname einbeziehen, falls vorhanden
@@ -271,8 +274,22 @@ struct ContactImport {
photoData = nil photoData = nil
} }
// Telefon: Mobil/iPhone bevorzugen, dann erste verfügbare Nummer
let mobileLabels = ["iPhone", "_$!<Mobile>!$_", "_$!<Main>!$_"]
let phoneNumber: String?
if let labeled = contact.phoneNumbers.first(where: { mobileLabels.contains($0.label ?? "") }) {
phoneNumber = labeled.value.stringValue
} else {
phoneNumber = contact.phoneNumbers.first?.value.stringValue
}
// E-Mail: erste verfügbare Adresse
let emailAddress = contact.emailAddresses.first.map { $0.value as String }
return ContactImport(name: name, occupation: occupation, location: location, return ContactImport(name: name, occupation: occupation, location: location,
birthday: birthdayDate, photoData: photoData) birthday: birthdayDate, photoData: photoData,
phoneNumber: phoneNumber, emailAddress: emailAddress,
cnIdentifier: contact.identifier)
} }
} }
+57 -20
View File
@@ -5,6 +5,10 @@ import OSLog
private let logger = Logger(subsystem: "nahbar", category: "ContentView") private let logger = Logger(subsystem: "nahbar", category: "ContentView")
struct ContentView: View { struct ContentView: View {
/// Wird von NahbarApp auf `true` gesetzt, solange der Splash-Screen sichtbar ist.
/// UI-Präsentation (Sheets, Onboarding) startet erst, wenn dieser Wert auf `false` wechselt.
var splashVisible: Bool = false
@AppStorage("nahbarOnboardingDone") private var nahbarOnboardingDone = false @AppStorage("nahbarOnboardingDone") private var nahbarOnboardingDone = false
@AppStorage("callWindowOnboardingDone") private var onboardingDone = false @AppStorage("callWindowOnboardingDone") private var onboardingDone = false
@AppStorage("callSuggestionDate") private var suggestionDateStr = "" @AppStorage("callSuggestionDate") private var suggestionDateStr = ""
@@ -19,44 +23,41 @@ struct ContentView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.nahbarTheme) private var theme @Environment(\.nahbarTheme) private var theme
@Environment(TourCoordinator.self) private var tourCoordinator
@Query private var persons: [Person] @Query private var persons: [Person]
@State private var showingNahbarOnboarding = false @State private var showingNahbarOnboarding = false
@State private var showingOnboarding = false @State private var showingOnboarding = false
@State private var suggestedPerson: Person? = nil @State private var suggestedPerson: Person? = nil
@State private var showingSuggestion = false @State private var showingSuggestion = false
/// Steuert den aktiven Tab; nötig damit die Tour auf dem Menschen-Tab starten kann.
@State private var selectedTab: Int = 0
var body: some View { var body: some View {
TabView { TabView(selection: $selectedTab) {
TodayView() TodayView()
.tabItem { Label("Heute", systemImage: "sun.max") } .tabItem { Label("Heute", systemImage: "sun.max") }
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar) .tag(0)
.toolbarBackground(.visible, for: .tabBar)
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
PeopleListView() PeopleListView()
.tabItem { Label("Menschen", systemImage: "person.2") } .tabItem { Label("Menschen", systemImage: "person.2") }
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar) .tag(1)
.toolbarBackground(.visible, for: .tabBar)
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
IchView() IchView()
.tabItem { Label("Ich", systemImage: "person.circle") } .tabItem { Label("Ich", systemImage: "person.circle") }
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar) .tag(2)
.toolbarBackground(.visible, for: .tabBar)
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
SettingsView() SettingsView()
.tabItem { Label("Einstellungen", systemImage: "gearshape") } .tabItem { Label("Einstellungen", systemImage: "gearshape") }
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar) .tag(3)
.toolbarBackground(.visible, for: .tabBar)
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
} }
.fullScreenCover(isPresented: $showingNahbarOnboarding) { .fullScreenCover(isPresented: $showingNahbarOnboarding) {
OnboardingContainerView { OnboardingContainerView {
nahbarOnboardingDone = true nahbarOnboardingDone = true
showingNahbarOnboarding = false showingNahbarOnboarding = false
checkCallWindow() checkCallWindow()
scheduleTourIfNeeded()
} }
} }
.sheet(isPresented: $showingOnboarding) { .sheet(isPresented: $showingOnboarding) {
@@ -83,25 +84,30 @@ struct ContentView: View {
} }
} }
} }
.tourPresenter(coordinator: tourCoordinator)
.onAppear { .onAppear {
// Datenoperationen sofort starten (unabhängig vom Splash-Screen)
syncPeopleCache() syncPeopleCache()
importPendingMoments() importPendingMoments()
runPhotoRepairPass() runPhotoRepairPass()
runVisitMigrationPass() runVisitMigrationPass()
runNextStepMigrationPass() runNextStepMigrationPass()
if !nahbarOnboardingDone { // UI-Präsentation erst nach dem Splash
showingNahbarOnboarding = true if !splashVisible {
} else if !onboardingDone { showPendingUI()
showingOnboarding = true }
} else { }
checkCallWindow() .onChange(of: splashVisible) { _, visible in
// Sobald der Splash verschwindet, UI-Logik starten
if !visible {
showPendingUI()
} }
} }
.onChange(of: scenePhase) { _, phase in .onChange(of: scenePhase) { _, phase in
if phase == .active { if phase == .active {
syncPeopleCache() syncPeopleCache()
importPendingMoments() importPendingMoments()
checkCallWindow() if !splashVisible { checkCallWindow() }
} }
} }
.onChange(of: persons) { _, _ in .onChange(of: persons) { _, _ in
@@ -109,6 +115,36 @@ struct ContentView: View {
} }
} }
// MARK: - Pending UI
/// Startet die UI-Präsentation nach dem Splash-Screen:
/// Onboarding, Call-Window-Setup oder Gesprächsvorschlag.
private func showPendingUI() {
if !nahbarOnboardingDone {
showingNahbarOnboarding = true
} else if !onboardingDone {
showingOnboarding = true
} else {
checkCallWindow()
tourCoordinator.checkForPendingTours()
scheduleTourIfNeeded()
}
}
// MARK: - Tour
/// Wechselt zum Menschen-Tab (damit PeopleListView gerendert wird und seine
/// anchorPreference-Frames sammeln kann), wartet 700 ms und startet dann die Tour.
/// Die Verzögerung deckt die fullScreenCover-Dismiss-Animation (~350 ms) ab.
private func scheduleTourIfNeeded() {
guard !tourCoordinator.hasSeenOnboardingTour else { return }
selectedTab = 1
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(700))
tourCoordinator.startOnboardingTourIfNeeded()
}
}
// MARK: - Call Window // MARK: - Call Window
private func checkCallWindow() { private func checkCallWindow() {
@@ -347,4 +383,5 @@ struct ContentView: View {
.environmentObject(AppLockManager.shared) .environmentObject(AppLockManager.shared)
.environmentObject(CloudSyncMonitor()) .environmentObject(CloudSyncMonitor())
.environmentObject(UserProfileStore.shared) .environmentObject(UserProfileStore.shared)
.environment(TourCoordinator())
} }
+32 -37
View File
@@ -95,7 +95,7 @@ struct IchView: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack { HStack {
Text("Ich") Text("Ich")
.font(.system(size: 34, weight: .light, design: theme.displayDesign)) .font(.system(size: 32, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Spacer() Spacer()
Button { showingEdit = true } label: { Button { showingEdit = true } label: {
@@ -169,14 +169,10 @@ struct IchView: View {
private var infoSection: some View { private var infoSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
// Über mich // Über mich
let hasUeberMich = !profileStore.gender.isEmpty || !profileStore.location.isEmpty || !profileStore.socialStyle.isEmpty let hasUeberMich = !profileStore.location.isEmpty || !profileStore.socialStyle.isEmpty
if hasUeberMich { if hasUeberMich {
SectionHeader(title: "Über mich", icon: "person") SectionHeader(title: "Über mich", icon: "person")
VStack(spacing: 0) { VStack(spacing: 0) {
if !profileStore.gender.isEmpty {
infoRow(label: "Geschlecht", value: profileStore.gender)
if !profileStore.location.isEmpty || !profileStore.socialStyle.isEmpty { RowDivider() }
}
if !profileStore.location.isEmpty { if !profileStore.location.isEmpty {
infoRow(label: "Wohnort", value: profileStore.location) infoRow(label: "Wohnort", value: profileStore.location)
if !profileStore.socialStyle.isEmpty { RowDivider() } if !profileStore.socialStyle.isEmpty { RowDivider() }
@@ -220,11 +216,11 @@ struct IchView: View {
} else { } else {
VStack(spacing: 0) { VStack(spacing: 0) {
if !profileStore.likes.isEmpty { if !profileStore.likes.isEmpty {
preferenceRow(label: "Mag ich", text: profileStore.likes, color: .green) InterestChipRow(label: "Mag ich", text: profileStore.likes, color: .green)
if !profileStore.dislikes.isEmpty { RowDivider() } if !profileStore.dislikes.isEmpty { RowDivider() }
} }
if !profileStore.dislikes.isEmpty { if !profileStore.dislikes.isEmpty {
preferenceRow(label: "Mag ich nicht", text: profileStore.dislikes, color: .red) InterestChipRow(label: "Mag ich nicht", text: profileStore.dislikes, color: .red)
} }
} }
.background(theme.surfaceCard) .background(theme.surfaceCard)
@@ -233,32 +229,6 @@ struct IchView: View {
} }
} }
private func preferenceRow(label: String, text: String, color: Color) -> some View {
let items = text.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
return VStack(alignment: .leading, spacing: 8) {
Text(label)
.font(.system(size: 13))
.foregroundStyle(theme.contentTertiary)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(items, id: \.self) { item in
Text(item)
.font(.system(size: 13))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private func infoRow(label: String, value: String) -> some View { private func infoRow(label: String, value: String) -> some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Text(label) Text(label)
@@ -309,9 +279,12 @@ struct IchEditView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@EnvironmentObject var profileStore: UserProfileStore @EnvironmentObject var profileStore: UserProfileStore
@Query private var allPeople: [Person]
@State private var name: String @State private var name: String
@State private var hasBirthday: Bool @State private var hasBirthday: Bool
@State private var birthday: Date @State private var birthday: Date
@State private var gender: String @State private var gender: String
@State private var occupation: String @State private var occupation: String
@State private var location: String @State private var location: String
@@ -402,9 +375,19 @@ struct IchEditView: View {
// Vorlieben // Vorlieben
formSection("Vorlieben") { formSection("Vorlieben") {
VStack(spacing: 0) { VStack(spacing: 0) {
inlineField("Mag ich", text: $likes) InterestTagEditor(
label: "Mag ich",
text: $likes,
suggestions: preferenceSuggestions,
tagColor: .green
)
Divider().padding(.leading, 16) Divider().padding(.leading, 16)
inlineField("Mag ich nicht", text: $dislikes) InterestTagEditor(
label: "Mag nicht",
text: $dislikes,
suggestions: preferenceSuggestions,
tagColor: .red
)
} }
.background(theme.surfaceCard) .background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
@@ -522,9 +505,11 @@ struct IchEditView: View {
return name.isEmpty ? "?" : String(name.prefix(2)).uppercased() return name.isEmpty ? "?" : String(name.prefix(2)).uppercased()
} }
// MARK: - Gender Picker // MARK: - Gender Picker
private let genderOptions = ["Männlich", "Weiblich", "Divers", "Keine Angabe"] private let genderOptions = ["Männlich", "Weiblich", "Divers"]
private var genderPickerRow: some View { private var genderPickerRow: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
@@ -562,6 +547,16 @@ struct IchEditView: View {
} }
} }
// MARK: - Suggestion Pool
private var preferenceSuggestions: [String] {
InterestTagHelper.allSuggestions(
from: allPeople,
likes: profileStore.likes,
dislikes: profileStore.dislikes
)
}
// MARK: - Helpers // MARK: - Helpers
@ViewBuilder @ViewBuilder
File diff suppressed because it is too large Load Diff
-222
View File
@@ -2,15 +2,6 @@ import SwiftUI
import SwiftData import SwiftData
import CoreData import CoreData
// MARK: - AI Analysis State
private enum AnalysisState {
case idle
case loading
case result(AIAnalysisResult, Date)
case error(String)
}
// MARK: - Timeline Item // MARK: - Timeline Item
private enum LogbuchItem: Identifiable { private enum LogbuchItem: Identifiable {
@@ -64,15 +55,8 @@ struct LogbuchView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) var modelContext @Environment(\.modelContext) var modelContext
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@StateObject private var store = StoreManager.shared
let person: Person let person: Person
@State private var analysisState: AnalysisState = .idle
@State private var showPaywall = false
@State private var showAIConsent = false
@State private var remainingRequests: Int = AIAnalysisService.shared.remainingRequests
@AppStorage("aiConsentGiven") private var aiConsentGiven = false
// Kalender-Lösch-Bestätigung // Kalender-Lösch-Bestätigung
@State private var momentPendingDelete: Moment? = nil @State private var momentPendingDelete: Moment? = nil
@State private var showCalendarDeleteDialog = false @State private var showCalendarDeleteDialog = false
@@ -96,8 +80,6 @@ struct LogbuchView: View {
} }
} }
// PRO: KI-Analyse
aiAnalysisCard
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 16) .padding(.top, 16)
@@ -107,13 +89,6 @@ struct LogbuchView: View {
.navigationTitle("Logbuch") .navigationTitle("Logbuch")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.themedNavBar() .themedNavBar()
.sheet(isPresented: $showPaywall) { PaywallView(targeting: .max) }
.sheet(isPresented: $showAIConsent) {
AIConsentSheet {
aiConsentGiven = true
Task { await runAnalysis() }
}
}
.sheet(item: $momentForTextEdit) { moment in .sheet(item: $momentForTextEdit) { moment in
EditMomentView(moment: moment) EditMomentView(moment: moment)
} }
@@ -147,12 +122,6 @@ struct LogbuchView: View {
guard notification.userInfo?[NSInvalidatedAllObjectsKey] != nil else { return } guard notification.userInfo?[NSInvalidatedAllObjectsKey] != nil else { return }
dismiss() dismiss()
} }
.onAppear {
if let cached = AIAnalysisService.shared.loadCached(for: person) {
analysisState = .result(cached.asResult, cached.analyzedAt)
}
remainingRequests = AIAnalysisService.shared.remainingRequests
}
} }
// MARK: - Month Section // MARK: - Month Section
@@ -320,197 +289,6 @@ struct LogbuchView: View {
.padding(.vertical, 48) .padding(.vertical, 48)
} }
// MARK: - MAX: KI-Analyse
private var canUseAI: Bool {
store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
}
private var aiAnalysisCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
SectionHeader(title: "KI-Auswertung", icon: "sparkles")
MaxBadge()
if !store.isMax && canUseAI {
Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(theme.contentTertiary)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(theme.backgroundSecondary)
.clipShape(Capsule())
}
}
if !canUseAI {
// Gesperrt: alle Freiabfragen verbraucht
Button { showPaywall = true } label: {
HStack(spacing: 10) {
Image(systemName: "sparkles")
.foregroundStyle(theme.accent)
Text("nahbar Max freischalten für KI-Analyse")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(theme.accent)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
.padding(16)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
} else {
// Active state
VStack(alignment: .leading, spacing: 0) {
switch analysisState {
case .idle:
Button {
if aiConsentGiven {
Task { await runAnalysis() }
} else {
showAIConsent = true
}
} label: {
HStack(spacing: 10) {
Image(systemName: "sparkles")
.foregroundStyle(theme.accent)
Text("\(person.firstName) analysieren")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(theme.contentPrimary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
.padding(16)
}
case .loading:
HStack(spacing: 12) {
ProgressView().tint(theme.accent)
VStack(alignment: .leading, spacing: 2) {
Text("Analysiere Logbuch…")
.font(.system(size: 14))
.foregroundStyle(theme.contentSecondary)
Text("Das kann bis zu einer Minute dauern.")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
}
.padding(16)
case .result(let result, let date):
VStack(alignment: .leading, spacing: 0) {
analysisSection(icon: "waveform.path", title: "Muster & Themen", text: result.patterns)
RowDivider()
analysisSection(icon: "person.2", title: "Beziehungsqualität", text: result.relationship)
RowDivider()
analysisSection(icon: "arrow.right.circle", title: "Empfehlung", text: result.recommendation)
RowDivider()
HStack(spacing: 0) {
// Zeitstempel
VStack(alignment: .leading, spacing: 1) {
Text("Analysiert")
.font(.system(size: 11))
.foregroundStyle(theme.contentTertiary)
Text(date.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale(identifier: "de_DE"))))
.font(.system(size: 11))
.foregroundStyle(theme.contentTertiary)
}
.padding(.leading, 16)
.padding(.vertical, 12)
Spacer()
// Aktualisieren
Button {
Task { await runAnalysis() }
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 12))
Text(remainingRequests > 0 ? "Aktualisieren (\(remainingRequests))" : "Limit erreicht")
.font(.system(size: 13))
}
.foregroundStyle(remainingRequests > 0 ? theme.accent : theme.contentTertiary)
}
.disabled(remainingRequests == 0 || isPurchasing)
.padding(.trailing, 16)
.padding(.vertical, 12)
}
}
case .error(let msg):
VStack(alignment: .leading, spacing: 8) {
Label("Analyse fehlgeschlagen", systemImage: "exclamationmark.triangle")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(theme.contentSecondary)
Text(msg)
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
Button {
Task { await runAnalysis() }
} label: {
Text("Erneut versuchen")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(theme.accent)
}
}
.padding(16)
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
}
}
private func analysisSection(icon: String, title: String, text: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 13))
.foregroundStyle(theme.accent)
.frame(width: 20)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(theme.contentSecondary)
Text(LocalizedStringKey(text))
.font(.system(size: 14, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private var isPurchasing: Bool {
if case .loading = analysisState { return true }
return false
}
private func runAnalysis() async {
guard !mergedItems.isEmpty else { return }
guard !AIAnalysisService.shared.isRateLimited else { return }
analysisState = .loading
do {
let result = try await AIAnalysisService.shared.analyze(person: person)
remainingRequests = AIAnalysisService.shared.remainingRequests
if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() }
analysisState = .result(result, Date())
} catch {
// Bei Fehler alten Cache wiederherstellen falls vorhanden
if let cached = AIAnalysisService.shared.loadCached(for: person) {
analysisState = .result(cached.asResult, cached.analyzedAt)
} else {
analysisState = .error(error.localizedDescription)
}
}
}
// MARK: - Data // MARK: - Data
private var mergedItems: [LogbuchItem] { private var mergedItems: [LogbuchItem] {
+37 -1
View File
@@ -38,6 +38,25 @@ enum NudgeFrequency: String, CaseIterable, Codable {
case .quarterly: return 90 case .quarterly: return 90
} }
} }
/// Lesbares Label für den Nudge-Chip im Header
var displayLabel: String {
switch self {
case .never: return "Nie"
case .weekly: return "Wöchentlich"
case .biweekly: return "Alle 2 Wochen"
case .monthly: return "Monatlich"
case .quarterly: return "Quartalsweise"
}
}
}
/// Ampelstatus des Nudge-Intervalls einer Person
enum NudgeStatus: Equatable {
case never // kein Intervall gesetzt
case ok // < 75 % des Intervalls verstrichen
case soon // 75100 % verstrichen
case overdue // > 100 % verstrichen
} }
enum MomentType: String, Codable { enum MomentType: String, Codable {
@@ -117,6 +136,9 @@ class Person {
var interests: String? var interests: String?
var generalNotes: String? var generalNotes: String?
var culturalBackground: String? = nil // V6: kultureller Hintergrund var culturalBackground: String? = nil // V6: kultureller Hintergrund
var phoneNumber: String? = nil // V9: primäre Telefonnummer
var emailAddress: String? = nil // V9: primäre E-Mail-Adresse
var cnIdentifier: String? = nil // V9: Apple Contacts-ID für "Vom Kontakt aktualisieren"
var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue var nudgeFrequencyRaw: String = NudgeFrequency.monthly.rawValue
var nextStep: String? var nextStep: String?
var nextStepCompleted: Bool = false var nextStepCompleted: Bool = false
@@ -158,6 +180,9 @@ class Person {
self.interests = interests self.interests = interests
self.generalNotes = generalNotes self.generalNotes = generalNotes
self.culturalBackground = culturalBackground self.culturalBackground = culturalBackground
self.phoneNumber = nil
self.emailAddress = nil
self.cnIdentifier = nil
self.nudgeFrequencyRaw = nudgeFrequency.rawValue self.nudgeFrequencyRaw = nudgeFrequency.rawValue
self.photoData = nil self.photoData = nil
self.photo = nil self.photo = nil
@@ -196,6 +221,17 @@ class Person {
return Date().timeIntervalSince(createdAt) > Double(days * 86400) return Date().timeIntervalSince(createdAt) > Double(days * 86400)
} }
/// Dreistufiger Ampelstatus basierend auf verstrichener Zeit vs. Nudge-Intervall
var nudgeStatus: NudgeStatus {
guard nudgeFrequency != .never, let days = nudgeFrequency.days else { return .never }
let interval = Double(days * 86400)
let reference = lastMomentDate ?? createdAt
let elapsed = Date().timeIntervalSince(reference)
if elapsed >= interval { return .overdue }
if elapsed >= interval * 0.75 { return .soon }
return .ok
}
func hasBirthdayWithin(days: Int) -> Bool { func hasBirthdayWithin(days: Int) -> Bool {
guard let birthday else { return false } guard let birthday else { return false }
let cal = Calendar.current let cal = Calendar.current
@@ -295,7 +331,7 @@ class Person {
// MARK: - LogEntryType // MARK: - LogEntryType
enum LogEntryType: String, Codable { enum LogEntryType: String, Codable, CaseIterable {
case nextStep = "Schritt abgeschlossen" case nextStep = "Schritt abgeschlossen"
case calendarEvent = "Termin geplant" case calendarEvent = "Termin geplant"
case call = "Anruf" case call = "Anruf"
+64 -12
View File
@@ -1,12 +1,41 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import OSLog import OSLog
import UserNotifications
import UIKit
private let logger = Logger(subsystem: "nahbar", category: "App") private let logger = Logger(subsystem: "nahbar", category: "App")
private let notificationLogger = Logger(subsystem: "nahbar", category: "Notification")
// MARK: - App Delegate
// Setzt den UNUserNotificationCenterDelegate damit Benachrichtigungen auch im
// Vordergrund als Banner angezeigt werden.
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
UNUserNotificationCenter.current().delegate = self
// UIKit-Appearance VOR der View-Erstellung setzen verhindert weißes Aufblitzen
NahbarApp.applyInitialTabBarAppearance()
return true
}
/// Zeigt Benachrichtigungen auch an, wenn die App im Vordergrund läuft.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound])
}
}
@main @main
struct NahbarApp: App { struct NahbarApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
// Static let stellt sicher, dass der Container exakt einmal erstellt wird // Static let stellt sicher, dass der Container exakt einmal erstellt wird
// unabhängig davon, wie oft body ausgewertet wird. // unabhängig davon, wie oft body ausgewertet wird.
private static let containerBuild = AppGroup.makeMainContainerWithMigration() private static let containerBuild = AppGroup.makeMainContainerWithMigration()
@@ -19,6 +48,9 @@ struct NahbarApp: App {
@StateObject private var profileStore = UserProfileStore.shared @StateObject private var profileStore = UserProfileStore.shared
@StateObject private var eventLog = AppEventLog.shared @StateObject private var eventLog = AppEventLog.shared
/// Shared tour coordinator passed via environment to all views.
@State private var tourCoordinator = TourCoordinator()
@AppStorage("activeThemeID") private var activeThemeIDRaw: String = ThemeID.linen.rawValue @AppStorage("activeThemeID") private var activeThemeIDRaw: String = ThemeID.linen.rawValue
@AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false @AppStorage("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false
@@ -33,12 +65,13 @@ struct NahbarApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ZStack { ZStack {
ContentView() ContentView(splashVisible: showSplash)
.environmentObject(callWindowManager) .environmentObject(callWindowManager)
.environmentObject(appLockManager) .environmentObject(appLockManager)
.environmentObject(cloudSyncMonitor) .environmentObject(cloudSyncMonitor)
.environmentObject(profileStore) .environmentObject(profileStore)
.environmentObject(eventLog) .environmentObject(eventLog)
.environment(tourCoordinator)
// Verhindert Touch-Durchfall bei aktivem Splash- oder Lock-Screen // Verhindert Touch-Durchfall bei aktivem Splash- oder Lock-Screen
.allowsHitTesting(!showSplash && !appLockManager.isLocked) .allowsHitTesting(!showSplash && !appLockManager.isLocked)
@@ -71,14 +104,17 @@ struct NahbarApp: App {
.animation(.easeInOut(duration: 0.40), value: showSplash) .animation(.easeInOut(duration: 0.40), value: showSplash)
.environment(\.nahbarTheme, activeTheme) .environment(\.nahbarTheme, activeTheme)
.tint(activeTheme.accent) .tint(activeTheme.accent)
// Zwingt das System in den richtigen Light/Dark-Modus für das aktive Theme
// dadurch passt Liquid Glass (Tab-Bar, Sheets, Alerts) automatisch korrekt an.
.preferredColorScheme(activeTheme.id.isDark ? .dark : .light)
.onAppear { .onAppear {
applyTabBarAppearance(activeTheme) NahbarApp.applyTabBarAppearance(activeTheme)
cloudSyncMonitor.startMonitoring(iCloudEnabled: icloudSyncEnabled) cloudSyncMonitor.startMonitoring(iCloudEnabled: icloudSyncEnabled)
AftermathNotificationManager.shared.registerCategory() AftermathNotificationManager.shared.registerCategory()
logger.info("App gestartet. Container-Modus: \(String(describing: NahbarApp.containerFallback))") logger.info("App gestartet. Container-Modus: \(String(describing: NahbarApp.containerFallback))")
AppEventLog.shared.record("Container-Modus: \(NahbarApp.containerFallback)", level: .info, category: "Lifecycle") AppEventLog.shared.record("Container-Modus: \(NahbarApp.containerFallback)", level: .info, category: "Lifecycle")
} }
.onChange(of: activeThemeIDRaw) { _, _ in applyTabBarAppearance(activeTheme) } .onChange(of: activeThemeIDRaw) { _, _ in NahbarApp.applyTabBarAppearance(activeTheme) }
.onChange(of: icloudSyncEnabled) { _, enabled in .onChange(of: icloudSyncEnabled) { _, enabled in
cloudSyncMonitor.startMonitoring(iCloudEnabled: enabled) cloudSyncMonitor.startMonitoring(iCloudEnabled: enabled)
} }
@@ -91,11 +127,29 @@ struct NahbarApp: App {
} }
} }
private func applyTabBarAppearance(_ theme: NahbarTheme) { /// Liest das aktive Theme aus UserDefaults und setzt UITabBar-Appearance
let bg = UIColor(theme.backgroundPrimary).withAlphaComponent(0.88) /// vor der ersten View-Erstellung (AppDelegate-Kontext).
let normal = UIColor(theme.contentTertiary) static func applyInitialTabBarAppearance() {
let themeID = UserDefaults.standard.string(forKey: "activeThemeID")
.flatMap { ThemeID(rawValue: $0) } ?? .linen
applyTabBarAppearance(NahbarTheme.theme(for: themeID))
}
/// Setzt UIKit-Appearance für das gegebene Theme.
/// Auf iOS 26+ übernimmt Liquid Glass die Tab-Bar automatisch
/// eigene UITabBarAppearance-Hintergründe würden es stören.
/// Auf iOS 1725 wird die Tab-Bar explizit in Theme-Farben eingefärbt.
static func applyTabBarAppearance(_ theme: NahbarTheme) {
let border = UIColor(theme.borderSubtle)
let selected = UIColor(theme.accent) let selected = UIColor(theme.accent)
let border = UIColor(theme.borderSubtle).withAlphaComponent(0.6) let navBg = UIColor(theme.backgroundPrimary)
let titleColor = UIColor(theme.contentPrimary)
// iOS 1725: Liquid Glass existiert nicht; eigene UITabBarAppearance nötig.
// iOS 26+: Block weggelassen Liquid Glass + .preferredColorScheme übernehmen.
if #unavailable(iOS 26) {
let bg = UIColor(theme.backgroundPrimary)
let normal = UIColor(theme.contentTertiary)
let item = UITabBarItemAppearance() let item = UITabBarItemAppearance()
item.normal.iconColor = normal item.normal.iconColor = normal
@@ -104,7 +158,7 @@ struct NahbarApp: App {
item.selected.titleTextAttributes = [.foregroundColor: selected] item.selected.titleTextAttributes = [.foregroundColor: selected]
let tabAppearance = UITabBarAppearance() let tabAppearance = UITabBarAppearance()
tabAppearance.configureWithTransparentBackground() tabAppearance.configureWithOpaqueBackground()
tabAppearance.backgroundColor = bg tabAppearance.backgroundColor = bg
tabAppearance.shadowColor = border tabAppearance.shadowColor = border
tabAppearance.stackedLayoutAppearance = item tabAppearance.stackedLayoutAppearance = item
@@ -113,12 +167,10 @@ struct NahbarApp: App {
UITabBar.appearance().standardAppearance = tabAppearance UITabBar.appearance().standardAppearance = tabAppearance
UITabBar.appearance().scrollEdgeAppearance = tabAppearance UITabBar.appearance().scrollEdgeAppearance = tabAppearance
}
let navBg = UIColor(theme.backgroundPrimary).withAlphaComponent(0.92)
let titleColor = UIColor(theme.contentPrimary)
let navAppearance = UINavigationBarAppearance() let navAppearance = UINavigationBarAppearance()
navAppearance.configureWithTransparentBackground() navAppearance.configureWithOpaqueBackground()
navAppearance.backgroundColor = navBg navAppearance.backgroundColor = navBg
navAppearance.shadowColor = border navAppearance.shadowColor = border
navAppearance.titleTextAttributes = [.foregroundColor: titleColor] navAppearance.titleTextAttributes = [.foregroundColor: titleColor]
+136 -6
View File
@@ -640,15 +640,141 @@ enum NahbarSchemaV7: VersionedSchema {
} }
} }
// MARK: - Schema V8 (aktuelles Schema) // MARK: - Schema V8 (eingefrorener Snapshot)
// Referenziert die Live-Typen aus Models.swift. // Exakter Zustand aller Modelle zum Zeitpunkt des V8-Deployments.
// Beim Hinzufügen von V9 muss V8 als eingefrorener Snapshot gesichert werden. // WICHTIG: Niemals nachträglich ändern dieser Snapshot muss dem gespeicherten
// Schema-Hash von V8-Datenbanken auf Nutzer-Geräten entsprechen.
// //
// V8 fügt hinzu: // V8 fügte hinzu:
// Todo: reminderDate (optionale Push-Benachrichtigung) // Todo: reminderDate (optionale Push-Benachrichtigung)
enum NahbarSchemaV8: VersionedSchema { enum NahbarSchemaV8: VersionedSchema {
static var versionIdentifier = Schema.Version(8, 0, 0) static var versionIdentifier = Schema.Version(8, 0, 0)
static var models: [any PersistentModel.Type] {
[PersonPhoto.self, Person.self, Moment.self, LogEntry.self,
Visit.self, Rating.self, HealthSnapshot.self, Todo.self]
}
@Model final class PersonPhoto {
var id: UUID = UUID()
@Attribute(.externalStorage) var imageData: Data = Data()
var createdAt: Date = Date()
init() {}
}
@Model final class Person {
var id: UUID = UUID()
var name: String = ""
var tagRaw: String = "Andere"
var birthday: Date? = nil
var occupation: String? = nil
var location: String? = nil
var interests: String? = nil
var generalNotes: String? = nil
var culturalBackground: String? = nil
var nudgeFrequencyRaw: String = "Monatlich"
var nextStep: String? = nil
var nextStepCompleted: Bool = false
var nextStepReminderDate: Date? = nil
var lastSuggestedForCall: Date? = nil
var createdAt: Date = Date()
var updatedAt: Date = Date()
var isArchived: Bool = false
@Relationship(deleteRule: .cascade) var photo: PersonPhoto? = nil
var photoData: Data? = nil
@Relationship(deleteRule: .cascade) var moments: [Moment]? = []
@Relationship(deleteRule: .cascade) var logEntries: [LogEntry]? = []
@Relationship(deleteRule: .cascade) var visits: [Visit]? = []
@Relationship(deleteRule: .cascade) var todos: [Todo]? = []
init() {}
}
@Model final class Moment {
var id: UUID = UUID()
var text: String = ""
var typeRaw: String = "Gespräch"
var sourceRaw: String? = nil
var createdAt: Date = Date()
var updatedAt: Date = Date()
var isImportant: Bool = false
var person: Person? = nil
@Relationship(deleteRule: .cascade) var ratings: [Rating]? = []
@Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil
var statusRaw: String? = nil
var aftermathNotificationScheduled: Bool = false
var aftermathCompletedAt: Date? = nil
var reminderDate: Date? = nil
var isCompleted: Bool = false
init() {}
}
@Model final class LogEntry {
var id: UUID = UUID()
var typeRaw: String = "Schritt abgeschlossen"
var title: String = ""
var loggedAt: Date = Date()
var updatedAt: Date = Date()
var person: Person? = nil
init() {}
}
@Model final class Visit {
var id: UUID = UUID()
var visitDate: Date = Date()
var statusRaw: String = "sofort_abgeschlossen"
var note: String? = nil
var aftermathNotificationScheduled: Bool = false
var aftermathCompletedAt: Date? = nil
var person: Person? = nil
@Relationship(deleteRule: .cascade) var ratings: [Rating]? = []
@Relationship(deleteRule: .cascade) var healthSnapshot: HealthSnapshot? = nil
init() {}
}
@Model final class Rating {
var id: UUID = UUID()
var categoryRaw: String = "Selbst"
var questionIndex: Int = 0
var value: Int? = nil
var isAftermath: Bool = false
var visit: Visit? = nil
var moment: Moment? = nil
init() {}
}
@Model final class HealthSnapshot {
var id: UUID = UUID()
var sleepHours: Double? = nil
var hrvMs: Double? = nil
var restingHR: Int? = nil
var steps: Int? = nil
var visit: Visit? = nil
var moment: Moment? = nil
init() {}
}
@Model final class Todo {
var id: UUID = UUID()
var title: String = ""
var dueDate: Date = Date()
var isCompleted: Bool = false
var completedAt: Date? = nil
var reminderDate: Date? = nil // V8-Feld
var person: Person? = nil
var createdAt: Date = Date()
init() {}
}
}
// MARK: - Schema V9 (aktuelles Schema)
// Referenziert die Live-Typen aus Models.swift.
// Beim Hinzufügen von V10 muss V9 als eingefrorener Snapshot gesichert werden.
//
// V9 fügt hinzu:
// Person: phoneNumber, emailAddress, cnIdentifier (Kontaktdaten für direkte Aktionen)
enum NahbarSchemaV9: VersionedSchema {
static var versionIdentifier = Schema.Version(9, 0, 0)
static var models: [any PersistentModel.Type] { static var models: [any PersistentModel.Type] {
[nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self, [nahbar.PersonPhoto.self, nahbar.Person.self, nahbar.Moment.self, nahbar.LogEntry.self,
nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self, nahbar.Todo.self] nahbar.Visit.self, nahbar.Rating.self, nahbar.HealthSnapshot.self, nahbar.Todo.self]
@@ -661,7 +787,7 @@ enum NahbarMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { static var schemas: [any VersionedSchema.Type] {
[NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self, [NahbarSchemaV1.self, NahbarSchemaV2.self, NahbarSchemaV3.self,
NahbarSchemaV4.self, NahbarSchemaV5.self, NahbarSchemaV6.self, NahbarSchemaV4.self, NahbarSchemaV5.self, NahbarSchemaV6.self,
NahbarSchemaV7.self, NahbarSchemaV8.self] NahbarSchemaV7.self, NahbarSchemaV8.self, NahbarSchemaV9.self]
} }
static var stages: [MigrationStage] { static var stages: [MigrationStage] {
@@ -693,7 +819,11 @@ enum NahbarMigrationPlan: SchemaMigrationPlan {
// V7 V8: Todo bekommt reminderDate = nil. // V7 V8: Todo bekommt reminderDate = nil.
// Optionales Feld mit nil-Default lightweight-Migration reicht aus. // Optionales Feld mit nil-Default lightweight-Migration reicht aus.
.lightweight(fromVersion: NahbarSchemaV7.self, toVersion: NahbarSchemaV8.self) .lightweight(fromVersion: NahbarSchemaV7.self, toVersion: NahbarSchemaV8.self),
// V8 V9: Person bekommt phoneNumber, emailAddress, cnIdentifier = nil.
// Alle drei Felder sind optional mit nil-Default lightweight-Migration reicht aus.
.lightweight(fromVersion: NahbarSchemaV8.self, toVersion: NahbarSchemaV9.self)
] ]
} }
} }
+43 -78
View File
@@ -9,8 +9,7 @@ private let onboardingLogger = Logger(subsystem: "nahbar", category: "Onboarding
// MARK: - OnboardingContainerView // MARK: - OnboardingContainerView
/// Root container for the first-launch onboarding flow. /// Root container for the first-launch onboarding flow.
/// Shows a TabView with Phase 1 (Profile) and Phase 2 (Contacts), /// Phase 1: Profil, Phase 2: Kontakte, Phase 3: Datenschutz.
/// then overlays the Phase 3 (Feature Tour) on top with a blurred background.
struct OnboardingContainerView: View { struct OnboardingContainerView: View {
let onComplete: () -> Void let onComplete: () -> Void
@@ -20,68 +19,49 @@ struct OnboardingContainerView: View {
/// Current tab page index (0 = profile, 1 = contacts). /// Current tab page index (0 = profile, 1 = contacts).
@State private var tabPage: Int = 0 @State private var tabPage: Int = 0
/// Whether the feature tour overlay is visible. /// Whether the final privacy screen is visible (shown after contacts).
@State private var showTour: Bool = false
/// Whether the final privacy screen is visible (shown after the feature tour).
@State private var showPrivacyScreen: Bool = false @State private var showPrivacyScreen: Bool = false
var body: some View { var body: some View {
ZStack { ZStack {
// Background pages (blurred when tour or privacy screen is active) // Background pages (blurred when privacy screen is active)
TabView(selection: $tabPage) { TabView(selection: $tabPage) {
OnboardingProfileView(coordinator: coordinator) OnboardingProfileView(coordinator: coordinator)
.tag(0) .tag(0)
OnboardingQuizPromptView(coordinator: coordinator)
.tag(1)
OnboardingContactImportView( OnboardingContactImportView(
coordinator: coordinator, coordinator: coordinator,
onContinue: startTour, onContinue: startPrivacyScreen
onSkip: startTour
) )
.tag(2) .tag(1)
} }
.tabViewStyle(.page(indexDisplayMode: .never)) .tabViewStyle(.page(indexDisplayMode: .never))
.blur(radius: (showTour || showPrivacyScreen) ? 20 : 0) .blur(radius: showPrivacyScreen ? 20 : 0)
.disabled(showTour || showPrivacyScreen) .disabled(showPrivacyScreen)
// Dark overlay // Dark overlay when privacy screen shown
if showTour || showPrivacyScreen { if showPrivacyScreen {
Color.black.opacity(0.45) Color.black.opacity(0.45)
.ignoresSafeArea() .ignoresSafeArea()
.transition(.opacity) .transition(.opacity)
} }
// Feature tour
if showTour {
FeatureTourView(onFinish: startPrivacyScreen)
.transition(.opacity)
}
// Privacy screen (final step) // Privacy screen (final step)
if showPrivacyScreen { if showPrivacyScreen {
OnboardingPrivacyView(onFinish: finishOnboarding) OnboardingPrivacyView(onFinish: finishOnboarding)
.transition(.opacity) .transition(.opacity)
} }
} }
.animation(.easeInOut(duration: 0.35), value: showTour)
.animation(.easeInOut(duration: 0.35), value: showPrivacyScreen) .animation(.easeInOut(duration: 0.35), value: showPrivacyScreen)
.onChange(of: coordinator.currentStep) { _, step in .onChange(of: coordinator.currentStep) { _, step in
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
// Cap tab index at 2 (contacts is the last real page after quiz) tabPage = min(step.rawValue, 1)
tabPage = min(step.rawValue, 2)
} }
} }
} }
private func startTour() {
withAnimation { showTour = true }
}
private func startPrivacyScreen() { private func startPrivacyScreen() {
withAnimation(.easeInOut(duration: 0.35)) { withAnimation(.easeInOut(duration: 0.35)) {
showTour = false
showPrivacyScreen = true showPrivacyScreen = true
} }
} }
@@ -227,7 +207,7 @@ private struct OnboardingProfileView: View {
Button { Button {
let newValue = selected ? "" : option let newValue = selected ? "" : option
coordinator.gender = newValue coordinator.gender = newValue
// Sofort persistieren, damit der Quiz-Schritt es lesen kann // Sofort persistieren
UserProfileStore.shared.updateGender(newValue) UserProfileStore.shared.updateGender(newValue)
} label: { } label: {
Text(option) Text(option)
@@ -259,7 +239,7 @@ private struct OnboardingProfileView: View {
// Continue button // Continue button
Button { Button {
coordinator.advanceToQuiz() coordinator.advanceToContacts()
} label: { } label: {
Text("Weiter") Text("Weiter")
.font(.headline) .font(.headline)
@@ -273,7 +253,7 @@ private struct OnboardingProfileView: View {
} }
.disabled(!coordinator.isProfileValid) .disabled(!coordinator.isProfileValid)
.padding(.horizontal, 24) .padding(.horizontal, 24)
.accessibilityLabel("Weiter zum Persönlichkeitsquiz") .accessibilityLabel("Weiter zu Kontakten")
.accessibilityHint(coordinator.isProfileValid .accessibilityHint(coordinator.isProfileValid
? "" ? ""
: "Bitte gib zuerst deinen Vornamen ein.") : "Bitte gib zuerst deinen Vornamen ein.")
@@ -357,42 +337,17 @@ private struct OnboardingProfileView: View {
} }
} }
// MARK: - Phase 2: OnboardingQuizPromptView // MARK: - Phase 2: OnboardingContactImportView
/// Onboarding-Seite für das Persönlichkeitsquiz.
/// Zeigt die Quiz-Intro-UI und präsentiert PersonalityQuizView als Sheet.
private struct OnboardingQuizPromptView: View {
@ObservedObject var coordinator: OnboardingCoordinator
@AppStorage("hasSkippedPersonalityQuiz") private var hasSkippedQuiz: Bool = false
@State private var showingQuiz = false
var body: some View {
QuizIntroScreen(
onStart: { showingQuiz = true },
onSkip: {
hasSkippedQuiz = true
coordinator.skipQuiz()
}
)
.sheet(isPresented: $showingQuiz) {
PersonalityQuizView(skipIntro: true) { _ in
coordinator.advanceFromQuizToContacts()
}
}
}
}
// MARK: - Phase 3: OnboardingContactImportView
/// Uses CNContactPickerViewController (system picker, no permission needed). /// Uses CNContactPickerViewController (system picker, no permission needed).
/// Multi-select is activated automatically by implementing didSelectContacts:. /// Multi-select is activated automatically by implementing didSelectContacts:.
private struct OnboardingContactImportView: View { private struct OnboardingContactImportView: View {
@ObservedObject var coordinator: OnboardingCoordinator @ObservedObject var coordinator: OnboardingCoordinator
let onContinue: () -> Void let onContinue: () -> Void
let onSkip: () -> Void
@State private var showingPicker = false @State private var showingPicker = false
@State private var showSkipConfirmation: Bool = false @State private var showingLimitAlert = false
@State private var droppedByLimit = 0
private let maxContacts = 3 private let maxContacts = 3
private var atLimit: Bool { coordinator.selectedContacts.count >= maxContacts } private var atLimit: Bool { coordinator.selectedContacts.count >= maxContacts }
@@ -451,29 +406,21 @@ private struct OnboardingContactImportView: View {
: "\(coordinator.selectedContacts.count) Kontakte ausgewählt. Weiter." : "\(coordinator.selectedContacts.count) Kontakte ausgewählt. Weiter."
) )
Button { if coordinator.selectedContacts.isEmpty {
showSkipConfirmation = true Text("Füge mindestens eine Person hinzu, um fortzufahren sonst macht nahbar leider keinen Sinn.")
} label: { .font(.caption)
Text("Überspringen")
.font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} }
.accessibilityLabel("Kontakte überspringen")
.accessibilityHint("Zeigt eine Bestätigungsabfrage.")
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.vertical, 16) .padding(.vertical, 16)
} }
.confirmationDialog( .alert("Limit erreicht", isPresented: $showingLimitAlert) {
"Kontakte überspringen?", Button("OK", role: .cancel) {}
isPresented: $showSkipConfirmation,
titleVisibility: .visible
) {
Button("Trotzdem überspringen", role: .destructive, action: onSkip)
Button("Abbrechen", role: .cancel) {}
} message: { } message: {
Text("Du kannst Kontakte jederzeit später in der App hinzufügen.") Text(limitAlertMessage)
} }
.overlay(alignment: .center) { .overlay(alignment: .center) {
// Invisible trigger finds the hosting UIViewController via // Invisible trigger finds the hosting UIViewController via
@@ -563,20 +510,38 @@ private struct OnboardingContactImportView: View {
// MARK: Merge helper // MARK: Merge helper
private var limitAlertMessage: String {
if droppedByLimit == 1 {
return String(localized: "1 Kontakt wurde nicht hinzugefügt. Im Free-Tier kannst du beim Onboarding bis zu 3 Personen auswählen.")
}
return String.localizedStringWithFormat(
String(localized: "%lld Kontakte wurden nicht hinzugefügt. Im Free-Tier kannst du beim Onboarding bis zu 3 Personen auswählen."),
droppedByLimit
)
}
/// Merges newly picked contacts into the existing selection (no duplicates). /// Merges newly picked contacts into the existing selection (no duplicates).
private func mergeContacts(_ contacts: [CNContact]) { private func mergeContacts(_ contacts: [CNContact]) {
var dropped = 0
for contact in contacts { for contact in contacts {
guard coordinator.selectedContacts.count < maxContacts else { break } if coordinator.selectedContacts.count >= maxContacts {
dropped += 1
continue
}
let alreadySelected = coordinator.selectedContacts let alreadySelected = coordinator.selectedContacts
.contains { $0.cnIdentifier == contact.identifier } .contains { $0.cnIdentifier == contact.identifier }
if !alreadySelected { if !alreadySelected {
coordinator.selectedContacts.append(NahbarContact(from: contact)) coordinator.selectedContacts.append(NahbarContact(from: contact))
} }
} }
if dropped > 0 {
droppedByLimit = dropped
showingLimitAlert = true
}
} }
} }
// MARK: - Phase 4: OnboardingPrivacyView // MARK: - Phase 3: OnboardingPrivacyView
/// Final onboarding screen. Explains the app's privacy-first approach and /// Final onboarding screen. Explains the app's privacy-first approach and
/// informs users that AI features are optional and involve a third-party service. /// informs users that AI features are optional and involve a third-party service.
+3 -32
View File
@@ -5,11 +5,9 @@ import Combine
/// Each phase of the first-launch onboarding flow. /// Each phase of the first-launch onboarding flow.
enum OnboardingStep: Int, CaseIterable { enum OnboardingStep: Int, CaseIterable {
case profile = 0 case profile = 0 // Profilangaben
case quiz = 1 // Persönlichkeitsquiz-Prompt case contacts = 1 // Kontakte importieren
case contacts = 2 // was 1 case complete = 2
case tour = 3 // was 2
case complete = 4 // was 3
} }
// MARK: - OnboardingCoordinator // MARK: - OnboardingCoordinator
@@ -42,39 +40,12 @@ final class OnboardingCoordinator: ObservableObject {
// MARK: Navigation actions // MARK: Navigation actions
/// Advances to the personality quiz prompt if the profile is valid.
func advanceToQuiz() {
guard isProfileValid else { return }
currentStep = .quiz
}
/// Skips the personality quiz and goes directly to contact import.
func skipQuiz() {
currentStep = .contacts
}
/// Called after the personality quiz completes or is dismissed; advances to contact import.
func advanceFromQuizToContacts() {
currentStep = .contacts
}
/// Advances to the contact import phase. Validates profile first. /// Advances to the contact import phase. Validates profile first.
func advanceToContacts() { func advanceToContacts() {
guard isProfileValid else { return } guard isProfileValid else { return }
currentStep = .contacts currentStep = .contacts
} }
/// Advances to the feature tour if at least one contact has been selected.
func advanceToTour() {
guard !selectedContacts.isEmpty else { return }
currentStep = .tour
}
/// Skips the contact selection and goes directly to the feature tour.
func skipToTour() {
currentStep = .tour
}
/// Marks onboarding as fully complete. /// Marks onboarding as fully complete.
func completeOnboarding() { func completeOnboarding() {
currentStep = .complete currentStep = .complete
+2 -1
View File
@@ -27,6 +27,7 @@ struct PaywallView: View {
private let maxExtraFeatures: [(icon: String, text: String)] = [ private let maxExtraFeatures: [(icon: String, text: String)] = [
("brain.head.profile", "KI-Analyse: Muster, Beziehungsqualität & Empfehlungen"), ("brain.head.profile", "KI-Analyse: Muster, Beziehungsqualität & Empfehlungen"),
("gift.fill", "Geschenkideen: KI-Vorschläge bei Geburtstagen"), ("gift.fill", "Geschenkideen: KI-Vorschläge bei Geburtstagen"),
("text.bubble.fill", "Gesprächsthemen vorschlagen: KI-Impulse für bessere Treffen"),
("infinity", "Unbegrenzte KI-Abfragen ohne Limit"), ("infinity", "Unbegrenzte KI-Abfragen ohne Limit"),
] ]
@@ -81,7 +82,7 @@ struct PaywallView: View {
.padding(.top, 24) .padding(.top, 24)
Text("nahbar") Text("nahbar")
.font(.system(size: 28, weight: .light, design: theme.displayDesign)) .font(.system(size: 26, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text("Wähle deinen Plan") Text("Wähle deinen Plan")
+13 -1
View File
@@ -4,6 +4,7 @@ import SwiftData
struct PeopleListView: View { struct PeopleListView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) var modelContext @Environment(\.modelContext) var modelContext
@Environment(TourCoordinator.self) private var tourCoordinator
@Query(sort: \Person.name) private var people: [Person] @Query(sort: \Person.name) private var people: [Person]
@StateObject private var store = StoreManager.shared @StateObject private var store = StoreManager.shared
@@ -34,7 +35,7 @@ struct PeopleListView: View {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Menschen") Text("Menschen")
.font(.system(size: 34, weight: .light, design: theme.displayDesign)) .font(.system(size: 32, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
// Kontaktlimit-Hinweis für Free-Nutzer // Kontaktlimit-Hinweis für Free-Nutzer
if !store.isPro { if !store.isPro {
@@ -61,6 +62,7 @@ struct PeopleListView: View {
.clipShape(Circle()) .clipShape(Circle())
} }
.accessibilityLabel("Person hinzufügen") .accessibilityLabel("Person hinzufügen")
.tourTarget(.addContactButton)
} }
// Search bar // Search bar
@@ -98,6 +100,7 @@ struct PeopleListView: View {
} }
} }
} }
.tourTarget(.filterChips)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 12) .padding(.top, 12)
@@ -120,6 +123,7 @@ struct PeopleListView: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.tourTarget(index == 0 ? TourTargetID.contactCardFirst : nil)
if index < filteredPeople.count - 1 { if index < filteredPeople.count - 1 {
RowDivider() RowDivider()
} }
@@ -146,6 +150,14 @@ struct PeopleListView: View {
.sheet(isPresented: $showingPaywall) { .sheet(isPresented: $showingPaywall) {
PaywallView(targeting: .pro) PaywallView(targeting: .pro)
} }
// Automatisch zum ersten Kontakt navigieren, wenn die Tour
// den +Moment- oder +Todo-Button spotlighten möchte.
.onChange(of: tourCoordinator.currentStep?.target) { _, target in
guard target == .addMomentButton || target == .addTodoButton else { return }
if selectedPerson == nil, let first = filteredPeople.first {
selectedPerson = first
}
}
} }
private var emptyState: some View { private var emptyState: some View {
+502 -68
View File
@@ -2,11 +2,24 @@ import SwiftUI
import SwiftData import SwiftData
import CoreData import CoreData
import UserNotifications import UserNotifications
import OSLog
import UIKit
private let todoNotificationLogger = Logger(subsystem: "nahbar", category: "TodoNotification")
// Wiederverwendet in AIAnalysisSheet (scoped auf diese Datei)
private enum AnalysisState {
case idle
case loading
case result(AIAnalysisResult, Date)
case error(String)
}
struct PersonDetailView: View { struct PersonDetailView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) var modelContext @Environment(\.modelContext) var modelContext
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL
@Bindable var person: Person @Bindable var person: Person
@State private var showingAddMoment = false @State private var showingAddMoment = false
@@ -26,20 +39,42 @@ struct PersonDetailView: View {
@State private var todoForEdit: Todo? = nil @State private var todoForEdit: Todo? = nil
@State private var fadingOutTodos: [Todo] = [] @State private var fadingOutTodos: [Todo] = []
// Neu hinzugefügte Logbuch-Momente 5 s in Momente sichtbar, dann in Verlauf
@State private var fadingOutMoments: [Moment] = []
@State private var seenMomentIDs: Set<UUID> = []
// Treffen-Momente warten auf Rating-Survey-Abschluss bevor der 5-s-Timer startet
@State private var pendingFadeAfterSurvey: [Moment] = []
// Kalender-Lösch-Bestätigung // Kalender-Lösch-Bestätigung
@State private var momentPendingDelete: Moment? = nil @State private var momentPendingDelete: Moment? = nil
@State private var showCalendarDeleteDialog = false @State private var showCalendarDeleteDialog = false
@StateObject private var personalityStore = PersonalityStore.shared // Kontakt-Aktionsblatt (Telefon)
@State private var activityHint: String = "" @State private var showingPhoneActionSheet = false
// Fallback wenn keine Mail-App installiert
@State private var showingEmailFallback = false
@StateObject private var storeManager = StoreManager.shared
// KI-Analyse
@State private var showingAIAnalysis = false
@State private var showingAIPaywall = false
private var canUseAI: Bool {
storeManager.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
}
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 28) { VStack(alignment: .leading, spacing: 28) {
personHeader personHeader
if person.phoneNumber != nil || person.emailAddress != nil {
kontaktSection
}
momentsSection momentsSection
todosSection todosSection
if !person.sortedMoments.isEmpty || !person.sortedLogEntries.isEmpty { logbuchSection } if !mergedLogPreview.isEmpty { logbuchSection }
if hasInfoContent { infoSection } if hasInfoContent { infoSection }
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
@@ -55,6 +90,7 @@ struct PersonDetailView: View {
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundStyle(theme.accent) .foregroundStyle(theme.accent)
} }
} }
.sheet(isPresented: $showingAddTodo) { .sheet(isPresented: $showingAddTodo) {
AddTodoView(person: person) AddTodoView(person: person)
@@ -68,6 +104,34 @@ struct PersonDetailView: View {
} }
} }
} }
.onChange(of: showingAddMoment) { _, isShowing in
if isShowing {
seenMomentIDs = Set(person.sortedMoments.map(\.id))
} else {
// Neu gespeicherte Logbuch-Momente (keine Vorhaben, kein Zukunftstreffen) kurz anzeigen
let newLogbuchMoments = person.sortedMoments.filter { moment in
guard !seenMomentIDs.contains(moment.id) else { return false }
let isActive = moment.isOpen || (moment.isMeeting && moment.createdAt > Date())
return !isActive
}
// Sofort visuell in Momente einblenden
for moment in newLogbuchMoments {
withAnimation { fadingOutMoments.append(moment) }
}
// Treffen-Momente: Timer erst nach Rating-Survey starten (survey läuft noch)
let meetingMoments = newLogbuchMoments.filter { $0.isMeeting }
let otherMoments = newLogbuchMoments.filter { !$0.isMeeting }
scheduleFadeOut(otherMoments)
pendingFadeAfterSurvey.append(contentsOf: meetingMoments)
}
}
.onChange(of: momentForRating) { old, new in
// Survey geschlossen (war gesetzt, ist jetzt nil) Timer für wartende Treffen starten
guard old != nil && new == nil, !pendingFadeAfterSurvey.isEmpty else { return }
let toFade = pendingFadeAfterSurvey
pendingFadeAfterSurvey = []
scheduleFadeOut(toFade)
}
.sheet(isPresented: $showingEditPerson) { .sheet(isPresented: $showingEditPerson) {
AddPersonView(existingPerson: person) AddPersonView(existingPerson: person)
} }
@@ -101,6 +165,12 @@ struct PersonDetailView: View {
.sheet(item: $todoForEdit) { todo in .sheet(item: $todoForEdit) { todo in
EditTodoView(todo: todo) EditTodoView(todo: todo)
} }
.sheet(isPresented: $showingAIAnalysis) {
AIAnalysisSheet(person: person)
}
.sheet(isPresented: $showingAIPaywall) {
PaywallView(targeting: .max)
}
.confirmationDialog( .confirmationDialog(
"Moment löschen", "Moment löschen",
isPresented: $showCalendarDeleteDialog, isPresented: $showCalendarDeleteDialog,
@@ -120,6 +190,36 @@ struct PersonDetailView: View {
} message: { _ in } message: { _ in
Text("Dieser Moment hat einen verknüpften Kalendereintrag. Soll dieser ebenfalls gelöscht werden?") Text("Dieser Moment hat einen verknüpften Kalendereintrag. Soll dieser ebenfalls gelöscht werden?")
} }
.confirmationDialog(
"Telefon",
isPresented: $showingPhoneActionSheet,
titleVisibility: .hidden
) {
if let phone = person.phoneNumber {
let sanitized = phone.components(separatedBy: .init(charactersIn: " -()")).joined()
if let url = URL(string: "tel://\(sanitized)") {
Button("Anrufen") { openURL(url) }
}
if let url = URL(string: "sms://\(sanitized)") {
Button("Nachricht") { openURL(url) }
}
if let url = URL(string: "facetime://\(sanitized)") {
Button("FaceTime") { openURL(url) }
}
let waNumber = phone.filter { $0.isNumber }
if !waNumber.isEmpty, let url = URL(string: "https://wa.me/\(waNumber)") {
Button("WhatsApp") { openURL(url) }
}
}
}
.alert("Keine Mail-App gefunden", isPresented: $showingEmailFallback) {
Button("Kopieren") {
UIPasteboard.general.string = person.emailAddress
}
Button("OK", role: .cancel) {}
} message: {
Text(person.emailAddress ?? "")
}
// Schützt vor Crash wenn der ModelContext durch Migration oder // Schützt vor Crash wenn der ModelContext durch Migration oder
// CloudKit-Sync intern reset() aufruft und Person-Objekte ungültig werden. // CloudKit-Sync intern reset() aufruft und Person-Objekte ungültig werden.
.onReceive( .onReceive(
@@ -135,12 +235,12 @@ struct PersonDetailView: View {
// MARK: - Header // MARK: - Header
private var personHeader: some View { private var personHeader: some View {
HStack(spacing: 16) { HStack(alignment: .top, spacing: 16) {
PersonAvatar(person: person, size: 64) PersonAvatar(person: person, size: 64)
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
Text(person.name) Text(person.name)
.font(.system(size: 26, weight: .light, design: theme.displayDesign)) .font(.system(size: 24, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
TagBadge(text: person.tag.rawValue) TagBadge(text: person.tag.rawValue)
@@ -150,13 +250,153 @@ struct PersonDetailView: View {
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundStyle(theme.contentSecondary) .foregroundStyle(theme.contentSecondary)
} }
}
Spacer() if person.nudgeStatus != .never {
nudgeChip
} }
} }
Spacer()
Button {
if canUseAI {
showingAIAnalysis = true
} else {
showingAIPaywall = true
}
} label: {
HStack(spacing: 3) {
Image(systemName: "sparkles")
.font(.system(size: 18))
.foregroundStyle(theme.accent)
MaxBadge()
}
}
.padding(.top, 4)
}
}
private var nudgeChip: some View {
let status = person.nudgeStatus
let dotColor: Color = switch status {
case .overdue: .red
case .soon: .orange
default: .green
}
let reference = person.lastMomentDate ?? person.createdAt
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
let relativeTime = formatter.localizedString(for: reference, relativeTo: Date())
return Menu {
ForEach(NudgeFrequency.allCases, id: \.self) { freq in
Button {
person.nudgeFrequency = freq
person.touch()
try? modelContext.save()
} label: {
if freq == person.nudgeFrequency {
Label(freq.displayLabel, systemImage: "checkmark")
} else {
Text(freq.displayLabel)
}
}
}
} label: {
HStack(spacing: 5) {
Circle()
.fill(dotColor)
.frame(width: 7, height: 7)
Text(person.nudgeFrequency.displayLabel)
.font(.system(size: 13))
.foregroundStyle(theme.contentSecondary)
Text("·")
.font(.system(size: 13))
.foregroundStyle(theme.contentTertiary)
Text(relativeTime)
.font(.system(size: 13))
.foregroundStyle(theme.contentTertiary)
}
}
.padding(.top, 2)
}
// MARK: - Kontakt
private var kontaktSection: some View {
VStack(alignment: .leading, spacing: 10) {
SectionHeader(title: "Kontakt", icon: "phone")
VStack(spacing: 0) {
if let phone = person.phoneNumber {
Button { showingPhoneActionSheet = true } label: {
kontaktRow(label: "Telefon", value: phone, icon: "phone.fill")
}
.buttonStyle(.plain)
if person.emailAddress != nil { RowDivider() }
}
if let email = person.emailAddress {
Button {
if let url = URL(string: "mailto:\(email)") {
openURL(url) { accepted in
if !accepted { showingEmailFallback = true }
}
}
} label: {
kontaktRow(label: "E-Mail", value: email, icon: "envelope.fill")
}
.buttonStyle(.plain)
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
}
private func kontaktRow(label: String, value: String, icon: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Text(label)
.font(.system(size: 13))
.foregroundStyle(theme.contentTertiary)
.frame(width: 88, alignment: .leading)
Text(value)
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
Image(systemName: icon)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
// MARK: - Momente // MARK: - Momente
/// Aktive Momente: offene Vorhaben + noch ausstehende Treffen in der Zukunft.
private var activeMoments: [Moment] {
person.sortedMoments.filter { $0.isOpen || ($0.isMeeting && $0.createdAt > Date()) }
}
/// Was in der Momente-Sektion angezeigt wird: aktive + kurzzeitig sichtbare neue Logbuch-Momente.
private var visibleMoments: [Moment] {
let fadingIDs = Set(fadingOutMoments.map(\.id))
let active = activeMoments.filter { !fadingIDs.contains($0.id) }
return active + fadingOutMoments
}
/// Startet den 5-s-Ausblend-Timer für die angegebenen Momente.
private func scheduleFadeOut(_ moments: [Moment]) {
for moment in moments {
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
withAnimation(.easeOut(duration: 0.35)) {
fadingOutMoments.removeAll { $0.id == moment.id }
}
}
}
}
private var momentsSection: some View { private var momentsSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
HStack { HStack {
@@ -177,12 +417,7 @@ struct PersonDetailView: View {
.background(theme.accent.opacity(0.10)) .background(theme.accent.opacity(0.10))
.clipShape(Capsule()) .clipShape(Capsule())
} }
} .tourTarget(.addMomentButton)
// Persönlichkeitsbasierte Vorhaben-Vorschläge (ersetzt nextStepSection)
if person.openIntentions.isEmpty,
let profile = personalityStore.profile, profile.isComplete {
intentionSuggestionButton(profile: profile)
} }
if person.sortedMoments.isEmpty { if person.sortedMoments.isEmpty {
@@ -190,9 +425,9 @@ struct PersonDetailView: View {
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundStyle(theme.contentTertiary) .foregroundStyle(theme.contentTertiary)
.padding(.vertical, 4) .padding(.vertical, 4)
} else { } else if !visibleMoments.isEmpty {
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(Array(person.sortedMoments.enumerated()), id: \.element.id) { index, moment in ForEach(Array(visibleMoments.enumerated()), id: \.element.id) { index, moment in
VStack(spacing: 0) { VStack(spacing: 0) {
MomentRowView( MomentRowView(
moment: moment, moment: moment,
@@ -204,7 +439,8 @@ struct PersonDetailView: View {
onEdit: { momentForTextEdit = moment }, onEdit: { momentForTextEdit = moment },
onToggleImportant: { toggleImportant(moment) } onToggleImportant: { toggleImportant(moment) }
) )
if index < person.sortedMoments.count - 1 { RowDivider() } .opacity(fadingOutMoments.contains(where: { $0.id == moment.id }) ? 0.45 : 1.0)
if index < visibleMoments.count - 1 { RowDivider() }
} }
} }
} }
@@ -214,52 +450,6 @@ struct PersonDetailView: View {
} }
} }
// MARK: - Vorhaben-Vorschlag
private func intentionSuggestionButton(profile: PersonalityProfile) -> some View {
let hint = activityHint.isEmpty ? refreshActivityHint(profile: profile) : activityHint
return HStack(spacing: 0) {
Button {
showingAddMoment = true
} label: {
HStack(spacing: 6) {
Image(systemName: "brain")
.font(.system(size: 11))
.foregroundStyle(NahbarInsightStyle.accentPetrol)
Text("Idee: \(hint)")
.font(.system(size: 13))
.foregroundStyle(theme.contentSecondary)
.lineLimit(1)
}
.padding(.leading, 14)
.padding(.vertical, 7)
.frame(maxWidth: .infinity, alignment: .leading)
}
// Neue Idee würfeln
Button {
activityHint = refreshActivityHint(profile: profile)
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
.padding(.horizontal, 12)
.padding(.vertical, 7)
}
}
}
@discardableResult
private func refreshActivityHint(profile: PersonalityProfile) -> String {
let suggestions = PersonalityEngine.suggestedActivities(
for: profile, tag: person.tag, count: 2
)
let hint = suggestions.joined(separator: " oder ")
activityHint = hint
return hint
}
// MARK: - Logbuch Vorschau // MARK: - Logbuch Vorschau
private let logbuchPreviewLimit = 5 private let logbuchPreviewLimit = 5
@@ -274,7 +464,13 @@ struct PersonDetailView: View {
} }
private var mergedLogPreview: [LogPreviewItem] { private var mergedLogPreview: [LogPreviewItem] {
let momentItems = person.sortedMoments.map { // Nur Momente die weder aktiv (offene Vorhaben / Zukunftstreffen) noch gerade sichtbar ausklingend sind
let activeIDs = Set(activeMoments.map(\.id))
let fadingIDs = Set(fadingOutMoments.map(\.id))
let logbuchMoments = person.sortedMoments.filter {
!activeIDs.contains($0.id) && !fadingIDs.contains($0.id)
}
let momentItems = logbuchMoments.map {
LogPreviewItem(id: "m-\($0.id)", icon: $0.type.icon, title: $0.text, LogPreviewItem(id: "m-\($0.id)", icon: $0.type.icon, title: $0.text,
typeLabel: $0.type.displayName, date: $0.createdAt) typeLabel: $0.type.displayName, date: $0.createdAt)
} }
@@ -292,7 +488,7 @@ struct PersonDetailView: View {
return VStack(alignment: .leading, spacing: 10) { return VStack(alignment: .leading, spacing: 10) {
HStack { HStack {
SectionHeader(title: "Verlauf & KI-Analyse", icon: "sparkles") SectionHeader(title: "Verlauf", icon: "clock.arrow.circlepath")
Spacer() Spacer()
NavigationLink(destination: LogbuchView(person: person)) { NavigationLink(destination: LogbuchView(person: person)) {
Text("Alle") Text("Alle")
@@ -387,7 +583,7 @@ struct PersonDetailView: View {
RowDivider() RowDivider()
} }
if let interests = person.interests, !interests.isEmpty { if let interests = person.interests, !interests.isEmpty {
InfoRowView(label: "Interessen", value: interests) InterestChipRow(label: "Interessen", text: interests, color: .green)
RowDivider() RowDivider()
} }
if let bg = person.culturalBackground, !bg.isEmpty { if let bg = person.culturalBackground, !bg.isEmpty {
@@ -462,6 +658,7 @@ struct PersonDetailView: View {
.background(theme.accent.opacity(0.10)) .background(theme.accent.opacity(0.10))
.clipShape(Capsule()) .clipShape(Capsule())
} }
.tourTarget(.addTodoButton)
} }
if visibleTodos.isEmpty { if visibleTodos.isEmpty {
@@ -1247,12 +1444,20 @@ struct EditTodoView: View {
private func scheduleReminder() { private func scheduleReminder() {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { granted, _ in center.requestAuthorization(options: [.alert, .sound]) { granted, error in
guard granted else { return } if let error {
todoNotificationLogger.error("Berechtigung-Fehler: \(error.localizedDescription)")
}
guard granted else {
todoNotificationLogger.warning("Notification-Berechtigung abgelehnt keine Todo-Erinnerung.")
return
}
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = todo.person?.firstName ?? "" content.title = todo.person?.firstName ?? ""
content.subtitle = String(localized: "Dein Todo")
content.body = todo.title content.body = todo.title
content.sound = .default content.sound = .default
content.userInfo = ["todoID": todo.id.uuidString]
let components = Calendar.current.dateComponents( let components = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute], from: reminderDate [.year, .month, .day, .hour, .minute], from: reminderDate
) )
@@ -1262,7 +1467,236 @@ struct EditTodoView: View {
content: content, content: content,
trigger: trigger trigger: trigger
) )
center.add(request) center.add(request) { error in
if let error {
todoNotificationLogger.error("Todo-Erinnerung konnte nicht geplant werden: \(error.localizedDescription)")
} else {
todoNotificationLogger.info("Todo-Erinnerung geplant: \(todo.id.uuidString)")
}
}
}
}
}
// MARK: - AI Analysis Sheet
private struct AIAnalysisSheet: View {
@Environment(\.nahbarTheme) var theme
@Environment(\.dismiss) var dismiss
@StateObject private var store = StoreManager.shared
let person: Person
@State private var analysisState: AnalysisState = .idle
@State private var showAIConsent = false
@State private var showPaywall = false
@State private var remainingRequests: Int = AIAnalysisService.shared.remainingRequests
@AppStorage("aiConsentGiven") private var aiConsentGiven = false
private var canUseAI: Bool {
store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Header mit MAX-Badge
HStack(spacing: 6) {
SectionHeader(title: "KI-Auswertung", icon: "sparkles")
MaxBadge()
if !store.isMax && canUseAI {
Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(theme.contentTertiary)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(theme.backgroundSecondary)
.clipShape(Capsule())
}
}
// Inhalt
VStack(alignment: .leading, spacing: 0) {
switch analysisState {
case .idle:
Button {
if aiConsentGiven {
Task { await runAnalysis() }
} else {
showAIConsent = true
}
} label: {
HStack(spacing: 10) {
Image(systemName: "sparkles")
.foregroundStyle(theme.accent)
Text("\(person.firstName) analysieren")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(theme.contentPrimary)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
.padding(16)
}
case .loading:
HStack(spacing: 12) {
ProgressView().tint(theme.accent)
VStack(alignment: .leading, spacing: 2) {
Text("Analysiere Logbuch…")
.font(.system(size: 14))
.foregroundStyle(theme.contentSecondary)
Text("Das kann bis zu einer Minute dauern.")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
}
.padding(16)
case .result(let result, let date):
VStack(alignment: .leading, spacing: 0) {
analysisSection(icon: "waveform.path", title: "Muster & Themen", text: result.patterns)
RowDivider()
analysisSection(icon: "person.2", title: "Beziehungsqualität", text: result.relationship)
RowDivider()
analysisSection(icon: "arrow.right.circle", title: "Empfehlung", text: result.recommendation)
RowDivider()
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 1) {
Text("Analysiert")
.font(.system(size: 11))
.foregroundStyle(theme.contentTertiary)
Text(date.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale.current)))
.font(.system(size: 11))
.foregroundStyle(theme.contentTertiary)
}
.padding(.leading, 16)
.padding(.vertical, 12)
Spacer()
Button {
Task { await runAnalysis() }
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 12))
Text(remainingRequests > 0
? "Aktualisieren (\(remainingRequests))"
: "Limit erreicht")
.font(.system(size: 13))
}
.foregroundStyle(remainingRequests > 0 ? theme.accent : theme.contentTertiary)
}
.disabled(remainingRequests == 0 || isAnalyzing)
.padding(.trailing, 16)
.padding(.vertical, 12)
}
}
case .error(let msg):
VStack(alignment: .leading, spacing: 8) {
Label("Analyse fehlgeschlagen", systemImage: "exclamationmark.triangle")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(theme.contentSecondary)
Text(msg)
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
Button {
Task { await runAnalysis() }
} label: {
Text("Erneut versuchen")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(theme.accent)
}
}
.padding(16)
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
.padding(20)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationTitle("KI Insights zu \(person.firstName)")
.navigationBarTitleDisplayMode(.inline)
.themedNavBar()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Schließen") { dismiss() }
.foregroundStyle(theme.accent)
}
}
.sheet(isPresented: $showAIConsent) {
AIConsentSheet {
aiConsentGiven = true
Task { await runAnalysis() }
}
}
.sheet(isPresented: $showPaywall) {
PaywallView(targeting: .max)
}
}
.onAppear {
// Cache laden
if let cached = AIAnalysisService.shared.loadCached(for: person) {
analysisState = .result(cached.asResult, cached.analyzedAt)
}
remainingRequests = AIAnalysisService.shared.remainingRequests
// Auto-start: kein Cache direkt starten wenn möglich
if case .idle = analysisState {
if canUseAI && aiConsentGiven {
Task { await runAnalysis() }
} else if canUseAI && !aiConsentGiven {
showAIConsent = true
}
}
}
}
private func analysisSection(icon: String, title: String, text: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 13))
.foregroundStyle(theme.accent)
.frame(width: 20)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(theme.contentSecondary)
Text(LocalizedStringKey(text))
.font(.system(size: 14, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private var isAnalyzing: Bool {
if case .loading = analysisState { return true }
return false
}
private func runAnalysis() async {
guard !AIAnalysisService.shared.isRateLimited else { return }
analysisState = .loading
do {
let result = try await AIAnalysisService.shared.analyze(person: person)
remainingRequests = AIAnalysisService.shared.remainingRequests
if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() }
analysisState = .result(result, Date())
} catch {
if let cached = AIAnalysisService.shared.loadCached(for: person) {
analysisState = .result(cached.asResult, cached.analyzedAt)
} else {
analysisState = .error(error.localizedDescription)
}
} }
} }
} }
+37 -108
View File
@@ -74,6 +74,43 @@ enum PersonalityEngine {
} }
} }
// MARK: - Notification-Texte
/// Body-Text für Gesprächsfenster-Notification (allgemeine Kontakt-Erinnerung, kein spezifischer Name).
/// Zentralisiert die bisher in CallWindowManager inline definierte Persönlichkeitslogik.
/// - High Extraversion direkt, motivierend
/// - High Neuroticism (nicht high E) weich, ermutigend
/// - Default freundlich-neutral
static func callWindowCopy(profile: PersonalityProfile?) -> String {
guard let profile else {
return String(localized: "Wer freut sich heute von dir zu hören?")
}
switch (profile.level(for: .extraversion), profile.level(for: .neuroticism)) {
case (.high, _):
return String(localized: "Wer freut sich heute von dir zu hören?")
case (_, .high):
return String(localized: "Magst du heute jemanden kurz anschreiben? Das kann viel bedeuten. 🙂")
default:
return String(localized: "Zeit, dich bei jemandem zu melden?")
}
}
/// Body-Text für Nachwirkungs-Notification nach einem Treffen.
/// Zentralisiert die bisher in AftermathNotificationManager inline verwendete Persönlichkeitslogik.
/// - High Neuroticism weich, optional einladend
/// - Default direkt, warm
static func aftermathCopy(profile: PersonalityProfile?) -> String {
guard let profile else {
return String(localized: "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen dauert 1 Minute.")
}
switch profile.level(for: .neuroticism) {
case .high:
return String(localized: "Wenn du magst, kannst du das Treffen kurz reflektieren.")
default:
return String(localized: "Wie wirkt euer Treffen jetzt auf dich? 3 kurze Fragen dauert 1 Minute.")
}
}
// MARK: - Besuchsbewertungs-Timing // MARK: - Besuchsbewertungs-Timing
/// Gibt an, ob der Besuchsfragebogen verzögert angezeigt werden soll. /// Gibt an, ob der Besuchsfragebogen verzögert angezeigt werden soll.
@@ -148,95 +185,6 @@ enum PersonalityEngine {
} }
} }
// MARK: - Vorhaben-Priorisierung
/// Gibt an, welche Art von Aktivität zuerst angezeigt werden soll.
static func preferredActivityStyle(for profile: PersonalityProfile?) -> ActivityStyle {
guard let profile else { return .oneOnOne }
switch profile.level(for: .extraversion) {
case .high: return .group
case .medium: return .oneOnOne
case .low: return .oneOnOne
}
}
/// Gibt an, ob Erlebnis-Aktivitäten hervorgehoben werden sollen.
static func highlightNovelty(for profile: PersonalityProfile?) -> Bool {
profile?.level(for: .openness) == .high
}
/// Gibt `count` Aktivitätsvorschläge zurück, gewichtet nach Persönlichkeit und Kontakt-Tag.
/// Innerhalb gleicher Scores wird zufällig variiert jeder Aufruf kann andere Ergebnisse liefern.
static func suggestedActivities(
for profile: PersonalityProfile?,
tag: PersonTag?,
count: Int = 2
) -> [String] {
let preferred = preferredActivityStyle(for: profile)
let highlightNew = highlightNovelty(for: profile)
func score(_ s: ActivitySuggestion) -> Int {
var p = 0
if s.style == preferred { p += 2 }
if s.isNovelty && highlightNew { p += 1 }
if let t = s.preferredTag, t == tag { p += 1 }
return p
}
// Nach Score gruppieren, innerhalb jeder Gruppe mischen Abwechslung
let grouped = Dictionary(grouping: activityPool) { score($0) }
var result: [String] = []
for key in grouped.keys.sorted(by: >) {
guard result.count < count else { break }
let bucket = (grouped[key] ?? []).shuffled()
for item in bucket {
guard result.count < count else { break }
result.append(item.text)
}
}
return result
}
// MARK: - Aktivitäts-Pool (intern, für Tests zugänglich via suggestedActivities)
static let activityPool: [ActivitySuggestion] = [
// 1:1
ActivitySuggestion("Kaffee trinken", style: .oneOnOne, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Spazieren gehen", style: .oneOnOne, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Zusammen frühstücken", style: .oneOnOne, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Mittagessen", style: .oneOnOne, isNovelty: false, preferredTag: .work),
ActivitySuggestion("Auf ein Getränk treffen", style: .oneOnOne, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Zusammen kochen", style: .oneOnOne, isNovelty: false, preferredTag: .family),
ActivitySuggestion("Bummeln gehen", style: .oneOnOne, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Rad fahren", style: .oneOnOne, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Joggen gehen", style: .oneOnOne, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Picknick", style: .oneOnOne, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Besuch machen", style: .oneOnOne, isNovelty: false, preferredTag: .family),
ActivitySuggestion("Gemeinsam lesen", style: .oneOnOne, isNovelty: false, preferredTag: nil),
// Gruppe
ActivitySuggestion("Abendessen", style: .group, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Spieleabend", style: .group, isNovelty: false, preferredTag: .friends),
ActivitySuggestion("Kino", style: .group, isNovelty: false, preferredTag: .friends),
ActivitySuggestion("Konzert oder Show", style: .group, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Museum besuchen", style: .group, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Wandern", style: .group, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Grillabend", style: .group, isNovelty: false, preferredTag: .friends),
ActivitySuggestion("Sportevent", style: .group, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Veranstaltung besuchen", style: .group, isNovelty: false, preferredTag: .community),
// Erlebnis
ActivitySuggestion("Etwas Neues ausprobieren", style: nil, isNovelty: true, preferredTag: nil),
ActivitySuggestion("Escape Room", style: nil, isNovelty: true, preferredTag: .friends),
ActivitySuggestion("Kochkurs", style: nil, isNovelty: true, preferredTag: nil),
ActivitySuggestion("Weinprobe oder Tasting", style: nil, isNovelty: true, preferredTag: nil),
ActivitySuggestion("Kletterpark", style: nil, isNovelty: true, preferredTag: .friends),
ActivitySuggestion("Workshop besuchen", style: nil, isNovelty: true, preferredTag: .community),
ActivitySuggestion("Karaoke", style: nil, isNovelty: true, preferredTag: .friends),
// Einfach / Remote
ActivitySuggestion("Anrufen", style: nil, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Nachricht schicken", style: nil, isNovelty: false, preferredTag: nil),
ActivitySuggestion("Artikel oder Tipp teilen", style: nil, isNovelty: false, preferredTag: nil),
]
// MARK: - Intervall-Empfehlung für Einstellungen // MARK: - Intervall-Empfehlung für Einstellungen
/// Gibt den empfohlenen Benachrichtigungs-Intervall für das Einstellungsmenü zurück. /// Gibt den empfohlenen Benachrichtigungs-Intervall für das Einstellungsmenü zurück.
@@ -264,23 +212,4 @@ enum RatingPromptTiming {
case delayed(seconds: Int, copy: String?) case delayed(seconds: Int, copy: String?)
} }
/// Präferierter Aktivitätsstil für Vorhaben-Vorschläge.
enum ActivityStyle {
case group
case oneOnOne
}
/// Ein einzelner Aktivitätsvorschlag aus dem Pool.
struct ActivitySuggestion {
let text: String
let style: ActivityStyle?
let isNovelty: Bool
let preferredTag: PersonTag?
init(_ text: String, style: ActivityStyle?, isNovelty: Bool, preferredTag: PersonTag?) {
self.text = text
self.style = style
self.isNovelty = isNovelty
self.preferredTag = preferredTag
}
}
+30 -30
View File
@@ -97,90 +97,90 @@ struct QuizQuestion: Identifiable {
QuizQuestion( QuizQuestion(
id: "O1", id: "O1",
dimension: .openness, dimension: .openness,
situation: "Ein Freund schlägt spontan eine Aktivität vor, die du noch nie gemacht hast.", situation: String(localized: "Ein Freund schlägt spontan eine Aktivität vor, die du noch nie gemacht hast."),
optionA: "Du sagst sofort zu neue Erfahrungen reizen dich.", optionA: String(localized: "Du sagst sofort zu neue Erfahrungen reizen dich."),
optionB: "Du schlägst lieber etwas vor, das ihr beide gut kennt.", optionB: String(localized: "Du schlägst lieber etwas vor, das ihr beide gut kennt."),
optionAScore: 1 optionAScore: 1
), ),
// Offenheit O2 // Offenheit O2
QuizQuestion( QuizQuestion(
id: "O2", id: "O2",
dimension: .openness, dimension: .openness,
situation: "In deinem Viertel gibt es ein neues Treffen niemand, den du kennst, ist dabei.", situation: String(localized: "In deinem Viertel gibt es ein neues Treffen niemand, den du kennst, ist dabei."),
optionA: "Du gehst einfach hin Neugier auf fremde Menschen treibt dich.", optionA: String(localized: "Du gehst einfach hin Neugier auf fremde Menschen treibt dich."),
optionB: "Du wartest, bis ein Bekannter mitkommt.", optionB: String(localized: "Du wartest, bis ein Bekannter mitkommt."),
optionAScore: 1 optionAScore: 1
), ),
// Verlässlichkeit C1 // Verlässlichkeit C1
QuizQuestion( QuizQuestion(
id: "C1", id: "C1",
dimension: .conscientiousness, dimension: .conscientiousness,
situation: "Du hast einem Freund versprochen zu helfen. Am Morgen bist du müde.", situation: String(localized: "Du hast einem Freund versprochen zu helfen. Am Morgen bist du müde."),
optionA: "Du erscheinst wie abgemacht dein Wort gilt.", optionA: String(localized: "Du erscheinst wie abgemacht dein Wort gilt."),
optionB: "Du fragst kurz nach, ob es sich verschieben lässt.", optionB: String(localized: "Du fragst kurz nach, ob es sich verschieben lässt."),
optionAScore: 1 optionAScore: 1
), ),
// Verlässlichkeit C2 // Verlässlichkeit C2
QuizQuestion( QuizQuestion(
id: "C2", id: "C2",
dimension: .conscientiousness, dimension: .conscientiousness,
situation: "Nächste Woche hat eine Freundin Geburtstag.", situation: String(localized: "Nächste Woche hat eine Freundin Geburtstag."),
optionA: "Du hast es dir sofort notiert und planst etwas Besonderes.", optionA: String(localized: "Du hast es dir sofort notiert und planst etwas Besonderes."),
optionB: "Du reagierst spontan, wenn der Tag kommt.", optionB: String(localized: "Du reagierst spontan, wenn der Tag kommt."),
optionAScore: 1 optionAScore: 1
), ),
// Geselligkeit E1 // Geselligkeit E1
QuizQuestion( QuizQuestion(
id: "E1", id: "E1",
dimension: .extraversion, dimension: .extraversion,
situation: "Nach einer anstrengenden Woche hast du einen freien Samstag.", situation: String(localized: "Nach einer anstrengenden Woche hast du einen freien Samstag."),
optionA: "Du rufst spontan Freunde an und organisierst ein Treffen.", optionA: String(localized: "Du rufst spontan Freunde an und organisierst ein Treffen."),
optionB: "Du genießt die Ruhe und tankst alleine auf.", optionB: String(localized: "Du genießt die Ruhe und tankst alleine auf."),
optionAScore: 1 optionAScore: 1
), ),
// Geselligkeit E2 // Geselligkeit E2
QuizQuestion( QuizQuestion(
id: "E2", id: "E2",
dimension: .extraversion, dimension: .extraversion,
situation: "Auf einer Nachbarschaftsparty kennst du kaum jemanden.", situation: String(localized: "Auf einer Nachbarschaftsparty kennst du kaum jemanden."),
optionA: "Du gehst aktiv auf Fremde zu und fängst Gespräche an.", optionA: String(localized: "Du gehst aktiv auf Fremde zu und fängst Gespräche an."),
optionB: "Du wartest, bis jemand dich anspricht.", optionB: String(localized: "Du wartest, bis jemand dich anspricht."),
optionAScore: 1 optionAScore: 1
), ),
// Verträglichkeit A1 // Verträglichkeit A1
QuizQuestion( QuizQuestion(
id: "A1", id: "A1",
dimension: .agreeableness, dimension: .agreeableness,
situation: "Ein Nachbar bittet um einen Gefallen, der dir gerade ungelegen kommt.", situation: String(localized: "Ein Nachbar bittet um einen Gefallen, der dir gerade ungelegen kommt."),
optionA: "Du hilfst trotzdem anderen etwas Gutes tun liegt dir.", optionA: String(localized: "Du hilfst trotzdem anderen etwas Gutes tun liegt dir."),
optionB: "Du erklärst ehrlich, dass es dir gerade nicht passt.", optionB: String(localized: "Du erklärst ehrlich, dass es dir gerade nicht passt."),
optionAScore: 1 optionAScore: 1
), ),
// Verträglichkeit A2 // Verträglichkeit A2
QuizQuestion( QuizQuestion(
id: "A2", id: "A2",
dimension: .agreeableness, dimension: .agreeableness,
situation: "Ein Freund erzählt von einem Plan, den du für einen Fehler hältst.", situation: String(localized: "Ein Freund erzählt von einem Plan, den du für einen Fehler hältst."),
optionA: "Du unterstützt ihn und behältst deine Bedenken für dich.", optionA: String(localized: "Du unterstützt ihn und behältst deine Bedenken für dich."),
optionB: "Du sprichst deine Sorgen an, auch wenn es Spannung erzeugt.", optionB: String(localized: "Du sprichst deine Sorgen an, auch wenn es Spannung erzeugt."),
optionAScore: 1 optionAScore: 1
), ),
// Ausgeglichenheit N1 (invertiert: A = stabil = hohes N-inverted) // Ausgeglichenheit N1 (invertiert: A = stabil = hohes N-inverted)
QuizQuestion( QuizQuestion(
id: "N1", id: "N1",
dimension: .neuroticism, dimension: .neuroticism,
situation: "Von einem guten Freund hast du zwei Wochen nichts gehört.", situation: String(localized: "Von einem guten Freund hast du zwei Wochen nichts gehört."),
optionA: "Du meldest dich locker er ist wahrscheinlich einfach beschäftigt.", optionA: String(localized: "Du meldest dich locker er ist wahrscheinlich einfach beschäftigt."),
optionB: "Du fragst dich, ob du etwas falsch gemacht hast, und das lässt dich nicht los.", optionB: String(localized: "Du fragst dich, ob du etwas falsch gemacht hast, und das lässt dich nicht los."),
optionAScore: 0 // A = emotional stabil = 0 Neurotizismus-Punkte optionAScore: 0 // A = emotional stabil = 0 Neurotizismus-Punkte
), ),
// Ausgeglichenheit N2 (invertiert) // Ausgeglichenheit N2 (invertiert)
QuizQuestion( QuizQuestion(
id: "N2", id: "N2",
dimension: .neuroticism, dimension: .neuroticism,
situation: "Verabredungen mit Freunden fallen kurzfristig aus.", situation: String(localized: "Verabredungen mit Freunden fallen kurzfristig aus."),
optionA: "Du zuckst die Schultern und findest schnell etwas anderes.", optionA: String(localized: "Du zuckst die Schultern und findest schnell etwas anderes."),
optionB: "Du bist enttäuscht und brauchst Zeit, um dich neu zu sortieren.", optionB: String(localized: "Du bist enttäuscht und brauchst Zeit, um dich neu zu sortieren."),
optionAScore: 0 // A = emotional stabil = 0 Neurotizismus-Punkte optionAScore: 0 // A = emotional stabil = 0 Neurotizismus-Punkte
), ),
] ]
+4 -4
View File
@@ -125,7 +125,7 @@ struct QuizIntroScreen: View {
Button(action: onSkip) { Button(action: onSkip) {
Text("Überspringen") Text("Überspringen")
.font(.subheadline) .font(.subheadline.weight(.medium))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.accessibilityLabel("Quiz überspringen") .accessibilityLabel("Quiz überspringen")
@@ -222,7 +222,7 @@ private struct GenderSelectionScreen: View {
Button(action: onSkip) { Button(action: onSkip) {
Text("Überspringen") Text("Überspringen")
.font(.subheadline) .font(.subheadline.weight(.medium))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.accessibilityLabel("Quiz überspringen") .accessibilityLabel("Quiz überspringen")
@@ -273,8 +273,8 @@ private struct QuizQuestionsScreen: View {
Button(action: skipCurrentQuestion) { Button(action: skipCurrentQuestion) {
Text("Überspringen") Text("Überspringen")
.font(NahbarInsightStyle.captionFont) .font(.subheadline.weight(.medium))
.foregroundStyle(.tertiary) .foregroundStyle(.secondary)
} }
.padding(.bottom, 32) .padding(.bottom, 32)
} }
+430 -418
View File
@@ -21,20 +21,13 @@ struct SettingsView: View {
@AppStorage("aiModel") private var aiModel: String = AIConfig.fallback.model @AppStorage("aiModel") private var aiModel: String = AIConfig.fallback.model
@StateObject private var store = StoreManager.shared @StateObject private var store = StoreManager.shared
@StateObject private var personalityStore = PersonalityStore.shared @StateObject private var personalityStore = PersonalityStore.shared
@Environment(\.modelContext) private var modelContext
@State private var showingPINSetup = false @State private var showingPINSetup = false
@State private var showingPINDisable = false @State private var showingPINDisable = false
@State private var showPaywall = false @State private var showPaywall = false
@State private var showingResetConfirmation = false
@State private var showingQuiz = false @State private var showingQuiz = false
@AppStorage("hasSkippedPersonalityQuiz") private var hasSkippedPersonalityQuiz = false @AppStorage("hasSkippedPersonalityQuiz") private var hasSkippedPersonalityQuiz = false
// Onboarding-Flags zum Zurücksetzen
@AppStorage("nahbarOnboardingDone") private var nahbarOnboardingDone = false
@AppStorage("callWindowOnboardingDone") private var callWindowOnboardingDone = false
@AppStorage("photoRepairPassDone") private var photoRepairPassDone = false
@AppStorage("callSuggestionDate") private var callSuggestionDate = ""
private var biometricLabel: String { private var biometricLabel: String {
switch appLockManager.biometricType { switch appLockManager.biometricType {
case .faceID: return String(localized: "Face ID aktiviert") case .faceID: return String(localized: "Face ID aktiviert")
@@ -54,71 +47,123 @@ struct SettingsView: View {
// Header // Header
Text("Einstellungen") Text("Einstellungen")
.font(.system(size: 34, weight: .light, design: theme.displayDesign)) .font(.system(size: 32, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 12) .padding(.top, 12)
// Abonnement (oben) abonnementSection
darstellungSection
funktionenSection
systemSection
// Entwickler (versteckt, nur für Entwickler)
NavigationLink(destination: DeveloperSettingsView()) {
Text("Entwickler")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.padding(.horizontal, 20)
}
.padding(.bottom, 40)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationBarHidden(true)
}
.sheet(isPresented: $showingPINSetup, onDismiss: { appLockManager.refreshBiometricType() }) {
AppLockSetupView(isDisabling: false).environmentObject(appLockManager)
}
.sheet(isPresented: $showingPINDisable) {
AppLockSetupView(isDisabling: true).environmentObject(appLockManager)
}
.sheet(isPresented: $showPaywall) {
PaywallView(targeting: store.isPro ? .max : .pro)
}
.sheet(isPresented: $showingQuiz) {
PersonalityQuizView { _ in }
}
}
// MARK: - 1 · Abonnement (hervorgehoben)
private var abonnementSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Abonnement", icon: "star.fill") SectionHeader(title: "Abonnement", icon: "star.fill")
.padding(.horizontal, 20) .padding(.horizontal, 20)
if store.isMax { if store.isMax {
HStack { HStack(spacing: 14) {
VStack(alignment: .leading, spacing: 2) { Image(systemName: "checkmark.seal.fill")
.font(.system(size: 26))
.foregroundStyle(theme.accent)
VStack(alignment: .leading, spacing: 3) {
Text("Max aktiv") Text("Max aktiv")
.font(.system(size: 15)) .font(.system(size: 15, weight: .semibold))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text("Alle Features freigeschaltet") Text("Alle Features freigeschaltet")
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundStyle(theme.contentTertiary) .foregroundStyle(theme.contentTertiary)
} }
Spacer() Spacer()
Image(systemName: "checkmark.seal.fill")
.foregroundStyle(theme.accent)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 16)
.background(theme.surfaceCard) .background(
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) RoundedRectangle(cornerRadius: theme.radiusCard)
.fill(theme.accent.opacity(0.07))
.overlay(
RoundedRectangle(cornerRadius: theme.radiusCard)
.stroke(theme.accent.opacity(0.22), lineWidth: 1)
)
)
.padding(.horizontal, 20) .padding(.horizontal, 20)
} else { } else {
Button { showPaywall = true } label: { Button { showPaywall = true } label: {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 4) {
Text("Pro oder Max-Abo") Text(store.isPro ? "Auf Max upgraden" : "nahbar Pro oder Max")
.font(.system(size: 15, weight: .medium)) .font(.system(size: 15, weight: .semibold))
.foregroundStyle(theme.accent) .foregroundStyle(theme.accent)
Text(store.isPro Text(store.isPro
? "Auf Max upgraden KI-Analyse freischalten" ? "KI Insights freischalten"
: "KI-Analyse, Themes & mehr") : "KI Insights, Themes & mehr")
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundStyle(theme.contentTertiary) .foregroundStyle(theme.contentTertiary)
} }
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium)) .font(.system(size: 12, weight: .medium))
.foregroundStyle(theme.contentTertiary) .foregroundStyle(theme.accent.opacity(0.5))
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 16)
.background(theme.surfaceCard) .background(
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) RoundedRectangle(cornerRadius: theme.radiusCard)
.fill(theme.accent.opacity(0.07))
.overlay(
RoundedRectangle(cornerRadius: theme.radiusCard)
.stroke(theme.accent.opacity(0.22), lineWidth: 1)
)
)
.padding(.horizontal, 20) .padding(.horizontal, 20)
} }
} }
} }
.sheet(isPresented: $showPaywall) { PaywallView(targeting: store.isPro ? .max : .pro) } }
// Theme picker // MARK: - 2 · Darstellung & Profil
private var darstellungSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Atmosphäre", icon: "paintpalette") SectionHeader(title: "Darstellung & Profil", icon: "paintpalette")
.padding(.horizontal, 20) .padding(.horizontal, 20)
VStack(spacing: 0) {
// Theme
NavigationLink(destination: ThemePickerView()) { NavigationLink(destination: ThemePickerView()) {
HStack(spacing: 14) { HStack(spacing: 14) {
// Swatch
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(NahbarTheme.theme(for: activeThemeID).backgroundPrimary) .fill(NahbarTheme.theme(for: activeThemeID).backgroundPrimary)
@@ -147,18 +192,235 @@ struct SettingsView: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
}
RowDivider()
// Persönlichkeit
if let profile = personalityStore.profile, profile.isComplete {
let days = PersonalityEngine.suggestedNudgeInterval(for: profile)
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Persönlichkeit")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Text("Nudge alle \(days) Tage · Quiz abgeschlossen")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
Spacer()
Button {
personalityStore.reset()
hasSkippedPersonalityQuiz = false
} label: {
Text("Zurücksetzen")
.font(.system(size: 13))
.foregroundStyle(theme.contentTertiary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
} else {
Button {
hasSkippedPersonalityQuiz = false
showingQuiz = true
} label: {
HStack {
Text("Persönlichkeitsquiz")
.font(.system(size: 15))
.foregroundStyle(theme.accent)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
}
.background(theme.surfaceCard) .background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20) .padding(.horizontal, 20)
} }
} }
// App-Schutz // MARK: - 3 · Funktionen
private var funktionenSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "App-Schutz", icon: "lock") SectionHeader(title: "Funktionen", icon: "slider.horizontal.3")
.padding(.horizontal, 20) .padding(.horizontal, 20)
VStack(spacing: 0) { VStack(spacing: 0) {
// Gesprächszeit
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Gesprächszeit")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Text("Tägliche Erinnerung für Anrufe")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
Spacer()
Toggle("", isOn: $callWindowManager.isEnabled)
.tint(theme.accent)
.onChange(of: callWindowManager.isEnabled) { _, enabled in
if enabled { callWindowManager.scheduleNotifications() }
else { callWindowManager.cancelNotifications() }
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if callWindowManager.isEnabled {
RowDivider()
NavigationLink {
CallWindowSetupView(manager: callWindowManager, isOnboarding: false, onDone: {})
} label: {
HStack {
Text("Zeitfenster")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Text(callWindowManager.windowDescription)
.font(.system(size: 14))
.foregroundStyle(theme.contentTertiary)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
RowDivider()
// Kalender
HStack {
Text("Termine & Geburtstage")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Picker("", selection: $daysAhead) {
Text("3 Tage").tag(3)
Text("1 Woche").tag(7)
Text("2 Wochen").tag(14)
Text("1 Monat").tag(30)
}
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
if settingsCalendars.count > 1 {
RowDivider()
HStack {
Text("Kalender")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Picker("", selection: $defaultCalendarID) {
ForEach(settingsCalendars, id: \.calendarIdentifier) { cal in
HStack {
Image(systemName: "circle.fill")
.foregroundStyle(Color(cal.cgColor))
Text(cal.title)
}
.tag(cal.calendarIdentifier)
}
}
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
RowDivider()
// Treffen
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Nachwirkungs-Erinnerung")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Text("Push nach dem Treffen")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
Spacer()
Toggle("", isOn: $aftermathNotificationsEnabled)
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if aftermathNotificationsEnabled {
RowDivider()
HStack {
Text("Verzögerung")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Picker("", selection: $aftermathDelayRaw) {
ForEach(AftermathDelayOption.allCases, id: \.rawValue) { opt in
Text(opt.label).tag(opt.rawValue)
}
}
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
RowDivider()
// KI Modell
HStack {
HStack(spacing: 6) {
Text("KI Modell")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
MaxBadge()
}
Spacer()
TextField(AIConfig.fallback.model, text: $aiModel)
.font(.system(size: 14))
.foregroundStyle(theme.contentSecondary)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.multilineTextAlignment(.trailing)
.frame(maxWidth: 180)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
.task {
guard settingsCalendars.isEmpty else { return }
guard CalendarManager.shared.isAuthorized else { return }
let calendars = await CalendarManager.shared.availableCalendars()
settingsCalendars = calendars
if defaultCalendarID.isEmpty || !calendars.map(\.calendarIdentifier).contains(defaultCalendarID) {
defaultCalendarID = CalendarManager.shared.defaultCalendarIdentifier ?? ""
}
}
}
}
// MARK: - 4 · System
private var systemSection: some View {
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "System", icon: "gear")
.padding(.horizontal, 20)
VStack(spacing: 0) {
// App-Schutz
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Code-Schutz") Text("Code-Schutz")
@@ -199,209 +461,18 @@ struct SettingsView: View {
.padding(.vertical, 12) .padding(.vertical, 12)
} }
} }
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
.sheet(isPresented: $showingPINSetup, onDismiss: { appLockManager.refreshBiometricType() }) {
AppLockSetupView(isDisabling: false)
.environmentObject(appLockManager)
}
.sheet(isPresented: $showingPINDisable) {
AppLockSetupView(isDisabling: true)
.environmentObject(appLockManager)
}
// Gesprächszeit
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Gesprächszeit", icon: "phone.arrow.up.right")
.padding(.horizontal, 20)
VStack(spacing: 0) {
HStack {
Text("Aktiv")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Toggle("", isOn: $callWindowManager.isEnabled)
.tint(theme.accent)
.onChange(of: callWindowManager.isEnabled) { _, enabled in
if enabled {
callWindowManager.scheduleNotifications()
} else {
callWindowManager.cancelNotifications()
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if callWindowManager.isEnabled {
RowDivider() RowDivider()
NavigationLink {
CallWindowSetupView(
manager: callWindowManager,
isOnboarding: false,
onDone: {}
)
} label: {
HStack {
Text("Zeitfenster")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Text(callWindowManager.windowDescription)
.font(.system(size: 14))
.foregroundStyle(theme.contentTertiary)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
// Kalender-Einstellungen
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Kalender-Einstellungen", icon: "calendar")
.padding(.horizontal, 20)
VStack(spacing: 0) {
HStack {
Text("Vorschau Geburtstage & Termine")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Picker("", selection: $daysAhead) {
Text("3 Tage").tag(3)
Text("1 Woche").tag(7)
Text("2 Wochen").tag(14)
Text("1 Monat").tag(30)
}
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
if settingsCalendars.count > 1 {
RowDivider()
HStack {
Text("Kalender")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Picker("", selection: $defaultCalendarID) {
ForEach(settingsCalendars, id: \.calendarIdentifier) { cal in
HStack {
Image(systemName: "circle.fill")
.foregroundStyle(Color(cal.cgColor))
Text(cal.title)
}
.tag(cal.calendarIdentifier)
}
}
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
.task {
guard settingsCalendars.isEmpty else { return }
let calendars = await CalendarManager.shared.availableCalendars()
settingsCalendars = calendars
if defaultCalendarID.isEmpty || !calendars.map(\.calendarIdentifier).contains(defaultCalendarID) {
defaultCalendarID = CalendarManager.shared.defaultCalendarIdentifier ?? ""
}
}
}
// Treffen & Bewertungen
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Treffen", icon: "star.fill")
.padding(.horizontal, 20)
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Nachwirkungs-Erinnerung")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Text("Push-Benachrichtigung nach dem Besuch")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
Spacer()
Toggle("", isOn: $aftermathNotificationsEnabled)
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if aftermathNotificationsEnabled {
RowDivider()
HStack {
Text("Verzögerung")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Spacer()
Picker("", selection: $aftermathDelayRaw) {
ForEach(AftermathDelayOption.allCases, id: \.rawValue) { opt in
Text(opt.label).tag(opt.rawValue)
}
}
.tint(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
// KI-Einstellungen
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
SectionHeader(title: "KI-Analyse", icon: "sparkles")
MaxBadge()
}
.padding(.horizontal, 20)
VStack(spacing: 0) {
settingsTextField(label: "Modell", value: $aiModel, placeholder: AIConfig.fallback.model)
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
// iCloud // iCloud
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "iCloud", icon: "icloud")
.padding(.horizontal, 20)
VStack(spacing: 0) {
// Toggle
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("iCloud-Sync") Text("iCloud-Sync")
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text(icloudSyncEnabled Text(icloudSyncEnabled
? "Daten werden geräteübergreifend synchronisiert" ? "Geräteübergreifend synchronisiert"
: "Daten werden nur lokal gespeichert") : "Nur lokal gespeichert")
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundStyle(theme.contentTertiary) .foregroundStyle(theme.contentTertiary)
} }
@@ -418,7 +489,6 @@ struct SettingsView: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
// Live-Sync-Status (nur wenn aktiviert)
if icloudSyncEnabled { if icloudSyncEnabled {
RowDivider() RowDivider()
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -435,7 +505,6 @@ struct SettingsView: View {
.padding(.vertical, 10) .padding(.vertical, 10)
} }
// Neustart-Banner wenn Toggle verändert wurde
if icloudToggleChanged { if icloudToggleChanged {
RowDivider() RowDivider()
HStack(spacing: 10) { HStack(spacing: 10) {
@@ -446,15 +515,20 @@ struct SettingsView: View {
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundStyle(theme.contentSecondary) .foregroundStyle(theme.contentSecondary)
Spacer() Spacer()
Button("Jetzt") { Button("Jetzt") { exit(0) }
exit(0)
}
.font(.system(size: 12, weight: .semibold)) .font(.system(size: 12, weight: .semibold))
.foregroundStyle(theme.accent) .foregroundStyle(theme.accent)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 10) .padding(.vertical, 10)
} }
RowDivider()
// Über nahbar
SettingsInfoRow(label: "Version", value: "1.0 Draft")
RowDivider()
SettingsInfoRow(label: "Datenschutz", value: "Deine Daten verlassen nicht dein Gerät")
} }
.background(theme.surfaceCard) .background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
@@ -463,192 +537,6 @@ struct SettingsView: View {
.animation(.easeInOut(duration: 0.2), value: icloudToggleChanged) .animation(.easeInOut(duration: 0.2), value: icloudToggleChanged)
.animation(.easeInOut(duration: 0.2), value: cloudSyncMonitor.state == .syncing) .animation(.easeInOut(duration: 0.2), value: cloudSyncMonitor.state == .syncing)
} }
// Persönlichkeit
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Persönlichkeit", icon: "brain")
.padding(.horizontal, 20)
VStack(spacing: 0) {
if let profile = personalityStore.profile, profile.isComplete {
// Empfohlenes Intervall
let days = PersonalityEngine.suggestedNudgeInterval(for: profile)
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text("Empfohlenes Nudge-Intervall")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Text("Alle \(days) Tage basierend auf deinem Profil")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
Spacer()
RecommendedBadge(variant: .small)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
RowDivider()
// Quiz zurücksetzen
Button {
personalityStore.reset()
hasSkippedPersonalityQuiz = false
} label: {
HStack {
Text("Quiz zurücksetzen")
.font(.system(size: 15))
.foregroundStyle(theme.contentSecondary)
Spacer()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
} else {
Button {
hasSkippedPersonalityQuiz = false
showingQuiz = true
} label: {
HStack {
Text("Persönlichkeitsquiz starten")
.font(.system(size: 15))
.foregroundStyle(theme.accent)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(theme.contentTertiary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
.sheet(isPresented: $showingQuiz) {
PersonalityQuizView { _ in }
}
// Diagnose / Entwickler-Tools
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Diagnose", icon: "list.bullet.rectangle")
.padding(.horizontal, 20)
// App zurücksetzen
Button {
showingResetConfirmation = true
} label: {
HStack(spacing: 14) {
Image(systemName: "arrow.counterclockwise")
.font(.system(size: 15))
.foregroundStyle(.red)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text("App zurücksetzen")
.font(.system(size: 15))
.foregroundStyle(.red)
Text("Onboarding, Profil und alle Daten löschen")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
NavigationLink(destination: LogExportView()) {
HStack(spacing: 14) {
Image(systemName: "doc.text")
.font(.system(size: 15))
.foregroundStyle(theme.contentTertiary)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text("Entwickler-Log")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Text("\(AppEventLog.shared.entries.count) Einträge Export als Textdatei")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
}
// About
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Über nahbar", icon: "info.circle")
.padding(.horizontal, 20)
VStack(spacing: 0) {
SettingsInfoRow(label: "Version", value: "1.0 Draft")
RowDivider()
SettingsInfoRow(label: "Datenschutz", value: "Deine Daten verlassen nicht dein Gerät")
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
}
.padding(.bottom, 40)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationBarHidden(true)
}
.confirmationDialog(
"App wirklich zurücksetzen?",
isPresented: $showingResetConfirmation,
titleVisibility: .visible
) {
Button("Alles löschen und Onboarding starten", role: .destructive) {
resetApp()
}
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Alle Personen, Momente, Besuche und dein Profil werden unwiderruflich gelöscht. Die App startet neu.")
}
}
// MARK: - App Reset (Entwickler-Tool)
private func resetApp() {
// 1. SwiftData: alle Objekte löschen
try? modelContext.delete(model: Person.self)
try? modelContext.delete(model: Moment.self)
try? modelContext.delete(model: LogEntry.self)
try? modelContext.delete(model: Visit.self)
try? modelContext.delete(model: Rating.self)
try? modelContext.delete(model: HealthSnapshot.self)
try? modelContext.delete(model: PersonPhoto.self)
// 2. Profil und Kontakte löschen
UserProfileStore.shared.reset()
ContactStore.shared.reset()
// 3. Onboarding- und Migrations-Flags zurücksetzen
nahbarOnboardingDone = false
callWindowOnboardingDone = false
photoRepairPassDone = false
callSuggestionDate = ""
UserDefaults.standard.removeObject(forKey: "visitMigrationPassDone")
UserDefaults.standard.removeObject(forKey: "nextStepMigrationPassDone")
// 4. App neu starten damit alle States frisch initialisiert werden
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { exit(0) }
} }
} }
@@ -673,6 +561,121 @@ extension SettingsView {
} }
} }
// MARK: - Developer Settings View
private struct DeveloperSettingsView: View {
@Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) private var modelContext
@Environment(TourCoordinator.self) private var tourCoordinator
@StateObject private var personalityStore = PersonalityStore.shared
@AppStorage("nahbarOnboardingDone") private var nahbarOnboardingDone = false
@AppStorage("callWindowOnboardingDone") private var callWindowOnboardingDone = false
@AppStorage("photoRepairPassDone") private var photoRepairPassDone = false
@AppStorage("callSuggestionDate") private var callSuggestionDate = ""
@AppStorage("hasSkippedPersonalityQuiz") private var hasSkippedPersonalityQuiz = false
@State private var showingResetConfirmation = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(spacing: 0) {
// App zurücksetzen
Button { showingResetConfirmation = true } label: {
HStack(spacing: 14) {
Image(systemName: "arrow.counterclockwise")
.font(.system(size: 15))
.foregroundStyle(.red)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text("App zurücksetzen")
.font(.system(size: 15))
.foregroundStyle(.red)
Text("Onboarding, Profil und alle Daten löschen")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
RowDivider()
// Entwickler-Log
NavigationLink(destination: LogExportView()) {
HStack(spacing: 14) {
Image(systemName: "doc.text")
.font(.system(size: 15))
.foregroundStyle(theme.contentTertiary)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text("Entwickler-Log")
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Text("\(AppEventLog.shared.entries.count) Einträge Export als Textdatei")
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(theme.contentTertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
.padding(.top, 16)
.padding(.bottom, 40)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.navigationTitle("Entwickler")
.navigationBarTitleDisplayMode(.inline)
.themedNavBar()
.confirmationDialog(
"App wirklich zurücksetzen?",
isPresented: $showingResetConfirmation,
titleVisibility: .visible
) {
Button("Alles löschen und Onboarding starten", role: .destructive) { resetApp() }
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Alle Personen, Momente, Besuche und dein Profil werden unwiderruflich gelöscht. Die App startet neu.")
}
}
private func resetApp() {
try? modelContext.delete(model: Person.self)
try? modelContext.delete(model: Moment.self)
try? modelContext.delete(model: LogEntry.self)
try? modelContext.delete(model: Visit.self)
try? modelContext.delete(model: Rating.self)
try? modelContext.delete(model: HealthSnapshot.self)
try? modelContext.delete(model: PersonPhoto.self)
UserProfileStore.shared.reset()
ContactStore.shared.reset()
nahbarOnboardingDone = false
callWindowOnboardingDone = false
photoRepairPassDone = false
callSuggestionDate = ""
hasSkippedPersonalityQuiz = false
UserDefaults.standard.removeObject(forKey: "visitMigrationPassDone")
UserDefaults.standard.removeObject(forKey: "nextStepMigrationPassDone")
tourCoordinator.resetSeenTours()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { exit(0) }
}
}
// MARK: - Theme Option Row // MARK: - Theme Option Row
struct ThemeOptionRow: View { struct ThemeOptionRow: View {
@@ -803,11 +806,20 @@ enum AppLanguage: String, CaseIterable {
} }
} }
var conversationInstruction: String {
switch self {
case .german:
return "Du bereitest mich auf ein bevorstehendes Treffen mit dieser Person vor. Halte dich STRIKT an die vorhandenen Momente und Log-Einträge. Erfinde KEINE Details, Erlebnisse oder Themen die nicht explizit in den Daten stehen. Gib mir sehr knappe Vorschläge maximal 8 Wörter pro Punkt, nur Stichwörter oder kurze Fragen. Antworte in exakt diesem Format:\n\nTHEMEN: [2-3 Stichworte oder kurze Fragen aus den echten Daten, kommasepariert]\nGESPRAECHSRETTER: [2-3 kurze Impulse, je max. 8 Wörter, kommasepariert]\nTIEFE: [ein konkreter Tipp, max. 12 Wörter]"
case .english:
return "You are preparing me for an upcoming meeting with this person. Stick STRICTLY to the available moments and log entries. Do NOT invent details, experiences or topics that are not explicitly in the data. Give very concise suggestions maximum 8 words per point, keywords or short questions only. Respond in exactly this format:\n\nTHEMEN: [2-3 keywords or short questions from the actual data, comma-separated]\nGESPRAECHSRETTER: [2-3 short impulses, max. 8 words each, comma-separated]\nTIEFE: [one concrete tip, max. 12 words]"
}
}
var momentsLabel: String { self == .english ? "Moments" : "Momente" } var momentsLabel: String { self == .english ? "Moments" : "Momente" }
var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" } var logEntriesLabel: String { self == .english ? "Log entries" : "Log-Einträge" }
var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" } var birthYearLabel: String { self == .english ? "Birth year" : "Geburtsjahr" }
var interestsLabel: String { self == .english ? "Interests" : "Interessen" } var interestsLabel: String { self == .english ? "Interests" : "Interessen" }
var culturalBackgroundLabel: String { self == .english ? "Cultural background": "Kultureller Hintergrund" } var culturalBackgroundLabel: String { self == .english ? "Cultural background" : "Kultureller Hintergrund" }
/// Leitet die KI-Antwortsprache aus der iOS-Systemsprache ab. /// Leitet die KI-Antwortsprache aus der iOS-Systemsprache ab.
/// Unterstützte Sprachen: de, en alle anderen fallen auf .german zurück. /// Unterstützte Sprachen: de, en alle anderen fallen auf .german zurück.
+239 -4
View File
@@ -1,4 +1,217 @@
import SwiftUI import SwiftUI
import SwiftData
// MARK: - Interest Tag Helper
/// Rein-statische Hilfsfunktionen für kommaseparierte Interessen-Tags.
enum InterestTagHelper {
/// Zerlegt einen kommaseparierten String in bereinigte, nicht-leere Tags.
static func parse(_ text: String) -> [String] {
text.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
/// Sortiert Tags alphabetisch und verbindet sie zu einem kommaseparierten String.
static func join(_ tags: [String]) -> String {
tags.sorted(by: { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending })
.joined(separator: ", ")
}
/// Fügt einen Tag hinzu (ignoriert Duplikate, Groß-/Kleinschreibung irrelevant).
/// Ergebnis ist alphabetisch sortiert.
static func addTag(_ tag: String, to text: String) -> String {
let trimmed = tag.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return text }
var current = parse(text)
let alreadyExists = current.contains { $0.localizedCaseInsensitiveCompare(trimmed) == .orderedSame }
guard !alreadyExists else { return text }
current.append(trimmed)
return join(current)
}
/// Entfernt einen Tag aus dem kommaseparierten String.
static func removeTag(_ tag: String, from text: String) -> String {
let updated = parse(text).filter { $0.localizedCaseInsensitiveCompare(tag) != .orderedSame }
return join(updated)
}
/// Sammelt alle vorhandenen Tags aus Personen-Interessen, User-Likes und Dislikes.
/// Dedupliziert und alphabetisch sortiert.
static func allSuggestions(from people: [Person], likes: String, dislikes: String) -> [String] {
let personTags = people.flatMap { parse($0.interests ?? "") }
let userTags = parse(likes) + parse(dislikes)
let combined = Set(personTags + userTags)
return combined.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
}
// MARK: - Interest Chip Row (Display-only)
/// Zeigt kommaseparierte Interessen-Tags als horizontale Chip-Reihe an.
/// Verwendung in PersonDetailView und IchView (Display-Modus).
struct InterestChipRow: View {
@Environment(\.nahbarTheme) private var theme
let label: String
let text: String
let color: Color
private var tags: [String] { InterestTagHelper.parse(text) }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(label)
.font(.system(size: 13))
.foregroundStyle(theme.contentTertiary)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(tags, id: \.self) { tag in
Text(tag)
.font(.system(size: 13))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
// MARK: - Interest Tag Editor
/// Bearbeitbares Tag-Eingabefeld mit Autocomplete.
/// Bestehendes Tags werden als entfernbare farbige Chips gezeigt.
/// Beim Tippen erscheint eine horizontale Chip-Reihe passender Vorschläge.
struct InterestTagEditor: View {
@Environment(\.nahbarTheme) private var theme
let label: String
@Binding var text: String
let suggestions: [String]
let tagColor: Color
@State private var inputText = ""
@FocusState private var inputFocused: Bool
private var tags: [String] { InterestTagHelper.parse(text) }
private var filteredSuggestions: [String] {
let q = inputText.trimmingCharacters(in: .whitespaces)
guard !q.isEmpty else { return [] }
return suggestions.filter { suggestion in
suggestion.localizedCaseInsensitiveContains(q) &&
!tags.contains { $0.localizedCaseInsensitiveCompare(suggestion) == .orderedSame }
}
}
private func addTag(_ tag: String) {
text = InterestTagHelper.addTag(tag, to: text)
inputText = ""
}
private func removeTag(_ tag: String) {
text = InterestTagHelper.removeTag(tag, from: text)
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
// Zeile 1: bestehende Tags + ggf. Platzhalter
HStack(alignment: .top, spacing: 12) {
Text(label)
.font(.system(size: 15))
.foregroundStyle(theme.contentTertiary)
.frame(width: 80, alignment: .leading)
.padding(.top, tags.isEmpty ? 0 : 2)
if tags.isEmpty {
Text("noch keine")
.font(.system(size: 15))
.foregroundStyle(theme.contentTertiary.opacity(0.45))
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture { inputFocused = true }
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(tags, id: \.self) { tag in
HStack(spacing: 3) {
Text(tag)
.font(.system(size: 13))
Button {
removeTag(tag)
} label: {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .bold))
}
}
.foregroundStyle(tagColor)
.padding(.leading, 10)
.padding(.trailing, 7)
.padding(.vertical, 5)
.background(tagColor.opacity(0.12))
.clipShape(Capsule())
}
}
}
}
}
// Zeile 2: Texteingabe
HStack(spacing: 12) {
Spacer().frame(width: 80)
TextField("Tag hinzufügen…", text: $inputText)
.font(.system(size: 14))
.foregroundStyle(theme.contentPrimary)
.tint(theme.accent)
.focused($inputFocused)
.submitLabel(.done)
.onSubmit {
addTag(inputText)
}
.onChange(of: inputText) { _, new in
// Komma-Eingabe als Trennzeichen
if new.hasSuffix(",") {
let candidate = String(new.dropLast())
addTag(candidate)
}
}
}
// Zeile 3: Vorschlags-Chips (nur während Eingabe)
if !filteredSuggestions.isEmpty {
HStack(spacing: 12) {
Spacer().frame(width: 80)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(filteredSuggestions, id: \.self) { suggestion in
Button { addTag(suggestion) } label: {
Text(suggestion)
.font(.system(size: 12))
.foregroundStyle(theme.contentSecondary)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(theme.backgroundSecondary)
.clipShape(Capsule())
.overlay(
Capsule().stroke(theme.borderSubtle, lineWidth: 0.5)
)
}
}
}
}
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
// MARK: - Person Avatar // MARK: - Person Avatar
@@ -45,12 +258,33 @@ struct TagBadge: View {
} }
} }
// MARK: - Pro Badge
struct ProBadge: View {
@Environment(\.nahbarTheme) var theme
@StateObject private var store = StoreManager.shared
var body: some View {
if !store.isPro {
Text("PRO")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(theme.accent)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(theme.accent.opacity(0.10))
.clipShape(Capsule())
}
}
}
// MARK: - Max Badge // MARK: - Max Badge
struct MaxBadge: View { struct MaxBadge: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@StateObject private var store = StoreManager.shared
var body: some View { var body: some View {
if !store.isMax {
Text("MAX") Text("MAX")
.font(.system(size: 10, weight: .bold)) .font(.system(size: 10, weight: .bold))
.foregroundStyle(theme.accent) .foregroundStyle(theme.accent)
@@ -59,6 +293,7 @@ struct MaxBadge: View {
.background(theme.accent.opacity(0.10)) .background(theme.accent.opacity(0.10))
.clipShape(Capsule()) .clipShape(Capsule())
} }
}
} }
// MARK: - Section Header // MARK: - Section Header
@@ -71,13 +306,13 @@ struct SectionHeader: View {
var body: some View { var body: some View {
HStack(spacing: 6) { HStack(spacing: 6) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 11, weight: .medium)) .font(.system(size: theme.sectionHeaderSize, weight: .medium))
.foregroundStyle(theme.contentTertiary) .foregroundStyle(theme.sectionHeaderColor)
Text(title) Text(title)
.textCase(.uppercase) .textCase(.uppercase)
.font(.system(size: 11, weight: .semibold)) .font(.system(size: theme.sectionHeaderSize, weight: .semibold))
.tracking(0.8) .tracking(0.8)
.foregroundStyle(theme.contentTertiary) .foregroundStyle(theme.sectionHeaderColor)
} }
} }
} }
+49 -2
View File
@@ -2,6 +2,49 @@ import StoreKit
import SwiftUI import SwiftUI
import Combine import Combine
// MARK: - Feature Gate
/// Reiner Value-Type, der den Zugriff auf alle abonnierten Features kapselt.
/// Kann ohne StoreKit in Unit-Tests instanziiert werden.
struct FeatureGate: Equatable {
let isPro: Bool
let isMax: Bool
// MARK: Pro-Features (freigeschaltet durch Pro ODER Max)
var unlimitedContacts: Bool { isPro }
var premiumThemes: Bool { isPro }
var shareExtension: Bool { isPro }
// MARK: Max-Features (nur durch Max)
var aiAnalysis: Bool { isMax }
var giftSuggestions: Bool { isMax }
var conversationTopics: Bool { isMax }
var unlimitedAIQueries: Bool { isMax }
/// Invariante: Max schließt immer alle Pro-Features ein.
var isConsistent: Bool { !isMax || isPro }
/// Erstellt FeatureGate aus dem Ergebnis eines Transaktions-Scans.
/// Max setzt automatisch auch isPro = true.
static func from(foundPro: Bool, foundMax: Bool) -> FeatureGate {
FeatureGate(isPro: foundPro || foundMax, isMax: foundMax)
}
static let free = FeatureGate(isPro: false, isMax: false)
// MARK: - Hilfsfunktionen (Pure Logic, testbar)
/// Darf die KI genutzt werden? Max-Abonnenten immer, sonst nur wenn noch Gratis-Abfragen übrig.
static func canUseAI(isMax: Bool, hasFreeQueriesLeft: Bool) -> Bool {
isMax || hasFreeQueriesLeft
}
/// Soll eine Gratis-Abfrage verbraucht werden? Nur bei Nicht-Max-Abonnenten.
static func shouldConsumeFreeQuery(isMax: Bool) -> Bool {
!isMax
}
}
// MARK: - Subscription Tier // MARK: - Subscription Tier
enum SubscriptionTier: CaseIterable { enum SubscriptionTier: CaseIterable {
@@ -40,6 +83,9 @@ class StoreManager: ObservableObject {
/// Rückwärtskompatibilität für bestehende Aufrufstellen /// Rückwärtskompatibilität für bestehende Aufrufstellen
var product: Product? { proProduct } var product: Product? { proProduct }
/// Aktueller Feature-Zustand als testbarer Value-Type.
var features: FeatureGate { FeatureGate(isPro: isPro, isMax: isMax) }
private var transactionListenerTask: Task<Void, Never>? = nil private var transactionListenerTask: Task<Void, Never>? = nil
private init() { private init() {
@@ -119,8 +165,9 @@ class StoreManager: ObservableObject {
} }
} }
} }
isMax = foundMax let gate = FeatureGate.from(foundPro: foundPro, foundMax: foundMax)
isPro = foundPro || foundMax // Max schließt alle Pro-Features ein isMax = gate.isMax
isPro = gate.isPro
AppGroup.saveProStatus(isPro) AppGroup.saveProStatus(isPro)
} }
+1 -7
View File
@@ -132,13 +132,7 @@ struct ThemePickerView: View {
Spacer() Spacer()
if id.isPremium && !isActive { if id.isPremium && !isActive {
Text("PRO") ProBadge()
.font(.system(size: 10, weight: .bold))
.foregroundStyle(theme.accent)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(theme.accent.opacity(0.10))
.clipShape(Capsule())
} }
if isActive { if isActive {
+158 -2
View File
@@ -5,17 +5,19 @@ import SwiftUI
enum ThemeID: String, CaseIterable, Codable { enum ThemeID: String, CaseIterable, Codable {
case linen, slate, mist, grove, ink, copper case linen, slate, mist, grove, ink, copper
case abyss, dusk, basalt case abyss, dusk, basalt
case chalk, flint
case onyx, ember, birch, vapor
var isPremium: Bool { var isPremium: Bool {
switch self { switch self {
case .linen, .slate, .mist: return false case .linen, .slate, .mist, .chalk, .flint: return false
default: return true default: return true
} }
} }
var isDark: Bool { var isDark: Bool {
switch self { switch self {
case .copper, .abyss, .dusk, .basalt: return true case .copper, .abyss, .dusk, .basalt, .flint, .onyx, .ember: return true
default: return false default: return false
} }
} }
@@ -38,6 +40,12 @@ enum ThemeID: String, CaseIterable, Codable {
case .abyss: return "Abyss" case .abyss: return "Abyss"
case .dusk: return "Dusk" case .dusk: return "Dusk"
case .basalt: return "Basalt" case .basalt: return "Basalt"
case .chalk: return "Chalk"
case .flint: return "Flint"
case .onyx: return "Onyx"
case .ember: return "Ember"
case .birch: return "Birch"
case .vapor: return "Vapor"
} }
} }
@@ -52,6 +60,12 @@ enum ThemeID: String, CaseIterable, Codable {
case .abyss: return "Tief & fokussiert · ND" case .abyss: return "Tief & fokussiert · ND"
case .dusk: return "Warm & augenschonend · ND" case .dusk: return "Warm & augenschonend · ND"
case .basalt: return "Neutral & reizarm · ND" case .basalt: return "Neutral & reizarm · ND"
case .chalk: return "Klar & kontrastreich"
case .flint: return "Scharf & dunkel"
case .onyx: return "Edel & tiefgründig"
case .ember: return "Glühend & intensiv"
case .birch: return "Natürlich & klar"
case .vapor: return "Kühl & präzise"
} }
} }
} }
@@ -76,6 +90,10 @@ struct NahbarTheme {
// Typography // Typography
let displayDesign: Font.Design let displayDesign: Font.Design
// Section Headers
let sectionHeaderSize: CGFloat
let sectionHeaderColor: Color
// Shape // Shape
let radiusCard: CGFloat let radiusCard: CGFloat
let radiusTag: CGFloat let radiusTag: CGFloat
@@ -99,6 +117,8 @@ extension NahbarTheme {
contentTertiary: Color(red: 0.620, green: 0.561, blue: 0.494), contentTertiary: Color(red: 0.620, green: 0.561, blue: 0.494),
accent: Color(red: 0.710, green: 0.443, blue: 0.290), accent: Color(red: 0.710, green: 0.443, blue: 0.290),
displayDesign: .default, displayDesign: .default,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.447, green: 0.384, blue: 0.318),
radiusCard: 16, radiusCard: 16,
radiusTag: 8, radiusTag: 8,
reducedMotion: false reducedMotion: false
@@ -115,6 +135,8 @@ extension NahbarTheme {
contentTertiary: Color(red: 0.557, green: 0.596, blue: 0.643), contentTertiary: Color(red: 0.557, green: 0.596, blue: 0.643),
accent: Color(red: 0.239, green: 0.353, blue: 0.945), accent: Color(red: 0.239, green: 0.353, blue: 0.945),
displayDesign: .default, displayDesign: .default,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.353, green: 0.388, blue: 0.431),
radiusCard: 12, radiusCard: 12,
radiusTag: 6, radiusTag: 6,
reducedMotion: false reducedMotion: false
@@ -131,6 +153,8 @@ extension NahbarTheme {
contentTertiary: Color(red: 0.651, green: 0.651, blue: 0.671), contentTertiary: Color(red: 0.651, green: 0.651, blue: 0.671),
accent: Color(red: 0.569, green: 0.541, blue: 0.745), accent: Color(red: 0.569, green: 0.541, blue: 0.745),
displayDesign: .default, displayDesign: .default,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.455, green: 0.455, blue: 0.475),
radiusCard: 20, radiusCard: 20,
radiusTag: 10, radiusTag: 10,
reducedMotion: true reducedMotion: true
@@ -147,6 +171,8 @@ extension NahbarTheme {
contentTertiary: Color(red: 0.467, green: 0.573, blue: 0.455), contentTertiary: Color(red: 0.467, green: 0.573, blue: 0.455),
accent: Color(red: 0.220, green: 0.412, blue: 0.227), accent: Color(red: 0.220, green: 0.412, blue: 0.227),
displayDesign: .serif, displayDesign: .serif,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.298, green: 0.408, blue: 0.298),
radiusCard: 16, radiusCard: 16,
radiusTag: 8, radiusTag: 8,
reducedMotion: false reducedMotion: false
@@ -163,6 +189,8 @@ extension NahbarTheme {
contentTertiary: Color(red: 0.541, green: 0.541, blue: 0.541), contentTertiary: Color(red: 0.541, green: 0.541, blue: 0.541),
accent: Color(red: 0.749, green: 0.220, blue: 0.165), accent: Color(red: 0.749, green: 0.220, blue: 0.165),
displayDesign: .serif, displayDesign: .serif,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.310, green: 0.310, blue: 0.310),
radiusCard: 8, radiusCard: 8,
radiusTag: 4, radiusTag: 4,
reducedMotion: true reducedMotion: true
@@ -179,6 +207,8 @@ extension NahbarTheme {
contentTertiary: Color(red: 0.502, green: 0.443, blue: 0.373), contentTertiary: Color(red: 0.502, green: 0.443, blue: 0.373),
accent: Color(red: 0.784, green: 0.514, blue: 0.227), accent: Color(red: 0.784, green: 0.514, blue: 0.227),
displayDesign: .default, displayDesign: .default,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.714, green: 0.659, blue: 0.588),
radiusCard: 16, radiusCard: 16,
radiusTag: 8, radiusTag: 8,
reducedMotion: false reducedMotion: false
@@ -196,6 +226,8 @@ extension NahbarTheme {
contentTertiary: Color(red: 0.349, green: 0.408, blue: 0.502), contentTertiary: Color(red: 0.349, green: 0.408, blue: 0.502),
accent: Color(red: 0.357, green: 0.553, blue: 0.937), accent: Color(red: 0.357, green: 0.553, blue: 0.937),
displayDesign: .default, displayDesign: .default,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.541, green: 0.612, blue: 0.710),
radiusCard: 14, radiusCard: 14,
radiusTag: 7, radiusTag: 7,
reducedMotion: true reducedMotion: true
@@ -213,6 +245,8 @@ extension NahbarTheme {
contentTertiary: Color(red: 0.431, green: 0.357, blue: 0.271), contentTertiary: Color(red: 0.431, green: 0.357, blue: 0.271),
accent: Color(red: 0.831, green: 0.573, blue: 0.271), accent: Color(red: 0.831, green: 0.573, blue: 0.271),
displayDesign: .default, displayDesign: .default,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.651, green: 0.565, blue: 0.451),
radiusCard: 18, radiusCard: 18,
radiusTag: 9, radiusTag: 9,
reducedMotion: true reducedMotion: true
@@ -230,11 +264,127 @@ extension NahbarTheme {
contentTertiary: Color(red: 0.365, green: 0.365, blue: 0.365), contentTertiary: Color(red: 0.365, green: 0.365, blue: 0.365),
accent: Color(red: 0.376, green: 0.725, blue: 0.545), accent: Color(red: 0.376, green: 0.725, blue: 0.545),
displayDesign: .monospaced, displayDesign: .monospaced,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.561, green: 0.561, blue: 0.561),
radiusCard: 10, radiusCard: 10,
radiusTag: 5, radiusTag: 5,
reducedMotion: true reducedMotion: true
) )
// MARK: - Chalk (Hochkontrast Hell, kostenlos)
static let chalk = NahbarTheme(
id: .chalk,
backgroundPrimary: Color(red: 0.976, green: 0.976, blue: 0.976),
backgroundSecondary: Color(red: 0.945, green: 0.945, blue: 0.945),
surfaceCard: Color(red: 1.000, green: 1.000, blue: 1.000),
borderSubtle: Color(red: 0.690, green: 0.690, blue: 0.690).opacity(0.50),
contentPrimary: Color(red: 0.059, green: 0.059, blue: 0.059),
contentSecondary: Color(red: 0.267, green: 0.267, blue: 0.267),
contentTertiary: Color(red: 0.482, green: 0.482, blue: 0.482),
accent: Color(red: 0.196, green: 0.392, blue: 0.902),
displayDesign: .default,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.267, green: 0.267, blue: 0.267),
radiusCard: 12,
radiusTag: 6,
reducedMotion: false
)
// MARK: - Flint (Hochkontrast Dunkel, kostenlos)
static let flint = NahbarTheme(
id: .flint,
backgroundPrimary: Color(red: 0.102, green: 0.102, blue: 0.102),
backgroundSecondary: Color(red: 0.137, green: 0.137, blue: 0.137),
surfaceCard: Color(red: 0.173, green: 0.173, blue: 0.173),
borderSubtle: Color(red: 0.376, green: 0.376, blue: 0.376).opacity(0.50),
contentPrimary: Color(red: 0.941, green: 0.941, blue: 0.941),
contentSecondary: Color(red: 0.651, green: 0.651, blue: 0.651),
contentTertiary: Color(red: 0.416, green: 0.416, blue: 0.416),
accent: Color(red: 0.220, green: 0.820, blue: 0.796),
displayDesign: .default,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.651, green: 0.651, blue: 0.651),
radiusCard: 12,
radiusTag: 6,
reducedMotion: false
)
// MARK: - Onyx (Tiefes Schwarz, Gold-Serif, bezahlt)
static let onyx = NahbarTheme(
id: .onyx,
backgroundPrimary: Color(red: 0.063, green: 0.063, blue: 0.063),
backgroundSecondary: Color(red: 0.094, green: 0.094, blue: 0.094),
surfaceCard: Color(red: 0.125, green: 0.125, blue: 0.125),
borderSubtle: Color(red: 0.310, green: 0.255, blue: 0.176).opacity(0.50),
contentPrimary: Color(red: 0.965, green: 0.949, blue: 0.922),
contentSecondary: Color(red: 0.647, green: 0.612, blue: 0.557),
contentTertiary: Color(red: 0.412, green: 0.380, blue: 0.333),
accent: Color(red: 0.835, green: 0.682, blue: 0.286),
displayDesign: .serif,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.647, green: 0.612, blue: 0.557),
radiusCard: 14,
radiusTag: 7,
reducedMotion: false
)
// MARK: - Ember (Warmes Dunkel, Orangerot, bezahlt)
static let ember = NahbarTheme(
id: .ember,
backgroundPrimary: Color(red: 0.110, green: 0.086, blue: 0.078),
backgroundSecondary: Color(red: 0.145, green: 0.114, blue: 0.102),
surfaceCard: Color(red: 0.184, green: 0.149, blue: 0.133),
borderSubtle: Color(red: 0.392, green: 0.263, blue: 0.212).opacity(0.50),
contentPrimary: Color(red: 0.957, green: 0.918, blue: 0.882),
contentSecondary: Color(red: 0.671, green: 0.561, blue: 0.494),
contentTertiary: Color(red: 0.435, green: 0.349, blue: 0.298),
accent: Color(red: 0.910, green: 0.388, blue: 0.192),
displayDesign: .default,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.671, green: 0.561, blue: 0.494),
radiusCard: 16,
radiusTag: 8,
reducedMotion: false
)
// MARK: - Birch (Helles Naturcreme, Waldgrün-Serif, bezahlt)
static let birch = NahbarTheme(
id: .birch,
backgroundPrimary: Color(red: 0.969, green: 0.961, blue: 0.945),
backgroundSecondary: Color(red: 0.937, green: 0.925, blue: 0.906),
surfaceCard: Color(red: 0.988, green: 0.984, blue: 0.969),
borderSubtle: Color(red: 0.682, green: 0.659, blue: 0.612).opacity(0.40),
contentPrimary: Color(red: 0.067, green: 0.133, blue: 0.071),
contentSecondary: Color(red: 0.227, green: 0.349, blue: 0.224),
contentTertiary: Color(red: 0.408, green: 0.502, blue: 0.396),
accent: Color(red: 0.118, green: 0.392, blue: 0.153),
displayDesign: .serif,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.227, green: 0.349, blue: 0.224),
radiusCard: 16,
radiusTag: 8,
reducedMotion: false
)
// MARK: - Vapor (Kühles Weiß, Tintenblau, Violett, bezahlt)
static let vapor = NahbarTheme(
id: .vapor,
backgroundPrimary: Color(red: 0.961, green: 0.965, blue: 0.976),
backgroundSecondary: Color(red: 0.925, green: 0.933, blue: 0.953),
surfaceCard: Color(red: 0.988, green: 0.988, blue: 1.000),
borderSubtle: Color(red: 0.647, green: 0.671, blue: 0.737).opacity(0.45),
contentPrimary: Color(red: 0.047, green: 0.063, blue: 0.145),
contentSecondary: Color(red: 0.275, green: 0.306, blue: 0.447),
contentTertiary: Color(red: 0.478, green: 0.506, blue: 0.616),
accent: Color(red: 0.455, green: 0.255, blue: 0.855),
displayDesign: .default,
sectionHeaderSize: 13,
sectionHeaderColor: Color(red: 0.275, green: 0.306, blue: 0.447),
radiusCard: 14,
radiusTag: 7,
reducedMotion: false
)
static func theme(for id: ThemeID) -> NahbarTheme { static func theme(for id: ThemeID) -> NahbarTheme {
switch id { switch id {
case .linen: return .linen case .linen: return .linen
@@ -246,6 +396,12 @@ extension NahbarTheme {
case .abyss: return .abyss case .abyss: return .abyss
case .dusk: return .dusk case .dusk: return .dusk
case .basalt: return .basalt case .basalt: return .basalt
case .chalk: return .chalk
case .flint: return .flint
case .onyx: return .onyx
case .ember: return .ember
case .birch: return .birch
case .vapor: return .vapor
} }
} }
} }
+62 -16
View File
@@ -5,6 +5,7 @@ import UserNotifications
struct TodayView: View { struct TodayView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@EnvironmentObject private var profileStore: UserProfileStore
@Query private var people: [Person] @Query private var people: [Person]
// V5: Nachwirkungen sind jetzt Treffen-Momente mit Status "warte_nachwirkung" // V5: Nachwirkungen sind jetzt Treffen-Momente mit Status "warte_nachwirkung"
@Query(filter: #Predicate<Moment> { @Query(filter: #Predicate<Moment> {
@@ -14,6 +15,8 @@ struct TodayView: View {
@State private var selectedMomentForAftermath: Moment? = nil @State private var selectedMomentForAftermath: Moment? = nil
@State private var showPersonPicker = false @State private var showPersonPicker = false
@State private var personForNewMoment: Person? = nil @State private var personForNewMoment: Person? = nil
@State private var showTodoPersonPicker = false
@State private var personForNewTodo: Person? = nil
@State private var todoForEdit: Todo? = nil @State private var todoForEdit: Todo? = nil
@State private var fadingOutTodos: [Todo] = [] @State private var fadingOutTodos: [Todo] = []
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7 @AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
@@ -100,11 +103,16 @@ struct TodayView: View {
} }
} }
private var greeting: LocalizedStringKey { private var greeting: String {
let hour = Calendar.current.component(.hour, from: Date()) let hour = Calendar.current.component(.hour, from: Date())
if hour < 12 { return "Guten Morgen." } let base: String
if hour < 18 { return "Guten Tag." } if hour < 12 { base = String(localized: "Guten Morgen") }
return "Guten Abend." else if hour < 18 { base = String(localized: "Guten Tag") }
else { base = String(localized: "Guten Abend") }
let firstName = profileStore.name.split(separator: " ").first.map(String.init) ?? ""
if firstName.isEmpty { return "\(base)." }
return "\(base), \(firstName)."
} }
private var formattedToday: String { private var formattedToday: String {
@@ -118,7 +126,7 @@ struct TodayView: View {
// Header // Header
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(greeting) Text(greeting)
.font(.system(size: 34, weight: .light, design: theme.displayDesign)) .font(.system(size: 32, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text(formattedToday) Text(formattedToday)
.font(.system(size: 15, design: theme.displayDesign)) .font(.system(size: 15, design: theme.displayDesign))
@@ -279,6 +287,14 @@ struct TodayView: View {
.sheet(item: $personForNewMoment) { person in .sheet(item: $personForNewMoment) { person in
AddMomentView(person: person) AddMomentView(person: person)
} }
.sheet(isPresented: $showTodoPersonPicker) {
TodayPersonPickerSheet(people: activePeople) { person in
personForNewTodo = person
}
}
.sheet(item: $personForNewTodo) { person in
AddTodoView(person: person)
}
} }
} }
@@ -292,36 +308,68 @@ struct TodayView: View {
Text("Ein ruhiger Tag.") Text("Ein ruhiger Tag.")
.font(.system(size: 20, weight: .light, design: theme.displayDesign)) .font(.system(size: 20, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text("Oder einer, der es noch wird.") Text("Lass uns mit der Beziehungspflege starten.")
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundStyle(theme.contentTertiary) .foregroundStyle(theme.contentTertiary)
} }
VStack(spacing: 12) {
Button { Button {
showPersonPicker = true showPersonPicker = true
} label: { } label: {
HStack(spacing: 14) { HStack(spacing: 14) {
Image(systemName: "plus.circle.fill") Image(systemName: "plus.circle.fill")
.font(.system(size: 24)) .font(.system(size: 22))
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Fangen wir an") Text("Moment erfassen")
.font(.system(size: 16, weight: .semibold, design: theme.displayDesign)) .font(.system(size: 15, weight: .semibold, design: theme.displayDesign))
Text("Momente planen und hinzufügen") Text("Treffen, Gespräch oder Erlebnis")
.font(.system(size: 13)) .font(.system(size: 12))
.opacity(0.8) .opacity(0.8)
} }
Spacer() Spacer()
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium)) .font(.system(size: 12, weight: .medium))
.opacity(0.6) .opacity(0.6)
} }
.foregroundStyle(theme.backgroundPrimary) .foregroundStyle(theme.backgroundPrimary)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 18) .padding(.vertical, 16)
.background(theme.accent) .background(theme.accent)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
Button {
showTodoPersonPicker = true
} label: {
HStack(spacing: 14) {
Image(systemName: "checkmark.circle")
.font(.system(size: 22))
VStack(alignment: .leading, spacing: 2) {
Text("Todo hinzufügen")
.font(.system(size: 15, weight: .semibold, design: theme.displayDesign))
Text("Aufgabe für eine Person anlegen")
.font(.system(size: 12))
.opacity(0.7)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.opacity(0.5)
}
.foregroundStyle(theme.accent)
.padding(.horizontal, 20)
.padding(.vertical, 16)
.background(theme.accent.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.overlay(
RoundedRectangle(cornerRadius: theme.radiusCard)
.strokeBorder(theme.accent.opacity(0.25), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 20) .padding(.horizontal, 20)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -476,9 +524,7 @@ struct GiftSuggestionRow: View {
Text("Geschenkidee vorschlagen") Text("Geschenkidee vorschlagen")
.font(.system(size: 13)) .font(.system(size: 13))
Spacer() Spacer()
if store.isMax { if !store.isMax && canUseAI {
MaxBadge()
} else if canUseAI {
Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis") Text("\(AIAnalysisService.shared.freeQueriesRemaining) gratis")
.font(.system(size: 10, weight: .bold)) .font(.system(size: 10, weight: .bold))
.foregroundStyle(theme.contentTertiary) .foregroundStyle(theme.contentTertiary)
+57
View File
@@ -0,0 +1,57 @@
import SwiftUI
// MARK: - SpotlightShape
/// A Shape that fills the entire rect with an even-odd "hole" cut out for the spotlight.
/// The filled region is everything OUTSIDE `spotlight`; the cutout is transparent.
///
/// Usage:
/// ```swift
/// SpotlightShape(spotlight: frame, cornerRadius: 18)
/// .fill(Color.black.opacity(0.45), style: FillStyle(eoFill: true))
/// ```
struct SpotlightShape: Shape {
var spotlight: CGRect
var cornerRadius: CGFloat
// MARK: Animatable
var animatableData: AnimatablePair<
AnimatablePair<CGFloat, CGFloat>,
AnimatablePair<AnimatablePair<CGFloat, CGFloat>, CGFloat>
> {
get {
AnimatablePair(
AnimatablePair(spotlight.origin.x, spotlight.origin.y),
AnimatablePair(
AnimatablePair(spotlight.size.width, spotlight.size.height),
cornerRadius
)
)
}
set {
spotlight = CGRect(
x: newValue.first.first,
y: newValue.first.second,
width: newValue.second.first.first,
height: newValue.second.first.second
)
cornerRadius = newValue.second.second
}
}
// MARK: Path
func path(in rect: CGRect) -> Path {
var path = Path()
// Outer rect (full screen)
path.addRect(rect)
// Spotlight cutout (rounded rectangle)
path.addRoundedRect(
in: spotlight,
cornerSize: CGSize(width: cornerRadius, height: cornerRadius)
)
return path
}
}
+45
View File
@@ -0,0 +1,45 @@
import Foundation
// MARK: - TriggerMode
/// Controls when a tour is automatically started.
enum TriggerMode: Hashable {
/// Started explicitly by app code; never auto-started by checkForPendingTours().
case manualOrFirstLaunch
/// Auto-started when the app version advances past `minAppVersion` and tour hasn't been seen.
case autoOnUpdate
/// Never auto-started; only via explicit `TourCoordinator.start(_:)` call.
case manualOnly
}
// MARK: - Tour
/// Metadata and steps for a single guided tour. Max 6 steps is enforced by precondition.
struct Tour: Identifiable, Hashable {
let id: TourID
let title: LocalizedStringResource
let steps: [TourStep]
/// Semantic version string, e.g. "1.0". Used for update-tour gating.
let minAppVersion: String
let triggerMode: TriggerMode
init(
id: TourID,
title: LocalizedStringResource,
steps: [TourStep],
minAppVersion: String,
triggerMode: TriggerMode
) {
precondition(!steps.isEmpty, "A tour must have at least 1 step.")
precondition(steps.count <= 8, "A tour must not exceed 8 steps. Got \(steps.count).")
self.id = id
self.title = title
self.steps = steps
self.minAppVersion = minAppVersion
self.triggerMode = triggerMode
}
// Equatable / Hashable based on id
static func == (lhs: Tour, rhs: Tour) -> Bool { lhs.id == rhs.id }
func hash(into hasher: inout Hasher) { hasher.combine(id) }
}
+126
View File
@@ -0,0 +1,126 @@
import SwiftUI
// MARK: - TourCardView
/// The info card shown during a tour step. Contains progress dots, title, body, and navigation buttons.
struct TourCardView: View {
let coordinator: TourCoordinator
let totalSteps: Int
let currentIndex: Int
var body: some View {
VStack(spacing: 0) {
// Header: progress dots + close button
HStack(alignment: .center) {
progressDots
Spacer()
Button {
coordinator.close()
} label: {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.padding(8)
.background(Color.secondary.opacity(0.12), in: Circle())
}
.accessibilityLabel("Tour schließen")
}
.padding(.horizontal, 20)
.padding(.top, 18)
// Content: title + body
if let step = coordinator.currentStep {
VStack(spacing: 12) {
Text(step.title)
.font(.title3.bold())
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Text(step.body)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 20)
.padding(.top, 18)
.padding(.bottom, 8)
}
// Footer: skip + back/next buttons
HStack {
// Step counter + skip
VStack(alignment: .leading, spacing: 2) {
Button {
coordinator.skip()
} label: {
Text("Tour überspringen")
.font(.caption)
.foregroundStyle(.tertiary)
}
Text(verbatim: String.localizedStringWithFormat(
String(localized: "%lld von %lld"),
Int64(currentIndex + 1),
Int64(totalSteps)
))
.font(.caption2)
.foregroundStyle(.quaternary)
}
Spacer()
// Back button (hidden on first step)
if !coordinator.isFirstStep {
Button {
coordinator.previous()
} label: {
Text("Zurück")
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
}
.padding(.trailing, 8)
}
// Next / Finish button explicit LocalizedStringKey to ensure lookup
let nextLabel: LocalizedStringKey = coordinator.isLastStep ? "Loslegen" : "Weiter"
Button {
coordinator.next()
} label: {
Text(nextLabel)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 18)
.padding(.vertical, 9)
.background(NahbarInsightStyle.accentPetrol, in: Capsule())
}
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 20)
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: .black.opacity(0.18), radius: 24, y: 10)
.sensoryFeedback(.selection, trigger: currentIndex)
}
// MARK: Progress Dots
private var progressDots: some View {
HStack(spacing: 5) {
ForEach(0..<totalSteps, id: \.self) { i in
Capsule()
.fill(i == currentIndex ? NahbarInsightStyle.accentPetrol : Color.secondary.opacity(0.3))
.frame(
width: i == currentIndex ? 20 : 6,
height: 6
)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: currentIndex)
}
}
.accessibilityLabel("Schritt \(currentIndex + 1) von \(totalSteps)")
.accessibilityHidden(false)
}
}
+72
View File
@@ -0,0 +1,72 @@
import Foundation
// MARK: - TourCatalog
/// Static registry of all tours defined in the app.
/// Strings use German text as keys (consistent with project xcstrings convention).
/// New tours are added as static properties and included in `all`.
enum TourCatalog {
// MARK: Onboarding Tour
static let onboarding = Tour(
id: .onboarding,
title: "App-Einführung",
steps: [
TourStep(
title: "Willkommen bei nahbar",
body: "nahbar hilft dir, echte Verbindungen zu pflegen ohne Stress, ohne Algorithmen.",
target: nil,
preferredCardPosition: .center
),
TourStep(
title: "Deine Menschen im Mittelpunkt",
body: "Füge Personen hinzu, die dir wichtig sind. Notiere Interessen, Gesprächsthemen und was euch verbindet.",
target: .addContactButton,
preferredCardPosition: .below
),
TourStep(
title: "Momente festhalten",
body: "Tippe auf eine Person und erfasse Treffen, Gespräche oder Erlebnisse so weißt du immer, worüber ihr das letzte Mal geredet habt.",
target: .contactCardFirst,
preferredCardPosition: .below
),
TourStep(
title: "Plane das Nächste",
body: "Tippe auf '+ Moment', um Treffen oder Gespräche festzuhalten so weißt du immer, worüber ihr das letzte Mal geredet habt.",
target: .addMomentButton,
preferredCardPosition: .below
),
TourStep(
title: "Todos anlegen",
body: "Mit '+ Todo' planst du konkrete Aufgaben für diese Person mit optionaler Erinnerung, damit nichts vergessen wird.",
target: .addTodoButton,
preferredCardPosition: .below
),
TourStep(
title: "Sanfte Erinnerungen",
body: "nahbar erinnert dich, wenn du lange nichts von jemandem gehört hast. Du entscheidest, wie oft.",
target: nil,
preferredCardPosition: .center
),
TourStep(
title: "Einblicke, wenn du willst",
body: "Optionale KI-Analyse zeigt Muster in deinen Verbindungen. Den Persönlichkeitstest findest du in den Einstellungen er macht nahbar noch persönlicher.",
target: nil,
preferredCardPosition: .center
),
],
minAppVersion: "1.0",
triggerMode: .manualOrFirstLaunch
)
// MARK: Registry
/// All tours known to the app. Order matters for display in Settings.
static let all: [Tour] = [onboarding]
/// Looks up a tour by its ID.
static func tour(for id: TourID) -> Tour? {
all.first { $0.id == id }
}
}
+169
View File
@@ -0,0 +1,169 @@
import Foundation
import OSLog
private let logger = Logger(subsystem: "nahbar", category: "TourCoordinator")
// MARK: - TourCoordinator
/// Observable coordinator that drives the guided tour flow.
/// Passed as an environment object from NahbarApp.
@Observable
final class TourCoordinator {
// MARK: Observed State (trigger SwiftUI re-renders)
private(set) var activeTour: Tour?
private(set) var currentStepIndex: Int = 0
// MARK: Non-Observed Internal State
@ObservationIgnored private var pendingQueue: [TourID] = []
@ObservationIgnored private var onTourComplete: (() -> Void)?
@ObservationIgnored private let tours: [Tour]
@ObservationIgnored private let seenStore: TourSeenStore
@ObservationIgnored private let appVersionProvider: () -> String
// MARK: Init
init(
tours: [Tour] = TourCatalog.all,
seenStore: TourSeenStore = TourSeenStore(),
appVersionProvider: @escaping () -> String = {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0"
}
) {
self.tours = tours
self.seenStore = seenStore
self.appVersionProvider = appVersionProvider
}
// MARK: Computed
var currentStep: TourStep? {
guard let tour = activeTour, currentStepIndex < tour.steps.count else { return nil }
return tour.steps[currentStepIndex]
}
var isActive: Bool { activeTour != nil }
var isFirstStep: Bool { currentStepIndex == 0 }
var isLastStep: Bool {
guard let tour = activeTour else { return false }
return currentStepIndex == tour.steps.count - 1
}
var stepCount: Int { activeTour?.steps.count ?? 0 }
// MARK: Auto-Start
/// Checks for update tours that should be started automatically.
/// Call this from ContentView.onAppear or NahbarApp.task.
func checkForPendingTours() {
let currentVersion = appVersionProvider()
let lastSeen = seenStore.lastSeenAppVersion ?? ""
let pending = tours.filter { tour in
guard tour.triggerMode == .autoOnUpdate else { return false }
return !seenStore.hasSeen(tour.id) && versionIsNewer(tour.minAppVersion, than: lastSeen)
}
if !pending.isEmpty {
logger.info("Ausstehende Tours gefunden: \(pending.map { $0.id.rawValue })")
pendingQueue = pending.map { $0.id }
startNextInQueue()
}
}
// MARK: Tour Control
/// Starts a tour by ID. An optional `onComplete` closure is called when the tour finishes
/// (whether by completing all steps, skipping, or closing).
func start(_ id: TourID, onComplete: (() -> Void)? = nil) {
guard let tour = tours.first(where: { $0.id == id }) else {
logger.warning("Tour nicht gefunden: \(id.rawValue)")
return
}
logger.info("Tour gestartet: \(id.rawValue)")
onTourComplete = onComplete
currentStepIndex = 0
activeTour = tour
}
func next() {
guard let tour = activeTour else { return }
if currentStepIndex < tour.steps.count - 1 {
currentStepIndex += 1
} else {
completeTour()
}
}
func previous() {
guard currentStepIndex > 0 else { return }
currentStepIndex -= 1
}
/// Skips the tour and marks it as seen.
func skip() { completeTour() }
/// Closes the tour (semantically identical to skip in v1) and marks it as seen.
func close() { completeTour() }
/// `true` when the onboarding tour has already been completed or skipped.
var hasSeenOnboardingTour: Bool {
seenStore.hasSeen(.onboarding)
}
/// Starts the onboarding tour if it hasn't been seen yet.
/// Call this after first-launch onboarding completes and the main app is visible.
func startOnboardingTourIfNeeded() {
guard !seenStore.hasSeen(.onboarding) else { return }
start(.onboarding)
}
/// Resets the "seen" state for all tours. Call this when the app data is reset.
func resetSeenTours() {
seenStore.reset()
}
// MARK: Target Frames (populated by tourPresenter overlay)
/// Not observed updated by the tourPresenter via preference keys.
@ObservationIgnored var targetFrames: [TourTargetID: CGRect] = [:]
// MARK: Private
private func completeTour() {
guard let tour = activeTour else { return }
seenStore.markSeen(tour.id)
// Capture and clear callbacks before resetting state
let completion = onTourComplete
onTourComplete = nil
activeTour = nil
currentStepIndex = 0
logger.info("Tour abgeschlossen: \(tour.id.rawValue)")
completion?()
// If pending queue is now empty, update the stored version
if pendingQueue.isEmpty {
seenStore.lastSeenAppVersion = appVersionProvider()
}
startNextInQueue()
}
private func startNextInQueue() {
guard !pendingQueue.isEmpty, activeTour == nil else { return }
let nextID = pendingQueue.removeFirst()
start(nextID)
}
private func versionIsNewer(_ version: String, than other: String) -> Bool {
guard !version.isEmpty else { return false }
if other.isEmpty { return true }
return version.compare(other, options: .numeric) == .orderedDescending
}
}
+10
View File
@@ -0,0 +1,10 @@
import Foundation
// MARK: - TourID
/// Identifies a concrete tour. New tours are added here.
enum TourID: String, CaseIterable, Codable, Hashable {
case onboarding
case v1_2_besuchsfragebogen
case v1_3_personality
}
+142
View File
@@ -0,0 +1,142 @@
import SwiftUI
// MARK: - TourOverlayView
/// Full-screen overlay that renders the spotlight cutout and tour card.
/// Inserted by the `tourPresenter()` modifier.
struct TourOverlayView: View {
let coordinator: TourCoordinator
/// Frames of all registered tour targets in the overlay's coordinate space.
let targetFrames: [TourTargetID: CGRect]
// MARK: Computed
private var spotlightFrame: CGRect {
guard let target = coordinator.currentStep?.target else { return .zero }
return targetFrames[target] ?? .zero
}
private var hasSpotlight: Bool { spotlightFrame != .zero }
private var paddedSpotlight: CGRect {
guard hasSpotlight, let step = coordinator.currentStep else { return .zero }
return spotlightFrame.insetBy(dx: -step.spotlightPadding, dy: -step.spotlightPadding)
}
private var cornerRadius: CGFloat {
coordinator.currentStep?.spotlightCornerRadius ?? 18
}
// MARK: Body
var body: some View {
if coordinator.isActive {
GeometryReader { geo in
ZStack(alignment: .top) {
// Backdrop
backdrop(in: geo)
// Spotlight glow ring
if hasSpotlight {
spotlightGlow
}
// Tour card
tourCard(in: geo)
}
}
.ignoresSafeArea()
.transition(.opacity)
.animation(.easeInOut(duration: 0.25), value: coordinator.isActive)
// Success haptic at tour end is triggered by TourCardView step change
.sensoryFeedback(.success, trigger: !coordinator.isActive)
}
}
// MARK: Backdrop
@ViewBuilder
private func backdrop(in geo: GeometryProxy) -> some View {
let fullRect = CGRect(origin: .zero, size: geo.size)
if hasSpotlight {
// Subtle dark tint only slightly dims the non-spotlight area
// so the user can still see and orient themselves in the UI
SpotlightShape(spotlight: paddedSpotlight, cornerRadius: cornerRadius)
.fill(Color.black.opacity(0.18), style: FillStyle(eoFill: true))
.frame(width: fullRect.width, height: fullRect.height)
.ignoresSafeArea()
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: paddedSpotlight)
} else {
// No spotlight: very subtle tint so the screen stays readable
Color.black.opacity(0.15)
.ignoresSafeArea()
}
}
// MARK: Spotlight Glow
private var spotlightGlow: some View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.strokeBorder(NahbarInsightStyle.accentCoral.opacity(0.6), lineWidth: 1.5)
.shadow(color: NahbarInsightStyle.accentCoral.opacity(0.45), radius: 12)
.frame(width: paddedSpotlight.width, height: paddedSpotlight.height)
.position(x: paddedSpotlight.midX, y: paddedSpotlight.midY)
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: paddedSpotlight)
}
// MARK: Tour Card
@ViewBuilder
private func tourCard(in geo: GeometryProxy) -> some View {
let cardWidth: CGFloat = min(geo.size.width - 48, 380)
let cardY = cardYPosition(in: geo)
TourCardView(
coordinator: coordinator,
totalSteps: coordinator.stepCount,
currentIndex: coordinator.currentStepIndex
)
.frame(width: cardWidth)
.position(x: geo.size.width / 2, y: cardY)
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: coordinator.currentStepIndex)
}
private func cardYPosition(in geo: GeometryProxy) -> CGFloat {
let screenHeight = geo.size.height
let cardHeight: CGFloat = 260 // Approximate card height
let margin: CGFloat = 24
let safeTop = geo.safeAreaInsets.top
let safeBottom = geo.safeAreaInsets.bottom
guard hasSpotlight else {
// No spotlight center the card
return screenHeight / 2
}
let step = coordinator.currentStep
let position = step?.preferredCardPosition ?? .auto
// Space above and below spotlight
let spaceAbove = paddedSpotlight.minY - safeTop - margin
let spaceBelow = screenHeight - paddedSpotlight.maxY - safeBottom - margin
switch position {
case .above:
return paddedSpotlight.minY - cardHeight / 2 - margin
case .below:
return paddedSpotlight.maxY + cardHeight / 2 + margin
case .center:
return screenHeight / 2
case .auto:
if spaceBelow >= cardHeight {
return paddedSpotlight.maxY + cardHeight / 2 + margin
} else if spaceAbove >= cardHeight {
return paddedSpotlight.minY - cardHeight / 2 - margin
} else {
return screenHeight / 2
}
}
}
}
+66
View File
@@ -0,0 +1,66 @@
import Foundation
import OSLog
private let logger = Logger(subsystem: "nahbar", category: "TourSeenStore")
// MARK: - TourSeenStore
/// Persists which tour IDs have been completed and the last-seen app version.
/// Injected into TourCoordinator to keep it testable.
final class TourSeenStore {
private let defaults: UserDefaults
private let seenIDsKey = "nahbar.tour.seenIDs"
private let lastSeenVersionKey = "nahbar.tour.lastSeenAppVersion"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
// MARK: Seen IDs
func hasSeen(_ id: TourID) -> Bool {
seenIDs.contains(id)
}
func markSeen(_ id: TourID) {
var ids = seenIDs
ids.insert(id)
seenIDs = ids
logger.info("Tour als gesehen markiert: \(id.rawValue)")
}
func reset() {
defaults.removeObject(forKey: seenIDsKey)
defaults.removeObject(forKey: lastSeenVersionKey)
logger.info("TourSeenStore zurückgesetzt")
}
// MARK: Last Seen App Version
var lastSeenAppVersion: String? {
get { defaults.string(forKey: lastSeenVersionKey) }
set {
if let newValue {
defaults.set(newValue, forKey: lastSeenVersionKey)
} else {
defaults.removeObject(forKey: lastSeenVersionKey)
}
}
}
// MARK: Private
private var seenIDs: Set<TourID> {
get {
guard let data = defaults.data(forKey: seenIDsKey),
let ids = try? JSONDecoder().decode(Set<TourID>.self, from: data)
else { return [] }
return ids
}
set {
guard let data = try? JSONEncoder().encode(newValue) else { return }
defaults.set(data, forKey: seenIDsKey)
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import Foundation
// MARK: - CardPosition
/// Controls where the info card is placed relative to the spotlight.
enum CardPosition: Hashable {
case auto
case above
case below
case center
}
// MARK: - TourStep
/// A single step in a guided tour.
struct TourStep: Identifiable, Hashable {
static func == (lhs: TourStep, rhs: TourStep) -> Bool { lhs.id == rhs.id }
func hash(into hasher: inout Hasher) { hasher.combine(id) }
let id: UUID
let title: LocalizedStringResource
let body: LocalizedStringResource
/// `nil` means a centered intro card with no spotlight.
let target: TourTargetID?
let preferredCardPosition: CardPosition
let spotlightPadding: CGFloat
let spotlightCornerRadius: CGFloat?
init(
id: UUID = UUID(),
title: LocalizedStringResource,
body: LocalizedStringResource,
target: TourTargetID? = nil,
preferredCardPosition: CardPosition = .auto,
spotlightPadding: CGFloat = 12,
spotlightCornerRadius: CGFloat? = nil
) {
self.id = id
self.title = title
self.body = body
self.target = target
self.preferredCardPosition = preferredCardPosition
self.spotlightPadding = spotlightPadding
self.spotlightCornerRadius = spotlightCornerRadius
}
}
+17
View File
@@ -0,0 +1,17 @@
import Foundation
// MARK: - TourTargetID
/// Identifies a spotlightable UI element. Views mark themselves with `.tourTarget(_:)`.
enum TourTargetID: String, Hashable, CaseIterable {
case addContactButton
case filterChips
case relationshipStrengthBadge
case contactCardFirst
case addMomentButton
case addTodoButton
case personalityTab
case insightsTab
case settingsEntry
case todayTab
}
@@ -0,0 +1,57 @@
import SwiftUI
// MARK: - TourTargetPreferenceKey
/// Collects Anchor<CGRect> values for each registered TourTargetID.
struct TourTargetPreferenceKey: PreferenceKey {
static var defaultValue: [TourTargetID: Anchor<CGRect>] = [:]
static func reduce(
value: inout [TourTargetID: Anchor<CGRect>],
nextValue: () -> [TourTargetID: Anchor<CGRect>]
) {
value.merge(nextValue()) { _, new in new }
}
}
// MARK: - View Modifiers
extension View {
/// Marks this view as a spotlightable tour target.
/// The frame is collected via a preference key and forwarded to TourCoordinator.
func tourTarget(_ id: TourTargetID) -> some View {
anchorPreference(key: TourTargetPreferenceKey.self, value: .bounds) { anchor in
[id: anchor]
}
}
/// Conditional variant applies `tourTarget` only when `id` is non-nil.
/// Use `index == 0 ? .someTarget : nil` patterns for list-based targets.
@ViewBuilder
func tourTarget(_ id: TourTargetID?) -> some View {
if let id {
tourTarget(id)
} else {
self
}
}
/// Adds the tour overlay to this view. Place at the root of a view hierarchy
/// (ContentView for main-app tours, OnboardingContainerView for onboarding).
///
/// - Parameter coordinator: The shared TourCoordinator instance.
func tourPresenter(coordinator: TourCoordinator) -> some View {
overlayPreferenceValue(TourTargetPreferenceKey.self) { anchors in
GeometryReader { geo in
// Convert preference anchors to CGRect in this coordinate space
let frames: [TourTargetID: CGRect] = anchors.reduce(into: [:]) { result, pair in
result[pair.key] = geo[pair.value]
}
TourOverlayView(coordinator: coordinator, targetFrames: frames)
.frame(width: geo.size.width, height: geo.size.height)
}
.ignoresSafeArea()
}
}
}
+6 -6
View File
@@ -155,14 +155,14 @@ struct SchemaRegressionTests {
#expect(NahbarSchemaV3.versionIdentifier.patch == 0) #expect(NahbarSchemaV3.versionIdentifier.patch == 0)
} }
@Test("Migrationsplan enthält genau 8 Schemas (V1V8)") @Test("Migrationsplan enthält genau 9 Schemas (V1V9)")
func migrationPlanHasEightSchemas() { func migrationPlanHasNineSchemas() {
#expect(NahbarMigrationPlan.schemas.count == 8) #expect(NahbarMigrationPlan.schemas.count == 9)
} }
@Test("Migrationsplan enthält genau 7 Stages (V1→V2 bis V7→V8)") @Test("Migrationsplan enthält genau 8 Stages (V1→V2 bis V8→V9)")
func migrationPlanHasSevenStages() { func migrationPlanHasEightStages() {
#expect(NahbarMigrationPlan.stages.count == 7) #expect(NahbarMigrationPlan.stages.count == 8)
} }
@Test("ContainerFallback-Gleichheit funktioniert korrekt") @Test("ContainerFallback-Gleichheit funktioniert korrekt")
@@ -0,0 +1,117 @@
import Testing
import Foundation
@testable import nahbar
@Suite("ConversationSuggestionResult")
struct ConversationSuggestionTests {
let service = AIAnalysisService.shared
// MARK: - Parsing
@Test("Alle drei Sektionen werden korrekt extrahiert")
func parsesAllThreeSections() {
let input = """
THEMEN: Erinnerst du dich an euren Wandertrip letzten Herbst? Frag nach den Fotos.
GESPRAECHSRETTER: Stell eine offene Frage zu ihrer Arbeit. Erzähl von deiner letzten Begegnung mit X.
TIEFE: Frag sie, was ihr aktuell am meisten Energie gibt das öffnet meist viele Türen.
"""
let result = service.parseConversationResult(input)
#expect(result.topics.contains("Wandertrip"))
#expect(result.rescue.contains("offene Frage"))
#expect(result.depth.contains("Energie"))
}
@Test("Fettdruck-Markierungen werden vor dem Parsing normalisiert")
func normalizesBoldMarkers() {
let input = """
**THEMEN:** Letzte Reise gemeinsam besprechen.
**GESPRAECHSRETTER:** Nach dem Haustier fragen.
**TIEFE:** Frag nach einem Traum oder Ziel.
"""
let result = service.parseConversationResult(input)
#expect(!result.topics.isEmpty)
#expect(!result.rescue.isEmpty)
#expect(!result.depth.isEmpty)
}
@Test("Alternativer Fettdruck-Stil wird ebenfalls normalisiert")
func normalizesAlternativeBoldStyle() {
let input = """
**THEMEN**: Thema eins.
**GESPRAECHSRETTER**: Rettungsanker.
**TIEFE**: Tiefer gehen.
"""
let result = service.parseConversationResult(input)
#expect(!result.topics.isEmpty)
#expect(!result.rescue.isEmpty)
#expect(!result.depth.isEmpty)
}
@Test("Fehlende Sektion gibt leeren String zurück (kein Crash)")
func missingRescueSectionReturnsEmpty() {
let input = """
THEMEN: Nur dieses Thema.
TIEFE: Nur diese Tiefe.
"""
let result = service.parseConversationResult(input)
#expect(!result.topics.isEmpty)
#expect(result.rescue.isEmpty)
#expect(!result.depth.isEmpty)
}
@Test("Vollständig leere Eingabe gibt drei leere Strings zurück")
func emptyInputReturnsAllEmpty() {
let result = service.parseConversationResult("")
#expect(result.topics.isEmpty)
#expect(result.rescue.isEmpty)
#expect(result.depth.isEmpty)
}
@Test("Mehrzeiliger Sektionsinhalt bleibt vollständig erhalten")
func multilineTopicsPreserved() {
let input = """
THEMEN: Zeile 1
Zeile 2
Zeile 3
GESPRAECHSRETTER: Rettung.
TIEFE: Tiefe.
"""
let result = service.parseConversationResult(input)
#expect(result.topics.contains("Zeile 1"))
#expect(result.topics.contains("Zeile 2"))
}
// MARK: - Codable Round-Trip
@Test("CachedConversationSuggestion Codable Round-Trip")
func cacheRoundTrip() throws {
let original = ConversationSuggestionResult(
topics: "Reise besprechen",
rescue: "Nach Hobbys fragen",
depth: "Was gibt dir Energie?"
)
let cached = CachedConversationSuggestion(result: original, date: Date(timeIntervalSince1970: 1_000_000))
let data = try JSONEncoder().encode(cached)
let decoded = try JSONDecoder().decode(CachedConversationSuggestion.self, from: data)
#expect(decoded.topics == original.topics)
#expect(decoded.rescue == original.rescue)
#expect(decoded.depth == original.depth)
#expect(decoded.generatedAt.timeIntervalSince1970 == 1_000_000)
}
@Test("asResult gibt korrekte ConversationSuggestionResult zurück")
func asResultRoundTrip() {
let original = ConversationSuggestionResult(
topics: "Thema A",
rescue: "Rettung B",
depth: "Tiefe C"
)
let cached = CachedConversationSuggestion(result: original)
let result = cached.asResult
#expect(result.topics == "Thema A")
#expect(result.rescue == "Rettung B")
#expect(result.depth == "Tiefe C")
}
}
+91 -4
View File
@@ -43,6 +43,80 @@ struct NudgeFrequencyTests {
} }
} }
} }
@Test("displayLabel ist nicht leer für alle Fälle")
func displayLabelNotEmpty() {
for freq in NudgeFrequency.allCases {
#expect(!freq.displayLabel.isEmpty)
}
}
@Test("biweekly displayLabel enthält '2 Wochen'")
func biweeklyDisplayLabel() {
#expect(NudgeFrequency.biweekly.displayLabel.contains("2 Wochen"))
}
}
// MARK: - NudgeStatus Tests
@Suite("NudgeStatus")
struct NudgeStatusTests {
private func makePerson(frequency: NudgeFrequency, lastContact: Date?) -> Person {
let p = Person(name: "Test", tag: .friends)
p.nudgeFrequency = frequency
// lastMomentDate ist computed aus moments wir simulieren via createdAt
// Wir setzen createdAt auf den gewünschten Referenzzeitpunkt
if let date = lastContact {
p.createdAt = date
}
return p
}
@Test("never → .never Status")
func neverFrequencyReturnsNever() {
let p = makePerson(frequency: .never, lastContact: nil)
#expect(p.nudgeStatus == .never)
}
@Test("kürzlicher Kontakt → .ok")
func recentContactReturnsOk() {
// Letzte Aktivität: gestern weit unter 75 % des monatlichen Intervalls
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date())!
let p = makePerson(frequency: .monthly, lastContact: yesterday)
#expect(p.nudgeStatus == .ok)
}
@Test("75100 % des Intervalls → .soon")
func approachingDeadlineReturnsSoon() {
// 25 Tage her bei monatlichem Intervall (30 Tage) = 83 %
let almostDue = Calendar.current.date(byAdding: .day, value: -25, to: Date())!
let p = makePerson(frequency: .monthly, lastContact: almostDue)
#expect(p.nudgeStatus == .soon)
}
@Test("über 100 % des Intervalls → .overdue")
func overdueReturnsOverdue() {
// 40 Tage her bei monatlichem Intervall (30 Tage)
let tooLong = Calendar.current.date(byAdding: .day, value: -40, to: Date())!
let p = makePerson(frequency: .monthly, lastContact: tooLong)
#expect(p.nudgeStatus == .overdue)
}
@Test("wöchentlich + 8 Tage her → .overdue")
func weeklyOverdue() {
let eightDaysAgo = Calendar.current.date(byAdding: .day, value: -8, to: Date())!
let p = makePerson(frequency: .weekly, lastContact: eightDaysAgo)
#expect(p.nudgeStatus == .overdue)
}
@Test("nudgeStatus stimmt mit needsAttention überein wenn overdue")
func nudgeStatusConsistentWithNeedsAttention() {
let tooLong = Calendar.current.date(byAdding: .day, value: -40, to: Date())!
let p = makePerson(frequency: .monthly, lastContact: tooLong)
#expect(p.nudgeStatus == .overdue)
#expect(p.needsAttention == true)
}
} }
// MARK: - PersonTag Tests // MARK: - PersonTag Tests
@@ -437,10 +511,23 @@ struct LogEntryComputedPropertyTests {
@Test("alle LogEntryTypes haben ein nicht-leeres Icon und color") @Test("alle LogEntryTypes haben ein nicht-leeres Icon und color")
func allTypesHaveIconAndColor() { func allTypesHaveIconAndColor() {
let types: [LogEntryType] = [.nextStep, .calendarEvent, .call] for type_ in LogEntryType.allCases {
for type_ in types { #expect(!type_.icon.isEmpty, "\(type_.rawValue) hat leeres icon")
#expect(!type_.icon.isEmpty) #expect(!type_.color.isEmpty, "\(type_.rawValue) hat leere color")
#expect(!type_.color.isEmpty)
} }
} }
@Test(".todoCompleted ist in allCases enthalten Regressionswächter")
func todoCompletedInAllCases() {
#expect(LogEntryType.allCases.contains(.todoCompleted))
#expect(LogEntryType.allCases.count == 4)
}
@Test("Stabile rawValues Regressionswächter")
func stableRawValues() {
#expect(LogEntryType.nextStep.rawValue == "Schritt abgeschlossen")
#expect(LogEntryType.calendarEvent.rawValue == "Termin geplant")
#expect(LogEntryType.call.rawValue == "Anruf")
#expect(LogEntryType.todoCompleted.rawValue == "Todo abgeschlossen")
}
} }
+90 -86
View File
@@ -416,79 +416,6 @@ struct PersonalityEngineBehaviorTests {
} }
} }
@Test("Hohe Offenheit → highlightNovelty true")
func highOpennessHighlightsNovelty() {
let p = profile(o: .high)
#expect(PersonalityEngine.highlightNovelty(for: p))
}
@Test("Niedrige Offenheit → highlightNovelty false")
func lowOpennessDoesNotHighlightNovelty() {
let p = profile(o: .low)
#expect(!PersonalityEngine.highlightNovelty(for: p))
}
}
// MARK: - suggestedActivities Tests
@Suite("PersonalityEngine suggestedActivities")
struct SuggestedActivitiesTests {
@Test("Gibt genau count Elemente zurück")
func returnsRequestedCount() {
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 2)
#expect(result.count == 2)
}
@Test("count: 1 → genau ein Vorschlag")
func countOne() {
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 1)
#expect(result.count == 1)
}
@Test("Alle zurückgegebenen Texte stammen aus dem Pool")
func resultsAreFromPool() {
let poolTexts = Set(PersonalityEngine.activityPool.map(\.text))
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 5)
for text in result {
#expect(poolTexts.contains(text), "'\(text)' nicht im Pool")
}
}
@Test("Pool hat mindestens 20 Einträge")
func poolIsSufficient() {
#expect(PersonalityEngine.activityPool.count >= 20)
}
@Test("Keine Duplikate in einem Ergebnis")
func noDuplicates() {
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 5)
#expect(result.count == Set(result).count)
}
@Test("Ergebnis ist nicht leer wenn Pool vorhanden")
func notEmptyWhenPoolExists() {
let result = PersonalityEngine.suggestedActivities(for: nil, tag: nil, count: 2)
#expect(!result.isEmpty)
}
@Test("Pool enthält Erlebnis-Aktivitäten (isNovelty)")
func poolContainsNoveltyActivities() {
#expect(PersonalityEngine.activityPool.contains { $0.isNovelty })
}
@Test("Pool enthält 1:1 und Gruppen-Aktivitäten")
func poolContainsBothStyles() {
#expect(PersonalityEngine.activityPool.contains { $0.style == .oneOnOne })
#expect(PersonalityEngine.activityPool.contains { $0.style == .group })
}
@Test("Pool enthält Tag-spezifische Aktivitäten")
func poolContainsTagSpecificActivities() {
#expect(PersonalityEngine.activityPool.contains { $0.preferredTag == .friends })
#expect(PersonalityEngine.activityPool.contains { $0.preferredTag == .family })
#expect(PersonalityEngine.activityPool.contains { $0.preferredTag == .work })
}
} }
// MARK: - GenderSelectionScreen Skip-Logik // MARK: - GenderSelectionScreen Skip-Logik
@@ -519,22 +446,99 @@ struct PersonalityQuizGenderSkipTests {
} }
} }
// MARK: - OnboardingStep Regressionswächter (nach Quiz-Erweiterung) // MARK: - Notification Copy Tests
@Suite("OnboardingStep RawValues (Quiz-Erweiterung)") @Suite("PersonalityEngine Notification Copy")
struct OnboardingStepQuizTests { struct NotificationCopyTests {
@Test("RawValues sind aufsteigend 04") private func profile(e: TraitLevel = .medium, n: TraitLevel = .medium,
@MainActor func rawValuesSequential() { c: TraitLevel = .medium) -> PersonalityProfile {
#expect(OnboardingStep.profile.rawValue == 0) func score(_ l: TraitLevel) -> Int { l == .low ? 0 : l == .medium ? 1 : 2 }
#expect(OnboardingStep.quiz.rawValue == 1) return PersonalityProfile(scores: [
#expect(OnboardingStep.contacts.rawValue == 2) .extraversion: score(e), .neuroticism: score(n),
#expect(OnboardingStep.tour.rawValue == 3) .conscientiousness: score(c), .agreeableness: 1, .openness: 1
#expect(OnboardingStep.complete.rawValue == 4) ], completedAt: Date())
} }
@Test("allCases enthält genau 5 Schritte") // MARK: callWindowCopy
@MainActor func allCasesCountIsFive() {
#expect(OnboardingStep.allCases.count == 5) @Test("callWindowCopy: nil-Profil liefert nicht-leeren Fallback")
func callWindowCopyNilProfile() {
let copy = PersonalityEngine.callWindowCopy(profile: nil)
#expect(!copy.isEmpty)
}
@Test("callWindowCopy: hohe Extraversion → direkter Text")
func callWindowCopyHighExtraversion() {
let copy = PersonalityEngine.callWindowCopy(profile: profile(e: .high))
#expect(copy.contains("freut sich"))
}
@Test("callWindowCopy: hoher Neurotizismus (nicht high E) → weicherer, ermutigender Text")
func callWindowCopyHighNeuroticism() {
let copy = PersonalityEngine.callWindowCopy(profile: profile(e: .low, n: .high))
#expect(copy.contains("Magst du"))
}
@Test("callWindowCopy: Medium-Profil → Default-Text")
func callWindowCopyDefault() {
let copy = PersonalityEngine.callWindowCopy(profile: profile(e: .medium, n: .medium))
#expect(!copy.isEmpty)
}
@Test("callWindowCopy: alle Varianten sind nicht leer")
func callWindowCopyAllVariantsNonEmpty() {
for e in TraitLevel.allCases {
for n in TraitLevel.allCases {
let copy = PersonalityEngine.callWindowCopy(profile: profile(e: e, n: n))
#expect(!copy.isEmpty, "callWindowCopy leer für e=\(e), n=\(n)")
}
}
}
// MARK: aftermathCopy
@Test("aftermathCopy: nil-Profil liefert nicht-leeren Fallback")
func aftermathCopyNilProfile() {
let copy = PersonalityEngine.aftermathCopy(profile: nil)
#expect(!copy.isEmpty)
}
@Test("aftermathCopy: hoher Neurotizismus → weicher, einladender Text")
func aftermathCopyHighNeuroticism() {
let copy = PersonalityEngine.aftermathCopy(profile: profile(n: .high))
#expect(copy.contains("Wenn du magst"))
}
@Test("aftermathCopy: niedriger Neurotizismus → direkter Text")
func aftermathCopyLowNeuroticism() {
let copy = PersonalityEngine.aftermathCopy(profile: profile(n: .low))
#expect(copy.contains("Wie wirkt"))
}
@Test("aftermathCopy: alle Varianten sind nicht leer")
func aftermathCopyAllVariantsNonEmpty() {
for n in TraitLevel.allCases {
let copy = PersonalityEngine.aftermathCopy(profile: profile(n: n))
#expect(!copy.isEmpty, "aftermathCopy leer für n=\(n)")
}
}
}
// MARK: - OnboardingStep Regressionswächter
@Suite("OnboardingStep RawValues")
struct OnboardingStepQuizTests {
@Test("RawValues sind aufsteigend 02")
@MainActor func rawValuesSequential() {
#expect(OnboardingStep.profile.rawValue == 0)
#expect(OnboardingStep.contacts.rawValue == 1)
#expect(OnboardingStep.complete.rawValue == 2)
}
@Test("allCases enthält genau 3 Schritte")
@MainActor func allCasesCountIsThree() {
#expect(OnboardingStep.allCases.count == 3)
} }
} }
+5 -69
View File
@@ -69,40 +69,6 @@ struct OnboardingCoordinatorNavigationTests {
#expect(coord.currentStep == .profile) #expect(coord.currentStep == .profile)
} }
@Test("advanceToQuiz ohne Vorname bleibt auf .profile")
@MainActor func advanceToQuizWithoutNameStaysOnProfile() {
let coord = OnboardingCoordinator()
coord.firstName = ""
coord.advanceToQuiz()
#expect(coord.currentStep == .profile)
}
@Test("advanceToQuiz mit gültigem Vorname → .quiz")
@MainActor func advanceToQuizWithNameGoesToQuiz() {
let coord = OnboardingCoordinator()
coord.firstName = "Anna"
coord.advanceToQuiz()
#expect(coord.currentStep == .quiz)
}
@Test("skipQuiz überspring Quiz und geht zu .contacts")
@MainActor func skipQuizGoesToContacts() {
let coord = OnboardingCoordinator()
coord.firstName = "Anna"
coord.advanceToQuiz()
coord.skipQuiz()
#expect(coord.currentStep == .contacts)
}
@Test("advanceFromQuizToContacts → .contacts")
@MainActor func advanceFromQuizToContacts() {
let coord = OnboardingCoordinator()
coord.firstName = "Anna"
coord.advanceToQuiz()
coord.advanceFromQuizToContacts()
#expect(coord.currentStep == .contacts)
}
@Test("advanceToContacts ohne Vorname bleibt auf .profile") @Test("advanceToContacts ohne Vorname bleibt auf .profile")
@MainActor func advanceToContactsWithoutNameStaysOnProfile() { @MainActor func advanceToContactsWithoutNameStaysOnProfile() {
let coord = OnboardingCoordinator() let coord = OnboardingCoordinator()
@@ -119,34 +85,6 @@ struct OnboardingCoordinatorNavigationTests {
#expect(coord.currentStep == .contacts) #expect(coord.currentStep == .contacts)
} }
@Test("advanceToTour ohne Kontakte bleibt auf .contacts")
@MainActor func advanceToTourWithoutContactsStaysOnContacts() {
let coord = OnboardingCoordinator()
coord.firstName = "Anna"
coord.advanceToContacts()
coord.advanceToTour() // keine Kontakte ausgewählt
#expect(coord.currentStep == .contacts)
}
@Test("advanceToTour mit Kontakt → .tour")
@MainActor func advanceToTourWithContactGoesToTour() {
let coord = OnboardingCoordinator()
coord.firstName = "Anna"
coord.advanceToContacts()
coord.selectedContacts = [NahbarContact(givenName: "Kai", familyName: "Müller")]
coord.advanceToTour()
#expect(coord.currentStep == .tour)
}
@Test("skipToTour überspringt Kontakt-Schritt")
@MainActor func skipToTourSkipsContacts() {
let coord = OnboardingCoordinator()
coord.firstName = "Anna"
coord.advanceToContacts()
coord.skipToTour()
#expect(coord.currentStep == .tour)
}
@Test("completeOnboarding setzt Schritt auf .complete") @Test("completeOnboarding setzt Schritt auf .complete")
@MainActor func completeOnboardingSetsComplete() { @MainActor func completeOnboardingSetsComplete() {
let coord = OnboardingCoordinator() let coord = OnboardingCoordinator()
@@ -270,18 +208,16 @@ struct NahbarContactCodableTests {
@Suite("OnboardingStep RawValue") @Suite("OnboardingStep RawValue")
struct OnboardingStepTests { struct OnboardingStepTests {
@Test("RawValues sind aufsteigend 04") @Test("RawValues sind aufsteigend 02")
func rawValuesAreSequential() { func rawValuesAreSequential() {
#expect(OnboardingStep.profile.rawValue == 0) #expect(OnboardingStep.profile.rawValue == 0)
#expect(OnboardingStep.quiz.rawValue == 1) #expect(OnboardingStep.contacts.rawValue == 1)
#expect(OnboardingStep.contacts.rawValue == 2) #expect(OnboardingStep.complete.rawValue == 2)
#expect(OnboardingStep.tour.rawValue == 3)
#expect(OnboardingStep.complete.rawValue == 4)
} }
@Test("allCases enthält genau 5 Schritte") @Test("allCases enthält genau 3 Schritte")
func allCasesCount() { func allCasesCount() {
#expect(OnboardingStep.allCases.count == 5) #expect(OnboardingStep.allCases.count == 3)
} }
@Test("Reihenfolge von allCases stimmt mit rawValue überein") @Test("Reihenfolge von allCases stimmt mit rawValue überein")
+297
View File
@@ -162,6 +162,165 @@ struct AppGroupProStatusTests {
} }
} }
// MARK: - FeatureGate Consistency Tests
/// Stellt sicher, dass die Invariante "Max implies Pro" unter allen Transaktions-Kombinationen gilt.
@Suite("FeatureGate Konsistenz")
struct FeatureGateConsistencyTests {
@Test("free: isPro und isMax beide false")
func freeHasNoAccess() {
let gate = FeatureGate.from(foundPro: false, foundMax: false)
#expect(gate.isPro == false)
#expect(gate.isMax == false)
}
@Test("Pro-only: isPro true, isMax false")
func proOnlySetsPro() {
let gate = FeatureGate.from(foundPro: true, foundMax: false)
#expect(gate.isPro == true)
#expect(gate.isMax == false)
}
@Test("Max-only: isMax true setzt isPro automatisch true (Max implies Pro)")
func maxOnlyImpliesPro() {
let gate = FeatureGate.from(foundPro: false, foundMax: true)
#expect(gate.isMax == true)
#expect(gate.isPro == true, "Max-Abonnenten müssen auch Pro-Features erhalten")
}
@Test("Pro + Max: beide true")
func proAndMaxBothTrue() {
let gate = FeatureGate.from(foundPro: true, foundMax: true)
#expect(gate.isPro == true)
#expect(gate.isMax == true)
}
@Test("isConsistent gilt für alle 4 Transaktions-Kombinationen")
func consistencyHoldsForAllCombinations() {
let combinations = [(false, false), (true, false), (false, true), (true, true)]
for (foundPro, foundMax) in combinations {
let gate = FeatureGate.from(foundPro: foundPro, foundMax: foundMax)
#expect(gate.isConsistent, "Invariante verletzt für foundPro=\(foundPro), foundMax=\(foundMax)")
}
}
@Test("FeatureGate.free entspricht from(false, false)")
func freeStaticMatchesFromFactory() {
#expect(FeatureGate.free == FeatureGate.from(foundPro: false, foundMax: false))
}
}
// MARK: - FeatureGate Access Tests
/// Verifiziert, dass jedes Feature genau für den richtigen Tier freigeschaltet wird.
@Suite("FeatureGate Feature-Zugriff")
struct FeatureGateAccessTests {
// MARK: Free-User
@Test("Free-User: keine Pro-Features")
func freeUserHasNoProFeatures() {
let gate = FeatureGate.free
#expect(!gate.unlimitedContacts)
#expect(!gate.premiumThemes)
#expect(!gate.shareExtension)
}
@Test("Free-User: keine Max-Features")
func freeUserHasNoMaxFeatures() {
let gate = FeatureGate.free
#expect(!gate.aiAnalysis)
#expect(!gate.giftSuggestions)
#expect(!gate.conversationTopics)
#expect(!gate.unlimitedAIQueries)
}
// MARK: Pro-only
@Test("Pro-User: alle Pro-Features aktiv")
func proUserHasAllProFeatures() {
let gate = FeatureGate.from(foundPro: true, foundMax: false)
#expect(gate.unlimitedContacts)
#expect(gate.premiumThemes)
#expect(gate.shareExtension)
}
@Test("Pro-User: keine Max-Features")
func proUserHasNoMaxFeatures() {
let gate = FeatureGate.from(foundPro: true, foundMax: false)
#expect(!gate.aiAnalysis)
#expect(!gate.giftSuggestions)
#expect(!gate.conversationTopics)
#expect(!gate.unlimitedAIQueries)
}
// MARK: Max-User
@Test("Max-User: alle Pro-Features aktiv (Max implies Pro)")
func maxUserHasAllProFeatures() {
let gate = FeatureGate.from(foundPro: false, foundMax: true)
#expect(gate.unlimitedContacts, "Max-Abonnenten müssen unbegrenzte Kontakte haben")
#expect(gate.premiumThemes, "Max-Abonnenten müssen Premium-Themes haben")
#expect(gate.shareExtension, "Max-Abonnenten müssen die Share-Extension nutzen können")
}
@Test("Max-User: alle Max-Features aktiv")
func maxUserHasAllMaxFeatures() {
let gate = FeatureGate.from(foundPro: false, foundMax: true)
#expect(gate.aiAnalysis, "Max-Abonnenten müssen KI-Analyse nutzen können")
#expect(gate.giftSuggestions, "Max-Abonnenten müssen Geschenkideen nutzen können")
#expect(gate.conversationTopics, "Max-Abonnenten müssen Gesprächsthemen nutzen können")
#expect(gate.unlimitedAIQueries, "Max-Abonnenten müssen unbegrenzte KI-Abfragen haben")
}
}
// MARK: - FeatureGate canUseAI Tests
/// Verifiziert die canUseAI-Logik, die in AddMomentView, TodayView und LogbuchView identisch ist:
/// store.isMax || AIAnalysisService.shared.hasFreeQueriesLeft
@Suite("FeatureGate canUseAI")
struct FeatureGateCanUseAITests {
@Test("Max + keine Gratis-Abfragen: KI verfügbar (unbegrenzt)")
func maxUserAlwaysCanUseAI() {
#expect(FeatureGate.canUseAI(isMax: true, hasFreeQueriesLeft: false))
}
@Test("Max + Gratis-Abfragen vorhanden: KI verfügbar")
func maxUserWithQueriesCanUseAI() {
#expect(FeatureGate.canUseAI(isMax: true, hasFreeQueriesLeft: true))
}
@Test("Nicht-Max + Gratis-Abfragen vorhanden: KI verfügbar")
func nonMaxWithQueriesCanUseAI() {
#expect(FeatureGate.canUseAI(isMax: false, hasFreeQueriesLeft: true))
}
@Test("Nicht-Max + keine Gratis-Abfragen: KI gesperrt")
func nonMaxWithoutQueriesCannotUseAI() {
#expect(!FeatureGate.canUseAI(isMax: false, hasFreeQueriesLeft: false))
}
}
// MARK: - FeatureGate shouldConsumeFreeQuery Tests
/// Verifiziert die Verbrauchs-Logik:
/// if !store.isMax { AIAnalysisService.shared.consumeFreeQuery() }
@Suite("FeatureGate shouldConsumeFreeQuery")
struct FeatureGateFreeQueryConsumptionTests {
@Test("Max-Abonnent: Gratis-Abfrage wird NICHT verbraucht")
func maxUserDoesNotConsumeFreeQuery() {
#expect(!FeatureGate.shouldConsumeFreeQuery(isMax: true))
}
@Test("Nicht-Max-Abonnent: Gratis-Abfrage wird verbraucht")
func nonMaxUserConsumesQuery() {
#expect(FeatureGate.shouldConsumeFreeQuery(isMax: false))
}
}
// MARK: - Paywall-Targeting Tests // MARK: - Paywall-Targeting Tests
/// Dokumentiert die Logik aus SettingsView: /// Dokumentiert die Logik aus SettingsView:
@@ -190,3 +349,141 @@ struct PaywallTargetingTests {
#expect(target(isPro: false) != target(isPro: true)) #expect(target(isPro: false) != target(isPro: true))
} }
} }
// MARK: - InterestTagHelper Tests
@Suite("InterestTagHelper parse")
struct InterestTagHelperParseTests {
@Test("Leerer String → leeres Array")
func emptyStringReturnsEmpty() {
#expect(InterestTagHelper.parse("") == [])
}
@Test("Einzelner Tag ohne Komma")
func singleTag() {
#expect(InterestTagHelper.parse("Fußball") == ["Fußball"])
}
@Test("Mehrere Tags kommasepariert")
func multipleTags() {
let result = InterestTagHelper.parse("Fußball, Musik, Lesen")
#expect(result == ["Fußball", "Musik", "Lesen"])
}
@Test("Whitespace wird getrimmt")
func whitespaceTrimmed() {
let result = InterestTagHelper.parse(" Kino , Sport ")
#expect(result == ["Kino", "Sport"])
}
@Test("Leere Segmente werden gefiltert")
func emptySegmentsFiltered() {
let result = InterestTagHelper.parse("Kino,,Musik,")
#expect(result == ["Kino", "Musik"])
}
}
@Suite("InterestTagHelper join")
struct InterestTagHelperJoinTests {
@Test("Leeres Array → leerer String")
func emptyArrayReturnsEmpty() {
#expect(InterestTagHelper.join([]) == "")
}
@Test("Einzelner Tag bleibt unverändert")
func singleTagUnchanged() {
#expect(InterestTagHelper.join(["Fußball"]) == "Fußball")
}
@Test("Tags werden alphabetisch sortiert")
func tagsSortedAlphabetically() {
let result = InterestTagHelper.join(["Musik", "Fußball", "Lesen"])
#expect(result == "Fußball, Lesen, Musik")
}
@Test("Sortierung ist case-insensitive")
func sortingCaseInsensitive() {
let result = InterestTagHelper.join(["bier", "Apfel", "Chips"])
#expect(result == "Apfel, bier, Chips")
}
}
@Suite("InterestTagHelper addTag")
struct InterestTagHelperAddTagTests {
@Test("Tag zu leerem String hinzufügen")
func addToEmpty() {
#expect(InterestTagHelper.addTag("Kino", to: "") == "Kino")
}
@Test("Tag zu bestehendem String alphabetisch einsortiert")
func addSortsAlphabetically() {
let result = InterestTagHelper.addTag("Musik", to: "Fußball")
#expect(result == "Fußball, Musik")
}
@Test("Duplikat wird ignoriert")
func duplicateIgnored() {
let result = InterestTagHelper.addTag("Kino", to: "Kino, Sport")
#expect(result == "Kino, Sport")
}
@Test("Duplikat ignoriert (Groß-/Kleinschreibung)")
func duplicateCaseInsensitive() {
let result = InterestTagHelper.addTag("kino", to: "Kino")
#expect(result == "Kino")
}
@Test("Leerer String wird ignoriert")
func emptyTagIgnored() {
let result = InterestTagHelper.addTag("", to: "Kino")
#expect(result == "Kino")
}
}
@Suite("InterestTagHelper removeTag")
struct InterestTagHelperRemoveTagTests {
@Test("Tag aus String entfernen")
func removeExistingTag() {
let result = InterestTagHelper.removeTag("Musik", from: "Fußball, Musik, Sport")
#expect(result == "Fußball, Sport")
}
@Test("Nicht vorhandener Tag → unveränderter String")
func removeNonExistentTag() {
let result = InterestTagHelper.removeTag("Kino", from: "Fußball, Sport")
#expect(result == "Fußball, Sport")
}
@Test("Letzten Tag entfernen → leerer String")
func removeLastTagReturnsEmpty() {
let result = InterestTagHelper.removeTag("Kino", from: "Kino")
#expect(result == "")
}
}
@Suite("InterestTagHelper allSuggestions")
struct InterestTagHelperSuggestionsTests {
@Test("Keine Personen + leere Vorlieben → leere Vorschläge")
func emptyInputsReturnEmpty() {
let result = InterestTagHelper.allSuggestions(from: [], likes: "", dislikes: "")
#expect(result.isEmpty)
}
@Test("Vorschläge aus likes und dislikes kombiniert und sortiert")
func combinesLikesAndDislikes() {
let result = InterestTagHelper.allSuggestions(from: [], likes: "Musik, Kino", dislikes: "Sport")
#expect(result == ["Kino", "Musik", "Sport"])
}
@Test("Duplikate werden dedupliziert")
func deduplicates() {
let result = InterestTagHelper.allSuggestions(from: [], likes: "Kino, Musik", dislikes: "Kino")
#expect(!result.contains { _ in result.filter { $0 == "Kino" }.count > 1 })
#expect(result.filter { $0 == "Kino" }.count == 1)
}
}
+124
View File
@@ -0,0 +1,124 @@
import Testing
import Foundation
@testable import nahbar
// MARK: - ThemeID Enum Tests
@Suite("ThemeID Enum")
struct ThemeIDTests {
@Test("Genau 15 Themes vorhanden")
func allCasesCount() {
#expect(ThemeID.allCases.count == 15)
}
@Test("rawValues sind einzigartig")
func rawValuesAreUnique() {
let values = ThemeID.allCases.map { $0.rawValue }
#expect(Set(values).count == ThemeID.allCases.count)
}
@Test("displayNames sind einzigartig")
func displayNamesAreUnique() {
let names = ThemeID.allCases.map { $0.displayName }
#expect(Set(names).count == ThemeID.allCases.count)
}
@Test("displayNames sind nicht leer")
func displayNamesNotEmpty() {
for id in ThemeID.allCases {
#expect(!id.displayName.isEmpty, "displayName für \(id.rawValue) ist leer")
}
}
@Test("Genau 5 kostenlose Themes")
func freeThemesCount() {
let free = ThemeID.allCases.filter { !$0.isPremium }
#expect(free.count == 5)
}
@Test("Kostenlose Themes: linen, slate, mist, chalk, flint")
func freeThemeIdentities() {
let free = Set(ThemeID.allCases.filter { !$0.isPremium })
#expect(free == [.linen, .slate, .mist, .chalk, .flint])
}
@Test("Genau 10 bezahlte Themes")
func premiumThemesCount() {
let premium = ThemeID.allCases.filter { $0.isPremium }
#expect(premium.count == 10)
}
@Test("isDark: flint, copper, onyx, ember, abyss, dusk, basalt sind dunkel")
func darkThemes() {
let dark = Set(ThemeID.allCases.filter { $0.isDark })
#expect(dark == [.flint, .copper, .onyx, .ember, .abyss, .dusk, .basalt])
}
@Test("isNeurodiverseFocused: nur abyss, dusk, basalt")
func ndThemes() {
let nd = Set(ThemeID.allCases.filter { $0.isNeurodiverseFocused })
#expect(nd == [.abyss, .dusk, .basalt])
}
@Test("ND-Themes sind alle dunkel")
func ndThemesAreDark() {
for id in ThemeID.allCases where id.isNeurodiverseFocused {
#expect(id.isDark, "\(id.rawValue) ist ND aber nicht dunkel")
}
}
@Test("Neue Hochkontrast-Themes sind nicht ND-fokussiert")
func highContrastThemesNotND() {
let highContrast: [ThemeID] = [.chalk, .flint, .onyx, .ember, .birch, .vapor]
for id in highContrast {
#expect(!id.isNeurodiverseFocused, "\(id.rawValue) sollte nicht ND-fokussiert sein")
}
}
}
// MARK: - NahbarTheme Token Tests
@Suite("NahbarTheme Tokens")
struct NahbarThemeTokenTests {
@Test("theme(for:) gibt für jeden ThemeID ein Theme zurück")
func themeForAllIDs() {
for id in ThemeID.allCases {
let t = NahbarTheme.theme(for: id)
#expect(t.id == id, "theme(for: \(id.rawValue)).id stimmt nicht überein")
}
}
@Test("sectionHeaderSize ist positiv für alle Themes")
func sectionHeaderSizePositive() {
for id in ThemeID.allCases {
let t = NahbarTheme.theme(for: id)
#expect(t.sectionHeaderSize > 0, "sectionHeaderSize für \(id.rawValue) ist nicht positiv")
}
}
@Test("Alle Themes haben sectionHeaderSize 13")
func allThemesHeaderSize() {
for id in ThemeID.allCases {
let t = NahbarTheme.theme(for: id)
#expect(t.sectionHeaderSize == 13, "\(id.rawValue) sectionHeaderSize sollte 13 sein")
}
}
@Test("radiusCard ist positiv für alle Themes")
func radiusCardPositive() {
for id in ThemeID.allCases {
let t = NahbarTheme.theme(for: id)
#expect(t.radiusCard > 0, "radiusCard für \(id.rawValue) ist nicht positiv")
}
}
@Test("radiusTag ist positiv für alle Themes")
func radiusTagPositive() {
for id in ThemeID.allCases {
let t = NahbarTheme.theme(for: id)
#expect(t.radiusTag > 0, "radiusTag für \(id.rawValue) ist nicht positiv")
}
}
}
@@ -0,0 +1,166 @@
import Testing
import Foundation
@testable import nahbar
// MARK: - AutoStart Logic Tests
/// Helper: builds a coordinator with controlled version and tour list.
private func makeAutoCoordinator(
tours: [Tour],
currentVersion: String,
lastSeenVersion: String? = nil
) -> (TourCoordinator, TourSeenStore) {
let suiteName = "test.autostart.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
let store = TourSeenStore(defaults: defaults)
store.lastSeenAppVersion = lastSeenVersion
let coordinator = TourCoordinator(tours: tours, seenStore: store, appVersionProvider: { currentVersion })
return (coordinator, store)
}
/// Creates a minimal test tour with autoOnUpdate trigger.
private func makeUpdateTour(id: TourID, minVersion: String) -> Tour {
Tour(
id: id,
title: "Test Tour",
steps: [TourStep(title: "Step", body: "Body")],
minAppVersion: minVersion,
triggerMode: .autoOnUpdate
)
}
/// Creates a minimal test tour with manualOnly trigger.
private func makeManualTour(id: TourID) -> Tour {
Tour(
id: id,
title: "Manual Tour",
steps: [TourStep(title: "Step", body: "Body")],
minAppVersion: "1.0",
triggerMode: .manualOnly
)
}
@Suite("AutoStart checkForPendingTours")
struct AutoStartLogicTests {
@Test("Frische Installation (kein lastSeenVersion): autoOnUpdate-Tour wird gestartet")
func freshInstallStartsUpdateTour() {
let updateTour = makeUpdateTour(id: .v1_2_besuchsfragebogen, minVersion: "1.2")
let (coordinator, _) = makeAutoCoordinator(
tours: [updateTour],
currentVersion: "1.2",
lastSeenVersion: nil
)
coordinator.checkForPendingTours()
#expect(coordinator.activeTour?.id == .v1_2_besuchsfragebogen)
}
@Test("Bereits gesehene Tour wird nicht erneut gestartet")
func seenTourNotStartedAgain() {
let updateTour = makeUpdateTour(id: .v1_2_besuchsfragebogen, minVersion: "1.2")
let (coordinator, store) = makeAutoCoordinator(
tours: [updateTour],
currentVersion: "1.2",
lastSeenVersion: nil
)
store.markSeen(.v1_2_besuchsfragebogen)
coordinator.checkForPendingTours()
#expect(!coordinator.isActive)
}
@Test("Tour mit minVersion == currentVersion wird gestartet (wenn lastSeen kleiner)")
func tourWithCurrentVersionStarted() {
let updateTour = makeUpdateTour(id: .v1_2_besuchsfragebogen, minVersion: "1.2")
let (coordinator, _) = makeAutoCoordinator(
tours: [updateTour],
currentVersion: "1.2",
lastSeenVersion: "1.0"
)
coordinator.checkForPendingTours()
#expect(coordinator.isActive)
}
@Test("Tour mit minVersion < lastSeenVersion wird NICHT gestartet")
func oldTourNotStartedAfterUpdate() {
let updateTour = makeUpdateTour(id: .v1_2_besuchsfragebogen, minVersion: "1.2")
let (coordinator, _) = makeAutoCoordinator(
tours: [updateTour],
currentVersion: "1.3",
lastSeenVersion: "1.3"
)
coordinator.checkForPendingTours()
#expect(!coordinator.isActive)
}
@Test("manualOnly-Tour wird von checkForPendingTours NIE gestartet")
func manualOnlyTourNeverAutoStarts() {
let manualTour = makeManualTour(id: .v1_3_personality)
let (coordinator, _) = makeAutoCoordinator(
tours: [manualTour],
currentVersion: "1.3",
lastSeenVersion: nil
)
coordinator.checkForPendingTours()
#expect(!coordinator.isActive)
}
@Test("manualOrFirstLaunch-Tour wird von checkForPendingTours NIE gestartet")
func manualOrFirstLaunchTourNeverAutoStarts() {
// TourCatalog.onboarding is manualOrFirstLaunch
let (coordinator, _) = makeAutoCoordinator(
tours: [TourCatalog.onboarding],
currentVersion: "1.0",
lastSeenVersion: nil
)
coordinator.checkForPendingTours()
#expect(!coordinator.isActive)
}
@Test("Mehrere pending Tours werden nacheinander abgearbeitet")
func multiplePendingToursQueuedSequentially() {
let tour1 = makeUpdateTour(id: .v1_2_besuchsfragebogen, minVersion: "1.2")
let tour2 = makeUpdateTour(id: .v1_3_personality, minVersion: "1.3")
let (coordinator, _) = makeAutoCoordinator(
tours: [tour1, tour2],
currentVersion: "1.3",
lastSeenVersion: "1.0"
)
coordinator.checkForPendingTours()
// First tour should be active
#expect(coordinator.activeTour?.id == .v1_2_besuchsfragebogen)
// Skip first, second should start
coordinator.skip()
#expect(coordinator.activeTour?.id == .v1_3_personality)
// Skip second, no more tours
coordinator.skip()
#expect(!coordinator.isActive)
}
@Test("Nach Abschluss aller Pending-Touren wird lastSeenAppVersion auf currentVersion gesetzt")
func lastSeenVersionUpdatedAfterAllToursComplete() {
let updateTour = makeUpdateTour(id: .v1_2_besuchsfragebogen, minVersion: "1.2")
let (coordinator, store) = makeAutoCoordinator(
tours: [updateTour],
currentVersion: "1.2",
lastSeenVersion: "1.0"
)
coordinator.checkForPendingTours()
coordinator.skip()
#expect(store.lastSeenAppVersion == "1.2")
}
@Test("Manuelles start(.onboarding) ist unabhängig von checkForPendingTours")
func manualStartIgnoresPendingLogic() {
let (coordinator, _) = makeAutoCoordinator(
tours: [TourCatalog.onboarding],
currentVersion: "1.0",
lastSeenVersion: nil
)
// checkForPendingTours should not start it
coordinator.checkForPendingTours()
#expect(!coordinator.isActive)
// Manual start should work
coordinator.start(.onboarding)
#expect(coordinator.isActive)
}
}
@@ -0,0 +1,133 @@
import Foundation
import Testing
@testable import nahbar
// MARK: - TourCatalog Tests
@Suite("TourCatalog Validierung")
struct TourCatalogTests {
@Test("Alle Touren haben mindestens 1 und höchstens 7 Steps")
func allToursHaveValidStepCount() {
for tour in TourCatalog.all {
#expect(!tour.steps.isEmpty, "Tour \(tour.id.rawValue) hat keine Steps")
#expect(tour.steps.count <= 7, "Tour \(tour.id.rawValue) hat mehr als 7 Steps")
}
}
@Test("TourCatalog.tour(for:) gibt für jede bekannte TourID die richtige Tour zurück")
func tourLookupByID() {
// Only test IDs that are actually in TourCatalog.all
for tour in TourCatalog.all {
let found = TourCatalog.tour(for: tour.id)
#expect(found != nil, "Keine Tour für ID \(tour.id.rawValue) gefunden")
#expect(found?.id == tour.id)
}
}
@Test("Onboarding-Tour hat genau 7 Steps")
func onboardingTourHasSevenSteps() {
#expect(TourCatalog.onboarding.steps.count == 7)
}
@Test("Onboarding-Tour hat triggerMode .manualOrFirstLaunch")
func onboardingTourTriggerMode() {
#expect(TourCatalog.onboarding.triggerMode == .manualOrFirstLaunch)
}
@Test("Onboarding-Tour hat minAppVersion '1.0'")
func onboardingTourMinVersion() {
#expect(TourCatalog.onboarding.minAppVersion == "1.0")
}
@Test("TourCatalog.all enthält mindestens eine Tour")
func catalogIsNotEmpty() {
#expect(!TourCatalog.all.isEmpty)
}
@Test("Alle Tour-IDs im Catalog sind eindeutig")
func catalogIDsAreUnique() {
let ids = TourCatalog.all.map { $0.id }
let uniqueIDs = Set(ids)
#expect(ids.count == uniqueIDs.count)
}
@Test("Alle Steps einer Tour haben eindeutige IDs")
func stepIDsAreUniquePerTour() {
for tour in TourCatalog.all {
let stepIDs = tour.steps.map { $0.id }
let uniqueIDs = Set(stepIDs)
#expect(stepIDs.count == uniqueIDs.count, "Tour \(tour.id.rawValue) hat doppelte Step-IDs")
}
}
}
// MARK: - TourStep Target Tests
@Suite("TourCatalog Step-Targets")
struct TourStepTargetTests {
@Test("Onboarding-Step 4 (Index 3) targetet .addMomentButton")
func step4TargetsAddMomentButton() {
let steps = TourCatalog.onboarding.steps
#expect(steps.count > 3)
#expect(steps[3].target == .addMomentButton)
}
@Test("Onboarding-Step 5 (Index 4) targetet .addTodoButton")
func step5TargetsAddTodoButton() {
let steps = TourCatalog.onboarding.steps
#expect(steps.count > 4)
#expect(steps[4].target == .addTodoButton)
}
@Test("Schritte mit addMomentButton und addTodoButton haben CardPosition .below")
func momentAndTodoStepsAreBelow() {
let steps = TourCatalog.onboarding.steps
let momentStep = steps.first { $0.target == .addMomentButton }
let todoStep = steps.first { $0.target == .addTodoButton }
#expect(momentStep?.preferredCardPosition == .below)
#expect(todoStep?.preferredCardPosition == .below)
}
@Test("Genau ein Step targetet .addMomentButton")
func exactlyOneAddMomentStep() {
let count = TourCatalog.onboarding.steps.filter { $0.target == .addMomentButton }.count
#expect(count == 1)
}
@Test("Genau ein Step targetet .addTodoButton")
func exactlyOneAddTodoStep() {
let count = TourCatalog.onboarding.steps.filter { $0.target == .addTodoButton }.count
#expect(count == 1)
}
}
// MARK: - Tour Model Tests
@Suite("Tour Preconditions")
struct TourModelTests {
@Test("Tour mit 6 Steps kann erstellt werden")
func tourWithSixStepsIsValid() {
let steps = (0..<6).map { _ in
TourStep(title: "test", body: "body")
}
let tour = Tour(
id: .onboarding,
title: "test",
steps: steps,
minAppVersion: "1.0",
triggerMode: .manualOrFirstLaunch
)
#expect(tour.steps.count == 6)
}
@Test("Tour-Gleichheit basiert auf ID")
func tourEqualityBasedOnID() {
let step = TourStep(title: "t", body: "b")
let tourA = Tour(id: .onboarding, title: "A", steps: [step], minAppVersion: "1.0", triggerMode: .manualOrFirstLaunch)
let tourB = Tour(id: .onboarding, title: "B", steps: [step], minAppVersion: "1.1", triggerMode: .autoOnUpdate)
#expect(tourA == tourB)
}
}
@@ -0,0 +1,192 @@
import CoreGraphics
import Foundation
import Testing
@testable import nahbar
// MARK: - TourCoordinator Tests
/// Builds an isolated coordinator with a known tour set and fresh UserDefaults.
private func makeCoordinator(
tours: [Tour] = [TourCatalog.onboarding],
version: String = "1.0"
) -> TourCoordinator {
let suiteName = "test.coordinator.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
let store = TourSeenStore(defaults: defaults)
return TourCoordinator(tours: tours, seenStore: store, appVersionProvider: { version })
}
@Suite("TourCoordinator Starten & Navigieren")
struct TourCoordinatorNavigationTests {
@Test("start setzt activeTour und currentStepIndex = 0")
func startSetsTourAndIndex() {
let coordinator = makeCoordinator()
coordinator.start(.onboarding)
#expect(coordinator.activeTour?.id == .onboarding)
#expect(coordinator.currentStepIndex == 0)
}
@Test("isActive ist false vor dem Start")
func isActiveIsFalseBeforeStart() {
let coordinator = makeCoordinator()
#expect(!coordinator.isActive)
}
@Test("isActive ist true nach dem Start")
func isActiveIsTrueAfterStart() {
let coordinator = makeCoordinator()
coordinator.start(.onboarding)
#expect(coordinator.isActive)
}
@Test("next erhöht currentStepIndex")
func nextIncrementsIndex() {
let coordinator = makeCoordinator()
coordinator.start(.onboarding)
coordinator.next()
#expect(coordinator.currentStepIndex == 1)
}
@Test("previous verringert currentStepIndex")
func previousDecrementsIndex() {
let coordinator = makeCoordinator()
coordinator.start(.onboarding)
coordinator.next()
coordinator.previous()
#expect(coordinator.currentStepIndex == 0)
}
@Test("previous am ersten Step tut nichts")
func previousOnFirstStepDoesNothing() {
let coordinator = makeCoordinator()
coordinator.start(.onboarding)
coordinator.previous()
#expect(coordinator.currentStepIndex == 0)
}
@Test("next am letzten Step schließt die Tour")
func nextOnLastStepClosesTour() {
let coordinator = makeCoordinator()
coordinator.start(.onboarding)
let stepCount = TourCatalog.onboarding.steps.count
for _ in 0..<(stepCount - 1) {
coordinator.next()
}
#expect(coordinator.isLastStep)
coordinator.next()
#expect(!coordinator.isActive)
#expect(coordinator.activeTour == nil)
}
@Test("isFirstStep ist true nur bei Index 0")
func isFirstStepOnlyAtIndexZero() {
let coordinator = makeCoordinator()
coordinator.start(.onboarding)
#expect(coordinator.isFirstStep)
coordinator.next()
#expect(!coordinator.isFirstStep)
}
@Test("isLastStep ist true nur am letzten Step")
func isLastStepOnlyAtEnd() {
let coordinator = makeCoordinator()
coordinator.start(.onboarding)
#expect(!coordinator.isLastStep)
let stepCount = TourCatalog.onboarding.steps.count
for _ in 0..<(stepCount - 1) {
coordinator.next()
}
#expect(coordinator.isLastStep)
}
@Test("currentStep gibt den korrekten Step zurück")
func currentStepMatchesIndex() {
let coordinator = makeCoordinator()
coordinator.start(.onboarding)
let firstStep = coordinator.currentStep
#expect(firstStep != nil)
#expect(firstStep?.id == TourCatalog.onboarding.steps[0].id)
}
}
@Suite("TourCoordinator Beenden & Seen-Status")
struct TourCoordinatorCompletionTests {
@Test("skip markiert Tour als gesehen und schließt sie")
func skipMarksSeen() {
let suiteName = "test.coordinator.skip.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
let store = TourSeenStore(defaults: defaults)
let coordinator = TourCoordinator(tours: [TourCatalog.onboarding], seenStore: store, appVersionProvider: { "1.0" })
coordinator.start(.onboarding)
coordinator.skip()
#expect(!coordinator.isActive)
#expect(store.hasSeen(.onboarding))
}
@Test("close markiert Tour als gesehen und schließt sie")
func closeMarksSeen() {
let suiteName = "test.coordinator.close.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
let store = TourSeenStore(defaults: defaults)
let coordinator = TourCoordinator(tours: [TourCatalog.onboarding], seenStore: store, appVersionProvider: { "1.0" })
coordinator.start(.onboarding)
coordinator.close()
#expect(!coordinator.isActive)
#expect(store.hasSeen(.onboarding))
}
@Test("onComplete-Callback wird nach Tour-Ende aufgerufen")
func onCompleteCalledAfterTourEnds() {
let coordinator = makeCoordinator()
var callbackCalled = false
coordinator.start(.onboarding) { callbackCalled = true }
coordinator.skip()
#expect(callbackCalled)
}
@Test("onComplete-Callback wird nur einmal aufgerufen")
func onCompleteCalledOnce() {
let coordinator = makeCoordinator()
var callCount = 0
coordinator.start(.onboarding) { callCount += 1 }
coordinator.skip()
// Attempting to skip again on an inactive tour does nothing
coordinator.skip()
#expect(callCount == 1)
}
@Test("Nach Tour-Ende ist currentStepIndex wieder 0")
func indexResetAfterCompletion() {
let coordinator = makeCoordinator()
coordinator.start(.onboarding)
coordinator.next()
coordinator.skip()
#expect(coordinator.currentStepIndex == 0)
}
}
@Suite("TourCoordinator Target Frames")
struct TourCoordinatorTargetFrameTests {
@Test("updateTargetFrame speichert Frame korrekt")
func updateTargetFrameStoresFrame() {
let coordinator = makeCoordinator()
let frame = CGRect(x: 10, y: 20, width: 100, height: 50)
coordinator.targetFrames[.addContactButton] = frame
#expect(coordinator.targetFrames[.addContactButton] == frame)
}
@Test("clearTargetFrame entfernt Frame")
func clearTargetFrameRemovesFrame() {
let coordinator = makeCoordinator()
coordinator.targetFrames[.addContactButton] = CGRect(x: 0, y: 0, width: 44, height: 44)
coordinator.targetFrames.removeValue(forKey: .addContactButton)
#expect(coordinator.targetFrames[.addContactButton] == nil)
}
}
@@ -0,0 +1,109 @@
import Testing
import Foundation
@testable import nahbar
// MARK: - TourSeenStore Tests
@Suite("TourSeenStore Grundfunktionen")
struct TourSeenStoreTests {
/// Creates an isolated UserDefaults for each test to avoid state leakage.
private func makeStore() -> TourSeenStore {
let suiteName = "test.tour.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
return TourSeenStore(defaults: defaults)
}
@Test("hasSeen gibt false zurück bevor markSeen aufgerufen wurde")
func hasSeenFalseInitially() {
let store = makeStore()
#expect(!store.hasSeen(.onboarding))
}
@Test("markSeen setzt hasSeen auf true")
func markSeenSetsHasSeen() {
let store = makeStore()
store.markSeen(.onboarding)
#expect(store.hasSeen(.onboarding))
}
@Test("markSeen für eine ID beeinflusst andere IDs nicht")
func markSeenDoesNotAffectOtherIDs() {
let store = makeStore()
store.markSeen(.onboarding)
#expect(!store.hasSeen(.v1_2_besuchsfragebogen))
#expect(!store.hasSeen(.v1_3_personality))
}
@Test("Mehrfaches markSeen ist idempotent")
func markSeenIsIdempotent() {
let store = makeStore()
store.markSeen(.onboarding)
store.markSeen(.onboarding)
#expect(store.hasSeen(.onboarding))
}
@Test("reset setzt hasSeen zurück auf false")
func resetClearsSeenIDs() {
let store = makeStore()
store.markSeen(.onboarding)
store.reset()
#expect(!store.hasSeen(.onboarding))
}
@Test("reset löscht auch lastSeenAppVersion")
func resetClearsLastSeenVersion() {
let store = makeStore()
store.lastSeenAppVersion = "1.2"
store.reset()
#expect(store.lastSeenAppVersion == nil)
}
@Test("lastSeenAppVersion ist initial nil")
func lastSeenVersionIsInitiallyNil() {
let store = makeStore()
#expect(store.lastSeenAppVersion == nil)
}
@Test("lastSeenAppVersion wird korrekt geschrieben und gelesen")
func lastSeenVersionPersists() {
let store = makeStore()
store.lastSeenAppVersion = "1.3"
#expect(store.lastSeenAppVersion == "1.3")
}
}
@Suite("TourSeenStore Persistenz")
struct TourSeenStorePersistenceTests {
@Test("markSeen persistiert über Store-Neuinstanzierung")
func persistenceAcrossInstances() {
let suiteName = "test.tour.persist.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
let store1 = TourSeenStore(defaults: defaults)
store1.markSeen(.onboarding)
// New instance, same defaults
let store2 = TourSeenStore(defaults: defaults)
#expect(store2.hasSeen(.onboarding))
// Cleanup
defaults.removePersistentDomain(forName: suiteName)
}
@Test("lastSeenAppVersion persistiert über Store-Neuinstanzierung")
func versionPersistenceAcrossInstances() {
let suiteName = "test.tour.version.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
let store1 = TourSeenStore(defaults: defaults)
store1.lastSeenAppVersion = "2.0"
let store2 = TourSeenStore(defaults: defaults)
#expect(store2.lastSeenAppVersion == "2.0")
// Cleanup
defaults.removePersistentDomain(forName: suiteName)
}
}
@@ -0,0 +1,52 @@
import Foundation
import Testing
@testable import nahbar
// MARK: - TourTargetID Tests
@Suite("TourTargetID Vollständigkeit")
struct TourTargetIDTests {
@Test("Alle 10 erwarteten Cases sind vorhanden")
func allCasesCount() {
#expect(TourTargetID.allCases.count == 10)
}
@Test("Alle rawValues sind nicht leer")
func allRawValuesNonEmpty() {
for target in TourTargetID.allCases {
#expect(!target.rawValue.isEmpty, "rawValue für \(target) ist leer")
}
}
@Test("Alle rawValues sind eindeutig")
func allRawValuesUnique() {
let rawValues = TourTargetID.allCases.map { $0.rawValue }
#expect(Set(rawValues).count == rawValues.count)
}
@Test("addMomentButton hat den korrekten rawValue")
func addMomentButtonRawValue() {
#expect(TourTargetID.addMomentButton.rawValue == "addMomentButton")
}
@Test("addTodoButton hat den korrekten rawValue")
func addTodoButtonRawValue() {
#expect(TourTargetID.addTodoButton.rawValue == "addTodoButton")
}
@Test("addContactButton ist im CaseIterable enthalten")
func addContactButtonPresent() {
#expect(TourTargetID.allCases.contains(.addContactButton))
}
@Test("addMomentButton ist im CaseIterable enthalten")
func addMomentButtonPresent() {
#expect(TourTargetID.allCases.contains(.addMomentButton))
}
@Test("addTodoButton ist im CaseIterable enthalten")
func addTodoButtonPresent() {
#expect(TourTargetID.allCases.contains(.addTodoButton))
}
}
@@ -186,6 +186,57 @@ struct UserProfileStoreNewFieldsTests {
} }
} }
// MARK: - Vorname-Extraktion (Begrüßungslogik)
@Suite("TodayView Vorname-Extraktion")
struct GreetingFirstNameTests {
// Spiegelt die Logik aus TodayView.greeting wider:
// profileStore.name.split(separator: " ").first.map(String.init) ?? ""
private func firstName(from name: String) -> String {
name.split(separator: " ").first.map(String.init) ?? ""
}
@Test("Vorname aus vollem Namen")
func firstNameFromFullName() {
#expect(firstName(from: "Max Mustermann") == "Max")
}
@Test("Vorname aus dreiteiligem Namen")
func firstNameFromThreeWordName() {
#expect(firstName(from: "Anna Maria Schmidt") == "Anna")
}
@Test("Einzelner Name → dieser selbst als Vorname")
func firstNameFromSingleWord() {
#expect(firstName(from: "Max") == "Max")
}
@Test("Leerer Name → leerer Vorname")
func firstNameFromEmptyString() {
#expect(firstName(from: "").isEmpty)
}
@Test("Nur Leerzeichen → leerer Vorname")
func firstNameFromWhitespaceOnly() {
#expect(firstName(from: " ").isEmpty)
}
@Test("Begrüßung mit Vorname enthält Komma und Punkt")
func greetingFormatWithName() {
let first = firstName(from: "Max Mustermann")
let greeting = "Guten Tag, \(first)."
#expect(greeting == "Guten Tag, Max.")
}
@Test("Begrüßung ohne Name endet mit Punkt (kein Komma)")
func greetingFormatWithoutName() {
let first = firstName(from: "")
let greeting = first.isEmpty ? "Guten Tag." : "Guten Tag, \(first)."
#expect(greeting == "Guten Tag.")
}
}
// MARK: - Vorlieben-Nudge Anzeigelogik // MARK: - Vorlieben-Nudge Anzeigelogik
@Suite("IchView Vorlieben-Nudge Sichtbarkeit") @Suite("IchView Vorlieben-Nudge Sichtbarkeit")
+6 -6
View File
@@ -359,13 +359,13 @@ struct SchemaV5RegressionTests {
#expect(NahbarSchemaV5.versionIdentifier.patch == 0) #expect(NahbarSchemaV5.versionIdentifier.patch == 0)
} }
@Test("Migrationsplan enthält genau 8 Schemas (V1V8)") @Test("Migrationsplan enthält genau 9 Schemas (V1V9)")
func migrationPlanHasEightSchemas() { func migrationPlanHasNineSchemas() {
#expect(NahbarMigrationPlan.schemas.count == 8) #expect(NahbarMigrationPlan.schemas.count == 9)
} }
@Test("Migrationsplan enthält genau 7 Stages (V1→V2 bis V7→V8)") @Test("Migrationsplan enthält genau 8 Stages (V1→V2 bis V8→V9)")
func migrationPlanHasSevenStages() { func migrationPlanHasEightStages() {
#expect(NahbarMigrationPlan.stages.count == 7) #expect(NahbarMigrationPlan.stages.count == 8)
} }
} }