diff --git a/SwiftForge/Managers/GiteaAPIService.swift b/SwiftForge/Managers/GiteaAPIService.swift index ca4dbf3..227f3fd 100644 --- a/SwiftForge/Managers/GiteaAPIService.swift +++ b/SwiftForge/Managers/GiteaAPIService.swift @@ -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 { } } } - diff --git a/SwiftForge/Models/GiteaModels.swift b/SwiftForge/Models/GiteaModels.swift index 458f70e..66cac4d 100644 --- a/SwiftForge/Models/GiteaModels.swift +++ b/SwiftForge/Models/GiteaModels.swift @@ -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: Codable { let ok: Bool } - - // MARK: - Authentication Token struct AccessToken: Codable, Identifiable { let id: Int diff --git a/SwiftForge/Views/RepositoryDetailView.swift b/SwiftForge/Views/RepositoryDetailView.swift index 66eda14..85dfd1b 100644 --- a/SwiftForge/Views/RepositoryDetailView.swift +++ b/SwiftForge/Views/RepositoryDetailView.swift @@ -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()) -}