diff --git a/nahbar/nahbar.xcodeproj/project.pbxproj b/nahbar/nahbar.xcodeproj/project.pbxproj index 844161b..c370967 100644 --- a/nahbar/nahbar.xcodeproj/project.pbxproj +++ b/nahbar/nahbar.xcodeproj/project.pbxproj @@ -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 = ""; }; + 263FF44B2F99356E00C1957C /* TourTargetID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourTargetID.swift; sourceTree = ""; }; + 263FF44D2F99357500C1957C /* TourStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourStep.swift; sourceTree = ""; }; + 263FF44F2F99357F00C1957C /* Tour.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tour.swift; sourceTree = ""; }; + 263FF4512F99358800C1957C /* TourCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourCatalog.swift; sourceTree = ""; }; + 263FF4532F99359600C1957C /* TourSeenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourSeenStore.swift; sourceTree = ""; }; + 263FF4552F9935AC00C1957C /* TourCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourCoordinator.swift; sourceTree = ""; }; + 263FF4572F9935BC00C1957C /* SpotlightShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightShape.swift; sourceTree = ""; }; + 263FF4592F9935CD00C1957C /* TourCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourCardView.swift; sourceTree = ""; }; + 263FF45B2F9935E400C1957C /* TourOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourOverlayView.swift; sourceTree = ""; }; + 263FF45D2F9935EF00C1957C /* TourViewModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TourViewModifiers.swift; sourceTree = ""; }; 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 = ""; }; 269ECE652F92B5C700444B14 /* NahbarMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NahbarMigration.swift; sourceTree = ""; }; @@ -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 = ""; + }; 265F92172F9109B500CE0A5C = { isa = PBXGroup; children = ( @@ -290,6 +330,7 @@ 26F8B0D22F94E7ED004905B9 /* PersonalityComponents.swift */, 2670595B2F96640E00956084 /* CalendarManager.swift */, 26D07C682F9866DE001D3F98 /* AddTodoView.swift */, + 263FF4482F99356600C1957C /* Tour */, ); path = nahbar; sourceTree = ""; @@ -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 */, diff --git a/nahbar/nahbar/ContentView.swift b/nahbar/nahbar/ContentView.swift index f69c4f4..8bc3421 100644 --- a/nahbar/nahbar/ContentView.swift +++ b/nahbar/nahbar/ContentView.swift @@ -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 diff --git a/nahbar/nahbar/Localizable.xcstrings b/nahbar/nahbar/Localizable.xcstrings index 473e9c3..be6db62 100644 --- a/nahbar/nahbar/Localizable.xcstrings +++ b/nahbar/nahbar/Localizable.xcstrings @@ -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", diff --git a/nahbar/nahbar/NahbarApp.swift b/nahbar/nahbar/NahbarApp.swift index e10f38f..b5479a3 100644 --- a/nahbar/nahbar/NahbarApp.swift +++ b/nahbar/nahbar/NahbarApp.swift @@ -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) diff --git a/nahbar/nahbar/OnboardingContainerView.swift b/nahbar/nahbar/OnboardingContainerView.swift index 05a16e4..6f380c1 100644 --- a/nahbar/nahbar/OnboardingContainerView.swift +++ b/nahbar/nahbar/OnboardingContainerView.swift @@ -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 } } diff --git a/nahbar/nahbar/SettingsView.swift b/nahbar/nahbar/SettingsView.swift index 6b3a73b..57e8577 100644 --- a/nahbar/nahbar/SettingsView.swift +++ b/nahbar/nahbar/SettingsView.swift @@ -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") diff --git a/nahbar/nahbar/Tour/SpotlightShape.swift b/nahbar/nahbar/Tour/SpotlightShape.swift new file mode 100644 index 0000000..0f42c9d --- /dev/null +++ b/nahbar/nahbar/Tour/SpotlightShape.swift @@ -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, + AnimatablePair, 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 + } +} diff --git a/nahbar/nahbar/Tour/Tour.swift b/nahbar/nahbar/Tour/Tour.swift new file mode 100644 index 0000000..0b0464a --- /dev/null +++ b/nahbar/nahbar/Tour/Tour.swift @@ -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) } +} diff --git a/nahbar/nahbar/Tour/TourCardView.swift b/nahbar/nahbar/Tour/TourCardView.swift new file mode 100644 index 0000000..40b410b --- /dev/null +++ b/nahbar/nahbar/Tour/TourCardView.swift @@ -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.. Tour? { + all.first { $0.id == id } + } +} diff --git a/nahbar/nahbar/Tour/TourCoordinator.swift b/nahbar/nahbar/Tour/TourCoordinator.swift new file mode 100644 index 0000000..5a631a0 --- /dev/null +++ b/nahbar/nahbar/Tour/TourCoordinator.swift @@ -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 + } +} diff --git a/nahbar/nahbar/Tour/TourID.swift b/nahbar/nahbar/Tour/TourID.swift new file mode 100644 index 0000000..b65a68e --- /dev/null +++ b/nahbar/nahbar/Tour/TourID.swift @@ -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 +} diff --git a/nahbar/nahbar/Tour/TourOverlayView.swift b/nahbar/nahbar/Tour/TourOverlayView.swift new file mode 100644 index 0000000..da4474f --- /dev/null +++ b/nahbar/nahbar/Tour/TourOverlayView.swift @@ -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 + } + } + } +} diff --git a/nahbar/nahbar/Tour/TourSeenStore.swift b/nahbar/nahbar/Tour/TourSeenStore.swift new file mode 100644 index 0000000..7e74127 --- /dev/null +++ b/nahbar/nahbar/Tour/TourSeenStore.swift @@ -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 { + get { + guard let data = defaults.data(forKey: seenIDsKey), + let ids = try? JSONDecoder().decode(Set.self, from: data) + else { return [] } + return ids + } + set { + guard let data = try? JSONEncoder().encode(newValue) else { return } + defaults.set(data, forKey: seenIDsKey) + } + } +} diff --git a/nahbar/nahbar/Tour/TourStep.swift b/nahbar/nahbar/Tour/TourStep.swift new file mode 100644 index 0000000..680264a --- /dev/null +++ b/nahbar/nahbar/Tour/TourStep.swift @@ -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 + } +} diff --git a/nahbar/nahbar/Tour/TourTargetID.swift b/nahbar/nahbar/Tour/TourTargetID.swift new file mode 100644 index 0000000..7854d03 --- /dev/null +++ b/nahbar/nahbar/Tour/TourTargetID.swift @@ -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 +} diff --git a/nahbar/nahbar/Tour/TourViewModifiers.swift b/nahbar/nahbar/Tour/TourViewModifiers.swift new file mode 100644 index 0000000..fe0d6a7 --- /dev/null +++ b/nahbar/nahbar/Tour/TourViewModifiers.swift @@ -0,0 +1,46 @@ +import SwiftUI + +// MARK: - TourTargetPreferenceKey + +/// Collects Anchor values for each registered TourTargetID. +struct TourTargetPreferenceKey: PreferenceKey { + static var defaultValue: [TourTargetID: Anchor] = [:] + + static func reduce( + value: inout [TourTargetID: Anchor], + nextValue: () -> [TourTargetID: Anchor] + ) { + 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() + } + } +} diff --git a/nahbar/nahbarTests/Tour/AutoStartLogicTests.swift b/nahbar/nahbarTests/Tour/AutoStartLogicTests.swift new file mode 100644 index 0000000..4c0760e --- /dev/null +++ b/nahbar/nahbarTests/Tour/AutoStartLogicTests.swift @@ -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) + } +} diff --git a/nahbar/nahbarTests/Tour/TourCatalogTests.swift b/nahbar/nahbarTests/Tour/TourCatalogTests.swift new file mode 100644 index 0000000..bb7d748 --- /dev/null +++ b/nahbar/nahbarTests/Tour/TourCatalogTests.swift @@ -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) + } +} diff --git a/nahbar/nahbarTests/Tour/TourCoordinatorTests.swift b/nahbar/nahbarTests/Tour/TourCoordinatorTests.swift new file mode 100644 index 0000000..f6c3607 --- /dev/null +++ b/nahbar/nahbarTests/Tour/TourCoordinatorTests.swift @@ -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) + } +} diff --git a/nahbar/nahbarTests/Tour/TourSeenStoreTests.swift b/nahbar/nahbarTests/Tour/TourSeenStoreTests.swift new file mode 100644 index 0000000..6b353fb --- /dev/null +++ b/nahbar/nahbarTests/Tour/TourSeenStoreTests.swift @@ -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) + } +}