mana-swift-ui/Sources/ManaFeedbackUI/FeedbackFormModel.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

125 lines
4 KiB
Swift

import Foundation
import ManaCore
import Observation
import PhotosUI
import SwiftUI
/// State-Maschine für ``ManaFeedbackSheet``. Wraps ``FeedbackClient``.
///
/// Bei `isLoggedIn == false` wird zusätzlich ein optionales Kontakt-Mail-Feld
/// angeboten (Rückfrage-Adresse) und der Hinweis gezeigt, dass die
/// Einreichung erst durch die Moderation geht.
@MainActor
@Observable
public final class FeedbackFormModel {
public enum Status: Equatable, Sendable {
case idle
case sending
case sent
case error(String)
}
public var kind: FeedbackKind = .feedback
public var title: String = ""
public var details: String = ""
public var contactEmail: String = ""
public private(set) var status: Status = .idle
/// Höchstzahl Screenshot-Anhänge — deckt sich mit Web + Backend (`MAX_IMAGES`).
public let maxImages = 3
/// Auswahl aus dem ``PhotosPicker``; ``loadPickedImages()`` verarbeitet sie.
public var pickedItems: [PhotosPickerItem] = []
/// Aufbereitete (herunterskalierte, EXIF-freie) JPEG-Bytes — Quelle für
/// Thumbnails **und** den Upload.
public private(set) var images: [Data] = []
private let client: FeedbackClient
public let isLoggedIn: Bool
public init(client: FeedbackClient, isLoggedIn: Bool) {
self.client = client
self.isLoggedIn = isLoggedIn
}
public var canAddImages: Bool { images.count < maxImages }
/// Lädt die `PhotosPicker`-Auswahl, skaliert herunter (EXIF-frei) und füllt
/// ``images``. Wird `onChange(of: pickedItems)` aufgerufen.
public func loadPickedImages() async {
guard !pickedItems.isEmpty else { return }
var collected: [Data] = []
for item in pickedItems {
if images.count + collected.count >= maxImages { break }
guard let raw = try? await item.loadTransferable(type: Data.self),
let prepped = FeedbackImagePrep.prepare(raw) else { continue }
collected.append(prepped)
}
images = Array((images + collected).prefix(maxImages))
pickedItems = []
}
public func removeImage(at index: Int) {
guard images.indices.contains(index) else { return }
images.remove(at: index)
}
/// Anonyme Einreichung → Kontakt-Feld + Moderations-Hinweis zeigen.
public var showsContactField: Bool { !isLoggedIn }
public var canSubmit: Bool {
guard trimmed(title).count >= 3 else { return false }
if case .sending = status { return false }
return true
}
public var isSending: Bool {
if case .sending = status { return true }
return false
}
public var errorMessage: String? {
if case .error(let message) = status { return message }
return nil
}
public func submit() async {
guard canSubmit else { return }
status = .sending
let submission = FeedbackSubmission(
kind: kind,
title: trimmed(title),
description: trimmed(details),
contactEmail: isLoggedIn ? nil : nilIfEmpty(trimmed(contactEmail)),
images: images.map { FeedbackImage(mime: "image/jpeg", b64: $0.base64EncodedString()) }
)
do {
_ = try await client.submit(submission)
status = .sent
} catch {
let message = (error as? LocalizedError)?.errorDescription
?? "Senden fehlgeschlagen. Bitte später erneut versuchen."
status = .error(message)
}
}
private func trimmed(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func nilIfEmpty(_ value: String) -> String? {
value.isEmpty ? nil : value
}
}
extension FeedbackKind {
/// Deutsche Anzeige-Bezeichnung — neutral, nicht „Wunsch"-lastig.
public var displayName: String {
switch self {
case .wunsch: return "Wunsch"
case .bug: return "Problem"
case .feedback: return "Feedback"
}
}
}