From 051a710c70ece501b5403cb3e65a5e93f0f6d551 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Sun, 9 Feb 2025 10:02:19 +0100 Subject: [PATCH] damn this looks nice ong --- Sora.xcodeproj/project.pbxproj | 44 ++++ .../AniList/HomePage/AniList-Seasonal.swift | 117 ++++++++++ .../AniList/HomePage/AniList-Trending.swift | 93 ++++++++ .../AniList/Struct/AniListItem.swift | 24 +++ Sora/Views/HomeView.swift | 201 +++++++++++++++++- 5 files changed, 474 insertions(+), 5 deletions(-) create mode 100644 Sora/Tracking Services/AniList/HomePage/AniList-Seasonal.swift create mode 100644 Sora/Tracking Services/AniList/HomePage/AniList-Trending.swift create mode 100644 Sora/Tracking Services/AniList/Struct/AniListItem.swift diff --git a/Sora.xcodeproj/project.pbxproj b/Sora.xcodeproj/project.pbxproj index cbec598..e9b65af 100644 --- a/Sora.xcodeproj/project.pbxproj +++ b/Sora.xcodeproj/project.pbxproj @@ -8,6 +8,9 @@ /* Begin PBXBuildFile section */ 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; }; + 13103E842D589D8B000F0673 /* AniList-Seasonal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E832D589D8B000F0673 /* AniList-Seasonal.swift */; }; + 13103E862D58A328000F0673 /* AniList-Trending.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E852D58A328000F0673 /* AniList-Trending.swift */; }; + 13103E892D58A39A000F0673 /* AniListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E882D58A39A000F0673 /* AniListItem.swift */; }; 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */; }; 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; }; 133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; }; @@ -45,6 +48,9 @@ /* Begin PBXFileReference section */ 130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = ""; }; 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = ""; }; + 13103E832D589D8B000F0673 /* AniList-Seasonal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-Seasonal.swift"; sourceTree = ""; }; + 13103E852D58A328000F0673 /* AniList-Trending.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-Trending.swift"; sourceTree = ""; }; + 13103E882D58A39A000F0673 /* AniListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AniListItem.swift; sourceTree = ""; }; 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewGeneral.swift; sourceTree = ""; }; 133D7C6A2D2BE2500075467E /* Sora.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sora.app; sourceTree = BUILT_PRODUCTS_DIR; }; 133D7C6D2D2BE2500075467E /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = ""; }; @@ -92,6 +98,40 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 13103E802D589D6C000F0673 /* Tracking Services */ = { + isa = PBXGroup; + children = ( + 13103E812D589D77000F0673 /* AniList */, + ); + path = "Tracking Services"; + sourceTree = ""; + }; + 13103E812D589D77000F0673 /* AniList */ = { + isa = PBXGroup; + children = ( + 13103E872D58A392000F0673 /* Struct */, + 13103E822D589D7D000F0673 /* HomePage */, + ); + path = AniList; + sourceTree = ""; + }; + 13103E822D589D7D000F0673 /* HomePage */ = { + isa = PBXGroup; + children = ( + 13103E832D589D8B000F0673 /* AniList-Seasonal.swift */, + 13103E852D58A328000F0673 /* AniList-Trending.swift */, + ); + path = HomePage; + sourceTree = ""; + }; + 13103E872D58A392000F0673 /* Struct */ = { + isa = PBXGroup; + children = ( + 13103E882D58A39A000F0673 /* AniListItem.swift */, + ); + path = Struct; + sourceTree = ""; + }; 133D7C612D2BE2500075467E = { isa = PBXGroup; children = ( @@ -113,6 +153,7 @@ children = ( 130C6BF82D53A4C200DC1432 /* Sora.entitlements */, 13DC0C412D2EC9BA00D0F966 /* Info.plist */, + 13103E802D589D6C000F0673 /* Tracking Services */, 133D7C852D2BE2640075467E /* Utils */, 133D7C7B2D2BE2630075467E /* Views */, 133D7C6D2D2BE2500075467E /* SoraApp.swift */, @@ -373,8 +414,10 @@ 133D7C932D2BE2640075467E /* Modules.swift in Sources */, 133D7C702D2BE2500075467E /* ContentView.swift in Sources */, 13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */, + 13103E862D58A328000F0673 /* AniList-Trending.swift in Sources */, 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */, 133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */, + 13103E892D58A39A000F0673 /* AniListItem.swift in Sources */, 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */, 133D7C8D2D2BE2640075467E /* HomeView.swift in Sources */, 13EA2BDC2D32D9FF00C1EBD7 /* MiruDataStruct.swift in Sources */, @@ -385,6 +428,7 @@ 133D7C922D2BE2640075467E /* URLSession.swift in Sources */, 133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */, 133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */, + 13103E842D589D8B000F0673 /* AniList-Seasonal.swift in Sources */, 133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */, 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */, 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */, diff --git a/Sora/Tracking Services/AniList/HomePage/AniList-Seasonal.swift b/Sora/Tracking Services/AniList/HomePage/AniList-Seasonal.swift new file mode 100644 index 0000000..341615f --- /dev/null +++ b/Sora/Tracking Services/AniList/HomePage/AniList-Seasonal.swift @@ -0,0 +1,117 @@ +// +// AniList-Seasonal.swift +// Sora +// +// Created by Francesco on 09/02/25. +// + +import Foundation + +class AnilistServiceSeasonalAnime { + func fetchSeasonalAnime(completion: @escaping ([AniListItem]?) -> Void) { + let currentDate = Date() + let calendar = Calendar.current + let year = calendar.component(.year, from: currentDate) + let month = calendar.component(.month, from: currentDate) + + let season: String + switch month { + case 1...3: + season = "WINTER" + case 4...6: + season = "SPRING" + case 7...9: + season = "SUMMER" + default: + season = "FALL" + } + + let query = """ + query { + Page(page: 1, perPage: 100) { + media(season: \(season), seasonYear: \(year), type: ANIME, isAdult: false) { + id + title { + romaji + english + native + } + coverImage { + large + } + } + } + } + """ + + guard let url = URL(string: "https://graphql.anilist.co") else { + print("Invalid URL") + completion(nil) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + + let parameters: [String: Any] = ["query": query] + do { + request.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: []) + } catch { + print("Error encoding JSON: \(error.localizedDescription)") + completion(nil) + return + } + + let task = URLSession.custom.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + if let error = error { + print("Error fetching seasonal anime: \(error.localizedDescription)") + completion(nil) + return + } + + guard let data = data else { + print("No data returned") + completion(nil) + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let dataObject = json["data"] as? [String: Any], + let page = dataObject["Page"] as? [String: Any], + let media = page["media"] as? [[String: Any]] { + + let seasonalAnime: [AniListItem] = media.compactMap { item -> AniListItem? in + guard let id = item["id"] as? Int, + let titleData = item["title"] as? [String: Any], + let romaji = titleData["romaji"] as? String, + let english = titleData["english"] as? String?, + let native = titleData["native"] as? String?, + let coverImageData = item["coverImage"] as? [String: Any], + let largeImageUrl = coverImageData["large"] as? String, + URL(string: largeImageUrl) != nil else { + return nil + } + + return AniListItem( + id: id, + title: AniListTitle(romaji: romaji, english: english, native: native), + coverImage: AniListCoverImage(large: largeImageUrl) + ) + } + completion(seasonalAnime) + } else { + print("Error parsing JSON or missing expected fields") + completion(nil) + } + } catch { + print("Error decoding JSON: \(error.localizedDescription)") + completion(nil) + } + } + } + task.resume() + } +} diff --git a/Sora/Tracking Services/AniList/HomePage/AniList-Trending.swift b/Sora/Tracking Services/AniList/HomePage/AniList-Trending.swift new file mode 100644 index 0000000..b02e970 --- /dev/null +++ b/Sora/Tracking Services/AniList/HomePage/AniList-Trending.swift @@ -0,0 +1,93 @@ +// +// AniList-Trending.swift +// Sora +// +// Created by Francesco on 09/02/25. +// + +import Foundation + +class AnilistServiceTrendingAnime { + func fetchTrendingAnime(completion: @escaping ([AniListItem]?) -> Void) { + let query = """ + query { + Page(page: 1, perPage: 100) { + media(sort: TRENDING_DESC, type: ANIME, isAdult: false) { + id + title { + romaji + english + native + } + coverImage { + large + } + } + } + } + """ + guard let url = URL(string: "https://graphql.anilist.co") else { + print("Invalid URL") + completion(nil) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + let parameters: [String: Any] = ["query": query] + do { + request.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: []) + } catch { + print("Error encoding JSON: \(error.localizedDescription)") + completion(nil) + return + } + + let task = URLSession.custom.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + if let error = error { + print("Error fetching trending anime: \(error.localizedDescription)") + completion(nil) + return + } + guard let data = data else { + print("No data returned") + completion(nil) + return + } + do { + if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let dataObject = json["data"] as? [String: Any], + let page = dataObject["Page"] as? [String: Any], + let media = page["media"] as? [[String: Any]] { + + let trendingAnime: [AniListItem] = media.compactMap { item in + guard let id = item["id"] as? Int, + let titleData = item["title"] as? [String: Any], + let romaji = titleData["romaji"] as? String, + let coverImageData = item["coverImage"] as? [String: Any], + let largeImageUrl = coverImageData["large"] as? String else { + return nil + } + + return AniListItem( + id: id, + title: AniListTitle(romaji: romaji, english: titleData["english"] as? String, native: titleData["native"] as? String), + coverImage: AniListCoverImage(large: largeImageUrl) + ) + } + completion(trendingAnime) + } else { + print("Error parsing JSON or missing expected fields") + completion(nil) + } + } catch { + print("Error decoding JSON: \(error.localizedDescription)") + completion(nil) + } + } + } + task.resume() + } +} diff --git a/Sora/Tracking Services/AniList/Struct/AniListItem.swift b/Sora/Tracking Services/AniList/Struct/AniListItem.swift new file mode 100644 index 0000000..e868318 --- /dev/null +++ b/Sora/Tracking Services/AniList/Struct/AniListItem.swift @@ -0,0 +1,24 @@ +// +// AniListItem.swift +// Sora +// +// Created by Francesco on 09/02/25. +// + +import Foundation + +struct AniListItem: Codable { + let id: Int + let title: AniListTitle + let coverImage: AniListCoverImage +} + +struct AniListTitle: Codable { + let romaji: String + let english: String? + let native: String? +} + +struct AniListCoverImage: Codable { + let large: String +} diff --git a/Sora/Views/HomeView.swift b/Sora/Views/HomeView.swift index 72ecb7c..4fd7c2b 100644 --- a/Sora/Views/HomeView.swift +++ b/Sora/Views/HomeView.swift @@ -6,11 +6,202 @@ // import SwiftUI +import Kingfisher -struct HomeView: View { - var body: some View { - Text("Home View") - .font(.largeTitle) - .padding() +struct Shimmer: ViewModifier { + @State private var phase: CGFloat = -1 + + func body(content: Content) -> some View { + content + .overlay( + Rectangle() + .fill( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.white.opacity(0.0), location: 0.3), + .init(color: Color.white.opacity(0.6), location: 0.5), + .init(color: Color.white.opacity(0.0), location: 0.7) + ]), + startPoint: .leading, + endPoint: .trailing + ) + ) + .rotationEffect(.degrees(20)) + .offset(x: self.phase * 300) + .blendMode(.plusLighter) + ) + .mask(content) + .onAppear { + withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) { + self.phase = 1 + } + } + } +} + +extension View { + func shimmering() -> some View { + self.modifier(Shimmer()) + } +} + +struct SkeletonCell: View { + var body: some View { + VStack { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.3)) + .frame(width: 130, height: 195) + .cornerRadius(10) + .shimmering() + + RoundedRectangle(cornerRadius: 5) + .fill(Color.gray.opacity(0.3)) + .frame(width: 130, height: 20) + .padding(.top, 4) + .shimmering() + } + } +} + +struct HomeView: View { + @State private var aniListItems: [AniListItem] = [] + @State private var trendingItems: [AniListItem] = [] + + private var currentDeviceSeasonAndYear: (season: String, year: Int) { + let currentDate = Date() + let calendar = Calendar.current + let year = calendar.component(.year, from: currentDate) + let month = calendar.component(.month, from: currentDate) + + let season: String + switch month { + case 1...3: + season = "Winter" + case 4...6: + season = "Spring" + case 7...9: + season = "Summer" + default: + season = "Fall" + } + return (season, year) + } + + private var trendingDateString: String { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, dd MMMM yyyy" + return formatter.string(from: Date()) + } + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .bottom, spacing: 5) { + Text("Seasonal") + .font(.headline) + Text("of \(currentDeviceSeasonAndYear.season) \(String(format: "%d", currentDeviceSeasonAndYear.year))") + .font(.subheadline) + .foregroundColor(.gray) + } + .padding(.horizontal, 8) + .padding(.top, 8) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + if aniListItems.isEmpty { + ForEach(0..<5, id: \.self) { _ in + SkeletonCell() + } + } else { + ForEach(aniListItems, id: \.id) { item in + VStack { + KFImage(URL(string: item.coverImage.large)) + .placeholder { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.3)) + .frame(width: 130, height: 195) + .shimmering() + } + .setProcessor(RoundCornerImageProcessor(cornerRadius: 10)) + .resizable() + .scaledToFill() + .frame(width: 130, height: 195) + .cornerRadius(10) + .clipped() + + Text(item.title.romaji) + .font(.caption) + .frame(width: 130) + .lineLimit(1) + .multilineTextAlignment(.center) + } + } + } + } + .padding(.horizontal, 8) + } + + HStack(alignment: .bottom, spacing: 5) { + Text("Trending") + .font(.headline) + Text("on \(trendingDateString)") + .font(.subheadline) + .foregroundColor(.gray) + } + .padding(.horizontal, 8) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + if trendingItems.isEmpty { + ForEach(0..<5, id: \.self) { _ in + SkeletonCell() + } + } else { + ForEach(trendingItems, id: \.id) { item in + VStack { + KFImage(URL(string: item.coverImage.large)) + .placeholder { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.3)) + .frame(width: 130, height: 195) + .shimmering() + } + .setProcessor(RoundCornerImageProcessor(cornerRadius: 10)) + .resizable() + .scaledToFill() + .frame(width: 130, height: 195) + .cornerRadius(10) + .clipped() + + Text(item.title.romaji) + .font(.caption) + .frame(width: 130) + .lineLimit(1) + .multilineTextAlignment(.center) + } + } + } + } + .padding(.horizontal, 8) + } + } + .padding(.bottom, 16) + .padding(.leading, 7) + } + .navigationTitle("Home") + } + .onAppear { + AnilistServiceSeasonalAnime().fetchSeasonalAnime { items in + if let items = items { + aniListItems = items + } + } + AnilistServiceTrendingAnime().fetchTrendingAnime { items in + if let items = items { + trendingItems = items + } + } + } } }