mirror of
https://github.com/cranci1/Sora.git
synced 2026-03-11 17:45:37 +00:00
595 lines
24 KiB
Swift
595 lines
24 KiB
Swift
//
|
|
// SettingsViewTrackers.swift
|
|
// Sora
|
|
//
|
|
// Created by Francesco on 23/03/25.
|
|
//
|
|
|
|
import NukeUI
|
|
import SwiftUI
|
|
import Security
|
|
|
|
fileprivate struct SettingsSection<Content: View>: View {
|
|
let title: String
|
|
let footer: String?
|
|
let content: Content
|
|
|
|
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
|
|
self.title = title
|
|
self.footer = footer
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(title.uppercased())
|
|
.font(.footnote)
|
|
.foregroundStyle(.gray)
|
|
.padding(.horizontal, 20)
|
|
|
|
VStack(spacing: 0) {
|
|
content
|
|
}
|
|
.background(.ultraThinMaterial)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.strokeBorder(
|
|
LinearGradient(
|
|
gradient: Gradient(stops: [
|
|
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
|
.init(color: Color.accentColor.opacity(0), location: 1)
|
|
]),
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
),
|
|
lineWidth: 0.5
|
|
)
|
|
)
|
|
.padding(.horizontal, 20)
|
|
|
|
if let footer = footer {
|
|
Text(footer)
|
|
.font(.footnote)
|
|
.foregroundStyle(.gray)
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate struct SettingsToggleRow: View {
|
|
let icon: String
|
|
let title: String
|
|
@Binding var isOn: Bool
|
|
var showDivider: Bool = true
|
|
|
|
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
|
|
self.icon = icon
|
|
self.title = title
|
|
self._isOn = isOn
|
|
self.showDivider = showDivider
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Image(systemName: icon)
|
|
.frame(width: 24, height: 24)
|
|
.foregroundStyle(.primary)
|
|
|
|
Text(title)
|
|
.foregroundStyle(.primary)
|
|
|
|
Spacer()
|
|
|
|
Toggle("", isOn: $isOn)
|
|
.labelsHidden()
|
|
.tint(.accentColor.opacity(0.7))
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.frame(height: 48)
|
|
|
|
if showDivider {
|
|
Divider()
|
|
.padding(.horizontal, 16)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SettingsViewTrackers: View {
|
|
@AppStorage("sendPushUpdates") private var isSendPushUpdates = true
|
|
@State private var anilistStatus: String = "You are not logged in"
|
|
@State private var isAnilistLoggedIn: Bool = false
|
|
@State private var anilistUsername: String = ""
|
|
@State private var isAnilistLoading: Bool = false
|
|
@State private var profileColor: Color = .accentColor
|
|
|
|
@AppStorage("sendTraktUpdates") private var isSendTraktUpdates = true
|
|
@State private var traktStatus: String = "You are not logged in"
|
|
@State private var isTraktLoggedIn: Bool = false
|
|
@State private var traktUsername: String = ""
|
|
@State private var isTraktLoading: Bool = false
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
SettingsSection(title: NSLocalizedString("AniList", comment: "")) {
|
|
VStack(spacing: 0) {
|
|
HStack(alignment: .center, spacing: 10) {
|
|
LazyImage(url: URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png")) { state in
|
|
if let uiImage = state.imageContainer?.image {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.frame(width: 60, height: 60)
|
|
.clipShape(Rectangle())
|
|
.cornerRadius(10)
|
|
.padding(.trailing, 10)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(Color.gray.opacity(0.3))
|
|
.frame(width: 60, height: 60)
|
|
.shimmering()
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(NSLocalizedString("AniList.co", comment: ""))
|
|
.font(.title3)
|
|
.fontWeight(.semibold)
|
|
|
|
if isAnilistLoading {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
.frame(height: 18)
|
|
} else if isAnilistLoggedIn {
|
|
HStack(spacing: 0) {
|
|
Text(NSLocalizedString("Logged in as", comment: ""))
|
|
.font(.footnote)
|
|
.foregroundStyle(.gray)
|
|
Text(anilistUsername)
|
|
.font(.footnote)
|
|
.fontWeight(.medium)
|
|
.foregroundStyle(profileColor)
|
|
}
|
|
.frame(height: 18)
|
|
} else {
|
|
Text(NSLocalizedString("You are not logged in", comment: ""))
|
|
.font(.footnote)
|
|
.foregroundStyle(.gray)
|
|
.frame(height: 18)
|
|
}
|
|
}
|
|
.frame(height: 60, alignment: .center)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.frame(height: 84)
|
|
|
|
if isAnilistLoggedIn {
|
|
Divider()
|
|
.padding(.horizontal, 16)
|
|
|
|
SettingsToggleRow(
|
|
icon: "arrow.triangle.2.circlepath",
|
|
title: NSLocalizedString("Sync anime progress", comment: ""),
|
|
isOn: $isSendPushUpdates,
|
|
showDivider: false
|
|
)
|
|
}
|
|
|
|
Divider()
|
|
.padding(.horizontal, 16)
|
|
|
|
Button(action: {
|
|
if isAnilistLoggedIn {
|
|
logoutAniList()
|
|
} else {
|
|
loginAniList()
|
|
}
|
|
}) {
|
|
HStack {
|
|
Image(systemName: isAnilistLoggedIn ? "rectangle.portrait.and.arrow.right" : "person.badge.key")
|
|
.frame(width: 24, height: 24)
|
|
.foregroundStyle(isAnilistLoggedIn ? .red : .accentColor)
|
|
|
|
Text(isAnilistLoggedIn ? NSLocalizedString("Log Out from AniList", comment: "") : NSLocalizedString("Log In with AniList", comment: ""))
|
|
.foregroundStyle(isAnilistLoggedIn ? .red : .accentColor)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.frame(height: 48)
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsSection(title: NSLocalizedString("Trakt", comment: "")) {
|
|
VStack(spacing: 0) {
|
|
HStack(alignment: .center, spacing: 10) {
|
|
LazyImage(url: URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png")) { state in
|
|
if let uiImage = state.imageContainer?.image {
|
|
Image(uiImage: uiImage)
|
|
.resizable()
|
|
.frame(width: 60, height: 60)
|
|
.clipShape(Rectangle())
|
|
.cornerRadius(10)
|
|
.padding(.trailing, 10)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(Color.gray.opacity(0.3))
|
|
.frame(width: 60, height: 60)
|
|
.shimmering()
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(NSLocalizedString("Trakt.tv", comment: ""))
|
|
.font(.title3)
|
|
.fontWeight(.semibold)
|
|
|
|
if isTraktLoading {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
.frame(height: 18)
|
|
} else if isTraktLoggedIn {
|
|
HStack(spacing: 0) {
|
|
Text(NSLocalizedString("Logged in as", comment: ""))
|
|
.font(.footnote)
|
|
.foregroundStyle(.gray)
|
|
Text(traktUsername)
|
|
.font(.footnote)
|
|
.fontWeight(.medium)
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
.frame(height: 18)
|
|
} else {
|
|
Text(NSLocalizedString("You are not logged in", comment: ""))
|
|
.font(.footnote)
|
|
.foregroundStyle(.gray)
|
|
.frame(height: 18)
|
|
}
|
|
}
|
|
.frame(height: 60, alignment: .center)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.frame(height: 84)
|
|
|
|
if isTraktLoggedIn {
|
|
Divider()
|
|
.padding(.horizontal, 16)
|
|
|
|
SettingsToggleRow(
|
|
icon: "arrow.triangle.2.circlepath",
|
|
title: NSLocalizedString("Sync TV shows progress", comment: ""),
|
|
isOn: $isSendTraktUpdates,
|
|
showDivider: false
|
|
)
|
|
}
|
|
|
|
Divider()
|
|
.padding(.horizontal, 16)
|
|
|
|
Button(action: {
|
|
if isTraktLoggedIn {
|
|
logoutTrakt()
|
|
} else {
|
|
loginTrakt()
|
|
}
|
|
}) {
|
|
HStack {
|
|
Image(systemName: isTraktLoggedIn ? "rectangle.portrait.and.arrow.right" : "person.badge.key")
|
|
.frame(width: 24, height: 24)
|
|
.foregroundStyle(isTraktLoggedIn ? .red : .accentColor)
|
|
|
|
Text(isTraktLoggedIn ? NSLocalizedString("Log Out from Trakt", comment: "") : NSLocalizedString("Log In with Trakt", comment: ""))
|
|
.foregroundStyle(isTraktLoggedIn ? .red : .accentColor)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.frame(height: 48)
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsSection(
|
|
title: NSLocalizedString("Info", comment: ""),
|
|
footer: NSLocalizedString("Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate.", comment: "")
|
|
) {}
|
|
}
|
|
.padding(.vertical, 20)
|
|
}
|
|
.scrollViewBottomPadding()
|
|
.navigationTitle(NSLocalizedString("Trackers", comment: ""))
|
|
.onAppear {
|
|
updateAniListStatus()
|
|
updateTraktStatus()
|
|
setupNotificationObservers()
|
|
}
|
|
.onDisappear {
|
|
removeNotificationObservers()
|
|
}
|
|
}
|
|
|
|
func removeNotificationObservers() {
|
|
NotificationCenter.default.removeObserver(self, name: AniListToken.authSuccessNotification, object: nil)
|
|
NotificationCenter.default.removeObserver(self, name: AniListToken.authFailureNotification, object: nil)
|
|
|
|
NotificationCenter.default.removeObserver(self, name: TraktToken.authSuccessNotification, object: nil)
|
|
NotificationCenter.default.removeObserver(self, name: TraktToken.authFailureNotification, object: nil)
|
|
}
|
|
|
|
func setupNotificationObservers() {
|
|
NotificationCenter.default.addObserver(forName: AniListToken.authSuccessNotification, object: nil, queue: .main) { _ in
|
|
self.anilistStatus = "Authentication successful!"
|
|
self.updateAniListStatus()
|
|
}
|
|
|
|
NotificationCenter.default.addObserver(forName: AniListToken.authFailureNotification, object: nil, queue: .main) { notification in
|
|
if let error = notification.userInfo?["error"] as? String {
|
|
self.anilistStatus = "Login failed: \(error)"
|
|
} else {
|
|
self.anilistStatus = "Login failed with unknown error"
|
|
}
|
|
self.isAnilistLoggedIn = false
|
|
self.isAnilistLoading = false
|
|
}
|
|
|
|
NotificationCenter.default.addObserver(forName: TraktToken.authSuccessNotification, object: nil, queue: .main) { _ in
|
|
self.traktStatus = "Authentication successful!"
|
|
self.updateTraktStatus()
|
|
}
|
|
|
|
NotificationCenter.default.addObserver(forName: TraktToken.authFailureNotification, object: nil, queue: .main) { notification in
|
|
if let error = notification.userInfo?["error"] as? String {
|
|
self.traktStatus = "Login failed: \(error)"
|
|
} else {
|
|
self.traktStatus = "Login failed with unknown error"
|
|
}
|
|
self.isTraktLoggedIn = false
|
|
self.isTraktLoading = false
|
|
}
|
|
}
|
|
|
|
func loginTrakt() {
|
|
traktStatus = "Starting authentication..."
|
|
isTraktLoading = true
|
|
TraktLogin.authenticate()
|
|
}
|
|
|
|
func logoutTrakt() {
|
|
removeTraktTokenFromKeychain()
|
|
traktStatus = "You are not logged in"
|
|
isTraktLoggedIn = false
|
|
traktUsername = ""
|
|
}
|
|
|
|
func updateTraktStatus() {
|
|
if let token = getTraktTokenFromKeychain() {
|
|
isTraktLoggedIn = true
|
|
fetchTraktUserInfo(token: token)
|
|
} else {
|
|
isTraktLoggedIn = false
|
|
traktStatus = "You are not logged in"
|
|
}
|
|
}
|
|
|
|
func fetchTraktUserInfo(token: String) {
|
|
isTraktLoading = true
|
|
let userInfoURL = URL(string: "https://api.trakt.tv/users/settings")!
|
|
var request = URLRequest(url: userInfoURL)
|
|
request.httpMethod = "GET"
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("2", forHTTPHeaderField: "trakt-api-version")
|
|
request.setValue(TraktToken.clientID, forHTTPHeaderField: "trakt-api-key")
|
|
|
|
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
DispatchQueue.main.async {
|
|
self.isTraktLoading = false
|
|
if let error = error {
|
|
self.traktStatus = "Error: \(error.localizedDescription)"
|
|
return
|
|
}
|
|
|
|
guard let data = data else {
|
|
self.traktStatus = "No data received"
|
|
return
|
|
}
|
|
|
|
do {
|
|
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let user = json["user"] as? [String: Any],
|
|
let username = user["username"] as? String {
|
|
self.traktUsername = username
|
|
self.traktStatus = "Logged in as \(username)"
|
|
}
|
|
} catch {
|
|
self.traktStatus = "Failed to parse response"
|
|
}
|
|
}
|
|
}.resume()
|
|
}
|
|
|
|
func getTraktTokenFromKeychain() -> String? {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: TraktToken.serviceName,
|
|
kSecAttrAccount as String: TraktToken.accessTokenKey,
|
|
kSecReturnData as String: kCFBooleanTrue!,
|
|
kSecMatchLimit as String: kSecMatchLimitOne
|
|
]
|
|
|
|
var item: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
guard status == errSecSuccess,
|
|
let tokenData = item as? Data,
|
|
let token = String(data: tokenData, encoding: .utf8) else {
|
|
return nil
|
|
}
|
|
return token
|
|
}
|
|
|
|
func removeTraktTokenFromKeychain() {
|
|
let deleteQuery: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: TraktToken.serviceName,
|
|
kSecAttrAccount as String: TraktToken.accessTokenKey
|
|
]
|
|
SecItemDelete(deleteQuery as CFDictionary)
|
|
|
|
let refreshDeleteQuery: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: TraktToken.serviceName,
|
|
kSecAttrAccount as String: TraktToken.refreshTokenKey
|
|
]
|
|
SecItemDelete(refreshDeleteQuery as CFDictionary)
|
|
}
|
|
|
|
func loginAniList() {
|
|
anilistStatus = "Starting authentication..."
|
|
isAnilistLoading = true
|
|
AniListLogin.authenticate()
|
|
}
|
|
|
|
func logoutAniList() {
|
|
removeTokenFromKeychain()
|
|
anilistStatus = "You are not logged in"
|
|
isAnilistLoggedIn = false
|
|
anilistUsername = ""
|
|
profileColor = .primary
|
|
}
|
|
|
|
func updateAniListStatus() {
|
|
if let token = getTokenFromKeychain() {
|
|
isAnilistLoggedIn = true
|
|
fetchUserInfo(token: token)
|
|
} else {
|
|
isAnilistLoggedIn = false
|
|
anilistStatus = "You are not logged in"
|
|
}
|
|
}
|
|
|
|
func fetchUserInfo(token: String) {
|
|
isAnilistLoading = true
|
|
let userInfoURL = URL(string: "https://graphql.anilist.co")!
|
|
var request = URLRequest(url: userInfoURL)
|
|
request.httpMethod = "POST"
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
let query = """
|
|
{
|
|
Viewer {
|
|
id
|
|
name
|
|
options {
|
|
profileColor
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
let body: [String: Any] = ["query": query]
|
|
|
|
do {
|
|
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
|
|
} catch {
|
|
anilistStatus = "Failed to serialize request"
|
|
Logger.shared.log("Failed to serialize request", type: "Error")
|
|
isAnilistLoading = false
|
|
return
|
|
}
|
|
|
|
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
DispatchQueue.main.async {
|
|
isAnilistLoading = false
|
|
if let error = error {
|
|
anilistStatus = "Error: \(error.localizedDescription)"
|
|
Logger.shared.log("Error: \(error.localizedDescription)", type: "Error")
|
|
return
|
|
}
|
|
guard let data = data else {
|
|
anilistStatus = "No data received"
|
|
Logger.shared.log("No data received", type: "Error")
|
|
return
|
|
}
|
|
do {
|
|
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
|
let dataDict = json["data"] as? [String: Any],
|
|
let viewer = dataDict["Viewer"] as? [String: Any],
|
|
let name = viewer["name"] as? String,
|
|
let options = viewer["options"] as? [String: Any],
|
|
let colorName = options["profileColor"] as? String {
|
|
|
|
anilistUsername = name
|
|
profileColor = colorFromName(colorName)
|
|
anilistStatus = "Logged in as \(name)"
|
|
} else {
|
|
anilistStatus = "Unexpected response format!"
|
|
Logger.shared.log("Unexpected response format!", type: "Error")
|
|
}
|
|
} catch {
|
|
anilistStatus = "Failed to parse response: \(error.localizedDescription)"
|
|
Logger.shared.log("Failed to parse response: \(error.localizedDescription)", type: "Error")
|
|
}
|
|
}
|
|
}.resume()
|
|
}
|
|
|
|
func colorFromName(_ name: String) -> Color {
|
|
switch name.lowercased() {
|
|
case "blue":
|
|
return .blue
|
|
case "purple":
|
|
return .purple
|
|
case "green":
|
|
return .green
|
|
case "orange":
|
|
return .orange
|
|
case "red":
|
|
return .red
|
|
case "pink":
|
|
return .pink
|
|
case "gray":
|
|
return .gray
|
|
default:
|
|
return .accentColor
|
|
}
|
|
}
|
|
|
|
func getTokenFromKeychain() -> String? {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: "me.cranci.sora.AniListToken",
|
|
kSecAttrAccount as String: "AniListAccessToken",
|
|
kSecReturnData as String: kCFBooleanTrue!,
|
|
kSecMatchLimit as String: kSecMatchLimitOne
|
|
]
|
|
var item: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
guard status == errSecSuccess, let tokenData = item as? Data else {
|
|
return nil
|
|
}
|
|
return String(data: tokenData, encoding: .utf8)
|
|
}
|
|
|
|
func removeTokenFromKeychain() {
|
|
let deleteQuery: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: "me.cranci.sora.AniListToken",
|
|
kSecAttrAccount as String: "AniListAccessToken"
|
|
]
|
|
SecItemDelete(deleteQuery as CFDictionary)
|
|
}
|
|
}
|