From 6007b2b9e866113d46ebeae3724255739aa6abee Mon Sep 17 00:00:00 2001 From: Andrew Bulhak Date: Mon, 11 Nov 2024 14:47:16 +0100 Subject: [PATCH] Implement SwiftUI Shadowsocks settings view with custom field editing --- ios/MullvadVPN.xcodeproj/project.pbxproj | 8 + .../UI appearance/UIColor+Palette.swift | 9 +- .../ShadowsocksObfuscationSettingsView.swift | 72 ++++ ...dowsocksObfuscationSettingsViewModel.swift | 40 +++ ...tionSettingsWatchingObservableObject.swift | 22 +- .../UDPTCPObfuscationSettingsView.swift | 4 +- .../UDPTCPObfuscationSettingsViewModel.swift | 7 +- .../SwiftUI components/SingleChoiceList.swift | 328 +++++++++++++++++- .../VPNSettings/VPNSettingsDataSource.swift | 2 - .../VPNSettingsViewController.swift | 11 +- 10 files changed, 465 insertions(+), 38 deletions(-) create mode 100644 ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift create mode 100644 ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsViewModel.swift diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index d4b7a3e1d0d0..23a3866e9524 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -45,6 +45,8 @@ 440E5AB42CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */; }; 4422C0712CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4422C0702CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift */; }; 4424CDD32CDBD4A6009D8C9F /* SingleChoiceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */; }; + 447F3D8A2CDE1853006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */; }; + 447F3D8B2CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 447F3D892CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift */; }; 449275422C3570CA000526DE /* ICMP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449275412C3570CA000526DE /* ICMP.swift */; }; 449872E12B7BBC5400094DDC /* TunnelSettingsUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */; }; 449872E42B7CB96300094DDC /* TunnelSettingsUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449872E32B7CB96300094DDC /* TunnelSettingsUpdateTests.swift */; }; @@ -1397,6 +1399,8 @@ 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelObfuscationSettingsWatchingObservableObject.swift; sourceTree = ""; }; 4422C0702CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPTCPObfuscationSettingsView.swift; sourceTree = ""; }; 4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleChoiceList.swift; sourceTree = ""; }; + 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksObfuscationSettingsViewModel.swift; sourceTree = ""; }; + 447F3D892CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowsocksObfuscationSettingsView.swift; sourceTree = ""; }; 449275412C3570CA000526DE /* ICMP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ICMP.swift; sourceTree = ""; }; 449275432C3C3029000526DE /* TunnelPinger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelPinger.swift; sourceTree = ""; }; 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsUpdate.swift; sourceTree = ""; }; @@ -2602,6 +2606,8 @@ 4422C06F2CCFF6520001A385 /* Obfuscation */ = { isa = PBXGroup; children = ( + 447F3D892CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift */, + 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */, 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */, 4422C0702CCFF6790001A385 /* UDPTCPObfuscationSettingsView.swift */, 44075DFA2CDA4F7400F61139 /* UDPTCPObfuscationSettingsViewModel.swift */, @@ -5747,6 +5753,7 @@ 58B26E22294351EA00D5980C /* InAppNotificationProvider.swift in Sources */, 5893716A28817A45004EE76C /* DeviceManagementViewController.swift in Sources */, 7A9CCCB82A96302800DD6A34 /* SetupAccountCompletedCoordinator.swift in Sources */, + 447F3D8B2CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift in Sources */, 58BFA5C622A7C97F00A6173D /* RelayCacheTracker.swift in Sources */, 7A0B311E2B303A0D004B12E0 /* AccessbilityIdentifier.swift in Sources */, E158B360285381C60002F069 /* String+AccountFormatting.swift in Sources */, @@ -5889,6 +5896,7 @@ F02F41A22B9723AF00625A4F /* AddLocationsCoordinator.swift in Sources */, 7A27E3CD2CB814EF0088BCFF /* DAITAInfoView.swift in Sources */, F028A56A2A34D4E700C0CAA3 /* RedeemVoucherViewController.swift in Sources */, + 447F3D8A2CDE1853006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift in Sources */, 7A5869C52B5A899C00640D27 /* MethodSettingsCellConfiguration.swift in Sources */, 58E11188292FA11F009FCA84 /* SettingsMigrationUIHandler.swift in Sources */, 58CAFA002983FF0200BE19F7 /* LoginInteractor.swift in Sources */, diff --git a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift index 9d5cfe84bcb5..df7c675a415a 100644 --- a/ios/MullvadVPN/UI appearance/UIColor+Palette.swift +++ b/ios/MullvadVPN/UI appearance/UIColor+Palette.swift @@ -31,18 +31,21 @@ extension UIColor { enum TextField { static let placeholderTextColor = UIColor(red: 0.16, green: 0.30, blue: 0.45, alpha: 0.40) + static let inactivePlaceholderTextColor = UIColor(white: 1.0, alpha: 0.4) static let textColor = UIColor(red: 0.16, green: 0.30, blue: 0.45, alpha: 1.0) + static let inactiveTextColor = UIColor.white static let backgroundColor = UIColor.white + static let inactiveBackgroundColor = UIColor(white: 1.0, alpha: 0.1) static let invalidInputTextColor = UIColor.dangerColor } enum SearchTextField { static let placeholderTextColor = TextField.placeholderTextColor - static let inactivePlaceholderTextColor = UIColor(white: 1.0, alpha: 0.4) + static let inactivePlaceholderTextColor = TextField.inactivePlaceholderTextColor static let textColor = TextField.textColor - static let inactiveTextColor = UIColor.white + static let inactiveTextColor = TextField.inactiveTextColor static let backgroundColor = TextField.backgroundColor - static let inactiveBackgroundColor = UIColor(white: 1.0, alpha: 0.1) + static let inactiveBackgroundColor = TextField.inactiveBackgroundColor static let leftViewTintColor = UIColor.primaryColor static let inactiveLeftViewTintColor = UIColor.white } diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift new file mode 100644 index 000000000000..0c5184ecf712 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsView.swift @@ -0,0 +1,72 @@ +// +// ShadowsocksObfuscationSettingsView.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2024-11-07. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadSettings +import SwiftUI + +struct ShadowsocksObfuscationSettingsView: View where VM: ShadowsocksObfuscationSettingsViewModel { + @StateObject var viewModel: VM + + var body: some View { + let portString = NSLocalizedString( + "SHADOWSOCKS_PORT_LABEL", + tableName: "Shadowsocks", + value: "Port", + comment: "" + ) + + SingleChoiceList( + title: portString, + options: [WireGuardObfuscationShadowsockPort.automatic], + value: $viewModel.value, + itemDescription: { item in NSLocalizedString( + "SHADOWSOCKS_PORT_VALUE_\(item)", + tableName: "Shadowsocks", + value: "\(item)", + comment: "" + ) }, + parseCustomValue: { UInt16($0).flatMap { $0 > 0 ? WireGuardObfuscationShadowsockPort.custom($0) : nil } + }, + formatCustomValue: { + if case let .custom(port) = $0 { + "\(port)" + } else { + nil + } + }, + customLabel: NSLocalizedString( + "SHADOWSOCKS_PORT_VALUE_CUSTOM", + tableName: "Shadowsocks", + value: "Custom", + comment: "" + ), + customPrompt: NSLocalizedString( + "SHADOWSOCKS_PORT_VALUE_PORT_PROMPT", + tableName: "Shadowsocks", + value: "Port", + comment: "" + ), + customLegend: NSLocalizedString( + "SHADOWSOCKS_PORT_VALUE_PORT_LEGEND", + tableName: "Shadowsocks", + value: "Valid range: 1 - 65535", + comment: "" + ), + customInputMinWidth: 100, + customInputMaxLength: 5, + customFieldMode: .numericText + ).onDisappear { + viewModel.commit() + } + } +} + +#Preview { + var model = MockShadowsocksObfuscationSettingsViewModel(shadowsocksPort: .automatic) + return ShadowsocksObfuscationSettingsView(viewModel: model) +} diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsViewModel.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsViewModel.swift new file mode 100644 index 000000000000..4d917496a6b0 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/ShadowsocksObfuscationSettingsViewModel.swift @@ -0,0 +1,40 @@ +// +// ShadowsocksObfuscationSettingsViewModel.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2024-11-07. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadSettings + +protocol ShadowsocksObfuscationSettingsViewModel: ObservableObject { + var value: WireGuardObfuscationShadowsockPort { get set } + + func commit() +} + +/** A simple mock view model for use in Previews and similar */ +class MockShadowsocksObfuscationSettingsViewModel: ShadowsocksObfuscationSettingsViewModel { + @Published var value: WireGuardObfuscationShadowsockPort + + init(shadowsocksPort: WireGuardObfuscationShadowsockPort = .automatic) { + self.value = shadowsocksPort + } + + func commit() {} +} + +/// ** The live view model which interfaces with the TunnelManager */ +class TunnelShadowsocksObfuscationSettingsViewModel: TunnelObfuscationSettingsWatchingObservableObject< + WireGuardObfuscationShadowsockPort +>, + ShadowsocksObfuscationSettingsViewModel { + init(tunnelManager: TunnelManager) { + super.init( + tunnelManager: tunnelManager, + keyPath: \.shadowsocksPort + ) + } +} diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/TunnelObfuscationSettingsWatchingObservableObject.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/TunnelObfuscationSettingsWatchingObservableObject.swift index 58e8144ad363..c83ff130284d 100644 --- a/ios/MullvadVPN/View controllers/Settings/Obfuscation/TunnelObfuscationSettingsWatchingObservableObject.swift +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/TunnelObfuscationSettingsWatchingObservableObject.swift @@ -17,21 +17,12 @@ class TunnelObfuscationSettingsWatchingObservableObject: Observabl let keyPath: WritableKeyPath private var tunnelObserver: TunnelObserver? - // this is essentially @Published from scratch - var value: T { - willSet(newValue) { - guard newValue != self.value else { return } - objectWillChange.send() - var obfuscationSettings = tunnelManager.settings.wireGuardObfuscation - obfuscationSettings[keyPath: keyPath] = newValue - tunnelManager.updateSettings([.obfuscation(obfuscationSettings)]) - } - } + @Published var value: T - init(tunnelManager: TunnelManager, keyPath: WritableKeyPath, _ initialValue: T) { + init(tunnelManager: TunnelManager, keyPath: WritableKeyPath) { self.tunnelManager = tunnelManager self.keyPath = keyPath - self.value = initialValue + self.value = tunnelManager.settings.wireGuardObfuscation[keyPath: keyPath] tunnelObserver = TunnelBlockObserver(didUpdateTunnelSettings: { [weak self] _, newSettings in guard let self else { return } @@ -45,4 +36,11 @@ class TunnelObfuscationSettingsWatchingObservableObject: Observabl value = newValue } } + + // Commit the temporarily stored value upstream + func commit() { + var obfuscationSettings = tunnelManager.settings.wireGuardObfuscation + obfuscationSettings[keyPath: keyPath] = value + tunnelManager.updateSettings([.obfuscation(obfuscationSettings)]) + } } diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsView.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsView.swift index 70769d71eef7..ac8abdb261eb 100644 --- a/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsView.swift +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsView.swift @@ -29,7 +29,9 @@ struct UDPTCPObfuscationSettingsView: View where VM: UDPTCPObfuscationSettin value: "\(item)", comment: "" ) } - ) + ).onDisappear { + viewModel.commit() + } } } diff --git a/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsViewModel.swift b/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsViewModel.swift index f712f0e644e8..5a4d595ae3da 100644 --- a/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsViewModel.swift +++ b/ios/MullvadVPN/View controllers/Settings/Obfuscation/UDPTCPObfuscationSettingsViewModel.swift @@ -11,6 +11,8 @@ import MullvadSettings protocol UDPTCPObfuscationSettingsViewModel: ObservableObject { var value: WireGuardObfuscationUdpOverTcpPort { get set } + + func commit() } /** A simple mock view model for use in Previews and similar */ @@ -20,6 +22,8 @@ class MockUDPTCPObfuscationSettingsViewModel: UDPTCPObfuscationSettingsViewModel init(udpTcpPort: WireGuardObfuscationUdpOverTcpPort = .automatic) { self.value = udpTcpPort } + + func commit() {} } /** The live view model which interfaces with the TunnelManager */ @@ -30,8 +34,7 @@ class TunnelUDPTCPObfuscationSettingsViewModel: TunnelObfuscationSettingsWatchin init(tunnelManager: TunnelManager) { super.init( tunnelManager: tunnelManager, - keyPath: \.udpOverTcpPort, - tunnelManager.settings.wireGuardObfuscation.udpOverTcpPort + keyPath: \.udpOverTcpPort ) } } diff --git a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift index adb0f7bd28f4..f2265a6e068a 100644 --- a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift +++ b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift @@ -9,31 +9,174 @@ import SwiftUI /** - A component presenting a vertical list in the Mullvad style for selecting a single item from a list. - The items can be any Hashable type. - */ + A component presenting a vertical list in the Mullvad style for selecting a single item from a list. + This is parametrised over a value type known as `Value`, which can be any Equatable type. One would typically use an `enum` for this. As the name suggests, this allows one value to be chosen, which it sets a provided binding to. -struct SingleChoiceList: View where Item: Hashable { + The simplest use case for `SingleChoiceList` is to present a list of options, each of which being a simple value without additional data; i.e., + + ```swift + SingleChoiceList( + title: "Colour", + options: [.red, .green, .blue], + value: $colour, + itemDescription: { NSLocalizedString("colour_\($0)") } + ) + ``` + + `SingleChoiceList` also provides support for having a value that takes a user-defined value, and presents a UI for filling this. In this case, the caller needs to provide not only the UI elements but functions for parsing the entered text to a value and unparsing the value to the text field, like so: + + ```swift + enum TipAmount { + case none + case fivePercent + case tenPercent + case custom(Int) + } + + SingleChoiceList( + title: "Tip", + options: [.none, .fivePercent, .tenPercent], + value: $tipAmount, + parseCustomValue: { Int($0).map { TipAmount.custom($0) }, + formatCustomValue: { + if case let .custom(t) = $0 { "\(t)" } else { nil } + }, + customLabel: "Custom", + customPrompt: "% ", + customFieldMode: .numericText + ) + + ``` + */ + +struct SingleChoiceList: View where Value: Equatable { let title: String - let options: [Item] - var value: Binding - let itemDescription: (Item) -> String + private let options: [OptionSpec] + var value: Binding + @State var initialValue: Value? + let itemDescription: (Value) -> String + let customFieldMode: CustomFieldMode + + /// The configuration for the field for a custom value row + enum CustomFieldMode { + /// The field is a text field into which any text may be typed + case freeText + /// The field is a text field configured for numeric input; i.e., the user will see a numeric keyboard + case numericText + } + + // Assumption: there will be only one custom value input per list. + // This makes sense if it's something like a port; if we ever need to + // use this with a type with more than one form of custom value, we will + // need to add some mitigations + @State var customValueInput = "" + @FocusState var customValueIsFocused: Bool + @State var customValueInputIsInvalid = false + + // an individual option being presented in a row + fileprivate struct OptionSpec: Identifiable { + enum OptValue { + // this row consists of a constant item with a fixed Value. It may only be selected as is + case literal(Value) + // this row consists of a text field into which the user can enter a custom value, which may yield a valid Value. This has accompanying text, and functions to translate between text field contents and the Value. (The fromValue method only needs to give a non-nil value if its input is a custom value that could have come from this row.) + case custom( + label: String, + prompt: String, + legend: String?, + minInputWidth: CGFloat?, + maxInputLength: Int?, + toValue: (String) -> Value?, + fromValue: (Value) -> String? + ) + } + let id: Int + let value: OptValue + } - init(title: String, options: [Item], value: Binding, itemDescription: ((Item) -> String)? = nil) { + // an internal constructor, building the element from basics + fileprivate init( + title: String, + optionSpecs: [OptionSpec.OptValue], + value: Binding, + itemDescription: ((Value) -> String)? = nil, + customFieldMode: CustomFieldMode = .freeText + ) { self.title = title - self.options = options + self.options = optionSpecs.enumerated().map { OptionSpec(id: $0.offset, value: $0.element) } self.value = value self.itemDescription = itemDescription ?? { "\($0)" } + self.customFieldMode = customFieldMode + self.initialValue = value.wrappedValue + } + + /// Create a `SingleChoiceList` presenting a choice of several fixed values. + /// + /// - Parameters: + /// - title: The title of the list, which is typically the name of the item being chosen. + /// - options: A list of `Value`s to be presented. + /// - itemDescription: An optional function that, when given a `Value`, returns the string representation to present in the list. If not provided, this will be generated naïvely using string interpolation. + init(title: String, options: [Value], value: Binding, itemDescription: ((Value) -> String)? = nil) { + self.init( + title: title, + optionSpecs: options.map { .literal($0) }, + value: value, + itemDescription: itemDescription + ) } - func row(_ item: Item) -> some View { - let isSelected = value.wrappedValue == item - return HStack { + /// Create a `SingleChoiceList` presenting a choice of several fixed values, plus a row where the user may enter an argument for a custom value. + /// + /// - Parameters: + /// - title: The title of the list, which is typically the name of the item being chosen. + /// - options: A list of fixed `Value`s to be presented. + /// - itemDescription: An optional function that, when given a `Value`, returns the string representation to present in the list. If not provided, this will be generated naïvely using string interpolation. This is only used for the non-custom values. + /// - parseCustomValue: A function that attempts to parse the text entered into the text field and produce a `Value` (typically the tagged custom value with an argument applied to it). If the text is not valid for a value, it should return `nil` + /// - formatCustomValue: A function that, when passed a `Value` containing user-entered custom data, formats that data into a string, which should match what the user would have entered. This function can expect to only be called for the custom value, and should return `nil` in the event of its argument not being a valid custom value. + /// - customLabel: The caption to display in the custom row, next to the text field. + /// - customPrompt: The text to display, greyed, in the text field when it is empty. This also serves to set the width of the field, and should be right-padded with spaces as appropriate. + /// - customLegend: Optional text to display below the custom field, i.e., to explain sensible values + /// - customInputWidth: An optional minimum width (in pseudo-pixels) for the custom input field + /// - customInputMaxLength: An optional maximum length to which input is truncated + /// - customFieldMode: An enumeration that sets the mode of the custom value entry text field. If this is `.numericText`, the data is expected to be a decimal number, and the device will present a numeric keyboard when the field is focussed. If it is `.freeText`, a standard alphanumeric keyboard will be presented. If not specified, this defaults to `.freeText`. + init( + title: String, + options: [Value], + value: Binding, + itemDescription: ((Value) -> String)? = nil, + parseCustomValue: @escaping ((String) -> Value?), + formatCustomValue: @escaping ((Value) -> String?), + customLabel: String, + customPrompt: String, + customLegend: String? = nil, + customInputMinWidth: CGFloat? = nil, + customInputMaxLength: Int? = nil, + customFieldMode: CustomFieldMode = .freeText + ) { + self.init( + title: title, + optionSpecs: options.map { .literal($0) } + [.custom( + label: customLabel, + prompt: customPrompt, + legend: customLegend, + minInputWidth: customInputMinWidth, + maxInputLength: customInputMaxLength, + toValue: parseCustomValue, + fromValue: formatCustomValue + )], + value: value, + itemDescription: itemDescription, + customFieldMode: customFieldMode + ) + } + + // Construct a row with arbitrary content and the correct style + private func row(isSelected: Bool, @ViewBuilder items: () -> V) -> some View { + HStack { Image(uiImage: UIImage(resource: .iconTick)).opacity(isSelected ? 1.0 : 0.0) Spacer().frame(width: UIMetrics.SettingsCell.selectableSettingsCellLeftViewSpacing) - Text(verbatim: itemDescription(item)) - Spacer() + + items() } .padding(EdgeInsets(UIMetrics.SettingsCell.layoutMargins)) .background( @@ -42,9 +185,120 @@ struct SingleChoiceList: View where Item: Hashable { : Color(UIColor.Cell.Background.indentationLevelOne) ) .foregroundColor(Color(UIColor.Cell.titleTextColor)) + } + + // Construct a literal row for a specific literal value + private func literalRow(_ item: Value) -> some View { + row( + isSelected: value.wrappedValue == item && !customValueIsFocused + ) { + Text(verbatim: itemDescription(item)) + Spacer() + } .onTapGesture { value.wrappedValue = item + customValueIsFocused = false + customValueInput = "" + } + } + + // Construct the one row with a custom input field for a custom value + private func customRow( + label: String, + prompt: String, + inputWidth: CGFloat?, + maxInputLength: Int?, + toValue: @escaping (String) -> Value?, + fromValue: @escaping (Value) -> String? + ) -> some View { + row( + isSelected: value.wrappedValue == toValue(customValueInput) || customValueIsFocused + ) { + Text(label) + Spacer() + TextField( + "value", + text: $customValueInput, + prompt: Text(prompt).foregroundColor( + customValueIsFocused + ? Color(UIColor.TextField.placeholderTextColor) + : Color(UIColor.TextField.inactivePlaceholderTextColor) + ) + ) + .keyboardType(customFieldMode == .numericText ? .numberPad : .default) + .multilineTextAlignment( + customFieldMode == .numericText + ? .trailing + : .leading/*@END_MENU_TOKEN@*/ + ) + .frame(minWidth: inputWidth, maxWidth: .infinity) + .fixedSize() + .padding(4) + .foregroundColor( + customValueIsFocused + ? customValueInputIsInvalid + ? Color(UIColor.TextField.invalidInputTextColor) + : Color(UIColor.TextField.textColor) + : Color(UIColor.TextField.inactiveTextColor) + ) + .background( + customValueIsFocused + ? Color(UIColor.TextField.backgroundColor) + : Color(UIColor.TextField.inactiveBackgroundColor) + ) + .cornerRadius(4.0) + // .border doesn't honour .cornerRadius, so overlaying a RoundedRectangle is necessary + .overlay( + RoundedRectangle(cornerRadius: 4.0) + .stroke( + customValueInputIsInvalid ? Color(UIColor.TextField.invalidInputTextColor) : .clear, + lineWidth: 1 + ) + ) + .focused($customValueIsFocused) + .onChange(of: customValueInput) { newValue in + if let maxInputLength { + if customValueInput.count > maxInputLength { + customValueInput = String(customValueInput.prefix(maxInputLength)) + } + } + if let parsedValue = toValue(customValueInput) { + value.wrappedValue = parsedValue + customValueInputIsInvalid = false + } else { + // this is not a valid value, so we fall back to the + // initial value, showing the invalid-value state if + // the field is not empty + if let initialValue { + value.wrappedValue = initialValue + } + customValueInputIsInvalid = !customValueInput.isEmpty + } + } + .onAppear { + if let valueText = fromValue(value.wrappedValue) { + customValueInput = valueText + } + } + } + .onTapGesture { + if let v = toValue(customValueInput) { + value.wrappedValue = v + } else { + customValueIsFocused = true + } + } + } + + private func subtitleRow(_ text: String) -> some View { + HStack { + Text(text) + .font(.callout) + .opacity(0.6) + Spacer() } + .padding(.horizontal, UIMetrics.SettingsCell.layoutMargins.leading) + .padding(.vertical, 4) } var body: some View { @@ -55,17 +309,57 @@ struct SingleChoiceList: View where Item: Hashable { } .padding(EdgeInsets(UIMetrics.SettingsCell.layoutMargins)) .background(Color(UIColor.Cell.Background.normal)) - ForEach(options, id: \.self) { opt in - row(opt) + ForEach(options) { opt in + switch opt.value { + case let .literal(v): + literalRow(v) + case let .custom(label, prompt, legend, inputWidth, maxInputLength, toValue, fromValue): + customRow( + label: label, + prompt: prompt, + inputWidth: inputWidth, + maxInputLength: maxInputLength, + toValue: toValue, + fromValue: fromValue + ) + if let legend { + subtitleRow(legend) + } + } } Spacer() } .padding(EdgeInsets(top: 24, leading: 0, bottom: 0, trailing: 0)) .background(Color(.secondaryColor)) .foregroundColor(Color(.primaryTextColor)) + .onAppear { + initialValue = value.wrappedValue + } } } -#Preview { +#Preview("Static values") { StatefulPreviewWrapper(1) { SingleChoiceList(title: "Test", options: [1, 2, 3], value: $0) } } + +#Preview("Optional value") { + enum ExampleValue: Equatable { + case two + case three + case someNumber(Int) + } + return StatefulPreviewWrapper(ExampleValue.two) { SingleChoiceList( + title: "Test", + options: [.two, .three], + value: $0, + parseCustomValue: { Int($0).flatMap { $0 > 3 ? ExampleValue.someNumber($0) : nil } }, + formatCustomValue: { if case let .someNumber(n) = $0 { "\(n)" } else { nil } }, + customLabel: "Custom", + customPrompt: "Number", + customLegend: "The legend goes here", + customInputMinWidth: 120, + customInputMaxLength: 6, + customFieldMode: .numericText + ) + } +} diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift index f74717f1bcae..6e33890235db 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsDataSource.swift @@ -317,11 +317,9 @@ final class VPNSettingsDataSource: UITableViewDiffableDataSource< case .wireGuardObfuscationUdpOverTcp: selectObfuscationState(.udpOverTcp) delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings)) - // TODO: When ready, add implementation for selected obfuscation (navigate to new view etc). case .wireGuardObfuscationShadowsocks: selectObfuscationState(.shadowsocks) delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings)) - // TODO: When ready, add implementation for selected obfuscation (navigate to new view etc). case .wireGuardObfuscationOff: selectObfuscationState(.off) delegate?.didUpdateTunnelSettings(TunnelSettingsUpdate.obfuscation(obfuscationSettings)) diff --git a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift index f6628feaa9e3..916956c76d5f 100644 --- a/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift +++ b/ios/MullvadVPN/View controllers/VPNSettings/VPNSettingsViewController.swift @@ -144,7 +144,16 @@ extension VPNSettingsViewController: VPNSettingsDataSourceDelegate { } private func showShadowsocksObfuscationSettings() { - // TODO: + let viewModel = TunnelShadowsocksObfuscationSettingsViewModel(tunnelManager: interactor.tunnelManager) + let view = ShadowsocksObfuscationSettingsView(viewModel: viewModel) + let vc = UIHostingController(rootView: view) + vc.title = NSLocalizedString( + "SHADOWSOCKS_TITLE", + tableName: "VPNSettings", + value: "Shadowsocks", + comment: "" + ) + navigationController?.pushViewController(vc, animated: true) } func didSelectWireGuardPort(_ port: UInt16?) {