Sora/Sora/Views/SettingsView/SettingsView.swift
2025-07-25 21:43:19 +02:00

535 lines
23 KiB
Swift

//
// SettingsView.swift
// Sora
//
// Created by Francesco on 05/01/25.
//
import SwiftUI
import NukeUI
fileprivate struct SettingsNavigationRow: View {
let icon: String
let titleKey: String
let isExternal: Bool
let textColor: Color
init(icon: String, titleKey: String, isExternal: Bool = false, textColor: Color = .primary) {
self.icon = icon
self.titleKey = titleKey
self.isExternal = isExternal
self.textColor = textColor
}
var body: some View {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(textColor)
Text(NSLocalizedString(titleKey, comment: ""))
.foregroundStyle(textColor)
Spacer()
if isExternal {
Image(systemName: "safari")
.foregroundStyle(.gray)
} else {
Image(systemName: "chevron.right")
.foregroundStyle(.gray)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
fileprivate struct ModulePreviewRow: View {
@EnvironmentObject var moduleManager: ModuleManager
@AppStorage("selectedModuleId") private var selectedModuleId: String?
private var selectedModule: ScrapingModule? {
guard let id = selectedModuleId else { return nil }
return moduleManager.modules.first { $0.id.uuidString == id }
}
var body: some View {
HStack(spacing: 16) {
if let module = selectedModule {
LazyImage(url: URL(string: module.metadata.iconUrl)) { state in
if let uiImage = state.imageContainer?.image {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 12))
} else {
Image(systemName: "cube")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 60, height: 60)
.foregroundStyle(Color.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
VStack(alignment: .leading, spacing: 4) {
Text(module.metadata.sourceName)
.font(.headline)
.foregroundStyle(.primary)
Text("Tap to manage your modules")
.font(.subheadline)
.foregroundStyle(.gray)
.lineLimit(1)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Image(systemName: "cube")
.font(.system(size: 36))
.foregroundStyle(Color.accentColor)
VStack(alignment: .leading, spacing: 4) {
Text("No Module Selected")
.font(.headline)
.foregroundStyle(.primary)
Text("Tap to select a module")
.font(.subheadline)
.foregroundStyle(.gray)
.lineLimit(1)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
Image(systemName: "chevron.right")
.foregroundStyle(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 16)
.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
)
)
}
}
struct SettingsView: View {
@Environment(\.colorScheme) var colorScheme
@StateObject var settings = Settings()
@EnvironmentObject var moduleManager: ModuleManager
@State private var isNavigationActive = false
var body: some View {
NavigationView {
ScrollView(showsIndicators: false) {
VStack(spacing: 24) {
Text("Settings")
.font(.largeTitle)
.fontWeight(.bold)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.top, 16)
VStack(alignment: .leading, spacing: 4) {
Text("MODULES")
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
NavigationLink(destination: SettingsViewModule().navigationBarBackButtonHidden(false)) {
ModulePreviewRow()
}
.padding(.horizontal, 20)
}
VStack(alignment: .leading, spacing: 4) {
Text("MAIN SETTINGS")
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
NavigationLink(destination: SettingsViewGeneral().navigationBarBackButtonHidden(false)) {
SettingsNavigationRow(icon: "gearshape", titleKey: "General Preferences")
}
Divider().padding(.horizontal, 16)
NavigationLink(destination: SettingsViewLibrary().navigationBarBackButtonHidden(false)) {
SettingsNavigationRow(icon: "books.vertical", titleKey: "Library")
}
Divider().padding(.horizontal, 16)
NavigationLink(destination: SettingsViewPlayer().navigationBarBackButtonHidden(false)) {
SettingsNavigationRow(icon: "play.circle", titleKey: "Video Player")
}
Divider().padding(.horizontal, 16)
NavigationLink(destination: SettingsViewDownloads().navigationBarBackButtonHidden(false)) {
SettingsNavigationRow(icon: "arrow.down.circle", titleKey: "Downloads")
}
Divider().padding(.horizontal, 16)
NavigationLink(destination: SettingsViewTrackers().navigationBarBackButtonHidden(false)) {
SettingsNavigationRow(icon: "square.3.stack.3d", titleKey: "Trackers")
}
}
.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)
}
VStack(alignment: .leading, spacing: 4) {
Text("DATA & LOGS")
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
NavigationLink(destination: SettingsViewData().navigationBarBackButtonHidden(false)) {
SettingsNavigationRow(icon: "folder", titleKey: "Data")
}
Divider().padding(.horizontal, 16)
NavigationLink(destination: SettingsViewLogger().navigationBarBackButtonHidden(false)) {
SettingsNavigationRow(icon: "doc.text", titleKey: "Logs")
}
Divider().padding(.horizontal, 16)
NavigationLink(destination: SettingsViewBackup().navigationBarBackButtonHidden(false)) {
SettingsNavigationRow(icon: "arrow.triangle.2.circlepath", titleKey: NSLocalizedString("Backup & Restore", comment: "Settings navigation row for backup and restore"))
}
}
.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)
}
VStack(alignment: .leading, spacing: 4) {
Text(NSLocalizedString("INFOS", comment: ""))
.font(.footnote)
.foregroundStyle(.gray)
.padding(.horizontal, 20)
VStack(spacing: 0) {
NavigationLink(destination: SettingsViewAbout().navigationBarBackButtonHidden(false)) {
SettingsNavigationRow(icon: "info.circle", titleKey: "About Sora")
}
Divider().padding(.horizontal, 16)
Link(destination: URL(string: "https://github.com/cranci1/Sora")!) {
HStack {
Image("Github Icon")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.padding(.leading, 2)
.padding(.trailing, 4)
Text(NSLocalizedString("Sora GitHub Repository", comment: ""))
.foregroundStyle(.gray)
Spacer()
Image(systemName: "safari")
.foregroundStyle(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
Divider().padding(.horizontal, 16)
Link(destination: URL(string: "https://discord.gg/x7hppDWFDZ")!) {
HStack {
Image("Discord Icon")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.padding(.leading, 2)
.padding(.trailing, 4)
Text(NSLocalizedString("Join the Discord", comment: ""))
.foregroundStyle(.gray)
Spacer()
Image(systemName: "safari")
.foregroundStyle(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
Divider().padding(.horizontal, 16)
Link(destination: URL(string: "https://github.com/cranci1/Sora/issues")!) {
SettingsNavigationRow(
icon: "exclamationmark.circle.fill",
titleKey: "Report an Issue",
isExternal: true,
textColor: .gray
)
}
Divider().padding(.horizontal, 16)
Link(destination: URL(string: "https://github.com/cranci1/Sora/blob/dev/LICENSE")!) {
SettingsNavigationRow(
icon: "doc.text.fill",
titleKey: "License (GPLv3.0)",
isExternal: true,
textColor: .gray
)
}
}
.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)
}
Text("Sora 1.0.1 by cranci1")
.font(.footnote)
.foregroundStyle(.gray)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 8)
}
.scrollViewBottomPadding()
.padding(.bottom, 20)
}
.deviceScaled()
}
.navigationViewStyle(StackNavigationViewStyle())
.navigationBarHidden(true)
.onChange(of: colorScheme) { newScheme in
if settings.selectedAppearance == .system {
settings.updateAccentColor(currentColorScheme: newScheme)
}
}
.onChange(of: settings.selectedAppearance) { _ in
settings.updateAccentColor(currentColorScheme: colorScheme)
}
.onAppear {
settings.updateAccentColor(currentColorScheme: colorScheme)
}
}
}
enum Appearance: String, CaseIterable, Identifiable {
case system, light, dark
var id: String { self.rawValue }
}
class Settings: ObservableObject {
@Published var accentColor: Color {
didSet {
}
}
@Published var selectedAppearance: Appearance {
didSet {
UserDefaults.standard.set(selectedAppearance.rawValue, forKey: "selectedAppearance")
updateAppearance()
}
}
@Published var selectedLanguage: String {
didSet {
UserDefaults.standard.set(selectedLanguage, forKey: "selectedLanguage")
updateLanguage()
}
}
init() {
self.accentColor = .primary
if let appearanceRawValue = UserDefaults.standard.string(forKey: "selectedAppearance"),
let appearance = Appearance(rawValue: appearanceRawValue) {
self.selectedAppearance = appearance
} else {
self.selectedAppearance = .system
}
self.selectedLanguage = UserDefaults.standard.string(forKey: "selectedLanguage") ?? "English"
updateAppearance()
updateLanguage()
}
func updateAccentColor(currentColorScheme: ColorScheme? = nil) {
switch selectedAppearance {
case .system:
if let scheme = currentColorScheme {
accentColor = scheme == .dark ? .white : .black
} else {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first else { return }
accentColor = window.traitCollection.userInterfaceStyle == .dark ? .white : .black
}
case .light:
accentColor = .black
case .dark:
accentColor = .white
}
}
func updateAppearance() {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
switch selectedAppearance {
case .system:
windowScene.windows.first?.overrideUserInterfaceStyle = .unspecified
case .light:
windowScene.windows.first?.overrideUserInterfaceStyle = .light
case .dark:
windowScene.windows.first?.overrideUserInterfaceStyle = .dark
}
}
func updateLanguage() {
let languageCode: String
switch selectedLanguage {
case "Dutch":
languageCode = "nl"
case "French":
languageCode = "fr"
case "German":
languageCode = "de"
case "Arabic":
languageCode = "ar"
case "Bosnian":
languageCode = "bos"
case "Czech":
languageCode = "cs"
case "Slovak":
languageCode = "sk"
case "Spanish":
languageCode = "es"
case "Russian":
languageCode = "ru"
case "Norsk":
languageCode = "nn"
case "Kazakh":
languageCode = "kk"
case "Mongolian":
languageCode = "mn"
let mainBundle = Bundle.main
if let lprojPaths = mainBundle.paths(forResourcesOfType: "lproj", inDirectory: nil) as? [String] {
let availableLangs = lprojPaths.map { path -> String in
let components = path.components(separatedBy: "/")
let filename = components.last ?? ""
return filename.replacingOccurrences(of: ".lproj", with: "")
}
Logger.shared.log("Available language bundles: \(availableLangs.joined(separator: ", "))", type: "Debug")
}
if let _ = mainBundle.path(forResource: "mn", ofType: "lproj") {
Logger.shared.log("Found mn.lproj bundle", type: "Debug")
} else {
Logger.shared.log("mn.lproj bundle not found", type: "Error")
}
case "Romanian":
languageCode = "ro"
let mainBundle = Bundle.main
if let lprojPaths = mainBundle.paths(forResourcesOfType: "lproj", inDirectory: nil) as? [String] {
let availableLangs = lprojPaths.map { path -> String in
let components = path.components(separatedBy: "/")
let filename = components.last ?? ""
return filename.replacingOccurrences(of: ".lproj", with: "")
}
Logger.shared.log("Available language bundles: \(availableLangs.joined(separator: ", "))", type: "Debug")
}
if let _ = mainBundle.path(forResource: "ro", ofType: "lproj") {
Logger.shared.log("Found ro.lproj bundle", type: "Debug")
} else {
Logger.shared.log("ro.lproj bundle not found", type: "Error")
}
case "Swedish":
languageCode = "sv"
case "Italian":
languageCode = "it"
default:
languageCode = "en"
}
UserDefaults.standard.set([languageCode], forKey: "AppleLanguages")
Logger.shared.log("Setting language to: \(languageCode) for \(selectedLanguage)", type: "Debug")
UserDefaults.standard.synchronize()
LocalizationManager.shared.setLanguage(languageCode)
if selectedLanguage == "Mongolian" {
if let mongolianBundle = Bundle(path: Bundle.main.path(forResource: "mn", ofType: "lproj") ?? "") {
Logger.shared.log("Mongolian bundle: \(mongolianBundle)", type: "Debug")
let testKey = "About"
let testString = mongolianBundle.localizedString(forKey: testKey, value: nil, table: nil)
Logger.shared.log("Test Mongolian string for '\(testKey)': \(testString)", type: "Debug")
}
}
if selectedLanguage == "Romanian" {
if let romanianBundle = Bundle(path: Bundle.main.path(forResource: "ro", ofType: "lproj") ?? "") {
Logger.shared.log("Romanian bundle: \(romanianBundle)", type: "Debug")
let testKey = "About"
let testString = romanianBundle.localizedString(forKey: testKey, value: nil, table: nil)
Logger.shared.log("Test Romanian string for '\(testKey)': \(testString)", type: "Debug")
}
}
}
}