Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CForge/Utilities/AvatarURLNormalizer.swift
Original file line number Diff line number Diff line change
@@ -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)
}
87 changes: 87 additions & 0 deletions CForge/Views/Common/EmptyStateView.swift
Original file line number Diff line number Diff line change
@@ -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") {}
)
}
2 changes: 1 addition & 1 deletion CForge/Views/Contest/ContestListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ struct ContestListView: View {
NavigationStack {
Group {
if contests.isEmpty && !isRefreshing {
ProgressView()
ProgressView("Loading Contests...")
.onAppear { Task { await loadContests() } }
} else {
contentView
Expand Down
18 changes: 6 additions & 12 deletions CForge/Views/Problem/ProblemListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 14 additions & 17 deletions CForge/Views/Problem/ProblemSubmissionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions CForge/Views/Profile/ProfileModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 25 additions & 14 deletions CForge/Views/Profile/ProfileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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...")
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -124,7 +135,7 @@ struct ProfileView: View {
}
.padding(.vertical)
}

// MARK: - Rating Section
private func ratingSection(user: CodeforcesUser) -> some View {

Expand Down
38 changes: 38 additions & 0 deletions CForgeTests/AvatarURLNormalizerTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}