mana-swift-ui/Sources/ManaFeedbackUI/ManaFeedbackSheet.swift
Till JS 7a0216dc44 feat(feedback): Screenshot-Anhaenge via PhotosPicker im ManaFeedbackSheet (v0.13.0)
PhotosPicker (bis 3 Bilder) + Thumbnails + Entfernen; FeedbackImagePrep
(ImageIO/CoreGraphics, iOS+macOS, EXIF-frei). FeedbackFormModel haelt
pickedItems/images. Braucht ManaCore >= 2.2.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:56:10 +02:00

219 lines
7.9 KiB
Swift

import ManaCore
import ManaThemeUI
import ManaTokens
import PhotosUI
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
/// Das native Feedback-Sheet — Pendant zum Web-`FeedbackWidget`. Kind-Picker
/// (Wunsch/Problem/Feedback), Titel, Beschreibung, bei anonymer Nutzung
/// zusätzlich ein optionales Kontakt-Mail-Feld. Postet via ``FeedbackClient``
/// an die zentrale Wunsch-App.
///
/// Wird typischerweise nicht direkt instanziiert, sondern über
/// ``ManaFeedbackButton`` oder den `.manaFeedbackSheet(...)`-Modifier
/// präsentiert.
public struct ManaFeedbackSheet: View {
@Environment(\.manaTheme) private var theme
@Environment(\.dismiss) private var dismiss
@State private var model: FeedbackFormModel
public init(client: FeedbackClient, isLoggedIn: Bool) {
_model = State(initialValue: FeedbackFormModel(client: client, isLoggedIn: isLoggedIn))
}
public var body: some View {
NavigationStack {
Group {
if model.status == .sent {
successView
} else {
form
}
}
.navigationTitle("Feedback")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
}
}
}
// MARK: - Form
private var form: some View {
Form {
Section {
Picker("Art", selection: $model.kind) {
ForEach(FeedbackKind.allCases, id: \.self) { kind in
Text(kind.displayName).tag(kind)
}
}
.pickerStyle(.segmented)
}
Section {
TextField("Titel", text: $model.title)
ZStack(alignment: .topLeading) {
if model.details.isEmpty {
Text(placeholder)
.foregroundStyle(theme.mutedForeground)
.padding(.top, 8)
.padding(.leading, 4)
.allowsHitTesting(false)
}
TextEditor(text: $model.details)
.frame(minHeight: 120)
.scrollContentBackground(.hidden)
}
} header: {
Text("Worum geht's?")
}
Section {
if !model.images.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(model.images.enumerated()), id: \.offset) { index, data in
ZStack(alignment: .topTrailing) {
thumbnail(data)
.frame(width: 64, height: 64)
.clipShape(RoundedRectangle(cornerRadius: 8))
Button {
model.removeImage(at: index)
} label: {
Image(systemName: "xmark.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(theme.background, theme.mutedForeground)
}
.buttonStyle(.plain)
.padding(2)
}
}
}
.padding(.vertical, 4)
}
}
if model.canAddImages {
PhotosPicker(
selection: $model.pickedItems,
maxSelectionCount: model.maxImages - model.images.count,
matching: .images
) {
Label("Screenshot hinzufügen", systemImage: "photo.badge.plus")
}
.onChange(of: model.pickedItems) { _, _ in
Task { await model.loadPickedImages() }
}
}
} header: {
Text("Screenshots (optional)")
}
if model.showsContactField {
Section {
TextField("E-Mail (optional)", text: $model.contactEmail)
#if os(iOS)
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
#endif
.disableAutocorrection(true)
} footer: {
Text("Anonym eingereicht — geht zuerst durch die Moderation. "
+ "E-Mail nur, falls wir nachfragen dürfen.")
}
}
if let error = model.errorMessage {
Section {
Text(error).foregroundStyle(theme.error)
}
}
Section {
submitButton
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
}
}
private var submitButton: some View {
Button {
Task { await model.submit() }
} label: {
HStack(spacing: 8) {
if model.isSending {
ProgressView().controlSize(.small).tint(theme.primaryForeground)
}
Text("Senden").fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(theme.primary, in: RoundedRectangle.mana(Radius.md))
.foregroundStyle(theme.primaryForeground)
}
.buttonStyle(.plain)
.disabled(!model.canSubmit)
.opacity(model.canSubmit ? 1 : 0.6)
}
/// Plattform-neutrale Thumbnail-View aus JPEG-Bytes (iOS: UIImage,
/// macOS: NSImage).
@ViewBuilder
private func thumbnail(_ data: Data) -> some View {
#if canImport(UIKit)
if let image = UIImage(data: data) {
Image(uiImage: image).resizable().scaledToFill()
}
#elseif canImport(AppKit)
if let image = NSImage(data: data) {
Image(nsImage: image).resizable().scaledToFill()
}
#endif
}
private var placeholder: String {
switch model.kind {
case .bug: return "Was ist passiert? Was hattest du erwartet?"
case .wunsch: return "Was würde dir helfen?"
case .feedback: return "Erzähl uns, was dir auffällt."
}
}
// MARK: - Erfolg
private var successView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 48))
.foregroundStyle(theme.primary)
Text("Danke für dein Feedback!")
.font(.headline)
Text(model.isLoggedIn
? "Es ist bei uns angekommen."
: "Es geht jetzt durch die Moderation.")
.font(.subheadline)
.foregroundStyle(theme.mutedForeground)
.multilineTextAlignment(.center)
if model.isLoggedIn {
Text("🌱 Wird daraus ein bestätigter Bug oder ein vielgewünschtes Feature, schreiben wir dir Aura gut.")
.font(.footnote)
.foregroundStyle(theme.mutedForeground)
.multilineTextAlignment(.center)
}
Button("Fertig") { dismiss() }
.padding(.top, 8)
}
.padding(32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}