diff --git a/CForge/Utilities/AvatarURLNormalizer.swift b/CForge/Utilities/AvatarURLNormalizer.swift new file mode 100644 index 0000000..bca1830 --- /dev/null +++ b/CForge/Utilities/AvatarURLNormalizer.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Normalizes a raw avatar URL string from the Codeforces API into a loadable URL. +/// +/// Codeforces returns protocol-relative URLs (e.g. "//userpic.codeforces.org/..."), +/// which `URL(string:)` accepts but image loaders cannot fetch without a scheme. +/// Empty and nil inputs return nil rather than producing a meaningless URL. +func normalizeAvatarURL(_ raw: String?) -> URL? { + guard let raw = raw, !raw.isEmpty else { return nil } + let normalized = raw.hasPrefix("//") ? "https:" + raw : raw + return URL(string: normalized) +} diff --git a/CForge/Views/Common/EmptyStateView.swift b/CForge/Views/Common/EmptyStateView.swift new file mode 100644 index 0000000..f483a0e --- /dev/null +++ b/CForge/Views/Common/EmptyStateView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct EmptyStateView: View { + /// A label/handler pair for the optional call-to-action button. + /// Grouping them in one struct makes it impossible to pass a label + /// without a handler (or vice versa) and silently lose the button. + struct Action { + let label: String + let handler: () -> Void + + init(label: String, handler: @escaping () -> Void) { + self.label = label + self.handler = handler + } + } + + let icon: String + let title: String + let subtitle: String? + let action: Action? + + init( + icon: String, + title: String, + subtitle: String? = nil, + action: Action? = nil + ) { + self.icon = icon + self.title = title + self.subtitle = subtitle + self.action = action + } + + var body: some View { + VStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 50)) + .foregroundStyle( + LinearGradient( + colors: [.neonBlue, .neonPurple], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + Text(title) + .font(.headline) + .foregroundColor(.textPrimary) + .multilineTextAlignment(.center) + if let subtitle = subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundColor(.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + if let action = action { + Button(action: action.handler) { + Text(action.label) + .font(.headline) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background( + LinearGradient( + colors: [.neonBlue, .neonPurple], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .cornerRadius(12) + } + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} + +#Preview { + EmptyStateView( + icon: "tray", + title: "Nothing here", + subtitle: "Pull to refresh or check back later.", + action: .init(label: "Retry") {} + ) +} diff --git a/CForge/Views/Contest/ContestListView.swift b/CForge/Views/Contest/ContestListView.swift index aedd426..08aa232 100644 --- a/CForge/Views/Contest/ContestListView.swift +++ b/CForge/Views/Contest/ContestListView.swift @@ -14,7 +14,7 @@ struct ContestListView: View { NavigationStack { Group { if contests.isEmpty && !isRefreshing { - ProgressView() + ProgressView("Loading Contests...") .onAppear { Task { await loadContests() } } } else { contentView diff --git a/CForge/Views/Problem/ProblemListView.swift b/CForge/Views/Problem/ProblemListView.swift index 819c9f8..2408172 100644 --- a/CForge/Views/Problem/ProblemListView.swift +++ b/CForge/Views/Problem/ProblemListView.swift @@ -26,20 +26,14 @@ struct ProblemListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) case .error(let message): - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 50)) - .foregroundColor(.red) - Text(message) - .multilineTextAlignment(.center) - .foregroundColor(.textPrimary) - .padding() - Button("Retry") { + EmptyStateView( + icon: "exclamationmark.triangle", + title: "Something Went Wrong", + subtitle: message, + action: .init(label: "Retry") { Task { await viewModel.loadProblems(forceRefresh: true) } } - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + ) case .idle, .loaded: ScrollView { diff --git a/CForge/Views/Problem/ProblemSubmissionsView.swift b/CForge/Views/Problem/ProblemSubmissionsView.swift index bc18411..8ed8707 100644 --- a/CForge/Views/Problem/ProblemSubmissionsView.swift +++ b/CForge/Views/Problem/ProblemSubmissionsView.swift @@ -9,10 +9,10 @@ extension ProblemListView { var body: some View { VStack { if userManager.userHandle.isEmpty { - ContentUnavailableView( - "Not Signed In", - systemImage: "person.crop.circle.badge.exclamationmark", - description: Text("Sign in to track your progress and see your past attempts.") + EmptyStateView( + icon: "person.crop.circle.badge.exclamationmark", + title: "Not Signed In", + subtitle: "Sign in to track your progress and see your past attempts." ) } else { switch viewModel.state { @@ -22,17 +22,14 @@ extension ProblemListView { .frame(maxWidth: .infinity, maxHeight: .infinity) case .error(let message): - VStack(spacing: 16) { - ContentUnavailableView( - "Error Fetching Data", - systemImage: "exclamationmark.triangle", - description: Text(message) - ) - Button("Retry") { + EmptyStateView( + icon: "exclamationmark.triangle", + title: "Error Fetching Data", + subtitle: message, + action: .init(label: "Retry") { Task { await viewModel.loadSubmissions(contestId: problem.contestId, handle: userManager.userHandle) } } - .buttonStyle(.bordered) - } + ) case .idle: Color.clear.onAppear { @@ -43,10 +40,10 @@ extension ProblemListView { let problemSubmissions = allSubmissions.filter { $0.problem.index == problem.index } if problemSubmissions.isEmpty { - ContentUnavailableView( - "No Attempts Yet", - systemImage: "folder.badge.questionmark", - description: Text("You haven't submitted any solutions for this problem yet. Give it a shot!") + EmptyStateView( + icon: "folder.badge.questionmark", + title: "No Attempts Yet", + subtitle: "You haven't submitted any solutions for this problem yet. Give it a shot!" ) } else { ScrollView { diff --git a/CForge/Views/Profile/ProfileModels.swift b/CForge/Views/Profile/ProfileModels.swift index 7f08f85..290388a 100644 --- a/CForge/Views/Profile/ProfileModels.swift +++ b/CForge/Views/Profile/ProfileModels.swift @@ -29,6 +29,8 @@ struct CodeforcesUser: Codable { let contribution: Int? let solvedProblems: Int? let attemptedProblems: Int? + let avatar: String? + let titlePhoto: String? } struct CodeforcesProfileResponse: Codable { let status: String diff --git a/CForge/Views/Profile/ProfileView.swift b/CForge/Views/Profile/ProfileView.swift index f0dcaa7..2365580 100644 --- a/CForge/Views/Profile/ProfileView.swift +++ b/CForge/Views/Profile/ProfileView.swift @@ -28,9 +28,11 @@ struct ProfileView: View { } .padding() } else if let errorMessage = errorMessage { - Text(errorMessage) - .foregroundColor(.red) - .padding() + EmptyStateView( + icon: "exclamationmark.triangle", + title: "Couldn't Load Profile", + subtitle: errorMessage + ) } else { ProgressView("Fetching Profile...") } @@ -65,17 +67,26 @@ struct ProfileView: View { )) .frame(width: 80, height: 80) - Image(systemName: "person.fill") - .resizable() - .scaledToFit() - .frame(width: 40, height: 40) - .foregroundStyle( - LinearGradient( - colors: [.neonBlue, .neonPurple], - startPoint: .top, - endPoint: .bottom + WebImage(url: normalizeAvatarURL(user.titlePhoto ?? user.avatar)) { image in + image + .resizable() + .scaledToFill() + } placeholder: { + Image(systemName: "person.fill") + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + .foregroundStyle( + LinearGradient( + colors: [.neonBlue, .neonPurple], + startPoint: .top, + endPoint: .bottom + ) ) - ) + } + .indicator(.activity) + .frame(width: 80, height: 80) + .clipShape(Circle()) } .overlay( Circle() @@ -124,7 +135,7 @@ struct ProfileView: View { } .padding(.vertical) } - + // MARK: - Rating Section private func ratingSection(user: CodeforcesUser) -> some View { diff --git a/CForgeTests/AvatarURLNormalizerTests.swift b/CForgeTests/AvatarURLNormalizerTests.swift new file mode 100644 index 0000000..a0a9dae --- /dev/null +++ b/CForgeTests/AvatarURLNormalizerTests.swift @@ -0,0 +1,38 @@ +// +// AvatarURLNormalizerTests.swift +// CForgeTests +// + +import Foundation +import Testing +@testable import CForge + +struct AvatarURLNormalizerTests { + + @Test func nilInputReturnsNil() { + #expect(normalizeAvatarURL(nil) == nil) + } + + @Test func emptyStringReturnsNil() { + #expect(normalizeAvatarURL("") == nil) + } + + @Test func protocolRelativeURLGetsHTTPSScheme() { + let url = normalizeAvatarURL("//userpic.codeforces.org/no-title.jpg") + #expect(url?.absoluteString == "https://userpic.codeforces.org/no-title.jpg") + #expect(url?.scheme == "https") + } + + @Test func absoluteHTTPSURLPassesThroughUnchanged() { + let url = normalizeAvatarURL("https://userpic.codeforces.org/12345/avatar.jpg") + #expect(url?.absoluteString == "https://userpic.codeforces.org/12345/avatar.jpg") + } + + @Test func plainHTTPURLIsPreservedNotUpgraded() { + // Documents current behavior: http:// is passed through as-is. + // App Transport Security will block the load at fetch time unless + // the host is exempted; normalization does not silently rewrite it. + let url = normalizeAvatarURL("http://userpic.codeforces.org/avatar.jpg") + #expect(url?.scheme == "http") + } +}