diff --git a/CHANGELOG.md b/CHANGELOG.md index 87bfc2e..1651323 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/Package.swift b/Package.swift index 9602174..45260c7 100644 --- a/Package.swift +++ b/Package.swift @@ -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"], diff --git a/Sources/ManaFeedbackUI/FeedbackFormModel.swift b/Sources/ManaFeedbackUI/FeedbackFormModel.swift new file mode 100644 index 0000000..5f3093d --- /dev/null +++ b/Sources/ManaFeedbackUI/FeedbackFormModel.swift @@ -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" + } + } +} diff --git a/Sources/ManaFeedbackUI/ManaFeedbackButton.swift b/Sources/ManaFeedbackUI/ManaFeedbackButton.swift new file mode 100644 index 0000000..aed595a --- /dev/null +++ b/Sources/ManaFeedbackUI/ManaFeedbackButton.swift @@ -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: 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 { + /// 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, + client: FeedbackClient, + isLoggedIn: Bool + ) -> some View { + sheet(isPresented: isPresented) { + ManaFeedbackSheet(client: client, isLoggedIn: isLoggedIn) + } + } +} diff --git a/Sources/ManaFeedbackUI/ManaFeedbackSheet.swift b/Sources/ManaFeedbackUI/ManaFeedbackSheet.swift new file mode 100644 index 0000000..9e3f07f --- /dev/null +++ b/Sources/ManaFeedbackUI/ManaFeedbackSheet.swift @@ -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) + } +}