diff --git a/Packages/App/Sources/CommonLibrary/CommonLibrary.swift b/Packages/App/Sources/CommonLibrary/CommonLibrary.swift index d9cfefd61..a7bcb69c4 100644 --- a/Packages/App/Sources/CommonLibrary/CommonLibrary.swift +++ b/Packages/App/Sources/CommonLibrary/CommonLibrary.swift @@ -74,8 +74,8 @@ private extension CommonLibrary { func configureShared() { UserDefaults.appGroup.register(defaults: [ - AppPreference.logsPrivateData.key: false, - AppPreference.dnsFallsBack.key: true + AppPreference.dnsFallsBack.key: true, + AppPreference.logsPrivateData.key: false ]) } } diff --git a/Packages/App/Sources/UILibrary/Domain/SystemAppearance.swift b/Packages/App/Sources/UILibrary/Domain/SystemAppearance.swift new file mode 100644 index 000000000..f139450d2 --- /dev/null +++ b/Packages/App/Sources/UILibrary/Domain/SystemAppearance.swift @@ -0,0 +1,43 @@ +// +// SystemAppearance.swift +// Passepartout +// +// Created by Davide De Rosa on 2/17/25. +// Copyright (c) 2025 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import Foundation +import SwiftUI + +public enum SystemAppearance: String, RawRepresentable { + case light + + case dark +} + +extension Optional where Wrapped == SystemAppearance { + public var colorScheme: ColorScheme? { + switch self { + case .none: return nil + case .light: return .light + case .dark: return .dark + } + } +} diff --git a/Packages/App/Sources/UILibrary/Domain/UIPreference.swift b/Packages/App/Sources/UILibrary/Domain/UIPreference.swift index 735248be4..7b0d6503e 100644 --- a/Packages/App/Sources/UILibrary/Domain/UIPreference.swift +++ b/Packages/App/Sources/UILibrary/Domain/UIPreference.swift @@ -39,6 +39,8 @@ public enum UIPreference: String, PreferenceProtocol { case profilesLayout + case systemAppearance + public var key: String { "UI.\(rawValue)" } diff --git a/Packages/App/Sources/UILibrary/L10n/SwiftGen+Strings.swift b/Packages/App/Sources/UILibrary/L10n/SwiftGen+Strings.swift index 255b4834e..97ad3521c 100644 --- a/Packages/App/Sources/UILibrary/L10n/SwiftGen+Strings.swift +++ b/Packages/App/Sources/UILibrary/L10n/SwiftGen+Strings.swift @@ -93,6 +93,16 @@ public enum Strings { /// Inactive public static let inactive = Strings.tr("Localizable", "entities.tunnel_status.inactive", fallback: "Inactive") } + public enum Ui { + public enum SystemAppearance { + /// Dark + public static let dark = Strings.tr("Localizable", "entities.ui.system_appearance.dark", fallback: "Dark") + /// Light + public static let light = Strings.tr("Localizable", "entities.ui.system_appearance.light", fallback: "Light") + /// System + public static let system = Strings.tr("Localizable", "entities.ui.system_appearance.system", fallback: "System") + } + } } public enum Errors { public enum App { @@ -844,6 +854,8 @@ public enum Strings { public static let locksInBackground = Strings.tr("Localizable", "views.preferences.locks_in_background", fallback: "Lock in background") /// Pin active profile public static let pinsActiveProfile = Strings.tr("Localizable", "views.preferences.pins_active_profile", fallback: "Pin active profile") + /// Appearance + public static let systemAppearance = Strings.tr("Localizable", "views.preferences.system_appearance", fallback: "Appearance") public enum DnsFallsBack { /// Fall back to CloudFlare servers when the VPN does not provide DNS settings. public static let footer = Strings.tr("Localizable", "views.preferences.dns_falls_back.footer", fallback: "Fall back to CloudFlare servers when the VPN does not provide DNS settings.") diff --git a/Packages/App/Sources/UILibrary/L10n/UILibrary+L10n.swift b/Packages/App/Sources/UILibrary/L10n/UILibrary+L10n.swift new file mode 100644 index 000000000..c5e56557e --- /dev/null +++ b/Packages/App/Sources/UILibrary/L10n/UILibrary+L10n.swift @@ -0,0 +1,38 @@ +// +// UILibrary+L10n.swift +// Passepartout +// +// Created by Davide De Rosa on 2/17/25. +// Copyright (c) 2025 Davide De Rosa. All rights reserved. +// +// https://github.com/passepartoutvpn +// +// This file is part of Passepartout. +// +// Passepartout is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Passepartout is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Passepartout. If not, see . +// + +import CommonUtils +import Foundation + +extension Optional: LocalizableEntity where Wrapped == SystemAppearance { + public var localizedDescription: String { + let V = Strings.Entities.Ui.SystemAppearance.self + switch self { + case .none: return V.system + case .light: return V.light + case .dark: return V.dark + } + } +} diff --git a/Packages/App/Sources/UILibrary/Resources/en.lproj/Localizable.strings b/Packages/App/Sources/UILibrary/Resources/en.lproj/Localizable.strings index 4340878c3..e399386cd 100644 --- a/Packages/App/Sources/UILibrary/Resources/en.lproj/Localizable.strings +++ b/Packages/App/Sources/UILibrary/Resources/en.lproj/Localizable.strings @@ -98,6 +98,7 @@ "views.paywall.alerts.restricted.message" = "Some features are unavailable in this build."; "views.paywall.alerts.pending.message" = "The purchase is pending external confirmation. The feature will be credited upon approval."; +"views.preferences.system_appearance" = "Appearance"; "views.preferences.launches_on_login" = "Launch on login"; "views.preferences.launches_on_login.footer" = "Open the app in background after login."; "views.preferences.keeps_in_menu" = "Keep in menu bar"; @@ -235,6 +236,12 @@ "entities.openvpn.otp_method.append" = "Append"; "entities.openvpn.otp_method.encode" = "Encode"; +// MARK: Entities (Library) + +"entities.ui.system_appearance.system" = "System"; +"entities.ui.system_appearance.light" = "Light"; +"entities.ui.system_appearance.dark" = "Dark"; + // MARK: - Features "features.appletv" = "%@"; diff --git a/Packages/App/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift b/Packages/App/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift index e7503d621..5d51d765c 100644 --- a/Packages/App/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift +++ b/Packages/App/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift @@ -65,6 +65,10 @@ public enum ThemeModalSize: Hashable { } extension View { + public func themeAppearance(systemScheme: ColorScheme) -> some View { + modifier(ThemeAppearanceModifier(systemScheme: systemScheme)) + } + public func themeModal( isPresented: Binding, options: ThemeModalOptions? = nil, @@ -335,6 +339,12 @@ struct ThemeBooleanModalModifier: ViewModifier where Modal: View { @EnvironmentObject private var theme: Theme + @Environment(\.colorScheme) + private var colorScheme + + @AppStorage(UIPreference.systemAppearance.key) + private var systemAppearance: SystemAppearance? + @Binding var isPresented: Bool @@ -358,6 +368,7 @@ struct ThemeBooleanModalModifier: ViewModifier where Modal: View { #endif .interactiveDismissDisabled(!options.isInteractive) .themeLockScreen() + .themeAppearance(systemScheme: colorScheme) } } } @@ -367,6 +378,12 @@ struct ThemeItemModalModifier: ViewModifier where Modal: View, T: Iden @EnvironmentObject private var theme: Theme + @Environment(\.colorScheme) + private var colorScheme + + @AppStorage(UIPreference.systemAppearance.key) + private var systemAppearance: SystemAppearance? + @Binding var item: T? @@ -390,6 +407,7 @@ struct ThemeItemModalModifier: ViewModifier where Modal: View, T: Iden #endif .interactiveDismissDisabled(!options.isInteractive) .themeLockScreen() + .themeAppearance(systemScheme: colorScheme) } } } @@ -454,6 +472,19 @@ struct ThemeNavigationStackModifier: ViewModifier { // MARK: - Content modifiers +struct ThemeAppearanceModifier: ViewModifier { + + @AppStorage(UIPreference.systemAppearance.key) + private var systemAppearance: SystemAppearance? + + let systemScheme: ColorScheme + + func body(content: Content) -> some View { + content + .preferredColorScheme(systemAppearance.colorScheme ?? systemScheme) + } +} + struct ThemeManualInputModifier: ViewModifier { } diff --git a/Packages/App/Sources/UILibrary/Views/Preferences/PreferencesGroup.swift b/Packages/App/Sources/UILibrary/Views/Preferences/PreferencesGroup.swift index a1982f0e1..be18b5799 100644 --- a/Packages/App/Sources/UILibrary/Views/Preferences/PreferencesGroup.swift +++ b/Packages/App/Sources/UILibrary/Views/Preferences/PreferencesGroup.swift @@ -32,6 +32,9 @@ import SwiftUI public struct PreferencesGroup: View { + @AppStorage(UIPreference.systemAppearance.key) + private var systemAppearance: SystemAppearance? + #if os(iOS) @AppStorage(UIPreference.locksInBackground.key) private var locksInBackground = false @@ -56,6 +59,7 @@ public struct PreferencesGroup: View { } public var body: some View { + systemAppearancePicker #if os(iOS) lockInBackgroundToggle #elseif os(macOS) @@ -69,6 +73,20 @@ public struct PreferencesGroup: View { } private extension PreferencesGroup { + static let systemAppearances: [SystemAppearance?] = [ + nil, + .light, + .dark + ] + + var systemAppearancePicker: some View { + Picker(Strings.Views.Preferences.systemAppearance, selection: $systemAppearance) { + ForEach(Self.systemAppearances, id: \.self) { + Text($0.localizedDescription) + } + } + } + #if os(iOS) var lockInBackgroundToggle: some View { Toggle(Strings.Views.Preferences.locksInBackground, isOn: $locksInBackground) diff --git a/Passepartout/App/PassepartoutApp.swift b/Passepartout/App/PassepartoutApp.swift index 0d28ed53d..8da8d42fd 100644 --- a/Passepartout/App/PassepartoutApp.swift +++ b/Passepartout/App/PassepartoutApp.swift @@ -36,6 +36,9 @@ import SwiftUI @main struct PassepartoutApp: App { + @Environment(\.colorScheme) + var colorScheme + #if os(iOS) || os(tvOS) @UIApplicationDelegateAdaptor diff --git a/Passepartout/App/Platforms/App+iOS.swift b/Passepartout/App/Platforms/App+iOS.swift index 30abbad3f..f881b0799 100644 --- a/Passepartout/App/Platforms/App+iOS.swift +++ b/Passepartout/App/Platforms/App+iOS.swift @@ -52,6 +52,7 @@ extension PassepartoutApp { .withEnvironment(from: context, theme: theme) .environment(\.isUITesting, AppCommandLine.contains(.uiTesting)) .tint(.accentColor) + .themeAppearance(systemScheme: colorScheme) } } } diff --git a/Passepartout/App/Platforms/App+macOS.swift b/Passepartout/App/Platforms/App+macOS.swift index 4481a057a..7ca82de42 100644 --- a/Passepartout/App/Platforms/App+macOS.swift +++ b/Passepartout/App/Platforms/App+macOS.swift @@ -64,6 +64,7 @@ extension PassepartoutApp { .withEnvironment(from: context, theme: theme) .environment(\.isUITesting, AppCommandLine.contains(.uiTesting)) .frame(minWidth: 600, minHeight: 400) + .themeAppearance(systemScheme: colorScheme) } .defaultSize(width: 600, height: 400) @@ -72,6 +73,7 @@ extension PassepartoutApp { .withEnvironment(from: context, theme: theme) .environmentObject(settings) .environment(\.isUITesting, AppCommandLine.contains(.uiTesting)) + .themeAppearance(systemScheme: colorScheme) } .defaultSize(width: 500, height: 400)