diff --git a/score-ios.xcodeproj/project.pbxproj b/score-ios.xcodeproj/project.pbxproj index f382538..a973a17 100644 --- a/score-ios.xcodeproj/project.pbxproj +++ b/score-ios.xcodeproj/project.pbxproj @@ -25,8 +25,11 @@ 7626AD6B2E973D9B002149CD /* HighlightTileArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7626AD652E973D9B002149CD /* HighlightTileArticle.swift */; }; 7626AD6C2E973D9B002149CD /* HighlightTileVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7626AD662E973D9B002149CD /* HighlightTileVideo.swift */; }; 7626AD6F2E973E08002149CD /* View+CornerRadius.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7626AD6E2E973E08002149CD /* View+CornerRadius.swift */; }; + 7665A4072EB00531004A9903 /* HighlightsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7665A4062EB00528004A9903 /* HighlightsViewModel.swift */; }; + 7675D0932EBC0F1D00940292 /* NoHighlightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7675D0922EBC0F1700940292 /* NoHighlightView.swift */; }; + 76AED8512EC50B7F00694C0B /* YoutubeVideo.graphql in Resources */ = {isa = PBXBuildFile; fileRef = 76AED8502EC50B7100694C0B /* YoutubeVideo.graphql */; }; + 76AED8532EC50C2700694C0B /* Article.graphql in Resources */ = {isa = PBXBuildFile; fileRef = 76AED8522EC50C2100694C0B /* Article.graphql */; }; 76D998E92E9F1AF900713EE5 /* SearchViewFullScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76D998E82E9F1AF300713EE5 /* SearchViewFullScreen.swift */; }; - 76E679212E9FF6A100C39132 /* NoHighlightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76E679202E9FF69C00C39132 /* NoHighlightView.swift */; }; B136701ECD164EE9AC64667F /* Article.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CB4DBAE237D47AB882D4EBC /* Article.swift */; }; CE335CD32C922E8D0037F572 /* PrimaryColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE335CD22C922E8D0037F572 /* PrimaryColors.swift */; }; CE335CD52C922ECB0037F572 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE335CD42C922ECB0037F572 /* Constants.swift */; }; @@ -137,8 +140,11 @@ 7626AD662E973D9B002149CD /* HighlightTileVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightTileVideo.swift; sourceTree = ""; }; 7626AD672E973D9B002149CD /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; 7626AD6E2E973E08002149CD /* View+CornerRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+CornerRadius.swift"; sourceTree = ""; }; + 7665A4062EB00528004A9903 /* HighlightsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightsViewModel.swift; sourceTree = ""; }; + 7675D0922EBC0F1700940292 /* NoHighlightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoHighlightView.swift; sourceTree = ""; }; + 76AED8502EC50B7100694C0B /* YoutubeVideo.graphql */ = {isa = PBXFileReference; lastKnownFileType = text; path = YoutubeVideo.graphql; sourceTree = ""; }; + 76AED8522EC50C2100694C0B /* Article.graphql */ = {isa = PBXFileReference; lastKnownFileType = text; path = Article.graphql; sourceTree = ""; }; 76D998E82E9F1AF300713EE5 /* SearchViewFullScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewFullScreen.swift; sourceTree = ""; }; - 76E679202E9FF69C00C39132 /* NoHighlightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoHighlightView.swift; sourceTree = ""; }; 840304A20FA141C291346BA8 /* Highlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Highlight.swift; sourceTree = ""; }; CE335CD22C922E8D0037F572 /* PrimaryColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryColors.swift; sourceTree = ""; }; CE335CD42C922ECB0037F572 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; @@ -431,7 +437,7 @@ D86347AE2CDBD2F3003DD8F6 /* PastGameCard.swift */, CE528FF62C979DA000C238B5 /* GameView.swift */, D836AD912CB62C8800BD1545 /* NoGameView.swift */, - 76E679202E9FF69C00C39132 /* NoHighlightView.swift */, + 7675D0922EBC0F1700940292 /* NoHighlightView.swift */, D87882272CC060FC00421F67 /* GameDetailedScoreView.swift */, CE3C9C402D010177008BFB4C /* ScoringSummary.swift */, ); @@ -491,6 +497,7 @@ isa = PBXGroup; children = ( D87787C72CFFAE5200EA79E1 /* GamesViewModel.swift */, + 7665A4062EB00528004A9903 /* HighlightsViewModel.swift */, D864B5AA2D793A7400A3A50E /* PastGameViewModel.swift */, ); path = ViewModels; @@ -508,6 +515,8 @@ children = ( D8DD4E632CFD48E400F2C46E /* Team.graphql */, D891020A2CED6A86004CE226 /* Game.graphql */, + 76AED8522EC50C2100694C0B /* Article.graphql */, + 76AED8502EC50B7100694C0B /* YoutubeVideo.graphql */, D89102062CED6A28004CE226 /* schema.graphqls */, ); path = GraphQL; @@ -615,7 +624,7 @@ packageReferences = ( D89102012CED68D9004CE226 /* XCRemoteSwiftPackageReference "apollo-ios" */, D0A904F32E8DDD990008194B /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, - 76101F682E9743EC006D6EDD /* XCLocalSwiftPackageReference "gameAPI" */, + 761050E32EC50DCC00FD73F8 /* XCLocalSwiftPackageReference "gameAPI" */, ); productRefGroup = CE725D392C89120200386943 /* Products */; projectDirPath = ""; @@ -636,9 +645,11 @@ D8DD4E642CFD48ED00F2C46E /* Team.graphql in Resources */, D891020B2CED6A8E004CE226 /* Game.graphql in Resources */, 2C1375CB2E7233390089EBC7 /* GoogleService-Info.plist in Resources */, + 76AED8512EC50B7F00694C0B /* YoutubeVideo.graphql in Resources */, CE528FE42C96A27500C238B5 /* Poppins-Light.ttf in Resources */, CE528FEE2C96A27500C238B5 /* Poppins-Black.ttf in Resources */, CE528FEC2C96A27500C238B5 /* Poppins-LightItalic.ttf in Resources */, + 76AED8532EC50C2700694C0B /* Article.graphql in Resources */, CE528FEF2C96A27500C238B5 /* Poppins-Thin.ttf in Resources */, CE528FE02C96A27500C238B5 /* Poppins-ExtraLight.ttf in Resources */, CE528FF02C96A27500C238B5 /* Poppins-SemiBold.ttf in Resources */, @@ -749,16 +760,17 @@ FD27F4232DC0A68900CC172E /* GamesCacheManager.swift in Sources */, CE725D3C2C89120200386943 /* Home.swift in Sources */, FD5A38DF2D8F3E1400CF5E30 /* ShimmerModifier.swift in Sources */, - 76E679212E9FF6A100C39132 /* NoHighlightView.swift in Sources */, CE528FA02C96420700C238B5 /* PickerView.swift in Sources */, CE528FA42C9653C200C238B5 /* Error.swift in Sources */, CE335CD52C922ECB0037F572 /* Constants.swift in Sources */, CE8ED4FE2D6BF49E00A274DE /* GameUpdate.swift in Sources */, + 7665A4072EB00531004A9903 /* HighlightsViewModel.swift in Sources */, D86347E12CE98D37003DD8F6 /* TabViewIcon.swift in Sources */, CE3C9C412D010177008BFB4C /* ScoringSummary.swift in Sources */, CE8ED50E2D6C3B8000A274DE /* SportSelectorView.swift in Sources */, CE8ED5082D6C36E200A274DE /* GameListView.swift in Sources */, CE8ED5142D6C42D400A274DE /* GameSectionHeaderView.swift in Sources */, + 7675D0932EBC0F1D00940292 /* NoHighlightView.swift in Sources */, D86347B12CDBFF7C003DD8F6 /* UpcomingGamesView.swift in Sources */, D86347DF2CE98B3C003DD8F6 /* MainTabView.swift in Sources */, 1C87865F2D8CDADC00EBDF74 /* String+Extension.swift in Sources */, @@ -1099,7 +1111,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 76101F682E9743EC006D6EDD /* XCLocalSwiftPackageReference "gameAPI" */ = { + 761050E32EC50DCC00FD73F8 /* XCLocalSwiftPackageReference "gameAPI" */ = { isa = XCLocalSwiftPackageReference; relativePath = gameAPI; }; diff --git a/score-ios/Models/Article.swift b/score-ios/Models/Article.swift index b5387c0..198236b 100644 --- a/score-ios/Models/Article.swift +++ b/score-ios/Models/Article.swift @@ -6,15 +6,15 @@ // import Foundation +import GameAPI struct Article: Identifiable { var id: String var title: String - var summary: String var image: String var url: String - var source: String var publishedAt: String + var sport: Sport var formattedDate: String { if let date = ISO8601DateFormatter().date(from: publishedAt) { @@ -24,46 +24,13 @@ struct Article: Identifiable { } return publishedAt } -} - -// MARK: - Dummy Data -extension Article { - static let dummyData: [Article] = [ - Article( - id: "1", - title: "Cornell Upsets Rival in Thrilling Overtime Victory", - summary: "Cornell's offense exploded late in the fourth quarter to secure a dramatic win.", - image: "https://snworksceo.imgix.net/cds/2f1df221-010c-4a5b-94cc-ec7a100b7aa1.sized-1000x1000.jpg?w=1000&dpr=2", - url: "https://cornellbigred.com/news/2025/10/08/article", - source: "Cornell Daily Sun", - publishedAt: "2025-10-08T00:00:00Z" - ), - Article( - id: "2", - title: "Cornell Daily Sun Reports Historic Win", - summary: "Cornell's offense shines in a big win.", - image: "https://snworksceo.imgix.net/cds/2f1df221-010c-4a5b-94cc-ec7a100b7aa1.sized-1000x1000.jpg?w=1000&dpr=2", - url: "https://cornellsun.com/article", - source: "Cornell Daily Sun", - publishedAt: "2025-10-09T00:00:00Z" - ), - Article( - id: "3", - title: "Big Red Basketball Team Advances to Championship", - summary: "Cornell basketball team secures spot in the championship game with dominant performance.", - image: "https://snworksceo.imgix.net/cds/2f1df221-010c-4a5b-94cc-ec7a100b7aa1.sized-1000x1000.jpg?w=1000&dpr=2", - url: "https://cornellsun.com/basketball-championship", - source: "Cornell Daily Sun", - publishedAt: "2025-10-10T00:00:00Z" - ), - Article( - id: "4", - title: "Hockey Team Prepares for Rivalry Game", - summary: "Cornell hockey team gears up for the highly anticipated rivalry matchup.", - image: "https://snworksceo.imgix.net/cds/2f1df221-010c-4a5b-94cc-ec7a100b7aa1.sized-1000x1000.jpg?w=1000&dpr=2", - url: "https://cornellsun.com/hockey-rivalry", - source: "Cornell Daily Sun", - publishedAt: "2025-10-11T00:00:00Z" - ) - ] + + init(from gqlArticle: ArticlesQuery.Data.Article) { + self.id = gqlArticle.id ?? UUID().uuidString + self.image = gqlArticle.image ?? "" + self.title = gqlArticle.title + self.url = gqlArticle.url + self.publishedAt = gqlArticle.publishedAt + self.sport = Sport(normalizedValue: gqlArticle.sportsType) ?? .All + } } diff --git a/score-ios/Models/GraphQL/Article.graphql b/score-ios/Models/GraphQL/Article.graphql new file mode 100644 index 0000000..2806293 --- /dev/null +++ b/score-ios/Models/GraphQL/Article.graphql @@ -0,0 +1,10 @@ +query Articles($sportsType: String) { + articles(sportsType: $sportsType) { + id + title + image + sportsType + publishedAt + url + } +} diff --git a/score-ios/Models/GraphQL/YoutubeVideo.graphql b/score-ios/Models/GraphQL/YoutubeVideo.graphql new file mode 100644 index 0000000..e8d602b --- /dev/null +++ b/score-ios/Models/GraphQL/YoutubeVideo.graphql @@ -0,0 +1,13 @@ +query YoutubeVideos { + youtubeVideos { + id + title + description + thumbnail + b64Thumbnail + url + publishedAt + duration + sportsType + } +} diff --git a/score-ios/Models/GraphQL/schema.graphqls b/score-ios/Models/GraphQL/schema.graphqls index 9bacad1..a02c9e1 100644 --- a/score-ios/Models/GraphQL/schema.graphqls +++ b/score-ios/Models/GraphQL/schema.graphqls @@ -74,6 +74,7 @@ Attributes: - url: The URL to the video. - published_at: The date and time the video was published. - duration: The duration of the video (optional). + - sportsType: The sport type extracted from the video title. """ type YoutubeVideoType { id: String @@ -84,6 +85,7 @@ type YoutubeVideoType { url: String! publishedAt: String! duration: String + sportsType: String } """ @@ -201,4 +203,4 @@ type CreateYoutubeVideo { type CreateArticle { article: ArticleType -} \ No newline at end of file +} diff --git a/score-ios/Models/Highlight.swift b/score-ios/Models/Highlight.swift index 586309f..7ce71c7 100644 --- a/score-ios/Models/Highlight.swift +++ b/score-ios/Models/Highlight.swift @@ -29,84 +29,22 @@ enum Highlight: Identifiable { } } - var title: String{ - switch self{ + var title: String { + switch self { case .article(let article): return article.title case .video(let video): return video.title } } + + var sport: Sport { + switch self { + case .article(let article): + return article.sport + case .video(let video): + return video.sport + } + } } -// MARK: - Dummy Data -extension Highlight { - static let dummyData: [Highlight] = [ - .video( - YouTubeVideo( - id: "QGHb9heJAco", - title: "Cornell Celebrates Coach Mike Schafer '86", - description: "Cornell Celebrates Coach Mike Schafer '86 Narrated by Jeremy Schaap '91.", - thumbnail: "https://i.ytimg.com/vi/QGHb9heJAco/hqdefault.jpg", - b64Thumbnail: nil, - url: "https://youtube.com/watch?v=QGHb9heJAco", - publishedAt: "2025-10-014T00:00:00Z" - ) - ), - .article( - Article( - id: "1", - title: "Cornell Daily Sun Reports Historic Win", - summary: "Cornell's offense shines in a big win.", - image: "https://snworksceo.imgix.net/cds/2f1df221-010c-4a5b-94cc-ec7a100b7aa1.sized-1000x1000.jpg?w=1000&dpr=2", - url: "https://cornellsun.com/article", - source: "Cornell Daily Sun", - publishedAt: "2025-10-14T00:00:00Z" - ) - ), - .video( - YouTubeVideo( - id: "ABC123def", - title: "Cornell Basketball Highlights - Championship Game", - description: "Watch the best moments from Cornell's championship victory.", - thumbnail: "https://i.ytimg.com/vi/ABC123def/hqdefault.jpg", - b64Thumbnail: nil, - url: "https://youtube.com/watch?v=ABC123def", - publishedAt: "2025-10-013T00:00:00Z" - ) - ), - .article( - Article( - id: "2", - title: "Cornell Upsets Rival in Thrilling Overtime Victory", - summary: "Cornell's offense exploded late in the fourth quarter to secure a dramatic win.", - image: "https://snworksceo.imgix.net/cds/2f1df221-010c-4a5b-94cc-ec7a100b7aa1.sized-1000x1000.jpg?w=1000&dpr=2", - url: "https://cornellbigred.com/news/2025/10/08/article", - source: "Cornell Daily Sun", - publishedAt: "2025-10-14T00:00:00Z" - ) - ), - .video( - YouTubeVideo( - id: "XYZ789ghi", - title: "Cornell Hockey Rivalry Game Recap", - description: "Complete recap of the intense rivalry game between Cornell and their arch-rivals.", - thumbnail: "https://i.ytimg.com/vi/XYZ789ghi/hqdefault.jpg", - b64Thumbnail: nil, - url: "https://youtube.com/watch?v=XYZ789ghi", - publishedAt: "2025-10-14T00:00:00Z" - ) - ), - .article( - Article( - id: "3", - title: "Big Red Basketball Team Advances to Championship", - summary: "Cornell basketball team secures spot in the championship game with dominant performance.", - image: "https://snworksceo.imgix.net/cds/2f1df221-010c-4a5b-94cc-ec7a100b7aa1.sized-1000x1000.jpg?w=1000&dpr=2", - url: "https://cornellsun.com/basketball-championship", - source: "Cornell Daily Sun", - publishedAt: "2025-10-13T00:00:00Z" - ) - ) - ] -} diff --git a/score-ios/Models/YouTubeVideo.swift b/score-ios/Models/YouTubeVideo.swift index e855d28..8d54361 100644 --- a/score-ios/Models/YouTubeVideo.swift +++ b/score-ios/Models/YouTubeVideo.swift @@ -6,6 +6,7 @@ // import Foundation +import GameAPI struct YouTubeVideo: Identifiable { var id: String @@ -15,6 +16,8 @@ struct YouTubeVideo: Identifiable { var b64Thumbnail: String? var url: String var publishedAt: String + var sport: Sport + var duration: String? // Format publishedAt -> MM/dd or similar var formattedDate: String { @@ -25,46 +28,16 @@ struct YouTubeVideo: Identifiable { } return publishedAt } -} - -// MARK: - Dummy Data -extension YouTubeVideo { - static let dummyData: [YouTubeVideo] = [ - YouTubeVideo( - id: "QGHb9heJAco", - title: "Cornell Celebrates Coach Mike Schafer '86", - description: "Cornell Celebrates Coach Mike Schafer '86 Narrated by Jeremy Schaap '91.", - thumbnail: "https://i.ytimg.com/vi/QGHb9heJAco/hqdefault.jpg", - b64Thumbnail: nil, - url: "https://youtube.com/watch?v=QGHb9heJAco", - publishedAt: "2025-10-09T00:00:00Z" - ), - YouTubeVideo( - id: "ABC123def", - title: "Cornell Basketball Highlights - Championship Game", - description: "Watch the best moments from Cornell's championship victory.", - thumbnail: "https://i.ytimg.com/vi/ABC123def/hqdefault.jpg", - b64Thumbnail: nil, - url: "https://youtube.com/watch?v=ABC123def", - publishedAt: "2025-10-08T00:00:00Z" - ), - YouTubeVideo( - id: "XYZ789ghi", - title: "Cornell Hockey Rivalry Game Recap", - description: "Complete recap of the intense rivalry game between Cornell and their arch-rivals.", - thumbnail: "https://i.ytimg.com/vi/XYZ789ghi/hqdefault.jpg", - b64Thumbnail: nil, - url: "https://youtube.com/watch?v=XYZ789ghi", - publishedAt: "2025-10-10T00:00:00Z" - ), - YouTubeVideo( - id: "DEF456jkl", - title: "Cornell Football Season Highlights", - description: "Best plays and moments from Cornell's football season.", - thumbnail: "https://i.ytimg.com/vi/DEF456jkl/hqdefault.jpg", - b64Thumbnail: nil, - url: "https://youtube.com/watch?v=DEF456jkl", - publishedAt: "2025-10-11T00:00:00Z" - ) - ] + + init(from gqlYouTubeVideo: YoutubeVideosQuery.Data.YoutubeVideo) { + self.id = gqlYouTubeVideo.id ?? UUID().uuidString + self.title = gqlYouTubeVideo.title + self.description = gqlYouTubeVideo.description + self.thumbnail = gqlYouTubeVideo.thumbnail + self.b64Thumbnail = gqlYouTubeVideo.b64Thumbnail + self.url = gqlYouTubeVideo.url + self.publishedAt = gqlYouTubeVideo.publishedAt + self.sport = Sport(normalizedValue: gqlYouTubeVideo.sportsType ?? "All") ?? .All + self.duration = gqlYouTubeVideo.duration + } } diff --git a/score-ios/Networking/NetworkManager.swift b/score-ios/Networking/NetworkManager.swift index 57bace5..b40db61 100644 --- a/score-ios/Networking/NetworkManager.swift +++ b/score-ios/Networking/NetworkManager.swift @@ -46,5 +46,64 @@ class NetworkManager { } } } + + func fetchArticles(completion: @escaping ([ArticlesQuery.Data.Article]?, Error?) -> Void) { + let query = ArticlesQuery(sportsType: nil) + + apolloClient.fetch(query: query) { result in + switch result { + case .success(let graphQLResult): + if let articlesData = graphQLResult.data?.articles?.compactMap({ $0 }) { + completion(articlesData, nil) + } else if let errors = graphQLResult.errors { + let errorDescription = errors.map { $0.localizedDescription }.joined(separator: "\n") + completion(nil, NSError(domain: "GraphQL", code: 0, userInfo: [NSLocalizedDescriptionKey: errorDescription])) + } + case .failure(let error): + completion(nil, error) + } + } + } + + func fetchYouTubeVideos(completion: @escaping ([YoutubeVideosQuery.Data.YoutubeVideo]?, Error?) -> Void) { + let query = YoutubeVideosQuery() + + apolloClient.fetch(query: query) { result in + switch result { + case .success(let graphQLResult): + if let youTubeVideoData = graphQLResult.data?.youtubeVideos?.compactMap({ $0 }) { + completion(youTubeVideoData, nil) + } else if let errors = graphQLResult.errors { + let errorDescription = errors.map { $0.localizedDescription }.joined(separator: "\n") + completion(nil, NSError(domain: "GraphQL", code: 0, userInfo: [NSLocalizedDescriptionKey: errorDescription])) + } + case .failure(let error): + completion(nil, error) + } + } + } + + func fetchArticles() async throws -> [ArticlesQuery.Data.Article] { + try await withCheckedThrowingContinuation { continuation in + fetchArticles { articles, error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: articles ?? []) + } + } + } + } + func fetchYouTubeVideos() async throws -> [YoutubeVideosQuery.Data.YoutubeVideo] { + try await withCheckedThrowingContinuation { continuation in + fetchYouTubeVideos { videos, error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: videos ?? []) + } + } + } + } } diff --git a/score-ios/Resources/Assets.xcassets/HighlightStar.imageset/Contents.json b/score-ios/Resources/Assets.xcassets/HighlightStar.imageset/Contents.json new file mode 100644 index 0000000..a999c7e --- /dev/null +++ b/score-ios/Resources/Assets.xcassets/HighlightStar.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "HighlightStar.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "HighlightStarx2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "HighlightStarx3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/score-ios/Resources/Assets.xcassets/HighlightStar.imageset/HighlightStar.png b/score-ios/Resources/Assets.xcassets/HighlightStar.imageset/HighlightStar.png new file mode 100644 index 0000000..7e7ee81 Binary files /dev/null and b/score-ios/Resources/Assets.xcassets/HighlightStar.imageset/HighlightStar.png differ diff --git a/score-ios/Resources/Assets.xcassets/HighlightStar.imageset/HighlightStarx2.png b/score-ios/Resources/Assets.xcassets/HighlightStar.imageset/HighlightStarx2.png new file mode 100644 index 0000000..669fdcf Binary files /dev/null and b/score-ios/Resources/Assets.xcassets/HighlightStar.imageset/HighlightStarx2.png differ diff --git a/score-ios/Resources/Assets.xcassets/HighlightStar.imageset/HighlightStarx3.png b/score-ios/Resources/Assets.xcassets/HighlightStar.imageset/HighlightStarx3.png new file mode 100644 index 0000000..d7dffeb Binary files /dev/null and b/score-ios/Resources/Assets.xcassets/HighlightStar.imageset/HighlightStarx3.png differ diff --git a/score-ios/Resources/Assets.xcassets/Image.imageset/Contents.json b/score-ios/Resources/Assets.xcassets/Image.imageset/Contents.json new file mode 100644 index 0000000..a19a549 --- /dev/null +++ b/score-ios/Resources/Assets.xcassets/Image.imageset/Contents.json @@ -0,0 +1,20 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/score-ios/Utils/Dates.swift b/score-ios/Utils/Dates.swift index 6dd25f4..352324b 100644 --- a/score-ios/Utils/Dates.swift +++ b/score-ios/Utils/Dates.swift @@ -39,13 +39,32 @@ extension Date { return calendar.date(from: components) ?? Date() } + /// Formatter for "yyyy-MM-dd'T'HH:mm:ssXXXXX" strings + static var highlightDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX" // ISO 8601 format + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + return formatter + }() + + /// Checks if a date is any time today. + static func isToday(_ date: Date) -> Bool { + Calendar.current.isDateInToday(date) + } + // Return true if 'date' is within 'days' from today static func isWithinPastDays(_ date: Date, days: Int) -> Bool { - guard let pastDate = Calendar.current.date(byAdding: .day, value: -days, to: Date()) else { return false } + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: Date()) - return date >= pastDate && date < Calendar.current.startOfDay(for: Date()) + guard let pastDate = calendar.date(byAdding: .day, value: -days, to: startOfToday) else { + return false + } + + return date >= pastDate && date < startOfToday } - + static func parseDate(dateString: String, timeString: String) -> Date { // Set up date formatter let dateFormatter = DateFormatter() diff --git a/score-ios/ViewModels/HighlightsViewModel.swift b/score-ios/ViewModels/HighlightsViewModel.swift new file mode 100644 index 0000000..73a51d3 --- /dev/null +++ b/score-ios/ViewModels/HighlightsViewModel.swift @@ -0,0 +1,188 @@ +// +// HighlightsViewModel.swift +// score-ios +// +// Created by Zain Bilal on 10/27/25. +// + +import Foundation +import SwiftUI +import GameAPI + +@MainActor +class HighlightsViewModel: ObservableObject { + // MARK: - Published Properties + @Published var dataState: DataState = .idle + + @Published var allHighlights: [Highlight] = [] + @Published var mainTodayHighlights: [Highlight] = [] + @Published var mainPastThreeDaysHighlights: [Highlight] = [] + @Published var detailedTodayHighlights: [Highlight] = [] + @Published var detailedPastThreeDaysHighlights: [Highlight] = [] + @Published var allHighlightsSearchResults: [Highlight] = [] + + @Published var searchQuery: String = "" + @Published var selectedSport: Sport = .All + @Published var sportSelectorOffset: CGFloat = 0 + @Published var currentScope: HighlightsScope = .main + + // MARK: - Private Properties + private var privateAllHighlights: [Highlight] = [] + + // MARK: - Singleton + static let shared = HighlightsViewModel() + private init() {} + + // MARK: - Computed + var hasNotFetchedYet: Bool { dataState == .idle } + + // MARK: - Loading + func loadHighlights() { + dataState = .loading + + self.allHighlights.removeAll() + self.mainTodayHighlights.removeAll() + self.mainPastThreeDaysHighlights.removeAll() + self.detailedTodayHighlights.removeAll() + self.detailedPastThreeDaysHighlights.removeAll() + self.allHighlightsSearchResults.removeAll() + + Task { + do { + async let articles = NetworkManager.shared.fetchArticles() + async let videos = NetworkManager.shared.fetchYouTubeVideos() + + let (articleData, videoData) = try await (articles, videos) + + processHighlights(articleData, videoData) + } catch { + handleError(.networkError) + } + } + } + + func retryFetch() { + loadHighlights() + } + + /** + * Converts network data to local models, sorts, and filters. + */ + private func processHighlights(_ articleDataArray: [ArticlesQuery.Data.Article], _ youTubeVideoDataArray: [YoutubeVideosQuery.Data.YoutubeVideo]) { + let localArticles = articleDataArray.map { Article(from: $0) } + let localYouTubeVideos = youTubeVideoDataArray.map {YouTubeVideo(from: $0)} + + self.privateAllHighlights += localArticles.map { Highlight.article($0) } + localYouTubeVideos.map { Highlight.video($0) } + self.allHighlights = self.uniqueHighlights(from: self.privateAllHighlights) + self.allHighlights.sort(by: { $0.publishedAt > $1.publishedAt }) + self.filter() + + self.dataState = .success + } + + /** + * Function to filter out duplicate highlights by ID. + */ + private func uniqueHighlights(from highlights: [Highlight]) -> [Highlight] { + var uniqueHighlights: [Highlight] = [] + var seenArticleIDs: Set = [] + var seenVideoIDs: Set = [] + + for highlight in highlights { + if case .article(let article) = highlight { + if !seenArticleIDs.contains(article.id) { + uniqueHighlights.append(highlight) + seenArticleIDs.insert(article.id) + } + } + if case .video(let video) = highlight { + if !seenVideoIDs.contains(video.id) { + uniqueHighlights.append(highlight) + seenVideoIDs.insert(video.id) + } + } + } + return uniqueHighlights + } + + // MARK: - Filtering + func filter() { + let filteredBySport: [Highlight] + if selectedSport == .All { + filteredBySport = allHighlights + } else { + filteredBySport = allHighlights.filter { $0.sport == selectedSport } + } + + let filteredBySearch: [Highlight] + if searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + filteredBySearch = filteredBySport + } else { + let query = searchQuery.lowercased() + filteredBySearch = filteredBySport.filter { + highlightTitle($0).lowercased().contains(query) + } + } + + mainTodayHighlights = filteredBySport.filter { + guard let date = Date.highlightDateFormatter.date(from: $0.publishedAt) else { return false } + return Date.isToday(date) + } + + mainPastThreeDaysHighlights = filteredBySport.filter { + guard let date = Date.highlightDateFormatter.date(from: $0.publishedAt) else { return false } + return Date.isWithinPastDays(date, days: 3) + } + + // --- Detailed Page Filters (by sport + search) + detailedTodayHighlights = filteredBySearch.filter { + guard let date = Date.highlightDateFormatter.date(from: $0.publishedAt) else { return false } + return Date.isToday(date) + } + + detailedPastThreeDaysHighlights = filteredBySearch.filter { + guard let date = Date.highlightDateFormatter.date(from: $0.publishedAt) else { return false } + return Date.isWithinPastDays(date, days: 3) + } + + // --- “Search All” Page + allHighlightsSearchResults = filteredBySearch + } + + // MARK: - Search & Sport + func filterBySearch(_ query: String) { + searchQuery = query + filter() + } + + func clearSearch() { + searchQuery = "" + filter() + } + + func selectSport(_ sport: Sport) { + selectedSport = sport + filter() + } + + // MARK: - Helpers + private func highlightTitle(_ highlight: Highlight) -> String { + switch highlight { + case .video(let video): return video.title + case .article(let article): return article.title + } + } + + func handleError(_ error: ScoreError) { + DispatchQueue.main.async { + self.dataState = .error(error: error) + } + } +} + +enum HighlightsScope { + case main + case today + case pastThreeDays + case all +} diff --git a/score-ios/Views/DetailedViews/DetailedHighlightView.swift b/score-ios/Views/DetailedViews/DetailedHighlightView.swift index 3f9ae06..128c36c 100644 --- a/score-ios/Views/DetailedViews/DetailedHighlightView.swift +++ b/score-ios/Views/DetailedViews/DetailedHighlightView.swift @@ -9,67 +9,108 @@ import SwiftUI struct DetailedHighlightsView: View { @Environment(\.dismiss) private var dismiss + @EnvironmentObject var viewModel: HighlightsViewModel + var title: String - var highlights: [Highlight] + var highlightScope: HighlightsScope var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 16) { + LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { + // Custom header ZStack { - Text(title) + Text(title) .font(Constants.Fonts.header) .foregroundStyle(Constants.Colors.black) - - HStack { - Button(action: { dismiss() }) { - Image("arrow_back_ios") - .resizable() - .frame(width: 9.87, height: 18.57) - } - - Spacer() - } - } + + HStack { + Button(action: { dismiss() }) { + Image("arrow_back_ios") + .resizable() + .frame(width: 9.87, height: 18.57) + } + + Spacer() + } + } .padding(.top, 24) .padding(.horizontal, 24) - Divider() - .background(.clear) + Divider().background(.clear) - VStack(alignment: .leading, spacing: 0) { - SearchView(highlights: highlights, title: "Search \(title)") - .padding(.horizontal, 24) - .padding(.top, 20) - - SportSelectorView() - .padding(.top, 20) - - VStack{ - ForEach(highlights) { highlight in - HighlightTile(highlight: highlight, width: 360) + Section( + header: + VStack(alignment: .leading, spacing: 0) { + SearchView(title: "Search \(title)", scope: highlightScope) .padding(.horizontal, 24) - .padding(.top, 12) + .padding(.top, 20) + + SportSelectorView() + .padding(.top, 20) + } + .padding(.bottom, 20) + .cornerRadius(12, corners: [.bottomLeft, .bottomRight]) + , + content: { + VStack(alignment: .leading, spacing: 0) { + if(highlightsForScope.isEmpty) { + NoHighlightView() + .frame(maxWidth: .infinity) + .frame(minHeight: UIScreen.main.bounds.height - 350) + // push view to the middle of the screen + } + else{ + LazyVStack { + ForEach(highlightsForScope, id: \.id) { highlight in + HighlightTile(highlight: highlight, width: 360) + .padding(.horizontal, 24) + .padding(.top, 12) + } + } + } } } - .padding(.top, 20) - } - - + ) + .background(Color.white) + .edgesIgnoringSafeArea(.top) } } - // hide default nav bar so only your custom one shows .navigationBarBackButtonHidden(true) .navigationBarTitleDisplayMode(.inline) .safeAreaInset(edge: .bottom) { Color.clear.frame(height: 200) } + .environmentObject(viewModel) + .onAppear { + if viewModel.hasNotFetchedYet { + viewModel.loadHighlights() + } + + viewModel.clearSearch() + } + .onChange(of: viewModel.selectedSport) { _, _ in + viewModel.filter() + } } + + // MARK: - Helpers + private var highlightsForScope: [Highlight] { + switch highlightScope { + case .today: + return viewModel.detailedTodayHighlights + case .pastThreeDays: + return viewModel.detailedPastThreeDaysHighlights + default: + return viewModel.allHighlights + } + } } #Preview { DetailedHighlightsView( title: "Today", - highlights: Highlight.dummyData + highlightScope: .pastThreeDays ) + .environmentObject(HighlightsViewModel.shared) } diff --git a/score-ios/Views/DetailedViews/NoHighlightView.swift b/score-ios/Views/DetailedViews/NoHighlightView.swift index 1143990..9a6f418 100644 --- a/score-ios/Views/DetailedViews/NoHighlightView.swift +++ b/score-ios/Views/DetailedViews/NoHighlightView.swift @@ -2,7 +2,7 @@ // NoHighlightView.swift // score-ios // -// Created by Zain Bilal on 10/15/25. +// Created by Zain Bilal on 11/5/25. // import SwiftUI @@ -10,14 +10,13 @@ import SwiftUI struct NoHighlightView: View { var body: some View { VStack { + Spacer() VStack { - // TODO: make this image better (higher quality and more accurate colors) - Image("highlight") + Image("HighlightStar") .resizable() .frame(width: 100, height: 100, alignment: .center) - .tint(Constants.Colors.gray_icons) Text("No results yet.") .font(Constants.Fonts.bodyBold) @@ -36,4 +35,3 @@ struct NoHighlightView: View { #Preview { NoHighlightView() } - diff --git a/score-ios/Views/ListViews/HighlightTileArticle.swift b/score-ios/Views/ListViews/HighlightTileArticle.swift index f9b693d..16b7354 100644 --- a/score-ios/Views/ListViews/HighlightTileArticle.swift +++ b/score-ios/Views/ListViews/HighlightTileArticle.swift @@ -48,7 +48,6 @@ struct HighlightTileArticle: View { // Text overlay VStack(alignment: .leading, spacing: 0) { - // Title at top left Text(article.title) .font(.title3) .fontWeight(.bold) @@ -60,14 +59,7 @@ struct HighlightTileArticle: View { Spacer() - // Source and date at bottom HStack { - Text(article.source) - .font(.subheadline) - .fontWeight(.bold) - .foregroundColor(Constants.Colors.white) - .underline() - Image(systemName: "arrow.up.right") .foregroundStyle(Constants.Colors.white) .fontWeight(.bold) @@ -90,15 +82,5 @@ struct HighlightTileArticle: View { ) } } - - } } - - -// MARK: - Preview - -#Preview { - HighlightTileArticle(article: Article.dummyData[0], width: 345) -} - diff --git a/score-ios/Views/ListViews/HighlightTileVideo.swift b/score-ios/Views/ListViews/HighlightTileVideo.swift index aa2cf08..a8dd354 100644 --- a/score-ios/Views/ListViews/HighlightTileVideo.swift +++ b/score-ios/Views/ListViews/HighlightTileVideo.swift @@ -43,9 +43,10 @@ struct HighlightTileVideo: View { HStack(spacing: 2) { Image(systemName: "play.fill") .font(.caption2) - - Text("1:25") - .font(.caption) + if let duration = video.duration{ + Text(duration) + .font(.caption) + } } .fontWeight(.heavy) .foregroundStyle(.white) @@ -99,8 +100,3 @@ struct HighlightTileVideo: View { } } } - - -#Preview { - HighlightTileVideo(video: YouTubeVideo.dummyData[0], width: 241) -} diff --git a/score-ios/Views/ListViews/HighlightView.swift b/score-ios/Views/ListViews/HighlightView.swift index 0ff0810..27264fa 100644 --- a/score-ios/Views/ListViews/HighlightView.swift +++ b/score-ios/Views/ListViews/HighlightView.swift @@ -8,60 +8,85 @@ import SwiftUI struct HighlightView: View { - @State var highlights: [Highlight] + @EnvironmentObject var viewModel: HighlightsViewModel var body: some View { - // Filter highlights - let todayHighlights = highlights.filter { - if let date = Date.fullDateFormatter.date(from: $0.publishedAt) { - return Date.isWithinPastDays(date, days: 1) - } - return false - } - - let pastThreeDaysHighlights = highlights.filter { - guard let date = Date.fullDateFormatter.date(from: $0.publishedAt) else { return false } - return !Date.isWithinPastDays(date, days: 1) && Date.isWithinPastDays(date, days: 3) - } - ScrollView(showsIndicators: false) { - LazyVStack(alignment: .leading, pinnedViews: [.sectionHeaders]) { - VStack(alignment: .leading, spacing: 4) { - Text("Highlights") - .font(Constants.Fonts.semibold24) - .foregroundStyle(Constants.Colors.black) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 24) - .padding(.horizontal, 24) - - SearchView(highlights: highlights, title: "Search All Highlights") + VStack(alignment: .leading, spacing: 4) { + Text("Highlights") + .font(Constants.Fonts.semibold24) + .foregroundStyle(Constants.Colors.black) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 24) + .padding(.horizontal, 24) + + SearchView(title: "Search All Highlights", scope: .all) .padding(.horizontal, 20) .padding(.top, 12) + + SportSelectorView() + .padding(.horizontal, 20) + .padding(.top, 12) + + if viewModel.mainPastThreeDaysHighlights.isEmpty && viewModel.mainTodayHighlights.isEmpty { + NoHighlightView() + .frame(maxWidth: .infinity) + .frame(minHeight: UIScreen.main.bounds.height - 350) + // push view to the middle of the screen - SportSelectorView() - .padding(.horizontal, 20) - .padding(.top, 12) - - if !todayHighlights.isEmpty { - HighlightSectionView(title: "Today", highlights: todayHighlights) - } - - if !pastThreeDaysHighlights.isEmpty { - HighlightSectionView(title: "Past 3 Days", highlights: pastThreeDaysHighlights) - } + } + + if !viewModel.mainTodayHighlights.isEmpty { + HighlightSectionView( + title: "Today", + scope: .today + ) + } + + if !viewModel.mainPastThreeDaysHighlights.isEmpty { + HighlightSectionView( + title: "Past 3 Days", + scope: .pastThreeDays + ) } } } + .environmentObject(viewModel) + .onAppear { + if viewModel.hasNotFetchedYet { + viewModel.loadHighlights() + } + + viewModel.clearSearch() + } + .onChange(of: viewModel.selectedSport) { _, _ in + viewModel.filter() + } } } struct HighlightSectionView: View { + @EnvironmentObject var viewModel: HighlightsViewModel + let title: String - let highlights: [Highlight] + let scope: HighlightsScope + private var highlights: [Highlight] { + switch scope { + case .today: + return viewModel.mainTodayHighlights + case .pastThreeDays: + return viewModel.mainPastThreeDaysHighlights + default: + return [] // Should not happen on this screen + } + } + var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationLink(destination: DetailedHighlightsView(title: title, highlights: highlights)) { + NavigationLink(destination: + DetailedHighlightsView(title: title, highlightScope: scope) + .environmentObject(viewModel)) { HStack { Text(title) .font(Constants.Fonts.subheader) @@ -99,5 +124,6 @@ struct HighlightSectionView: View { // MARK: - Preview #Preview { - HighlightView(highlights: Highlight.dummyData) + HighlightView() + .environmentObject(HighlightsViewModel.shared) } diff --git a/score-ios/Views/ListViews/SearchView.swift b/score-ios/Views/ListViews/SearchView.swift index afd1734..a9722d9 100644 --- a/score-ios/Views/ListViews/SearchView.swift +++ b/score-ios/Views/ListViews/SearchView.swift @@ -8,9 +8,10 @@ import SwiftUI struct SearchView: View { + @EnvironmentObject private var viewModel: HighlightsViewModel @State private var showSearch = false - var highlights: [Highlight] let title: String + let scope: HighlightsScope var body: some View { Button(action: { showSearch = true }) { @@ -31,12 +32,17 @@ struct SearchView: View { } .buttonStyle(PlainButtonStyle()) .fullScreenCover(isPresented: $showSearch) { - SearchViewFullScreen(title: title, allHighlights: highlights) + SearchViewFullScreen(title: title, scope: scope) + .environmentObject(viewModel) + .onAppear { + viewModel.clearSearch() + } } + .environmentObject(viewModel) } } #Preview { - SearchView(highlights: Highlight.dummyData, title: "Search All Highlights") + SearchView(title: "Search All Highlights", scope: .all) } diff --git a/score-ios/Views/ListViews/SportSelectorView.swift b/score-ios/Views/ListViews/SportSelectorView.swift index 7a9e1b6..15a9cda 100644 --- a/score-ios/Views/ListViews/SportSelectorView.swift +++ b/score-ios/Views/ListViews/SportSelectorView.swift @@ -8,7 +8,7 @@ import SwiftUI struct SportSelectorView: View { - @ObservedObject private var vm = GamesViewModel.shared + @ObservedObject private var vm = HighlightsViewModel.shared @State private var scrollOffset: CGFloat = 0 var body: some View { diff --git a/score-ios/Views/MainViews/MainTabView.swift b/score-ios/Views/MainViews/MainTabView.swift index 99d9235..adbb84f 100644 --- a/score-ios/Views/MainViews/MainTabView.swift +++ b/score-ios/Views/MainViews/MainTabView.swift @@ -13,6 +13,7 @@ struct MainTabView: View { @Binding var selectedTab: MainTab @StateObject private var gamesViewModel = GamesViewModel.shared + @StateObject private var highlightViewModel = HighlightsViewModel.shared var body: some View { NavigationStack { @@ -22,8 +23,8 @@ struct MainTabView: View { UpcomingGamesView() .environmentObject(gamesViewModel) case .highlights: - HighlightView(highlights: Highlight.dummyData) - .environmentObject(gamesViewModel) + HighlightView() + .environmentObject(highlightViewModel) case .scores: PastGamesView() .environmentObject(gamesViewModel) diff --git a/score-ios/Views/MainViews/SearchViewFullScreen.swift b/score-ios/Views/MainViews/SearchViewFullScreen.swift index c609746..c10447d 100644 --- a/score-ios/Views/MainViews/SearchViewFullScreen.swift +++ b/score-ios/Views/MainViews/SearchViewFullScreen.swift @@ -8,21 +8,33 @@ import SwiftUI struct SearchViewFullScreen: View { + @EnvironmentObject private var viewModel: HighlightsViewModel let title: String - let allHighlights: [Highlight] + var scope: HighlightsScope @Environment(\.dismiss) private var dismiss @State private var searchText = "" - @State private var filteredHighlights: [Highlight] = [] - @State private var debouncedText = "" @State private var debounceWorkItem: DispatchWorkItem? - @State private var isLoading = false @FocusState private var isSearchFieldFocused: Bool private let debounceDelay: TimeInterval = 0.8 + private var searchResults: [Highlight] { + let model = viewModel // avoid dynamicMemberLookup confusion + + switch scope { + case .today: + return model.detailedTodayHighlights + case .pastThreeDays: + return model.detailedPastThreeDaysHighlights + default: + return model.allHighlightsSearchResults + } + } + + var body: some View { VStack(spacing: 0) { // MARK: Header @@ -51,7 +63,10 @@ struct SearchViewFullScreen: View { .focused($isSearchFieldFocused) if !searchText.isEmpty { - Button(action: { searchText = "" }) { + Button(action: { + searchText = "" + viewModel.clearSearch() + }) { Image(systemName: "xmark.circle.fill") .foregroundColor(Constants.Colors.gray_text) } @@ -72,28 +87,24 @@ struct SearchViewFullScreen: View { .padding() .padding(.horizontal, 6) + SportSelectorView() + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 20) + .cornerRadius(12, corners: [.bottomLeft, .bottomRight]) + // MARK: Results - if searchText.isEmpty { - Spacer() - } else if isLoading { - VStack { - Spacer() - - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .scaleEffect(1.2) - - Spacer() - } - } else if filteredHighlights.isEmpty { - VStack { - NoHighlightView() - } + if viewModel.dataState == .loading { + + } else if !searchText.isEmpty && searchResults.isEmpty { + NoHighlightView() + .frame(maxWidth: .infinity) + .frame(minHeight: UIScreen.main.bounds.height - 350) + // push view to the middle of the screen } else { ScrollView { HStack { - Text("\(filteredHighlights.count) results") - .padding(.top, 12) + Text("\(searchResults.count) results") .padding(.horizontal, 24) .font(Constants.Fonts.subheader) .foregroundStyle(Constants.Colors.gray_text) @@ -102,7 +113,7 @@ struct SearchViewFullScreen: View { } LazyVStack(alignment: .leading, spacing: 24) { - ForEach(filteredHighlights) { highlight in + ForEach(searchResults) { highlight in HighlightTile(highlight: highlight, width: 360) .padding(.horizontal, 24) } @@ -112,45 +123,35 @@ struct SearchViewFullScreen: View { } } .onAppear { - filteredHighlights = allHighlights isSearchFieldFocused = true + searchText = viewModel.searchQuery + viewModel.filter() + } + .onDisappear { + viewModel.clearSearch() } } + // MARK: - Debounce private func debounceSearch(_ text: String) { debounceWorkItem?.cancel() - isLoading = true let workItem = DispatchWorkItem { DispatchQueue.main.async { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - debouncedText = trimmed - if trimmed.isEmpty { - filteredHighlights = allHighlights - } else { - filteredHighlights = allHighlights.filter { highlight in - highlightTitle(highlight).localizedCaseInsensitiveContains(trimmed) - } - } - - isLoading = false + viewModel.filterBySearch(trimmed) + viewModel.filter() } } debounceWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + debounceDelay, execute: workItem) } - - private func highlightTitle(_ highlight: Highlight) -> String { - switch highlight { - case .video(let video): return video.title - case .article(let article): return article.title - } - } } // MARK: - Preview #Preview { - SearchViewFullScreen(title: "Search All Highlights", allHighlights: Highlight.dummyData) + SearchViewFullScreen(title: "Search All Highlights", scope: .pastThreeDays) + .environmentObject(HighlightsViewModel.shared) }