Fix #27: Onboarding-Tour im Haupt-Kontext + UI-Verbesserungen

- Tour-Spotlight-System: Overlay transparent, Hintergrund bleibt sichtbar
- Tour startet nach Splash-Screen im ContentView-Kontext (nicht mehr im fullScreenCover)
- Splash-Screen: UI-Präsentation (Onboarding, CallWindow-Setup) wartet auf Splash-Ende
- TodayView: Leerer Zustand mit zwei separaten CTAs (Moment erfassen / Todo hinzufügen)
- OnboardingCoordinator: 3 Schritte (profile, contacts, complete), Tour separat
- PeopleListView: .tourTarget() für addContactButton und contactCardFirst
- SettingsView: resetSeenTours() bei App-Reset

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 20:20:17 +02:00
parent a0741ba608
commit 1e75f357ba
13 changed files with 250 additions and 249 deletions
+55 -9
View File
@@ -5,6 +5,10 @@ import OSLog
private let logger = Logger(subsystem: "nahbar", category: "ContentView") private let logger = Logger(subsystem: "nahbar", category: "ContentView")
struct ContentView: View { struct ContentView: View {
/// Wird von NahbarApp auf `true` gesetzt, solange der Splash-Screen sichtbar ist.
/// UI-Präsentation (Sheets, Onboarding) startet erst, wenn dieser Wert auf `false` wechselt.
var splashVisible: Bool = false
@AppStorage("nahbarOnboardingDone") private var nahbarOnboardingDone = false @AppStorage("nahbarOnboardingDone") private var nahbarOnboardingDone = false
@AppStorage("callWindowOnboardingDone") private var onboardingDone = false @AppStorage("callWindowOnboardingDone") private var onboardingDone = false
@AppStorage("callSuggestionDate") private var suggestionDateStr = "" @AppStorage("callSuggestionDate") private var suggestionDateStr = ""
@@ -27,38 +31,47 @@ struct ContentView: View {
@State private var showingOnboarding = false @State private var showingOnboarding = false
@State private var suggestedPerson: Person? = nil @State private var suggestedPerson: Person? = nil
@State private var showingSuggestion = false @State private var showingSuggestion = false
/// Steuert den aktiven Tab; nötig damit die Tour auf dem Menschen-Tab starten kann.
@State private var selectedTab: Int = 0
var body: some View { var body: some View {
TabView { TabView(selection: $selectedTab) {
TodayView() TodayView()
.tabItem { Label("Heute", systemImage: "sun.max") } .tabItem { Label("Heute", systemImage: "sun.max") }
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar) .toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar)
.toolbarBackground(.visible, for: .tabBar) .toolbarBackground(.visible, for: .tabBar)
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar) .toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
.tag(0)
PeopleListView() PeopleListView()
.tabItem { Label("Menschen", systemImage: "person.2") } .tabItem { Label("Menschen", systemImage: "person.2") }
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar) .toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar)
.toolbarBackground(.visible, for: .tabBar) .toolbarBackground(.visible, for: .tabBar)
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar) .toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
.tag(1)
IchView() IchView()
.tabItem { Label("Ich", systemImage: "person.circle") } .tabItem { Label("Ich", systemImage: "person.circle") }
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar) .toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar)
.toolbarBackground(.visible, for: .tabBar) .toolbarBackground(.visible, for: .tabBar)
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar) .toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
.tag(2)
SettingsView() SettingsView()
.tabItem { Label("Einstellungen", systemImage: "gearshape") } .tabItem { Label("Einstellungen", systemImage: "gearshape") }
.toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar) .toolbarBackground(theme.backgroundPrimary.opacity(0.88), for: .tabBar)
.toolbarBackground(.visible, for: .tabBar) .toolbarBackground(.visible, for: .tabBar)
.toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar) .toolbarColorScheme(theme.id.isDark ? .dark : .light, for: .tabBar)
.tag(3)
} }
.fullScreenCover(isPresented: $showingNahbarOnboarding) { .fullScreenCover(isPresented: $showingNahbarOnboarding) {
OnboardingContainerView { OnboardingContainerView {
nahbarOnboardingDone = true nahbarOnboardingDone = true
showingNahbarOnboarding = false showingNahbarOnboarding = false
checkCallWindow() checkCallWindow()
// Tour nach Onboarding: Tab-Wechsel + Verzögerung nötig damit
// PeopleListView gerendert ist und anchorPreference-Frames gesammelt wurden.
scheduleTourIfNeeded()
} }
} }
.sheet(isPresented: $showingOnboarding) { .sheet(isPresented: $showingOnboarding) {
@@ -87,25 +100,28 @@ struct ContentView: View {
} }
.tourPresenter(coordinator: tourCoordinator) .tourPresenter(coordinator: tourCoordinator)
.onAppear { .onAppear {
// Datenoperationen sofort starten (unabhängig vom Splash-Screen)
syncPeopleCache() syncPeopleCache()
importPendingMoments() importPendingMoments()
runPhotoRepairPass() runPhotoRepairPass()
runVisitMigrationPass() runVisitMigrationPass()
runNextStepMigrationPass() runNextStepMigrationPass()
if !nahbarOnboardingDone { // UI-Präsentation erst nach dem Splash
showingNahbarOnboarding = true if !splashVisible {
} else if !onboardingDone { showPendingUI()
showingOnboarding = true }
} else { }
checkCallWindow() .onChange(of: splashVisible) { _, visible in
tourCoordinator.checkForPendingTours() // Sobald der Splash verschwindet, UI-Logik starten
if !visible {
showPendingUI()
} }
} }
.onChange(of: scenePhase) { _, phase in .onChange(of: scenePhase) { _, phase in
if phase == .active { if phase == .active {
syncPeopleCache() syncPeopleCache()
importPendingMoments() importPendingMoments()
checkCallWindow() if !splashVisible { checkCallWindow() }
} }
} }
.onChange(of: persons) { _, _ in .onChange(of: persons) { _, _ in
@@ -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 // MARK: - Call Window
private func checkCallWindow() { private func checkCallWindow() {
+68 -34
View File
@@ -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" : { "Aus Kontakten ausfüllen" : {
"localizations" : { "localizations" : {
"en" : { "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" : { "Ergebnis bestätigen und fortfahren" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -2332,6 +2332,7 @@
}, },
"Fangen wir an" : { "Fangen wir an" : {
"comment" : "TodayView empty state CTA button title", "comment" : "TodayView empty state CTA button title",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "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" : { "Moment festhalten" : {
"comment" : "AddMomentView sheet navigation title", "comment" : "AddMomentView sheet navigation title",
"localizations" : { "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." : { "Momente und abgeschlossene Schritte erscheinen hier." : {
"comment" : "LogbuchView empty state subtitle", "comment" : "LogbuchView empty state subtitle",
"localizations" : { "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", "comment" : "TourCatalog onboarding step 6 body",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "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" : { "Todo" : {
"comment" : "PersonDetailView button label to add a new Todo", "comment" : "PersonDetailView button label to add a new Todo",
"localizations" : { "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" : { "Todos" : {
"comment" : "PersonDetailView section header for todos", "comment" : "PersonDetailView section header for todos",
"localizations" : { "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" : { "Trotzdem überspringen" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -5847,6 +5881,16 @@
} }
} }
}, },
"Weiter zu Kontakten" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Continue to contacts"
}
}
}
},
"Weiter zum nächsten Schritt" : { "Weiter zum nächsten Schritt" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@@ -5857,16 +5901,6 @@
} }
} }
}, },
"Weiter zum Persönlichkeitsquiz" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Continue to personality quiz"
}
}
}
},
"Weiter, kein Kontakt ausgewählt" : { "Weiter, kein Kontakt ausgewählt" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
+1 -1
View File
@@ -63,7 +63,7 @@ struct NahbarApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ZStack { ZStack {
ContentView() ContentView(splashVisible: showSplash)
.environmentObject(callWindowManager) .environmentObject(callWindowManager)
.environmentObject(appLockManager) .environmentObject(appLockManager)
.environmentObject(cloudSyncMonitor) .environmentObject(cloudSyncMonitor)
+11 -59
View File
@@ -9,21 +9,17 @@ private let onboardingLogger = Logger(subsystem: "nahbar", category: "Onboarding
// MARK: - OnboardingContainerView // MARK: - OnboardingContainerView
/// Root container for the first-launch onboarding flow. /// Root container for the first-launch onboarding flow.
/// Shows a TabView with Phase 1 (Profile) and Phase 2 (Contacts), /// Phase 1: Profil, Phase 2: Kontakte, Phase 3: Datenschutz.
/// then overlays the Phase 3 (Feature Tour) on top with a blurred background.
struct OnboardingContainerView: View { struct OnboardingContainerView: View {
let onComplete: () -> Void let onComplete: () -> Void
@StateObject private var coordinator = OnboardingCoordinator() @StateObject private var coordinator = OnboardingCoordinator()
@Environment(\.contactStore) private var contactStore @Environment(\.contactStore) private var contactStore
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(TourCoordinator.self) private var tourCoordinator
/// Current tab page index (0 = profile, 1 = contacts). /// Current tab page index (0 = profile, 1 = contacts).
@State private var tabPage: Int = 0 @State private var tabPage: Int = 0
/// Tracks whether the onboarding tour was started (to detect its completion). /// Whether the final privacy screen is visible (shown after contacts).
@State private var onboardingTourStarted: Bool = false
/// Whether the final privacy screen is visible (shown after the feature tour).
@State private var showPrivacyScreen: Bool = false @State private var showPrivacyScreen: Bool = false
var body: some View { var body: some View {
@@ -33,15 +29,12 @@ struct OnboardingContainerView: View {
OnboardingProfileView(coordinator: coordinator) OnboardingProfileView(coordinator: coordinator)
.tag(0) .tag(0)
OnboardingQuizPromptView(coordinator: coordinator)
.tag(1)
OnboardingContactImportView( OnboardingContactImportView(
coordinator: coordinator, coordinator: coordinator,
onContinue: startTour, onContinue: startPrivacyScreen,
onSkip: startTour onSkip: startPrivacyScreen
) )
.tag(2) .tag(1)
} }
.tabViewStyle(.page(indexDisplayMode: .never)) .tabViewStyle(.page(indexDisplayMode: .never))
.blur(radius: showPrivacyScreen ? 20 : 0) .blur(radius: showPrivacyScreen ? 20 : 0)
@@ -60,28 +53,12 @@ struct OnboardingContainerView: View {
.transition(.opacity) .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) .animation(.easeInOut(duration: 0.35), value: showPrivacyScreen)
.onChange(of: coordinator.currentStep) { _, step in .onChange(of: coordinator.currentStep) { _, step in
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
// Cap tab index at 2 (contacts is the last real page after quiz) tabPage = min(step.rawValue, 1)
tabPage = min(step.rawValue, 2)
} }
} }
// 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() { private func startPrivacyScreen() {
@@ -231,7 +208,7 @@ private struct OnboardingProfileView: View {
Button { Button {
let newValue = selected ? "" : option let newValue = selected ? "" : option
coordinator.gender = newValue coordinator.gender = newValue
// Sofort persistieren, damit der Quiz-Schritt es lesen kann // Sofort persistieren
UserProfileStore.shared.updateGender(newValue) UserProfileStore.shared.updateGender(newValue)
} label: { } label: {
Text(option) Text(option)
@@ -263,7 +240,7 @@ private struct OnboardingProfileView: View {
// Continue button // Continue button
Button { Button {
coordinator.advanceToQuiz() coordinator.advanceToContacts()
} label: { } label: {
Text("Weiter") Text("Weiter")
.font(.headline) .font(.headline)
@@ -277,7 +254,7 @@ private struct OnboardingProfileView: View {
} }
.disabled(!coordinator.isProfileValid) .disabled(!coordinator.isProfileValid)
.padding(.horizontal, 24) .padding(.horizontal, 24)
.accessibilityLabel("Weiter zum Persönlichkeitsquiz") .accessibilityLabel("Weiter zu Kontakten")
.accessibilityHint(coordinator.isProfileValid .accessibilityHint(coordinator.isProfileValid
? "" ? ""
: "Bitte gib zuerst deinen Vornamen ein.") : "Bitte gib zuerst deinen Vornamen ein.")
@@ -361,32 +338,7 @@ private struct OnboardingProfileView: View {
} }
} }
// MARK: - Phase 2: OnboardingQuizPromptView // MARK: - Phase 2: OnboardingContactImportView
/// Onboarding-Seite für das Persönlichkeitsquiz.
/// Zeigt die Quiz-Intro-UI und präsentiert PersonalityQuizView als Sheet.
private struct OnboardingQuizPromptView: View {
@ObservedObject var coordinator: OnboardingCoordinator
@AppStorage("hasSkippedPersonalityQuiz") private var hasSkippedQuiz: Bool = false
@State private var showingQuiz = false
var body: some View {
QuizIntroScreen(
onStart: { showingQuiz = true },
onSkip: {
hasSkippedQuiz = true
coordinator.skipQuiz()
}
)
.sheet(isPresented: $showingQuiz) {
PersonalityQuizView(skipIntro: true) { _ in
coordinator.advanceFromQuizToContacts()
}
}
}
}
// MARK: - Phase 3: OnboardingContactImportView
/// Uses CNContactPickerViewController (system picker, no permission needed). /// Uses CNContactPickerViewController (system picker, no permission needed).
/// Multi-select is activated automatically by implementing didSelectContacts:. /// Multi-select is activated automatically by implementing didSelectContacts:.
@@ -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 /// Final onboarding screen. Explains the app's privacy-first approach and
/// informs users that AI features are optional and involve a third-party service. /// informs users that AI features are optional and involve a third-party service.
+3 -32
View File
@@ -5,11 +5,9 @@ import Combine
/// Each phase of the first-launch onboarding flow. /// Each phase of the first-launch onboarding flow.
enum OnboardingStep: Int, CaseIterable { enum OnboardingStep: Int, CaseIterable {
case profile = 0 case profile = 0 // Profilangaben
case quiz = 1 // Persönlichkeitsquiz-Prompt case contacts = 1 // Kontakte importieren
case contacts = 2 // was 1 case complete = 2
case tour = 3 // was 2
case complete = 4 // was 3
} }
// MARK: - OnboardingCoordinator // MARK: - OnboardingCoordinator
@@ -42,39 +40,12 @@ final class OnboardingCoordinator: ObservableObject {
// MARK: Navigation actions // MARK: Navigation actions
/// Advances to the personality quiz prompt if the profile is valid.
func advanceToQuiz() {
guard isProfileValid else { return }
currentStep = .quiz
}
/// Skips the personality quiz and goes directly to contact import.
func skipQuiz() {
currentStep = .contacts
}
/// Called after the personality quiz completes or is dismissed; advances to contact import.
func advanceFromQuizToContacts() {
currentStep = .contacts
}
/// Advances to the contact import phase. Validates profile first. /// Advances to the contact import phase. Validates profile first.
func advanceToContacts() { func advanceToContacts() {
guard isProfileValid else { return } guard isProfileValid else { return }
currentStep = .contacts currentStep = .contacts
} }
/// Advances to the feature tour if at least one contact has been selected.
func advanceToTour() {
guard !selectedContacts.isEmpty else { return }
currentStep = .tour
}
/// Skips the contact selection and goes directly to the feature tour.
func skipToTour() {
currentStep = .tour
}
/// Marks onboarding as fully complete. /// Marks onboarding as fully complete.
func completeOnboarding() { func completeOnboarding() {
currentStep = .complete currentStep = .complete
+3
View File
@@ -61,6 +61,7 @@ struct PeopleListView: View {
.clipShape(Circle()) .clipShape(Circle())
} }
.accessibilityLabel("Person hinzufügen") .accessibilityLabel("Person hinzufügen")
.tourTarget(.addContactButton)
} }
// Search bar // Search bar
@@ -98,6 +99,7 @@ struct PeopleListView: View {
} }
} }
} }
.tourTarget(.filterChips)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 12) .padding(.top, 12)
@@ -120,6 +122,7 @@ struct PeopleListView: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.tourTarget(index == 0 ? TourTargetID.contactCardFirst : nil)
if index < filteredPeople.count - 1 { if index < filteredPeople.count - 1 {
RowDivider() RowDivider()
} }
+1
View File
@@ -685,6 +685,7 @@ struct SettingsView: View {
callSuggestionDate = "" callSuggestionDate = ""
UserDefaults.standard.removeObject(forKey: "visitMigrationPassDone") UserDefaults.standard.removeObject(forKey: "visitMigrationPassDone")
UserDefaults.standard.removeObject(forKey: "nextStepMigrationPassDone") UserDefaults.standard.removeObject(forKey: "nextStepMigrationPassDone")
tourCoordinator.resetSeenTours()
// 4. App neu starten damit alle States frisch initialisiert werden // 4. App neu starten damit alle States frisch initialisiert werden
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { exit(0) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { exit(0) }
+64 -22
View File
@@ -14,6 +14,8 @@ struct TodayView: View {
@State private var selectedMomentForAftermath: Moment? = nil @State private var selectedMomentForAftermath: Moment? = nil
@State private var showPersonPicker = false @State private var showPersonPicker = false
@State private var personForNewMoment: Person? = nil @State private var personForNewMoment: Person? = nil
@State private var showTodoPersonPicker = false
@State private var personForNewTodo: Person? = nil
@State private var todoForEdit: Todo? = nil @State private var todoForEdit: Todo? = nil
@State private var fadingOutTodos: [Todo] = [] @State private var fadingOutTodos: [Todo] = []
@AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7 @AppStorage("upcomingDaysAhead") private var daysAhead: Int = 7
@@ -279,6 +281,14 @@ struct TodayView: View {
.sheet(item: $personForNewMoment) { person in .sheet(item: $personForNewMoment) { person in
AddMomentView(person: person) AddMomentView(person: person)
} }
.sheet(isPresented: $showTodoPersonPicker) {
TodayPersonPickerSheet(people: activePeople) { person in
personForNewTodo = person
}
}
.sheet(item: $personForNewTodo) { person in
AddTodoView(person: person)
}
} }
} }
@@ -297,31 +307,63 @@ struct TodayView: View {
.foregroundStyle(theme.contentTertiary) .foregroundStyle(theme.contentTertiary)
} }
Button { VStack(spacing: 12) {
showPersonPicker = true Button {
} label: { showPersonPicker = true
HStack(spacing: 14) { } label: {
Image(systemName: "plus.circle.fill") HStack(spacing: 14) {
.font(.system(size: 24)) Image(systemName: "plus.circle.fill")
VStack(alignment: .leading, spacing: 2) { .font(.system(size: 22))
Text("Fangen wir an") VStack(alignment: .leading, spacing: 2) {
.font(.system(size: 16, weight: .semibold, design: theme.displayDesign)) Text("Moment erfassen")
Text("Momente planen und hinzufügen") .font(.system(size: 15, weight: .semibold, design: theme.displayDesign))
.font(.system(size: 13)) Text("Treffen, Gespräch oder Erlebnis")
.opacity(0.8) .font(.system(size: 12))
.opacity(0.8)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.opacity(0.6)
} }
Spacer() .foregroundStyle(theme.backgroundPrimary)
Image(systemName: "chevron.right") .padding(.horizontal, 20)
.font(.system(size: 13, weight: .medium)) .padding(.vertical, 16)
.opacity(0.6) .background(theme.accent)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
} }
.foregroundStyle(theme.backgroundPrimary) .buttonStyle(.plain)
.padding(.horizontal, 20)
.padding(.vertical, 18) Button {
.background(theme.accent) showTodoPersonPicker = true
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard)) } 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) .padding(.horizontal, 20)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
+6 -6
View File
@@ -22,14 +22,14 @@ enum TourCatalog {
TourStep( TourStep(
title: "Deine Menschen im Mittelpunkt", title: "Deine Menschen im Mittelpunkt",
body: "Füge Personen hinzu, die dir wichtig sind. Notiere Interessen, Gesprächsthemen und was euch verbindet.", body: "Füge Personen hinzu, die dir wichtig sind. Notiere Interessen, Gesprächsthemen und was euch verbindet.",
target: nil, target: .addContactButton,
preferredCardPosition: .center preferredCardPosition: .below
), ),
TourStep( TourStep(
title: "Momente festhalten", title: "Momente festhalten",
body: "Erfasse Treffen, Nachrichten und Erlebnisse. So weißt du, worüber ihr das letzte Mal geredet habt.", 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: nil, target: .contactCardFirst,
preferredCardPosition: .center preferredCardPosition: .below
), ),
TourStep( TourStep(
title: "Plane das Nächste", title: "Plane das Nächste",
@@ -45,7 +45,7 @@ enum TourCatalog {
), ),
TourStep( TourStep(
title: "Einblicke, wenn du willst", 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, target: nil,
preferredCardPosition: .center preferredCardPosition: .center
), ),
+17
View File
@@ -110,6 +110,23 @@ final class TourCoordinator {
/// Closes the tour (semantically identical to skip in v1) and marks it as seen. /// Closes the tour (semantically identical to skip in v1) and marks it as seen.
func close() { completeTour() } 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) // MARK: Target Frames (populated by tourPresenter overlay)
/// Not observed updated by the tourPresenter via preference keys. /// Not observed updated by the tourPresenter via preference keys.
+5 -17
View File
@@ -61,28 +61,16 @@ struct TourOverlayView: View {
let fullRect = CGRect(origin: .zero, size: geo.size) let fullRect = CGRect(origin: .zero, size: geo.size)
if hasSpotlight { if hasSpotlight {
// Material layer blurs only the non-spotlight area // Subtle dark tint only slightly dims the non-spotlight area
Rectangle() // so the user can still see and orient themselves in the UI
.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) 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) .frame(width: fullRect.width, height: fullRect.height)
.ignoresSafeArea() .ignoresSafeArea()
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: paddedSpotlight) .animation(.spring(response: 0.45, dampingFraction: 0.85), value: paddedSpotlight)
} else { } else {
// No spotlight: simple dark + blur overlay (centered card scenario) // No spotlight: very subtle tint so the screen stays readable
Rectangle() Color.black.opacity(0.15)
.fill(.thinMaterial)
.ignoresSafeArea()
Color.black.opacity(0.35)
.ignoresSafeArea() .ignoresSafeArea()
} }
} }
@@ -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 /// Adds the tour overlay to this view. Place at the root of a view hierarchy
/// (ContentView for main-app tours, OnboardingContainerView for onboarding). /// (ContentView for main-app tours, OnboardingContainerView for onboarding).
/// ///
+5 -69
View File
@@ -69,40 +69,6 @@ struct OnboardingCoordinatorNavigationTests {
#expect(coord.currentStep == .profile) #expect(coord.currentStep == .profile)
} }
@Test("advanceToQuiz ohne Vorname bleibt auf .profile")
@MainActor func advanceToQuizWithoutNameStaysOnProfile() {
let coord = OnboardingCoordinator()
coord.firstName = ""
coord.advanceToQuiz()
#expect(coord.currentStep == .profile)
}
@Test("advanceToQuiz mit gültigem Vorname → .quiz")
@MainActor func advanceToQuizWithNameGoesToQuiz() {
let coord = OnboardingCoordinator()
coord.firstName = "Anna"
coord.advanceToQuiz()
#expect(coord.currentStep == .quiz)
}
@Test("skipQuiz überspring Quiz und geht zu .contacts")
@MainActor func skipQuizGoesToContacts() {
let coord = OnboardingCoordinator()
coord.firstName = "Anna"
coord.advanceToQuiz()
coord.skipQuiz()
#expect(coord.currentStep == .contacts)
}
@Test("advanceFromQuizToContacts → .contacts")
@MainActor func advanceFromQuizToContacts() {
let coord = OnboardingCoordinator()
coord.firstName = "Anna"
coord.advanceToQuiz()
coord.advanceFromQuizToContacts()
#expect(coord.currentStep == .contacts)
}
@Test("advanceToContacts ohne Vorname bleibt auf .profile") @Test("advanceToContacts ohne Vorname bleibt auf .profile")
@MainActor func advanceToContactsWithoutNameStaysOnProfile() { @MainActor func advanceToContactsWithoutNameStaysOnProfile() {
let coord = OnboardingCoordinator() let coord = OnboardingCoordinator()
@@ -119,34 +85,6 @@ struct OnboardingCoordinatorNavigationTests {
#expect(coord.currentStep == .contacts) #expect(coord.currentStep == .contacts)
} }
@Test("advanceToTour ohne Kontakte bleibt auf .contacts")
@MainActor func advanceToTourWithoutContactsStaysOnContacts() {
let coord = OnboardingCoordinator()
coord.firstName = "Anna"
coord.advanceToContacts()
coord.advanceToTour() // keine Kontakte ausgewählt
#expect(coord.currentStep == .contacts)
}
@Test("advanceToTour mit Kontakt → .tour")
@MainActor func advanceToTourWithContactGoesToTour() {
let coord = OnboardingCoordinator()
coord.firstName = "Anna"
coord.advanceToContacts()
coord.selectedContacts = [NahbarContact(givenName: "Kai", familyName: "Müller")]
coord.advanceToTour()
#expect(coord.currentStep == .tour)
}
@Test("skipToTour überspringt Kontakt-Schritt")
@MainActor func skipToTourSkipsContacts() {
let coord = OnboardingCoordinator()
coord.firstName = "Anna"
coord.advanceToContacts()
coord.skipToTour()
#expect(coord.currentStep == .tour)
}
@Test("completeOnboarding setzt Schritt auf .complete") @Test("completeOnboarding setzt Schritt auf .complete")
@MainActor func completeOnboardingSetsComplete() { @MainActor func completeOnboardingSetsComplete() {
let coord = OnboardingCoordinator() let coord = OnboardingCoordinator()
@@ -270,18 +208,16 @@ struct NahbarContactCodableTests {
@Suite("OnboardingStep RawValue") @Suite("OnboardingStep RawValue")
struct OnboardingStepTests { struct OnboardingStepTests {
@Test("RawValues sind aufsteigend 04") @Test("RawValues sind aufsteigend 02")
func rawValuesAreSequential() { func rawValuesAreSequential() {
#expect(OnboardingStep.profile.rawValue == 0) #expect(OnboardingStep.profile.rawValue == 0)
#expect(OnboardingStep.quiz.rawValue == 1) #expect(OnboardingStep.contacts.rawValue == 1)
#expect(OnboardingStep.contacts.rawValue == 2) #expect(OnboardingStep.complete.rawValue == 2)
#expect(OnboardingStep.tour.rawValue == 3)
#expect(OnboardingStep.complete.rawValue == 4)
} }
@Test("allCases enthält genau 5 Schritte") @Test("allCases enthält genau 3 Schritte")
func allCasesCount() { func allCasesCount() {
#expect(OnboardingStep.allCases.count == 5) #expect(OnboardingStep.allCases.count == 3)
} }
@Test("Reihenfolge von allCases stimmt mit rawValue überein") @Test("Reihenfolge von allCases stimmt mit rawValue überein")