feat: multi-photo support in feedback (up to 5 screenshots)
iOS: - PhotosPicker with maxSelectionCount: 5 - Horizontal scroll preview strip with individual remove buttons - Sends "images" array instead of single "image" Server: - Gateway accepts both "image" (single, backwards compat) and "images" (array) fields - Uploads each as separate Gitea issue attachment Also closed Gitea issues #11, #12, #13, #14. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,9 +27,8 @@ struct FeedbackSheet: View {
|
||||
@State private var isSending = false
|
||||
@State private var sent = false
|
||||
@State private var error: String?
|
||||
@State private var selectedPhoto: PhotosPickerItem?
|
||||
@State private var photoData: Data?
|
||||
@State private var photoPreview: UIImage?
|
||||
@State private var selectedPhotos: [PhotosPickerItem] = []
|
||||
@State private var photoPreviews: [(Data, UIImage)] = []
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
@@ -68,13 +67,17 @@ struct FeedbackSheet: View {
|
||||
.stroke(Color.textTertiary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
|
||||
// Photo attachment
|
||||
HStack {
|
||||
PhotosPicker(selection: $selectedPhoto, matching: .screenshots) {
|
||||
// Photo attachments
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
PhotosPicker(
|
||||
selection: $selectedPhotos,
|
||||
maxSelectionCount: 5,
|
||||
matching: .images
|
||||
) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.caption)
|
||||
Text(photoPreview != nil ? "Change photo" : "Add screenshot")
|
||||
Text(photoPreviews.isEmpty ? "Add screenshots" : "Add more")
|
||||
.font(.caption.weight(.medium))
|
||||
}
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
@@ -84,25 +87,34 @@ struct FeedbackSheet: View {
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
if let preview = photoPreview {
|
||||
Image(uiImage: preview)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
if !photoPreviews.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(photoPreviews.enumerated()), id: \.offset) { index, item in
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: item.1)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 60, height: 60)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
Button {
|
||||
photoData = nil
|
||||
photoPreview = nil
|
||||
selectedPhoto = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
Button {
|
||||
photoPreviews.remove(at: index)
|
||||
if index < selectedPhotos.count {
|
||||
selectedPhotos.remove(at: index)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.white)
|
||||
.background(Circle().fill(.black.opacity(0.5)))
|
||||
}
|
||||
.offset(x: 4, y: -4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if let error {
|
||||
@@ -131,13 +143,16 @@ struct FeedbackSheet: View {
|
||||
}
|
||||
.padding()
|
||||
.background(Color.surfaceCard)
|
||||
.onChange(of: selectedPhoto) {
|
||||
.onChange(of: selectedPhotos) {
|
||||
Task {
|
||||
if let item = selectedPhoto,
|
||||
let data = try? await item.loadTransferable(type: Data.self) {
|
||||
photoData = data
|
||||
photoPreview = UIImage(data: data)
|
||||
var newPreviews: [(Data, UIImage)] = []
|
||||
for item in selectedPhotos {
|
||||
if let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) {
|
||||
newPreviews.append((data, image))
|
||||
}
|
||||
}
|
||||
photoPreviews = newPreviews
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,9 +164,13 @@ struct FeedbackSheet: View {
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
var body: [String: Any] = ["text": text.trimmingCharacters(in: .whitespaces), "source": "ios"]
|
||||
if let imgData = photoData {
|
||||
body["image"] = imgData.base64EncodedString()
|
||||
var body: [String: Any] = [
|
||||
"text": text.trimmingCharacters(in: .whitespaces),
|
||||
"source": "ios"
|
||||
]
|
||||
if !photoPreviews.isEmpty {
|
||||
let images = photoPreviews.map { $0.0.base64EncodedString() }
|
||||
body["images"] = images
|
||||
}
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: body)
|
||||
let data = try await APIClient.shared.rawPost(path: "/api/feedback", data: jsonData)
|
||||
|
||||
Reference in New Issue
Block a user