feat: instant feedback button — creates Gitea issues with auto-labels
iOS: - Subtle floating feedback button (bottom-left, speech bubble icon) - Quick sheet: type → send → auto-creates Gitea issue - Shows checkmark on success, auto-dismisses - No friction — tap, type, done Gateway: - POST /api/feedback endpoint - Auto-labels: bug/feature/enhancement + ios/web + fitness/brain/reader/podcasts - Keyword detection for label assignment - Creates issue via Gitea API with user name and source Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@
|
||||
A10030 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10030; };
|
||||
A10031 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10031; };
|
||||
A10032 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C10001; };
|
||||
A10033 /* FeedbackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10034; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -74,6 +75,7 @@
|
||||
B10030 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
|
||||
B10031 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
|
||||
B10033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
B10034 /* FeedbackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackView.swift; sourceTree = "<group>"; };
|
||||
C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
@@ -128,6 +130,7 @@
|
||||
F10006 /* Home */,
|
||||
F10007 /* Fitness */,
|
||||
F10014 /* Assistant */,
|
||||
F10020 /* Feedback */,
|
||||
);
|
||||
path = Features;
|
||||
sourceTree = "<group>";
|
||||
@@ -221,6 +224,14 @@
|
||||
path = Assistant;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F10020 /* Feedback */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B10034 /* FeedbackView.swift */,
|
||||
);
|
||||
path = Feedback;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F10015 /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -357,6 +368,7 @@
|
||||
A10029 /* LoadingView.swift in Sources */,
|
||||
A10030 /* Color+Extensions.swift in Sources */,
|
||||
A10031 /* Date+Extensions.swift in Sources */,
|
||||
A10033 /* FeedbackView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -40,11 +40,17 @@ struct MainTabView: View {
|
||||
}
|
||||
.tint(Color.accentWarm)
|
||||
|
||||
// Floating + button
|
||||
// Floating buttons
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
HStack(alignment: .bottom) {
|
||||
// Feedback button (subtle, bottom-left)
|
||||
FeedbackButton()
|
||||
.padding(.leading, 20)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Add food button (prominent, bottom-right)
|
||||
Button { showAssistant = true } label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
120
ios/Platform/Platform/Features/Feedback/FeedbackView.swift
Normal file
120
ios/Platform/Platform/Features/Feedback/FeedbackView.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FeedbackButton: View {
|
||||
@State private var showSheet = false
|
||||
|
||||
var body: some View {
|
||||
Button { showSheet = true } label: {
|
||||
Image(systemName: "exclamationmark.bubble.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Color.surfaceCard.opacity(0.8))
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.08), radius: 4, y: 2)
|
||||
}
|
||||
.sheet(isPresented: $showSheet) {
|
||||
FeedbackSheet()
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedbackSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var text = ""
|
||||
@State private var isSending = false
|
||||
@State private var sent = false
|
||||
@State private var error: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Capsule()
|
||||
.fill(Color.textTertiary.opacity(0.3))
|
||||
.frame(width: 36, height: 5)
|
||||
.padding(.top, 8)
|
||||
|
||||
Text("Feedback")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
|
||||
Text("Bug report, feature request, or anything else")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
|
||||
if sent {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(Color.emerald)
|
||||
Text("Sent! We'll look into it.")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(Color.textPrimary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
TextEditor(text: $text)
|
||||
.font(.body)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(12)
|
||||
.background(Color.canvas)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.textTertiary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
|
||||
if let error {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
Button {
|
||||
sendFeedback()
|
||||
} label: {
|
||||
if isSending {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Send")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(text.trimmingCharacters(in: .whitespaces).isEmpty ? Color.textTertiary : Color.accentWarm)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.disabled(text.trimmingCharacters(in: .whitespaces).isEmpty || isSending)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.surfaceCard)
|
||||
}
|
||||
|
||||
private func sendFeedback() {
|
||||
isSending = true
|
||||
error = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let body: [String: String] = ["text": text.trimmingCharacters(in: .whitespaces), "source": "ios"]
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: body)
|
||||
let (data, response) = try await APIClient.shared.rawPost("/api/feedback", body: jsonData)
|
||||
|
||||
if let httpResp = response as? HTTPURLResponse, httpResp.statusCode < 300 {
|
||||
withAnimation { sent = true }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
dismiss()
|
||||
}
|
||||
} else {
|
||||
let respBody = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
error = (respBody?["error"] as? String) ?? "Failed to send"
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isSending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user