Add appearance setting

This commit is contained in:
Rokas Ambrazevicius
2024-02-01 22:34:37 +02:00
parent 36a4f93127
commit f194fb9cca
23 changed files with 461 additions and 214 deletions
+10 -5
View File
@@ -1,10 +1,15 @@
import SwiftUI
import UIComponents
import AppSettings
import Modifiers
import Theme
import UIComponents
public struct HomeView: View {
@ObservedObject private var viewModel = HomeViewModel(selectedNetwork: .mixnet)
public init() {}
@ObservedObject private var viewModel: HomeViewModel
public init(viewModel: HomeViewModel) {
self.viewModel = viewModel
}
public var body: some View {
HomeFlowCoordinator(state: viewModel, content: content)
@@ -23,6 +28,7 @@ private extension HomeView {
connectionSection()
connectButton()
}
.appearanceUpdate()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
NymColor.background
@@ -34,8 +40,7 @@ private extension HomeView {
func navbar() -> some View {
CustomNavBar(
title: "NymVPN".localizedString,
rightButton: CustomNavBarButton(type: .settings, action: { print("settings")
viewModel.navigateToSettings() })
rightButton: CustomNavBarButton(type: .settings, action: { viewModel.navigateToSettings() })
)
Spacer()
.frame(height: 50)
@@ -17,6 +17,7 @@
D99A147A2B357E9900F2728B /* NymVPNApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D99A14792B357E9900F2728B /* NymVPNApp.swift */; };
D99A148A2B35A73F00F2728B /* Home in Frameworks */ = {isa = PBXBuildFile; productRef = D99A14892B35A73F00F2728B /* Home */; };
D99A148C2B35A74200F2728B /* Theme in Frameworks */ = {isa = PBXBuildFile; productRef = D99A148B2B35A74200F2728B /* Theme */; };
D9CD835D2B6C32D4008DA864 /* Modifiers in Frameworks */ = {isa = PBXBuildFile; productRef = D9CD835C2B6C32D4008DA864 /* Modifiers */; };
D9F699E02B4C1862005576A9 /* Settings in Frameworks */ = {isa = PBXBuildFile; productRef = D9F699DF2B4C1862005576A9 /* Settings */; };
/* End PBXBuildFile section */
@@ -76,6 +77,7 @@
D968BDC92B6AEA9D003F42E5 /* AppSettings in Frameworks */,
D99A148C2B35A74200F2728B /* Theme in Frameworks */,
D99A148A2B35A73F00F2728B /* Home in Frameworks */,
D9CD835D2B6C32D4008DA864 /* Modifiers in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -181,6 +183,7 @@
D96740222B56A67A001F6891 /* Tunnels */,
D968BDC82B6AEA9D003F42E5 /* AppSettings */,
D968BDCA2B6BC98E003F42E5 /* AppVersionProvider */,
D9CD835C2B6C32D4008DA864 /* Modifiers */,
);
productName = NymVPN;
productReference = D99A14762B357E9900F2728B /* NymVPN.app */;
@@ -605,6 +608,10 @@
isa = XCSwiftPackageProductDependency;
productName = Theme;
};
D9CD835C2B6C32D4008DA864 /* Modifiers */ = {
isa = XCSwiftPackageProductDependency;
productName = Modifiers;
};
D9F699DF2B4C1862005576A9 /* Settings */ = {
isa = XCSwiftPackageProductDependency;
productName = Settings;
+2 -2
View File
@@ -6,6 +6,7 @@ import Tunnels
@main
struct NymVPNApp: App {
init() {
setup()
}
@@ -13,9 +14,8 @@ struct NymVPNApp: App {
var body: some Scene {
WindowGroup {
NavigationStack {
HomeView()
HomeView(viewModel: HomeViewModel(selectedNetwork: .mixnet))
}
.preferredColorScheme(AppSettings.shared.currentTheme.colorScheme)
.environmentObject(AppSettings.shared)
.environmentObject(TunnelsManager.shared)
}
+11
View File
@@ -18,6 +18,10 @@ let package = Package(
name: "AppVersionProvider",
targets: ["AppVersionProvider"]
),
.library(
name: "Modifiers",
targets: ["Modifiers"]
),
.library(
name: "Tunnels",
targets: ["Tunnels"]
@@ -34,6 +38,13 @@ let package = Package(
dependencies: [],
path: "Sources/Services/AppVersionProvider"
),
.target(
name: "Modifiers",
dependencies: [
"AppSettings"
],
path: "Sources/Services/Modifiers"
),
.target(
name: "Tunnels",
dependencies: [],
@@ -3,5 +3,5 @@ import SwiftUI
public final class AppSettings: ObservableObject {
public static let shared = AppSettings()
@AppStorage("currentTheme") public var currentTheme: Theme = .automatic
@AppStorage("currentAppearance") public var currentTheme: AppSetting.Appearance = .automatic
}
@@ -0,0 +1,20 @@
import SwiftUI
public struct AppSetting {
public enum Appearance: Int, CaseIterable {
case automatic
case light
case dark
public var colorScheme: ColorScheme? {
switch self {
case .light:
return .light
case .dark:
return .dark
case .automatic:
return ColorScheme(.unspecified)
}
}
}
}
@@ -1,18 +0,0 @@
import SwiftUI
public enum Theme: Int {
case light
case dark
case automatic
public var colorScheme: ColorScheme? {
switch self {
case .light:
return .light
case .dark:
return .dark
case .automatic:
return ColorScheme(.unspecified)
}
}
}
@@ -0,0 +1,17 @@
import SwiftUI
import AppSettings
public struct AppearanceUpdate: ViewModifier {
@EnvironmentObject private var appSettings: AppSettings
public func body(content: Content) -> some View {
content
.preferredColorScheme(appSettings.currentTheme.colorScheme)
}
}
public extension View {
func appearanceUpdate() -> some View {
modifier(AppearanceUpdate())
}
}
-4
View File
@@ -16,16 +16,12 @@ let package = Package(
)
],
dependencies: [
// .package(name: "AppVersionProvider", path: "../Services"),
// .package(name: "AppSettings", path: "../Services"),
.package(path: "../UIComponents")
],
targets: [
.target(
name: "Settings",
dependencies: [
// "AppVersionProvider",
// "AppSettings",
"UIComponents"
]
),
@@ -1,98 +0,0 @@
import Theme
import UIComponents
public struct SettingsConfig {
public init() {}
var sections: [SettingsSection] {
[
connectionSection(),
themeSection(),
logsSection(),
feedbackSection(),
legalSection()
]
}
}
private extension SettingsConfig {
func connectionSection() -> SettingsSection {
.connection(
viewModels: [
SettingsListItemViewModel(
accessory: .arrow,
title: "autoConnectTitle".localizedString,
subtitle: "autoConnectSubtitle".localizedString,
imageName: "autoConnect",
action: {}
),
SettingsListItemViewModel(
accessory: .toggle,
title: "entryLocationTitle".localizedString,
subtitle: "entryLocationSubtitle".localizedString,
imageName: "entryHop",
action: {}
)
]
)
}
func themeSection() -> SettingsSection {
.theme(
viewModels: [
SettingsListItemViewModel(
accessory: .arrow,
title: "displayTheme".localizedString,
imageName: "displayTheme",
action: {
}
)
]
)
}
func logsSection() -> SettingsSection {
.logs(
viewModels: [
SettingsListItemViewModel(
accessory: .arrow,
title: "logs".localizedString,
imageName: "logs",
action: {}
)
]
)
}
func feedbackSection() -> SettingsSection {
.feedback(
viewModels: [
SettingsListItemViewModel(
accessory: .arrow,
title: "feedback".localizedString,
imageName: "feedback",
action: {}
),
SettingsListItemViewModel(
accessory: .arrow,
title: "support".localizedString,
imageName: "support",
action: {}
)
]
)
}
func legalSection() -> SettingsSection {
.legal(
viewModels: [
SettingsListItemViewModel(
accessory: .arrow,
title: "legal".localizedString,
action: {}
)
]
)
}
}
@@ -1,4 +1,5 @@
import SwiftUI
import AppSettings
struct SettingsFlowCoordinator<Content: View>: View {
@ObservedObject var state: SettingsFlowState
@@ -12,7 +13,7 @@ struct SettingsFlowCoordinator<Content: View>: View {
@ViewBuilder private func linkDestination(link: SettingsLink) -> some View {
switch link {
case .theme:
SettingsThemeView()
AppearanceView(viewModel: AppearanceViewModel(path: $state.path, appSettings: AppSettings.shared))
}
}
}
@@ -1,9 +1,11 @@
import SwiftUI
import Theme
import AppSettings
import Modifiers
import UIComponents
import Theme
public struct SettingsView: View {
@StateObject var viewModel: SettingsViewModel
@StateObject private var viewModel: SettingsViewModel
public init(viewModel: SettingsViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
@@ -22,6 +24,7 @@ private extension SettingsView {
settingsList()
Spacer()
}
.appearanceUpdate()
.navigationBarBackButtonHidden(true)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea(edges: [.bottom])
@@ -44,7 +47,7 @@ private extension SettingsView {
SettingsList(
viewModel:
SettingsListViewModel(
sections: viewModel.settingsConfig.sections,
sections: viewModel.sections,
appVersion: viewModel.appVersion()
)
)
@@ -1,9 +1,19 @@
import Foundation
import AppVersionProvider
import UIComponents
public class SettingsViewModel: SettingsFlowState {
let settingsTitle = "settings".localizedString
let settingsConfig = SettingsConfig()
var sections: [SettingsSection] {
[
connectionSection(),
themeSection(),
logsSection(),
feedbackSection(),
legalSection()
]
}
func navigateHome() {
path = .init()
@@ -19,3 +29,85 @@ private extension SettingsViewModel {
path.append(SettingsLink.theme)
}
}
private extension SettingsViewModel {
func connectionSection() -> SettingsSection {
.connection(
viewModels: [
SettingsListItemViewModel(
accessory: .arrow,
title: "autoConnectTitle".localizedString,
subtitle: "autoConnectSubtitle".localizedString,
imageName: "autoConnect",
action: {}
),
SettingsListItemViewModel(
accessory: .toggle,
title: "entryLocationTitle".localizedString,
subtitle: "entryLocationSubtitle".localizedString,
imageName: "entryHop",
action: {}
)
]
)
}
func themeSection() -> SettingsSection {
.theme(
viewModels: [
SettingsListItemViewModel(
accessory: .arrow,
title: "displayTheme".localizedString,
imageName: "displayTheme",
action: { [weak self] in
self?.navigateToTheme()
}
)
]
)
}
func logsSection() -> SettingsSection {
.logs(
viewModels: [
SettingsListItemViewModel(
accessory: .arrow,
title: "logs".localizedString,
imageName: "logs",
action: {}
)
]
)
}
func feedbackSection() -> SettingsSection {
.feedback(
viewModels: [
SettingsListItemViewModel(
accessory: .arrow,
title: "feedback".localizedString,
imageName: "feedback",
action: {}
),
SettingsListItemViewModel(
accessory: .arrow,
title: "support".localizedString,
imageName: "support",
action: {}
)
]
)
}
func legalSection() -> SettingsSection {
.legal(
viewModels: [
SettingsListItemViewModel(
accessory: .arrow,
title: "legal".localizedString,
action: {}
)
]
)
}
}
@@ -0,0 +1,57 @@
import SwiftUI
import AppSettings
import Modifiers
import Theme
import UIComponents
public struct AppearanceView: View {
private let viewModel: AppearanceViewModel
public init(viewModel: AppearanceViewModel) {
self.viewModel = viewModel
}
public var body: some View {
VStack {
navbar()
themeOptions()
Spacer()
}
.appearanceUpdate()
.navigationBarBackButtonHidden(true)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea(edges: [.bottom])
.background {
NymColor.background
.ignoresSafeArea()
}
}
}
private extension AppearanceView {
@ViewBuilder
func navbar() -> some View {
CustomNavBar(
title: viewModel.title,
leftButton: CustomNavBarButton(type: .back, action: { viewModel.navigateBack() })
)
}
@ViewBuilder
func themeOptions() -> some View {
ForEach(viewModel.themes, id: \.self) { theme in
SettingButton(
viewModel:
SettingButtonViewModel(
title: viewModel.themeTitle(for: theme),
subtitle: viewModel.themeSubtitle(for: theme),
isSelected: viewModel.isSelected(for: theme)
)
)
.onTapGesture {
viewModel.setCurrentTheme(with: theme)
}
.padding(EdgeInsets(top: 24, leading: 16, bottom: 0, trailing: 16))
}
}
}
@@ -0,0 +1,60 @@
import SwiftUI
import AppSettings
import Theme
public struct AppearanceViewModel {
private let appSettings: AppSettings
let title = "displayTheme".localizedString
@Binding var path: NavigationPath
var themes: [AppSetting.Appearance] {
AppSetting.Appearance.allCases
}
var currentTheme: AppSetting.Appearance {
appSettings.currentTheme
}
public init(path: Binding<NavigationPath>, appSettings: AppSettings) {
_path = path
self.appSettings = appSettings
}
func setCurrentTheme(with theme: AppSetting.Appearance) {
appSettings.currentTheme = theme
}
}
extension AppearanceViewModel {
func themeTitle(for theme: AppSetting.Appearance) -> String {
switch theme {
case .light:
return "lightThemeTitle".localizedString
case .dark:
return "darkThemeTitle".localizedString
case .automatic:
return "automaticThemeTitle".localizedString
}
}
func themeSubtitle(for theme: AppSetting.Appearance) -> String? {
switch theme {
case .light, .dark:
return nil
case .automatic:
return "automaticThemeSubtitle".localizedString
}
}
func isSelected(for theme: AppSetting.Appearance) -> Bool {
appSettings.currentTheme == theme
}
}
extension AppearanceViewModel {
func navigateBack() {
path.removeLast()
}
}
@@ -1,13 +0,0 @@
import SwiftUI
public struct SettingsThemeView: View {
// private let viewModel: SettingsThemeViewModel
//
// public init(viewModel: SettingsThemeViewModel) {
// self.viewModel = viewModel
// }
public var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
}
}
@@ -1,9 +0,0 @@
import Foundation
public struct SettingsThemeViewModel {
// private let appSettings: AppSettings
//
// public init(appSettings: AppSettings) {
// self.appSettings = appSettings
// }
}
@@ -67,6 +67,28 @@
}
}
},
"automaticThemeSubtitle" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Use device theme"
}
}
}
},
"automaticThemeTitle" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Automatic"
}
}
}
},
"connect" : {
"extractionState" : "manual",
"localizations" : {
@@ -111,6 +133,17 @@
}
}
},
"darkThemeTitle" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dark theme"
}
}
}
},
"disconnected" : {
"extractionState" : "manual",
"localizations" : {
@@ -243,6 +276,17 @@
}
}
},
"lightThemeTitle" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Light theme"
}
}
}
},
"logs" : {
"extractionState" : "manual",
"localizations" : {
@@ -41,62 +41,3 @@ public struct NetworkButton: View {
)
}
}
public struct NetworkButtonViewModel {
public enum ButtonType {
case mixnet
case wireguard
var imageName: String {
switch self {
case .mixnet:
return "mixnetIcon"
case .wireguard:
return "wireguardIcon"
}
}
var title: String {
switch self {
case .mixnet:
"5hopMixnetTitle".localizedString
case .wireguard:
"2hopWireGuardTitle".localizedString
}
}
var subtitle: String {
switch self {
case .mixnet:
"5hopMixnetSubtitle".localizedString
case .wireguard:
"2hopWireGuardSubtitle".localizedString
}
}
}
let type: ButtonType
@Binding var selectedNetwork: ButtonType
public init(type: ButtonType, selectedNetwork: Binding<ButtonType>) {
self.type = type
self._selectedNetwork = selectedNetwork
}
private var isSelected: Bool {
type == selectedNetwork
}
var selectionImageName: String {
isSelected ? "networkSelectedCircle" : "networkCircle"
}
var selectionImageColor: Color {
isSelected ? NymColor.primaryOrange : NymColor.networkButtonCircle
}
var selectionStrokeColor: Color {
isSelected ? NymColor.primaryOrange : .clear
}
}
@@ -0,0 +1,61 @@
import SwiftUI
import Theme
public struct NetworkButtonViewModel {
public enum ButtonType {
case mixnet
case wireguard
var imageName: String {
switch self {
case .mixnet:
return "mixnetIcon"
case .wireguard:
return "wireguardIcon"
}
}
var title: String {
switch self {
case .mixnet:
"5hopMixnetTitle".localizedString
case .wireguard:
"2hopWireGuardTitle".localizedString
}
}
var subtitle: String {
switch self {
case .mixnet:
"5hopMixnetSubtitle".localizedString
case .wireguard:
"2hopWireGuardSubtitle".localizedString
}
}
}
let type: ButtonType
@Binding var selectedNetwork: ButtonType
public init(type: ButtonType, selectedNetwork: Binding<ButtonType>) {
self.type = type
self._selectedNetwork = selectedNetwork
}
private var isSelected: Bool {
type == selectedNetwork
}
var selectionImageName: String {
isSelected ? "networkSelectedCircle" : "networkCircle"
}
var selectionImageColor: Color {
isSelected ? NymColor.primaryOrange : NymColor.networkButtonCircle
}
var selectionStrokeColor: Color {
isSelected ? NymColor.primaryOrange : .clear
}
}
@@ -0,0 +1,41 @@
import SwiftUI
import Theme
public struct SettingButton: View {
private let viewModel: SettingButtonViewModel
public init(viewModel: SettingButtonViewModel) {
self.viewModel = viewModel
}
public var body: some View {
VStack {
HStack {
Image(viewModel.selectionImageName, bundle: .module)
.foregroundStyle(viewModel.selectionImageColor)
.padding(.leading, 16)
VStack(alignment: .leading) {
Text(viewModel.title)
.foregroundStyle(NymColor.sysOnSurface)
.textStyle(.Body.Large.primary)
if let subtitle = viewModel.subtitle {
Text(subtitle)
.foregroundStyle(NymColor.sysOutline)
.textStyle(.Body.Medium.primary)
}
}
.padding(.leading, 8)
Spacer()
}
}
.frame(maxWidth: .infinity, minHeight: 64, maxHeight: 64)
.background(NymColor.navigationBarBackground)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.inset(by: 0.5)
.stroke(viewModel.selectionStrokeColor)
)
}
}
@@ -0,0 +1,26 @@
import SwiftUI
import Theme
public struct SettingButtonViewModel {
let title: String
let subtitle: String?
let isSelected: Bool
public init(title: String, subtitle: String?, isSelected: Bool) {
self.title = title
self.subtitle = subtitle
self.isSelected = isSelected
}
var selectionStrokeColor: Color {
isSelected ? NymColor.primaryOrange : .clear
}
var selectionImageName: String {
isSelected ? "networkSelectedCircle" : "networkCircle"
}
var selectionImageColor: Color {
isSelected ? NymColor.primaryOrange : NymColor.networkButtonCircle
}
}
@@ -34,6 +34,9 @@ public struct SettingsListItem: View {
)
)
.padding(.horizontal, 16)
.onTapGesture {
viewModel.action()
}
}
}