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:
parent
4b7c605a9c
commit
9844759e86
5 changed files with 332 additions and 0 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
92
Sources/ManaFeedbackUI/FeedbackFormModel.swift
Normal file
92
Sources/ManaFeedbackUI/FeedbackFormModel.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
65
Sources/ManaFeedbackUI/ManaFeedbackButton.swift
Normal file
65
Sources/ManaFeedbackUI/ManaFeedbackButton.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
151
Sources/ManaFeedbackUI/ManaFeedbackSheet.swift
Normal file
151
Sources/ManaFeedbackUI/ManaFeedbackSheet.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue