Skip to content
Draft
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
98 changes: 98 additions & 0 deletions SwiftLeeds.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

113 changes: 113 additions & 0 deletions SwiftLeeds/Data/Model/Review.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//
// Review.swift
// SwiftLeeds
//
// Created by Muralidharan Kathiresan on 09/09/25.
//

import Foundation

struct Review: Codable, Identifiable {
let id: UUID
let userName: String
let userInitials: String
let rating: Int
let comment: String
let date: Date
let isCurrentUser: Bool

init(id: UUID = UUID(),
userName: String?,
rating: Int,
comment: String,
date: Date = Date(),
isCurrentUser: Bool = false) {
self.id = id

if let userName = userName,
!userName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.userName = userName.trimmingCharacters(in: .whitespacesAndNewlines)
self.userInitials = Review.generateInitials(from: userName)
} else {
self.userName = "Anonymous"
self.userInitials = "AA"
}

self.rating = rating
self.comment = comment
self.date = date
self.isCurrentUser = isCurrentUser
}

private static func generateInitials(from name: String) -> String {
let components = name.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }

if components.isEmpty {
return "AA"
} else if components.count == 1 {
return String(components[0].prefix(2)).uppercased()
} else {
let firstInitial = String(components[0].prefix(1))
let lastInitial = String(components[components.count - 1].prefix(1))
return "\(firstInitial)\(lastInitial)".uppercased()
}
}
}

struct RatingSummary {
let averageRating: Double
let totalRatings: Int
let reviews: [Review]

init(reviews: [Review]) {
self.reviews = reviews
self.totalRatings = reviews.count
self.averageRating = reviews.isEmpty ? 0.0 : Double(reviews.map { $0.rating }.reduce(0, +)) / Double(reviews.count)
}
}

extension Review {
static let service = ReviewService()

// UserDefaults key for tracking reviewed speakers
private static let reviewedSpeakersKey = "ReviewedSpeakers"

/// Load reviews for a specific speaker
static func loadReviews(for speakerId: String) async throws -> [Review] {
return try await service.fetchReviews(for: speakerId)
}

/// Submit a new review for a specific speaker
static func submitReview(_ review: Review, for speakerId: String) async throws -> Review {
// Mark this speaker as reviewed
markSpeakerAsReviewed(speakerId)
return try await service.submitReview(review, for: speakerId)
}

/// Check if the current user has already reviewed this speaker
static func hasUserReviewed(speakerId: String) -> Bool {
let reviewedSpeakers = UserDefaults.standard.array(forKey: reviewedSpeakersKey) as? [String] ?? []
return reviewedSpeakers.contains(speakerId)
}

/// Mark a speaker as reviewed by the current user
private static func markSpeakerAsReviewed(_ speakerId: String) {
var reviewedSpeakers = UserDefaults.standard.array(forKey: reviewedSpeakersKey) as? [String] ?? []
if !reviewedSpeakers.contains(speakerId) {
reviewedSpeakers.append(speakerId)
UserDefaults.standard.set(reviewedSpeakers, forKey: reviewedSpeakersKey)
}
}

/// Get the user's review ID for a specific speaker (if exists)
static func getUserReviewId(for speakerId: String) -> String? {
return UserDefaults.standard.string(forKey: "UserReview_\(speakerId)")
}

/// Save the user's review ID for a specific speaker
static func saveUserReviewId(_ reviewId: String, for speakerId: String) {
UserDefaults.standard.set(reviewId, forKey: "UserReview_\(speakerId)")
}
}
129 changes: 129 additions & 0 deletions SwiftLeeds/Network/ReviewService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//
// ReviewService.swift
// SwiftLeeds
//
// Created by Muralidharan Kathiresan on 10/09/25.
//

import Foundation

protocol ReviewServiceProtocol {
func fetchReviews(for speakerId: String) async throws -> [Review]
func submitReview(_ review: Review, for speakerId: String) async throws -> Review
}

class ReviewService: ReviewServiceProtocol {
private let session: URLSession
private let isLocalMode: Bool

init(session: URLSession = .shared, isLocalMode: Bool = true) {
self.session = session
self.isLocalMode = isLocalMode
}


// MARK: - Fetch Reviews
func fetchReviews(for speakerId: String) async throws -> [Review] {
if isLocalMode {
return try await fetchLocalReviews(for: speakerId)
} else {
return try await fetchRemoteReviews(for: speakerId)
}
}

// MARK: - Submit Review
func submitReview(_ review: Review, for speakerId: String) async throws -> Review {
if isLocalMode {
// In local mode, we'll simulate the submission and return the review
// This can be extended to write to local storage if needed
return review
} else {
return try await submitRemoteReview(review, for: speakerId)
}
}

// MARK: - Private Methods - Local Mode
private func fetchLocalReviews(for speakerId: String) async throws -> [Review] {
guard let url = Bundle.main.url(forResource: speakerId, withExtension: "json") else {
// No reviews file found for this speaker - return empty array
return []
}

let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601

return try decoder.decode([Review].self, from: data)
}

// MARK: - Private Methods - Remote Mode (API)
private func fetchRemoteReviews(for speakerId: String) async throws -> [Review] {
// TODO: Replace with actual API endpoint once ready
let urlString = "https://api.swiftleeds.co.uk/reviews/\(speakerId)"
guard let url = URL(string: urlString) else {
throw ReviewServiceError.invalidURL
}

let (data, response) = try await session.data(from: url)

guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw ReviewServiceError.networkError
}

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601

return try decoder.decode([Review].self, from: data)
}

private func submitRemoteReview(_ review: Review, for speakerId: String) async throws -> Review {
// TODO: Replace with actual API endpoint once ready
let urlString = "https://api.swiftleeds.co.uk/reviews/\(speakerId)"
guard let url = URL(string: urlString) else {
throw ReviewServiceError.invalidURL
}

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")

let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601

request.httpBody = try encoder.encode(review)

let (data, response) = try await session.data(for: request)

guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 201 else {
throw ReviewServiceError.submissionFailed
}

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601

return try decoder.decode(Review.self, from: data)
}
}

// MARK: - Review Service Errors
enum ReviewServiceError: Error, LocalizedError {
case invalidURL
case networkError
case submissionFailed
case jsonParsingError

var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid URL for review service"
case .networkError:
return "Network error occurred while fetching reviews"
case .submissionFailed:
return "Failed to submit review"
case .jsonParsingError:
return "Failed to parse review data"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
[
{
"id": "bb0e8400-e29b-41d4-a716-446655440001",
"userName": "Rebecca Johnson",
"userInitials": "RJ",
"rating": 5,
"comment": "Sash's insights into shipping fast at Meta were incredible! The practical principles are already changing how our team approaches development.",
"date": "2024-09-08T09:30:00Z",
"isCurrentUser": false
},
{
"id": "bb0e8400-e29b-41d4-a716-446655440002",
"userName": "Tyler Brooks",
"userInitials": "TB",
"rating": 4,
"comment": "Fantastic talk on scaling development practices! The Meta insights were particularly valuable for our growing team.",
"date": "2024-09-08T10:15:00Z",
"isCurrentUser": false
},
{
"id": "bb0e8400-e29b-41d4-a716-446655440003",
"userName": "Megan Clark",
"userInitials": "MC",
"rating": 5,
"comment": "Outstanding presentation! Sash's experience with Threads and Meta AI gave amazing perspectives on rapid prototyping.",
"date": "2024-09-08T11:00:00Z",
"isCurrentUser": false
},
{
"id": "bb0e8400-e29b-41d4-a716-446655440004",
"userName": "Jason Miller",
"userInitials": "JM",
"rating": 4,
"comment": "Great practical advice on shipping fast! The battle-tested principles from Meta's experience are gold for any development team.",
"date": "2024-09-08T11:45:00Z",
"isCurrentUser": false
},
{
"id": "bb0e8400-e29b-41d4-a716-446655440005",
"userName": "Ashley Turner",
"userInitials": "AT",
"rating": 5,
"comment": "Brilliant talk! Sash's experience across iOS, backend, and hardware gave unique insights into shipping products at scale.",
"date": "2024-09-08T12:30:00Z",
"isCurrentUser": false
},
{
"id": "bb0e8400-e29b-41d4-a716-446655440006",
"userName": "Ryan Martinez",
"userInitials": "RM",
"rating": 4,
"comment": "Excellent insights into Meta's development culture! The principles for cutting time from idea to product are invaluable.",
"date": "2024-09-08T13:15:00Z",
"isCurrentUser": false
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
[
{
"id": "aa0e8400-e29b-41d4-a716-446655440001",
"userName": "Victoria Adams",
"userInitials": "VA",
"rating": 5,
"comment": "Chris's time zone talk was both hilarious and educational! His weather app struggles resonated with every developer in the room.",
"date": "2024-09-07T20:30:00Z",
"isCurrentUser": false
},
{
"id": "aa0e8400-e29b-41d4-a716-446655440002",
"userName": "Samuel Turner",
"userInitials": "ST",
"rating": 4,
"comment": "Great talk on time zone pitfalls! Chris made a complex topic approachable with humor and practical examples.",
"date": "2024-09-07T21:15:00Z",
"isCurrentUser": false
},
{
"id": "aa0e8400-e29b-41d4-a716-446655440003",
"userName": "Emma Collins",
"userInitials": "EC",
"rating": 5,
"comment": "Fantastic presentation! Chris turned time zone horror stories into valuable learning experiences. So relatable!",
"date": "2024-09-07T21:45:00Z",
"isCurrentUser": false
},
{
"id": "aa0e8400-e29b-41d4-a716-446655440004",
"userName": "Logan Peterson",
"userInitials": "LP",
"rating": 4,
"comment": "Excellent talk with great humor! The WeatherKit time zone quirks section saved me from future headaches.",
"date": "2024-09-07T22:20:00Z",
"isCurrentUser": false
},
{
"id": "aa0e8400-e29b-41d4-a716-446655440005",
"userName": "Isabella Carter",
"userInitials": "IC",
"rating": 5,
"comment": "Brilliant and entertaining! Chris made time zones less scary and more manageable. Love the practical tips.",
"date": "2024-09-07T23:00:00Z",
"isCurrentUser": false
},
{
"id": "aa0e8400-e29b-41d4-a716-446655440006",
"userName": "Zachary White",
"userInitials": "ZW",
"rating": 4,
"comment": "Great lighthearted approach to a complex topic. Chris's weather app journey was both amusing and instructive.",
"date": "2024-09-07T23:30:00Z",
"isCurrentUser": false
}
]
Loading