Files
GiteaIssue/GiteaIssueShare/ShareSheetView.swift
T
Claude 74e03c4e10 Initial commit — iOS share extension for filing Gitea issues
Two-target Xcode project (xcodegen spec). The GiteaIssue container app
holds the base URL + personal access token in a shared keychain group;
the GiteaIssueShare extension reads them, surfaces a repo picker (with
recents) and a title/notes form, then creates the issue, uploads the
screenshot as an asset, and patches the body to embed it inline.

Min iOS 26.0, signing team V3PF3M6B6U.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:15:43 -05:00

228 lines
7.9 KiB
Swift

import SwiftUI
import UIKit
struct ShareSheetView: View {
let image: UIImage?
var imageData: Data? = nil
var errorMessage: String? = nil
let onClose: () -> Void
@State private var repos: [GiteaRepo] = RepoCache.repos
@State private var loadingRepos = false
@State private var loadError: String?
@State private var search = ""
@State private var selectedRepo: GiteaRepo?
@State private var title = ""
@State private var notes = ""
@State private var submitting = false
@State private var submitError: String?
@State private var submitted: GiteaIssue?
var body: some View {
NavigationStack {
Form {
if let error = errorMessage {
Section {
Label(error, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
}
if let image {
Section {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 160)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
Section("Repository") {
if loadingRepos && repos.isEmpty {
HStack { ProgressView(); Text("Loading repos…") }
} else if let loadError {
Label(loadError, systemImage: "xmark.octagon")
.foregroundStyle(.red)
} else {
repoPicker
}
}
Section("Issue") {
TextField("Title", text: $title)
.textInputAutocapitalization(.sentences)
TextField("Notes (optional)", text: $notes, axis: .vertical)
.lineLimit(3...8)
}
if let submitError {
Section {
Label(submitError, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
}
}
if let issue = submitted {
Section {
Label("Created issue #\(issue.number)", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
Link("Open in Gitea", destination: URL(string: issue.htmlUrl)!)
}
}
}
.navigationTitle("New issue")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { onClose() }
}
ToolbarItem(placement: .confirmationAction) {
Button(submitting ? "Sending…" : "Submit") { submit() }
.disabled(!canSubmit)
}
}
.task { await loadReposIfNeeded() }
}
}
private var canSubmit: Bool {
selectedRepo != nil && !title.trimmingCharacters(in: .whitespaces).isEmpty && !submitting && submitted == nil
}
@ViewBuilder
private var repoPicker: some View {
TextField("Search repos", text: $search)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
let filtered = filteredRepos
let recents = recentRepos.filter { search.isEmpty || $0.fullName.localizedCaseInsensitiveContains(search) }
if !recents.isEmpty {
ForEach(recents) { repo in
row(repo, badge: "Recent")
}
}
ForEach(filtered.filter { r in !recents.contains(where: { $0.id == r.id }) }) { repo in
row(repo, badge: nil)
}
Button {
Task { await refreshRepos(force: true) }
} label: {
Label("Refresh repo list", systemImage: "arrow.clockwise")
}
}
private func row(_ repo: GiteaRepo, badge: String?) -> some View {
Button {
selectedRepo = repo
} label: {
HStack {
VStack(alignment: .leading) {
Text(repo.fullName)
.foregroundStyle(.primary)
if let badge {
Text(badge).font(.caption2).foregroundStyle(.secondary)
}
}
Spacer()
if selectedRepo?.id == repo.id {
Image(systemName: "checkmark").foregroundStyle(.tint)
}
}
}
}
private var filteredRepos: [GiteaRepo] {
let sorted = repos.sorted { $0.fullName.localizedCaseInsensitiveCompare($1.fullName) == .orderedAscending }
guard !search.isEmpty else { return sorted }
return sorted.filter { $0.fullName.localizedCaseInsensitiveContains(search) }
}
private var recentRepos: [GiteaRepo] {
let names = RepoCache.recentFullNames
return names.compactMap { name in repos.first(where: { $0.fullName == name }) }
}
private func loadReposIfNeeded() async {
if repos.isEmpty || RepoCache.isStale {
await refreshRepos(force: false)
}
}
private func refreshRepos(force: Bool) async {
loadingRepos = true
loadError = nil
do {
let client = try GiteaClient()
let fresh = try await client.searchRepos()
await MainActor.run {
self.repos = fresh
RepoCache.repos = fresh
self.loadingRepos = false
}
} catch {
await MainActor.run {
self.loadError = error.localizedDescription
self.loadingRepos = false
}
}
}
private func submit() {
guard let repo = selectedRepo, let imageData else {
submitError = "Pick a repo and make sure the screenshot loaded."
return
}
submitting = true
submitError = nil
Task {
do {
let client = try GiteaClient()
let bodyText = notes.trimmingCharacters(in: .whitespacesAndNewlines)
let issue = try await client.createIssue(
owner: repo.owner.login,
repo: repo.name,
title: title.trimmingCharacters(in: .whitespacesAndNewlines),
body: bodyText
)
let asset = try await client.uploadAsset(
owner: repo.owner.login,
repo: repo.name,
issueNumber: issue.number,
image: imageData,
filename: "screenshot-\(Int(Date().timeIntervalSince1970)).png"
)
let updatedBody = (bodyText.isEmpty ? "" : bodyText + "\n\n") +
"![screenshot](\(asset.browserDownloadUrl))"
try await client.updateIssueBody(
owner: repo.owner.login,
repo: repo.name,
issueNumber: issue.number,
body: updatedBody
)
RepoCache.touch(repo.fullName)
await MainActor.run {
submitting = false
submitted = issue
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
try? await Task.sleep(nanoseconds: 800_000_000)
await MainActor.run { onClose() }
} catch {
await MainActor.run {
submitting = false
submitError = error.localizedDescription
UINotificationFeedbackGenerator().notificationOccurred(.error)
}
}
}
}
}