diff --git a/Sora/Tracking Services/AniList/HomePage/DetailsView/AniList-DetailsView.swift b/Sora/Tracking Services/AniList/HomePage/DetailsView/AniList-DetailsView.swift index 1566715..4f7a9d4 100644 --- a/Sora/Tracking Services/AniList/HomePage/DetailsView/AniList-DetailsView.swift +++ b/Sora/Tracking Services/AniList/HomePage/DetailsView/AniList-DetailsView.swift @@ -8,6 +8,297 @@ import SwiftUI import Kingfisher +struct AniListDetailsView: View { + let animeID: Int + @StateObject private var viewModel: AniListDetailsViewModel + + init(animeID: Int) { + self.animeID = animeID + _viewModel = StateObject(wrappedValue: AniListDetailsViewModel(animeID: animeID)) + } + + var body: some View { + ScrollView { + VStack(spacing: 16) { + if viewModel.isLoading { + ProgressView() + .padding() + } else if let media = viewModel.mediaInfo { + MediaHeaderView(media: media) + Divider() + MediaDetailsScrollView(media: media) + Divider() + SynopsisView(synopsis: media["description"] as? String) + Divider() + CharactersView(characters: media["characters"] as? [String: Any]) + Divider() + ScoreDistributionView(stats: media["stats"] as? [String: Any]) + } else { + Text("Failed to load media details.") + .padding() + } + } + } + .navigationBarTitle("", displayMode: .inline) + .navigationViewStyle(StackNavigationViewStyle()) + .onAppear { + viewModel.fetchDetails() + } + } +} + +class AniListDetailsViewModel: ObservableObject { + @Published var mediaInfo: [String: AnyHashable]? + @Published var isLoading: Bool = true + + let animeID: Int + + init(animeID: Int) { + self.animeID = animeID + } + + func fetchDetails() { + AnilistServiceMediaInfo.fetchAnimeDetails(animeID: animeID) { result in + DispatchQueue.main.async { + switch result { + case .success(let media): + var convertedMedia: [String: AnyHashable] = [:] + for (key, value) in media { + if let value = value as? AnyHashable { + convertedMedia[key] = value + } + } + self.mediaInfo = convertedMedia + case .failure(let error): + print("Error: \(error)") + } + self.isLoading = false + } + } + } +} + +struct MediaHeaderView: View { + let media: [String: Any] + + var body: some View { + HStack(alignment: .top, spacing: 16) { + if let coverDict = media["coverImage"] as? [String: Any], + let posterURLString = coverDict["extraLarge"] as? String, + let posterURL = URL(string: posterURLString) { + KFImage(posterURL) + .placeholder { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.3)) + .frame(width: 150, height: 225) + .shimmering() + } + .resizable() + .aspectRatio(2/3, contentMode: .fill) + .cornerRadius(10) + .frame(width: 150, height: 225) + } + + VStack(alignment: .leading) { + if let titleDict = media["title"] as? [String: Any], + let userPreferred = titleDict["english"] as? String { + Text(userPreferred) + .font(.system(size: 17)) + .fontWeight(.bold) + .onLongPressGesture { + UIPasteboard.general.string = userPreferred + DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) + } + } + + if let titleDict = media["title"] as? [String: Any], + let userPreferred = titleDict["romaji"] as? String { + Text(userPreferred) + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + + if let titleDict = media["title"] as? [String: Any], + let userPreferred = titleDict["native"] as? String { + Text(userPreferred) + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + } + + Spacer() + } + .padding() + } +} + +struct MediaDetailsScrollView: View { + let media: [String: Any] + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + if let type = media["type"] as? String { + MediaDetailItem(title: "Type", value: type) + Divider() + } + if let episodes = media["episodes"] as? Int { + MediaDetailItem(title: "Episodes", value: "\(episodes)") + Divider() + } + if let duration = media["duration"] as? Int { + MediaDetailItem(title: "Length", value: "\(duration) mins") + Divider() + } + if let format = media["format"] as? String { + MediaDetailItem(title: "Format", value: format) + Divider() + } + if let status = media["status"] as? String { + MediaDetailItem(title: "Status", value: status) + Divider() + } + if let season = media["season"] as? String { + MediaDetailItem(title: "Season", value: season) + Divider() + } + if let startDate = media["startDate"] as? [String: Any], + let year = startDate["year"] as? Int, + let month = startDate["month"] as? Int, + let day = startDate["day"] as? Int { + MediaDetailItem(title: "Start Date", value: "\(year)-\(month)-\(day)") + Divider() + } + if let endDate = media["endDate"] as? [String: Any], + let year = endDate["year"] as? Int, + let month = endDate["month"] as? Int, + let day = endDate["day"] as? Int { + MediaDetailItem(title: "End Date", value: "\(year)-\(month)-\(day)") + } + } + } + } +} + +struct SynopsisView: View { + let synopsis: String? + + var body: some View { + if let synopsis = synopsis { + Text(synopsis.strippedHTML) + .padding(.horizontal) + .foregroundColor(.secondary) + .font(.system(size: 14)) + } else { + EmptyView() + } + } +} + +struct CharactersView: View { + let characters: [String: Any]? + + var body: some View { + if let charactersDict = characters, + let edges = charactersDict["edges"] as? [[String: Any]] { + VStack(alignment: .leading, spacing: 8) { + Text("Characters") + .font(.headline) + .padding(.horizontal) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Array(edges.prefix(15).enumerated()), id: \.offset) { _, edge in + if let node = edge["node"] as? [String: Any], + let nameDict = node["name"] as? [String: Any], + let fullName = nameDict["full"] as? String, + let imageDict = node["image"] as? [String: Any], + let imageUrlStr = imageDict["large"] as? String, + let imageUrl = URL(string: imageUrlStr) { + CharacterItemView(imageUrl: imageUrl, name: fullName) + } + } + } + .padding(.horizontal) + } + } + } else { + EmptyView() + } + } +} + +struct CharacterItemView: View { + let imageUrl: URL + let name: String + + var body: some View { + VStack { + KFImage(imageUrl) + .placeholder { + Circle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 90, height: 90) + .shimmering() + } + .resizable() + .scaledToFill() + .frame(width: 90, height: 90) + .clipShape(Circle()) + Text(name) + .font(.caption) + .lineLimit(1) + } + .frame(width: 105, height: 110) + } +} + +struct ScoreDistributionView: View { + let stats: [String: Any]? + + @State private var barHeights: [CGFloat] = [] + + var body: some View { + if let stats = stats, + let scoreDistribution = stats["scoreDistribution"] as? [[String: AnyHashable]] { + + let maxValue: Int = scoreDistribution.compactMap { $0["amount"] as? Int }.max() ?? 1 + + let calculatedHeights = scoreDistribution.map { dataPoint -> CGFloat in + guard let amount = dataPoint["amount"] as? Int else { return 0 } + return CGFloat(amount) / CGFloat(maxValue) * 100 + } + + VStack { + Text("Score Distribution") + .font(.headline) + HStack(alignment: .bottom) { + ForEach(Array(scoreDistribution.enumerated()), id: \.offset) { index, dataPoint in + if let score = dataPoint["score"] as? Int { + VStack { + Rectangle() + .fill(Color.accentColor) + .frame(width: 20, height: calculatedHeights[index]) + Text("\(score)") + .font(.caption) + } + } + } + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal) + .onAppear { + barHeights = calculatedHeights + } + .onChange(of: scoreDistribution) { _ in + barHeights = calculatedHeights + } + } else { + EmptyView() + } + } +} + struct MediaDetailItem: View { var title: String var value: String @@ -23,275 +314,3 @@ struct MediaDetailItem: View { .padding(.horizontal) } } - -struct AniListDetailsView: View { - let animeID: Int - @State private var mediaInfo: [String: Any]? - @State private var isLoading: Bool = true - - var body: some View { - ScrollView { - VStack(spacing: 16) { - if isLoading { - ProgressView() - .padding() - } else if let media = mediaInfo { - HStack(alignment: .top, spacing: 16) { - if let coverDict = media["coverImage"] as? [String: Any], - let posterURLString = coverDict["extraLarge"] as? String, - let posterURL = URL(string: posterURLString) { - KFImage(posterURL) - .placeholder { - RoundedRectangle(cornerRadius: 10) - .fill(Color.gray.opacity(0.3)) - .frame(width: 150, height: 225) - .shimmering() - } - .resizable() - .aspectRatio(2/3, contentMode: .fill) - .cornerRadius(10) - .frame(width: 150, height: 225) - } - - VStack(alignment: .leading) { - if let titleDict = media["title"] as? [String: Any], - let userPreferred = titleDict["english"] as? String { - Text(userPreferred) - .font(.system(size: 17)) - .fontWeight(.bold) - .onLongPressGesture { - UIPasteboard.general.string = userPreferred - DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) - } - } - - if let titleDict = media["title"] as? [String: Any], - let userPreferred = titleDict["romaji"] as? String { - Text(userPreferred) - .font(.system(size: 13)) - .foregroundColor(.secondary) - } - - if let titleDict = media["title"] as? [String: Any], - let userPreferred = titleDict["native"] as? String { - Text(userPreferred) - .font(.system(size: 13)) - .foregroundColor(.secondary) - } - } - - Spacer() - } - .padding() - - Divider() - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 4) { - if let type = media["type"] as? String { - MediaDetailItem(title: "Type", value: type) - Divider() - } - if let episodes = media["episodes"] as? Int { - MediaDetailItem(title: "Episodes", value: "\(episodes)") - Divider() - } - if let duration = media["duration"] as? Int { - MediaDetailItem(title: "Length", value: "\(duration) mins") - Divider() - } - if let format = media["format"] as? String { - MediaDetailItem(title: "Format", value: format) - Divider() - } - if let status = media["status"] as? String { - MediaDetailItem(title: "Status", value: status) - Divider() - } - if let season = media["season"] as? String { - MediaDetailItem(title: "Season", value: season) - Divider() - } - if let startDate = media["startDate"] as? [String: Any], - let year = startDate["year"] as? Int, - let month = startDate["month"] as? Int, - let day = startDate["day"] as? Int { - MediaDetailItem(title: "Start Date", value: "\(year)-\(month)-\(day)") - Divider() - } - if let endDate = media["endDate"] as? [String: Any], - let year = endDate["year"] as? Int, - let month = endDate["month"] as? Int, - let day = endDate["day"] as? Int { - MediaDetailItem(title: "End Date", value: "\(year)-\(month)-\(day)") - } - } - } - - Divider() - - if let synopsis = media["description"] as? String { - Text(synopsis.strippedHTML) - .padding(.horizontal) - .foregroundColor(.secondary) - .font(.system(size: 14)) - } - - Divider() - - if let charactersDict = media["characters"] as? [String: Any], - let edges = charactersDict["edges"] as? [[String: Any]] { - VStack(alignment: .leading, spacing: 8) { - Text("Characters") - .font(.headline) - .padding(.horizontal) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(Array(edges.enumerated()), id: \.offset) { _, edge in - if let node = edge["node"] as? [String: Any], - let nameDict = node["name"] as? [String: Any], - let fullName = nameDict["full"] as? String, - let imageDict = node["image"] as? [String: Any], - let imageUrlStr = imageDict["large"] as? String, - let imageUrl = URL(string: imageUrlStr) { - VStack { - KFImage(imageUrl) - .placeholder { - Circle() - .fill(Color.gray.opacity(0.3)) - .frame(width: 90, height: 90) - .shimmering() - } - .resizable() - .scaledToFill() - .frame(width: 90, height: 90) - .clipShape(Circle()) - Text(fullName) - .font(.caption) - } - .frame(width: 105, height: 105) - } - } - } - .padding(.horizontal) - } - } - } - - Divider() - - if let stats = media["stats"] as? [String: Any], - let scoreDistribution = stats["scoreDistribution"] as? [[String: Any]] { - - let maxValue: Int = scoreDistribution.compactMap { $0["amount"] as? Int }.max() ?? 1 - - let totalScore = scoreDistribution.reduce(0) { partialResult, dataPoint in - if let score = dataPoint["score"] as? Int, let amount = dataPoint["amount"] as? Int { - return partialResult + score * amount - } - return partialResult - } - - let totalCount = scoreDistribution.reduce(0) { partialResult, dataPoint in - if let amount = dataPoint["amount"] as? Int { - return partialResult + amount - } - return partialResult - } - let computedAverage = totalCount > 0 ? Double(totalScore) / Double(totalCount) : 0.0 - VStack { - Text("Score Distribution") - .font(.headline) - HStack(alignment: .center) { - MediaDetailItem(title: "Average Score", value: String(format: "%.1f", computedAverage)) - .frame(width: 120, alignment: .leading) - VStack(alignment: .center) { - HStack(alignment: .bottom, spacing: 8) { - ForEach(Array(scoreDistribution.enumerated()), id: \.offset) { _, dataPoint in - if let score = dataPoint["score"] as? Int, - let amount = dataPoint["amount"] as? Int { - VStack { - Rectangle() - .fill(Color.accentColor) - .frame(width: 20, height: CGFloat(amount) / CGFloat(maxValue) * 100) - Text("\(score)") - .font(.caption) - } - } - } - } - } - } - } - .frame(maxWidth: .infinity) - .padding(.horizontal) - } - - Divider() - - if let relations = media["relations"] as? [String: Any], - let nodes = relations["nodes"] as? [[String: Any]] { - VStack(alignment: .leading, spacing: 8) { - Text("Correlation") - .font(.headline) - .padding(.horizontal) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(Array(nodes.enumerated()), id: \.offset) { _, node in - if let titleDict = node["title"] as? [String: Any], - let title = titleDict["userPreferred"] as? String, - let coverImageDict = node["coverImage"] as? [String: Any], - let imageUrlStr = coverImageDict["extraLarge"] as? String, - let imageUrl = URL(string: imageUrlStr) { - VStack { - KFImage(imageUrl) - .placeholder { - RoundedRectangle(cornerRadius: 10) - .fill(Color.gray.opacity(0.3)) - .frame(width: 100, height: 150) - .shimmering() - } - .resizable() - .scaledToFill() - .frame(width: 100, height: 150) - .cornerRadius(10) - Text(title) - .font(.caption) - } - .frame(width: 130, height: 195) - } - } - } - .padding(.horizontal) - } - } - } - - } else { - Text("Failed to load media details.") - .padding() - } - } - } - .navigationBarTitle("") - .navigationBarTitleDisplayMode(.inline) - .navigationViewStyle(StackNavigationViewStyle()) - .onAppear { - fetchDetails() - } - } - - private func fetchDetails() { - AnilistServiceMediaInfo.fetchAnimeDetails(animeID: animeID) { result in - DispatchQueue.main.async { - switch result { - case .success(let media): - self.mediaInfo = media - case .failure(let error): - print("Error: \(error)") - } - self.isLoading = false - } - } - } -}