diff --git a/nahbar/nahbar/ContentView.swift b/nahbar/nahbar/ContentView.swift index 8bc3421..1e8cffb 100644 --- a/nahbar/nahbar/ContentView.swift +++ b/nahbar/nahbar/ContentView.swift @@ -5,6 +5,10 @@ import OSLog private let logger = Logger(subsystem: "nahbar", category: "ContentView") struct ContentView: View { + /// Wird von NahbarApp auf `true` gesetzt, solange der Splash-Screen sichtbar ist. + /// UI-Präsentation (Sheets, Onboarding) startet erst, wenn dieser Wert auf `false` wechselt. + var splashVisible: Bool = false + @AppStorage("nahbarOnboardingDone") private var nahbarOnboardingDone = false @AppStorage("callWindowOnboardingDone") private var onboardingDone = false @AppStorage("callSuggestionDate") private var suggestionDateStr = "" @@ -27,38 +31,47 @@ struct ContentView: View { @State private var showingOnboarding = false @State private var suggestedPerson: Person? = nil @State private var showingSuggestion = false + /// Steuert den aktiven Tab; nötig damit die Tour auf dem Menschen-Tab starten kann. + @State private var selectedTab: Int = 0 var body: some View { - TabView { + TabView(selection: $selectedTab) { TodayView() .tabItem { Label("Heute", systemImage: "sun.max") } .toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar) .toolbarBackground(.visible, for: .tabBar) .toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar) + .tag(0) PeopleListView() .tabItem { Label("Menschen", systemImage: "person.2") } .toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar) .toolbarBackground(.visible, for: .tabBar) .toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar) + .tag(1) IchView() .tabItem { Label("Ich", systemImage: "person.circle") } .toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar) .toolbarBackground(.visible, for: .tabBar) .toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar) + .tag(2) SettingsView() .tabItem { Label("Einstellungen", systemImage: "gearshape") } .toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar) .toolbarBackground(.visible, for: .tabBar) .toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar) + .tag(3) } .fullScreenCover(isPresented: $showingNahbarOnboarding) { OnboardingContainerView { nahbarOnboardingDone = true showingNahbarOnboarding = false checkCallWindow() + // Tour nach Onboarding: Tab-Wechsel + Verzögerung nötig damit + // PeopleListView gerendert ist und anchorPreference-Frames gesammelt wurden. + scheduleTourIfNeeded() } } .sheet(isPresented: $showingOnboarding) { @@ -87,25 +100,28 @@ struct ContentView: View { } .tourPresenter(coordinator: tourCoordinator) .onAppear { + // Datenoperationen sofort starten (unabhängig vom Splash-Screen) syncPeopleCache() importPendingMoments() runPhotoRepairPass() runVisitMigrationPass() runNextStepMigrationPass() - if !nahbarOnboardingDone { - showingNahbarOnboarding = true - } else if !onboardingDone { - showingOnboarding = true - } else { - checkCallWindow() - tourCoordinator.checkForPendingTours() + // UI-Präsentation erst nach dem Splash + if !splashVisible { + showPendingUI() + } + } + .onChange(of: splashVisible) { _, visible in + // Sobald der Splash verschwindet, UI-Logik starten + if !visible { + showPendingUI() } } .onChange(of: scenePhase) { _, phase in if phase == .active { syncPeopleCache() importPendingMoments() - checkCallWindow() + if !splashVisible { checkCallWindow() } } } .onChange(of: persons) { _, _ in @@ -113,6 +129,36 @@ struct ContentView: View { } } + // MARK: - Pending UI + + /// Startet die UI-Präsentation nach dem Splash-Screen: + /// Onboarding, Call-Window-Setup oder Gesprächsvorschlag. + private func showPendingUI() { + if !nahbarOnboardingDone { + showingNahbarOnboarding = true + } else if !onboardingDone { + showingOnboarding = true + } else { + checkCallWindow() + tourCoordinator.checkForPendingTours() + scheduleTourIfNeeded() + } + } + + // MARK: - Tour + + /// Wechselt zum Menschen-Tab (damit PeopleListView gerendert wird und seine + /// anchorPreference-Frames sammeln kann), wartet 700 ms und startet dann die Tour. + /// Die Verzögerung deckt die fullScreenCover-Dismiss-Animation (~350 ms) ab. + private func scheduleTourIfNeeded() { + guard !tourCoordinator.hasSeenOnboardingTour else { return } + selectedTab = 1 + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(700)) + tourCoordinator.startOnboardingTourIfNeeded() + } + } + // MARK: - Call Window private func checkCallWindow() { diff --git a/nahbar/nahbar/Localizable.xcstrings b/nahbar/nahbar/Localizable.xcstrings index de6b12a..924580a 100644 --- a/nahbar/nahbar/Localizable.xcstrings +++ b/nahbar/nahbar/Localizable.xcstrings @@ -969,6 +969,17 @@ } } }, + "Aufgabe für eine Person anlegen" : { + "comment" : "TodayView – empty state: secondary CTA button subtitle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a task for someone" + } + } + } + }, "Aus Kontakten ausfüllen" : { "localizations" : { "en" : { @@ -2109,17 +2120,6 @@ } } }, - "Erfasse Treffen, Nachrichten und Erlebnisse. So weißt du, worüber ihr das letzte Mal geredet habt." : { - "comment" : "TourCatalog – onboarding step 3 body", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Record meetings, messages, and experiences. So you know what you last talked about." - } - } - } - }, "Ergebnis bestätigen und fortfahren" : { "localizations" : { "en" : { @@ -2332,6 +2332,7 @@ }, "Fangen wir an" : { "comment" : "TodayView – empty state CTA button title", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3648,6 +3649,17 @@ } } }, + "Moment erfassen" : { + "comment" : "TodayView – empty state: primary CTA button title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capture a moment" + } + } + } + }, "Moment festhalten" : { "comment" : "AddMomentView – sheet navigation title", "localizations" : { @@ -3723,17 +3735,6 @@ } } }, - "Momente planen und hinzufügen" : { - "comment" : "TodayView – empty state CTA button subtitle", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Plan and add moments" - } - } - } - }, "Momente und abgeschlossene Schritte erscheinen hier." : { "comment" : "LogbuchView – empty state subtitle", "localizations" : { @@ -4444,13 +4445,13 @@ } } }, - "Optionale KI-Analyse zeigt Muster in deinen Verbindungen. Alles optional – deine Daten bleiben bei dir." : { + "Optionale KI-Analyse zeigt Muster in deinen Verbindungen. Den Persönlichkeitstest findest du in den Einstellungen – er macht nahbar noch persönlicher." : { "comment" : "TourCatalog – onboarding step 6 body", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Optional AI analysis shows patterns in your connections. Everything optional – your data stays with you." + "value" : "Optional AI analysis shows patterns in your connections. You'll find the personality quiz in Settings – it makes nahbar even more personal." } } } @@ -5132,6 +5133,17 @@ } } }, + "Tippe auf eine Person und erfasse Treffen, Gespräche oder Erlebnisse – so weißt du immer, worüber ihr das letzte Mal geredet habt." : { + "comment" : "TourCatalog – onboarding step 3 body", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap a person and record meetings, conversations, or experiences — so you always know what you last talked about." + } + } + } + }, "Todo" : { "comment" : "PersonDetailView – button label to add a new Todo", "localizations" : { @@ -5177,6 +5189,17 @@ } } }, + "Todo hinzufügen" : { + "comment" : "TodayView – empty state: secondary CTA button title", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add a todo" + } + } + } + }, "Todos" : { "comment" : "PersonDetailView – section header for todos", "localizations" : { @@ -5286,6 +5309,17 @@ } } }, + "Treffen, Gespräch oder Erlebnis" : { + "comment" : "TodayView – empty state: primary CTA button subtitle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meeting, conversation or experience" + } + } + } + }, "Trotzdem überspringen" : { "localizations" : { "en" : { @@ -5847,6 +5881,16 @@ } } }, + "Weiter zu Kontakten" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue to contacts" + } + } + } + }, "Weiter zum nächsten Schritt" : { "localizations" : { "en" : { @@ -5857,16 +5901,6 @@ } } }, - "Weiter zum Persönlichkeitsquiz" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Continue to personality quiz" - } - } - } - }, "Weiter, kein Kontakt ausgewählt" : { "localizations" : { "en" : { diff --git a/nahbar/nahbar/NahbarApp.swift b/nahbar/nahbar/NahbarApp.swift index b5479a3..8015679 100644 --- a/nahbar/nahbar/NahbarApp.swift +++ b/nahbar/nahbar/NahbarApp.swift @@ -63,7 +63,7 @@ struct NahbarApp: App { var body: some Scene { WindowGroup { ZStack { - ContentView() + ContentView(splashVisible: showSplash) .environmentObject(callWindowManager) .environmentObject(appLockManager) .environmentObject(cloudSyncMonitor) diff --git a/nahbar/nahbar/OnboardingContainerView.swift b/nahbar/nahbar/OnboardingContainerView.swift index 6f380c1..af1a04d 100644 --- a/nahbar/nahbar/OnboardingContainerView.swift +++ b/nahbar/nahbar/OnboardingContainerView.swift @@ -9,21 +9,17 @@ private let onboardingLogger = Logger(subsystem: "nahbar", category: "Onboarding // MARK: - OnboardingContainerView /// Root container for the first-launch onboarding flow. -/// Shows a TabView with Phase 1 (Profile) and Phase 2 (Contacts), -/// then overlays the Phase 3 (Feature Tour) on top with a blurred background. +/// Phase 1: Profil, Phase 2: Kontakte, Phase 3: Datenschutz. struct OnboardingContainerView: View { let onComplete: () -> Void @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 - /// 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). + /// Whether the final privacy screen is visible (shown after contacts). @State private var showPrivacyScreen: Bool = false var body: some View { @@ -33,15 +29,12 @@ struct OnboardingContainerView: View { OnboardingProfileView(coordinator: coordinator) .tag(0) - OnboardingQuizPromptView(coordinator: coordinator) - .tag(1) - OnboardingContactImportView( coordinator: coordinator, - onContinue: startTour, - onSkip: startTour + onContinue: startPrivacyScreen, + onSkip: startPrivacyScreen ) - .tag(2) + .tag(1) } .tabViewStyle(.page(indexDisplayMode: .never)) .blur(radius: showPrivacyScreen ? 20 : 0) @@ -60,28 +53,12 @@ struct OnboardingContainerView: View { .transition(.opacity) } } - // 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)) { - // Cap tab index at 2 (contacts is the last real page after quiz) - tabPage = min(step.rawValue, 2) + tabPage = min(step.rawValue, 1) } } - // 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() { - onboardingTourStarted = true - tourCoordinator.start(.onboarding) } private func startPrivacyScreen() { @@ -231,7 +208,7 @@ private struct OnboardingProfileView: View { Button { let newValue = selected ? "" : option coordinator.gender = newValue - // Sofort persistieren, damit der Quiz-Schritt es lesen kann + // Sofort persistieren UserProfileStore.shared.updateGender(newValue) } label: { Text(option) @@ -263,7 +240,7 @@ private struct OnboardingProfileView: View { // ── Continue button ────────────────────────────────────────── Button { - coordinator.advanceToQuiz() + coordinator.advanceToContacts() } label: { Text("Weiter") .font(.headline) @@ -277,7 +254,7 @@ private struct OnboardingProfileView: View { } .disabled(!coordinator.isProfileValid) .padding(.horizontal, 24) - .accessibilityLabel("Weiter zum Persönlichkeitsquiz") + .accessibilityLabel("Weiter zu Kontakten") .accessibilityHint(coordinator.isProfileValid ? "" : "Bitte gib zuerst deinen Vornamen ein.") @@ -361,32 +338,7 @@ private struct OnboardingProfileView: View { } } -// MARK: - Phase 2: OnboardingQuizPromptView - -/// Onboarding-Seite für das Persönlichkeitsquiz. -/// Zeigt die Quiz-Intro-UI und präsentiert PersonalityQuizView als Sheet. -private struct OnboardingQuizPromptView: View { - @ObservedObject var coordinator: OnboardingCoordinator - @AppStorage("hasSkippedPersonalityQuiz") private var hasSkippedQuiz: Bool = false - @State private var showingQuiz = false - - var body: some View { - QuizIntroScreen( - onStart: { showingQuiz = true }, - onSkip: { - hasSkippedQuiz = true - coordinator.skipQuiz() - } - ) - .sheet(isPresented: $showingQuiz) { - PersonalityQuizView(skipIntro: true) { _ in - coordinator.advanceFromQuizToContacts() - } - } - } -} - -// MARK: - Phase 3: OnboardingContactImportView +// MARK: - Phase 2: OnboardingContactImportView /// Uses CNContactPickerViewController (system picker, no permission needed). /// Multi-select is activated automatically by implementing didSelectContacts:. @@ -580,7 +532,7 @@ private struct OnboardingContactImportView: View { } } -// MARK: - Phase 4: OnboardingPrivacyView +// MARK: - Phase 3: OnboardingPrivacyView /// Final onboarding screen. Explains the app's privacy-first approach and /// informs users that AI features are optional and involve a third-party service. diff --git a/nahbar/nahbar/OnboardingCoordinator.swift b/nahbar/nahbar/OnboardingCoordinator.swift index d7a7d9e..087355a 100644 --- a/nahbar/nahbar/OnboardingCoordinator.swift +++ b/nahbar/nahbar/OnboardingCoordinator.swift @@ -5,11 +5,9 @@ import Combine /// Each phase of the first-launch onboarding flow. enum OnboardingStep: Int, CaseIterable { - case profile = 0 - case quiz = 1 // Persönlichkeitsquiz-Prompt - case contacts = 2 // was 1 - case tour = 3 // was 2 - case complete = 4 // was 3 + case profile = 0 // Profilangaben + case contacts = 1 // Kontakte importieren + case complete = 2 } // MARK: - OnboardingCoordinator @@ -42,39 +40,12 @@ final class OnboardingCoordinator: ObservableObject { // MARK: – Navigation actions - /// Advances to the personality quiz prompt if the profile is valid. - func advanceToQuiz() { - guard isProfileValid else { return } - currentStep = .quiz - } - - /// Skips the personality quiz and goes directly to contact import. - func skipQuiz() { - currentStep = .contacts - } - - /// Called after the personality quiz completes or is dismissed; advances to contact import. - func advanceFromQuizToContacts() { - currentStep = .contacts - } - /// Advances to the contact import phase. Validates profile first. func advanceToContacts() { guard isProfileValid else { return } currentStep = .contacts } - /// Advances to the feature tour if at least one contact has been selected. - func advanceToTour() { - guard !selectedContacts.isEmpty else { return } - currentStep = .tour - } - - /// Skips the contact selection and goes directly to the feature tour. - func skipToTour() { - currentStep = .tour - } - /// Marks onboarding as fully complete. func completeOnboarding() { currentStep = .complete diff --git a/nahbar/nahbar/PeopleListView.swift b/nahbar/nahbar/PeopleListView.swift index ed80976..e2f74ac 100644 --- a/nahbar/nahbar/PeopleListView.swift +++ b/nahbar/nahbar/PeopleListView.swift @@ -61,6 +61,7 @@ struct PeopleListView: View { .clipShape(Circle()) } .accessibilityLabel("Person hinzufügen") + .tourTarget(.addContactButton) } // Search bar @@ -98,6 +99,7 @@ struct PeopleListView: View { } } } + .tourTarget(.filterChips) } .padding(.horizontal, 20) .padding(.top, 12) @@ -120,6 +122,7 @@ struct PeopleListView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .tourTarget(index == 0 ? TourTargetID.contactCardFirst : nil) if index < filteredPeople.count - 1 { RowDivider() } diff --git a/nahbar/nahbar/SettingsView.swift b/nahbar/nahbar/SettingsView.swift index 6f9e10e..69720b7 100644 --- a/nahbar/nahbar/SettingsView.swift +++ b/nahbar/nahbar/SettingsView.swift @@ -685,6 +685,7 @@ struct SettingsView: View { callSuggestionDate = "" UserDefaults.standard.removeObject(forKey: "visitMigrationPassDone") UserDefaults.standard.removeObject(forKey: "nextStepMigrationPassDone") + tourCoordinator.resetSeenTours() // 4. App neu starten damit alle States frisch initialisiert werden DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { exit(0) } diff --git a/nahbar/nahbar/TodayView.swift b/nahbar/nahbar/TodayView.swift index 5cef1fa..afdceb9 100644 --- a/nahbar/nahbar/TodayView.swift +++ b/nahbar/nahbar/TodayView.swift @@ -14,6 +14,8 @@ struct TodayView: View { @State private var selectedMomentForAftermath: Moment? = nil @State private var showPersonPicker = false @State private var personForNewMoment: Person? = nil + @State private var showTodoPersonPicker = false + @State private var personForNewTodo: Person? = nil @State private var todoForEdit: Todo? = nil @State private var fadingOutTodos: [Todo] = [] @AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7 @@ -279,6 +281,14 @@ struct TodayView: View { .sheet(item: $personForNewMoment) { person in AddMomentView(person: person) } + .sheet(isPresented: $showTodoPersonPicker) { + TodayPersonPickerSheet(people: activePeople) { person in + personForNewTodo = person + } + } + .sheet(item: $personForNewTodo) { person in + AddTodoView(person: person) + } } } @@ -297,31 +307,63 @@ struct TodayView: View { .foregroundStyle(theme.contentTertiary) } - Button { - showPersonPicker = true - } label: { - HStack(spacing: 14) { - Image(systemName: "plus.circle.fill") - .font(.system(size: 24)) - VStack(alignment: .leading, spacing: 2) { - Text("Fangen wir an") - .font(.system(size: 16, weight: .semibold, design: theme.displayDesign)) - Text("Momente planen und hinzufügen") - .font(.system(size: 13)) - .opacity(0.8) + VStack(spacing: 12) { + Button { + showPersonPicker = true + } label: { + HStack(spacing: 14) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 22)) + VStack(alignment: .leading, spacing: 2) { + Text("Moment erfassen") + .font(.system(size: 15, weight: .semibold, design: theme.displayDesign)) + Text("Treffen, Gespräch oder Erlebnis") + .font(.system(size: 12)) + .opacity(0.8) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .medium)) + .opacity(0.6) } - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .medium)) - .opacity(0.6) + .foregroundStyle(theme.backgroundPrimary) + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(theme.accent) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) } - .foregroundStyle(theme.backgroundPrimary) - .padding(.horizontal, 20) - .padding(.vertical, 18) - .background(theme.accent) - .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + .buttonStyle(.plain) + + Button { + showTodoPersonPicker = true + } label: { + HStack(spacing: 14) { + Image(systemName: "checkmark.circle") + .font(.system(size: 22)) + VStack(alignment: .leading, spacing: 2) { + Text("Todo hinzufügen") + .font(.system(size: 15, weight: .semibold, design: theme.displayDesign)) + Text("Aufgabe für eine Person anlegen") + .font(.system(size: 12)) + .opacity(0.7) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .medium)) + .opacity(0.5) + } + .foregroundStyle(theme.accent) + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(theme.accent.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) + .overlay( + RoundedRectangle(cornerRadius: theme.radiusCard) + .strokeBorder(theme.accent.opacity(0.25), lineWidth: 1) + ) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) .padding(.horizontal, 20) } .frame(maxWidth: .infinity) diff --git a/nahbar/nahbar/Tour/TourCatalog.swift b/nahbar/nahbar/Tour/TourCatalog.swift index 9bf6d80..05ef68d 100644 --- a/nahbar/nahbar/Tour/TourCatalog.swift +++ b/nahbar/nahbar/Tour/TourCatalog.swift @@ -22,14 +22,14 @@ enum TourCatalog { TourStep( title: "Deine Menschen im Mittelpunkt", body: "Füge Personen hinzu, die dir wichtig sind. Notiere Interessen, Gesprächsthemen und was euch verbindet.", - target: nil, - preferredCardPosition: .center + target: .addContactButton, + preferredCardPosition: .below ), TourStep( title: "Momente festhalten", - body: "Erfasse Treffen, Nachrichten und Erlebnisse. So weißt du, worüber ihr das letzte Mal geredet habt.", - target: nil, - preferredCardPosition: .center + body: "Tippe auf eine Person und erfasse Treffen, Gespräche oder Erlebnisse – so weißt du immer, worüber ihr das letzte Mal geredet habt.", + target: .contactCardFirst, + preferredCardPosition: .below ), TourStep( title: "Plane das Nächste", @@ -45,7 +45,7 @@ enum TourCatalog { ), TourStep( title: "Einblicke, wenn du willst", - body: "Optionale KI-Analyse zeigt Muster in deinen Verbindungen. Alles optional – deine Daten bleiben bei dir.", + body: "Optionale KI-Analyse zeigt Muster in deinen Verbindungen. Den Persönlichkeitstest findest du in den Einstellungen – er macht nahbar noch persönlicher.", target: nil, preferredCardPosition: .center ), diff --git a/nahbar/nahbar/Tour/TourCoordinator.swift b/nahbar/nahbar/Tour/TourCoordinator.swift index 5a631a0..5f8b51c 100644 --- a/nahbar/nahbar/Tour/TourCoordinator.swift +++ b/nahbar/nahbar/Tour/TourCoordinator.swift @@ -110,6 +110,23 @@ final class TourCoordinator { /// Closes the tour (semantically identical to skip in v1) and marks it as seen. func close() { completeTour() } + /// `true` when the onboarding tour has already been completed or skipped. + var hasSeenOnboardingTour: Bool { + seenStore.hasSeen(.onboarding) + } + + /// Starts the onboarding tour if it hasn't been seen yet. + /// Call this after first-launch onboarding completes and the main app is visible. + func startOnboardingTourIfNeeded() { + guard !seenStore.hasSeen(.onboarding) else { return } + start(.onboarding) + } + + /// Resets the "seen" state for all tours. Call this when the app data is reset. + func resetSeenTours() { + seenStore.reset() + } + // MARK: Target Frames (populated by tourPresenter overlay) /// Not observed — updated by the tourPresenter via preference keys. diff --git a/nahbar/nahbar/Tour/TourOverlayView.swift b/nahbar/nahbar/Tour/TourOverlayView.swift index da4474f..320f086 100644 --- a/nahbar/nahbar/Tour/TourOverlayView.swift +++ b/nahbar/nahbar/Tour/TourOverlayView.swift @@ -61,28 +61,16 @@ struct TourOverlayView: 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 + // Subtle dark tint — only slightly dims the non-spotlight area + // so the user can still see and orient themselves in the UI SpotlightShape(spotlight: paddedSpotlight, cornerRadius: cornerRadius) - .fill(Color.black.opacity(0.35), style: FillStyle(eoFill: true)) + .fill(Color.black.opacity(0.18), style: FillStyle(eoFill: true)) .frame(width: fullRect.width, height: fullRect.height) .ignoresSafeArea() .animation(.spring(response: 0.45, dampingFraction: 0.85), value: paddedSpotlight) } else { - // No spotlight: simple dark + blur overlay (centered card scenario) - Rectangle() - .fill(.thinMaterial) - .ignoresSafeArea() - Color.black.opacity(0.35) + // No spotlight: very subtle tint so the screen stays readable + Color.black.opacity(0.15) .ignoresSafeArea() } } diff --git a/nahbar/nahbar/Tour/TourViewModifiers.swift b/nahbar/nahbar/Tour/TourViewModifiers.swift index fe0d6a7..d417cb8 100644 --- a/nahbar/nahbar/Tour/TourViewModifiers.swift +++ b/nahbar/nahbar/Tour/TourViewModifiers.swift @@ -26,6 +26,17 @@ extension View { } } + /// Conditional variant — applies `tourTarget` only when `id` is non-nil. + /// Use `index == 0 ? .someTarget : nil` patterns for list-based targets. + @ViewBuilder + func tourTarget(_ id: TourTargetID?) -> some View { + if let id { + tourTarget(id) + } else { + self + } + } + /// Adds the tour overlay to this view. Place at the root of a view hierarchy /// (ContentView for main-app tours, OnboardingContainerView for onboarding). /// diff --git a/nahbar/nahbarTests/OnboardingTests.swift b/nahbar/nahbarTests/OnboardingTests.swift index ab82839..775e41e 100644 --- a/nahbar/nahbarTests/OnboardingTests.swift +++ b/nahbar/nahbarTests/OnboardingTests.swift @@ -69,40 +69,6 @@ struct OnboardingCoordinatorNavigationTests { #expect(coord.currentStep == .profile) } - @Test("advanceToQuiz ohne Vorname bleibt auf .profile") - @MainActor func advanceToQuizWithoutNameStaysOnProfile() { - let coord = OnboardingCoordinator() - coord.firstName = "" - coord.advanceToQuiz() - #expect(coord.currentStep == .profile) - } - - @Test("advanceToQuiz mit gültigem Vorname → .quiz") - @MainActor func advanceToQuizWithNameGoesToQuiz() { - let coord = OnboardingCoordinator() - coord.firstName = "Anna" - coord.advanceToQuiz() - #expect(coord.currentStep == .quiz) - } - - @Test("skipQuiz überspring Quiz und geht zu .contacts") - @MainActor func skipQuizGoesToContacts() { - let coord = OnboardingCoordinator() - coord.firstName = "Anna" - coord.advanceToQuiz() - coord.skipQuiz() - #expect(coord.currentStep == .contacts) - } - - @Test("advanceFromQuizToContacts → .contacts") - @MainActor func advanceFromQuizToContacts() { - let coord = OnboardingCoordinator() - coord.firstName = "Anna" - coord.advanceToQuiz() - coord.advanceFromQuizToContacts() - #expect(coord.currentStep == .contacts) - } - @Test("advanceToContacts ohne Vorname bleibt auf .profile") @MainActor func advanceToContactsWithoutNameStaysOnProfile() { let coord = OnboardingCoordinator() @@ -119,34 +85,6 @@ struct OnboardingCoordinatorNavigationTests { #expect(coord.currentStep == .contacts) } - @Test("advanceToTour ohne Kontakte bleibt auf .contacts") - @MainActor func advanceToTourWithoutContactsStaysOnContacts() { - let coord = OnboardingCoordinator() - coord.firstName = "Anna" - coord.advanceToContacts() - coord.advanceToTour() // keine Kontakte ausgewählt - #expect(coord.currentStep == .contacts) - } - - @Test("advanceToTour mit Kontakt → .tour") - @MainActor func advanceToTourWithContactGoesToTour() { - let coord = OnboardingCoordinator() - coord.firstName = "Anna" - coord.advanceToContacts() - coord.selectedContacts = [NahbarContact(givenName: "Kai", familyName: "Müller")] - coord.advanceToTour() - #expect(coord.currentStep == .tour) - } - - @Test("skipToTour überspringt Kontakt-Schritt") - @MainActor func skipToTourSkipsContacts() { - let coord = OnboardingCoordinator() - coord.firstName = "Anna" - coord.advanceToContacts() - coord.skipToTour() - #expect(coord.currentStep == .tour) - } - @Test("completeOnboarding setzt Schritt auf .complete") @MainActor func completeOnboardingSetsComplete() { let coord = OnboardingCoordinator() @@ -270,18 +208,16 @@ struct NahbarContactCodableTests { @Suite("OnboardingStep – RawValue") struct OnboardingStepTests { - @Test("RawValues sind aufsteigend 0–4") + @Test("RawValues sind aufsteigend 0–2") func rawValuesAreSequential() { #expect(OnboardingStep.profile.rawValue == 0) - #expect(OnboardingStep.quiz.rawValue == 1) - #expect(OnboardingStep.contacts.rawValue == 2) - #expect(OnboardingStep.tour.rawValue == 3) - #expect(OnboardingStep.complete.rawValue == 4) + #expect(OnboardingStep.contacts.rawValue == 1) + #expect(OnboardingStep.complete.rawValue == 2) } - @Test("allCases enthält genau 5 Schritte") + @Test("allCases enthält genau 3 Schritte") func allCasesCount() { - #expect(OnboardingStep.allCases.count == 5) + #expect(OnboardingStep.allCases.count == 3) } @Test("Reihenfolge von allCases stimmt mit rawValue überein")