Fix #26: Vorname im Gruß + Tour-Verbesserungen + UI-Feinschliff

- TodayView: Begrüßung zeigt Vornamen aus UserProfileStore
- TodayView: Leerer Zustand mit zwei CTA-Buttons (Moment / Todo)
- TodayView: Untertitel auf "Lass uns mit der Beziehungspflege starten." geändert
- Tour: Neue Schritte highlighten +Moment und +Todo in PersonDetailView
- Tour: PeopleListView navigiert automatisch zum ersten Kontakt beim Tour-Schritt
- Tour: App-Touren-Sektion in Einstellungen deaktiviert
- Schriftgrößen: Alle Überschriften um 2pt verkleinert (34→32, etc.)
- ContentView Preview: TourCoordinator-Environment ergänzt
- Lokalisierung: Neue Strings für Gruß, Leerzustand und Tour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 20:50:00 +02:00
parent 1e75f357ba
commit c647553eb7
15 changed files with 93 additions and 88 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ struct AppLockSetupView: View {
VStack(spacing: 8) { VStack(spacing: 8) {
Text(title) Text(title)
.font(.system(size: 24, weight: .light, design: theme.displayDesign)) .font(.system(size: 22, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text(subtitle) Text(subtitle)
.font(.system(size: 15)) .font(.system(size: 15))
+2 -2
View File
@@ -23,7 +23,7 @@ struct AppLockView: View {
// Title // Title
VStack(spacing: 6) { VStack(spacing: 6) {
Text("nahbar") Text("nahbar")
.font(.system(size: 34, weight: .light, design: theme.displayDesign)) .font(.system(size: 32, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text("Code eingeben") Text("Code eingeben")
.font(.system(size: 15)) .font(.system(size: 15))
@@ -166,7 +166,7 @@ struct PINPadView: View {
} else { } else {
Button { onKey(.digit(key.first!)) } label: { Button { onKey(.digit(key.first!)) } label: {
Text(key) Text(key)
.font(.system(size: 28, weight: .light, design: theme.displayDesign)) .font(.system(size: 26, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
.frame(width: 80, height: 80) .frame(width: 80, height: 80)
.background(theme.surfaceCard) .background(theme.surfaceCard)
+1 -1
View File
@@ -29,7 +29,7 @@ struct CallWindowSetupView: View {
.foregroundStyle(theme.accent) .foregroundStyle(theme.accent)
Text("Gesprächszeit") Text("Gesprächszeit")
.font(.system(size: 26, weight: .light, design: theme.displayDesign)) .font(.system(size: 24, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text("nahbar erinnert dich täglich in deinem Zeitfenster und schlägt einen Kontakt vor — mit Notizen, damit du vorbereitet bist.") Text("nahbar erinnert dich täglich in deinem Zeitfenster und schlägt einen Kontakt vor — mit Notizen, damit du vorbereitet bist.")
+1 -2
View File
@@ -69,8 +69,6 @@ struct ContentView: View {
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() scheduleTourIfNeeded()
} }
} }
@@ -397,4 +395,5 @@ struct ContentView: View {
.environmentObject(AppLockManager.shared) .environmentObject(AppLockManager.shared)
.environmentObject(CloudSyncMonitor()) .environmentObject(CloudSyncMonitor())
.environmentObject(UserProfileStore.shared) .environmentObject(UserProfileStore.shared)
.environment(TourCoordinator())
} }
+1 -1
View File
@@ -95,7 +95,7 @@ struct IchView: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack { HStack {
Text("Ich") Text("Ich")
.font(.system(size: 34, weight: .light, design: theme.displayDesign)) .font(.system(size: 32, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Spacer() Spacer()
Button { showingEdit = true } label: { Button { showingEdit = true } label: {
+42 -26
View File
@@ -230,6 +230,7 @@
}, },
"%lld Schritte" : { "%lld Schritte" : {
"comment" : "SettingsView tour step count label (e.g. '6 Schritte')", "comment" : "SettingsView tour step count label (e.g. '6 Schritte')",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"variations" : { "variations" : {
@@ -917,6 +918,7 @@
}, },
"App-Touren" : { "App-Touren" : {
"comment" : "SettingsView Tour section header", "comment" : "SettingsView Tour section header",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -2771,35 +2773,38 @@
} }
} }
}, },
"Guten Abend." : { "Guten Abend" : {
"comment" : "TodayView evening greeting", "comment" : "TodayView evening greeting (without punctuation, name appended in code)",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Good evening." "value" : "Good evening"
}
}
}
},
"Guten Morgen" : {
"comment" : "TodayView morning greeting (without punctuation, name appended in code)",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Good morning"
} }
} }
} }
}, },
"Guten Morgen." : { "Guten Morgen." : {
"comment" : "TodayView morning greeting",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Good morning."
}
}
}
}, },
"Guten Tag." : { "Guten Tag" : {
"comment" : "TodayView afternoon greeting", "comment" : "TodayView afternoon greeting (without punctuation, name appended in code)",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Good afternoon." "value" : "Good afternoon"
} }
} }
} }
@@ -3372,8 +3377,20 @@
} }
} }
}, },
"Lass uns mit der Beziehungspflege starten." : {
"comment" : "TodayView empty state subtitle",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Let's start nurturing your relationships."
}
}
}
},
"Leg Vorhaben an und erhalte eine Erinnerung damit aus 'Wir müssen mal wieder…' ein echtes Treffen wird." : { "Leg Vorhaben an und erhalte eine Erinnerung damit aus 'Wir müssen mal wieder…' ein echtes Treffen wird." : {
"comment" : "TourCatalog onboarding step 4 body", "comment" : "TourCatalog onboarding step 4 body",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@@ -3546,6 +3563,9 @@
} }
} }
} }
},
"Mit '+ Todo' planst du konkrete Aufgaben für diese Person mit optionaler Erinnerung, damit nichts vergessen wird." : {
}, },
"Mittel" : { "Mittel" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -4344,17 +4364,6 @@
} }
} }
}, },
"Oder einer, der es noch wird." : {
"comment" : "TodayView empty state subtitle",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Or one that will be."
}
}
}
},
"Offen" : { "Offen" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -5108,6 +5117,9 @@
} }
} }
} }
},
"Tippe auf '+ Moment', um Treffen oder Gespräche festzuhalten so weißt du immer, worüber ihr das letzte Mal geredet habt." : {
}, },
"Tippe auf + um jemanden hinzuzufügen." : { "Tippe auf + um jemanden hinzuzufügen." : {
"comment" : "A description of how to add a new contact.", "comment" : "A description of how to add a new contact.",
@@ -5210,6 +5222,9 @@
} }
} }
} }
},
"Todos anlegen" : {
}, },
"Touch ID aktiviert" : { "Touch ID aktiviert" : {
"comment" : "SettingsView biometric label when Touch ID is active", "comment" : "SettingsView biometric label when Touch ID is active",
@@ -5235,6 +5250,7 @@
}, },
"Tour starten" : { "Tour starten" : {
"comment" : "SettingsView button to replay a tour", "comment" : "SettingsView button to replay a tour",
"extractionState" : "stale",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
+1 -1
View File
@@ -82,7 +82,7 @@ struct PaywallView: View {
.padding(.top, 24) .padding(.top, 24)
Text("nahbar") Text("nahbar")
.font(.system(size: 28, weight: .light, design: theme.displayDesign)) .font(.system(size: 26, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text("Wähle deinen Plan") Text("Wähle deinen Plan")
+10 -1
View File
@@ -4,6 +4,7 @@ import SwiftData
struct PeopleListView: View { struct PeopleListView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) var modelContext @Environment(\.modelContext) var modelContext
@Environment(TourCoordinator.self) private var tourCoordinator
@Query(sort: \Person.name) private var people: [Person] @Query(sort: \Person.name) private var people: [Person]
@StateObject private var store = StoreManager.shared @StateObject private var store = StoreManager.shared
@@ -34,7 +35,7 @@ struct PeopleListView: View {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Menschen") Text("Menschen")
.font(.system(size: 34, weight: .light, design: theme.displayDesign)) .font(.system(size: 32, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
// Kontaktlimit-Hinweis für Free-Nutzer // Kontaktlimit-Hinweis für Free-Nutzer
if !store.isPro { if !store.isPro {
@@ -149,6 +150,14 @@ struct PeopleListView: View {
.sheet(isPresented: $showingPaywall) { .sheet(isPresented: $showingPaywall) {
PaywallView(targeting: .pro) PaywallView(targeting: .pro)
} }
// Automatisch zum ersten Kontakt navigieren, wenn die Tour
// den +Moment- oder +Todo-Button spotlighten möchte.
.onChange(of: tourCoordinator.currentStep?.target) { _, target in
guard target == .addMomentButton || target == .addTodoButton else { return }
if selectedPerson == nil, let first = filteredPeople.first {
selectedPerson = first
}
}
} }
private var emptyState: some View { private var emptyState: some View {
+3 -1
View File
@@ -143,7 +143,7 @@ struct PersonDetailView: View {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
Text(person.name) Text(person.name)
.font(.system(size: 26, weight: .light, design: theme.displayDesign)) .font(.system(size: 24, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
TagBadge(text: person.tag.rawValue) TagBadge(text: person.tag.rawValue)
@@ -180,6 +180,7 @@ struct PersonDetailView: View {
.background(theme.accent.opacity(0.10)) .background(theme.accent.opacity(0.10))
.clipShape(Capsule()) .clipShape(Capsule())
} }
.tourTarget(.addMomentButton)
} }
// Persönlichkeitsbasierte Vorhaben-Vorschläge (ersetzt nextStepSection) // Persönlichkeitsbasierte Vorhaben-Vorschläge (ersetzt nextStepSection)
@@ -465,6 +466,7 @@ struct PersonDetailView: View {
.background(theme.accent.opacity(0.10)) .background(theme.accent.opacity(0.10))
.clipShape(Capsule()) .clipShape(Capsule())
} }
.tourTarget(.addTodoButton)
} }
if visibleTodos.isEmpty { if visibleTodos.isEmpty {
+2 -37
View File
@@ -56,7 +56,7 @@ struct SettingsView: View {
// Header // Header
Text("Einstellungen") Text("Einstellungen")
.font(.system(size: 34, weight: .light, design: theme.displayDesign)) .font(.system(size: 32, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 12) .padding(.top, 12)
@@ -591,42 +591,7 @@ struct SettingsView: View {
} }
} }
// App-Touren // App-Touren (deaktiviert)
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "App-Touren", icon: "sparkles")
.padding(.horizontal, 20)
VStack(spacing: 0) {
ForEach(Array(TourCatalog.all.enumerated()), id: \.element.id) { index, tour in
if index > 0 { RowDivider() }
HStack(spacing: 14) {
Image(systemName: "sparkles")
.font(.system(size: 15))
.foregroundStyle(theme.contentTertiary)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(tour.title)
.font(.system(size: 15))
.foregroundStyle(theme.contentPrimary)
Text(String.localizedStringWithFormat(String(localized: "%lld Schritte"), Int64(tour.steps.count)))
.font(.system(size: 12))
.foregroundStyle(theme.contentTertiary)
}
Spacer()
Button("Tour starten") {
tourCoordinator.start(tour.id)
}
.font(.system(size: 13, weight: .medium))
.foregroundStyle(theme.accent)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.padding(.horizontal, 20)
}
// About // About
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
+12 -6
View File
@@ -5,6 +5,7 @@ import UserNotifications
struct TodayView: View { struct TodayView: View {
@Environment(\.nahbarTheme) var theme @Environment(\.nahbarTheme) var theme
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@EnvironmentObject private var profileStore: UserProfileStore
@Query private var people: [Person] @Query private var people: [Person]
// V5: Nachwirkungen sind jetzt Treffen-Momente mit Status "warte_nachwirkung" // V5: Nachwirkungen sind jetzt Treffen-Momente mit Status "warte_nachwirkung"
@Query(filter: #Predicate<Moment> { @Query(filter: #Predicate<Moment> {
@@ -102,11 +103,16 @@ struct TodayView: View {
} }
} }
private var greeting: LocalizedStringKey { private var greeting: String {
let hour = Calendar.current.component(.hour, from: Date()) let hour = Calendar.current.component(.hour, from: Date())
if hour < 12 { return "Guten Morgen." } let base: String
if hour < 18 { return "Guten Tag." } if hour < 12 { base = String(localized: "Guten Morgen") }
return "Guten Abend." else if hour < 18 { base = String(localized: "Guten Tag") }
else { base = String(localized: "Guten Abend") }
let firstName = profileStore.name.split(separator: " ").first.map(String.init) ?? ""
if firstName.isEmpty { return "\(base)." }
return "\(base), \(firstName)."
} }
private var formattedToday: String { private var formattedToday: String {
@@ -120,7 +126,7 @@ struct TodayView: View {
// Header // Header
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(greeting) Text(greeting)
.font(.system(size: 34, weight: .light, design: theme.displayDesign)) .font(.system(size: 32, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text(formattedToday) Text(formattedToday)
.font(.system(size: 15, design: theme.displayDesign)) .font(.system(size: 15, design: theme.displayDesign))
@@ -302,7 +308,7 @@ struct TodayView: View {
Text("Ein ruhiger Tag.") Text("Ein ruhiger Tag.")
.font(.system(size: 20, weight: .light, design: theme.displayDesign)) .font(.system(size: 20, weight: .light, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary) .foregroundStyle(theme.contentPrimary)
Text("Oder einer, der es noch wird.") Text("Lass uns mit der Beziehungspflege starten.")
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundStyle(theme.contentTertiary) .foregroundStyle(theme.contentTertiary)
} }
+1 -1
View File
@@ -31,7 +31,7 @@ struct Tour: Identifiable, Hashable {
triggerMode: TriggerMode triggerMode: TriggerMode
) { ) {
precondition(!steps.isEmpty, "A tour must have at least 1 step.") precondition(!steps.isEmpty, "A tour must have at least 1 step.")
precondition(steps.count <= 6, "A tour must not exceed 6 steps. Got \(steps.count).") precondition(steps.count <= 8, "A tour must not exceed 8 steps. Got \(steps.count).")
self.id = id self.id = id
self.title = title self.title = title
self.steps = steps self.steps = steps
+9 -3
View File
@@ -33,9 +33,15 @@ enum TourCatalog {
), ),
TourStep( TourStep(
title: "Plane das Nächste", title: "Plane das Nächste",
body: "Leg Vorhaben an und erhalte eine Erinnerung damit aus 'Wir müssen mal wieder…' ein echtes Treffen wird.", body: "Tippe auf '+ Moment', um Treffen oder Gespräche festzuhalten so weißt du immer, worüber ihr das letzte Mal geredet habt.",
target: nil, target: .addMomentButton,
preferredCardPosition: .center preferredCardPosition: .below
),
TourStep(
title: "Todos anlegen",
body: "Mit '+ Todo' planst du konkrete Aufgaben für diese Person mit optionaler Erinnerung, damit nichts vergessen wird.",
target: .addTodoButton,
preferredCardPosition: .below
), ),
TourStep( TourStep(
title: "Sanfte Erinnerungen", title: "Sanfte Erinnerungen",
+2
View File
@@ -8,6 +8,8 @@ enum TourTargetID: String, Hashable, CaseIterable {
case filterChips case filterChips
case relationshipStrengthBadge case relationshipStrengthBadge
case contactCardFirst case contactCardFirst
case addMomentButton
case addTodoButton
case personalityTab case personalityTab
case insightsTab case insightsTab
case settingsEntry case settingsEntry
@@ -7,11 +7,11 @@ import Testing
@Suite("TourCatalog Validierung") @Suite("TourCatalog Validierung")
struct TourCatalogTests { struct TourCatalogTests {
@Test("Alle Touren haben mindestens 1 und höchstens 6 Steps") @Test("Alle Touren haben mindestens 1 und höchstens 7 Steps")
func allToursHaveValidStepCount() { func allToursHaveValidStepCount() {
for tour in TourCatalog.all { for tour in TourCatalog.all {
#expect(!tour.steps.isEmpty, "Tour \(tour.id.rawValue) hat keine Steps") #expect(!tour.steps.isEmpty, "Tour \(tour.id.rawValue) hat keine Steps")
#expect(tour.steps.count <= 6, "Tour \(tour.id.rawValue) hat mehr als 6 Steps") #expect(tour.steps.count <= 7, "Tour \(tour.id.rawValue) hat mehr als 7 Steps")
} }
} }
@@ -25,9 +25,9 @@ struct TourCatalogTests {
} }
} }
@Test("Onboarding-Tour hat genau 6 Steps") @Test("Onboarding-Tour hat genau 7 Steps")
func onboardingTourHasSixSteps() { func onboardingTourHasSevenSteps() {
#expect(TourCatalog.onboarding.steps.count == 6) #expect(TourCatalog.onboarding.steps.count == 7)
} }
@Test("Onboarding-Tour hat triggerMode .manualOrFirstLaunch") @Test("Onboarding-Tour hat triggerMode .manualOrFirstLaunch")