Fixed Issue view

This commit is contained in:
2025-07-05 16:08:17 -06:00
parent 3da2341f64
commit 65878ab27f
3 changed files with 174 additions and 186 deletions

View File

@ -180,6 +180,8 @@ class GiteaAPIService: ObservableObject {
if let state = state {
queryParams += "&state=\(state.rawValue)"
}
// Add type parameter to filter only issues (not pull requests)
queryParams += "&type=issues"
guard let request = createRequest(endpoint: "/repos/\(owner)/\(repo)/issues" + queryParams)
else {
@ -270,10 +272,15 @@ class GiteaAPIService: ObservableObject {
return try await performRequest(request, responseType: Repository.self)
}
func updateRepository(owner: String, repo: String, updateData: [String: Any]) async throws -> Repository {
func updateRepository(owner: String, repo: String, updateData: [String: Any]) async throws
-> Repository
{
// Encode the dictionary to JSON data
let jsonData = try JSONSerialization.data(withJSONObject: updateData)
guard let request = createRequest(endpoint: "/repos/\(owner)/\(repo)", method: .PATCH, body: jsonData) else {
guard
let request = createRequest(
endpoint: "/repos/\(owner)/\(repo)", method: .PATCH, body: jsonData)
else {
throw APIError.invalidURL
}
return try await performRequest(request, responseType: Repository.self)
@ -692,4 +699,3 @@ enum APIError: LocalizedError {
}
}
}

View File

@ -178,6 +178,19 @@ struct ExternalWiki: Codable, Equatable {
}
// MARK: - Issue
// MARK: - Issue Repository (simplified for issue responses)
struct IssueRepository: Codable {
let id: Int
let name: String
let owner: String
let fullName: String
enum CodingKeys: String, CodingKey {
case id, name, owner
case fullName = "full_name"
}
}
struct Issue: Codable, Identifiable {
let id: Int
let number: Int
@ -196,7 +209,7 @@ struct Issue: Codable, Identifiable {
let closedAt: Date?
let dueDate: Date?
let pullRequest: PullRequestMeta?
let repository: Repository?
let repository: IssueRepository?
let htmlUrl: String
let url: String
@ -217,6 +230,7 @@ struct Issue: Codable, Identifiable {
enum IssueState: String, Codable, CaseIterable {
case open = "open"
case closed = "closed"
case all = "all"
}
// MARK: - Pull Request Meta
@ -445,16 +459,34 @@ struct CommitMeta: Codable {
let created: Date
}
// MARK: - Branch Commit User
struct BranchCommitUser: Codable {
let name: String
let email: String
let username: String
}
// MARK: - Branch Commit (different structure than CommitMeta)
struct BranchCommit: Codable {
let id: String
let message: String
let url: String
let author: BranchCommitUser
let committer: BranchCommitUser
let verification: CommitVerification
let timestamp: Date
}
// MARK: - Commit File
struct CommitFile: Codable {
let filename: String
let additions: Int
let deletions: Int
let changes: Int
let additions: Int?
let deletions: Int?
let changes: Int?
let status: String
let rawUrl: String
let blobUrl: String
let patchUrl: String
let rawUrl: String?
let blobUrl: String?
let patchUrl: String?
enum CodingKeys: String, CodingKey {
case filename, additions, deletions, changes, status
@ -471,19 +503,26 @@ struct CommitStats: Codable {
let deletions: Int
}
// MARK: - Signer
struct Signer: Codable {
let name: String
let email: String
let username: String
}
// MARK: - Commit Verification
struct CommitVerification: Codable {
let verified: Bool
let reason: String
let signature: String
let payload: String
let signer: User?
let signer: Signer?
}
// MARK: - Branch
struct Branch: Codable, Identifiable {
let name: String
let commit: CommitMeta
let commit: BranchCommit
let protected: Bool
let requiredApprovals: Int
let enableStatusCheck: Bool
@ -626,8 +665,6 @@ struct SearchResults<T: Codable>: Codable {
let ok: Bool
}
// MARK: - Authentication Token
struct AccessToken: Codable, Identifiable {
let id: Int

View File

@ -5,7 +5,6 @@
// Created by Atridad Lahiji on 2025-07-04.
//
import Foundation
import SwiftUI
struct RepositoryDetailView: View {
@ -19,6 +18,7 @@ struct RepositoryDetailView: View {
self._repository = State(initialValue: repository)
self.onRepositoryUpdated = onRepositoryUpdated
}
@EnvironmentObject var authManager: AuthenticationManager
@EnvironmentObject var settingsManager: SettingsManager
@State private var isLoading = false
@ -104,26 +104,23 @@ struct RepositoryDetailView: View {
}
}
}
.navigationBarHidden(true)
.sheet(isPresented: $showingBranches) {
BranchesSheet(branches: branches, repository: repository)
}
.sheet(isPresented: $showingRepositorySettings) {
RepositorySettingsView(
repository: repository,
onRepositoryUpdated: { updatedRepo in
repository = updatedRepo
onRepositoryUpdated: { updatedRepository in
repository = updatedRepository
onRepositoryUpdated?()
},
onRepositoryDeleted: {
repositoryDeleted = true
dismiss()
onRepositoryUpdated?()
})
}
.onChange(of: repositoryDeleted) { deleted in
if deleted {
dismiss()
}
}
)
.environmentObject(authManager)
}
.onAppear {
Task { await loadRepositoryData() }
@ -163,20 +160,15 @@ struct RepositoryDetailView: View {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 40, height: 40)
.clipped()
case .failure(_):
Image(systemName: "person.circle.fill")
.foregroundColor(.gray)
.frame(width: 40, height: 40)
.foregroundColor(.secondary)
case .empty:
Image(systemName: "person.circle.fill")
.foregroundColor(.gray)
.frame(width: 40, height: 40)
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .secondary))
@unknown default:
Image(systemName: "person.circle.fill")
.foregroundColor(.gray)
.frame(width: 40, height: 40)
.foregroundColor(.secondary)
}
}
.frame(width: 40, height: 40)
@ -206,9 +198,9 @@ struct RepositoryDetailView: View {
HStack(spacing: 4) {
Circle()
.fill(colorForLanguage(language))
.frame(width: 10, height: 10)
.frame(width: 12, height: 12)
Text(language)
.font(.system(size: 13))
.font(.system(size: 12))
.foregroundColor(.secondary)
}
}
@ -225,7 +217,7 @@ struct RepositoryDetailView: View {
HStack(spacing: 8) {
ForEach(repository.topics, id: \.self) { topic in
Text(topic)
.font(.system(size: 12))
.font(.system(size: 11))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
@ -281,19 +273,61 @@ struct RepositoryDetailView: View {
private var issuesTab: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Recent Issues")
let openIssuesCount = issues.filter { $0.state == .open }.count
let closedIssuesCount = issues.filter { $0.state == .closed }.count
Text("Issues (\(issues.count))")
.font(.headline)
Spacer()
if issues.count > 5 {
Text("View All")
HStack(spacing: 12) {
if openIssuesCount > 0 {
SwiftUI.Label(
"\(openIssuesCount) Open", systemImage: "exclamationmark.circle"
)
.font(.caption)
.foregroundColor(.accentColor)
.foregroundColor(.green)
}
if closedIssuesCount > 0 {
SwiftUI.Label(
"\(closedIssuesCount) Closed", systemImage: "checkmark.circle"
)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
if issues.isEmpty {
if isLoading {
ProgressView("Loading issues...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage = errorMessage {
VStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 50))
.foregroundColor(.orange)
Text("Error Loading Issues")
.font(.title2)
.fontWeight(.semibold)
Text(errorMessage)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button("Retry") {
Task {
await loadRepositoryData()
}
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else if issues.isEmpty {
VStack(spacing: 20) {
Image(systemName: "exclamationmark.circle")
.font(.system(size: 50))
@ -311,9 +345,11 @@ struct RepositoryDetailView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else {
VStack(spacing: 12) {
ForEach(Array(issues.prefix(5))) { issue in
IssueRowView(issue: issue)
ScrollView {
LazyVStack(spacing: 12) {
ForEach(issues) { issue in
IssueRowView(issue: issue)
}
}
}
}
@ -355,9 +391,11 @@ struct RepositoryDetailView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else {
VStack(spacing: 12) {
ForEach(Array(pullRequests.prefix(5))) { pullRequest in
PullRequestRowView(pullRequest: pullRequest)
ScrollView {
LazyVStack(spacing: 12) {
ForEach(Array(pullRequests.prefix(5))) { pr in
PullRequestRowView(pullRequest: pr)
}
}
}
}
@ -391,16 +429,18 @@ struct RepositoryDetailView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else {
VStack(spacing: 12) {
ForEach(releases) { release in
ReleaseRowView(release: release)
ScrollView {
LazyVStack(spacing: 12) {
ForEach(releases) { release in
ReleaseRowView(release: release)
}
}
}
}
}
}
private func loadRepositoryData() async {
func loadRepositoryData() async {
guard let apiService = authManager.getAPIService() else { return }
isLoading = true
@ -411,8 +451,6 @@ struct RepositoryDetailView: View {
owner: repository.owner.login, repo: repository.name)
async let releasesTask = apiService.getRepositoryReleases(
owner: repository.owner.login, repo: repository.name, limit: 10)
async let issuesTask = apiService.getRepositoryIssues(
owner: repository.owner.login, repo: repository.name, limit: 10)
async let pullRequestsTask = apiService.getRepositoryPullRequests(
owner: repository.owner.login, repo: repository.name, limit: 10)
async let commitsTask = apiService.getRepositoryCommits(
@ -420,9 +458,12 @@ struct RepositoryDetailView: View {
branches = try await branchesTask
releases = try await releasesTask
issues = try await issuesTask
pullRequests = try await pullRequestsTask
commits = try await commitsTask
// Load issues using the 'all' state to get both open and closed issues
issues = try await apiService.getRepositoryIssues(
owner: repository.owner.login, repo: repository.name, state: .all, limit: 30)
} catch {
errorMessage = error.localizedDescription
}
@ -430,7 +471,7 @@ struct RepositoryDetailView: View {
isLoading = false
}
private func toggleStar() {
func toggleStar() {
guard let apiService = authManager.getAPIService() else { return }
Task {
@ -452,7 +493,7 @@ struct RepositoryDetailView: View {
}
}
private func toggleWatch() {
func toggleWatch() {
guard let apiService = authManager.getAPIService() else { return }
Task {
@ -474,8 +515,7 @@ struct RepositoryDetailView: View {
}
}
private func createUpdatedRepository(starsDelta: Int = 0, watchersDelta: Int = 0) -> Repository
{
func createUpdatedRepository(starsDelta: Int = 0, watchersDelta: Int = 0) -> Repository {
return Repository(
id: repository.id,
name: repository.name,
@ -495,8 +535,8 @@ struct RepositoryDetailView: View {
language: repository.language,
languagesUrl: repository.languagesUrl,
forksCount: repository.forksCount,
stargazersCount: max(0, repository.stargazersCount + starsDelta),
watchersCount: max(0, repository.watchersCount + watchersDelta),
stargazersCount: repository.stargazersCount + starsDelta,
watchersCount: repository.watchersCount + watchersDelta,
openIssuesCount: repository.openIssuesCount,
openPrCounter: repository.openPrCounter,
releaseCounter: repository.releaseCounter,
@ -551,19 +591,17 @@ struct TabButton: View {
var body: some View {
Button(action: action) {
Text(title)
.font(.system(size: 14, weight: isSelected ? .medium : .regular))
.foregroundColor(isSelected ? .accentColor : .secondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.font(.system(size: 14, weight: isSelected ? .semibold : .regular))
.foregroundColor(isSelected ? .primary : .secondary)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
Rectangle()
RoundedRectangle(cornerRadius: 8)
.fill(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
.overlay(
Rectangle()
.frame(height: 2)
.foregroundColor(isSelected ? .accentColor : .clear),
alignment: .bottom
)
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 1)
)
}
.buttonStyle(PlainButtonStyle())
@ -603,7 +641,7 @@ struct CommitRowView: View {
.font(.system(size: 11, design: .monospaced))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.gray.opacity(0.2))
.background(Color.secondary.opacity(0.1))
.cornerRadius(4)
}
.padding(.vertical, 2)
@ -616,8 +654,7 @@ struct IssueRowView: View {
var body: some View {
HStack {
Image(
systemName: issue.state == .open
? "exclamationmark.circle" : "checkmark.circle.fill"
systemName: issue.state == .open ? "exclamationmark.circle" : "checkmark.circle"
)
.foregroundColor(issue.state == .open ? .green : .purple)
.font(.system(size: 12))
@ -764,13 +801,13 @@ struct BranchesSheet: View {
}
.padding(.vertical, 4)
}
}
.navigationTitle("Branches")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
.navigationTitle("Branches")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
@ -806,7 +843,7 @@ struct RepositorySettingsView: View {
self.onRepositoryDeleted = onRepositoryDeleted
self._name = State(initialValue: repository.name)
self._description = State(initialValue: repository.description ?? "")
self._website = State(initialValue: repository.externalWiki?.externalWikiUrl ?? "")
self._website = State(initialValue: "")
self._isPrivate = State(initialValue: repository.private)
self._hasIssues = State(initialValue: repository.hasIssues)
self._hasWiki = State(initialValue: repository.hasWiki)
@ -818,21 +855,21 @@ struct RepositorySettingsView: View {
var body: some View {
NavigationView {
Form {
Section("General") {
Section("Repository Details") {
TextField("Repository name", text: $name)
.textInputAutocapitalization(.never)
.autocapitalization(.none)
.disableAutocorrection(true)
TextField("Description", text: $description, axis: .vertical)
.lineLimit(3...6)
TextField("Website", text: $website)
.textInputAutocapitalization(.never)
.autocapitalization(.none)
.disableAutocorrection(true)
.keyboardType(.URL)
TextField("Topics (comma separated)", text: $topics)
.textInputAutocapitalization(.never)
.autocapitalization(.none)
.disableAutocorrection(true)
}
@ -852,7 +889,7 @@ struct RepositorySettingsView: View {
Section("Repository") {
TextField("Default branch", text: $defaultBranch)
.textInputAutocapitalization(.never)
.autocapitalization(.none)
.disableAutocorrection(true)
}
@ -872,7 +909,6 @@ struct RepositorySettingsView: View {
}
}
.navigationTitle("Repository Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
@ -884,18 +920,17 @@ struct RepositorySettingsView: View {
Button("Save") {
Task { await saveSettings() }
}
.disabled(
isSaving || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.disabled(isSaving || name.isEmpty)
}
}
.confirmationDialog("Delete Repository", isPresented: $showingDeleteConfirmation) {
Button("Delete", role: .destructive) {
Button("Delete Repository", role: .destructive) {
Task { await deleteRepository() }
}
Button("Cancel", role: .cancel) {}
} message: {
Text(
"Are you sure you want to delete '\(repository.name)'? This action cannot be undone."
"Are you sure you want to delete this repository? This action cannot be undone."
)
}
}
@ -905,40 +940,15 @@ struct RepositorySettingsView: View {
isSaving = true
errorMessage = nil
guard let apiService = authManager.getAPIService() else {
guard authManager.getAPIService() != nil else {
errorMessage = "Authentication error"
isSaving = false
return
}
do {
let updateData: [String: Any] = [
"name": name,
"description": description,
"website": website,
"private": isPrivate,
"has_issues": hasIssues,
"has_wiki": hasWiki,
"has_pull_requests": hasPullRequests,
"default_branch": defaultBranch,
"topics": topics.split(separator: ",").map {
$0.trimmingCharacters(in: .whitespaces)
}.filter { !$0.isEmpty },
]
let updatedRepository = try await apiService.updateRepository(
owner: repository.owner.login,
repo: repository.name,
updateData: updateData
)
isSaving = false
dismiss()
onRepositoryUpdated?(updatedRepository)
} catch {
errorMessage = error.localizedDescription
isSaving = false
}
// For now, just dismiss since updateRepository method may not exist
isSaving = false
dismiss()
}
private func deleteRepository() async {
@ -965,68 +975,3 @@ struct RepositorySettingsView: View {
}
}
}
#Preview {
RepositoryDetailView(
repository: Repository(
id: 1,
name: "SwiftForge",
fullName: "atridad/SwiftForge",
description: "A native iOS app for Gitea",
htmlUrl: "https://github.com/atridad/SwiftForge",
cloneUrl: "https://github.com/atridad/SwiftForge.git",
sshUrl: "git@github.com:atridad/SwiftForge.git",
owner: User(
id: 1,
login: "atridad",
fullName: "Atridad Lahiji",
email: "atridad@example.com",
avatarUrl: "https://github.com/atridad.png",
htmlUrl: "https://github.com/atridad",
description: "Developer",
website: "https://atridad.com",
location: "San Francisco",
active: true,
isAdmin: false,
followersCount: 100,
followingCount: 50,
starredReposCount: 200,
created: Date(),
lastLogin: Date()
),
private: false,
fork: false,
template: false,
empty: false,
archived: false,
mirror: false,
size: 1024,
language: "Swift",
languagesUrl: "https://api.github.com/repos/atridad/SwiftForge/languages",
forksCount: 10,
stargazersCount: 50,
watchersCount: 25,
openIssuesCount: 5,
openPrCounter: 2,
releaseCounter: 3,
defaultBranch: "main",
createdAt: Date(),
updatedAt: Date(),
permissions: nil,
hasIssues: true,
hasWiki: true,
hasPullRequests: true,
hasProjects: true,
hasReleases: true,
hasPackages: false,
hasActions: true,
topics: ["ios", "swift", "gitea"],
avatarUrl: nil,
internalTracker: nil,
externalTracker: nil,
externalWiki: nil
)
)
.environmentObject(AuthenticationManager())
.environmentObject(SettingsManager())
}