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>
This commit is contained in:
@@ -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
|
||||
@State private var values: [Int?]
|
||||
@State private var revealedCount: Int = 1
|
||||
@State private var showSummary: Bool = false
|
||||
|
||||
init(moment: Moment) {
|
||||
@@ -27,7 +28,7 @@ struct AftermathRatingFlowView: View {
|
||||
if showSummary {
|
||||
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
|
||||
} else {
|
||||
questionStep
|
||||
questionFlow
|
||||
}
|
||||
}
|
||||
.navigationTitle("Nachwirkung")
|
||||
@@ -36,46 +37,80 @@ struct AftermathRatingFlowView: View {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen") { dismiss() }
|
||||
}
|
||||
if !showSummary {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") {
|
||||
advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fragen-Screen
|
||||
// MARK: - Scrollbarer Fragen-Flow
|
||||
|
||||
private var questionStep: some View {
|
||||
ZStack {
|
||||
RatingQuestionView(
|
||||
question: questions[currentIndex],
|
||||
index: currentIndex,
|
||||
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,
|
||||
value: $values[currentIndex]
|
||||
isActive: i == revealedCount - 1,
|
||||
value: $values[i],
|
||||
onAnswer: { revealNext(after: i) },
|
||||
onSkip: { revealNext(after: i) }
|
||||
)
|
||||
.id(currentIndex)
|
||||
.id(i)
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .trailing),
|
||||
removal: .move(edge: .leading)
|
||||
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
||||
removal: .identity
|
||||
))
|
||||
}
|
||||
.clipped()
|
||||
|
||||
if revealedCount == questions.count {
|
||||
saveButton
|
||||
.id("save")
|
||||
.transition(.asymmetric(
|
||||
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
||||
removal: .identity
|
||||
))
|
||||
}
|
||||
}
|
||||
.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: - Navigation & Speichern
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
private func advance() {
|
||||
if currentIndex < questions.count - 1 {
|
||||
withAnimation { currentIndex += 1 }
|
||||
} else {
|
||||
saveAftermath()
|
||||
// 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 +126,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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,7 +31,7 @@ struct MeetingRatingFlowView: View {
|
||||
if showSummary {
|
||||
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
|
||||
} else {
|
||||
questionStep
|
||||
questionFlow
|
||||
}
|
||||
}
|
||||
.navigationTitle("Treffen bewerten")
|
||||
@@ -43,43 +40,75 @@ struct MeetingRatingFlowView: View {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen") { dismiss() }
|
||||
}
|
||||
if !showSummary {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") {
|
||||
advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fragen-Screen
|
||||
// MARK: - Scrollbarer Fragen-Flow
|
||||
|
||||
private var questionStep: some View {
|
||||
ZStack {
|
||||
RatingQuestionView(
|
||||
question: questions[currentIndex],
|
||||
index: currentIndex,
|
||||
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,
|
||||
value: $values[currentIndex]
|
||||
isActive: i == revealedCount - 1,
|
||||
value: $values[i],
|
||||
onAnswer: { revealNext(after: i) },
|
||||
onSkip: { revealNext(after: i) }
|
||||
)
|
||||
.id(currentIndex)
|
||||
.id(i)
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .trailing),
|
||||
removal: .move(edge: .leading)
|
||||
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
||||
removal: .identity
|
||||
))
|
||||
}
|
||||
.clipped()
|
||||
|
||||
if revealedCount == questions.count {
|
||||
saveButton
|
||||
.id("save")
|
||||
.transition(.asymmetric(
|
||||
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
||||
removal: .identity
|
||||
))
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +141,6 @@ struct MeetingRatingFlowView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// Nachwirkungs-Notification planen
|
||||
AftermathNotificationManager.shared.scheduleAftermath(
|
||||
momentID: moment.id,
|
||||
personName: moment.person?.firstName ?? "",
|
||||
|
||||
Reference in New Issue
Block a user