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>
This commit is contained in:
@@ -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 */,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ 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
|
||||||
@@ -83,6 +85,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.tourPresenter(coordinator: tourCoordinator)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
syncPeopleCache()
|
syncPeopleCache()
|
||||||
importPendingMoments()
|
importPendingMoments()
|
||||||
@@ -95,6 +98,7 @@ struct ContentView: View {
|
|||||||
showingOnboarding = true
|
showingOnboarding = true
|
||||||
} else {
|
} else {
|
||||||
checkCallWindow()
|
checkCallWindow()
|
||||||
|
tourCoordinator.checkForPendingTours()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
|||||||
@@ -869,6 +869,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"App-Touren" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Arbeit" : {
|
"Arbeit" : {
|
||||||
"comment" : "PersonTag.work raw value",
|
"comment" : "PersonTag.work raw value",
|
||||||
@@ -4688,6 +4691,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.tours.start" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"settings.tours.stepCount %lld" : {
|
||||||
|
"comment" : "SettingsView – step count label (e.g. '6 Schritte')",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"variations" : {
|
||||||
|
"plural" : {
|
||||||
|
"one" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "%lld step"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"other" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "%lld steps"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Signal" : {
|
"Signal" : {
|
||||||
"comment" : "MomentSource.signal raw value",
|
"comment" : "MomentSource.signal raw value",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -5006,6 +5035,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tour.common.back" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.common.close" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.common.finish" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.common.next" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.common.skip" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.common.stepCounter %lld %lld" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "tour.common.stepCounter %1$lld %2$lld"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tour.onboarding.step1.body" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.onboarding.step1.title" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.onboarding.step2.body" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.onboarding.step2.title" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.onboarding.step3.body" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.onboarding.step3.title" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.onboarding.step4.body" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.onboarding.step4.title" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.onboarding.step5.body" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.onboarding.step5.title" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.onboarding.step6.body" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.onboarding.step6.title" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"tour.onboarding.title" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Treffen" : {
|
"Treffen" : {
|
||||||
"comment" : "MomentType.meeting rawValue + VisitHistorySection / SettingsView section header",
|
"comment" : "MomentType.meeting rawValue + VisitHistorySection / SettingsView section header",
|
||||||
|
|||||||
@@ -46,6 +46,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
|
||||||
|
|
||||||
@@ -66,6 +69,7 @@ struct NahbarApp: App {
|
|||||||
.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)
|
||||||
|
|
||||||
|
|||||||
@@ -17,17 +17,18 @@ struct OnboardingContainerView: View {
|
|||||||
@StateObject private var coordinator = OnboardingCoordinator()
|
@StateObject private var coordinator = OnboardingCoordinator()
|
||||||
@Environment(\.contactStore) private var contactStore
|
@Environment(\.contactStore) private var contactStore
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(TourCoordinator.self) private var tourCoordinator
|
||||||
|
|
||||||
/// 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.
|
/// Tracks whether the onboarding tour was started (to detect its completion).
|
||||||
@State private var showTour: Bool = false
|
@State private var onboardingTourStarted: Bool = false
|
||||||
/// Whether the final privacy screen is visible (shown after the feature tour).
|
/// 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)
|
||||||
@@ -43,29 +44,25 @@ struct OnboardingContainerView: View {
|
|||||||
.tag(2)
|
.tag(2)
|
||||||
}
|
}
|
||||||
.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)
|
// The spotlight tour overlay is injected here for the onboarding context.
|
||||||
|
// It renders within this fullScreenCover, which is the correct layer.
|
||||||
|
.tourPresenter(coordinator: tourCoordinator)
|
||||||
.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)) {
|
||||||
@@ -73,15 +70,22 @@ struct OnboardingContainerView: View {
|
|||||||
tabPage = min(step.rawValue, 2)
|
tabPage = min(step.rawValue, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Detect when the onboarding tour completes → show privacy screen
|
||||||
|
.onChange(of: tourCoordinator.activeTour?.id) { _, activeID in
|
||||||
|
if activeID == nil && onboardingTourStarted {
|
||||||
|
onboardingTourStarted = false
|
||||||
|
startPrivacyScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startTour() {
|
private func startTour() {
|
||||||
withAnimation { showTour = true }
|
onboardingTourStarted = true
|
||||||
|
tourCoordinator.start(.onboarding)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startPrivacyScreen() {
|
private func startPrivacyScreen() {
|
||||||
withAnimation(.easeInOut(duration: 0.35)) {
|
withAnimation(.easeInOut(duration: 0.35)) {
|
||||||
showTour = false
|
|
||||||
showPrivacyScreen = true
|
showPrivacyScreen = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ struct SettingsView: View {
|
|||||||
@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
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(TourCoordinator.self) private var tourCoordinator
|
||||||
|
|
||||||
@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
|
||||||
@@ -589,6 +591,43 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App-Touren
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
SectionHeader(title: "App-Touren", icon: "sparkles")
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(Array(TourCatalog.all.enumerated()), id: \.element.id) { index, tour in
|
||||||
|
if index > 0 { RowDivider() }
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
.frame(width: 22)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(tour.title)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(theme.contentPrimary)
|
||||||
|
Text(String.localizedStringWithFormat(String(localized: "settings.tours.stepCount %lld"), Int64(tour.steps.count)))
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(theme.contentTertiary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button(String(localized: "settings.tours.start")) {
|
||||||
|
tourCoordinator.start(tour.id)
|
||||||
|
}
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(theme.accent)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(theme.surfaceCard)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
|
||||||
// About
|
// About
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
SectionHeader(title: "Über nahbar", icon: "info.circle")
|
SectionHeader(title: "Über nahbar", icon: "info.circle")
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - SpotlightShape
|
||||||
|
|
||||||
|
/// A Shape that fills the entire rect with an even-odd "hole" cut out for the spotlight.
|
||||||
|
/// The filled region is everything OUTSIDE `spotlight`; the cutout is transparent.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```swift
|
||||||
|
/// SpotlightShape(spotlight: frame, cornerRadius: 18)
|
||||||
|
/// .fill(Color.black.opacity(0.45), style: FillStyle(eoFill: true))
|
||||||
|
/// ```
|
||||||
|
struct SpotlightShape: Shape {
|
||||||
|
|
||||||
|
var spotlight: CGRect
|
||||||
|
var cornerRadius: CGFloat
|
||||||
|
|
||||||
|
// MARK: Animatable
|
||||||
|
|
||||||
|
var animatableData: AnimatablePair<
|
||||||
|
AnimatablePair<CGFloat, CGFloat>,
|
||||||
|
AnimatablePair<AnimatablePair<CGFloat, CGFloat>, CGFloat>
|
||||||
|
> {
|
||||||
|
get {
|
||||||
|
AnimatablePair(
|
||||||
|
AnimatablePair(spotlight.origin.x, spotlight.origin.y),
|
||||||
|
AnimatablePair(
|
||||||
|
AnimatablePair(spotlight.size.width, spotlight.size.height),
|
||||||
|
cornerRadius
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
spotlight = CGRect(
|
||||||
|
x: newValue.first.first,
|
||||||
|
y: newValue.first.second,
|
||||||
|
width: newValue.second.first.first,
|
||||||
|
height: newValue.second.first.second
|
||||||
|
)
|
||||||
|
cornerRadius = newValue.second.second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Path
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
// Outer rect (full screen)
|
||||||
|
path.addRect(rect)
|
||||||
|
// Spotlight cutout (rounded rectangle)
|
||||||
|
path.addRoundedRect(
|
||||||
|
in: spotlight,
|
||||||
|
cornerSize: CGSize(width: cornerRadius, height: cornerRadius)
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - TriggerMode
|
||||||
|
|
||||||
|
/// Controls when a tour is automatically started.
|
||||||
|
enum TriggerMode: Hashable {
|
||||||
|
/// Started explicitly by app code; never auto-started by checkForPendingTours().
|
||||||
|
case manualOrFirstLaunch
|
||||||
|
/// Auto-started when the app version advances past `minAppVersion` and tour hasn't been seen.
|
||||||
|
case autoOnUpdate
|
||||||
|
/// Never auto-started; only via explicit `TourCoordinator.start(_:)` call.
|
||||||
|
case manualOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tour
|
||||||
|
|
||||||
|
/// Metadata and steps for a single guided tour. Max 6 steps is enforced by precondition.
|
||||||
|
struct Tour: Identifiable, Hashable {
|
||||||
|
let id: TourID
|
||||||
|
let title: LocalizedStringResource
|
||||||
|
let steps: [TourStep]
|
||||||
|
/// Semantic version string, e.g. "1.0". Used for update-tour gating.
|
||||||
|
let minAppVersion: String
|
||||||
|
let triggerMode: TriggerMode
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: TourID,
|
||||||
|
title: LocalizedStringResource,
|
||||||
|
steps: [TourStep],
|
||||||
|
minAppVersion: String,
|
||||||
|
triggerMode: TriggerMode
|
||||||
|
) {
|
||||||
|
precondition(!steps.isEmpty, "A tour must have at least 1 step.")
|
||||||
|
precondition(steps.count <= 6, "A tour must not exceed 6 steps. Got \(steps.count).")
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.steps = steps
|
||||||
|
self.minAppVersion = minAppVersion
|
||||||
|
self.triggerMode = triggerMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equatable / Hashable based on id
|
||||||
|
static func == (lhs: Tour, rhs: Tour) -> Bool { lhs.id == rhs.id }
|
||||||
|
func hash(into hasher: inout Hasher) { hasher.combine(id) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - TourCardView
|
||||||
|
|
||||||
|
/// The info card shown during a tour step. Contains progress dots, title, body, and navigation buttons.
|
||||||
|
struct TourCardView: View {
|
||||||
|
|
||||||
|
let coordinator: TourCoordinator
|
||||||
|
let totalSteps: Int
|
||||||
|
let currentIndex: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
|
||||||
|
// ── Header: progress dots + close button ──────────────────────────
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
progressDots
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
coordinator.close()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.secondary.opacity(0.12), in: Circle())
|
||||||
|
}
|
||||||
|
.accessibilityLabel(Text("tour.common.close"))
|
||||||
|
}
|
||||||
|
.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.common.skip")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(Text("tour.common.skip"))
|
||||||
|
|
||||||
|
Text(verbatim: String.localizedStringWithFormat(
|
||||||
|
String(localized: "tour.common.stepCounter %lld %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("tour.common.back")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next / Finish button
|
||||||
|
Button {
|
||||||
|
coordinator.next()
|
||||||
|
} label: {
|
||||||
|
Text(coordinator.isLastStep ? "tour.common.finish" : "tour.common.next")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(NahbarInsightStyle.accentPetrol, in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.18), radius: 24, y: 10)
|
||||||
|
.sensoryFeedback(.selection, trigger: currentIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Progress Dots
|
||||||
|
|
||||||
|
private var progressDots: some View {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
ForEach(0..<totalSteps, id: \.self) { i in
|
||||||
|
Capsule()
|
||||||
|
.fill(i == currentIndex ? NahbarInsightStyle.accentPetrol : Color.secondary.opacity(0.3))
|
||||||
|
.frame(
|
||||||
|
width: i == currentIndex ? 20 : 6,
|
||||||
|
height: 6
|
||||||
|
)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: currentIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Schritt \(currentIndex + 1) von \(totalSteps)")
|
||||||
|
.accessibilityHidden(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - TourCatalog
|
||||||
|
|
||||||
|
/// Static registry of all tours defined in the app.
|
||||||
|
/// New tours are added as static properties and included in `all`.
|
||||||
|
enum TourCatalog {
|
||||||
|
|
||||||
|
// MARK: Onboarding Tour
|
||||||
|
|
||||||
|
static let onboarding = Tour(
|
||||||
|
id: .onboarding,
|
||||||
|
title: "tour.onboarding.title",
|
||||||
|
steps: [
|
||||||
|
TourStep(
|
||||||
|
title: "tour.onboarding.step1.title",
|
||||||
|
body: "tour.onboarding.step1.body",
|
||||||
|
target: nil,
|
||||||
|
preferredCardPosition: .center
|
||||||
|
),
|
||||||
|
TourStep(
|
||||||
|
title: "tour.onboarding.step2.title",
|
||||||
|
body: "tour.onboarding.step2.body",
|
||||||
|
target: nil,
|
||||||
|
preferredCardPosition: .center
|
||||||
|
),
|
||||||
|
TourStep(
|
||||||
|
title: "tour.onboarding.step3.title",
|
||||||
|
body: "tour.onboarding.step3.body",
|
||||||
|
target: nil,
|
||||||
|
preferredCardPosition: .center
|
||||||
|
),
|
||||||
|
TourStep(
|
||||||
|
title: "tour.onboarding.step4.title",
|
||||||
|
body: "tour.onboarding.step4.body",
|
||||||
|
target: nil,
|
||||||
|
preferredCardPosition: .center
|
||||||
|
),
|
||||||
|
TourStep(
|
||||||
|
title: "tour.onboarding.step5.title",
|
||||||
|
body: "tour.onboarding.step5.body",
|
||||||
|
target: nil,
|
||||||
|
preferredCardPosition: .center
|
||||||
|
),
|
||||||
|
TourStep(
|
||||||
|
title: "tour.onboarding.step6.title",
|
||||||
|
body: "tour.onboarding.step6.body",
|
||||||
|
target: nil,
|
||||||
|
preferredCardPosition: .center
|
||||||
|
),
|
||||||
|
],
|
||||||
|
minAppVersion: "1.0",
|
||||||
|
triggerMode: .manualOrFirstLaunch
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: Registry
|
||||||
|
|
||||||
|
/// All tours known to the app. Order matters for display in Settings.
|
||||||
|
static let all: [Tour] = [onboarding]
|
||||||
|
|
||||||
|
/// Looks up a tour by its ID.
|
||||||
|
static func tour(for id: TourID) -> Tour? {
|
||||||
|
all.first { $0.id == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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() }
|
||||||
|
|
||||||
|
// MARK: Target Frames (populated by tourPresenter overlay)
|
||||||
|
|
||||||
|
/// Not observed — updated by the tourPresenter via preference keys.
|
||||||
|
@ObservationIgnored var targetFrames: [TourTargetID: CGRect] = [:]
|
||||||
|
|
||||||
|
// MARK: Private
|
||||||
|
|
||||||
|
private func completeTour() {
|
||||||
|
guard let tour = activeTour else { return }
|
||||||
|
seenStore.markSeen(tour.id)
|
||||||
|
|
||||||
|
// Capture and clear callbacks before resetting state
|
||||||
|
let completion = onTourComplete
|
||||||
|
onTourComplete = nil
|
||||||
|
activeTour = nil
|
||||||
|
currentStepIndex = 0
|
||||||
|
|
||||||
|
logger.info("Tour abgeschlossen: \(tour.id.rawValue)")
|
||||||
|
completion?()
|
||||||
|
|
||||||
|
// If pending queue is now empty, update the stored version
|
||||||
|
if pendingQueue.isEmpty {
|
||||||
|
seenStore.lastSeenAppVersion = appVersionProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
startNextInQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startNextInQueue() {
|
||||||
|
guard !pendingQueue.isEmpty, activeTour == nil else { return }
|
||||||
|
let nextID = pendingQueue.removeFirst()
|
||||||
|
start(nextID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func versionIsNewer(_ version: String, than other: String) -> Bool {
|
||||||
|
guard !version.isEmpty else { return false }
|
||||||
|
if other.isEmpty { return true }
|
||||||
|
return version.compare(other, options: .numeric) == .orderedDescending
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - TourID
|
||||||
|
|
||||||
|
/// Identifies a concrete tour. New tours are added here.
|
||||||
|
enum TourID: String, CaseIterable, Codable, Hashable {
|
||||||
|
case onboarding
|
||||||
|
case v1_2_besuchsfragebogen
|
||||||
|
case v1_3_personality
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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 {
|
||||||
|
// Material layer — blurs only the non-spotlight area
|
||||||
|
Rectangle()
|
||||||
|
.fill(.thinMaterial)
|
||||||
|
.mask {
|
||||||
|
SpotlightShape(spotlight: paddedSpotlight, cornerRadius: cornerRadius)
|
||||||
|
.fill(.white, style: FillStyle(eoFill: true))
|
||||||
|
}
|
||||||
|
.frame(width: fullRect.width, height: fullRect.height)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// Dark tint — darkens only the non-spotlight area
|
||||||
|
SpotlightShape(spotlight: paddedSpotlight, cornerRadius: cornerRadius)
|
||||||
|
.fill(Color.black.opacity(0.35), 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: simple dark + blur overlay (centered card scenario)
|
||||||
|
Rectangle()
|
||||||
|
.fill(.thinMaterial)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
Color.black.opacity(0.35)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Spotlight Glow
|
||||||
|
|
||||||
|
private var spotlightGlow: some View {
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||||
|
.strokeBorder(NahbarInsightStyle.accentCoral.opacity(0.6), lineWidth: 1.5)
|
||||||
|
.shadow(color: NahbarInsightStyle.accentCoral.opacity(0.45), radius: 12)
|
||||||
|
.frame(width: paddedSpotlight.width, height: paddedSpotlight.height)
|
||||||
|
.position(x: paddedSpotlight.midX, y: paddedSpotlight.midY)
|
||||||
|
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: paddedSpotlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Tour Card
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func tourCard(in geo: GeometryProxy) -> some View {
|
||||||
|
let cardWidth: CGFloat = min(geo.size.width - 48, 380)
|
||||||
|
let cardY = cardYPosition(in: geo)
|
||||||
|
|
||||||
|
TourCardView(
|
||||||
|
coordinator: coordinator,
|
||||||
|
totalSteps: coordinator.stepCount,
|
||||||
|
currentIndex: coordinator.currentStepIndex
|
||||||
|
)
|
||||||
|
.frame(width: cardWidth)
|
||||||
|
.position(x: geo.size.width / 2, y: cardY)
|
||||||
|
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: coordinator.currentStepIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cardYPosition(in geo: GeometryProxy) -> CGFloat {
|
||||||
|
let screenHeight = geo.size.height
|
||||||
|
let cardHeight: CGFloat = 260 // Approximate card height
|
||||||
|
let margin: CGFloat = 24
|
||||||
|
let safeTop = geo.safeAreaInsets.top
|
||||||
|
let safeBottom = geo.safeAreaInsets.bottom
|
||||||
|
|
||||||
|
guard hasSpotlight else {
|
||||||
|
// No spotlight → center the card
|
||||||
|
return screenHeight / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
let step = coordinator.currentStep
|
||||||
|
let position = step?.preferredCardPosition ?? .auto
|
||||||
|
|
||||||
|
// Space above and below spotlight
|
||||||
|
let spaceAbove = paddedSpotlight.minY - safeTop - margin
|
||||||
|
let spaceBelow = screenHeight - paddedSpotlight.maxY - safeBottom - margin
|
||||||
|
|
||||||
|
switch position {
|
||||||
|
case .above:
|
||||||
|
return paddedSpotlight.minY - cardHeight / 2 - margin
|
||||||
|
case .below:
|
||||||
|
return paddedSpotlight.maxY + cardHeight / 2 + margin
|
||||||
|
case .center:
|
||||||
|
return screenHeight / 2
|
||||||
|
case .auto:
|
||||||
|
if spaceBelow >= cardHeight {
|
||||||
|
return paddedSpotlight.maxY + cardHeight / 2 + margin
|
||||||
|
} else if spaceAbove >= cardHeight {
|
||||||
|
return paddedSpotlight.minY - cardHeight / 2 - margin
|
||||||
|
} else {
|
||||||
|
return screenHeight / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "nahbar", category: "TourSeenStore")
|
||||||
|
|
||||||
|
// MARK: - TourSeenStore
|
||||||
|
|
||||||
|
/// Persists which tour IDs have been completed and the last-seen app version.
|
||||||
|
/// Injected into TourCoordinator to keep it testable.
|
||||||
|
final class TourSeenStore {
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private let seenIDsKey = "nahbar.tour.seenIDs"
|
||||||
|
private let lastSeenVersionKey = "nahbar.tour.lastSeenAppVersion"
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Seen IDs
|
||||||
|
|
||||||
|
func hasSeen(_ id: TourID) -> Bool {
|
||||||
|
seenIDs.contains(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markSeen(_ id: TourID) {
|
||||||
|
var ids = seenIDs
|
||||||
|
ids.insert(id)
|
||||||
|
seenIDs = ids
|
||||||
|
logger.info("Tour als gesehen markiert: \(id.rawValue)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
defaults.removeObject(forKey: seenIDsKey)
|
||||||
|
defaults.removeObject(forKey: lastSeenVersionKey)
|
||||||
|
logger.info("TourSeenStore zurückgesetzt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Last Seen App Version
|
||||||
|
|
||||||
|
var lastSeenAppVersion: String? {
|
||||||
|
get { defaults.string(forKey: lastSeenVersionKey) }
|
||||||
|
set {
|
||||||
|
if let newValue {
|
||||||
|
defaults.set(newValue, forKey: lastSeenVersionKey)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: lastSeenVersionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Private
|
||||||
|
|
||||||
|
private var seenIDs: Set<TourID> {
|
||||||
|
get {
|
||||||
|
guard let data = defaults.data(forKey: seenIDsKey),
|
||||||
|
let ids = try? JSONDecoder().decode(Set<TourID>.self, from: data)
|
||||||
|
else { return [] }
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard let data = try? JSONEncoder().encode(newValue) else { return }
|
||||||
|
defaults.set(data, forKey: seenIDsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - CardPosition
|
||||||
|
|
||||||
|
/// Controls where the info card is placed relative to the spotlight.
|
||||||
|
enum CardPosition: Hashable {
|
||||||
|
case auto
|
||||||
|
case above
|
||||||
|
case below
|
||||||
|
case center
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TourStep
|
||||||
|
|
||||||
|
/// A single step in a guided tour.
|
||||||
|
struct TourStep: Identifiable, Hashable {
|
||||||
|
static func == (lhs: TourStep, rhs: TourStep) -> Bool { lhs.id == rhs.id }
|
||||||
|
func hash(into hasher: inout Hasher) { hasher.combine(id) }
|
||||||
|
let id: UUID
|
||||||
|
let title: LocalizedStringResource
|
||||||
|
let body: LocalizedStringResource
|
||||||
|
/// `nil` means a centered intro card with no spotlight.
|
||||||
|
let target: TourTargetID?
|
||||||
|
let preferredCardPosition: CardPosition
|
||||||
|
let spotlightPadding: CGFloat
|
||||||
|
let spotlightCornerRadius: CGFloat?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
title: LocalizedStringResource,
|
||||||
|
body: LocalizedStringResource,
|
||||||
|
target: TourTargetID? = nil,
|
||||||
|
preferredCardPosition: CardPosition = .auto,
|
||||||
|
spotlightPadding: CGFloat = 12,
|
||||||
|
spotlightCornerRadius: CGFloat? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.body = body
|
||||||
|
self.target = target
|
||||||
|
self.preferredCardPosition = preferredCardPosition
|
||||||
|
self.spotlightPadding = spotlightPadding
|
||||||
|
self.spotlightCornerRadius = spotlightCornerRadius
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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 personalityTab
|
||||||
|
case insightsTab
|
||||||
|
case settingsEntry
|
||||||
|
case todayTab
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,91 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import nahbar
|
||||||
|
|
||||||
|
// MARK: - TourCatalog Tests
|
||||||
|
|
||||||
|
@Suite("TourCatalog – Validierung")
|
||||||
|
struct TourCatalogTests {
|
||||||
|
|
||||||
|
@Test("Alle Touren haben mindestens 1 und höchstens 6 Steps")
|
||||||
|
func allToursHaveValidStepCount() {
|
||||||
|
for tour in TourCatalog.all {
|
||||||
|
#expect(!tour.steps.isEmpty, "Tour \(tour.id.rawValue) hat keine Steps")
|
||||||
|
#expect(tour.steps.count <= 6, "Tour \(tour.id.rawValue) hat mehr als 6 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 6 Steps")
|
||||||
|
func onboardingTourHasSixSteps() {
|
||||||
|
#expect(TourCatalog.onboarding.steps.count == 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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: - 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,191 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user