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 = {
|
||||
|
||||
/* 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 */; };
|
||||
269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269ECE652F92B5C700444B14 /* NahbarMigration.swift */; };
|
||||
26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B1E2DA2F93985A009CF58B /* CloudSyncMonitor.swift */; };
|
||||
@@ -99,6 +110,17 @@
|
||||
/* End PBXCopyFilesBuildPhase 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; };
|
||||
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>"; };
|
||||
@@ -211,6 +233,24 @@
|
||||
/* End PBXFrameworksBuildPhase 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 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -290,6 +330,7 @@
|
||||
26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */,
|
||||
2670595B2F96640E00956084 /* CalendarManager.swift */,
|
||||
26D07C682F9866DE001D3F98 /* AddTodoView.swift */,
|
||||
263FF4482F99356600C1957C /* Tour */,
|
||||
);
|
||||
path = nahbar;
|
||||
sourceTree = "<group>";
|
||||
@@ -441,7 +482,9 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
269ECE662F92B5C700444B14 /* NahbarMigration.swift in Sources */,
|
||||
263FF4522F99358800C1957C /* TourCatalog.swift in Sources */,
|
||||
26EF66322F9112E700824F91 /* Models.swift in Sources */,
|
||||
263FF44A2F99356A00C1957C /* TourID.swift in Sources */,
|
||||
26F8B0CF2F94E7B1004905B9 /* PersonalityQuizView.swift in Sources */,
|
||||
26EF66332F9112E700824F91 /* TodayView.swift in Sources */,
|
||||
26EF66412F9129F000824F91 /* CallWindowSetupView.swift in Sources */,
|
||||
@@ -449,14 +492,18 @@
|
||||
26B9930C2F94B32800E9B16C /* PrivacyBadgeView.swift in Sources */,
|
||||
26B2CAE72F93C03F0039BA3B /* VisitRatingFlowView.swift in Sources */,
|
||||
26BB85BB2F924D9B00889312 /* StoreManager.swift in Sources */,
|
||||
263FF45E2F9935EF00C1957C /* TourViewModifiers.swift in Sources */,
|
||||
26EF66492F91352D00824F91 /* AppLockSetupView.swift in Sources */,
|
||||
26EF66432F912A0000824F91 /* CallSuggestionView.swift in Sources */,
|
||||
263FF4542F99359600C1957C /* TourSeenStore.swift in Sources */,
|
||||
26B2CAB62F93B55F0039BA3B /* IchView.swift in Sources */,
|
||||
26B2CAF12F93C52C0039BA3B /* VisitEditFlowView.swift in Sources */,
|
||||
26F8B0C52F94E47F004905B9 /* PersonalityModels.swift in Sources */,
|
||||
26EF66452F91350200824F91 /* AppLockManager.swift in Sources */,
|
||||
26B2CAEB2F93C05A0039BA3B /* VisitSummaryView.swift in Sources */,
|
||||
26B2CAE32F93C0180039BA3B /* RatingQuestionView.swift in Sources */,
|
||||
263FF45A2F9935CD00C1957C /* TourCardView.swift in Sources */,
|
||||
263FF44C2F99356E00C1957C /* TourTargetID.swift in Sources */,
|
||||
26EF66342F9112E700824F91 /* PersonDetailView.swift in Sources */,
|
||||
26EF66352F9112E700824F91 /* ThemeSystem.swift in Sources */,
|
||||
26EF66362F9112E700824F91 /* PeopleListView.swift in Sources */,
|
||||
@@ -466,11 +513,15 @@
|
||||
26EF66382F9112E700824F91 /* SettingsView.swift in Sources */,
|
||||
26EF66392F9112E700824F91 /* AddMomentView.swift in Sources */,
|
||||
26EF663A2F9112E700824F91 /* NahbarApp.swift in Sources */,
|
||||
263FF4562F9935AC00C1957C /* TourCoordinator.swift in Sources */,
|
||||
26BB85C52F926A1C00889312 /* AppGroup.swift in Sources */,
|
||||
26F8B0D12F94E7D5004905B9 /* PersonalityResultView.swift in Sources */,
|
||||
26EF66472F91351800824F91 /* AppLockView.swift in Sources */,
|
||||
26B2CAB82F93B7570039BA3B /* NahbarLogger.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 */,
|
||||
26B2CABA2F93B76E0039BA3B /* LogExportView.swift in Sources */,
|
||||
26B2CAB42F93B52B0039BA3B /* UserProfileStore.swift in Sources */,
|
||||
@@ -481,6 +532,7 @@
|
||||
26B1E2DB2F93985A009CF58B /* CloudSyncMonitor.swift in Sources */,
|
||||
26B993102F94B34C00E9B16C /* OnboardingCoordinator.swift in Sources */,
|
||||
26BB85C12F92525200889312 /* AIAnalysisService.swift in Sources */,
|
||||
263FF4582F9935BC00C1957C /* SpotlightShape.swift in Sources */,
|
||||
26EF663C2F9112E700824F91 /* ContactPickerView.swift in Sources */,
|
||||
26B2CAE12F93C0080039BA3B /* RatingDotPicker.swift in Sources */,
|
||||
26EF664E2F91514B00824F91 /* ThemePickerView.swift in Sources */,
|
||||
|
||||
@@ -19,6 +19,8 @@ struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.nahbarTheme) private var theme
|
||||
|
||||
@Environment(TourCoordinator.self) private var tourCoordinator
|
||||
|
||||
@Query private var persons: [Person]
|
||||
|
||||
@State private var showingNahbarOnboarding = false
|
||||
@@ -83,6 +85,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.tourPresenter(coordinator: tourCoordinator)
|
||||
.onAppear {
|
||||
syncPeopleCache()
|
||||
importPendingMoments()
|
||||
@@ -95,6 +98,7 @@ struct ContentView: View {
|
||||
showingOnboarding = true
|
||||
} else {
|
||||
checkCallWindow()
|
||||
tourCoordinator.checkForPendingTours()
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
|
||||
@@ -869,6 +869,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"App-Touren" : {
|
||||
|
||||
},
|
||||
"Arbeit" : {
|
||||
"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" : {
|
||||
"comment" : "MomentSource.signal raw value",
|
||||
"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" : {
|
||||
"comment" : "MomentType.meeting rawValue + VisitHistorySection / SettingsView section header",
|
||||
|
||||
@@ -46,6 +46,9 @@ struct NahbarApp: App {
|
||||
@StateObject private var profileStore = UserProfileStore.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("icloudSyncEnabled") private var icloudSyncEnabled: Bool = false
|
||||
|
||||
@@ -66,6 +69,7 @@ struct NahbarApp: App {
|
||||
.environmentObject(cloudSyncMonitor)
|
||||
.environmentObject(profileStore)
|
||||
.environmentObject(eventLog)
|
||||
.environment(tourCoordinator)
|
||||
// Verhindert Touch-Durchfall bei aktivem Splash- oder Lock-Screen
|
||||
.allowsHitTesting(!showSplash && !appLockManager.isLocked)
|
||||
|
||||
|
||||
@@ -17,17 +17,18 @@ struct OnboardingContainerView: View {
|
||||
@StateObject private var coordinator = OnboardingCoordinator()
|
||||
@Environment(\.contactStore) private var contactStore
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(TourCoordinator.self) private var tourCoordinator
|
||||
|
||||
/// Current tab page index (0 = profile, 1 = contacts).
|
||||
@State private var tabPage: Int = 0
|
||||
/// Whether the feature tour overlay is visible.
|
||||
@State private var showTour: Bool = false
|
||||
/// Tracks whether the onboarding tour was started (to detect its completion).
|
||||
@State private var onboardingTourStarted: Bool = false
|
||||
/// Whether the final privacy screen is visible (shown after the feature tour).
|
||||
@State private var showPrivacyScreen: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// ── Background pages (blurred when tour or privacy screen is active) ──
|
||||
// ── Background pages (blurred when privacy screen is active) ──────
|
||||
TabView(selection: $tabPage) {
|
||||
OnboardingProfileView(coordinator: coordinator)
|
||||
.tag(0)
|
||||
@@ -43,29 +44,25 @@ struct OnboardingContainerView: View {
|
||||
.tag(2)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.blur(radius: (showTour || showPrivacyScreen) ? 20 : 0)
|
||||
.disabled(showTour || showPrivacyScreen)
|
||||
.blur(radius: showPrivacyScreen ? 20 : 0)
|
||||
.disabled(showPrivacyScreen)
|
||||
|
||||
// ── Dark overlay ─────────────────────────────────────────────────
|
||||
if showTour || showPrivacyScreen {
|
||||
// ── Dark overlay when privacy screen shown ────────────────────────
|
||||
if showPrivacyScreen {
|
||||
Color.black.opacity(0.45)
|
||||
.ignoresSafeArea()
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
// ── Feature tour ─────────────────────────────────────────────────
|
||||
if showTour {
|
||||
FeatureTourView(onFinish: startPrivacyScreen)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
// ── Privacy screen (final step) ───────────────────────────────────
|
||||
if showPrivacyScreen {
|
||||
OnboardingPrivacyView(onFinish: finishOnboarding)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.35), value: showTour)
|
||||
// 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)
|
||||
.onChange(of: coordinator.currentStep) { _, step in
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||
@@ -73,15 +70,22 @@ struct OnboardingContainerView: View {
|
||||
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() {
|
||||
withAnimation { showTour = true }
|
||||
onboardingTourStarted = true
|
||||
tourCoordinator.start(.onboarding)
|
||||
}
|
||||
|
||||
private func startPrivacyScreen() {
|
||||
withAnimation(.easeInOut(duration: 0.35)) {
|
||||
showTour = false
|
||||
showPrivacyScreen = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ struct SettingsView: View {
|
||||
@StateObject private var store = StoreManager.shared
|
||||
@StateObject private var personalityStore = PersonalityStore.shared
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(TourCoordinator.self) private var tourCoordinator
|
||||
|
||||
@State private var showingPINSetup = false
|
||||
@State private var showingPINDisable = 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
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
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