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>
219 lines
7.9 KiB
Swift
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)
|
|
}
|
|
}
|