From e90ba11535febbe49420a0db5748f5a356d5428b Mon Sep 17 00:00:00 2001 From: Stephen B <158498287+StephenDev0@users.noreply.github.com> Date: Mon, 22 Sep 2025 22:33:20 -0400 Subject: [PATCH] 2.3.0 RC 2 (Very WIP) --- StikDebug.xcodeproj/project.pbxproj | 4 + .../xcshareddata/xcschemes/StikDebug.xcscheme | 3 + StikJIT/JSSupport/ScriptEditorView.swift | 72 +- StikJIT/JSSupport/ScriptListView.swift | 49 +- StikJIT/StikJITApp.swift | 95 +-- StikJIT/Utilities/AppTheme.swift | 240 ++++-- StikJIT/Utilities/ThemeExpansionManager.swift | 322 +++++++ StikJIT/Views/ConsoleLogsView.swift | 382 +-------- StikJIT/Views/Custom Error View.swift | 7 +- StikJIT/Views/DeviceInfoManager.swift | 7 +- StikJIT/Views/DisplayView.swift | 534 ++++++++++-- StikJIT/Views/HomeView.swift | 805 ++++++++++++++---- StikJIT/Views/InstalledAppsListView.swift | 276 ++++-- StikJIT/Views/MainTabView.swift | 14 +- StikJIT/Views/SettingsView.swift | 107 +-- StikJIT/Views/Testing.swift | 5 +- 16 files changed, 2008 insertions(+), 914 deletions(-) create mode 100644 StikJIT/Utilities/ThemeExpansionManager.swift diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index 2287c05..211b077 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 17C744F02E20BED000834F17 /* Pipify in Frameworks */ = {isa = PBXBuildFile; productRef = 17C744EF2E20BED000834F17 /* Pipify */; }; + 68AA2E4D2E8214A1008342E8 /* StikDebug.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 68AA2E4C2E8214A1008342E8 /* StikDebug.storekit */; }; 68AD80462E6B65DA00DE9112 /* ZsignSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 68AD80452E6B65DA00DE9112 /* ZsignSwift */; }; 68AD80482E6B65DA00DE9112 /* zsign in Frameworks */ = {isa = PBXBuildFile; productRef = 68AD80472E6B65DA00DE9112 /* zsign */; }; 68D569BE2E1B415700A5BA36 /* CodeEditorView in Frameworks */ = {isa = PBXBuildFile; productRef = 68D569BD2E1B415700A5BA36 /* CodeEditorView */; }; @@ -68,6 +69,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 68AA2E4C2E8214A1008342E8 /* StikDebug.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = StikDebug.storekit; sourceTree = ""; }; DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DebugWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; DC139F6D2DE97EA400F63846 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; DC139F6F2DE97EA400F63846 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; @@ -193,6 +195,7 @@ isa = PBXGroup; children = ( DC139F862DE97F2000F63846 /* DebugWidgetExtension.entitlements */, + 68AA2E4C2E8214A1008342E8 /* StikDebug.storekit */, DC6F1D392D94EADD0071B2B6 /* StikJIT */, DC6F1D4B2D94EADF0071B2B6 /* StikJITTests */, DC6F1D552D94EADF0071B2B6 /* StikJITUITests */, @@ -416,6 +419,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 68AA2E4D2E8214A1008342E8 /* StikDebug.storekit in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/StikDebug.xcodeproj/xcshareddata/xcschemes/StikDebug.xcscheme b/StikDebug.xcodeproj/xcshareddata/xcschemes/StikDebug.xcscheme index dd8d317..006d471 100644 --- a/StikDebug.xcodeproj/xcshareddata/xcschemes/StikDebug.xcscheme +++ b/StikDebug.xcodeproj/xcshareddata/xcschemes/StikDebug.xcscheme @@ -74,6 +74,9 @@ ReferencedContainer = "container:StikDebug.xcodeproj"> + + > = [] + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss + @Environment(\.themeExpansionManager) private var themeExpansion + + private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } + private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) } + private var editorTheme: Theme { + colorScheme == .dark ? Theme.defaultDark : Theme.defaultLight + } var body: some View { - VStack(spacing: 0) { - CodeEditor( - text: $scriptContent, - position: $position, - messages: $messages, - language: .swift() - ) - .font(.system(.footnote, design: .monospaced)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .environment( - \.codeEditorTheme, - colorScheme == .dark ? Theme.defaultDark : Theme.defaultLight - ) + ZStack { + ThemedBackground(style: backgroundStyle) + .ignoresSafeArea() - Divider() + VStack(spacing: 0) { + CodeEditor( + text: $scriptContent, + position: $position, + messages: $messages, + language: .swift() + ) + .font(.system(.footnote, design: .monospaced)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .environment(\.codeEditorTheme, editorTheme) - HStack(spacing: 12) { - WideGlassyButton(title: "Cancel", systemImage: "xmark") { - dismiss() - } - WideGlassyButton(title: "Save", systemImage: "checkmark") { - saveScript() - dismiss() + Divider() + + HStack(spacing: 12) { + WideGlassyButton(title: "Cancel", systemImage: "xmark") { + dismiss() + } + WideGlassyButton(title: "Save", systemImage: "checkmark") { + saveScript() + dismiss() + } } + .padding(.horizontal) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(bottomBarBackground) } - .padding(.horizontal) - .padding(.vertical, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity) } .navigationTitle(scriptURL.lastPathComponent) .navigationBarTitleDisplayMode(.inline) .onAppear(perform: loadScript) - .frame(maxWidth: .infinity, maxHeight: .infinity) + .preferredColorScheme(preferredScheme) } private func loadScript() { @@ -61,6 +74,17 @@ struct ScriptEditorView: View { private func saveScript() { try? scriptContent.write(to: scriptURL, atomically: true, encoding: .utf8) } + + private var bottomBarBackground: some View { + Rectangle() + .fill(Color.black.opacity(colorScheme == .dark ? 0.35 : 0.08)) + .overlay( + Rectangle() + .fill(Color.white.opacity(0.08)) + .frame(height: 1), + alignment: .top + ) + } } // MARK: - Equal-width rounded-rectangle button (centered content) diff --git a/StikJIT/JSSupport/ScriptListView.swift b/StikJIT/JSSupport/ScriptListView.swift index 5a89417..9e3e502 100644 --- a/StikJIT/JSSupport/ScriptListView.swift +++ b/StikJIT/JSSupport/ScriptListView.swift @@ -37,12 +37,14 @@ struct ScriptListView: View { } @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue - private var currentTheme: AppTheme { AppTheme(rawValue: appThemeRaw) ?? .system } + @Environment(\.themeExpansionManager) private var themeExpansion + private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } + private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) } var body: some View { NavigationStack { ZStack { - ThemedBackground(style: currentTheme.backgroundStyle) + ThemedBackground(style: backgroundStyle) .ignoresSafeArea() ScrollView { @@ -134,6 +136,7 @@ struct ScriptListView: View { } } } + .preferredColorScheme(preferredScheme) } // MARK: - Cards @@ -251,13 +254,41 @@ struct ScriptListView: View { } private var glassyBackground: some View { - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + ZStack { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: overlayColors()), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .opacity(0.32) + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + } + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + } + + private func overlayColors() -> [Color] { + let colors: [Color] + switch backgroundStyle { + case .staticGradient(let palette): + colors = palette + case .animatedGradient(let palette, _): + colors = palette + case .blobs(_, let background): + colors = background + case .particles(let particle, let background): + colors = background.isEmpty ? [particle, particle.opacity(0.4)] : background + case .customGradient(let palette): + colors = palette + } + if colors.count >= 2 { return colors } + if let first = colors.first { return [first, first.opacity(0.6)] } + return [Color.blue, Color.purple] } // MARK: - File Ops diff --git a/StikJIT/StikJITApp.swift b/StikJIT/StikJITApp.swift index 22d1600..dc2689e 100644 --- a/StikJIT/StikJITApp.swift +++ b/StikJIT/StikJITApp.swift @@ -25,9 +25,10 @@ struct WelcomeSheetView: View { var onDismiss: (() -> Void)? @Environment(\.colorScheme) private var colorScheme @AppStorage("customAccentColor") private var customAccentColorHex: String = "" + @Environment(\.themeExpansionManager) private var themeExpansion private var accent: Color { - customAccentColorHex.isEmpty ? .accentColor : (Color(hex: customAccentColorHex) ?? .accentColor) + themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue } var body: some View { @@ -496,11 +497,11 @@ struct HeartbeatApp: App { @State private var alert_string = "" @State private var alert_title = "" @State private var showTimeoutError = false - @State private var showLogs = false @State private var showContinueWarning = false @State private var timeoutTimer: Timer? @StateObject private var mount = MountingProgress.shared @StateObject private var dnsChecker = DNSChecker() + @StateObject private var themeExpansionManager = ThemeExpansionManager() @Environment(\.scenePhase) private var scenePhase // Observe scene lifecycle let urls: [String] = [ @@ -524,14 +525,18 @@ struct HeartbeatApp: App { let origMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:)))! method_exchangeImplementations(origMethod, fixMethod) - // Initialize UIKit tint from stored accent at launch - HeartbeatApp.updateUIKitTintFromStoredAccent() + // Initialize UIKit tint from stored accent at launch (defaults to blue until entitlements load) + HeartbeatApp.updateUIKitTint(customHex: customAccentColorHex, hasAccess: false) } // Make this static so we can call it without capturing self in init - private static func updateUIKitTintFromStoredAccent() { - let hex = UserDefaults.standard.string(forKey: "customAccentColor") ?? "" - let color = hex.isEmpty ? UIColor.tintColor : UIColor(Color(hex: hex) ?? .accentColor) + private static func updateUIKitTint(customHex: String, hasAccess: Bool) { + let color: UIColor + if hasAccess, !customHex.isEmpty, let swiftColor = Color(hex: customHex) { + color = UIColor(swiftColor) + } else { + color = .systemBlue + } UIView.appearance().tintColor = color } @@ -554,8 +559,7 @@ struct HeartbeatApp: App { } private var globalAccent: Color { - let hex = customAccentColorHex - return hex.isEmpty ? .accentColor : (Color(hex: hex) ?? .accentColor) + themeExpansionManager.resolvedAccentColor(from: customAccentColorHex) } var body: some Scene { @@ -646,14 +650,9 @@ struct HeartbeatApp: App { }, showButton: true, primaryButtonText: "Continue Anyway", - secondaryButtonText: "View Logs", onPrimaryButtonTap: { showContinueWarning = true - }, - onSecondaryButtonTap: { - showLogs = true - }, - showSecondaryButton: true + } ) } @@ -674,11 +673,6 @@ struct HeartbeatApp: App { } } ) - .sheet(isPresented: $showLogs, onDismiss: { - isLoading2 = false - }) { - ConsoleLogsView() - } } else { MainTabView() .onAppear { @@ -713,33 +707,34 @@ struct HeartbeatApp: App { ) } } - // Apply global tint to all SwiftUI views in this window - .tint(globalAccent) - .onAppear { - // On first launch, present the welcome sheet. - // Otherwise, start the VPN automatically. - if !hasLaunchedBefore { - showWelcomeSheet = true - } else { - TunnelManager.shared.startVPN() - } - // Update UIKit tint now and subscribe to changes without capturing self - HeartbeatApp.updateUIKitTintFromStoredAccent() - NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { _ in - HeartbeatApp.updateUIKitTintFromStoredAccent() - } + } + .themeExpansionManager(themeExpansionManager) + // Apply global tint to all SwiftUI views in this window + .tint(globalAccent) + .onAppear { + // On first launch, present the welcome sheet. + // Otherwise, start the VPN automatically. + if !hasLaunchedBefore { + showWelcomeSheet = true + } else { + TunnelManager.shared.startVPN() } - .sheet(isPresented: $showWelcomeSheet) { - WelcomeSheetView { - // When the user taps "Continue", mark the app as launched and start the VPN. - hasLaunchedBefore = true - showWelcomeSheet = false - TunnelManager.shared.startVPN() - } + HeartbeatApp.updateUIKitTint(customHex: customAccentColorHex, + hasAccess: themeExpansionManager.hasThemeExpansion) + } + .onChange(of: themeExpansionManager.hasThemeExpansion) { hasAccess in + HeartbeatApp.updateUIKitTint(customHex: customAccentColorHex, hasAccess: hasAccess) + } + .onChange(of: customAccentColorHex) { newHex in + HeartbeatApp.updateUIKitTint(customHex: newHex, + hasAccess: themeExpansionManager.hasThemeExpansion) + } + .sheet(isPresented: $showWelcomeSheet) { + WelcomeSheetView { + // When the user taps "Continue", mark the app as launched and start the VPN. + hasLaunchedBefore = true + showWelcomeSheet = false + TunnelManager.shared.startVPN() } } } @@ -954,13 +949,10 @@ struct LoadingView: View { @State private var animate = false @Environment(\.colorScheme) private var colorScheme @AppStorage("customAccentColor") private var customAccentColorHex: String = "" + @Environment(\.themeExpansionManager) private var themeExpansion private var accentColor: Color { - if customAccentColorHex.isEmpty { - return .accentColor - } else { - return Color(hex: customAccentColorHex) ?? .accentColor - } + themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue } var body: some View { @@ -1109,4 +1101,3 @@ func downloadFile(from urlString: String, to destinationURL: URL, completion: @e task.resume() completion("") } - diff --git a/StikJIT/Utilities/AppTheme.swift b/StikJIT/Utilities/AppTheme.swift index 72a9b60..1c6870a 100644 --- a/StikJIT/Utilities/AppTheme.swift +++ b/StikJIT/Utilities/AppTheme.swift @@ -10,63 +10,108 @@ import SwiftUI // MARK: - AppTheme model enum AppTheme: String, CaseIterable, Identifiable { - case system // follows system appearance; static gradient - case darkStatic // dark + static gradient - case neonAnimated // animated gradient - case blobs // morphing blobs - case particles // subtle particle field + case system // balanced default gradient + case darkStatic // deep charcoal blend + case neonAnimated // neon pulse + case blobs // vibrant haze + case particles // subtle celestial particles + case aurora // shifting aurora lights + case sunset // warm sunset glow + case ocean // tranquil ocean blues + case forest // lush forest canopy + case midnight // midnight horizon + case cyberwave // synthwave inspired var id: String { rawValue } var displayName: String { switch self { - case .system: return "System" - case .darkStatic: return "Dark" - case .neonAnimated: return "Neon" - case .blobs: return "Blobs" - case .particles: return "Particles" + case .system: return "Default" + case .darkStatic: return "Obsidian" + case .neonAnimated: return "Neon Pulse" + case .blobs: return "Haze" + case .particles: return "Stardust" + case .aurora: return "Aurora" + case .sunset: return "Sunset" + case .ocean: return "Ocean" + case .forest: return "Forest" + case .midnight: return "Midnight" + case .cyberwave: return "Cyberwave" } } - // If you want the theme to force a color scheme, set it here. - // Your app currently forces dark globally; we keep nil so nothing fights it. var preferredColorScheme: ColorScheme? { switch self { - case .system: return nil - case .darkStatic: return .dark - case .neonAnimated: return .dark - case .blobs: return .dark - case .particles: return .dark + case .system, .sunset, .forest: return nil + case .darkStatic, .neonAnimated, .blobs, .particles, .aurora, .ocean, .midnight, .cyberwave: return .dark } } var backgroundStyle: BackgroundStyle { switch self { - case .system: return .staticGradient - case .darkStatic: return .staticGradient - case .neonAnimated: return .animatedGradient - case .blobs: return .blobs - case .particles: return .particles + case .system: + return .staticGradient(colors: Palette.defaultGradient) + case .darkStatic: + return .staticGradient(colors: Palette.obsidianGradient) + case .neonAnimated: + return .animatedGradient(colors: Palette.neon, speed: 0.10) + case .blobs: + return .blobs(colors: Palette.hazeBlobs, background: Palette.hazeBackground) + case .particles: + return .particles(particle: Palette.stardustParticle, background: Palette.stardustBackground) + case .aurora: + return .animatedGradient(colors: Palette.aurora, speed: 0.08) + case .sunset: + return .staticGradient(colors: Palette.sunset) + case .ocean: + return .animatedGradient(colors: Palette.ocean, speed: 0.06) + case .forest: + return .staticGradient(colors: Palette.forest) + case .midnight: + return .particles(particle: Palette.midnightParticle, background: Palette.midnightBackground) + case .cyberwave: + return .blobs(colors: Palette.cyberwaveBlobs, background: Palette.cyberwaveBackground) } } } // MARK: - Background styles and factory -enum BackgroundStyle { - case staticGradient - case animatedGradient - case blobs - case particles +enum BackgroundStyle: Equatable { + case staticGradient(colors: [Color]) + case animatedGradient(colors: [Color], speed: Double) + case blobs(colors: [Color], background: [Color]) + case particles(particle: Color, background: [Color]) + case customGradient(colors: [Color]) } -// Slightly brighter, clearly-not-black static background that still respects dark mode -private func staticBackgroundGradient() -> LinearGradient { +private struct Palette { + static let defaultGradient = hexColors("#1C1F3A", "#3E4C7C", "#1F1C2C") + static let obsidianGradient = hexColors("#000000", "#1C1C1C", "#262626") + static let neon = hexColors("#00F5A0", "#00D9F5", "#C96BFF") + static let hazeBlobs = hexColors("#FF8BA7", "#A98BFF", "#70C8FF", "#67FFDA") + static let hazeBackground = hexColors("#141321", "#1E1C2A") + static let stardustParticle = Color(hex: "#9BD4FF") ?? .white + static let stardustBackground = hexColors("#090A1A", "#1C1F3A") + static let aurora = hexColors("#0BA360", "#3CBA92", "#8241FF") + static let sunset = hexColors("#FF5F6D", "#FFC371", "#FF9966") + static let ocean = hexColors("#0093E9", "#80D0C7", "#13547A") + static let forest = hexColors("#2F7336", "#AA3A38", "#052E03") + static let midnightParticle = Color(hex: "#9F9FFF") ?? .white + static let midnightBackground = hexColors("#0F2027", "#203A43", "#2C5364") + static let cyberwaveBlobs = hexColors("#FF0080", "#7928CA", "#2A2A72", "#00F0FF") + static let cyberwaveBackground = hexColors("#08001A", "#110032") + + static func hexColors(_ hexes: String...) -> [Color] { + hexes.compactMap { Color(hex: $0) } + } +} + +// MARK: - Helpers + +private func staticGradientView(colors: [Color]) -> some View { LinearGradient( - gradient: Gradient(colors: [ - Color(UIColor.systemBackground), - Color(UIColor.secondarySystemBackground) - ]), + gradient: Gradient(colors: colors.ensureMinimumCount()), startPoint: .topLeading, endPoint: .bottomTrailing ) @@ -79,30 +124,33 @@ struct ThemedBackground: View { var body: some View { Group { switch style { - case .staticGradient: - staticBackgroundGradient() + case .staticGradient(let colors): + staticGradientView(colors: colors) .ignoresSafeArea() - case .animatedGradient: + case .animatedGradient(let colors, let speed): if reduceMotion { - staticBackgroundGradient() + staticGradientView(colors: colors) .ignoresSafeArea() } else { - AnimatedGradientBackground() + AnimatedGradientBackground(colors: colors, speed: speed) } - case .blobs: + case .blobs(let colors, let background): if reduceMotion { - staticBackgroundGradient() + staticGradientView(colors: background.isEmpty ? colors : background) .ignoresSafeArea() } else { - BlobBackground() + BlobBackground(blobColors: colors.ensureMinimumCount(), backgroundColors: background.ensureMinimumCount()) } - case .particles: + case .particles(let particle, let background): if reduceMotion { - staticBackgroundGradient() + staticGradientView(colors: background.ensureMinimumCount()) .ignoresSafeArea() } else { - ParticleFieldBackground() + ParticleFieldBackground(particleColor: particle, backgroundColors: background.ensureMinimumCount()) } + case .customGradient(let colors): + staticGradientView(colors: colors) + .ignoresSafeArea() } } } @@ -112,52 +160,54 @@ struct ThemedBackground: View { struct BackgroundContainer: View { @AppStorage("appTheme") private var rawTheme: String = AppTheme.system.rawValue + @Environment(\.themeExpansionManager) private var themeExpansion let content: Content init(@ViewBuilder content: () -> Content) { self.content = content() } - private var theme: AppTheme { - AppTheme(rawValue: rawTheme) ?? .system + private var backgroundStyle: BackgroundStyle { + themeExpansion?.backgroundStyle(for: rawTheme) ?? AppTheme.system.backgroundStyle + } + + private var preferredScheme: ColorScheme? { + themeExpansion?.preferredColorScheme(for: rawTheme) } var body: some View { ZStack { - ThemedBackground(style: theme.backgroundStyle) + ThemedBackground(style: backgroundStyle) .ignoresSafeArea() content } - .preferredColorScheme(theme.preferredColorScheme) // nil means no override + .preferredColorScheme(preferredScheme) } } // MARK: - Animated backgrounds private struct AnimatedGradientBackground: View { - @State private var t: Double = 0 + let colors: [Color] + let speed: Double var body: some View { TimelineView(.animation) { timeline in let now = timeline.date.timeIntervalSinceReferenceDate - let speed = 0.15 let phase = now * speed - - let c1 = Color(hue: (sin(phase) * 0.5 + 0.5), saturation: 0.55, brightness: 0.45) - let c2 = Color(hue: (sin(phase + .pi * 0.66) * 0.5 + 0.5), saturation: 0.75, brightness: 0.35) - let c3 = Color(hue: (sin(phase + .pi * 1.33) * 0.5 + 0.5), saturation: 0.65, brightness: 0.30) - - let angle = Angle(degrees: (sin(phase * 0.7) * 45) + 45) - - AngularGradient(colors: [c1, c2, c3, c1], center: .center, angle: angle) - .saturation(1.0) - .brightness(0.0) - .opacity(0.9) + let rotation = Angle(degrees: phase.truncatingRemainder(dividingBy: 360) * 45) + let gradientColors = colors.ensureMinimumCount() + + Rectangle() + .fill( + AngularGradient(colors: gradientColors + [gradientColors.first!], center: .center, angle: .degrees(0)) + ) + .hueRotation(.degrees(phase.truncatingRemainder(dividingBy: 360) * 30)) + .rotationEffect(rotation) + .scaleEffect(1.2) .ignoresSafeArea() .overlay( - LinearGradient(colors: [.black.opacity(0.25), .clear], - startPoint: .top, - endPoint: .bottom) + LinearGradient(colors: [.black.opacity(0.25), .clear], startPoint: .top, endPoint: .bottom) .ignoresSafeArea() ) } @@ -165,39 +215,41 @@ private struct AnimatedGradientBackground: View { } private struct BlobBackground: View { + let blobColors: [Color] + let backgroundColors: [Color] @State private var t: CGFloat = 0 var body: some View { Canvas { ctx, size in - let baseColors: [Color] = [ - .blue.opacity(0.35), - .purple.opacity(0.35), - .pink.opacity(0.35), - .cyan.opacity(0.35) - ] - - let blobs = 6 + let blobs = max(blobColors.count, 4) let radius = min(size.width, size.height) * 0.45 for i in 0.. [Color] { + if isEmpty { return [.blue, .purple] } + if count == 1 { return [self[0], self[0].opacity(0.6)] } + return self + } +} diff --git a/StikJIT/Utilities/ThemeExpansionManager.swift b/StikJIT/Utilities/ThemeExpansionManager.swift new file mode 100644 index 0000000..3229f92 --- /dev/null +++ b/StikJIT/Utilities/ThemeExpansionManager.swift @@ -0,0 +1,322 @@ +import Foundation +import StoreKit +import SwiftUI + +enum CustomThemeStyle: String, Codable, CaseIterable, Identifiable { + case staticGradient + case animatedGradient + case blobs + case particles + + var id: String { rawValue } + + var displayName: String { + switch self { + case .staticGradient: return "Static Gradient" + case .animatedGradient: return "Animated Gradient" + case .blobs: return "Floating Blobs" + case .particles: return "Particle Field" + } + } +} + +struct CustomTheme: Identifiable, Codable, Equatable { + var id: UUID + var name: String + var style: CustomThemeStyle + var colorHexes: [String] + var preferredColorSchemeRaw: String? + + init(id: UUID = UUID(), + name: String, + style: CustomThemeStyle, + colorHexes: [String], + preferredColorScheme: ColorScheme?) { + self.id = id + self.name = name + self.style = style + self.colorHexes = colorHexes + self.preferredColorSchemeRaw = preferredColorScheme.map { $0 == .dark ? "dark" : "light" } + } + + var preferredColorScheme: ColorScheme? { + switch preferredColorSchemeRaw { + case "light": return .light + case "dark": return .dark + default: return nil + } + } + + var gradientColors: [Color] { + colorHexes.compactMap { Color(hex: $0) } + } + + func updating(name: String? = nil, + style: CustomThemeStyle? = nil, + colors: [String]? = nil, + preferredColorScheme: ColorScheme?? = nil) -> CustomTheme { + CustomTheme(id: id, + name: name ?? self.name, + style: style ?? self.style, + colorHexes: colors ?? self.colorHexes, + preferredColorScheme: preferredColorScheme ?? self.preferredColorScheme) + } +} + +@MainActor +final class ThemeExpansionManager: ObservableObject { + static let productIdentifier = "SD_Theme_Expansion" + + @Published private(set) var hasThemeExpansion = false + @Published private(set) var themeExpansionProduct: Product? + @Published private(set) var isProcessing = false + @Published var lastError: String? + @Published private(set) var customThemes: [CustomTheme] = [] + + private var updatesTask: Task? + private let isPreviewInstance: Bool + private let customThemesKey = "ThemeExpansion.CustomThemes" + + init(previewUnlocked: Bool = false) { + self.isPreviewInstance = previewUnlocked + self.hasThemeExpansion = previewUnlocked + loadCustomThemes() + + if previewUnlocked && customThemes.isEmpty { + customThemes = [ + CustomTheme(name: "Vapor Trail", + style: .animatedGradient, + colorHexes: ["#FF00E0", "#00D0FF", "#7A00FF"], + preferredColorScheme: .dark) + ] + } + + guard !previewUnlocked else { return } + + updatesTask = Task.detached(priority: .background) { [weak self] in + for await result in StoreKit.Transaction.updates { + await self?.handle(transactionResult: result) + } + } + + Task { await refreshEntitlements() } + } + + deinit { + updatesTask?.cancel() + } + + func refreshEntitlements() async { + guard !isPreviewInstance else { return } + isProcessing = true + defer { isProcessing = false } + do { + lastError = nil + let products = try await Product.products(for: [Self.productIdentifier]) + themeExpansionProduct = products.first + + if let latest = await StoreKit.Transaction.latest(for: Self.productIdentifier) { + await handle(transactionResult: latest, finishTransaction: false) + } else { + hasThemeExpansion = false + } + } catch { + lastError = error.localizedDescription + } + } + + func purchaseThemeExpansion() async { + guard !isPreviewInstance else { return } + + let product: Product + if let cached = themeExpansionProduct { + product = cached + } else { + do { + let products = try await Product.products(for: [Self.productIdentifier]) + if let first = products.first { + themeExpansionProduct = first + product = first + } else { + lastError = "Theme Expansion isn't available yet. Check your App Store configuration and try again." + return + } + } catch { + lastError = error.localizedDescription + return + } + } + + isProcessing = true + defer { isProcessing = false } + + do { + lastError = nil + let result = try await product.purchase() + switch result { + case .success(let verification): + await handle(transactionResult: verification) + case .pending: + lastError = "Purchase pending. You'll be notified when it's complete." + case .userCancelled: + break + @unknown default: + lastError = "Purchase failed due to an unknown error." + } + } catch { + lastError = error.localizedDescription + } + } + + func resolvedAccentColor(from hex: String) -> Color { + guard hasThemeExpansion, !hex.isEmpty, let custom = Color(hex: hex) else { return .blue } + return custom + } + + func resolvedTheme(from rawValue: String) -> AppTheme { + guard let theme = AppTheme(rawValue: rawValue), hasThemeExpansion || theme == .system else { + return .system + } + return theme + } + + func backgroundStyle(for identifier: String) -> BackgroundStyle { + if hasThemeExpansion, let custom = customTheme(for: identifier) { + let colors = normalizedColors(from: custom.colorHexes) + switch custom.style { + case .staticGradient: + return .staticGradient(colors: colors) + case .animatedGradient: + return .animatedGradient(colors: colors, speed: 0.08) + case .blobs: + return .blobs(colors: colors, background: colors) + case .particles: + return .particles(particle: colors.first ?? .white, background: colors) + } + } + + if let theme = AppTheme(rawValue: identifier), (hasThemeExpansion || theme == .system) { + return theme.backgroundStyle + } + + return AppTheme.system.backgroundStyle + } + + func preferredColorScheme(for identifier: String) -> ColorScheme? { + if hasThemeExpansion, let custom = customTheme(for: identifier) { + return custom.preferredColorScheme + } + if let theme = AppTheme(rawValue: identifier), (hasThemeExpansion || theme == .system) { + return theme.preferredColorScheme + } + return nil + } + + func customThemeIdentifier(for theme: CustomTheme) -> String { + "custom:\(theme.id.uuidString)" + } + + func isCustomThemeIdentifier(_ identifier: String) -> Bool { + identifier.hasPrefix("custom:") + } + + func customTheme(for identifier: String) -> CustomTheme? { + guard isCustomThemeIdentifier(identifier) else { return nil } + let idString = identifier.replacingOccurrences(of: "custom:", with: "") + guard let uuid = UUID(uuidString: idString) else { return nil } + return customThemes.first { $0.id == uuid } + } + + func upsert(customTheme: CustomTheme) { + guard hasThemeExpansion || isPreviewInstance else { return } + var sanitized = customTheme + sanitized.colorHexes = sanitize(hexes: sanitized.colorHexes) + + if let index = customThemes.firstIndex(where: { $0.id == sanitized.id }) { + customThemes[index] = sanitized + } else { + customThemes.append(sanitized) + } + saveCustomThemes() + } + + func delete(customTheme: CustomTheme) { + customThemes.removeAll { $0.id == customTheme.id } + saveCustomThemes() + } + + private func loadCustomThemes() { + guard let data = UserDefaults.standard.data(forKey: customThemesKey), + let decoded = try? JSONDecoder().decode([CustomTheme].self, from: data) else { + return + } + customThemes = decoded + } + + private func saveCustomThemes() { + guard let data = try? JSONEncoder().encode(customThemes) else { return } + UserDefaults.standard.set(data, forKey: customThemesKey) + } + + private func sanitize(hexes: [String]) -> [String] { + let cleaned = hexes.filter { !$0.isEmpty } + if cleaned.count >= 2 { return cleaned } + if let first = cleaned.first { + return [first, first] + } + return ["#1C1F3A", "#3E4C7C"] + } + + private func normalizedColors(from hexes: [String]) -> [Color] { + let colors = hexes.compactMap { Color(hex: $0) } + if colors.count >= 2 { return colors } + if let first = colors.first { return [first, first.opacity(0.65)] } + return [Color.blue, Color.purple] + } + + private func handle(transactionResult: VerificationResult, finishTransaction: Bool = true) async { + switch transactionResult { + case .verified(let transaction): + if transaction.productID == Self.productIdentifier { + hasThemeExpansion = true + lastError = nil + } + if finishTransaction { + await transaction.finish() + } + case .unverified(_, let error): + lastError = error.localizedDescription + } + } +} + +#if DEBUG +extension ThemeExpansionManager { + static func previewUnlocked() -> ThemeExpansionManager { + ThemeExpansionManager(previewUnlocked: true) + } + + static func previewLocked() -> ThemeExpansionManager { + ThemeExpansionManager(previewUnlocked: false) + } +} +#endif + +// MARK: - Environment support + +private struct ThemeExpansionEnvironmentKey: EnvironmentKey { + static let defaultValue: ThemeExpansionManager? = nil +} + +extension EnvironmentValues { + var themeExpansionManager: ThemeExpansionManager? { + get { self[ThemeExpansionEnvironmentKey.self] } + set { self[ThemeExpansionEnvironmentKey.self] = newValue } + } +} + +extension View { + func themeExpansionManager(_ manager: ThemeExpansionManager) -> some View { + environment(\.themeExpansionManager, manager) + } +} diff --git a/StikJIT/Views/ConsoleLogsView.swift b/StikJIT/Views/ConsoleLogsView.swift index 053d6cf..fe50775 100644 --- a/StikJIT/Views/ConsoleLogsView.swift +++ b/StikJIT/Views/ConsoleLogsView.swift @@ -13,10 +13,7 @@ struct ConsoleLogsView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.accentColor) private var environmentAccentColor @StateObject private var logManager = LogManager.shared - @StateObject private var systemLogStream = SystemLogStream() - @State private var selectedTab: ConsoleLogsTab = .jit @State private var jitScrollView: ScrollViewProxy? = nil - @State private var systemScrollView: ScrollViewProxy? = nil @AppStorage("customAccentColor") private var customAccentColorHex: String = "" @State private var showingCustomAlert = false @@ -30,103 +27,32 @@ struct ConsoleLogsView: View { @State private var lastProcessedLineCount = 0 @State private var isLoadingLogs = false @State private var jitIsAtBottom = true - @State private var systemIsAtBottom = true - @State private var systemFilterText: String = "" - @State private var systemFilterLevel: SystemLogFilterLevel = .all - + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue + @Environment(\.themeExpansionManager) private var themeExpansion + private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } + private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) } + private var accentColor: Color { - if customAccentColorHex.isEmpty { - return .blue - } else { - return Color(hex: customAccentColorHex) ?? .blue - } + themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue } - private let systemSpeedOptions: [(title: String, interval: TimeInterval)] = [ - ("Real-time", 0), - ("0.25s", 0.25), - ("0.5s", 0.5), - ("1s", 1), - ("2s", 2) - ] - - private enum ConsoleLogsTab: Int, CaseIterable, Identifiable { - case jit - case system - - var id: Int { rawValue } - - var title: String { - switch self { - case .jit: return "App Logs" - case .system: return "System Logs" - } - } + private var overlayOpacity: Double { + colorScheme == .dark ? 0.82 : 0.9 } - private enum SystemLogFilterLevel: String, CaseIterable, Identifiable { - case all - case warnings - case errors - - var id: String { rawValue } - - var title: String { - switch self { - case .all: return "All" - case .warnings: return "Warnings" - case .errors: return "Errors" - } - } - - func matches(_ line: String) -> Bool { - let lower = line.lowercased() - switch self { - case .all: - return true - case .warnings: - return lower.contains("warn") || lower.contains("warning") - case .errors: - return lower.contains("err") || lower.contains("fail") || lower.contains("fault") || lower.contains("critical") - } - } - } - var body: some View { NavigationView { ZStack { + ThemedBackground(style: backgroundStyle) + .ignoresSafeArea() Color(colorScheme == .dark ? .black : .white) - .edgesIgnoringSafeArea(.all) - + .opacity(overlayOpacity) + .ignoresSafeArea() + VStack(spacing: 0) { - tabPicker - .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 8) - - Divider() - .background(Color.white.opacity(colorScheme == .dark ? 0.05 : 0.15)) - - if selectedTab == .jit { - jitLogsPane - } else { - systemLogsPane - } - + jitLogsPane Spacer(minLength: 0) - - if selectedTab == .jit { - jitFooter - } else { - systemFooter - } - } - .onChange(of: selectedTab) { tab in - if tab == .system { - systemLogStream.start() - } else { - systemLogStream.stop() - } + jitFooter } .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -135,7 +61,7 @@ struct ConsoleLogsView: View { .font(.headline) .foregroundColor(colorScheme == .dark ? .white : .black) } - + ToolbarItem(placement: .navigationBarLeading) { Button(action: { dismiss() }) { HStack(spacing: 2) { @@ -145,7 +71,7 @@ struct ConsoleLogsView: View { .foregroundColor(accentColor) } } - + ToolbarItem(placement: .navigationBarTrailing) { HStack { Button(action: { @@ -154,7 +80,7 @@ struct ConsoleLogsView: View { Image(systemName: "arrow.clockwise") .foregroundColor(accentColor) } - + Button(action: { logManager.clearLogs() }) { @@ -185,26 +111,10 @@ struct ConsoleLogsView: View { } } ) - .onDisappear { - systemLogStream.stop() - } - .onAppear { - if selectedTab == .system { - systemLogStream.start() - } - } } + .preferredColorScheme(preferredScheme) } - private var tabPicker: some View { - Picker("Log Source".localized, selection: $selectedTab) { - ForEach(ConsoleLogsTab.allCases) { tab in - Text(tab.title.localized).tag(tab) - } - } - .pickerStyle(.segmented) - } - private var jitLogsPane: some View { ScrollViewReader { proxy in ScrollView { @@ -277,133 +187,6 @@ struct ConsoleLogsView: View { stopLogCheckTimer() } } - .onDisappear { - systemLogStream.stop() - } - } - - private var systemLogsPane: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { - systemFilterControls - - if let error = systemLogStream.lastError { - Text(error) - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(.red) - .padding(.vertical, 4) - .padding(.horizontal, 4) - } - - ForEach(filteredSystemLogs) { entry in - Text(AttributedString(systemLogAttributedString(entry))) - .font(.system(size: 11, design: .monospaced)) - .textSelection(.enabled) - .lineLimit(nil) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 1) - .padding(.horizontal, 4) - .id(entry.id) - } - } - .background( - GeometryReader { geometry in - Color.clear.preference( - key: ScrollOffsetPreferenceKey.self, - value: geometry.frame(in: .named("systemScroll")).minY - ) - } - ) - } - .coordinateSpace(name: "systemScroll") - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in - systemIsAtBottom = offset > -20 - } - .onChange(of: systemLogStream.entries.count) { _ in - guard systemIsAtBottom, let last = systemLogStream.entries.last else { return } - withAnimation { - proxy.scrollTo(last.id, anchor: .bottom) - } - } - .onAppear { - systemScrollView = proxy - systemLogStream.start() - } - .onDisappear { - systemScrollView = nil - } - } - } - - private var filteredSystemLogs: [SystemLogStream.Entry] { - var entries = systemLogStream.entries - let trimmed = systemFilterText.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - let query = trimmed.lowercased() - entries = entries.filter { entry in - entry.raw.lowercased().contains(query) - } - } - - if systemFilterLevel != .all { - entries = entries.filter { systemFilterLevel.matches($0.raw) } - } - - return entries - } - - private var systemSpeedSymbol: String { - switch systemLogStream.updateInterval { - case 0: - return "hare.fill" - case ..<0.5: - return "tortoise.fill" - case ..<1.5: - return "tortoise" - default: - return "tortoise" - } - } - - private var systemFilterControls: some View { - VStack(spacing: 10) { - HStack(spacing: 8) { - Image(systemName: "line.3.horizontal.decrease.circle") - .foregroundStyle(.secondary) - - TextField("Filter".localized, text: $systemFilterText) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .font(.system(size: 12, design: .monospaced)) - - if !systemFilterText.isEmpty { - Button { - systemFilterText = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .accessibilityLabel("Clear filter".localized) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.ultraThinMaterial) - ) - - Picker("Severity".localized, selection: $systemFilterLevel) { - ForEach(SystemLogFilterLevel.allCases) { level in - Text(level.title.localized).tag(level) - } - } - .pickerStyle(.segmented) - } - .padding(.horizontal, 4) - .padding(.bottom, 6) } private var jitFooter: some View { @@ -449,81 +232,6 @@ struct ConsoleLogsView: View { .padding(.vertical, 10) } - private var systemFooter: some View { - HStack(spacing: 12) { - if let error = systemLogStream.lastError { - Label(error, systemImage: "exclamationmark.triangle.fill") - .font(.subheadline) - .foregroundColor(.red) - .lineLimit(2) - } else { - let status = systemStatusDisplay() - Label(status.text, systemImage: status.icon) - .font(.subheadline) - .foregroundColor(.secondary) - } - - Spacer(minLength: 8) - - Button { - UIPasteboard.general.string = systemLogStream.concatenatedLog() - alertTitle = "System Logs Copied" - alertMessage = "Recent system logs copied to clipboard.".localized - isError = false - showingCustomAlert = true - } label: { - Image(systemName: "doc.on.doc") - .foregroundColor(accentColor) - } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel("Copy system logs") - - Button { - systemLogStream.clear() - } label: { - Image(systemName: "trash") - .foregroundColor(accentColor) - } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel("Clear system logs") - - Button { - systemLogStream.togglePause() - } label: { - Image(systemName: systemLogStream.isPaused ? "play.fill" : "pause.fill") - .foregroundColor(accentColor) - } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel(systemLogStream.isPaused ? "Resume stream" : "Pause stream") - - Menu { - ForEach(systemSpeedOptions, id: \.title) { option in - Button(option.title.localized) { - systemLogStream.updateInterval = option.interval - } - } - } label: { - Image(systemName: systemSpeedSymbol) - .foregroundColor(accentColor) - } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel("Adjust speed") - - if !systemLogStream.isStreaming { - Button { - systemLogStream.start() - } label: { - Image(systemName: "arrow.clockwise") - .foregroundColor(accentColor) - } - .buttonStyle(GlassOvalButtonStyle(height: 36, strokeOpacity: 0.18)) - .accessibilityLabel("Restart stream") - } - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - } - @ViewBuilder private var exportControl: some View { let logURL: URL = URL.documentsDirectory.appendingPathComponent("idevice_log.txt") @@ -580,30 +288,6 @@ struct ConsoleLogsView: View { return fullString } - - private func systemLogAttributedString(_ entry: SystemLogStream.Entry) -> NSAttributedString { - let fullString = NSMutableAttributedString() - - let timestamp = "[\(formatTime(date: entry.timestamp))]" - let tsAttr = NSAttributedString(string: timestamp, - attributes: [.foregroundColor: colorScheme == .dark ? UIColor.gray : UIColor.darkGray]) - fullString.append(tsAttr) - fullString.append(NSAttributedString(string: " ")) - - let type = systemLogType(for: entry.raw) - let typeString = "[\(type.rawValue)]" - let typeAttr = NSAttributedString(string: typeString, - attributes: [.foregroundColor: UIColor(colorForLogType(type))]) - fullString.append(typeAttr) - fullString.append(NSAttributedString(string: " ")) - - let messageAttr = NSAttributedString(string: entry.message, - attributes: [.foregroundColor: colorScheme == .dark ? UIColor.white : UIColor.black]) - fullString.append(messageAttr) - - return fullString - } - private func formatTime(date: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss" @@ -622,33 +306,6 @@ struct ConsoleLogsView: View { return .orange } } - - private func systemLogType(for raw: String) -> LogManager.LogEntry.LogType { - let lower = raw.lowercased() - if lower.contains("error") || lower.contains("failed") || lower.contains("fatal") || lower.contains("fault") { - return .error - } - if lower.contains("warn") { - return .warning - } - if lower.contains("debug") || lower.contains("trace") { - return .debug - } - return .info - } - - private func systemStatusDisplay() -> (text: String, icon: String) { - if !systemLogStream.isStreaming { - return ("Stopped".localized, "pause.circle") - } - if systemLogStream.isPaused { - return ("Paused".localized, "pause.circle") - } - if systemLogStream.updateInterval > 0 { - return ("Throttled".localized, systemSpeedSymbol) - } - return ("Streaming".localized, "dot.radiowaves.left.and.right") - } private func loadIdeviceLogsAsync() async { guard !isLoadingLogs else { return } @@ -804,6 +461,7 @@ private struct GlassOvalButtonStyle: ButtonStyle { struct ConsoleLogsView_Previews: PreviewProvider { static var previews: some View { ConsoleLogsView() + .themeExpansionManager(ThemeExpansionManager(previewUnlocked: true)) } } diff --git a/StikJIT/Views/Custom Error View.swift b/StikJIT/Views/Custom Error View.swift index f09aee9..cf80964 100644 --- a/StikJIT/Views/Custom Error View.swift +++ b/StikJIT/Views/Custom Error View.swift @@ -31,11 +31,8 @@ struct CustomErrorView: View { @AppStorage("customAccentColor") private var customAccentColorHex: String = "" private var accentColor: Color { - if customAccentColorHex.isEmpty { - return .white - } else { - return Color(hex: customAccentColorHex) ?? .white - } + if customAccentColorHex.isEmpty { return .blue } + return Color(hex: customAccentColorHex) ?? .blue } diff --git a/StikJIT/Views/DeviceInfoManager.swift b/StikJIT/Views/DeviceInfoManager.swift index 9bc829f..cd0a335 100644 --- a/StikJIT/Views/DeviceInfoManager.swift +++ b/StikJIT/Views/DeviceInfoManager.swift @@ -147,12 +147,14 @@ struct DeviceInfoView: View { } @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue - private var currentTheme: AppTheme { AppTheme(rawValue: appThemeRaw) ?? .system } + @Environment(\.themeExpansionManager) private var themeExpansion + private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } + private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) } var body: some View { NavigationStack { ZStack { - ThemedBackground(style: currentTheme.backgroundStyle) + ThemedBackground(style: backgroundStyle) .ignoresSafeArea() ScrollView { @@ -260,6 +262,7 @@ struct DeviceInfoView: View { .onAppear { if isPaired { mgr.initAndLoad() } } .onDisappear { mgr.cleanup() } } + .preferredColorScheme(preferredScheme) } // MARK: - UI Sections diff --git a/StikJIT/Views/DisplayView.swift b/StikJIT/Views/DisplayView.swift index 7c0008c..6660ad7 100644 --- a/StikJIT/Views/DisplayView.swift +++ b/StikJIT/Views/DisplayView.swift @@ -74,44 +74,56 @@ struct DisplayView: View { @AppStorage("customAccentColor") private var customAccentColorHex: String = "" @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true - @State private var selectedAccentColor: Color = .white + @State private var selectedAccentColor: Color = .blue @Environment(\.colorScheme) private var colorScheme - + @Environment(\.themeExpansionManager) private var themeExpansionOptional + @State private var justSaved = false - + @State private var showingCreateCustomTheme = false + @State private var editingCustomTheme: CustomTheme? + + private var themeExpansion: ThemeExpansionManager? { themeExpansionOptional } + + private var hasThemeExpansion: Bool { themeExpansion?.hasThemeExpansion == true } + private var accentColor: Color { - if customAccentColorHex.isEmpty { - return .white - } else { - return Color(hex: customAccentColorHex) ?? .white - } + themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue } - - private var selectedTheme: AppTheme { - get { AppTheme(rawValue: appThemeRaw) ?? .system } - set { appThemeRaw = newValue.rawValue } + + private var tintColor: Color { + hasThemeExpansion ? selectedAccentColor : .blue } - - private var hardcodedGradient: some View { - LinearGradient( - gradient: Gradient(colors: [ - Color(UIColor.systemBackground), - Color(UIColor.secondarySystemBackground) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + + private var selectedThemeIdentifier: String { appThemeRaw } + + private var selectedBuiltInTheme: AppTheme? { + AppTheme(rawValue: selectedThemeIdentifier) + } + + private var selectedCustomTheme: CustomTheme? { + themeExpansion?.customTheme(for: selectedThemeIdentifier) + } + + private var backgroundStyle: BackgroundStyle { + themeExpansion?.backgroundStyle(for: selectedThemeIdentifier) ?? AppTheme.system.backgroundStyle } var body: some View { NavigationStack { ZStack { - hardcodedGradient.ignoresSafeArea() + ThemedBackground(style: backgroundStyle) + .ignoresSafeArea() ScrollView { VStack(spacing: 20) { usernameCard - accentCard + if hasThemeExpansion { + accentCard + themeCard + customThemesSection + } else { + themeExpansionUpsellCard + } jitOptionsCard } .padding(.horizontal, 20) @@ -136,11 +148,61 @@ struct DisplayView: View { } .navigationTitle("Display") .onAppear { + if !hasThemeExpansion, let manager = themeExpansion, manager.isCustomThemeIdentifier(appThemeRaw) { + appThemeRaw = AppTheme.system.rawValue + } loadCustomAccentColor() - applyTheme(selectedTheme) + applyThemePreferences() + } + .onChange(of: appThemeRaw) { _, newValue in + guard hasThemeExpansion, let manager = themeExpansion else { return } + if manager.isCustomThemeIdentifier(newValue), manager.customTheme(for: newValue) == nil { + appThemeRaw = AppTheme.system.rawValue + } + applyThemePreferences() + } + .onChange(of: themeExpansion?.hasThemeExpansion ?? false) { unlocked in + if unlocked { + loadCustomAccentColor() + applyThemePreferences() + } else { + selectedAccentColor = .blue + appThemeRaw = AppTheme.system.rawValue + applyThemePreferences() + } } } - .tint(selectedAccentColor) + .tint(tintColor) + .sheet(isPresented: $showingCreateCustomTheme) { + CustomThemeEditorView(initialTheme: nil) { newTheme in + themeExpansion?.upsert(customTheme: newTheme) + if let manager = themeExpansion { + appThemeRaw = manager.customThemeIdentifier(for: newTheme) + } + applyThemePreferences() + showSavedToast() + } + } + .sheet(item: $editingCustomTheme) { theme in + CustomThemeEditorView(initialTheme: theme, + onSave: { updated in + themeExpansion?.upsert(customTheme: updated) + if let manager = themeExpansion { + appThemeRaw = manager.customThemeIdentifier(for: updated) + } + applyThemePreferences() + showSavedToast() + }, + onDelete: { + if let manager = themeExpansion { + manager.delete(customTheme: theme) + if manager.customThemeIdentifier(for: theme) == appThemeRaw { + appThemeRaw = AppTheme.system.rawValue + applyThemePreferences() + } + } + }) + } } // MARK: - Cards @@ -194,9 +256,9 @@ struct DisplayView: View { .font(.title3) .fontWeight(.semibold) .foregroundColor(.primary) - + AccentColorPicker(selectedColor: $selectedAccentColor) - + HStack(spacing: 12) { Button { if let hex = selectedAccentColor.toHex() { @@ -219,10 +281,10 @@ struct DisplayView: View { ) .foregroundColor(selectedAccentColor.contrastText()) } - + Button { customAccentColorHex = "" - selectedAccentColor = .white + selectedAccentColor = .blue showSavedToast() } label: { HStack { @@ -248,7 +310,115 @@ struct DisplayView: View { ) ) .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) - .onChange(of: selectedAccentColor) { _, _ in + } + + private var themeExpansionUpsellCard: some View { + let productLoaded = themeExpansion?.themeExpansionProduct != nil + return VStack(alignment: .leading, spacing: 14) { + Text("StikDebug Theme Expansion") + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Text("Unlock custom accent colors and dynamic backgrounds with the Theme Expansion.") + .font(.body) + .foregroundColor(.secondary) + + if let price = themeExpansion?.themeExpansionProduct?.displayPrice { + Text("One-time purchase • \(price)") + .font(.subheadline) + .foregroundColor(.secondary) + } + + if productLoaded, let manager = themeExpansion { + Button { + Task { await manager.purchaseThemeExpansion() } + } label: { + HStack { + if manager.isProcessing { + ProgressView() + .progressViewStyle(.circular) + } + Text(manager.isProcessing ? "Purchasing…" : "Unlock Theme Expansion") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.blue) + ) + .foregroundColor(Color.blue.contrastText()) + } + .disabled(manager.isProcessing) + } else if let manager = themeExpansion { + Button { + Task { await manager.refreshEntitlements() } + } label: { + HStack { + if manager.isProcessing { + ProgressView() + .progressViewStyle(.circular) + } + Text(manager.isProcessing ? "Contacting App Store…" : "Try Again") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.blue.opacity(0.4), lineWidth: 1) + ) + } + .disabled(manager.isProcessing) + } + + if let manager = themeExpansion { + Button { + Task { await manager.refreshEntitlements() } + } label: { + Text("Restore Purchase") + .font(.subheadline) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.blue.opacity(0.4), lineWidth: 1) + ) + } + .disabled(manager.isProcessing) + } + + if let manager = themeExpansion, !productLoaded, manager.lastError == nil { + Text(manager.isProcessing ? "Contacting the App Store…" : "Waiting for App Store information.") + .font(.footnote) + .foregroundColor(.secondary) + } + + if let error = themeExpansion?.lastError { + Text(error) + .font(.footnote) + .foregroundColor(.red) + } + } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) + .task { + if let manager = themeExpansion, + !manager.isProcessing, + manager.themeExpansionProduct == nil, + manager.lastError == nil { + await manager.refreshEntitlements() + } } } @@ -290,10 +460,12 @@ struct DisplayView: View { // Grid of theme previews LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { ForEach(AppTheme.allCases, id: \.self) { theme in - ThemePreviewCard(theme: theme, selected: selectedTheme == theme) { - // Write directly to AppStorage to avoid mutating through a computed property + ThemePreviewCard(style: theme.backgroundStyle, + title: theme.displayName, + selected: selectedBuiltInTheme == theme && selectedCustomTheme == nil) { + guard hasThemeExpansion else { return } appThemeRaw = theme.rawValue - applyTheme(theme) + applyThemePreferences() showSavedToast() } } @@ -308,28 +480,101 @@ struct DisplayView: View { .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) ) ) - .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) + .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) + } + + @ViewBuilder + private var customThemesSection: some View { + if hasThemeExpansion, let manager = themeExpansion { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("Custom Themes") + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) + Spacer() + Button { + showingCreateCustomTheme = true + } label: { + Label("New", systemImage: "plus.circle.fill") + .font(.subheadline.weight(.semibold)) + } + } + + if manager.customThemes.isEmpty { + VStack(spacing: 8) { + Text("Create your own themes with custom colors and motion.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + Button(action: { showingCreateCustomTheme = true }) { + Text("Create a Custom Theme") + .font(.subheadline.weight(.semibold)) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.blue) + ) + .foregroundColor(Color.blue.contrastText()) + } + } + } else { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + ForEach(manager.customThemes, id: \.id) { theme in + let identifier = manager.customThemeIdentifier(for: theme) + ThemePreviewCard(style: manager.backgroundStyle(for: identifier), + title: theme.name, + selected: selectedCustomTheme?.id == theme.id) { + appThemeRaw = identifier + applyThemePreferences() + showSavedToast() + } + .contextMenu { + Button("Edit") { editingCustomTheme = theme } + Button("Delete", role: .destructive) { + manager.delete(customTheme: theme) + let id = manager.customThemeIdentifier(for: theme) + if appThemeRaw == id { + appThemeRaw = AppTheme.system.rawValue + applyThemePreferences() + } + } + } + } + } + } + } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) + } } // MARK: - Helpers private func loadCustomAccentColor() { - if customAccentColorHex.isEmpty { - selectedAccentColor = .white - } else { - selectedAccentColor = Color(hex: customAccentColorHex) ?? .white - } + selectedAccentColor = themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue } - - private func applyTheme(_ theme: AppTheme) { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first { - switch theme { - case .darkStatic, .neonAnimated, .blobs, .particles: - window.overrideUserInterfaceStyle = .dark - case .system: - window.overrideUserInterfaceStyle = .unspecified - } + + private func applyThemePreferences() { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { return } + let scheme = themeExpansion?.preferredColorScheme(for: selectedThemeIdentifier) + switch scheme { + case .some(.dark): + window.overrideUserInterfaceStyle = .dark + case .some(.light): + window.overrideUserInterfaceStyle = .light + default: + window.overrideUserInterfaceStyle = .unspecified } } @@ -344,34 +589,25 @@ struct DisplayView: View { // MARK: - Theme Preview Card private struct ThemePreviewCard: View { - let theme: AppTheme + let style: BackgroundStyle + let title: String let selected: Bool let action: () -> Void - private var hardcodedGradient: some View { - LinearGradient( - gradient: Gradient(colors: [ - Color(UIColor.systemBackground), - Color(UIColor.secondarySystemBackground) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } - var body: some View { Button(action: action) { ZStack { - hardcodedGradient + ThemedBackground(style: style) .overlay( - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(.ultraThinMaterial) - .padding(8) + .padding(6) + .opacity(0.55) ) - .clipShape(RoundedRectangle(cornerRadius: 12)) - + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + VStack(spacing: 6) { - Text(theme.displayName) + Text(title) .font(.footnote.weight(.semibold)) .foregroundColor(.primary) .padding(.horizontal, 8) @@ -382,7 +618,7 @@ private struct ThemePreviewCard: View { } .frame(height: 120) .overlay( - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(selected ? Color.accentColor : Color.white.opacity(0.12), lineWidth: selected ? 2 : 1) ) .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) @@ -390,3 +626,163 @@ private struct ThemePreviewCard: View { .buttonStyle(.plain) } } + +// MARK: - Custom Theme Editor + +private struct CustomThemeEditorView: View { + @Environment(\.dismiss) private var dismiss + + @State private var name: String + @State private var style: CustomThemeStyle + @State private var colors: [Color] + @State private var appearance: AppearanceOption + + let onSave: (CustomTheme) -> Void + let onDelete: (() -> Void)? + + private let maxColors = 4 + + init(initialTheme: CustomTheme?, + onSave: @escaping (CustomTheme) -> Void, + onDelete: (() -> Void)? = nil) { + self.onSave = onSave + self.onDelete = onDelete + + if let theme = initialTheme { + _name = State(initialValue: theme.name) + _style = State(initialValue: theme.style) + let baseColors = theme.gradientColors + _colors = State(initialValue: baseColors.isEmpty ? [Color.blue, Color.purple] : baseColors) + _appearance = State(initialValue: AppearanceOption(theme.preferredColorScheme)) + self.initialTheme = theme + } else { + _name = State(initialValue: "") + _style = State(initialValue: .staticGradient) + _colors = State(initialValue: [Color(hex: "#3E4C7C") ?? .indigo, + Color(hex: "#1C1F3A") ?? .blue]) + _appearance = State(initialValue: .system) + self.initialTheme = nil + } + } + + private let initialTheme: CustomTheme? + + var body: some View { + NavigationStack { + Form { + Section(header: Text("Details")) { + TextField("Theme Name", text: $name) + .textInputAutocapitalization(.words) + + Picker("Style", selection: $style) { + ForEach(CustomThemeStyle.allCases) { style in + Text(style.displayName).tag(style) + } + } + + Picker("Appearance", selection: $appearance) { + ForEach(AppearanceOption.allCases) { option in + Text(option.title).tag(option) + } + } + .pickerStyle(.segmented) + } + + Section(header: Text("Colors")) { + ForEach(colors.indices, id: \.self) { index in + HStack { + ColorPicker("", selection: Binding(get: { + colors[index] + }, set: { newValue in + if index < colors.count { + colors[index] = newValue + } + }), supportsOpacity: false) + .labelsHidden() + + if colors.count > 2 { + Button(role: .destructive) { + colors.remove(at: index) + } label: { + Image(systemName: "minus.circle") + } + .padding(.leading, 4) + } + } + } + + if colors.count < maxColors { + Button { + colors.append(colors.last ?? .blue) + } label: { + Label("Add Color", systemImage: "plus.circle") + } + } + } + + if let onDelete { + Section { + Button("Delete Theme", role: .destructive) { + onDelete() + dismiss() + } + } + } + } + .navigationTitle(initialTheme == nil ? "New Theme" : "Edit Theme") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let hexes = colors.compactMap { $0.toHex() ?? "#3E4C7C" } + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + let finalName = trimmed.isEmpty ? "Untitled Theme" : trimmed + let theme = CustomTheme(id: initialTheme?.id ?? UUID(), + name: finalName, + style: style, + colorHexes: hexes, + preferredColorScheme: appearance.colorScheme) + onSave(theme) + dismiss() + } + .disabled(colors.count < 2 || colors.allSatisfy { $0.toHex() == nil }) + } + } + } + } + + private enum AppearanceOption: String, CaseIterable, Identifiable { + case system + case light + case dark + + var id: String { rawValue } + + var title: String { + switch self { + case .system: return "System" + case .light: return "Light" + case .dark: return "Dark" + } + } + + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } + + init(_ scheme: ColorScheme?) { + switch scheme { + case .some(.light): self = .light + case .some(.dark): self = .dark + default: self = .system + } + } + } +} diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index 5edad43..671a897 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -25,6 +25,8 @@ struct HomeView: View { @Environment(\.accentColor) private var environmentAccentColor let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @AppStorage("bundleID") private var bundleID: String = "" + @AppStorage("recentApps") private var recentApps: [String] = [] + @AppStorage("favoriteApps") private var favoriteApps: [String] = [] @State private var isProcessing = false @State private var isShowingInstalledApps = false @State private var isShowingPairingFilePicker = false @@ -32,12 +34,12 @@ struct HomeView: View { @State private var showPairingFileMessage = false @State private var pairingFileIsValid = false @State private var isImportingFile = false - @State private var showingConsoleLogsView = false @State private var importProgress: Float = 0.0 @State private var showPIDSheet = false @AppStorage("recentPIDs") private var recentPIDs: [Int] = [] @State private var justCopied = false + @State private var showingConsoleLogsView = false @State private var viewDidAppeared = false @State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil @@ -52,15 +54,18 @@ struct HomeView: View { @StateObject private var tunnel = TunnelManager.shared @State private var heartbeatOK = false + @State private var cachedAppNames: [String: String] = [:] + @State private var isLoadingQuickApps = false @AppStorage("showiOS26Disclaimer") private var showiOS26Disclaimer: Bool = true @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue - private var currentTheme: AppTheme { AppTheme(rawValue: appThemeRaw) ?? .system } - + @Environment(\.themeExpansionManager) private var themeExpansion + private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } + private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) } + private var accentColor: Color { - if customAccentColorHex.isEmpty { return .white } - return Color(hex: customAccentColorHex) ?? .white + themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue } private var ddiMounted: Bool { isMounted() } @@ -74,7 +79,7 @@ struct HomeView: View { var body: some View { NavigationStack { ZStack { - ThemedBackground(style: currentTheme.backgroundStyle) + ThemedBackground(style: backgroundStyle) .ignoresSafeArea() ScrollView { @@ -84,7 +89,8 @@ struct HomeView: View { .transition(.opacity.combined(with: .move(edge: .top))) } - topStatusAndActionsCard + readinessCard + quickConnectCard toolsCard tipsCard } @@ -116,9 +122,11 @@ struct HomeView: View { } .navigationTitle("Home") } + .preferredColorScheme(preferredScheme) .onAppear { checkPairingFileExists() refreshBackground() + loadAppListIfNeeded() NotificationCenter.default.addObserver( forName: NSNotification.Name("ShowPairingFilePicker"), object: nil, @@ -130,6 +138,19 @@ struct HomeView: View { checkPairingFileExists() heartbeatOK = pubHeartBeat } + .onChange(of: pairingFileExists) { _, newValue in + if newValue { + loadAppListIfNeeded(force: cachedAppNames.isEmpty) + } else { + cachedAppNames = [:] + } + } + .onChange(of: favoriteApps) { _, _ in + loadAppListIfNeeded() + } + .onChange(of: recentApps) { _, _ in + loadAppListIfNeeded() + } .fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList]) { result in switch result { case .success(let url): @@ -177,6 +198,13 @@ struct HomeView: View { print("Failed to import file: \(error)") } } + .sheet(isPresented: $showingConsoleLogsView) { + if let manager = themeExpansion { + ConsoleLogsView().themeExpansionManager(manager) + } else { + ConsoleLogsView() + } + } .sheet(isPresented: $isShowingInstalledApps) { InstalledAppsListView { selectedBundle in bundleID = selectedBundle @@ -291,104 +319,202 @@ struct HomeView: View { // MARK: - Styled Sections - private var topStatusAndActionsCard: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 12) { - indicatorCapsule(ok: pairingFileExists, systemImage: "doc.badge.plus", a11y: "Pairing") - indicatorCapsule(ok: ddiMounted, systemImage: "externaldrive", a11y: "Developer Disk Image") - indicatorCapsule(ok: tunnel.tunnelStatus == .connected, systemImage: "lock.shield", a11y: "VPN") - indicatorCapsule(ok: heartbeatOK, systemImage: "waveform.path.ecg", a11y: "Heartbeat") - Spacer() - } - VStack(spacing: 4) { - Text("Welcome, \(username)") - .font(.system(.title2, design: .rounded).weight(.bold)) - .foregroundColor(.primary) - .frame(maxWidth: .infinity, alignment: .leading) - Text(helperSubtitle()) - .font(.system(.subheadline, design: .rounded)) - .foregroundColor(.secondary) - } - .padding(.bottom, 4) - - VStack(spacing: 10) { - Button(action: primaryActionTapped) { - whiteCardButtonLabel( - icon: pairingFileExists ? "cable.connector.horizontal" : "doc.badge.plus", - title: pairingFileExists ? "Connect by App" : "Select Pairing File" - ) - } - .disabled(pairingFileExists && !ddiMounted) - .opacity(pairingFileExists && !ddiMounted ? 0.6 : 1.0) - - if pairingFileExists && enableAdvancedOptions { - Button(action: { showPIDSheet = true }) { - whiteCardButtonLabel(icon: "number.circle", title: "Connect by PID") + private var readinessCard: some View { + homeCard { + VStack(alignment: .leading, spacing: 16) { + let summary = readinessSummary + + HStack(alignment: .top, spacing: 12) { + StatusGlyph(icon: summary.icon, tint: summary.tint) + + VStack(alignment: .leading, spacing: 6) { + Text("Welcome, \(username)") + .font(.system(.title2, design: .rounded).weight(.bold)) + .foregroundStyle(.primary) + + Text(summary.title) + .font(.headline.weight(.semibold)) + .foregroundStyle(summary.tint) + + Text(summary.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } } + + VStack(alignment: .leading, spacing: 12) { + ForEach(readinessChecklist) { item in + ChecklistRow(item: item, accentColor: accentColor) + } + } + + VStack(spacing: 10) { + Button(action: primaryActionTapped) { + whiteCardButtonLabel(icon: primaryActionIcon, title: primaryActionTitle, isLoading: isProcessing) + } + .disabled(isProcessing) + + if pairingFileExists && enableAdvancedOptions { + Button(action: { showPIDSheet = true }) { + secondaryButtonLabel(icon: "number.circle", title: "Connect by PID") + } + .disabled(isProcessing) + } + } + + if isImportingFile { + pairingImportProgressView + } else if showPairingFileMessage && pairingFileIsValid { + pairingSuccessMessage + } + } + } + } + + private var readinessSummary: ReadinessSummary { + if !pairingFileExists { + return .init( + title: "Import your pairing file", + subtitle: "Grab the pairing file from your computer to unlock app discovery.", + icon: "doc.badge.plus", + tint: .orange + ) + } + if !ddiMounted { + return .init( + title: "Mount the Developer Disk Image", + subtitle: "Open Settings → Developer Disk Image and follow the prompts before connecting.", + icon: "externaldrive.badge.exclamationmark", + tint: .yellow + ) + } + if tunnel.tunnelStatus != .connected { + return .init( + title: "Connect the VPN", + subtitle: "Tap Allow when iOS prompts you, then flip the toggle in Settings if needed.", + icon: "lock.slash", + tint: .orange + ) + } + if !heartbeatOK { + return .init( + title: "Waiting for heartbeat", + subtitle: "StikDebug is reaching out to your device. We’ll connect automatically once it responds.", + icon: "waveform.path.ecg", + tint: .orange + ) + } + return .init( + title: "Ready when you are", + subtitle: "Everything is ready. Select an app to enable debugging or JIT in seconds.", + icon: "bolt.horizontal.circle.fill", + tint: .green + ) + } + + private var primaryActionTitle: String { + if !pairingFileExists { return "Import Pairing File" } + if !ddiMounted { return "Mount Developer Disk Image" } + return "Connect by App" + } + + private var primaryActionIcon: String { + if !pairingFileExists { return "doc.badge.plus" } + if !ddiMounted { return "externaldrive" } + return "cable.connector.horizontal" + } + + private var readinessChecklist: [ChecklistItem] { + let vpnConnected = tunnel.tunnelStatus == .connected + + return [ + ChecklistItem( + title: "Pairing file", + subtitle: pairingFileExists ? "Imported and valid." : "Import the pairing file generated from your trusted computer.", + status: pairingFileExists ? .ready : .actionRequired, + actionTitle: pairingFileExists ? nil : "Import", + action: pairingFileExists ? nil : { isShowingPairingFilePicker = true } + ), + ChecklistItem( + title: "Developer Disk Image", + subtitle: ddiMounted ? "Mounted successfully." : "Open Settings → Developer Disk Image and tap Mount.", + status: ddiMounted ? .ready : .attention, + actionTitle: nil, + action: nil + ), + ChecklistItem( + title: "VPN tunnel", + subtitle: vpnConnected ? "Active." : "Connect the StikDebug VPN from the Settings app if it’s not already on.", + status: vpnConnected ? .ready : .attention, + actionTitle: nil, + action: nil + ), + ChecklistItem( + title: "Heartbeat", + subtitle: heartbeatOK ? "Active." : "We retry automatically—leave the app open for a moment.", + status: heartbeatOK ? .ready : .waiting, + actionTitle: nil, + action: nil + ) + ] + } + + private var pairingImportProgressView: some View { + VStack(spacing: 8) { + HStack { + Text("Processing pairing file…") + .font(.system(.caption, design: .rounded)) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(importProgress * 100))%") + .font(.system(.caption, design: .rounded)) + .foregroundStyle(.secondary) } - if isImportingFile { - VStack(spacing: 8) { - HStack { - Text("Processing pairing file…") - .font(.system(.caption, design: .rounded)) - .foregroundColor(.secondary) - Spacer() - Text("\(Int(importProgress * 100))%") - .font(.system(.caption, design: .rounded)) - .foregroundColor(.secondary) - } - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 6).fill(Color(UIColor.tertiarySystemFill)).frame(height: 8) - RoundedRectangle(cornerRadius: 6).fill(Color.green) - .frame(width: geo.size.width * CGFloat(importProgress), height: 8) - .animation(.linear(duration: 0.3), value: importProgress) - } - } - .frame(height: 8) + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(UIColor.tertiarySystemFill)) + .frame(height: 8) + + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(accentColor) + .frame(width: geo.size.width * CGFloat(importProgress), height: 8) + .animation(.linear(duration: 0.25), value: importProgress) } - .padding(.top, 4) - } else if showPairingFileMessage && pairingFileIsValid { - HStack(spacing: 10) { - StatusDot(color: .green) - Text("Pairing file successfully imported") - .font(.system(.callout, design: .rounded)) - .foregroundColor(.green) - } - .padding(.top, 4) } + .frame(height: 8) } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + .accessibilityElement(children: .combine) } - - private func indicatorCapsule(ok: Bool, systemImage: String, a11y: String) -> some View { - HStack(spacing: 8) { - StatusDot(color: ok ? .green : .orange) - Image(systemName: systemImage) - .font(.subheadline.weight(.semibold)) - .foregroundColor(.primary) - .accessibilityLabel(a11y) + + private var pairingSuccessMessage: some View { + HStack(spacing: 10) { + StatusDot(color: .green) + Text("Pairing file successfully imported") + .font(.system(.callout, design: .rounded)) + .foregroundStyle(.green) + Spacer(minLength: 0) } - .frame(height: 32) - .padding(.horizontal, 10) - .background(Capsule(style: .continuous).fill(Color(UIColor.tertiarySystemBackground))) + .padding(.top, 4) + .transition(.opacity) } - - private func whiteCardButtonLabel(icon: String, title: String) -> some View { - HStack { - Image(systemName: icon).font(.system(size: 20)) - Text(title).font(.system(.title3, design: .rounded)).fontWeight(.semibold) + + private func whiteCardButtonLabel(icon: String, title: String, isLoading: Bool = false) -> some View { + HStack(spacing: 10) { + if isLoading { + ProgressView() + .progressViewStyle(.circular) + .tint(accentColor.contrastText()) + .frame(width: 20, height: 20) + } else { + Image(systemName: icon) + .font(.system(size: 20, weight: .semibold, design: .rounded)) + } + + Text(title) + .font(.system(.title3, design: .rounded).weight(.semibold)) } .frame(maxWidth: .infinity) .padding(.vertical, 14) @@ -398,108 +524,236 @@ struct HomeView: View { RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(Color.white.opacity(0.2), lineWidth: 0.5) ) + .animation(.easeInOut(duration: 0.2), value: isLoading) } - - private var toolsCard: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Tools").font(.headline).foregroundColor(.secondary) - Button(action: { showingConsoleLogsView = true }) { - HStack { - Image(systemName: "terminal").font(.system(size: 20)) - Text("Open Console").font(.system(.title3, design: .rounded)).fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .foregroundColor(accentColor.contrastText()) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.white.opacity(0.2), lineWidth: 0.5) - ) - } - .sheet(isPresented: $showingConsoleLogsView) { ConsoleLogsView() } + + private func secondaryButtonLabel(icon: String, title: String) -> some View { + HStack { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + Text(title) + .font(.system(.title3, design: .rounded).weight(.semibold)) } - .padding(20) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(UIColor.secondarySystemBackground).opacity(0.6)) ) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + .foregroundStyle(.primary) } - - private var tipsCard: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Tips").font(.headline).foregroundColor(.secondary) - if !pairingFileExists { - tipRow(systemImage: "doc.badge.plus", title: "Pairing file required", message: "Import your device’s pairing file to begin.") - } - if pairingFileExists && !ddiMounted { - tipRow(systemImage: "externaldrive.badge.exclamationmark", title: "Developer Disk Image not mounted", message: "Go to Settings → Developer Disk Image and ensure it’s mounted.") - } - tipRow(systemImage: "lock.shield", title: "Local only", message: "StikDebug runs entirely on-device. No data leaves your device.") - - Button { - if let url = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md") { - UIApplication.shared.open(url) - } - } label: { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "questionmark.circle") - .foregroundColor(accentColor) - .font(.system(size: 18)) - VStack(alignment: .leading, spacing: 2) { - Text("Pairing File Guide") - .font(.subheadline).fontWeight(.semibold) - Text("Learn how to create and import your pairing file.") - .font(.footnote) - .foregroundColor(.secondary) + + private var quickConnectCard: some View { + homeCard { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 8) { + Text("Quick Connect") + .font(.headline.weight(.semibold)) + .foregroundStyle(.primary) + + if isLoadingQuickApps { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) } - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 14)) - .foregroundColor(.secondary) - .padding(.top, 2) } - .padding(.vertical, 4) + + Text("Favorites and recents stay within reach so you can enable JIT without leaving Home.") + .font(.footnote) + .foregroundStyle(.secondary) + + if quickConnectItems.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("Pin apps from the Installed Apps list to see them here.") + .font(.caption) + .foregroundStyle(.secondary) + + Button { + isShowingInstalledApps = true + } label: { + secondaryButtonLabel(icon: "star", title: "Choose Favorites") + } + .buttonStyle(.plain) + } + } else { + VStack(spacing: 10) { + ForEach(quickConnectItems) { item in + QuickConnectRow( + item: item, + accentColor: accentColor, + isEnabled: canConnectByApp && !isProcessing, + action: { + HapticFeedbackHelper.trigger() + startJITInBackground(bundleID: item.bundleID, + pid: nil, + scriptData: nil, + scriptName: nil, + triggeredByURLScheme: false) + } + ) + } + } + } + + if !canConnectByApp { + Text("Finish the pairing and mounting steps above to enable quick launches.") + .font(.caption) + .foregroundStyle(.secondary) + } } - .buttonStyle(.plain) } - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) } - + + private var quickConnectItems: [QuickConnectItem] { + var seen = Set() + var ordered: [QuickConnectItem] = [] + for bundle in favoriteApps + recentApps { + guard seen.insert(bundle).inserted else { continue } + ordered.append(QuickConnectItem(bundleID: bundle, displayName: friendlyName(for: bundle))) + if ordered.count >= 4 { break } + } + return ordered + } + + private func friendlyName(for bundleID: String) -> String { + if let cached = cachedAppNames[bundleID], !cached.isEmpty { return cached } + let components = bundleID.split(separator: ".") + if let last = components.last { + let cleaned = last.replacingOccurrences(of: "_", with: " ") + let trimmed = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed.capitalized } + } + return bundleID + } + + private func loadAppListIfNeeded(force: Bool = false) { + guard pairingFileExists else { + cachedAppNames = [:] + isLoadingQuickApps = false + return + } + + if isLoadingQuickApps { return } + if !force && !cachedAppNames.isEmpty { return } + + isLoadingQuickApps = true + + DispatchQueue.global(qos: .userInitiated).async { + let result = (try? JITEnableContext.shared.getAppList()) ?? [:] + DispatchQueue.main.async { + cachedAppNames = result + isLoadingQuickApps = false + } + } + } + + private func homeCard(@ViewBuilder content: () -> Content) -> some View { + content() + .padding(20) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) + ) + ) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + } + + private var toolsCard: some View { + homeCard { + VStack(alignment: .leading, spacing: 12) { + Text("Tools") + .font(.headline) + .foregroundStyle(.secondary) + + VStack(spacing: 10) { + Button { + showingConsoleLogsView = true + } label: { + whiteCardButtonLabel(icon: "terminal", title: "Open Console") + } + + // Button { + // isShowingInstalledApps = true + // } label: { + // secondaryButtonLabel(icon: "list.bullet", title: "Installed Apps") + // } + // .buttonStyle(.plain) + } + } + } + } + + private var tipsCard: some View { + homeCard { + VStack(alignment: .leading, spacing: 12) { + Text("Tips") + .font(.headline) + .foregroundStyle(.secondary) + + if !pairingFileExists { + tipRow(systemImage: "doc.badge.plus", title: "Pairing file required", message: "Import your device’s pairing file to begin.") + } + if pairingFileExists && !ddiMounted { + tipRow(systemImage: "externaldrive.badge.exclamationmark", title: "Developer Disk Image not mounted", message: "Go to Settings → Developer Disk Image and ensure it’s mounted.") + } + tipRow(systemImage: "lock.shield", title: "Local only", message: "StikDebug runs entirely on-device. No data leaves your device.") + + Divider().background(Color.white.opacity(0.1)) + + Button { + if let url = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md") { + UIApplication.shared.open(url) + } + } label: { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "questionmark.circle") + .foregroundStyle(accentColor) + .font(.system(size: 18, weight: .semibold)) + + VStack(alignment: .leading, spacing: 2) { + Text("Pairing File Guide") + .font(.subheadline.weight(.semibold)) + Text("Step-by-step instructions from the community wiki.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + } + } + } + private func tipRow(systemImage: String, title: String, message: String) -> some View { HStack(alignment: .top, spacing: 12) { Image(systemName: systemImage) - .foregroundColor(accentColor) - .font(.system(size: 18)) + .foregroundStyle(accentColor) + .font(.system(size: 18, weight: .semibold)) VStack(alignment: .leading, spacing: 2) { - Text(title).font(.subheadline).fontWeight(.semibold) - Text(message).font(.footnote).foregroundColor(.secondary) + Text(title) + .font(.subheadline.weight(.semibold)) + Text(message) + .font(.footnote) + .foregroundStyle(.secondary) } - Spacer() + Spacer(minLength: 0) } .padding(.vertical, 4) } - - private func helperSubtitle() -> String { - if canConnectByApp { return "Select an app to start debugging." } - if pairingFileExists { return "Mount the Developer Disk Image in Settings." } - return "Import your pairing file to get started." - } - + private func primaryActionTapped() { if pairingFileExists { if !ddiMounted { @@ -688,6 +942,199 @@ private struct StatusDot: View { } } +private struct StatusGlyph: View { + let icon: String + let tint: Color + var size: CGFloat = 48 + var iconSize: CGFloat = 22 + + var body: some View { + ZStack { + Circle() + .fill(tint.opacity(0.18)) + .frame(width: size, height: size) + + Image(systemName: icon) + .font(.system(size: iconSize, weight: .semibold, design: .rounded)) + .foregroundStyle(tint) + } + .overlay( + Circle() + .stroke(Color.white.opacity(0.12), lineWidth: 0.5) + ) + } +} + +private struct ChecklistRow: View { + let item: ChecklistItem + let accentColor: Color + + var body: some View { + HStack(alignment: .center, spacing: 12) { + StatusGlyph(icon: item.status.iconName, tint: item.status.tint, size: 40, iconSize: 18) + + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Text(item.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + + if let actionTitle = item.actionTitle, let action = item.action { + Button(action: action) { + Text(actionTitle) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule(style: .continuous) + .fill(accentColor.opacity(0.18)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(accentColor.opacity(0.4), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .foregroundStyle(accentColor) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(UIColor.secondarySystemBackground).opacity(0.55)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + } +} + +private struct QuickConnectRow: View { + let item: QuickConnectItem + let accentColor: Color + let isEnabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 14) { + QuickAppBadge(title: item.displayName, accentColor: accentColor) + + VStack(alignment: .leading, spacing: 2) { + Text(item.displayName) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .lineLimit(1) + + Text(item.bundleID) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .textSelection(.enabled) + } + + Spacer(minLength: 0) + + Image(systemName: "bolt.horizontal.circle.fill") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(isEnabled ? accentColor : Color.secondary) + } + .padding(.vertical, 12) + .padding(.horizontal, 14) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color(UIColor.secondarySystemBackground).opacity(isEnabled ? 0.65 : 0.35)) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.white.opacity(0.1), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.55) + } +} + +private struct QuickAppBadge: View { + let title: String + let accentColor: Color + + private var initials: String { + let words = title.split(separator: " ") + if let first = words.first, !first.isEmpty { + return String(first.prefix(1)).uppercased() + } + return String(title.prefix(1)).uppercased() + } + + var body: some View { + Text(initials) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .frame(width: 36, height: 36) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(accentColor.opacity(0.16)) + ) + .foregroundStyle(accentColor) + } +} + +private struct ReadinessSummary { + let title: String + let subtitle: String + let icon: String + let tint: Color +} + +private enum ChecklistStatus { + case ready + case waiting + case attention + case actionRequired + + var iconName: String { + switch self { + case .ready: return "checkmark.circle.fill" + case .waiting: return "clock.fill" + case .attention: return "exclamationmark.triangle.fill" + case .actionRequired: return "exclamationmark.octagon.fill" + } + } + + var tint: Color { + switch self { + case .ready: return .green + case .waiting: return .orange + case .attention: return .yellow + case .actionRequired: return .red + } + } +} + +private struct ChecklistItem: Identifiable { + let id = UUID() + let title: String + let subtitle: String + let status: ChecklistStatus + let actionTitle: String? + let action: (() -> Void)? +} + +private struct QuickConnectItem: Identifiable { + let bundleID: String + let displayName: String + var id: String { bundleID } +} + // MARK: - Connect-by-PID Sheet (minus/plus removed) private struct ConnectByPIDSheet: View { diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index a392d61..78fc78e 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -30,16 +30,50 @@ struct InstalledAppsListView: View { @AppStorage("performanceMode") private var performanceMode = false @State private var showPerformanceToast = false - @State private var selectedTab: AppListTab = .debuggable @State private var launchingBundles: Set = [] @State private var launchFeedback: LaunchFeedback? = nil + @State private var debuggableSearchText: String = "" @State private var otherSearchText: String = "" @Environment(\.dismiss) private var dismiss var onSelectApp: (String) -> Void - private var filteredRecents: [String] { - recentApps.filter { viewModel.debuggableApps[$0] != nil && !favoriteApps.contains($0) } + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue + @Environment(\.themeExpansionManager) private var themeExpansion + private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } + private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) } + + private var debuggableSearchIsActive: Bool { + !debuggableSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var debuggableSortedApps: [(key: String, value: String)] { + viewModel.debuggableApps.sorted { lhs, rhs in + let comparison = lhs.value.localizedCaseInsensitiveCompare(rhs.value) + if comparison == .orderedSame { + return lhs.key < rhs.key + } + return comparison == .orderedAscending + } + } + + private var filteredDebuggableApps: [(key: String, value: String)] { + guard debuggableSearchIsActive else { return debuggableSortedApps } + let query = normalizedSearchString(debuggableSearchText) + guard !query.isEmpty else { return debuggableSortedApps } + return debuggableSortedApps.filter { matches(query, bundleID: $0.key, name: $0.value) } + } + + private var filteredDebuggableSet: Set { + Set(filteredDebuggableApps.map { $0.key }) + } + + private var filteredFavoriteBundles: [String] { + favoriteApps.filter { filteredDebuggableSet.contains($0) } + } + + private var filteredRecentBundles: [String] { + recentApps.filter { filteredDebuggableSet.contains($0) && !favoriteApps.contains($0) } } private enum AppListTab: String, CaseIterable, Identifiable { @@ -79,19 +113,22 @@ struct InstalledAppsListView: View { private var filteredOtherApps: [(key: String, value: String)] { let base = sortedOtherApps guard otherSearchIsActive else { return base } - let query = otherSearchText - .trimmingCharacters(in: .whitespacesAndNewlines) + let query = normalizedSearchString(otherSearchText) + guard !query.isEmpty else { return base } + return base.filter { matches(query, bundleID: $0.key, name: $0.value) } + } + + private func normalizedSearchString(_ text: String) -> String { + text.trimmingCharacters(in: .whitespacesAndNewlines) .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) .lowercased() - return base.filter { - let identifier = $0.key - .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) - .lowercased() - let name = $0.value - .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) - .lowercased() - return identifier.contains(query) || name.contains(query) - } + } + + private func matches(_ normalizedQuery: String, bundleID: String, name: String) -> Bool { + guard !normalizedQuery.isEmpty else { return true } + let identifier = normalizedSearchString(bundleID) + let displayName = normalizedSearchString(name) + return identifier.contains(normalizedQuery) || displayName.contains(normalizedQuery) } private func isEmpty(for tab: AppListTab) -> Bool { @@ -106,7 +143,8 @@ struct InstalledAppsListView: View { var body: some View { NavigationStack { ZStack { - Color.clear.ignoresSafeArea() + ThemedBackground(style: backgroundStyle) + .ignoresSafeArea() if viewModel.isLoading { ProgressView("Loading Apps…".localized) @@ -193,6 +231,7 @@ struct InstalledAppsListView: View { } } } + .preferredColorScheme(preferredScheme) } // MARK: Empty State @@ -241,14 +280,18 @@ struct InstalledAppsListView: View { // MARK: Apps List - private var debuggableSections: some View { + private func debuggableSections( + apps: [(key: String, value: String)], + favorites: [String], + recents: [String] + ) -> some View { VStack(spacing: 18) { - if !favoriteApps.isEmpty { + if !favorites.isEmpty { glassSection( - title: String(format: "Favorites (%d/4)".localized, favoriteApps.count) + title: String(format: "Favorites (%d/4)".localized, favorites.count) ) { LazyVStack(spacing: 12) { - ForEach(favoriteApps, id: \.self) { bundleID in + ForEach(favorites, id: \.self) { bundleID in AppButton( bundleID: bundleID, appName: viewModel.debuggableApps[bundleID] ?? bundleID, @@ -264,10 +307,10 @@ struct InstalledAppsListView: View { } } - if !filteredRecents.isEmpty { + if !recents.isEmpty { glassSection(title: "Recents".localized) { LazyVStack(spacing: 12) { - ForEach(filteredRecents, id: \.self) { bundleID in + ForEach(recents, id: \.self) { bundleID in AppButton( bundleID: bundleID, appName: viewModel.debuggableApps[bundleID] ?? bundleID, @@ -285,7 +328,7 @@ struct InstalledAppsListView: View { glassSection(title: "All Applications".localized) { LazyVStack(spacing: 12) { - ForEach(viewModel.debuggableApps.sorted(by: { $0.key < $1.key }), id: \.key) { bundleID, appName in + ForEach(apps, id: \.key) { bundleID, appName in AppButton( bundleID: bundleID, appName: appName, @@ -331,22 +374,14 @@ struct InstalledAppsListView: View { } private var tabbedContent: some View { - TabView(selection: $selectedTab) { - tabContent(for: .debuggable) - .tag(AppListTab.debuggable) - .tabItem { Text(AppListTab.debuggable.title.localized) } - - tabContent(for: .other) - .tag(AppListTab.other) - .tabItem { Text(AppListTab.other.title.localized) } - } + tabContent(for: .debuggable) } @ViewBuilder private func tabContent(for tab: AppListTab) -> some View { switch tab { case .debuggable: - if isEmpty(for: .debuggable) { + if viewModel.debuggableApps.isEmpty { VStack { Spacer(minLength: 0) emptyState(for: .debuggable) @@ -359,11 +394,22 @@ struct InstalledAppsListView: View { } else { ScrollView { VStack(spacing: 18) { + debuggableSearchBar + if let error = viewModel.lastError { errorBanner(error) } - debuggableSections + if filteredDebuggableApps.isEmpty { + debuggableSearchEmptyState + .transition(.opacity.combined(with: .scale)) + } else { + debuggableSections( + apps: filteredDebuggableApps, + favorites: filteredFavoriteBundles, + recents: filteredRecentBundles + ) + } } .padding(.horizontal, 20) .padding(.vertical, 24) @@ -400,6 +446,55 @@ struct InstalledAppsListView: View { } } + private var debuggableSearchBar: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + + TextField("Search apps or bundle ID".localized, text: $debuggableSearchText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + + if debuggableSearchIsActive { + Button { + debuggableSearchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(.secondary) + } + .accessibilityLabel("Clear search".localized) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(.white.opacity(0.08), lineWidth: 1) + ) + ) + } + + private var debuggableSearchEmptyState: some View { + VStack(spacing: 12) { + Image(systemName: "text.magnifyingglass") + .font(.system(size: 44, weight: .regular)) + .foregroundStyle(.secondary) + + Text("No matching apps".localized) + .font(.title3.weight(.semibold)) + + Text("Try a different name or bundle identifier.".localized) + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(24) + .glassCard(cornerRadius: 20, material: .thinMaterial, strokeOpacity: 0.12) + } + private var otherSearchEmptyState: some View { VStack(spacing: 12) { Image(systemName: "magnifyingglass") @@ -543,6 +638,8 @@ struct AppButton: View { @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true @AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue + @Environment(\.themeExpansionManager) private var themeExpansion var onSelectApp: (String) -> Void let sharedDefaults: UserDefaults @@ -550,6 +647,8 @@ struct AppButton: View { @State private var showScriptPicker = false + private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } + var body: some View { Button(action: selectApp) { HStack(spacing: loadAppIconsOnJIT ? 16 : 12) { @@ -663,24 +762,7 @@ struct AppButton: View { // MARK: Row Background private var rowBackground: some View { - Group { - if performanceMode { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(.secondarySystemBackground).opacity(0.65)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(.white.opacity(0.10), lineWidth: 1) - ) - } else { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(.white.opacity(0.15), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 3) - } - } + ThemedRowBackground(performanceMode: performanceMode, style: rowBackgroundStyle, cornerRadius: 16) } // MARK: Actions @@ -760,10 +842,14 @@ struct LaunchAppRow: View { @Binding var appIcons: [String: UIImage] @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue + @Environment(\.themeExpansionManager) private var themeExpansion let performanceMode: Bool var launchAction: () -> Void + private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } + var body: some View { Button { guard !isLaunching else { return } @@ -844,24 +930,7 @@ struct LaunchAppRow: View { } private var rowBackground: some View { - Group { - if performanceMode { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(.secondarySystemBackground).opacity(0.65)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(.white.opacity(0.10), lineWidth: 1) - ) - } else { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(.white.opacity(0.15), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 3) - } - } + ThemedRowBackground(performanceMode: performanceMode, style: rowBackgroundStyle, cornerRadius: 16) } private func loadAppIcon(for bundleID: String) { @@ -879,6 +948,75 @@ struct LaunchAppRow: View { } } +private struct ThemedRowBackground: View { + var performanceMode: Bool + var style: BackgroundStyle + var cornerRadius: CGFloat + + var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + + return Group { + if performanceMode { + shape + .fill(Color(.secondarySystemBackground).opacity(0.65)) + .overlay(shape.stroke(Color.white.opacity(0.10), lineWidth: 1)) + } else { + ZStack { + shape.fill(.ultraThinMaterial) + themedOverlay(shape: shape) + shape.stroke(Color.white.opacity(0.15), lineWidth: 1) + } + .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 3) + } + } + } + + @ViewBuilder + private func themedOverlay(shape: RoundedRectangle) -> some View { + switch style { + case .staticGradient(let colors), .customGradient(let colors): + shape + .fill( + LinearGradient( + gradient: Gradient(colors: normalized(colors)), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .opacity(0.32) + case .animatedGradient(let colors, _): + shape + .fill( + LinearGradient( + gradient: Gradient(colors: normalized(colors)), + startPoint: .leading, + endPoint: .trailing + ) + ) + .opacity(0.38) + case .blobs(let colors, _): + shape + .fill( + LinearGradient( + gradient: Gradient(colors: normalized(colors)), + startPoint: .top, + endPoint: .bottom + ) + ) + .opacity(0.40) + case .particles(let particle, _): + shape.fill(particle.opacity(0.18)) + } + } + + private func normalized(_ colors: [Color]) -> [Color] { + if colors.count >= 2 { return colors } + if let first = colors.first { return [first, first.opacity(0.6)] } + return [Color.blue, Color.purple] + } +} + final class IconCache { static let shared = IconCache() diff --git a/StikJIT/Views/MainTabView.swift b/StikJIT/Views/MainTabView.swift index 1041d31..6e82388 100644 --- a/StikJIT/Views/MainTabView.swift +++ b/StikJIT/Views/MainTabView.swift @@ -9,14 +9,21 @@ import SwiftUI struct MainTabView: View { @AppStorage("customAccentColor") private var customAccentColorHex: String = "" + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue @State private var selection: Int = 0 // Update checking @State private var showForceUpdate: Bool = false @State private var latestVersion: String? = nil + @Environment(\.themeExpansionManager) private var themeExpansion + private var accentColor: Color { - customAccentColorHex.isEmpty ? .white : (Color(hex: customAccentColorHex) ?? .white) + themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue + } + + private var preferredScheme: ColorScheme? { + themeExpansion?.preferredColorScheme(for: appThemeRaw) } var body: some View { @@ -42,9 +49,9 @@ struct MainTabView: View { .tabItem { Label("Settings", systemImage: "gearshape.fill") } .tag(4) } - .id(customAccentColorHex) + .id((themeExpansion?.hasThemeExpansion == true) ? customAccentColorHex : "default-accent") .tint(accentColor) - .preferredColorScheme(.dark) + .preferredColorScheme(preferredScheme) .onAppear { checkForUpdate() } @@ -143,5 +150,6 @@ struct MainTabView: View { struct MainTabView_Previews: PreviewProvider { static var previews: some View { MainTabView() + .themeExpansionManager(ThemeExpansionManager(previewUnlocked: true)) } } diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift index ab0f51b..8e95293 100644 --- a/StikJIT/Views/SettingsView.swift +++ b/StikJIT/Views/SettingsView.swift @@ -17,20 +17,22 @@ struct SettingsView: View { @AppStorage("enablePiP") private var enablePiP = false @AppStorage("customAccentColor") private var customAccentColorHex: String = "" @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue - private var currentTheme: AppTheme { AppTheme(rawValue: appThemeRaw) ?? .system } + @Environment(\.themeExpansionManager) private var themeExpansion + private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } + private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) } @State private var isShowingPairingFilePicker = false @Environment(\.colorScheme) private var colorScheme @State private var showIconPopover = false @State private var showPairingFileMessage = false - @State private var pairingFileIsValid = false @State private var isImportingFile = false @State private var importProgress: Float = 0.0 + @State private var pairingStatusMessage: String? = nil + @State private var showRemovePairingFileDialog = false @State private var is_lc = false @State private var showColorPickerPopup = false - - @State private var showingConsoleLogsView = false + @State private var showingDisplayView = false private var appVersion: String { @@ -39,8 +41,15 @@ struct SettingsView: View { } private var accentColor: Color { - if customAccentColorHex.isEmpty { return .white } - return Color(hex: customAccentColorHex) ?? .white + themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue + } + + private var currentThemeName: String { + AppTheme(rawValue: appThemeRaw)?.displayName ?? "Default" + } + + private var accentColorDescription: String { + customAccentColorHex.isEmpty ? "System Blue" : customAccentColorHex.uppercased() } // Developer profile image URLs private let developerProfiles: [String: String] = [ @@ -57,7 +66,7 @@ struct SettingsView: View { NavigationStack { ZStack { // Subtle depth gradient background - ThemedBackground(style: currentTheme.backgroundStyle) + ThemedBackground(style: backgroundStyle) .ignoresSafeArea() ScrollView { @@ -112,10 +121,12 @@ struct SettingsView: View { } // Success toast after import - if showPairingFileMessage && pairingFileIsValid && !isImportingFile { + if let pairingStatusMessage, + showPairingFileMessage, + !isImportingFile { VStack { Spacer() - Text("✓ Pairing file successfully imported") + Text(pairingStatusMessage) .font(.footnote.weight(.semibold)) .padding(.horizontal, 14) .padding(.vertical, 10) @@ -130,8 +141,9 @@ struct SettingsView: View { } .navigationTitle("Settings") } - // Force a white tint in Settings, overriding any global/user tint - .tint(Color.white) + // Match controls to the active accent color (defaults to blue) + .tint(accentColor) + .preferredColorScheme(preferredScheme) .fileImporter( isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList], @@ -156,7 +168,8 @@ struct SettingsView: View { DispatchQueue.main.async { isImportingFile = true importProgress = 0.0 - pairingFileIsValid = false + pairingStatusMessage = nil + showPairingFileMessage = false } let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in @@ -166,17 +179,6 @@ struct SettingsView: View { } else { timer.invalidate() isImportingFile = false - pairingFileIsValid = true - - withAnimation { - showPairingFileMessage = true - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - withAnimation { - showPairingFileMessage = false - } - } } } } @@ -198,7 +200,6 @@ struct SettingsView: View { print("Failed to import file: \(error)") } } - .preferredColorScheme(.dark) } // MARK: - Cards @@ -228,17 +229,38 @@ struct SettingsView: View { private var appearanceCard: some View { glassCard { - VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 16) { Text("Appearance") .font(.headline) .foregroundColor(.primary) - + + HStack(spacing: 14) { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(accentColor) + .frame(width: 44, height: 44) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.2), lineWidth: 1) + ) + + VStack(alignment: .leading, spacing: 4) { + Text(currentThemeName) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.primary) + Text("Accent · \(accentColorDescription)") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + } + .padding(.vertical, 4) + Button(action: { showingDisplayView = true }) { HStack { Image(systemName: "paintbrush") .font(.system(size: 18)) .foregroundColor(.primary.opacity(0.85)) - Text("Display") + Text("Customize Display") .foregroundColor(.primary.opacity(0.85)) Spacer() Image(systemName: "chevron.right") @@ -251,8 +273,11 @@ struct SettingsView: View { .padding(4) } .sheet(isPresented: $showingDisplayView) { - DisplayView() - .preferredColorScheme(.dark) + if let manager = themeExpansion { + DisplayView().themeExpansionManager(manager) + } else { + DisplayView() + } } } @@ -282,7 +307,7 @@ struct SettingsView: View { ) } - if showPairingFileMessage && pairingFileIsValid && !isImportingFile { + if showPairingFileMessage && !isImportingFile { HStack { Image(systemName: "checkmark.circle.fill").foregroundColor(.green) Text("Pairing file successfully imported") @@ -332,21 +357,6 @@ struct SettingsView: View { .font(.headline) .foregroundColor(.primary) - Button(action: { showingConsoleLogsView = true }) { - HStack { - Image(systemName: "terminal") - .font(.system(size: 18)) - .foregroundColor(.primary.opacity(0.8)) - Text("System Logs") - .foregroundColor(.primary.opacity(0.8)) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 14)) - .foregroundColor(.white) - } - .padding(.vertical, 8) - } - Button(action: { openAppFolder() }) { HStack { Image(systemName: "folder") @@ -363,10 +373,6 @@ struct SettingsView: View { } } } - .sheet(isPresented: $showingConsoleLogsView) { - ConsoleLogsView() - .preferredColorScheme(.dark) - } } private var helpCard: some View { @@ -549,14 +555,13 @@ struct LinkRow: View { } } .padding(.vertical, 8) - .preferredColorScheme(.dark) } } struct ConsoleLogsView_Preview: PreviewProvider { static var previews: some View { ConsoleLogsView() - .preferredColorScheme(.dark) + .themeExpansionManager(ThemeExpansionManager(previewUnlocked: true)) } } diff --git a/StikJIT/Views/Testing.swift b/StikJIT/Views/Testing.swift index ab0d139..33e09f7 100644 --- a/StikJIT/Views/Testing.swift +++ b/StikJIT/Views/Testing.swift @@ -540,7 +540,7 @@ enum ManagerSection: String, CaseIterable, Identifiable { struct GlassLiquidTabSwitcher: View { @Binding var selection: ManagerSection - var accent: Color = .white + var accent: Color = .blue var height: CGFloat = 56 private let pad: CGFloat = 6 @@ -685,8 +685,9 @@ private struct ManageCertsView: View { struct IPAAppManagerView: View { @AppStorage("customAccentColor") private var customAccentColorHex: String = "" + @Environment(\.themeExpansionManager) private var themeExpansion private var accent: Color { - customAccentColorHex.isEmpty ? .white : Color(hex: customAccentColorHex) ?? .white + themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue } @StateObject private var mgr = AppSignerManager()