Compare commits

...

2 Commits

Author SHA1 Message Date
sven 18112cb52c Resolves #11 Resolves #13 Fragebogen: Abbrechen-Button entfernt
Der Speichern-Button am Ende des Flows ersetzt den Abbrechen-Button
in der Toolbar für beide Fragebögen (Meeting + Nachwirkung).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:04:34 +02:00
sven b477a3e04b Resolves #13 Fragebogen: scrollbarer Einblend-Flow
Statt Fragen einzeln zu ersetzen, werden sie jetzt nacheinander von
unten eingeblendet und bleiben sichtbar:
- Tippen auf einen Dot zeigt die nächste Frage darunter an
- "Überspringen" blendet ebenfalls die nächste Frage ein
- Beantwortete Fragen bleiben sichtbar und können angepasst werden
- Nach der letzten Frage erscheint ein "Speichern"-Button
- Gilt für Sofort-Bewertung (MeetingRatingFlowView) und
  Nachwirkung (AftermathRatingFlowView)
- Neuer QuestionCard-Component in RatingQuestionView.swift

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 05:47:58 +02:00
4 changed files with 213 additions and 80 deletions
+70 -40
View File
@@ -2,18 +2,19 @@ import SwiftUI
import SwiftData
// MARK: - AftermathRatingFlowView
// Sheet-basierter Bewertungs-Flow für die Nachwirkungs-Bewertung (4 Fragen).
// Wird aus einer Push-Notification heraus oder aus der Momente-Liste geöffnet.
// Scrollbarer Bewertungs-Flow für die Nachwirkungs-Bewertung (4 Fragen).
// Gleiche Interaktion wie MeetingRatingFlowView Fragen blenden nacheinander ein.
struct AftermathRatingFlowView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.nahbarTheme) var theme
@Environment(\.dismiss) private var dismiss
let moment: Moment
private let questions = RatingQuestion.aftermath // 4 Fragen
@State private var currentIndex: Int = 0
private let questions = RatingQuestion.aftermath // 4 Fragen
@State private var values: [Int?]
@State private var revealedCount: Int = 1
@State private var showSummary: Bool = false
init(moment: Moment) {
@@ -27,55 +28,85 @@ struct AftermathRatingFlowView: View {
if showSummary {
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
} else {
questionStep
questionFlow
}
}
.navigationTitle("Nachwirkung")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
}
// MARK: - Scrollbarer Fragen-Flow
private var questionFlow: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 14) {
ForEach(0..<revealedCount, id: \.self) { i in
QuestionCard(
question: questions[i],
index: i,
total: questions.count,
isActive: i == revealedCount - 1,
value: $values[i],
onAnswer: { revealNext(after: i) },
onSkip: { revealNext(after: i) }
)
.id(i)
.transition(.asymmetric(
insertion: .opacity.combined(with: .move(edge: .bottom)),
removal: .identity
))
}
if revealedCount == questions.count {
saveButton
.id("save")
.transition(.asymmetric(
insertion: .opacity.combined(with: .move(edge: .bottom)),
removal: .identity
))
}
}
if !showSummary {
ToolbarItem(placement: .confirmationAction) {
Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") {
advance()
}
.padding(16)
.padding(.bottom, 32)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.onChange(of: revealedCount) { _, newCount in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.easeOut(duration: 0.4)) {
proxy.scrollTo(newCount - 1, anchor: .top)
}
}
}
}
}
// MARK: - Fragen-Screen
private var questionStep: some View {
ZStack {
RatingQuestionView(
question: questions[currentIndex],
index: currentIndex,
total: questions.count,
value: $values[currentIndex]
)
.id(currentIndex)
.transition(.asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading)
))
}
.clipped()
}
// MARK: - Navigation & Speichern
private func advance() {
if currentIndex < questions.count - 1 {
withAnimation { currentIndex += 1 }
} else {
saveAftermath()
private var saveButton: some View {
Button { saveAftermath() } label: {
Text("Speichern")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(theme.accent)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
}
// MARK: - Navigation
private func revealNext(after index: Int) {
guard index == revealedCount - 1 else { return }
guard revealedCount < questions.count else { return }
withAnimation(.easeOut(duration: 0.35)) {
revealedCount += 1
}
}
// MARK: - Speichern
private func saveAftermath() {
for (i, q) in questions.enumerated() {
let rating = Rating(
@@ -91,7 +122,6 @@ struct AftermathRatingFlowView: View {
moment.meetingStatus = .completed
moment.aftermathCompletedAt = Date()
// Evtl. geplante Notification abbrechen (falls Nutzer selbst geöffnet hat)
AftermathNotificationManager.shared.cancelAftermath(momentID: moment.id)
do {
+68
View File
@@ -1,5 +1,73 @@
import SwiftUI
// MARK: - QuestionCard
// Einzelne Bewertungsfrage als Card für den scrollbaren Flow.
// isActive = letzte sichtbare Frage (noch nicht bestätigt).
struct QuestionCard: View {
@Environment(\.nahbarTheme) var theme
let question: RatingQuestion
let index: Int
let total: Int
let isActive: Bool
@Binding var value: Int?
let onAnswer: () -> Void // Dot ausgewählt (nur wenn isActive)
let onSkip: () -> Void // Überspringen getippt (nur wenn isActive)
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("\(index + 1) / \(total)")
.font(.caption.weight(.medium))
.foregroundStyle(theme.contentTertiary)
Spacer()
HStack(spacing: 5) {
Image(systemName: question.category.icon)
.font(.caption.bold())
Text(LocalizedStringKey(question.category.rawValue))
.font(.caption.bold())
}
.foregroundStyle(question.category.color)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(question.category.color.opacity(0.12), in: Capsule())
}
Text(LocalizedStringKey(question.text))
.font(.system(size: 16, weight: .semibold, design: theme.displayDesign))
.foregroundStyle(theme.contentPrimary)
.fixedSize(horizontal: false, vertical: true)
RatingDotPicker(
value: $value,
negativePole: question.negativePole,
positivePole: question.positivePole
)
.onChange(of: value) { _, newValue in
if isActive, newValue != nil {
onAnswer()
}
}
if isActive {
Button {
value = nil
onSkip()
} label: {
Text("Überspringen")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
.padding(16)
.background(theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
.opacity(isActive ? 1.0 : 0.75)
}
}
// MARK: - RatingQuestionView
// Zeigt eine einzelne Bewertungsfrage mit Kategorie-Badge, Fragetext,
// RatingDotPicker und "Überspringen"-Button.
+64 -40
View File
@@ -2,24 +2,21 @@ import SwiftUI
import SwiftData
// MARK: - MeetingRatingFlowView
// Sheet-basierter Bewertungs-Flow für die Sofort-Bewertung eines Treffen-Moments.
// Erwartet einen bereits gespeicherten Moment vom Typ .meeting und ergänzt ihn
// um Ratings sowie den Nachwirkungs-Status.
// Scrollbarer Bewertungs-Flow für die Sofort-Bewertung eines Treffens.
// Fragen blenden nacheinander von unten ein, sobald die vorherige beantwortet wurde.
// Nach der letzten Frage erscheint ein "Speichern"-Button.
struct MeetingRatingFlowView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.nahbarTheme) var theme
@Environment(\.dismiss) private var dismiss
let moment: Moment
// Nachwirkungs-Verzögerung (aus App-Einstellungen übergeben)
var aftermathDelay: TimeInterval = 36 * 3600
// MARK: State
private let questions = RatingQuestion.immediate // 5 Fragen
@State private var currentIndex: Int = 0
@State private var values: [Int?] // [nil] × 5
@State private var values: [Int?]
@State private var revealedCount: Int = 1
@State private var showSummary: Bool = false
init(moment: Moment, aftermathDelay: TimeInterval = 36 * 3600) {
@@ -34,52 +31,80 @@ struct MeetingRatingFlowView: View {
if showSummary {
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
} else {
questionStep
questionFlow
}
}
.navigationTitle("Treffen bewerten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
}
// MARK: - Scrollbarer Fragen-Flow
private var questionFlow: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 14) {
ForEach(0..<revealedCount, id: \.self) { i in
QuestionCard(
question: questions[i],
index: i,
total: questions.count,
isActive: i == revealedCount - 1,
value: $values[i],
onAnswer: { revealNext(after: i) },
onSkip: { revealNext(after: i) }
)
.id(i)
.transition(.asymmetric(
insertion: .opacity.combined(with: .move(edge: .bottom)),
removal: .identity
))
}
if revealedCount == questions.count {
saveButton
.id("save")
.transition(.asymmetric(
insertion: .opacity.combined(with: .move(edge: .bottom)),
removal: .identity
))
}
}
if !showSummary {
ToolbarItem(placement: .confirmationAction) {
Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") {
advance()
}
.padding(16)
.padding(.bottom, 32)
}
.background(theme.backgroundPrimary.ignoresSafeArea())
.onChange(of: revealedCount) { _, newCount in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.easeOut(duration: 0.4)) {
proxy.scrollTo(newCount - 1, anchor: .top)
}
}
}
}
}
// MARK: - Fragen-Screen
private var questionStep: some View {
ZStack {
RatingQuestionView(
question: questions[currentIndex],
index: currentIndex,
total: questions.count,
value: $values[currentIndex]
)
.id(currentIndex)
.transition(.asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading)
))
private var saveButton: some View {
Button { saveRatings() } label: {
Text("Speichern")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 15)
.background(theme.accent)
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
}
.clipped()
}
// MARK: - Navigation
private func advance() {
if currentIndex < questions.count - 1 {
withAnimation { currentIndex += 1 }
} else {
saveRatings()
private func revealNext(after index: Int) {
guard index == revealedCount - 1 else { return }
guard revealedCount < questions.count else { return }
withAnimation(.easeOut(duration: 0.35)) {
revealedCount += 1
}
}
@@ -112,7 +137,6 @@ struct MeetingRatingFlowView: View {
)
}
// Nachwirkungs-Notification planen
AftermathNotificationManager.shared.scheduleAftermath(
momentID: moment.id,
personName: moment.person?.firstName ?? "",
+11
View File
@@ -115,6 +115,16 @@
}
}
},
"%lld / %lld" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lld / %2$lld"
}
}
}
},
"%lld ausgewählt" : {
"extractionState" : "stale",
"localizations" : {
@@ -5148,6 +5158,7 @@
}
},
"Verlauf" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {