From 6a9faafcf99842496366383583cf326c86b356a6 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 3 May 2026 07:34:50 -0700 Subject: [PATCH 1/3] feat(profile): load Codeforces avatar and introduce reusable EmptyStateView Closes #9 --- CForge/Views/Common/EmptyStateView.swift | 78 +++++++++++++++++++ CForge/Views/Contest/ContestListView.swift | 2 +- CForge/Views/Problem/ProblemListView.swift | 19 ++--- .../Problem/ProblemSubmissionsView.swift | 32 ++++---- CForge/Views/Profile/ProfileModels.swift | 2 + CForge/Views/Profile/ProfileView.swift | 42 ++++++---- 6 files changed, 132 insertions(+), 43 deletions(-) create mode 100644 CForge/Views/Common/EmptyStateView.swift diff --git a/CForge/Views/Common/EmptyStateView.swift b/CForge/Views/Common/EmptyStateView.swift new file mode 100644 index 0000000..57d0b29 --- /dev/null +++ b/CForge/Views/Common/EmptyStateView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +struct EmptyStateView: View { + let icon: String + let title: String + let subtitle: String? + let actionLabel: String? + let action: (() -> Void)? + + init( + icon: String, + title: String, + subtitle: String? = nil, + actionLabel: String? = nil, + action: (() -> Void)? = nil + ) { + self.icon = icon + self.title = title + self.subtitle = subtitle + self.actionLabel = actionLabel + 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 actionLabel = actionLabel, let action = action { + Button(action: action) { + Text(actionLabel) + .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.", + actionLabel: "Retry", + action: {} + ) +} diff --git a/CForge/Views/Contest/ContestListView.swift b/CForge/Views/Contest/ContestListView.swift index aedd426..04afe0e 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() + EmptyStateView(icon: "calendar", title: "Loading Contests...") .onAppear { Task { await loadContests() } } } else { contentView diff --git a/CForge/Views/Problem/ProblemListView.swift b/CForge/Views/Problem/ProblemListView.swift index 819c9f8..71dbb89 100644 --- a/CForge/Views/Problem/ProblemListView.swift +++ b/CForge/Views/Problem/ProblemListView.swift @@ -26,20 +26,15 @@ 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, + actionLabel: "Retry", + action: { 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..8a5a66f 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,15 @@ 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, + actionLabel: "Retry", + action: { Task { await viewModel.loadSubmissions(contestId: problem.contestId, handle: userManager.userHandle) } } - .buttonStyle(.bordered) - } + ) case .idle: Color.clear.onAppear { @@ -43,10 +41,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..444d3c1 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,25 @@ struct ProfileView: View { )) .frame(width: 80, height: 80) - Image(systemName: "person.fill") + WebImage(url: normalizeAvatarURL(user.titlePhoto ?? user.avatar)) .resizable() - .scaledToFit() - .frame(width: 40, height: 40) - .foregroundStyle( - LinearGradient( - colors: [.neonBlue, .neonPurple], - startPoint: .top, - endPoint: .bottom - ) - ) + .indicator(.activity) + .scaledToFill() + .frame(width: 80, height: 80) + .clipShape(Circle()) + .placeholder { + Image(systemName: "person.fill") + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + .foregroundStyle( + LinearGradient( + colors: [.neonBlue, .neonPurple], + startPoint: .top, + endPoint: .bottom + ) + ) + } } .overlay( Circle() @@ -124,6 +134,12 @@ struct ProfileView: View { } .padding(.vertical) } + + private 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) + } // MARK: - Rating Section private func ratingSection(user: CodeforcesUser) -> some View { From 132a0fa15b4d196b96daa942602aded58d7b55fb Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:01:32 -0700 Subject: [PATCH 2/3] refactor: address review feedback on EmptyStateView and avatar URL handling - Group EmptyStateView's actionLabel/action into a single Action struct so a label can never be passed without a handler (or vice versa) - Stop using EmptyStateView as a loading state in ContestListView; loading now uses ProgressView like the rest of the app - Move normalizeAvatarURL out of ProfileView into Utilities/ with internal access, and add unit tests covering nil, empty string, protocol-relative, https passthrough, and http preservation --- CForge/Utilities/AvatarURLNormalizer.swift | 12 ++++++ CForge/Views/Common/EmptyStateView.swift | 29 +++++++++----- CForge/Views/Contest/ContestListView.swift | 2 +- CForge/Views/Problem/ProblemListView.swift | 3 +- .../Problem/ProblemSubmissionsView.swift | 3 +- CForge/Views/Profile/ProfileView.swift | 6 --- CForgeTests/AvatarURLNormalizerTests.swift | 38 +++++++++++++++++++ 7 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 CForge/Utilities/AvatarURLNormalizer.swift create mode 100644 CForgeTests/AvatarURLNormalizerTests.swift 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 index 57d0b29..f483a0e 100644 --- a/CForge/Views/Common/EmptyStateView.swift +++ b/CForge/Views/Common/EmptyStateView.swift @@ -1,23 +1,33 @@ 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 actionLabel: String? - let action: (() -> Void)? + let action: Action? init( icon: String, title: String, subtitle: String? = nil, - actionLabel: String? = nil, - action: (() -> Void)? = nil + action: Action? = nil ) { self.icon = icon self.title = title self.subtitle = subtitle - self.actionLabel = actionLabel self.action = action } @@ -43,9 +53,9 @@ struct EmptyStateView: View { .multilineTextAlignment(.center) .padding(.horizontal) } - if let actionLabel = actionLabel, let action = action { - Button(action: action) { - Text(actionLabel) + if let action = action { + Button(action: action.handler) { + Text(action.label) .font(.headline) .foregroundColor(.white) .padding(.horizontal, 20) @@ -72,7 +82,6 @@ struct EmptyStateView: View { icon: "tray", title: "Nothing here", subtitle: "Pull to refresh or check back later.", - actionLabel: "Retry", - action: {} + action: .init(label: "Retry") {} ) } diff --git a/CForge/Views/Contest/ContestListView.swift b/CForge/Views/Contest/ContestListView.swift index 04afe0e..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 { - EmptyStateView(icon: "calendar", title: "Loading Contests...") + ProgressView("Loading Contests...") .onAppear { Task { await loadContests() } } } else { contentView diff --git a/CForge/Views/Problem/ProblemListView.swift b/CForge/Views/Problem/ProblemListView.swift index 71dbb89..2408172 100644 --- a/CForge/Views/Problem/ProblemListView.swift +++ b/CForge/Views/Problem/ProblemListView.swift @@ -30,8 +30,7 @@ struct ProblemListView: View { icon: "exclamationmark.triangle", title: "Something Went Wrong", subtitle: message, - actionLabel: "Retry", - action: { + action: .init(label: "Retry") { Task { await viewModel.loadProblems(forceRefresh: true) } } ) diff --git a/CForge/Views/Problem/ProblemSubmissionsView.swift b/CForge/Views/Problem/ProblemSubmissionsView.swift index 8a5a66f..8ed8707 100644 --- a/CForge/Views/Problem/ProblemSubmissionsView.swift +++ b/CForge/Views/Problem/ProblemSubmissionsView.swift @@ -26,8 +26,7 @@ extension ProblemListView { icon: "exclamationmark.triangle", title: "Error Fetching Data", subtitle: message, - actionLabel: "Retry", - action: { + action: .init(label: "Retry") { Task { await viewModel.loadSubmissions(contestId: problem.contestId, handle: userManager.userHandle) } } ) diff --git a/CForge/Views/Profile/ProfileView.swift b/CForge/Views/Profile/ProfileView.swift index 444d3c1..fac85b1 100644 --- a/CForge/Views/Profile/ProfileView.swift +++ b/CForge/Views/Profile/ProfileView.swift @@ -135,12 +135,6 @@ struct ProfileView: View { .padding(.vertical) } - private 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) - } - // 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") + } +} From 7b7a0aafac5bd393f274f5ba9eace17ef6786e4f Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:27:05 -0700 Subject: [PATCH 3/3] fix: avatar WebImage did not compile against SDWebImageSwiftUI 3.x The .placeholder modifier was applied after .clipShape, where the type is already erased to some View; SDWebImageSwiftUI 3.x also moved placeholder to an initializer closure. Rewritten with the content/ placeholder initializer so the branch builds and the placeholder actually renders. --- CForge/Views/Profile/ProfileView.swift | 37 +++++++++++++------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/CForge/Views/Profile/ProfileView.swift b/CForge/Views/Profile/ProfileView.swift index fac85b1..2365580 100644 --- a/CForge/Views/Profile/ProfileView.swift +++ b/CForge/Views/Profile/ProfileView.swift @@ -67,25 +67,26 @@ struct ProfileView: View { )) .frame(width: 80, height: 80) - WebImage(url: normalizeAvatarURL(user.titlePhoto ?? user.avatar)) - .resizable() - .indicator(.activity) - .scaledToFill() - .frame(width: 80, height: 80) - .clipShape(Circle()) - .placeholder { - 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()