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:
@@ -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,11 +100,40 @@ struct ContentView: View {
|
||||
}
|
||||
.tourPresenter(coordinator: tourCoordinator)
|
||||
.onAppear {
|
||||
// Datenoperationen sofort starten (unabhängig vom Splash-Screen)
|
||||
syncPeopleCache()
|
||||
importPendingMoments()
|
||||
runPhotoRepairPass()
|
||||
runVisitMigrationPass()
|
||||
runNextStepMigrationPass()
|
||||
// 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()
|
||||
if !splashVisible { checkCallWindow() }
|
||||
}
|
||||
}
|
||||
.onChange(of: persons) { _, _ in
|
||||
syncPeopleCache()
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -99,17 +141,21 @@ struct ContentView: View {
|
||||
} else {
|
||||
checkCallWindow()
|
||||
tourCoordinator.checkForPendingTours()
|
||||
scheduleTourIfNeeded()
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active {
|
||||
syncPeopleCache()
|
||||
importPendingMoments()
|
||||
checkCallWindow()
|
||||
}
|
||||
}
|
||||
.onChange(of: persons) { _, _ in
|
||||
syncPeopleCache()
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -63,7 +63,7 @@ struct NahbarApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ZStack {
|
||||
ContentView()
|
||||
ContentView(splashVisible: showSplash)
|
||||
.environmentObject(callWindowManager)
|
||||
.environmentObject(appLockManager)
|
||||
.environmentObject(cloudSyncMonitor)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
showPersonPicker = true
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 24))
|
||||
.font(.system(size: 22))
|
||||
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))
|
||||
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: 13, weight: .medium))
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.opacity(0.6)
|
||||
}
|
||||
.foregroundStyle(theme.backgroundPrimary)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 18)
|
||||
.padding(.vertical, 16)
|
||||
.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)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
// 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(.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))
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
///
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user