diff --git a/SwiftLeeds.xcodeproj/project.pbxproj b/SwiftLeeds.xcodeproj/project.pbxproj index e4f2b05..32b4697 100644 --- a/SwiftLeeds.xcodeproj/project.pbxproj +++ b/SwiftLeeds.xcodeproj/project.pbxproj @@ -41,6 +41,16 @@ 0B4CB3FC28EAF7C500246E62 /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 0B4CB3FB28EAF7C500246E62 /* CachedAsyncImage */; }; 0B4CB3FD28EAF7FE00246E62 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AECB295A27417F9E00CDC983 /* Assets.xcassets */; }; 0B4CB3FE28EAF80200246E62 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AECB29872741ACE000CDC983 /* Colors.xcassets */; }; + 0B59B5652E70E5D400820C3C /* TeamMember.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B59B5642E70E5D400820C3C /* TeamMember.swift */; }; + 0B59B5662E70E5D400820C3C /* TeamMember.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B59B5642E70E5D400820C3C /* TeamMember.swift */; }; + 0B59B5672E70E5D400820C3C /* TeamMember.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B59B5642E70E5D400820C3C /* TeamMember.swift */; }; + 0B59B5692E70E5FE00820C3C /* TeamMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B59B5682E70E5FE00820C3C /* TeamMemberView.swift */; }; + 0B59B56B2E70E6AC00820C3C /* CompactActionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B59B56A2E70E6AC00820C3C /* CompactActionItem.swift */; }; + 0B59B56E2E70EA4F00820C3C /* about.json in Resources */ = {isa = PBXBuildFile; fileRef = 0B59B56C2E70EA4F00820C3C /* about.json */; }; + 0B59B56F2E70EA4F00820C3C /* AboutViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B59B56D2E70EA4F00820C3C /* AboutViewModel.swift */; }; + 0B59B5712E70EA6600820C3C /* About.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B59B5702E70EA6600820C3C /* About.swift */; }; + 0B59B5722E70EA6600820C3C /* About.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B59B5702E70EA6600820C3C /* About.swift */; }; + 0B59B5732E70EA6600820C3C /* About.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B59B5702E70EA6600820C3C /* About.swift */; }; 0B910A352A48FEC100648B32 /* SponsorTileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA57DE4E2875B09900911F03 /* SponsorTileView.swift */; }; 0B910A372A49D07700648B32 /* Sponsor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B910A362A49D07700648B32 /* Sponsor.swift */; }; 0B910A382A49D09300648B32 /* Sponsor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B910A362A49D07700648B32 /* Sponsor.swift */; }; @@ -209,6 +219,12 @@ 0B4CB3CE28EAF19100246E62 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 0B4CB3D028EAF19100246E62 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 0B4CB3D128EAF19100246E62 /* SwiftLeedsAppClip.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SwiftLeedsAppClip.entitlements; sourceTree = ""; }; + 0B59B5642E70E5D400820C3C /* TeamMember.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamMember.swift; sourceTree = ""; }; + 0B59B5682E70E5FE00820C3C /* TeamMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamMemberView.swift; sourceTree = ""; }; + 0B59B56A2E70E6AC00820C3C /* CompactActionItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactActionItem.swift; sourceTree = ""; }; + 0B59B56C2E70EA4F00820C3C /* about.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = about.json; sourceTree = ""; }; + 0B59B56D2E70EA4F00820C3C /* AboutViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewModel.swift; sourceTree = ""; }; + 0B59B5702E70EA6600820C3C /* About.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = About.swift; sourceTree = ""; }; 0B910A362A49D07700648B32 /* Sponsor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sponsor.swift; sourceTree = ""; }; 2A3831112884A96600030002 /* FancyHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FancyHeaderView.swift; sourceTree = ""; }; 39345FD9288F17EE0031BCFF /* BottomSheetView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BottomSheetView.swift; sourceTree = ""; }; @@ -531,7 +547,11 @@ AED26F7628676A9200E06064 /* About */ = { isa = PBXGroup; children = ( + 0B59B56C2E70EA4F00820C3C /* about.json */, AED26F7728676A9900E06064 /* AboutView.swift */, + 0B59B56D2E70EA4F00820C3C /* AboutViewModel.swift */, + 0B59B56A2E70E6AC00820C3C /* CompactActionItem.swift */, + 0B59B5682E70E5FE00820C3C /* TeamMemberView.swift */, ); path = About; sourceTree = ""; @@ -613,12 +633,14 @@ FA57DE572875B0E300911F03 /* Model */ = { isa = PBXGroup; children = ( + 0B59B5702E70EA6600820C3C /* About.swift */, AE8C1B2128BFCF4700AF7318 /* Activity.swift */, FA534D8128A1909300A3BFBB /* Local.swift */, AE8C1B2328BFCFC700AF7318 /* Presentation.swift */, AEDC22542898288F00746247 /* Schedule.swift */, AE8C1B2528BFCFE700AF7318 /* Speaker.swift */, 0B910A362A49D07700648B32 /* Sponsor.swift */, + 0B59B5642E70E5D400820C3C /* TeamMember.swift */, ); path = Model; sourceTree = ""; @@ -829,6 +851,7 @@ AE1C801428A7BCD000996659 /* Settings.bundle in Resources */, AECB29882741ACE000CDC983 /* Colors.xcassets in Resources */, AECB295E27417F9E00CDC983 /* Preview Assets.xcassets in Resources */, + 0B59B56E2E70EA4F00820C3C /* about.json in Resources */, AECB295B27417F9E00CDC983 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -867,6 +890,7 @@ 0B4CB3EC28EAF5E900246E62 /* ActivityView.swift in Sources */, 0B4CB3F228EAF61600246E62 /* WebView.swift in Sources */, E3569B052E5B902B00BC9556 /* UserDefaultsKeys.swift in Sources */, + 0B59B5712E70EA6600820C3C /* About.swift in Sources */, AE1CDBF02AC05B2B00E83420 /* HttpMethod.swift in Sources */, 0B4B1A512A48FB6400ED7EA9 /* SponsorsViewModel.swift in Sources */, 0B4CB3EA28EAF5D900246E62 /* Color.swift in Sources */, @@ -891,6 +915,7 @@ 0B4CB3EB28EAF5E300246E62 /* TalkCell.swift in Sources */, AE1CDBEE2AC05B2B00E83420 /* Requests.swift in Sources */, 740162DB2A7053A000C2D1B3 /* AppState.swift in Sources */, + 0B59B5652E70E5D400820C3C /* TeamMember.swift in Sources */, 0B910A382A49D09300648B32 /* Sponsor.swift in Sources */, 0B4CB3F428EAF62100246E62 /* CommonTileView.swift in Sources */, 0B4CB3C828EAF19100246E62 /* SwiftLeedsAppClipApp.swift in Sources */, @@ -908,6 +933,7 @@ 74E62F7428CAAB30004422F9 /* SwiftLeedsMediumWidgetView.swift in Sources */, 74E62F7228CA98EA004422F9 /* SwiftLeedsSmallWidgetView.swift in Sources */, 7406572928E240720087F44F /* WidgetConstants.swift in Sources */, + 0B59B5672E70E5D400820C3C /* TeamMember.swift in Sources */, 74A09FF228C689AB00E03F39 /* Color.swift in Sources */, AE9367982A9357D000F2DB3F /* Helper.swift in Sources */, 74B14FB028CE21D7004C0A40 /* TimeineProvider.swift in Sources */, @@ -917,6 +943,7 @@ 74B14FB228CE2221004C0A40 /* SwiftLeedsWidgetEntry.swift in Sources */, 74E62F7028CA68D2004422F9 /* Constants.swift in Sources */, 74B14FB428CE2245004C0A40 /* SwiftLeedsWidgetEntryView.swift in Sources */, + 0B59B5732E70EA6600820C3C /* About.swift in Sources */, 7406572728E2304E0087F44F /* Calendar.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -928,8 +955,10 @@ AEDC22532898281300746247 /* MyConferenceViewModel.swift in Sources */, AED26F7828676A9900E06064 /* AboutView.swift in Sources */, FA57DE502875B09900911F03 /* SponsorTileView.swift in Sources */, + 0B59B5692E70E5FE00820C3C /* TeamMemberView.swift in Sources */, 394653AB288BB7C800212E1C /* SpeakerView.swift in Sources */, E3569B002E5A55D000BC9556 /* UserDefaultsKeys.swift in Sources */, + 0B59B56F2E70EA4F00820C3C /* AboutViewModel.swift in Sources */, FA57DE4D2875B08600911F03 /* LinearGradient.swift in Sources */, 394653A9288BB47A00212E1C /* SectionHeader.swift in Sources */, FA57DE552875B0C100911F03 /* SquishyButtonStyle.swift in Sources */, @@ -948,6 +977,8 @@ 2A3831122884A96600030002 /* FancyHeaderView.swift in Sources */, E3569B082E5B905100BC9556 /* SettingsView.swift in Sources */, FA57DE4B2875B06B00911F03 /* SwiftLeedsContainer.swift in Sources */, + 0B59B5722E70EA6600820C3C /* About.swift in Sources */, + 0B59B56B2E70E6AC00820C3C /* CompactActionItem.swift in Sources */, 74F5EF892A49CECB008D9413 /* SidebarView.swift in Sources */, FAAB819128844EBC001159BB /* View+MeasureSize.swift in Sources */, E3569AF92E5A301D00BC9556 /* ThemeManager.swift in Sources */, @@ -962,6 +993,7 @@ E3569AEF2E5A1D0200BC9556 /* ShimmerView.swift in Sources */, 0B4B1A4D2A486EA800ED7EA9 /* SponsorsViewModel.swift in Sources */, AECB295727417F9D00CDC983 /* SwiftLeedsApp.swift in Sources */, + 0B59B5662E70E5D400820C3C /* TeamMember.swift in Sources */, 0B4B1A4B2A4858F600ED7EA9 /* SponsorsView.swift in Sources */, AE8C1B2B28C4B39A00AF7318 /* TokenDetails.swift in Sources */, 0B910A372A49D07700648B32 /* Sponsor.swift in Sources */, diff --git a/SwiftLeeds/Data/Model/About.swift b/SwiftLeeds/Data/Model/About.swift new file mode 100644 index 0000000..455ec12 --- /dev/null +++ b/SwiftLeeds/Data/Model/About.swift @@ -0,0 +1,27 @@ +// +// About.swift +// SwiftLeeds +// +// Created by Muralidharan Kathiresan on 10/09/2025. +// + +import Foundation + +struct AboutURLs: Codable { + let venue: String + let codeOfConduct: String + let reportAProblem: String + let slack: String + let youtube: String +} + +struct AboutData: Codable { + let urls: AboutURLs + let truncatedAboutText: String + let teamMembers: [TeamMember] +} + +struct AboutContent: Codable { + let urls: AboutURLs + let truncatedAboutText: String +} diff --git a/SwiftLeeds/Data/Model/TeamMember.swift b/SwiftLeeds/Data/Model/TeamMember.swift new file mode 100644 index 0000000..101daed --- /dev/null +++ b/SwiftLeeds/Data/Model/TeamMember.swift @@ -0,0 +1,62 @@ +// +// TeamMember.swift +// SwiftLeeds +// +// Created by Muralidharan Kathiresan on 09/09/2025. +// + +import Foundation + +struct Team: Codable { + let teamMembers: [TeamMember] +} + +struct TeamMember: Codable, Identifiable { + let name: String + let role: String? + let linkedInURL: String? + let twitterURL: String? + let slackURL: String? + let photoURL: String? + + var id: String { name } + + private enum CodingKeys: String, CodingKey { + case name + case role + case linkedInURL = "linkedin" + case twitterURL = "twitter" + case slackURL = "slack" + case photoURL = "imageURL" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + name = try container.decode(String.self, forKey: .name) + role = try container.decodeIfPresent(String.self, forKey: .role) + linkedInURL = try container.decodeIfPresent(String.self, forKey: .linkedInURL) + twitterURL = try container.decodeIfPresent(String.self, forKey: .twitterURL) + slackURL = try container.decodeIfPresent(String.self, forKey: .slackURL) + + if let imageURL = try container.decodeIfPresent(String.self, forKey: .photoURL) { + photoURL = imageURL.hasPrefix("/") ? "https://swiftleeds.co.uk\(imageURL)" : imageURL + } else { + photoURL = nil + } + } + + init(name: String, + role: String?, + linkedInURL: String?, + twitterURL: String?, + slackURL: String?, + photoURL: String?) { + self.name = name + self.role = role + self.linkedInURL = linkedInURL + self.twitterURL = twitterURL + self.slackURL = slackURL + self.photoURL = photoURL + } +} diff --git a/SwiftLeeds/Network/Requests.swift b/SwiftLeeds/Network/Requests.swift index 312716d..28a7c6c 100644 --- a/SwiftLeeds/Network/Requests.swift +++ b/SwiftLeeds/Network/Requests.swift @@ -30,6 +30,12 @@ enum Requests { eTagKey: "etag-sponsors" ) + static let team = Request( + host: host, + path: "\(apiVersion2)/team", + eTagKey: "etag-team" + ) + static func schedule(for eventID: UUID) -> Request { Request( host: host, diff --git a/SwiftLeeds/Views/About/AboutView.swift b/SwiftLeeds/Views/About/AboutView.swift index d692ebe..5c3d074 100644 --- a/SwiftLeeds/Views/About/AboutView.swift +++ b/SwiftLeeds/Views/About/AboutView.swift @@ -7,22 +7,12 @@ import SwiftUI import ReadabilityModifier +import CachedAsyncImage struct AboutView: View { + @StateObject private var viewModel = AboutViewModel() @State private var isReportAProblemShown = false @State private var isFullAboutShown = false - - private let venueURL = URL(string: "https://swiftleeds.co.uk/#venue") - private let codeOfConductURL = URL(string: "https://swiftleeds.co.uk/conduct") - private let reportAProblemLink = "https://forms.gle/PJie9aRNAtzQUdUu9" - private let slackURL = URL(string: "https://join.slack.com/t/swiftleedsworkspace/shared_invite/zt-3dex3vb3k-JNYQ~ollX6R619D_tZVfXQ") - private let youtubeURL = URL(string: "https://www.youtube.com/@swiftleeds") - - private let truncatedAboutText = """ - Adam Rush founded SwiftLeeds in 2019, born from over ten years of experience attending conferences. The inspiration was bringing a modern, inclusive conference in the North of the UK to be more accessible for all. - - SwiftLeeds is now run with over ten community volunteers building the website, iOS applications... - """ private var gridColumns: [GridItem] { #if os(iOS) @@ -32,6 +22,15 @@ struct AboutView: View { #endif return Array(repeating: GridItem(.flexible(), spacing: Padding.cellGap), count: columnCount) } + + private var teamGridColumns: [GridItem] { + #if os(iOS) + let columnCount = UIDevice.current.userInterfaceIdiom == .pad ? 3 : 2 + #else + let columnCount = 2 + #endif + return Array(repeating: GridItem(.flexible(), spacing: Padding.cellGap), count: columnCount) + } var body: some View { SwiftLeedsContainer { @@ -56,7 +55,7 @@ struct AboutView: View { .foregroundColor(.primary) VStack(alignment: .leading, spacing: 8) { - Text(.init(truncatedAboutText)) + Text(.init(viewModel.truncatedAboutText)) .font(.subheadline.weight(.regular)) .foregroundColor(.secondary) @@ -78,12 +77,12 @@ struct AboutView: View { in: RoundedRectangle(cornerRadius: Constants.cellRadius) ) .accessibilityElement(children: .ignore) - .accessibilityLabel("About SwiftLeeds. \(truncatedAboutText). Tap More for full details.") + .accessibilityLabel("About SwiftLeeds. \(viewModel.truncatedAboutText). Tap More for full details.") LazyVGrid(columns: gridColumns, spacing: Padding.cellGap) { CompactActionItem( icon: "exclamationmark.triangle.fill", - title: "Report\nProblem", + title: "Report a\nProblem", accessibilityHint: "Opens a web view to allow a problem to be reported", action: { isReportAProblemShown = true } ) @@ -92,37 +91,61 @@ struct AboutView: View { icon: "doc.text.fill", title: "Code of\nConduct", accessibilityHint: "Opens a web page showing our code of conduct", - action: { openURL(url: codeOfConductURL) } + action: { openURL(url: viewModel.codeOfConductURL) } ) CompactActionItem( icon: "location.fill", title: "Venue\nInfo", accessibilityHint: "Opens a web page showing our venue information", - action: { openURL(url: venueURL) } + action: { openURL(url: viewModel.venueURL) } ) CompactActionItem( icon: "message.fill", title: "Join our\nSlack", accessibilityHint: "Opens an invite link to join the SwiftLeeds Slack workspace", - action: { openURL(url: slackURL) } + action: { openURL(url: viewModel.slackURL) } ) CompactActionItem( icon: "play.rectangle.fill", title: "YouTube\nChannel", accessibilityHint: "Opens the SwiftLeeds YouTube channel", - action: { openURL(url: youtubeURL) } + action: { openURL(url: viewModel.youtubeURL) } ) } .padding(.vertical, 8) + + VStack(alignment: .leading, spacing: Padding.stackGap) { + Text("Meet the Team") + .font(.headline.weight(.semibold)) + .foregroundColor(.primary) + + Text("Connect with our amazing volunteers who make SwiftLeeds possible") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .padding(Padding.cell) + .background( + Color.cellBackground, + in: RoundedRectangle(cornerRadius: Constants.cellRadius) + ) + + LazyVGrid(columns: teamGridColumns, spacing: Padding.cellGap) { + ForEach(viewModel.teamMembers) { member in + TeamMemberView(member: member) + } + } + .padding(.vertical, 8) } .fitToReadableContentGuide(type: .width) } .padding(.bottom, Padding.cellGap) .sheet(isPresented: $isReportAProblemShown) { - WebView(url: reportAProblemLink) + WebView(url: viewModel.reportAProblemLink) .ignoresSafeArea(edges: .bottom) } .sheet(isPresented: $isFullAboutShown) { @@ -162,39 +185,6 @@ struct AboutView: View { } } -struct CompactActionItem: View { - let icon: String - let title: String - let accessibilityHint: String - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: 8) { - Image(systemName: icon) - .font(.system(size: 24, weight: .medium)) - .foregroundColor(.accentColor) - .frame(height: 32) - - Text(title) - .font(.caption) - .multilineTextAlignment(.center) - .foregroundColor(.primary) - .lineLimit(2) - } - .frame(maxWidth: .infinity, minHeight: 80) - .padding(.vertical, 12) - .padding(.horizontal, 8) - .background( - Color.cellBackground, - in: RoundedRectangle(cornerRadius: Constants.cellRadius) - ) - } - .buttonStyle(SquishyButtonStyle()) - .accessibilityHint(accessibilityHint) - } -} - struct AboutView_Previews: PreviewProvider { static var previews: some View { AboutView() diff --git a/SwiftLeeds/Views/About/AboutViewModel.swift b/SwiftLeeds/Views/About/AboutViewModel.swift new file mode 100644 index 0000000..4005cfb --- /dev/null +++ b/SwiftLeeds/Views/About/AboutViewModel.swift @@ -0,0 +1,106 @@ +// +// AboutViewModel.swift +// SwiftLeeds +// +// Created by Muralidharan Kathiresan on 09/09/2025. +// + +import Foundation +import Combine + +// MARK: - ViewModel +@MainActor +class AboutViewModel: ObservableObject { + @Published var aboutContent: AboutContent? + @Published var teamMembers: [TeamMember] = [] + @Published var isLoading = true + @Published var errorMessage: String? + + var venueURL: URL? { + guard let urlString = aboutContent?.urls.venue else { return nil } + return URL(string: urlString) + } + + var codeOfConductURL: URL? { + guard let urlString = aboutContent?.urls.codeOfConduct else { return nil } + return URL(string: urlString) + } + + var reportAProblemLink: String { + return aboutContent?.urls.reportAProblem ?? "" + } + + var slackURL: URL? { + guard let urlString = aboutContent?.urls.slack else { return nil } + return URL(string: urlString) + } + + var youtubeURL: URL? { + guard let urlString = aboutContent?.urls.youtube else { return nil } + return URL(string: urlString) + } + + var truncatedAboutText: String { + return aboutContent?.truncatedAboutText ?? "" + } + + init() { + Task { + await loadData() + } + } + + private func loadData() async { + isLoading = true + errorMessage = nil + + // Load about content (URLs, text) from local JSON + loadLocalAboutContent() + + // Load team data from API + await loadTeamData() + + isLoading = false + } + + private func loadLocalAboutContent() { + guard let path = Bundle.main.path(forResource: "about", ofType: "json"), + let data = NSData(contentsOfFile: path) as Data? else { + errorMessage = "Could not find about.json file" + return + } + + do { + let decoder = JSONDecoder() + let loadedContent = try decoder.decode(AboutContent.self, from: data) + self.aboutContent = loadedContent + } catch { + errorMessage = "Error parsing about.json: \(error.localizedDescription)" + } + } + + private func loadTeamData() async { + do { + let teamApiResponse = try await URLSession.shared.decode( + Requests.team, + dateDecodingStrategy: Requests.defaultDateDecodingStratergy + ) + await updateTeamMembers(teamApiResponse.teamMembers) + } catch { + // Try to load from cache if API fails + if let cachedResponse = try? await URLSession.shared.cached( + Requests.team, + dateDecodingStrategy: Requests.defaultDateDecodingStratergy + ) { + await updateTeamMembers(cachedResponse.teamMembers) + } else { + errorMessage = "Failed to load team data: \(error.localizedDescription)" + } + } + } + + @MainActor + private func updateTeamMembers(_ members: [TeamMember]) async { + self.teamMembers = members + } +} diff --git a/SwiftLeeds/Views/About/CompactActionItem.swift b/SwiftLeeds/Views/About/CompactActionItem.swift new file mode 100644 index 0000000..48d1959 --- /dev/null +++ b/SwiftLeeds/Views/About/CompactActionItem.swift @@ -0,0 +1,42 @@ +// +// CompactActionItem.swift +// SwiftLeeds +// +// Created by Muralidharan Kathiresan on 09/09/2025. +// + +import SwiftUI + +struct CompactActionItem: View { + let icon: String + let title: String + let accessibilityHint: String + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 24, weight: .medium)) + .foregroundColor(.accentColor) + .frame(height: 32) + + Text(title) + .font(.caption) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + .lineLimit(2) + } + .frame(maxWidth: .infinity, minHeight: 80) + .padding(.vertical, 12) + .padding(.horizontal, 8) + .background( + Color.cellBackground, + in: RoundedRectangle(cornerRadius: Constants.cellRadius) + ) + } + .buttonStyle(SquishyButtonStyle()) + .accessibilityHint(accessibilityHint) + } +} + diff --git a/SwiftLeeds/Views/About/TeamMemberView.swift b/SwiftLeeds/Views/About/TeamMemberView.swift new file mode 100644 index 0000000..cefa831 --- /dev/null +++ b/SwiftLeeds/Views/About/TeamMemberView.swift @@ -0,0 +1,174 @@ +// +// TeamMemberView.swift +// SwiftLeeds +// +// Created by Muralidharan Kathiresan on 09/09/2025. +// + +import SwiftUI +import CachedAsyncImage + +struct TeamMemberView: View { + let member: TeamMember + + var body: some View { + VStack(spacing: 12) { + ZStack { + Circle() + .fill(LinearGradient( + colors: [.accentColor.opacity(0.8), .accentColor], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .frame(width: 80, height: 80) + + if let photoURL = member.photoURL, let url = URL(string: photoURL) { + CachedAsyncImage(url: url) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 80, height: 80) + .clipShape(Circle()) + } placeholder: { + Text(initials) + .font(.title.weight(.semibold)) + .foregroundColor(.white) + } + } else { + Text(initials) + .font(.title.weight(.semibold)) + .foregroundColor(.white) + } + } + .shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4) + + VStack(spacing: 6) { + Text(member.name) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.primary) + .multilineTextAlignment(.center) + .lineLimit(2) + + Text(member.role ?? " ") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .lineLimit(2) + .frame(minHeight: 32) + .opacity(member.role != nil ? 1.0 : 0.0) + } + + HStack(spacing: 16) { + if let linkedInURL = member.linkedInURL { + Button(action: { openURL(URL(string: linkedInURL)) }) { + Image(systemName: "person.crop.rectangle") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.accentColor) + } + .accessibilityLabel("LinkedIn profile for \(member.name)") + } + + if let twitterURL = member.twitterURL { + Button(action: { openURL(URL(string: twitterURL)) }) { + Image(systemName: "at") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.accentColor) + } + .accessibilityLabel("Twitter profile for \(member.name)") + } + + if let slackURL = member.slackURL { + Button(action: { openURL(URL(string: slackURL)) }) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.accentColor) + } + .accessibilityLabel("Message \(member.name) on Slack") + } + } + .frame(minHeight: 32) + } + .frame(maxWidth: .infinity, minHeight: 150) + .padding(Padding.cell) + .background( + Color.cellBackground, + in: RoundedRectangle(cornerRadius: Constants.cellRadius) + ) + } + + private var initials: String { + let components = member.name.components(separatedBy: " ") + let firstInitial = components.first?.first?.uppercased() ?? "" + let lastInitial = components.count > 1 ? (components.last?.first?.uppercased() ?? "") : "" + return firstInitial + lastInitial + } + + private func openURL(_ url: URL?) { + guard let url = url else { return } + UIApplication.shared.open(url) + } +} + +struct TeamMemberView_Previews: PreviewProvider { + static var previews: some View { + Group { + // Team member with role and all social links + TeamMemberView(member: TeamMember( + name: "Adam Rush", + role: "Founder and Host", + linkedInURL: "https://www.linkedin.com/in/swiftlyrush/", + twitterURL: "https://twitter.com/Adam9Rush", + slackURL: "https://swiftleedsworkspace.slack.com/archives/D02ELG76VC0", + photoURL: "https://swiftleeds.co.uk/img/team/rush.jpg" + )) + .previewDisplayName("With Role & All Links") + + // Team member without role + TeamMemberView(member: TeamMember( + name: "Adam Oxley", + role: nil, + linkedInURL: "https://www.linkedin.com/in/adam-oxley-41183a82/", + twitterURL: "https://twitter.com/admoxly", + slackURL: "https://swiftleedsworkspace.slack.com/team/U02DRL7KUCS", + photoURL: "https://swiftleeds.co.uk/img/team/oxley.jpg" + )) + .previewDisplayName("No Role") + + // Team member with partial social links + TeamMemberView(member: TeamMember( + name: "Kannan Prasad", + role: nil, + linkedInURL: "https://www.linkedin.com/in/kannanprasad/", + twitterURL: nil, + slackURL: "https://swiftleedsworkspace.slack.com/archives/D0477TRS28G", + photoURL: nil + )) + .previewDisplayName("Partial Links & No Photo") + + // Grid layout preview + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { + TeamMemberView(member: TeamMember( + name: "James Sherlock", + role: "Production Team Lead", + linkedInURL: "https://www.linkedin.com/in/jamessherlockdeveloper/", + twitterURL: "https://twitter.com/JamesSherlouk", + slackURL: "https://swiftleedsworkspace.slack.com/archives/D05RK6AAV29", + photoURL: "https://swiftleeds.co.uk/img/team/sherlock.jpg" + )) + + TeamMemberView(member: TeamMember( + name: "Joe Williams", + role: "Camera Operator", + linkedInURL: "https://www.linkedin.com/in/joe-williams-1676b871/", + twitterURL: "https://twitter.com/joedub_dev", + slackURL: "https://swiftleedsworkspace.slack.com/archives/C05N7JZE2NP", + photoURL: "https://swiftleeds.co.uk/img/team/joe.jpg" + )) + } + .padding() + .previewDisplayName("Grid Layout") + } + .previewLayout(.sizeThatFits) + .padding() + } +} diff --git a/SwiftLeeds/Views/About/about.json b/SwiftLeeds/Views/About/about.json new file mode 100644 index 0000000..f5d8c80 --- /dev/null +++ b/SwiftLeeds/Views/About/about.json @@ -0,0 +1,10 @@ +{ + "urls": { + "venue": "https://swiftleeds.co.uk/#venue", + "codeOfConduct": "https://swiftleeds.co.uk/conduct", + "reportAProblem": "https://forms.gle/PJie9aRNAtzQUdUu9", + "slack": "https://join.slack.com/t/swiftleedsworkspace/shared_invite/zt-3dex3vb3k-JNYQ~ollX6R619D_tZVfXQ", + "youtube": "https://www.youtube.com/@swiftleeds" + }, + "truncatedAboutText": "Adam Rush founded SwiftLeeds in 2019, born from over ten years of experience attending conferences. The inspiration was bringing a modern, inclusive conference in the North of the UK to be more accessible for all.\n\nSwiftLeeds is now run with over ten community volunteers building the website, iOS applications..." +}