feat(ManaFeedbackUI): natives Feedback-Sheet + Button (F-4.1)

Neues Library-Produkt ManaFeedbackUI: ManaFeedbackSheet (Kind-Picker/
Titel/Beschreibung, anon + Kontakt-Mail), ManaFeedbackButton +
.manaFeedbackSheet()-Modifier. Postet via ManaCore.FeedbackClient.
Theming via \.manaTheme. swift build grün.
mana/docs/FEEDBACK_NATIVE_PLAN.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-28 00:25:05 +02:00
parent 4b7c605a9c
commit 9844759e86
5 changed files with 332 additions and 0 deletions

View file

@ -6,6 +6,18 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an
## [Unreleased]
### Hinzugefügt
- **`ManaFeedbackUI`** — neues Library-Produkt. `ManaFeedbackSheet`
(Kind-Picker Wunsch/Problem/Feedback, Titel, Beschreibung; bei anonymer
Nutzung optionales Kontakt-Mail-Feld + Moderations-Hinweis),
`ManaFeedbackButton` (Default-Label „Feedback" oder Custom-Label) und
der `.manaFeedbackSheet(isPresented:client:isLoggedIn:)`-Modifier für
eigene Trigger (z.B. Toolbar/Settings-Row). Postet via
`ManaCore.FeedbackClient` an die zentrale Wunsch-App — Pendant zum
Web-`@mana/shared-feedback`-Widget. Theming über `\.manaTheme`
(ManaTokens). Siehe `mana/docs/FEEDBACK_NATIVE_PLAN.md`.
### Geändert
- `ManaWebShell`: Die Fehler-Leiste in `WebShellView` hat jetzt einen

View file

@ -12,6 +12,7 @@ let package = Package(
.library(name: "ManaAuthUI", targets: ["ManaAuthUI"]),
.library(name: "ManaWebShell", targets: ["ManaWebShell"]),
.library(name: "ManaLLMUI", targets: ["ManaLLMUI"]),
.library(name: "ManaFeedbackUI", targets: ["ManaFeedbackUI"]),
],
dependencies: [
// Lokaler Dev-Pfad. Apps konsumieren beide Pakete parallel über
@ -54,6 +55,17 @@ let package = Package(
.enableExperimentalFeature("StrictConcurrency"),
]
),
.target(
name: "ManaFeedbackUI",
dependencies: [
.product(name: "ManaCore", package: "mana-swift-core"),
.product(name: "ManaTokens", package: "mana-swift-core"),
],
path: "Sources/ManaFeedbackUI",
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency"),
]
),
.testTarget(
name: "ManaAuthUITests",
dependencies: ["ManaAuthUI"],

View file

@ -0,0 +1,92 @@
import Foundation
import ManaCore
import Observation
/// 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
private let client: FeedbackClient
public let isLoggedIn: Bool
public init(client: FeedbackClient, isLoggedIn: Bool) {
self.client = client
self.isLoggedIn = isLoggedIn
}
/// 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))
)
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"
}
}
}

View file

@ -0,0 +1,65 @@
import ManaCore
import SwiftUI
/// Knopf, der das ``ManaFeedbackSheet`` präsentiert. Für Apps, die kein
/// eigenes Trigger-Element haben sonst lieber der
/// `.manaFeedbackSheet(...)`-Modifier mit eigenem Button (HIG: kein
/// schwebendes Bubble-UI auf iOS, Platzierung in Settings/Konto/Toolbar).
///
/// ```swift
/// // eingeloggte App:
/// ManaFeedbackButton(
/// client: FeedbackClient(appId: "memoro") { try? await auth.freshAccessToken() },
/// isLoggedIn: session.isLoggedIn
/// )
/// // mit eigenem Label:
/// ManaFeedbackButton(client: client, isLoggedIn: false) {
/// Label("Feedback geben", systemImage: "bubble.left.and.bubble.right")
/// }
/// ```
public struct ManaFeedbackButton<Label: View>: View {
private let client: FeedbackClient
private let isLoggedIn: Bool
private let label: () -> Label
@State private var isPresented = false
public init(
client: FeedbackClient,
isLoggedIn: Bool,
@ViewBuilder label: @escaping () -> Label
) {
self.client = client
self.isLoggedIn = isLoggedIn
self.label = label
}
public var body: some View {
Button { isPresented = true } label: { label() }
.manaFeedbackSheet(isPresented: $isPresented, client: client, isLoggedIn: isLoggedIn)
}
}
extension ManaFeedbackButton where Label == SwiftUI.Label<Text, Image> {
/// Standard-Label Feedback" mit Sprechblasen-Icon.
public init(client: FeedbackClient, isLoggedIn: Bool, title: String = "Feedback") {
self.init(client: client, isLoggedIn: isLoggedIn) {
SwiftUI.Label(title, systemImage: "bubble.left.and.bubble.right")
}
}
}
extension View {
/// Hängt das ``ManaFeedbackSheet`` an einen Binding-gesteuerten
/// Präsentations-Zustand für Apps mit eigenem Trigger (z.B. ein
/// Nav-Pill oder Settings-Row).
public func manaFeedbackSheet(
isPresented: Binding<Bool>,
client: FeedbackClient,
isLoggedIn: Bool
) -> some View {
sheet(isPresented: isPresented) {
ManaFeedbackSheet(client: client, isLoggedIn: isLoggedIn)
}
}
}

View file

@ -0,0 +1,151 @@
import ManaCore
import ManaTokens
import SwiftUI
/// 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?")
}
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(cornerRadius: 10))
.foregroundStyle(theme.primaryForeground)
}
.buttonStyle(.plain)
.disabled(!model.canSubmit)
.opacity(model.canSubmit ? 1 : 0.6)
}
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)
Button("Fertig") { dismiss() }
.padding(.top, 8)
}
.padding(32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}