diff --git a/score-ios.xcodeproj/project.pbxproj b/score-ios.xcodeproj/project.pbxproj index f8a2cf3..ebe8796 100644 --- a/score-ios.xcodeproj/project.pbxproj +++ b/score-ios.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 13E348FB2F3D212D0014EC63 /* CalendarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E348FA2F3D21280014EC63 /* CalendarViewModel.swift */; }; 1C87865D2D8CD76900EBDF74 /* TrailingFadeGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C87865C2D8CD76900EBDF74 /* TrailingFadeGradient.swift */; }; 1C87865F2D8CDADC00EBDF74 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C87865E2D8CDADC00EBDF74 /* String+Extension.swift */; }; 2384C7B81B22428D94240957 /* Highlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840304A20FA141C291346BA8 /* Highlight.swift */; }; @@ -130,6 +131,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 13E348FA2F3D21280014EC63 /* CalendarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarViewModel.swift; sourceTree = ""; }; 1C87865C2D8CD76900EBDF74 /* TrailingFadeGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingFadeGradient.swift; sourceTree = ""; }; 1C87865E2D8CDADC00EBDF74 /* String+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; 2C1375CA2E7233390089EBC7 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; @@ -502,6 +504,7 @@ D87787C62CFFAE3D00EA79E1 /* ViewModels */ = { isa = PBXGroup; children = ( + 13E348FA2F3D21280014EC63 /* CalendarViewModel.swift */, D87787C72CFFAE5200EA79E1 /* GamesViewModel.swift */, 7665A4062EB00528004A9903 /* HighlightsViewModel.swift */, D864B5AA2D793A7400A3A50E /* PastGameViewModel.swift */, @@ -743,6 +746,7 @@ FD5A38DB2D8F2BDD00CF5E30 /* GameLoadingView.swift in Sources */, B136701ECD164EE9AC64667F /* Article.swift in Sources */, 64005CCECEAC4FD4BA8F51D2 /* YouTubeVideo.swift in Sources */, + 13E348FB2F3D212D0014EC63 /* CalendarViewModel.swift in Sources */, 2384C7B81B22428D94240957 /* Highlight.swift in Sources */, CE8ED4FC2D6BF47C00A274DE /* DummyData.swift in Sources */, CE335CD92C9244230037F572 /* Game.swift in Sources */, @@ -944,23 +948,26 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 26; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"score-ios/Preview Content\""; - DEVELOPMENT_TEAM = W7U2WA4D54; + DEVELOPMENT_TEAM = H5ZTDCQ89H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "score-ios/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Score; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports"; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Allow calendar access to add Cornell games that aren't in your calendar."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UIUserInterfaceStyle = Light; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 2.0.0; PRODUCT_BUNDLE_IDENTIFIER = "com.cornellappdev.score-ios"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -978,23 +985,26 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 26; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"score-ios/Preview Content\""; - DEVELOPMENT_TEAM = W7U2WA4D54; + DEVELOPMENT_TEAM = H5ZTDCQ89H; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "score-ios/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Score; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.sports"; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Allow calendar access to add Cornell games that aren't in your calendar."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UIUserInterfaceStyle = Light; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 2.0.0; PRODUCT_BUNDLE_IDENTIFIER = "com.cornellappdev.score-ios"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/score-ios/Info.plist b/score-ios/Info.plist index 5fd6b04..082f441 100644 --- a/score-ios/Info.plist +++ b/score-ios/Info.plist @@ -2,12 +2,6 @@ - UIUserInterfaceStyle - Light - UIViewControllerBasedStatusBarAppearance - - ITSAppUsesNonExemptEncryption - SCORE_DEV_URL $(SCORE_DEV_URL) SCORE_PROD_URL @@ -19,5 +13,7 @@ Poppins-Bold.ttf Poppins-Regular.ttf + UIViewControllerBasedStatusBarAppearance + diff --git a/score-ios/ViewModels/CalendarViewModel.swift b/score-ios/ViewModels/CalendarViewModel.swift new file mode 100644 index 0000000..5b9625e --- /dev/null +++ b/score-ios/ViewModels/CalendarViewModel.swift @@ -0,0 +1,94 @@ +// +// CalendarViewModel.swift +// score-ios +// +// Created by Duru Alayli on 2/11/26. +// + +import EventKit +import Foundation +import UIKit + +final class CalendarViewModel: ObservableObject{ + static let shared = CalendarViewModel() + private let eventStore = EKEventStore() + @Published var eachAlert: Alert? + + private init() {} + + struct Alert: Identifiable { + let id = UUID() + let alertTitle: String + let alertMessage: String + let openSettings: Bool + } + + func requestAccessandAdd(event: Game) { + eventStore.requestFullAccessToEvents{ [weak self] (granted, error) in + guard let self = self else { return } + if granted && error == nil { + + let title = "Cornell vs. \(event.opponent.name) \(event.sex) \(event.sport)" + let existing = self.eventStore.predicateForEvents( + withStart: event.date, + end: event.date.addingTimeInterval(7200), + calendars: [self.eventStore.defaultCalendarForNewEvents].compactMap { $0 } + ) + let existingEvents = self.eventStore.events(matching: existing) + + if existingEvents.contains(where: { $0.title == title && $0.startDate == event.date }) { + DispatchQueue.main.async { + self.showCalendarAlert ( + alertTitle: "Game already added.", + alertMessage: "This game is already added to your calendar." + ) + } + return + } + + let calendarEvent = EKEvent(eventStore: self.eventStore) + calendarEvent.title = title + calendarEvent.startDate = event.date + calendarEvent.endDate = event.date.addingTimeInterval(7200) + calendarEvent.location = event.address + calendarEvent.calendar = self.eventStore.defaultCalendarForNewEvents + + do { + try eventStore.save(calendarEvent, span: .thisEvent) + DispatchQueue.main.async { + if let url = URL(string: "calshow:\(event.date.timeIntervalSinceReferenceDate)") { + UIApplication.shared.open(url) + } + } + } catch { + DispatchQueue.main.async { + self.showCalendarAlert ( + alertTitle: "Game can't be added.", + alertMessage: "There was an error adding Cornell vs. \(event.opponent.name) to your calendar." + ) + } + } + } else { + DispatchQueue.main.async { + self.showCalendarAlert ( + alertTitle: "Game can't be added.", + alertMessage: "Calendar access denied. Please enable full calendar access in Settings.", + openSettings: true + ) + } + } + } + } + + private func showCalendarAlert( + alertTitle: String, + alertMessage: String, + openSettings: Bool = false + ) { + self.eachAlert = Alert( + alertTitle: alertTitle, + alertMessage: alertMessage, + openSettings: openSettings + ) + } +} diff --git a/score-ios/ViewModels/GamesViewModel.swift b/score-ios/ViewModels/GamesViewModel.swift index e9cc9aa..92173a7 100644 --- a/score-ios/ViewModels/GamesViewModel.swift +++ b/score-ios/ViewModels/GamesViewModel.swift @@ -144,7 +144,7 @@ class GamesViewModel: ObservableObject // TODO: Remove once backend is has implemented pagination with sorted dates and pages by game type func fetchGames() { // Set loading state before fetch - dataState = (hasNotFetchedYet ? .loading : .refreshing) + dataState = (dataState == .success ? .refreshing : .loading) self.privateUpcomingGames.removeAll() self.privatePastGames.removeAll() @@ -209,13 +209,12 @@ class GamesViewModel: ObservableObject } guard let fetchedGames = fetchedGames, !fetchedGames.isEmpty else { - // If this is the first fetch and no games, show empty data error if offset == 0 { + // First page returned empty —> no games self.dataState = .error(error: .emptyData) } else { -// // Otherwise process all accumulated games -// self.processGames(accumulatedGames) - self.dataState = .error(error: .networkError) + // Process what we have + self.processGames(accumulatedGames) } return } diff --git a/score-ios/ViewModels/HighlightsViewModel.swift b/score-ios/ViewModels/HighlightsViewModel.swift index 253156c..a6e746f 100644 --- a/score-ios/ViewModels/HighlightsViewModel.swift +++ b/score-ios/ViewModels/HighlightsViewModel.swift @@ -38,7 +38,7 @@ class HighlightsViewModel: ObservableObject { // MARK: - Loading func loadHighlights() { - dataState = (hasNotFetchedYet ? .loading : .refreshing) + dataState = (dataState == .success ? .refreshing : .loading) Task { do { diff --git a/score-ios/Views/DetailedViews/GameView.swift b/score-ios/Views/DetailedViews/GameView.swift index dcbba14..2558879 100644 --- a/score-ios/Views/DetailedViews/GameView.swift +++ b/score-ios/Views/DetailedViews/GameView.swift @@ -10,6 +10,7 @@ import SwiftUI struct GameView : View { var game : Game @ObservedObject var viewModel: PastGameViewModel + @ObservedObject var calendarViewModel = CalendarViewModel.shared @State var viewState: Int = 0 @State var dayFromNow: Int = 0 @State var hourFromNow: Int = 0 @@ -119,14 +120,14 @@ extension GameView { Text("\(game.sex.description) \(game.sport.description)") .font(Constants.Fonts.subheader) .foregroundStyle(Constants.Colors.black) - + ScrollView(.horizontal, showsIndicators: false){ Text("Cornell vs. " + game.opponent.name.removingUniversityPrefix()) .font(Constants.Fonts.header) .foregroundStyle(Constants.Colors.black) } .withTrailingFadeGradient() - + HStack(spacing: 10) { HStack { Image("Location-g") @@ -176,25 +177,50 @@ extension GameView { .padding(.top, 8) } .padding(.top, 20) - - // Ticketing Link Button - if let link = game.ticketLink, - let url = URL(string: link) { + HStack (spacing: 16){ + // Ticketing Link Button + if let link = game.ticketLink, + let url = URL(string: link) { + Button(action: { + UIApplication.shared.open(url) + }) { + HStack (spacing: 9){ + Image("Ticket") + .resizable() + .frame(width: 22, height: 22) + Text("Buy Tickets") + .foregroundStyle(Constants.Colors.white) + .font(.system(size: 16, weight: .medium)) + .font(Constants.Fonts.buttonLabel) + } + .foregroundColor(.white) + .padding(12) + .background( + Constants.Colors.primary_red + ) + .overlay( + RoundedRectangle(cornerRadius: 30) + .stroke(Color.black.opacity(0.1), lineWidth: 1) + .shadow(color: Color.black.opacity(0.25), radius: 5, x: 0, y: 2) + ) + .clipShape(RoundedRectangle(cornerRadius: 30)) + } + } + + // Calendar Button Button(action: { - UIApplication.shared.open(url) + calendarViewModel.requestAccessandAdd(event:game) }) { - HStack { - Image("Ticket") + HStack (spacing: 8){ + Image("Calendar") .resizable() - .frame(width: 25, height: 25) - Text("Buy Tickets") - .foregroundStyle(Constants.Colors.white) - .font(.system(size: 16, weight: .medium)) + .frame(width: 24, height: 24) + Text("Add to Calendar") .font(Constants.Fonts.buttonLabel) + .foregroundStyle(Constants.Colors.white) } .foregroundColor(.white) - .padding(.horizontal, 20) - .padding(.vertical, 15) + .padding(12) .background( Constants.Colors.primary_red ) @@ -203,41 +229,33 @@ extension GameView { .stroke(Color.black.opacity(0.1), lineWidth: 1) .shadow(color: Color.black.opacity(0.25), radius: 5, x: 0, y: 2) ) - .clipShape(RoundedRectangle(cornerRadius: 30)) // Clip to shape to ensure rounded corners + .clipShape(RoundedRectangle(cornerRadius: 30)) + } + .alert(item: $calendarViewModel.eachAlert) { alert in + if alert.openSettings { + return Alert ( + title: Text(alert.alertTitle), + message: Text(alert.alertMessage), + primaryButton: .default(Text("Go to settings")) { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }, + secondaryButton: .cancel() + ) + } else { + return Alert ( + title: Text(alert.alertTitle), + message: Text(alert.alertMessage) + ) + } } - .padding(.top, 80) } - - // Calendar Button -// TODO: make this back when we have login -// Button(action: { -// // TODO: action -// }) { -// HStack { -// Image("Calendar") -// .resizable() -// .frame(width: 24, height: 24) -// Text("Add to Calendar") -// .font(Constants.Fonts.buttonLabel) -// .foregroundStyle(Constants.Colors.white) -// } -// .foregroundColor(.white) -// .padding(.horizontal, 16) -// .padding(.vertical, 10) -// .background( -// Constants.Colors.primary_red -// ) -// .overlay( -// RoundedRectangle(cornerRadius: 30) -// .stroke(Color.black.opacity(0.1), lineWidth: 1) -// .shadow(color: Color.black.opacity(0.25), radius: 5, x: 0, y: 2) -// ) -// .clipShape(RoundedRectangle(cornerRadius: 30)) // Clip to shape to ensure rounded corners -// } -// .padding(.top, 68) -// } + .padding(.top, 80) } + } + private var summaryTab: some View { NavigationLink(destination: ScoringSummary(game: game)) { diff --git a/score-ios/Views/ListViews/GameErrorView.swift b/score-ios/Views/ListViews/GameErrorView.swift index 4e79952..e41cf68 100644 --- a/score-ios/Views/ListViews/GameErrorView.swift +++ b/score-ios/Views/ListViews/GameErrorView.swift @@ -10,7 +10,8 @@ import SwiftUI struct GameErrorView: View { - @ObservedObject var viewModel: GamesViewModel + let message: String + let onRetry: () -> Void var body: some View { ZStack { @@ -25,7 +26,7 @@ struct GameErrorView: View { .frame(width: 64, height: 64) .padding(.bottom, 16) - Text("Oops! Schedules failed to load.") + Text("Oops! \(message) failed to load.") .font(Constants.Fonts.Header.h2) .padding(.bottom, 8) @@ -35,7 +36,7 @@ struct GameErrorView: View { Spacer() Button { - viewModel.fetchGames() + onRetry() } label: { HStack { Image(systemName: "arrow.trianglehead.2.clockwise") diff --git a/score-ios/Views/ListViews/HighlightTileArticle.swift b/score-ios/Views/ListViews/HighlightTileArticle.swift index 16b7354..5f1b437 100644 --- a/score-ios/Views/ListViews/HighlightTileArticle.swift +++ b/score-ios/Views/ListViews/HighlightTileArticle.swift @@ -18,28 +18,15 @@ struct HighlightTileArticle: View { // Background Image with dark overlay AsyncImage(url: URL(string: article.image)) { phase in switch phase { - case .empty: - Rectangle() - .fill(Constants.Colors.gray_icons.opacity(0.2)) - .overlay(Color.black.opacity(0.60)) // dark tint case .success(let image): image .resizable() .scaledToFill() .overlay(Color.black.opacity(0.60)) // dark tint - case .failure(_): + default: Rectangle() - .fill(Constants.Colors.gray_icons.opacity(0.3)) - .overlay( - Image(systemName: "photo") - .resizable() - .scaledToFit() - .foregroundColor(.white.opacity(0.7)) - .padding() - .overlay(Color.black.opacity(0.60)) // dark tint - ) - @unknown default: - EmptyView() + .fill(Constants.Colors.gray_icons.opacity(0.2)) + .overlay(Color.black.opacity(0.60)) // dark tint } } .frame(width: width, height: 192) diff --git a/score-ios/Views/ListViews/HighlightTileVideo.swift b/score-ios/Views/ListViews/HighlightTileVideo.swift index ee3f5c2..b169fec 100644 --- a/score-ios/Views/ListViews/HighlightTileVideo.swift +++ b/score-ios/Views/ListViews/HighlightTileVideo.swift @@ -18,22 +18,15 @@ struct HighlightTileVideo: View { // Thumbnail AsyncImage(url: URL(string: video.thumbnail)) { phase in switch phase { - case .empty: - // While loading - Rectangle() - .fill(Constants.Colors.gray_icons.opacity(0.2)) case .success(let image): image .resizable() .scaledToFill() - case .failure(_): - // If loading fails - Image(systemName: "photo") - .resizable() - .scaledToFit() - .foregroundColor(.gray) - @unknown default: - EmptyView() + .overlay(Color.black.opacity(0.60)) // dark tint + default: + Rectangle() + .fill(Constants.Colors.gray_icons.opacity(0.2)) + .overlay(Color.black.opacity(0.60)) // dark tint } } .frame(width: width, height: 117) diff --git a/score-ios/Views/ListViews/HighlightView.swift b/score-ios/Views/ListViews/HighlightView.swift index 6299c86..7ac828f 100644 --- a/score-ios/Views/ListViews/HighlightView.swift +++ b/score-ios/Views/ListViews/HighlightView.swift @@ -16,7 +16,10 @@ struct HighlightView: View { switch viewModel.dataState { case .idle, .loading: HighlightLoadingView() - + + case .error: + GameErrorView(message: "Highlights", onRetry: { viewModel.loadHighlights() }) + default: VStack{ headerView diff --git a/score-ios/Views/ListViews/PastGamesView.swift b/score-ios/Views/ListViews/PastGamesView.swift index 6e855c1..3dd31bb 100644 --- a/score-ios/Views/ListViews/PastGamesView.swift +++ b/score-ios/Views/ListViews/PastGamesView.swift @@ -68,7 +68,7 @@ struct PastGamesView: View { } if case .error = vm.dataState { - GameErrorView(viewModel: vm) + GameErrorView(message: "Schedules", onRetry: { vm.fetchGames() }) } } } diff --git a/score-ios/Views/ListViews/UpcomingGamesView.swift b/score-ios/Views/ListViews/UpcomingGamesView.swift index b6061ac..af673fb 100644 --- a/score-ios/Views/ListViews/UpcomingGamesView.swift +++ b/score-ios/Views/ListViews/UpcomingGamesView.swift @@ -71,7 +71,7 @@ struct UpcomingGamesView: View { } if case .error = vm.dataState { - GameErrorView(viewModel: vm) + GameErrorView(message: "Schedules", onRetry: { vm.fetchGames() }) } } }