diff --git a/SwiftLeeds.xcodeproj/project.pbxproj b/SwiftLeeds.xcodeproj/project.pbxproj index c91f0e2..f2bd57c 100644 --- a/SwiftLeeds.xcodeproj/project.pbxproj +++ b/SwiftLeeds.xcodeproj/project.pbxproj @@ -120,6 +120,8 @@ AEDC22532898281300746247 /* MyConferenceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEDC22522898281300746247 /* MyConferenceViewModel.swift */; }; AEDC22552898288F00746247 /* Schedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEDC22542898288F00746247 /* Schedule.swift */; }; AEDC2257289C65D500746247 /* Calendar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEDC2256289C65D500746247 /* Calendar.swift */; }; + E3569AEE2E5A1D0200BC9556 /* ShimmerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3569AED2E5A1D0200BC9556 /* ShimmerView.swift */; }; + E3569AEF2E5A1D0200BC9556 /* ShimmerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3569AED2E5A1D0200BC9556 /* ShimmerView.swift */; }; FA1F7EF7287CB71600E12F8C /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1F7EF6287CB71600E12F8C /* HeaderView.swift */; }; FA534D8228A1909300A3BFBB /* Local.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA534D8128A1909300A3BFBB /* Local.swift */; }; FA534D8828A1939500A3BFBB /* LocalViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA534D8728A1939500A3BFBB /* LocalViewModel.swift */; }; @@ -261,6 +263,7 @@ AEDC22522898281300746247 /* MyConferenceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyConferenceViewModel.swift; sourceTree = ""; }; AEDC22542898288F00746247 /* Schedule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Schedule.swift; sourceTree = ""; }; AEDC2256289C65D500746247 /* Calendar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Calendar.swift; sourceTree = ""; }; + E3569AED2E5A1D0200BC9556 /* ShimmerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShimmerView.swift; sourceTree = ""; }; FA1F7EF6287CB71600E12F8C /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = ""; }; FA534D8128A1909300A3BFBB /* Local.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Local.swift; sourceTree = ""; }; FA534D8728A1939500A3BFBB /* LocalViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalViewModel.swift; sourceTree = ""; }; @@ -559,6 +562,7 @@ FA57DE432875B06500911F03 /* CommonTileButton.swift */, FA57DE442875B06500911F03 /* CommonTileView.swift */, 2A3831112884A96600030002 /* FancyHeaderView.swift */, + E3569AED2E5A1D0200BC9556 /* ShimmerView.swift */, FA1F7EF6287CB71600E12F8C /* HeaderView.swift */, FA57DE452875B06500911F03 /* StackedTileView.swift */, AEB06BD028CF8D2100E51967 /* WebView.swift */, @@ -852,6 +856,7 @@ AE1CDBED2AC05B2B00E83420 /* Request.swift in Sources */, AE9367972A9354CC00F2DB3F /* Helper.swift in Sources */, 0B4CB3E528EAF5B700246E62 /* Speaker.swift in Sources */, + E3569AEE2E5A1D0200BC9556 /* ShimmerView.swift in Sources */, 0B4CB3E828EAF5CD00246E62 /* AnnouncementCell.swift in Sources */, 0B4CB3DD28EAF57900246E62 /* MyConferenceView.swift in Sources */, 0B4CB3CA28EAF19100246E62 /* ContentView.swift in Sources */, @@ -922,6 +927,7 @@ AED26F7028675DA000E06064 /* AnnouncementCell.swift in Sources */, AEB06BD128CF8D2100E51967 /* WebView.swift in Sources */, AE8C1B2828C4B36B00AF7318 /* AppDelegate+Push.swift in Sources */, + E3569AEF2E5A1D0200BC9556 /* ShimmerView.swift in Sources */, 0B4B1A4D2A486EA800ED7EA9 /* SponsorsViewModel.swift in Sources */, AECB295727417F9D00CDC983 /* SwiftLeedsApp.swift in Sources */, 0B4B1A4B2A4858F600ED7EA9 /* SponsorsView.swift in Sources */, diff --git a/SwiftLeeds/Views/Components/ShimmerView.swift b/SwiftLeeds/Views/Components/ShimmerView.swift new file mode 100644 index 0000000..317e69a --- /dev/null +++ b/SwiftLeeds/Views/Components/ShimmerView.swift @@ -0,0 +1,67 @@ +// +// ShimmerView.swift +// SwiftLeeds +// +// Created by Adam Rush on 23/08/25. +// + +import SwiftUI + +struct ShimmerView: View { + @State private var shimmerOffset: CGFloat = -1 + + private let colors: [Color] + private let duration: Double + + init(colors: [Color] = [ + Color.gray.opacity(0.2), + Color.gray.opacity(0.3), + Color.gray.opacity(0.2) + ], duration: Double = 1.5) { + self.colors = colors + self.duration = duration + } + + var body: some View { + GeometryReader { geometry in + Rectangle() + .fill( + LinearGradient( + colors: colors, + startPoint: UnitPoint(x: shimmerOffset - 1, y: 0), + endPoint: UnitPoint(x: shimmerOffset, y: 0) + ) + ) + .onAppear { + withAnimation( + Animation.linear(duration: duration) + .repeatForever(autoreverses: false) + ) { + shimmerOffset = 2 + } + } + } + .clipped() + } +} + +struct ShimmerView_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 16) { + Rectangle() + .frame(height: 100) + .overlay(ShimmerView()) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + Rectangle() + .frame(height: 50) + .overlay(ShimmerView(colors: [ + Color.blue.opacity(0.2), + Color.blue.opacity(0.4), + Color.blue.opacity(0.2) + ])) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .padding() + } +} diff --git a/SwiftLeeds/Views/Sponsors/SponsorTileView.swift b/SwiftLeeds/Views/Sponsors/SponsorTileView.swift index dfa033f..a41e15d 100644 --- a/SwiftLeeds/Views/Sponsors/SponsorTileView.swift +++ b/SwiftLeeds/Views/Sponsors/SponsorTileView.swift @@ -10,122 +10,246 @@ import CachedAsyncImage struct SponsorTileView: View { let sponsor: Sponsor - + @State private var showingJobs = false + @State private var isImageLoaded = false @Environment(\.openURL) private var openURL - + var body: some View { - Button(action: { openURL(sponsor.url) }) { + VStack(alignment: .leading, spacing: 0) { + mainTile + + if !sponsor.jobs.isEmpty { + jobsSection + } + } + .background(Color.cellBackground, in: contentShape) + .overlay( + RoundedRectangle(cornerRadius: Constants.cellRadius, style: .continuous) + .stroke(Color.accent.opacity(0.1), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.05), radius: 8, x: 0, y: 2) + } + + private var mainTile: some View { + Button(action: { + if let url = URL(string: sponsor.url) { + openURL(url) + } + }) { VStack(alignment: .leading, spacing: 0) { - image - .padding(16) - - text - - if sponsor.jobs.isEmpty == false { - Text("JOBS") - .font(.caption) - .fontWeight(.thin) - .padding(Padding.cell) + imageSection + infoSection + } + } + .buttonStyle(SquishyButtonStyle()) + } + + private var imageSection: some View { + ZStack { + CachedAsyncImage( + url: URL(string: sponsor.image), + content: { image in + Rectangle() + .aspectRatio(1.66, contentMode: .fill) + .foregroundColor(.clear) + .background( + image + .resizable() + .aspectRatio(contentMode: .fit) + .transition(.opacity) + .onAppear { + withAnimation(.easeIn(duration: 0.3)) { + isImageLoaded = true + } + } + ) + .background( + LinearGradient( + colors: [Color.cellBackground, Color.cellBackground.opacity(0.8)], + startPoint: .top, + endPoint: .bottom + ) + ) + .clipped() + }, + placeholder: { + Rectangle() + .foregroundColor(.cellBackground) + .overlay( + ShimmerView() + ) } - - ForEach(sponsor.jobs) { job in - VStack(spacing: 0) { - Divider() - - CommonTileButton(primaryText: job.title, - subtitleText: job.location, - accessibilityHint: "Opens a web site showing more details about the job", - showChevron: true, - backgroundStyle: Color.cellBackground) - { - openURL(job.url) - } - .foregroundColor(.secondary) + ) + .aspectRatio(1.66, contentMode: .fit) + .accessibilityHidden(true) + + if sponsor.sponsorLevel == .platinum { + VStack { + HStack { + Spacer() + Label("Platinum", systemImage: "crown.fill") + .font(.caption2.weight(.bold)) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Capsule() + .fill( + LinearGradient( + colors: [.purple, .purple.opacity(0.8)], + startPoint: .leading, + endPoint: .trailing + ) + ) + ) + .padding(8) } + Spacer() } } } - .background(Color.cellBackground, in: contentShape) - .buttonStyle(SquishyButtonStyle()) + .padding(16) } - - private var image: some View { - CachedAsyncImage( - url: URL(string: sponsor.image), - content: { image in - Rectangle() - .aspectRatio(1.66, contentMode: .fill) - .foregroundColor(.clear) - .background( - image - .resizable() - .aspectRatio(contentMode: .fit) - .transition(.opacity) - ) - .background(Color.cellBackground) - .clipped() - .transition(contentTransition) - }, - placeholder: { - Rectangle() - .foregroundColor(.cellBackground) - .transition(contentTransition) - .overlay(content: { - ProgressView() - .tint(.white) - .opacity(0.5) - }) + + private var infoSection: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(sponsor.name) + .foregroundColor(.primary) + .font(.headline.weight(.semibold)) + .lineLimit(1) + + if !sponsor.subtitle.isEmpty { + Text(sponsor.subtitle) + .foregroundColor(.secondary) + .font(.subheadline) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer() + + Image(systemName: "arrow.up.right.circle.fill") + .font(.title3) + .foregroundColor(.accent.opacity(0.7)) } - ) - .aspectRatio(1.66, contentMode: .fit) - .accessibilityHidden(true) - } - - private var text: some View { - VStack(alignment: .leading, spacing: 4) { - Text(sponsor.name) - .foregroundColor(.primary) - .font(.subheadline.weight(.medium)) - - if sponsor.subtitle.isEmpty == false { - Text(sponsor.subtitle) - .foregroundColor(.secondary) - .font(.subheadline.weight(.regular)) + + if !sponsor.jobs.isEmpty { + HStack { + Label("\(sponsor.jobs.count) Job\(sponsor.jobs.count > 1 ? "s" : "")", systemImage: "briefcase.fill") + .font(.caption.weight(.medium)) + .foregroundColor(.accent) + + Spacer() + } + .padding(.top, 4) } } - .accessibilityElement(children: .ignore) - .accessibilityLabel( - "Sponsor, \(sponsor.name), \(sponsor.subtitle)" - ) .padding() - .frame(minHeight: 55) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Sponsor, \(sponsor.name), \(sponsor.subtitle). \(sponsor.jobs.isEmpty ? "" : "\(sponsor.jobs.count) job opportunities available")") } - + + private var jobsSection: some View { + VStack(spacing: 0) { + Divider() + + Button(action: { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { showingJobs.toggle() } }) { + HStack { + Text("Job Opportunities") + .font(.subheadline.weight(.semibold)) + .foregroundColor(.primary) + + Spacer() + + Image(systemName: showingJobs ? "chevron.up" : "chevron.down") + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary) + } + .padding() + .background(Color.accent.opacity(0.05)) + } + + if showingJobs { + VStack(spacing: 0) { + ForEach(sponsor.jobs) { job in + JobRowView(job: job) + } + } + .transition(.asymmetric( + insertion: .push(from: .top).combined(with: .opacity), + removal: .push(from: .bottom).combined(with: .opacity) + )) + } + } + } + private var contentShape: some Shape { RoundedRectangle(cornerRadius: Constants.cellRadius, style: .continuous) } +} - private var contentTransition: AnyTransition { - .opacity.animation(.spring()) - } - - private func openURL(_ urlString: String) { - guard let link = URL(string: urlString) else { return } - openURL(link) +struct JobRowView: View { + let job: Job + @Environment(\.openURL) private var openURL + @State private var isPressed = false + + var body: some View { + Button(action: { + if let url = URL(string: job.url) { + openURL(url) + } + }) { + VStack(spacing: 0) { + Divider() + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(job.title) + .font(.subheadline.weight(.medium)) + .foregroundColor(.primary) + .lineLimit(1) + + HStack(spacing: 4) { + Image(systemName: "location.fill") + .font(.caption2) + Text(job.location) + .font(.caption) + } + .foregroundColor(.secondary) + } + + Spacer() + + Image(systemName: "arrow.right.circle") + .font(.title3) + .foregroundColor(.accent.opacity(0.5)) + } + .padding() + .background(isPressed ? Color.accent.opacity(0.05) : Color.clear) + } + } + .scaleEffect(isPressed ? 0.97 : 1.0) + .onLongPressGesture(minimumDuration: 0.1, maximumDistance: .infinity, pressing: { pressing in + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + isPressed = pressing + } + }, perform: {}) } } + struct SponsorTileView_Previews: PreviewProvider { static var previews: some View { ZStack { Color.background.edgesIgnoringSafeArea(.all) - + VStack { - SponsorTileView( - sponsor: .sample - ) + SponsorTileView(sponsor: .sample) + .padding() } - .padding() } } } diff --git a/SwiftLeeds/Views/Sponsors/SponsorsView.swift b/SwiftLeeds/Views/Sponsors/SponsorsView.swift index c982734..8e6aa7d 100644 --- a/SwiftLeeds/Views/Sponsors/SponsorsView.swift +++ b/SwiftLeeds/Views/Sponsors/SponsorsView.swift @@ -10,6 +10,9 @@ import ReadabilityModifier struct SponsorsView: View { @StateObject private var viewModel = SponsorsViewModel() + @State private var isLoading = true + @State private var searchText = "" + @State private var selectedSponsorLevel: SponsorLevel? var body: some View { SwiftLeedsContainer { @@ -19,75 +22,263 @@ struct SponsorsView: View { } private var content: some View { - List { - ForEach(viewModel.sections) { section in - switch section.type { - case .platinum: - Section(header: sectionHeader(for: section.type)) { - ForEach(section.sponsors) { sponsor in - sponsorTile(for: sponsor) - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) - .padding(.bottom, Padding.cellGap ) + ScrollView { + VStack(spacing: 0) { + FancyHeaderView( + title: "Sponsors", + foregroundImageName: Assets.Image.swiftLeedsIcon + ) + + if isLoading { + loadingView + .padding(.top, Padding.screen) + } else if viewModel.sections.isEmpty { + emptyStateView + .padding(.top, Padding.screen) + } else { + sponsorsList + } + } + } + .scrollIndicators(.hidden) + .task { + await loadSponsors() + } + } + + private var loadingView: some View { + VStack(spacing: Padding.cellGap) { + ProgressView() + .scaleEffect(1.2) + .tint(.accent) + Text("Loading sponsors...") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 300) + .padding() + } + + private var emptyStateView: some View { + VStack(spacing: Padding.cellGap) { + Image(systemName: "sparkles") + .font(.system(size: 50)) + .foregroundColor(.secondary) + Text("No sponsors available") + .font(.headline) + Text("Check back later for updates") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 300) + .padding() + } + + private var tierEmptyStateView: some View { + VStack(spacing: Padding.cellGap) { + if let selectedLevel = selectedSponsorLevel { + Image(systemName: iconForLevel(selectedLevel)) + .font(.system(size: 50)) + .foregroundColor(.secondary.opacity(0.6)) + + Text("No \(selectedLevel.rawValue.capitalized) Sponsors") + .font(.headline) + .foregroundColor(.primary) + + Text("This tier doesn't have any sponsors yet") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity, minHeight: 200) + .padding() + } + + private var sponsorsList: some View { + VStack(spacing: Padding.cellGap) { + filterChips + + if filteredSections.isEmpty && selectedSponsorLevel != nil { + tierEmptyStateView + } else { + ForEach(filteredSections) { section in + sectionView(for: section) + } + } + } + .padding(.horizontal, horizontalPadding) + .padding(.bottom, Padding.cellGap) + } + + private var filterChips: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + FilterChip( + title: "All", + isSelected: selectedSponsorLevel == nil, + action: { selectedSponsorLevel = nil } + ) + + ForEach([SponsorLevel.platinum, .gold, .silver], id: \.self) { level in + FilterChip( + title: level.rawValue.capitalized, + isSelected: selectedSponsorLevel == level, + action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + selectedSponsorLevel = level == selectedSponsorLevel ? nil : level + } } + ) + } + } + } + .padding(.vertical, 8) + } + + private var filteredSections: [SponsorsViewModel.Section] { + if let selectedLevel = selectedSponsorLevel { + return viewModel.sections.filter { $0.type == selectedLevel } + } + return viewModel.sections + } + + private var gridColumns: [GridItem] { + return Array(repeating: GridItem(.flexible(), spacing: Padding.cellGap), count: columnCount) + } + + private var columnCount: Int { + #if os(iOS) + return UIDevice.current.userInterfaceIdiom == .pad ? 3 : 2 + #else + return 2 + #endif + } + + private var horizontalPadding: CGFloat { + #if os(iOS) + return UIDevice.current.userInterfaceIdiom == .pad ? Padding.screen * 2 : Padding.screen + #else + return Padding.screen + #endif + } + + private func sectionView(for section: SponsorsViewModel.Section) -> some View { + VStack(alignment: .leading, spacing: Padding.stackGap) { + sectionHeader(for: section.type) + + switch section.type { + case .platinum: + VStack(spacing: Padding.cellGap) { + ForEach(section.sponsors) { sponsor in + SponsorTileView(sponsor: sponsor) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .scale.combined(with: .opacity) + )) } - case .gold, .silver: - Section(header: sectionHeader(for: section.type)) { - grid(for: section.sponsors) - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets()) + } + case .gold, .silver: + LazyVGrid( + columns: gridColumns, + alignment: .leading, + spacing: Padding.cellGap + ) { + ForEach(section.sponsors) { sponsor in + SponsorTileView(sponsor: sponsor) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .scale.combined(with: .opacity) + )) } } } } - .padding(.top, 50) - .scrollIndicators(.hidden) - .scrollContentBackground(.hidden) - .fitToReadableContentGuide(type: .width) - .task { - try? await viewModel.loadSponsors() + .padding(.bottom, Padding.cellGap) + } + + private func sectionHeader(for sponsorLevel: SponsorLevel) -> some View { + HStack { + Image(systemName: iconForLevel(sponsorLevel)) + .font(.caption) + .foregroundColor(colorForLevel(sponsorLevel)) + + Text("\(sponsorLevel.rawValue.capitalized) Sponsors") + .font(.headline.weight(.semibold)) + .foregroundColor(.primary) + + Spacer() + + Text("\(sponsorCount(for: sponsorLevel))") + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.1)) + .clipShape(Capsule()) } + .accessibilityAddTraits(.isHeader) + .padding(.vertical, 8) } -} - -private extension SponsorsView { - func sectionHeader(for sponsorLevel: SponsorLevel) -> some View { - Text("\(sponsorLevel.rawValue.capitalized) Sponsors") - .font(.callout.weight(.semibold)) - .foregroundColor(.secondary) - .frame(maxWidth:.infinity, alignment: .leading) - .accessibilityAddTraits(.isHeader) - .textCase(nil) - .padding(.bottom, 8) + + private func iconForLevel(_ level: SponsorLevel) -> String { + switch level { + case .platinum: return "crown.fill" + case .gold: return "star.fill" + case .silver: return "star" + } } - func sponsorTile(for sponsor: Sponsor) -> some View { - SponsorTileView(sponsor: sponsor) + private func colorForLevel(_ level: SponsorLevel) -> Color { + switch level { + case .platinum: return .purple + case .gold: return .yellow + case .silver: return .gray + } } - func grid(for sponsors: [Sponsor]) -> some View { - let columns = [ - GridItem(.flexible()), GridItem(.flexible()) - ] + private func sponsorCount(for level: SponsorLevel) -> Int { + viewModel.sections.first { $0.type == level }?.sponsors.count ?? 0 + } + + private func loadSponsors() async { + do { + try await viewModel.loadSponsors() + withAnimation(.easeInOut(duration: 0.3)) { + isLoading = false + } + } catch { + withAnimation(.easeInOut(duration: 0.3)) { + isLoading = false + } + } + } +} - return LazyVGrid( - columns: columns, - alignment: .leading, - spacing: Padding.cellGap, - pinnedViews: []) { - ForEach(sponsors, id: \.self) { sponsor in - sponsorTile(for: sponsor) - } - }.foregroundColor(.clear) +struct FilterChip: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.subheadline.weight(isSelected ? .semibold : .regular)) + .foregroundColor(isSelected ? .white : .primary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + Capsule() + .fill(isSelected ? Color.accent : Color.secondary.opacity(0.1)) + ) + } + .buttonStyle(PlainButtonStyle()) } } + struct SponsorsView_Previews: PreviewProvider { static var previews: some View { - SwiftLeedsContainer { - ScrollView { - SponsorsView() - } - } + SponsorsView() } }