Compare commits
2 Commits
d541640c74
...
18112cb52c
| Author | SHA1 | Date | |
|---|---|---|---|
| 18112cb52c | |||
| b477a3e04b |
@@ -2,18 +2,19 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
// MARK: - AftermathRatingFlowView
|
// MARK: - AftermathRatingFlowView
|
||||||
// Sheet-basierter Bewertungs-Flow für die Nachwirkungs-Bewertung (4 Fragen).
|
// Scrollbarer Bewertungs-Flow für die Nachwirkungs-Bewertung (4 Fragen).
|
||||||
// Wird aus einer Push-Notification heraus oder aus der Momente-Liste geöffnet.
|
// Gleiche Interaktion wie MeetingRatingFlowView – Fragen blenden nacheinander ein.
|
||||||
|
|
||||||
struct AftermathRatingFlowView: View {
|
struct AftermathRatingFlowView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.nahbarTheme) var theme
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
let moment: Moment
|
let moment: Moment
|
||||||
|
|
||||||
private let questions = RatingQuestion.aftermath // 4 Fragen
|
private let questions = RatingQuestion.aftermath // 4 Fragen
|
||||||
@State private var currentIndex: Int = 0
|
|
||||||
@State private var values: [Int?]
|
@State private var values: [Int?]
|
||||||
|
@State private var revealedCount: Int = 1
|
||||||
@State private var showSummary: Bool = false
|
@State private var showSummary: Bool = false
|
||||||
|
|
||||||
init(moment: Moment) {
|
init(moment: Moment) {
|
||||||
@@ -27,55 +28,85 @@ struct AftermathRatingFlowView: View {
|
|||||||
if showSummary {
|
if showSummary {
|
||||||
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
|
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
|
||||||
} else {
|
} else {
|
||||||
questionStep
|
questionFlow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Nachwirkung")
|
.navigationTitle("Nachwirkung")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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 {
|
.padding(16)
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
.padding(.bottom, 32)
|
||||||
Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") {
|
}
|
||||||
advance()
|
.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 saveButton: some View {
|
||||||
|
Button { saveAftermath() } label: {
|
||||||
private var questionStep: some View {
|
Text("Speichern")
|
||||||
ZStack {
|
.font(.system(size: 16, weight: .semibold))
|
||||||
RatingQuestionView(
|
.foregroundStyle(.white)
|
||||||
question: questions[currentIndex],
|
.frame(maxWidth: .infinity)
|
||||||
index: currentIndex,
|
.padding(.vertical, 15)
|
||||||
total: questions.count,
|
.background(theme.accent)
|
||||||
value: $values[currentIndex]
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
)
|
|
||||||
.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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
private func saveAftermath() {
|
||||||
for (i, q) in questions.enumerated() {
|
for (i, q) in questions.enumerated() {
|
||||||
let rating = Rating(
|
let rating = Rating(
|
||||||
@@ -91,7 +122,6 @@ struct AftermathRatingFlowView: View {
|
|||||||
moment.meetingStatus = .completed
|
moment.meetingStatus = .completed
|
||||||
moment.aftermathCompletedAt = Date()
|
moment.aftermathCompletedAt = Date()
|
||||||
|
|
||||||
// Evtl. geplante Notification abbrechen (falls Nutzer selbst geöffnet hat)
|
|
||||||
AftermathNotificationManager.shared.cancelAftermath(momentID: moment.id)
|
AftermathNotificationManager.shared.cancelAftermath(momentID: moment.id)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -1,5 +1,73 @@
|
|||||||
import SwiftUI
|
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
|
// MARK: - RatingQuestionView
|
||||||
// Zeigt eine einzelne Bewertungsfrage mit Kategorie-Badge, Fragetext,
|
// Zeigt eine einzelne Bewertungsfrage mit Kategorie-Badge, Fragetext,
|
||||||
// RatingDotPicker und "Überspringen"-Button.
|
// RatingDotPicker und "Überspringen"-Button.
|
||||||
|
|||||||
@@ -2,24 +2,21 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
// MARK: - MeetingRatingFlowView
|
// MARK: - MeetingRatingFlowView
|
||||||
// Sheet-basierter Bewertungs-Flow für die Sofort-Bewertung eines Treffen-Moments.
|
// Scrollbarer Bewertungs-Flow für die Sofort-Bewertung eines Treffens.
|
||||||
// Erwartet einen bereits gespeicherten Moment vom Typ .meeting und ergänzt ihn
|
// Fragen blenden nacheinander von unten ein, sobald die vorherige beantwortet wurde.
|
||||||
// um Ratings sowie den Nachwirkungs-Status.
|
// Nach der letzten Frage erscheint ein "Speichern"-Button.
|
||||||
|
|
||||||
struct MeetingRatingFlowView: View {
|
struct MeetingRatingFlowView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.nahbarTheme) var theme
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
let moment: Moment
|
let moment: Moment
|
||||||
|
|
||||||
// Nachwirkungs-Verzögerung (aus App-Einstellungen übergeben)
|
|
||||||
var aftermathDelay: TimeInterval = 36 * 3600
|
var aftermathDelay: TimeInterval = 36 * 3600
|
||||||
|
|
||||||
// MARK: State
|
|
||||||
|
|
||||||
private let questions = RatingQuestion.immediate // 5 Fragen
|
private let questions = RatingQuestion.immediate // 5 Fragen
|
||||||
@State private var currentIndex: Int = 0
|
@State private var values: [Int?]
|
||||||
@State private var values: [Int?] // [nil] × 5
|
@State private var revealedCount: Int = 1
|
||||||
@State private var showSummary: Bool = false
|
@State private var showSummary: Bool = false
|
||||||
|
|
||||||
init(moment: Moment, aftermathDelay: TimeInterval = 36 * 3600) {
|
init(moment: Moment, aftermathDelay: TimeInterval = 36 * 3600) {
|
||||||
@@ -34,52 +31,80 @@ struct MeetingRatingFlowView: View {
|
|||||||
if showSummary {
|
if showSummary {
|
||||||
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
|
MeetingSummaryView(moment: moment, onDismiss: { dismiss() })
|
||||||
} else {
|
} else {
|
||||||
questionStep
|
questionFlow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Treffen bewerten")
|
.navigationTitle("Treffen bewerten")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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 {
|
.padding(16)
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
.padding(.bottom, 32)
|
||||||
Button(currentIndex == questions.count - 1 ? "Fertig" : "Weiter") {
|
}
|
||||||
advance()
|
.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 saveButton: some View {
|
||||||
|
Button { saveRatings() } label: {
|
||||||
private var questionStep: some View {
|
Text("Speichern")
|
||||||
ZStack {
|
.font(.system(size: 16, weight: .semibold))
|
||||||
RatingQuestionView(
|
.foregroundStyle(.white)
|
||||||
question: questions[currentIndex],
|
.frame(maxWidth: .infinity)
|
||||||
index: currentIndex,
|
.padding(.vertical, 15)
|
||||||
total: questions.count,
|
.background(theme.accent)
|
||||||
value: $values[currentIndex]
|
.clipShape(RoundedRectangle(cornerRadius: theme.radiusCard))
|
||||||
)
|
|
||||||
.id(currentIndex)
|
|
||||||
.transition(.asymmetric(
|
|
||||||
insertion: .move(edge: .trailing),
|
|
||||||
removal: .move(edge: .leading)
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
.clipped()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Navigation
|
// MARK: - Navigation
|
||||||
|
|
||||||
private func advance() {
|
private func revealNext(after index: Int) {
|
||||||
if currentIndex < questions.count - 1 {
|
guard index == revealedCount - 1 else { return }
|
||||||
withAnimation { currentIndex += 1 }
|
guard revealedCount < questions.count else { return }
|
||||||
} else {
|
withAnimation(.easeOut(duration: 0.35)) {
|
||||||
saveRatings()
|
revealedCount += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +137,6 @@ struct MeetingRatingFlowView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nachwirkungs-Notification planen
|
|
||||||
AftermathNotificationManager.shared.scheduleAftermath(
|
AftermathNotificationManager.shared.scheduleAftermath(
|
||||||
momentID: moment.id,
|
momentID: moment.id,
|
||||||
personName: moment.person?.firstName ?? "",
|
personName: moment.person?.firstName ?? "",
|
||||||
|
|||||||
@@ -115,6 +115,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"%lld / %lld" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$lld / %2$lld"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"%lld ausgewählt" : {
|
"%lld ausgewählt" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -5148,6 +5158,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Verlauf" : {
|
"Verlauf" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|||||||
Reference in New Issue
Block a user