From ab524811ab0d3b64787fbecac0b9e9b2c3be57c9 Mon Sep 17 00:00:00 2001 From: mojganii Date: Wed, 11 Dec 2024 15:13:09 +0100 Subject: [PATCH 1/4] Add FeatureIndicatorsView --- .../IPOverrideRepository.swift | 10 +- .../WireGuardObfuscationSettings.swift | 4 + ios/MullvadVPN.xcodeproj/project.pbxproj | 36 +++++++ .../Coordinators/ApplicationCoordinator.swift | 3 +- .../Coordinators/TunnelCoordinator.swift | 7 +- .../FeatureIndicators/ChipFeatures.swift | 88 +++++++++++++++++ .../ChipView/ChipContainerView.swift | 95 +++++++++++++++++++ .../ChipView/ChipModel.swift | 15 +++ .../FeatureIndicators/ChipView/ChipView.swift | 40 ++++++++ .../ChipView/ChipViewModelProtocol.swift | 32 +++++++ .../FeatureIndicators/ConnectionView.swift | 26 +++-- .../FI_TunnelViewController.swift | 45 ++++++--- .../FeatureIndicatorsView.swift | 31 ++++++ .../FeatureIndicatorsViewModel.swift | 37 ++++++++ .../TunnelViewControllerInteractor.swift | 27 +++++- ios/MullvadVPN/Views/MainButtonStyle.swift | 22 +++-- .../IPOverrideRepositoryStub.swift | 6 ++ 17 files changed, 492 insertions(+), 32 deletions(-) create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipFeatures.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipModel.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipView.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipViewModelProtocol.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsViewModel.swift diff --git a/ios/MullvadSettings/IPOverrideRepository.swift b/ios/MullvadSettings/IPOverrideRepository.swift index 867a1c077fd5..441ff6c35e1e 100644 --- a/ios/MullvadSettings/IPOverrideRepository.swift +++ b/ios/MullvadSettings/IPOverrideRepository.swift @@ -6,10 +6,11 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // -import Foundation +import Combine import MullvadLogging public protocol IPOverrideRepositoryProtocol { + var overridesPublisher: AnyPublisher<[IPOverride], Never> { get } func add(_ overrides: [IPOverride]) func fetchAll() -> [IPOverride] func deleteAll() @@ -17,6 +18,11 @@ public protocol IPOverrideRepositoryProtocol { } public class IPOverrideRepository: IPOverrideRepositoryProtocol { + private let overridesSubject: CurrentValueSubject<[IPOverride], Never> = .init([]) + public var overridesPublisher: AnyPublisher<[IPOverride], Never> { + overridesSubject.eraseToAnyPublisher() + } + private let logger = Logger(label: "IPOverrideRepository") private let readWriteLock = NSLock() @@ -58,6 +64,7 @@ public class IPOverrideRepository: IPOverrideRepositoryProtocol { do { try readWriteLock.withLock { try SettingsManager.store.delete(key: .ipOverrides) + overridesSubject.send([]) } } catch { logger.error("Could not delete all overrides. \nError: \(error)") @@ -85,6 +92,7 @@ public class IPOverrideRepository: IPOverrideRepositoryProtocol { try readWriteLock.withLock { try SettingsManager.store.write(data, for: .ipOverrides) + overridesSubject.send(overrides) } } diff --git a/ios/MullvadSettings/WireGuardObfuscationSettings.swift b/ios/MullvadSettings/WireGuardObfuscationSettings.swift index f067114cc634..c52a637626fd 100644 --- a/ios/MullvadSettings/WireGuardObfuscationSettings.swift +++ b/ios/MullvadSettings/WireGuardObfuscationSettings.swift @@ -46,6 +46,10 @@ public enum WireGuardObfuscationState: Codable { self = .off } } + + public var isEnabled: Bool { + [.udpOverTcp, .shadowsocks].contains(self) + } } public enum WireGuardObfuscationUdpOverTcpPort: Codable, Equatable, CustomStringConvertible { diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index b5f13b415f86..95ae9dc0ec3e 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -1001,7 +1001,14 @@ F0ADC3722CD3AD1600A1AD97 /* ChipCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */; }; F0ADC3742CD3C47400A1AD97 /* ChipFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */; }; F0ADF1CD2CFDFF3100299F09 /* StringConversionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADF1CC2CFDFF3100299F09 /* StringConversionError.swift */; }; + F0ADF1D12D01B55C00299F09 /* ChipModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADF1D02D01B55C00299F09 /* ChipModel.swift */; }; + F0ADF1D32D01B6B400299F09 /* FeatureIndicatorsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADF1D22D01B6B400299F09 /* FeatureIndicatorsViewModel.swift */; }; + F0ADF1D52D01DCFD00299F09 /* ChipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ADF1D42D01DCFD00299F09 /* ChipView.swift */; }; F0B0E6972AFE6E7E001DC66B /* XCTest+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */; }; + F0B495762D02025200CFEC2A /* ChipContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495752D02025200CFEC2A /* ChipContainerView.swift */; }; + F0B495782D02038B00CFEC2A /* ChipViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.swift */; }; + F0B4957A2D02F49200CFEC2A /* ChipFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495792D02F41F00CFEC2A /* ChipFeatures.swift */; }; + F0B4957C2D03154200CFEC2A /* FeatureIndicatorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B4957B2D03154200CFEC2A /* FeatureIndicatorsView.swift */; }; F0B894EF2BF751C500817A42 /* RelayWithLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */; }; F0B894F12BF751E300817A42 /* RelayWithDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F02BF751E300817A42 /* RelayWithDistance.swift */; }; F0B894F32BF7526700817A42 /* RelaySelector+Wireguard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */; }; @@ -2244,7 +2251,14 @@ F0ADC3712CD3AD1600A1AD97 /* ChipCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipCollectionView.swift; sourceTree = ""; }; F0ADC3732CD3C47400A1AD97 /* ChipFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipFlowLayout.swift; sourceTree = ""; }; F0ADF1CC2CFDFF3100299F09 /* StringConversionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringConversionError.swift; sourceTree = ""; }; + F0ADF1D02D01B55C00299F09 /* ChipModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipModel.swift; sourceTree = ""; }; + F0ADF1D22D01B6B400299F09 /* FeatureIndicatorsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIndicatorsViewModel.swift; sourceTree = ""; }; + F0ADF1D42D01DCFD00299F09 /* ChipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipView.swift; sourceTree = ""; }; F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTest+Async.swift"; sourceTree = ""; }; + F0B495752D02025200CFEC2A /* ChipContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipContainerView.swift; sourceTree = ""; }; + F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipViewModelProtocol.swift; sourceTree = ""; }; + F0B495792D02F41F00CFEC2A /* ChipFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipFeatures.swift; sourceTree = ""; }; + F0B4957B2D03154200CFEC2A /* FeatureIndicatorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIndicatorsView.swift; sourceTree = ""; }; F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithLocation.swift; sourceTree = ""; }; F0B894F02BF751E300817A42 /* RelayWithDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithDistance.swift; sourceTree = ""; }; F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelaySelector+Wireguard.swift"; sourceTree = ""; }; @@ -4076,9 +4090,13 @@ 7AA130972CFF364F00640DF9 /* FeatureIndicators */ = { isa = PBXGroup; children = ( + F0ADF1CF2D01B50B00299F09 /* ChipView */, 7AFBE3862D084C96002335FC /* ActivityIndicator.swift */, + F0B495792D02F41F00CFEC2A /* ChipFeatures.swift */, 7AA130982CFF365A00640DF9 /* ConnectionView.swift */, 7A0EAEA32D06DF8200D3EB8B /* ConnectionViewViewModel.swift */, + F0B4957B2D03154200CFEC2A /* FeatureIndicatorsView.swift */, + F0ADF1D22D01B6B400299F09 /* FeatureIndicatorsViewModel.swift */, 7AFBE3882D08915D002335FC /* FI_TunnelViewController.swift */, ); path = FeatureIndicators; @@ -4403,6 +4421,17 @@ path = MullvadTypes; sourceTree = ""; }; + F0ADF1CF2D01B50B00299F09 /* ChipView */ = { + isa = PBXGroup; + children = ( + F0B495752D02025200CFEC2A /* ChipContainerView.swift */, + F0ADF1D02D01B55C00299F09 /* ChipModel.swift */, + F0ADF1D42D01DCFD00299F09 /* ChipView.swift */, + F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.swift */, + ); + path = ChipView; + sourceTree = ""; + }; F0DC779F2B2222D20087F09D /* Relay */ = { isa = PBXGroup; children = ( @@ -5928,6 +5957,7 @@ 5878A27129091CF20096FC88 /* AccountInteractor.swift in Sources */, 7AF9BE882A30C62100DBFEDB /* SelectableSettingsCell.swift in Sources */, 58CCA010224249A1004F3011 /* TunnelViewController.swift in Sources */, + F0B495782D02038B00CFEC2A /* ChipViewModelProtocol.swift in Sources */, 58CEB30A2AFD584700E6E088 /* CustomCellDisclosureHandling.swift in Sources */, 58B26E22294351EA00D5980C /* InAppNotificationProvider.swift in Sources */, 5893716A28817A45004EE76C /* DeviceManagementViewController.swift in Sources */, @@ -6002,6 +6032,7 @@ 58293FB3251241B4005D0BB5 /* CustomTextView.swift in Sources */, 586A950E290125F3007BAF2B /* ProductsRequestOperation.swift in Sources */, 7AF9BE902A39F26000DBFEDB /* Collection+Sorting.swift in Sources */, + F0B495762D02025200CFEC2A /* ChipContainerView.swift in Sources */, 58F19E35228C15BA00C7710B /* SpinnerActivityIndicatorView.swift in Sources */, 7A0EAE9A2D01B41500D3EB8B /* MainButtonStyle.swift in Sources */, 58CEB3022AFD365600E6E088 /* SwitchCellContentConfiguration.swift in Sources */, @@ -6111,6 +6142,7 @@ 588D7EDE2AF3A585005DF40A /* ListAccessMethodItem.swift in Sources */, 5827B0B02B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift in Sources */, 588D7EE02AF3A595005DF40A /* ListAccessMethodInteractor.swift in Sources */, + F0B4957A2D02F49200CFEC2A /* ChipFeatures.swift in Sources */, 58607A4D2947287800BC467D /* AccountExpiryInAppNotificationProvider.swift in Sources */, 7A8A18FD2CE4BE8D000BCB5B /* CustomToggleStyle.swift in Sources */, 58C8191829FAA2C400DEB1B4 /* NotificationConfiguration.swift in Sources */, @@ -6145,8 +6177,10 @@ 586C0D782B039CC000E7CDD7 /* AccessMethodProtocolPicker.swift in Sources */, 58677710290975E9006F721F /* SettingsInteractorFactory.swift in Sources */, 7A9CCCC02A96302800DD6A34 /* ProfileVoucherCoordinator.swift in Sources */, + F0B4957C2D03154200CFEC2A /* FeatureIndicatorsView.swift in Sources */, 7A9CCCBC2A96302800DD6A34 /* ChangeLogCoordinator.swift in Sources */, 58B26E282943527300D5980C /* SystemNotificationProvider.swift in Sources */, + F0ADF1D52D01DCFD00299F09 /* ChipView.swift in Sources */, 586C0D932B03D90700E7CDD7 /* ShadowsocksItemIdentifier.swift in Sources */, 58EFC7712AFB45E500E9F4CB /* SettingsChildCoordinator.swift in Sources */, 7A8A19102CEE391B000BCB5B /* RowSeparator.swift in Sources */, @@ -6190,6 +6224,7 @@ 7A9CCCC22A96302800DD6A34 /* SafariCoordinator.swift in Sources */, 58CEB3082AFD484100E6E088 /* BasicCell.swift in Sources */, 7A5869C12B57D21A00640D27 /* IPOverrideStatusView.swift in Sources */, + F0ADF1D32D01B6B400299F09 /* FeatureIndicatorsViewModel.swift in Sources */, 58CEB2F52AFD0BB500E6E088 /* TextCellContentConfiguration.swift in Sources */, 58E20771274672CA00DE5D77 /* LaunchViewController.swift in Sources */, F0E8CC032A4C753B007ED3B4 /* WelcomeViewController.swift in Sources */, @@ -6209,6 +6244,7 @@ A99E5EE02B7628150033F241 /* ProblemReportViewModel.swift in Sources */, 58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */, 58B43C1925F77DB60002C8C3 /* TunnelControlView.swift in Sources */, + F0ADF1D12D01B55C00299F09 /* ChipModel.swift in Sources */, F09A297B2A9F8A9B00EA3B6F /* LogoutDialogueView.swift in Sources */, 58CEB2FB2AFD13E600E6E088 /* UIListContentConfiguration+Extensions.swift in Sources */, 5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */, diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift index a4ff7e3cd0f5..bbab9203012d 100644 --- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift @@ -486,7 +486,8 @@ final class ApplicationCoordinator: Coordinator, Presenting, RootContainerViewCo private func makeTunnelCoordinator() -> TunnelCoordinator { let tunnelCoordinator = TunnelCoordinator( tunnelManager: tunnelManager, - outgoingConnectionService: outgoingConnectionService + outgoingConnectionService: outgoingConnectionService, + ipOverrideRepository: ipOverrideRepository ) tunnelCoordinator.showSelectLocationPicker = { [weak self] in diff --git a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift index 42f8fba106e6..7a8145ddca51 100644 --- a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift @@ -6,6 +6,7 @@ // Copyright © 2023 Mullvad VPN AB. All rights reserved. // +import MullvadSettings import Routing import UIKit @@ -27,13 +28,15 @@ class TunnelCoordinator: Coordinator, Presenting { init( tunnelManager: TunnelManager, - outgoingConnectionService: OutgoingConnectionServiceHandling + outgoingConnectionService: OutgoingConnectionServiceHandling, + ipOverrideRepository: IPOverrideRepositoryProtocol ) { self.tunnelManager = tunnelManager let interactor = TunnelViewControllerInteractor( tunnelManager: tunnelManager, - outgoingConnectionService: outgoingConnectionService + outgoingConnectionService: outgoingConnectionService, + ipOverrideRepository: ipOverrideRepository ) controller = TunnelViewController(interactor: interactor) diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipFeatures.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipFeatures.swift new file mode 100644 index 000000000000..c005b3f080ed --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipFeatures.swift @@ -0,0 +1,88 @@ +// +// ChipFeatures.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-06. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// +import Foundation +import MullvadSettings +import SwiftUI + +protocol ChipFeature { + var isEnabled: Bool { get } + var name: LocalizedStringKey { get } +} + +struct DaitaFeature: ChipFeature { + let settings: LatestTunnelSettings + + var isEnabled: Bool { + settings.daita.daitaState.isEnabled + } + + var name: LocalizedStringKey { + LocalizedStringKey("DAITA") + } +} + +struct QuantumResistanceFeature: ChipFeature { + let settings: LatestTunnelSettings + var isEnabled: Bool { + settings.tunnelQuantumResistance.isEnabled + } + + var name: LocalizedStringKey { + LocalizedStringKey("Quantum resistance") + } +} + +struct MultihopFeature: ChipFeature { + let settings: LatestTunnelSettings + var isEnabled: Bool { + settings.tunnelMultihopState.isEnabled + } + + var name: LocalizedStringKey { + LocalizedStringKey("Multihop") + } +} + +struct ObfuscationFeature: ChipFeature { + let settings: LatestTunnelSettings + + var isEnabled: Bool { + settings.wireGuardObfuscation.state.isEnabled + } + + var name: LocalizedStringKey { + LocalizedStringKey("Obfuscation") + } +} + +struct DNSFeature: ChipFeature { + let settings: LatestTunnelSettings + + var isEnabled: Bool { + settings.dnsSettings.enableCustomDNS || !settings.dnsSettings.blockingOptions.isEmpty + } + + var name: LocalizedStringKey { + if !settings.dnsSettings.blockingOptions.isEmpty { + return LocalizedStringKey("DNS content blockers") + } + return LocalizedStringKey("Custom DNS") + } +} + +struct IPOverrideFeature: ChipFeature { + let overrides: [IPOverride] + + var isEnabled: Bool { + !overrides.isEmpty + } + + var name: LocalizedStringKey { + LocalizedStringKey("Server IP override") + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift new file mode 100644 index 000000000000..f06124ed4811 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift @@ -0,0 +1,95 @@ +// +// ChipContainerView.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct ChipContainerView: View where ViewModel: ChipViewModelProtocol { + @ObservedObject var viewModel: ViewModel + + @State var chipHeight: CGFloat = 0 + @State var fullContainerHeight: CGFloat = 0 + @State var visibleContainerHeight: CGFloat = 0 + + var body: some View { + GeometryReader { geo in + let containerWidth = geo.size.width + let chipsOverflow = !viewModel.isExpanded && (fullContainerHeight > chipHeight) + let numberOfChips = chipsOverflow ? 2 : viewModel.chips.count + + HStack { + ZStack(alignment: .topLeading) { + createChipViews(chips: Array(viewModel.chips.prefix(numberOfChips)), containerWidth: containerWidth) + } + .sizeOfView { visibleContainerHeight = $0.height } + + if chipsOverflow { + Text(LocalizedStringKey("\(viewModel.chips.count - numberOfChips) more...")) + .font(.subheadline) + .lineLimit(1) + .foregroundStyle(UIColor.primaryTextColor.color) + .padding(.bottom, 12) + } + + Spacer() + } + .background(preRenderViewSize(containerWidth: containerWidth)) + }.frame(height: visibleContainerHeight) + } + + // Renders all chips on screen, in this case specifically to get their combined height. + // Used to determine if content would overflow if view was not expanded and should + // only be called from a background modifier. + private func preRenderViewSize(containerWidth: CGFloat) -> some View { + ZStack(alignment: .topLeading) { + createChipViews(chips: viewModel.chips, containerWidth: containerWidth) + } + .hidden() + .sizeOfView { fullContainerHeight = $0.height } + } + + private func createChipViews(chips: [ChipModel], containerWidth: CGFloat) -> some View { + var width = CGFloat.zero + var height = CGFloat.zero + + return ForEach(chips) { data in + ChipView(item: data) + .padding(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 8)) + .alignmentGuide(.leading) { dimension in + if abs(width - dimension.width) > containerWidth { + width = 0 + height -= dimension.height + } + let result = width + if data.id == chips.last!.id { + width = 0 + } else { + width -= dimension.width + } + return result + } + .alignmentGuide(.top) { _ in + let result = height + if data.id == chips.last!.id { + height = 0 + } + return result + } + .sizeOfView { chipHeight = $0.height } + } + } +} + +#Preview("Normal") { + ChipContainerView(viewModel: MockFeatureIndicatorsViewModel()) + .background(UIColor.secondaryColor.color) +} + +#Preview("Expanded") { + ChipContainerView(viewModel: MockFeatureIndicatorsViewModel(isExpanded: true)) + .background(UIColor.secondaryColor.color) +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipModel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipModel.swift new file mode 100644 index 000000000000..c1e990a1b1d9 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipModel.swift @@ -0,0 +1,15 @@ +// +// FeatureChipModel.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import SwiftUI + +struct ChipModel: Identifiable { + let id = UUID() + let name: LocalizedStringKey +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipView.swift new file mode 100644 index 000000000000..6d6614973f5f --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipView.swift @@ -0,0 +1,40 @@ +// +// FeatureChipView.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct ChipView: View { + let item: ChipModel + var body: some View { + Text(item.name) + .font(.subheadline) + .lineLimit(1) + .foregroundStyle(UIColor.primaryTextColor.color) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 8.0) + .stroke( + UIColor.primaryColor.color, + lineWidth: 1 + ) + .background( + RoundedRectangle(cornerRadius: 8.0) + .fill(UIColor.secondaryColor.color) + ) + ) + } +} + +#Preview { + ZStack { + ChipView(item: ChipModel(name: LocalizedStringKey("Example"))) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(UIColor.secondaryColor.color) +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipViewModelProtocol.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipViewModelProtocol.swift new file mode 100644 index 000000000000..65e3b0ccef38 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipViewModelProtocol.swift @@ -0,0 +1,32 @@ +// +// ChipViewModelProtocol.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +protocol ChipViewModelProtocol: ObservableObject { + var chips: [ChipModel] { get } + var isExpanded: Bool { get set } +} + +class MockFeatureIndicatorsViewModel: ChipViewModelProtocol { + @Published var chips: [ChipModel] = [ + ChipModel(name: LocalizedStringKey("DAITA")), + ChipModel(name: LocalizedStringKey("Obfuscation")), + ChipModel(name: LocalizedStringKey("Quantum resistance")), + ChipModel(name: LocalizedStringKey("Multihop")), + ChipModel(name: LocalizedStringKey("DNS content blockers")), + ChipModel(name: LocalizedStringKey("Custom DNS")), + ChipModel(name: LocalizedStringKey("Server IP override")), + ] + + @Published var isExpanded: Bool + + init(isExpanded: Bool = false) { + self.isExpanded = isExpanded + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift index 03980fb361fd..3a1bf7d9afe3 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift @@ -6,12 +6,14 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import MullvadSettings import SwiftUI typealias ButtonAction = (ConnectionViewViewModel.TunnelControlAction) -> Void struct ConnectionView: View { @StateObject var viewModel: ConnectionViewViewModel + @StateObject var indicatorsViewModel: FeatureIndicatorsViewModel var action: ButtonAction? var onContentUpdate: (() -> Void)? @@ -27,6 +29,11 @@ struct ConnectionView: View { VStack(alignment: .leading, spacing: 16) { ConnectionPanel(viewModel: viewModel) + + if !indicatorsViewModel.chips.isEmpty { + FeatureIndicatorsView(viewModel: indicatorsViewModel) + } + ButtonPanel(viewModel: viewModel, action: action) } .padding(16) @@ -34,17 +41,24 @@ struct ConnectionView: View { .cornerRadius(12) .padding(16) } - .onReceive(viewModel.$tunnelState, perform: { _ in + .padding(.bottom, 8) // Adding some spacing so to not overlap with the map legal link. + .onReceive( + indicatorsViewModel.$isExpanded + .combineLatest( + viewModel.$tunnelState, + viewModel.$showsActivityIndicator + ) + ) { _ in onContentUpdate?() - }) - .onReceive(viewModel.$showsActivityIndicator, perform: { _ in - onContentUpdate?() - }) + } } } #Preview { - ConnectionView(viewModel: ConnectionViewViewModel(tunnelState: .disconnected)) { action in + ConnectionView( + viewModel: ConnectionViewViewModel(tunnelState: .disconnected), + indicatorsViewModel: FeatureIndicatorsViewModel(tunnelSettings: LatestTunnelSettings(), ipOverrides: []) + ) { action in print(action) } .background(UIColor.secondaryColor.color) diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift index b70c3a9ffa37..9aed89004110 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift @@ -6,8 +6,10 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import Combine import MapKit import MullvadLogging +import MullvadSettings import MullvadTypes import SwiftUI @@ -17,7 +19,8 @@ class FI_TunnelViewController: UIViewController, RootContainment { private let logger = Logger(label: "TunnelViewController") private let interactor: TunnelViewControllerInteractor private var tunnelState: TunnelState = .disconnected - private var viewModel = ConnectionViewViewModel(tunnelState: .disconnected) + private var connectionViewViewModel: ConnectionViewViewModel + private var indicatorsViewViewModel: FeatureIndicatorsViewModel private var connectionView: ConnectionView private var connectionController: UIHostingController? @@ -48,7 +51,18 @@ class FI_TunnelViewController: UIViewController, RootContainment { init(interactor: TunnelViewControllerInteractor) { self.interactor = interactor - connectionView = ConnectionView(viewModel: self.viewModel) + + tunnelState = interactor.tunnelStatus.state + connectionViewViewModel = ConnectionViewViewModel(tunnelState: tunnelState) + indicatorsViewViewModel = FeatureIndicatorsViewModel( + tunnelSettings: interactor.tunnelSettings, + ipOverrides: interactor.ipOverrides + ) + + connectionView = ConnectionView( + viewModel: self.connectionViewViewModel, + indicatorsViewModel: self.indicatorsViewViewModel + ) super.init(nibName: nil, bundle: nil) @@ -73,10 +87,17 @@ class FI_TunnelViewController: UIViewController, RootContainment { interactor.didUpdateTunnelStatus = { [weak self] tunnelStatus in self?.setTunnelState(tunnelStatus.state, animated: true) - self?.viewModel.tunnelState = tunnelStatus.state self?.view.setNeedsLayout() } + interactor.didUpdateTunnelSettings = { [weak self] tunnelSettings in + self?.indicatorsViewViewModel.tunnelSettings = tunnelSettings + } + + interactor.didUpdateIpOverrides = { [weak self] overrides in + self?.indicatorsViewViewModel.ipOverrides = overrides + } + connectionView.action = { [weak self] action in switch action { case .connect: @@ -102,10 +123,6 @@ class FI_TunnelViewController: UIViewController, RootContainment { addMapController() addContentView() - - tunnelState = interactor.tunnelStatus.state - viewModel.tunnelState = tunnelState - updateMap(animated: false) } @@ -125,6 +142,8 @@ class FI_TunnelViewController: UIViewController, RootContainment { private func setTunnelState(_ tunnelState: TunnelState, animated: Bool) { self.tunnelState = tunnelState + connectionViewViewModel.tunnelState = tunnelState + setNeedsHeaderBarStyleAppearanceUpdate() guard isViewLoaded else { return } @@ -137,17 +156,17 @@ class FI_TunnelViewController: UIViewController, RootContainment { case let .connecting(tunnelRelays, _, _): mapViewController.removeLocationMarker() mapViewController.setCenter(tunnelRelays?.exit.location.geoCoordinate, animated: animated) - viewModel.showsActivityIndicator = true + connectionViewViewModel.showsActivityIndicator = true case let .reconnecting(tunnelRelays, _, _), let .negotiatingEphemeralPeer(tunnelRelays, _, _, _): mapViewController.removeLocationMarker() mapViewController.setCenter(tunnelRelays.exit.location.geoCoordinate, animated: animated) - viewModel.showsActivityIndicator = true + connectionViewViewModel.showsActivityIndicator = true case let .connected(tunnelRelays, _, _): let center = tunnelRelays.exit.location.geoCoordinate mapViewController.setCenter(center, animated: animated) { - self.viewModel.showsActivityIndicator = false + self.connectionViewViewModel.showsActivityIndicator = false // Connection can change during animation, so make sure we're still connected before adding marker. if case .connected = self.tunnelState { @@ -157,16 +176,16 @@ class FI_TunnelViewController: UIViewController, RootContainment { case .pendingReconnect: mapViewController.removeLocationMarker() - viewModel.showsActivityIndicator = true + connectionViewViewModel.showsActivityIndicator = true case .waitingForConnectivity, .error: mapViewController.removeLocationMarker() - viewModel.showsActivityIndicator = false + connectionViewViewModel.showsActivityIndicator = false case .disconnected, .disconnecting: mapViewController.removeLocationMarker() mapViewController.setCenter(nil, animated: animated) - viewModel.showsActivityIndicator = false + connectionViewViewModel.showsActivityIndicator = false } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift new file mode 100644 index 000000000000..eb1a29ea8195 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift @@ -0,0 +1,31 @@ +// +// FeaturesIndicatorsView.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-06. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct FeatureIndicatorsView: View where ViewModel: ChipViewModelProtocol { + @ObservedObject var viewModel: ViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(LocalizedStringKey("Active features")) + .font(.footnote.weight(.semibold)) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + + ChipContainerView(viewModel: viewModel) + .onTapGesture { + viewModel.isExpanded.toggle() + } + } + } +} + +#Preview("FeatureIndicatorsView") { + FeatureIndicatorsView(viewModel: MockFeatureIndicatorsViewModel(isExpanded: true)) + .background(UIColor.secondaryColor.color) +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsViewModel.swift new file mode 100644 index 000000000000..42376b45608b --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsViewModel.swift @@ -0,0 +1,37 @@ +// +// FeatureIndicatorsViewModel.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-05. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadSettings + +class FeatureIndicatorsViewModel: ChipViewModelProtocol { + @Published var tunnelSettings: LatestTunnelSettings + @Published var ipOverrides: [IPOverride] + @Published var isExpanded = false + + init(tunnelSettings: LatestTunnelSettings, ipOverrides: [IPOverride], isExpanded: Bool = false) { + self.tunnelSettings = tunnelSettings + self.ipOverrides = ipOverrides + self.isExpanded = isExpanded + } + + var chips: [ChipModel] { + let features: [ChipFeature] = [ + DaitaFeature(settings: tunnelSettings), + QuantumResistanceFeature(settings: tunnelSettings), + MultihopFeature(settings: tunnelSettings), + ObfuscationFeature(settings: tunnelSettings), + DNSFeature(settings: tunnelSettings), + IPOverrideFeature(overrides: ipOverrides), + ] + + return features + .filter { $0.isEnabled } + .map { ChipModel(name: $0.name) } + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift index 47b75fd7d5ef..ef902e637a44 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift @@ -6,7 +6,7 @@ // Copyright © 2022 Mullvad VPN AB. All rights reserved. // -import Foundation +import Combine import MullvadSettings import MullvadTypes @@ -15,9 +15,13 @@ final class TunnelViewControllerInteractor { private let outgoingConnectionService: OutgoingConnectionServiceHandling private var tunnelObserver: TunnelObserver? private var outgoingConnectionTask: Task? + private var ipOverrideRepository: IPOverrideRepositoryProtocol + private var cancellables: Set = [] var didUpdateTunnelStatus: ((TunnelStatus) -> Void)? var didUpdateDeviceState: ((_ deviceState: DeviceState, _ previousDeviceState: DeviceState) -> Void)? + var didUpdateTunnelSettings: ((LatestTunnelSettings) -> Void)? + var didUpdateIpOverrides: (([IPOverride]) -> Void)? var didGetOutGoingAddress: (@MainActor (OutgoingConnectionInfo) -> Void)? var tunnelStatus: TunnelStatus { @@ -28,16 +32,26 @@ final class TunnelViewControllerInteractor { tunnelManager.deviceState } + var tunnelSettings: LatestTunnelSettings { + tunnelManager.settings + } + + var ipOverrides: [IPOverride] { + ipOverrideRepository.fetchAll() + } + deinit { outgoingConnectionTask?.cancel() } init( tunnelManager: TunnelManager, - outgoingConnectionService: OutgoingConnectionServiceHandling + outgoingConnectionService: OutgoingConnectionServiceHandling, + ipOverrideRepository: IPOverrideRepositoryProtocol ) { self.tunnelManager = tunnelManager self.outgoingConnectionService = outgoingConnectionService + self.ipOverrideRepository = ipOverrideRepository let tunnelObserver = TunnelBlockObserver( didUpdateTunnelStatus: { [weak self] _, tunnelStatus in @@ -56,12 +70,21 @@ final class TunnelViewControllerInteractor { }, didUpdateDeviceState: { [weak self] _, deviceState, previousDeviceState in self?.didUpdateDeviceState?(deviceState, previousDeviceState) + }, + didUpdateTunnelSettings: { [weak self] _, tunnelSettings in + self?.didUpdateTunnelSettings?(tunnelSettings) } ) tunnelManager.addObserver(tunnelObserver) self.tunnelObserver = tunnelObserver + + ipOverrideRepository.overridesPublisher + .sink { [weak self] overrides in + self?.didUpdateIpOverrides?(overrides) + } + .store(in: &cancellables) } func startTunnel() { diff --git a/ios/MullvadVPN/Views/MainButtonStyle.swift b/ios/MullvadVPN/Views/MainButtonStyle.swift index f638c87ac2b5..f32a27fa068a 100644 --- a/ios/MullvadVPN/Views/MainButtonStyle.swift +++ b/ios/MullvadVPN/Views/MainButtonStyle.swift @@ -22,16 +22,16 @@ struct MainButtonStyle: ButtonStyle { .padding(.horizontal, 8) .frame(height: 44) .foregroundColor( - configuration.isPressed - ? UIColor.secondaryTextColor.color - : disabled - ? UIColor.primaryTextColor.withAlphaComponent(0.2).color - : UIColor.primaryTextColor.color + disabled + ? UIColor.primaryTextColor.withAlphaComponent(0.2).color + : UIColor.primaryTextColor.color ) .background( disabled - ? style.color.darkened(by: 0.6) - : style.color + ? style.disabledColor + : configuration.isPressed + ? style.pressedColor + : style.color ) .font(.body.weight(.semibold)) } @@ -53,5 +53,13 @@ extension MainButtonStyle { Color(UIColor.successColor) } } + + var pressedColor: Color { + color.darkened(by: 0.4)! + } + + var disabledColor: Color { + color.darkened(by: 0.6)! + } } } diff --git a/ios/MullvadVPNTests/MullvadSettings/IPOverrideRepositoryStub.swift b/ios/MullvadVPNTests/MullvadSettings/IPOverrideRepositoryStub.swift index 633bc44bdb5f..c27c5cff5837 100644 --- a/ios/MullvadVPNTests/MullvadSettings/IPOverrideRepositoryStub.swift +++ b/ios/MullvadVPNTests/MullvadSettings/IPOverrideRepositoryStub.swift @@ -6,9 +6,15 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import Combine import MullvadSettings struct IPOverrideRepositoryStub: IPOverrideRepositoryProtocol { + let passthroughSubject: CurrentValueSubject<[IPOverride], Never> = CurrentValueSubject([]) + var overridesPublisher: AnyPublisher<[IPOverride], Never> { + passthroughSubject.eraseToAnyPublisher() + } + let overrides: [IPOverride] init(overrides: [IPOverride] = []) { From 2b962f99481671296ec924b47480525f7fc55cab Mon Sep 17 00:00:00 2001 From: Jon Petersson Date: Wed, 4 Dec 2024 10:18:22 +0100 Subject: [PATCH 2/4] Add toggle in connection view --- .../SelectedRelaysStub+Stubs.swift | 36 +++ .../EphemeralPeerNegotiator.swift | 3 - .../MullvadPostQuantum+Stubs.swift | 3 - ios/MullvadVPN.xcodeproj/project.pbxproj | 24 +- .../Classes/AccessbilityIdentifier.swift | 5 +- .../Coordinators/TunnelCoordinator.swift | 9 + .../Extensions/String+Helpers.swift | 6 +- .../Extensions/View+TapAreaSize.swift | 4 +- .../IconReload.imageset/Contents.json | 2 +- .../IconReload.imageset/IconReload.pdf | Bin 1236 -> 0 bytes .../IconReload.imageset/icon-reload.svg | 10 + ios/MullvadVPN/UI appearance/UIMetrics.swift | 5 + .../FeatureIndicators/ChipFeatures.swift | 88 ------- .../ChipView/ChipContainerView.swift | 68 +++--- .../ChipView/ChipFeature.swift | 123 ++++++++++ .../ChipView/ChipModel.swift | 2 +- .../FeatureIndicators/ChipView/ChipView.swift | 10 +- .../ChipView/ChipViewModelProtocol.swift | 59 +++-- .../FeatureIndicators/ConnectionView.swift | 219 ++++++++++++++---- .../ConnectionViewPreview.swift | 81 +++++++ .../ConnectionViewViewModel.swift | 139 ++++++++--- .../FI_TunnelViewController.swift | 12 +- .../FeatureIndicatorsView.swift | 24 +- .../FeatureIndicatorsViewModel.swift | 6 +- .../Tunnel/TunnelControlView.swift | 4 +- ios/MullvadVPN/Views/MainButton.swift | 3 +- ios/MullvadVPN/Views/MainButtonStyle.swift | 24 +- ios/MullvadVPN/Views/SplitMainButton.swift | 14 +- .../Pages/TunnelControlPage.swift | 4 +- .../Actor/PacketTunnelActor.swift | 2 +- ios/convert-assets.rb | 1 - 31 files changed, 711 insertions(+), 279 deletions(-) create mode 100644 ios/MullvadMockData/MullvadREST/SelectedRelaysStub+Stubs.swift delete mode 100644 ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/IconReload.pdf create mode 100644 ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/icon-reload.svg delete mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipFeatures.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipFeature.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewPreview.swift diff --git a/ios/MullvadMockData/MullvadREST/SelectedRelaysStub+Stubs.swift b/ios/MullvadMockData/MullvadREST/SelectedRelaysStub+Stubs.swift new file mode 100644 index 000000000000..adc749223000 --- /dev/null +++ b/ios/MullvadMockData/MullvadREST/SelectedRelaysStub+Stubs.swift @@ -0,0 +1,36 @@ +// +// SelectedRelaysStub+Stubs.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-12-18. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadREST +import MullvadTypes +import Network + +public struct SelectedRelaysStub { + public static let selectedRelays = SelectedRelays( + entry: nil, + exit: SelectedRelay( + endpoint: MullvadEndpoint( + ipv4Relay: IPv4Endpoint(ip: .loopback, port: 42), + ipv6Relay: IPv6Endpoint(ip: .loopback, port: 42), + ipv4Gateway: IPv4Address.loopback, + ipv6Gateway: IPv6Address.loopback, + publicKey: Data() + ), + hostname: "se-got-wg-001", + location: Location( + country: "Sweden", + countryCode: "se", + city: "Gothenburg", + cityCode: "got", + latitude: 42, + longitude: 42 + ) + ), + retryAttempt: 0 + ) +} diff --git a/ios/MullvadRustRuntime/EphemeralPeerNegotiator.swift b/ios/MullvadRustRuntime/EphemeralPeerNegotiator.swift index 8346b2686d45..85503c8bf468 100644 --- a/ios/MullvadRustRuntime/EphemeralPeerNegotiator.swift +++ b/ios/MullvadRustRuntime/EphemeralPeerNegotiator.swift @@ -11,7 +11,6 @@ import MullvadTypes import NetworkExtension import WireGuardKitTypes -// swiftlint:disable function_parameter_count public protocol EphemeralPeerNegotiating { func startNegotiation( devicePublicKey: PublicKey, @@ -70,5 +69,3 @@ public class EphemeralPeerNegotiator: EphemeralPeerNegotiating { drop_ephemeral_peer_exchange_token(cancelToken) } } - -// swiftlint:enable function_parameter_count diff --git a/ios/MullvadRustRuntimeTests/MullvadPostQuantum+Stubs.swift b/ios/MullvadRustRuntimeTests/MullvadPostQuantum+Stubs.swift index 52e4742c2126..ff7a3f74a4e3 100644 --- a/ios/MullvadRustRuntimeTests/MullvadPostQuantum+Stubs.swift +++ b/ios/MullvadRustRuntimeTests/MullvadPostQuantum+Stubs.swift @@ -12,7 +12,6 @@ import NetworkExtension @testable import PacketTunnelCore @testable import WireGuardKitTypes -// swiftlint:disable function_parameter_count class NWTCPConnectionStub: NWTCPConnection { var _isViable = false override var isViable: Bool { @@ -104,5 +103,3 @@ class SuccessfulNegotiatorStub: EphemeralPeerNegotiating { onCancelKeyNegotiation?() } } - -// swiftlint:enable function_parameter_count diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 95ae9dc0ec3e..d15e9a768017 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -655,6 +655,8 @@ 7AF10EB42ADE85BC00C090B9 /* RelayFilterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF10EB32ADE85BC00C090B9 /* RelayFilterCoordinator.swift */; }; 7AF36A9A2CA2964200E1D497 /* AnyIPAddressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF36A992CA2964000E1D497 /* AnyIPAddressTests.swift */; }; 7AF6E5F02A95051E00F2679D /* RouterBlockDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF6E5EF2A95051E00F2679D /* RouterBlockDelegate.swift */; }; + 7AF84F462D12C5B000C72690 /* SelectedRelaysStub+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF84F452D12C59F00C72690 /* SelectedRelaysStub+Stubs.swift */; }; + 7AF84F482D12C9D400C72690 /* ConnectionViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF84F472D12C9CF00C72690 /* ConnectionViewPreview.swift */; }; 7AF9BE882A30C62100DBFEDB /* SelectableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */; }; 7AF9BE8C2A321D1F00DBFEDB /* RelayFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8A2A321BEF00DBFEDB /* RelayFilter.swift */; }; 7AF9BE8E2A331C7B00DBFEDB /* RelayFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */; }; @@ -1007,7 +1009,7 @@ F0B0E6972AFE6E7E001DC66B /* XCTest+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */; }; F0B495762D02025200CFEC2A /* ChipContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495752D02025200CFEC2A /* ChipContainerView.swift */; }; F0B495782D02038B00CFEC2A /* ChipViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.swift */; }; - F0B4957A2D02F49200CFEC2A /* ChipFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495792D02F41F00CFEC2A /* ChipFeatures.swift */; }; + F0B4957A2D02F49200CFEC2A /* ChipFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495792D02F41F00CFEC2A /* ChipFeature.swift */; }; F0B4957C2D03154200CFEC2A /* FeatureIndicatorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B4957B2D03154200CFEC2A /* FeatureIndicatorsView.swift */; }; F0B894EF2BF751C500817A42 /* RelayWithLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */; }; F0B894F12BF751E300817A42 /* RelayWithDistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F02BF751E300817A42 /* RelayWithDistance.swift */; }; @@ -2028,6 +2030,8 @@ 7AF10EB32ADE85BC00C090B9 /* RelayFilterCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RelayFilterCoordinator.swift; sourceTree = ""; }; 7AF36A992CA2964000E1D497 /* AnyIPAddressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyIPAddressTests.swift; sourceTree = ""; }; 7AF6E5EF2A95051E00F2679D /* RouterBlockDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterBlockDelegate.swift; sourceTree = ""; }; + 7AF84F452D12C59F00C72690 /* SelectedRelaysStub+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SelectedRelaysStub+Stubs.swift"; sourceTree = ""; }; + 7AF84F472D12C9CF00C72690 /* ConnectionViewPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionViewPreview.swift; sourceTree = ""; }; 7AF9BE8A2A321BEF00DBFEDB /* RelayFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilter.swift; sourceTree = ""; }; 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewModel.swift; sourceTree = ""; }; 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Sorting.swift"; sourceTree = ""; }; @@ -2257,7 +2261,7 @@ F0B0E6962AFE6E7E001DC66B /* XCTest+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTest+Async.swift"; sourceTree = ""; }; F0B495752D02025200CFEC2A /* ChipContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipContainerView.swift; sourceTree = ""; }; F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipViewModelProtocol.swift; sourceTree = ""; }; - F0B495792D02F41F00CFEC2A /* ChipFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipFeatures.swift; sourceTree = ""; }; + F0B495792D02F41F00CFEC2A /* ChipFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipFeature.swift; sourceTree = ""; }; F0B4957B2D03154200CFEC2A /* FeatureIndicatorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIndicatorsView.swift; sourceTree = ""; }; F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithLocation.swift; sourceTree = ""; }; F0B894F02BF751E300817A42 /* RelayWithDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithDistance.swift; sourceTree = ""; }; @@ -3648,7 +3652,6 @@ A9D9A4C12C36D53C004088DD /* MullvadRustRuntimeTests */, 58CE5E61224146200008646E /* Products */, 584F991F2902CBDD001F858D /* Frameworks */, - 7A0EAE982D01B29E00D3EB8B /* Recovered References */, ); sourceTree = ""; }; @@ -3949,13 +3952,6 @@ path = Edit; sourceTree = ""; }; - 7A0EAE982D01B29E00D3EB8B /* Recovered References */ = { - isa = PBXGroup; - children = ( - ); - name = "Recovered References"; - sourceTree = ""; - }; 7A2960F72A964A3500389B82 /* Alert */ = { isa = PBXGroup; children = ( @@ -4092,8 +4088,8 @@ children = ( F0ADF1CF2D01B50B00299F09 /* ChipView */, 7AFBE3862D084C96002335FC /* ActivityIndicator.swift */, - F0B495792D02F41F00CFEC2A /* ChipFeatures.swift */, 7AA130982CFF365A00640DF9 /* ConnectionView.swift */, + 7AF84F472D12C9CF00C72690 /* ConnectionViewPreview.swift */, 7A0EAEA32D06DF8200D3EB8B /* ConnectionViewViewModel.swift */, F0B4957B2D03154200CFEC2A /* FeatureIndicatorsView.swift */, F0ADF1D22D01B6B400299F09 /* FeatureIndicatorsViewModel.swift */, @@ -4408,6 +4404,7 @@ F0ACE32E2BE4EA8B006D5333 /* MockProxyFactory.swift */, 58FE25EF2AA77664003D1918 /* RelaySelectorStub.swift */, A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */, + 7AF84F452D12C59F00C72690 /* SelectedRelaysStub+Stubs.swift */, ); path = MullvadREST; sourceTree = ""; @@ -4425,6 +4422,7 @@ isa = PBXGroup; children = ( F0B495752D02025200CFEC2A /* ChipContainerView.swift */, + F0B495792D02F41F00CFEC2A /* ChipFeature.swift */, F0ADF1D02D01B55C00299F09 /* ChipModel.swift */, F0ADF1D42D01DCFD00299F09 /* ChipView.swift */, F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.swift */, @@ -5947,6 +5945,7 @@ 7A27E3CB2CAE861D0088BCFF /* SettingsViewModel.swift in Sources */, 588527B2276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift in Sources */, 7A9F29392CABFAFC005F2089 /* InfoHeaderView.swift in Sources */, + 7AF84F482D12C9D400C72690 /* ConnectionViewPreview.swift in Sources */, 58DFF7D22B0256A300F864E0 /* MarkdownStylingOptions.swift in Sources */, 5867770E29096984006F721F /* OutOfTimeInteractor.swift in Sources */, F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */, @@ -6142,7 +6141,7 @@ 588D7EDE2AF3A585005DF40A /* ListAccessMethodItem.swift in Sources */, 5827B0B02B0F4CCD00CCBBA1 /* ListAccessMethodViewControllerDelegate.swift in Sources */, 588D7EE02AF3A595005DF40A /* ListAccessMethodInteractor.swift in Sources */, - F0B4957A2D02F49200CFEC2A /* ChipFeatures.swift in Sources */, + F0B4957A2D02F49200CFEC2A /* ChipFeature.swift in Sources */, 58607A4D2947287800BC467D /* AccountExpiryInAppNotificationProvider.swift in Sources */, 7A8A18FD2CE4BE8D000BCB5B /* CustomToggleStyle.swift in Sources */, 58C8191829FAA2C400DEB1B4 /* NotificationConfiguration.swift in Sources */, @@ -6530,6 +6529,7 @@ F0ACE3332BE516F1006D5333 /* RESTRequestExecutor+Stubs.swift in Sources */, F0ACE32D2BE4E784006D5333 /* AccountMock.swift in Sources */, 7A52F96A2C1735AE00B133B9 /* RelaySelectorStub.swift in Sources */, + 7AF84F462D12C5B000C72690 /* SelectedRelaysStub+Stubs.swift in Sources */, F03A69F72C2AD2D6000E2E7E /* TimeInterval+Timeout.swift in Sources */, F0ACE32F2BE4EA8B006D5333 /* MockProxyFactory.swift in Sources */, ); diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift index 60b0c22fe942..73d9a2d14011 100644 --- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift +++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift @@ -40,7 +40,7 @@ public enum AccessibilityIdentifier: Equatable { case purchaseButton case redeemVoucherButton case restorePurchasesButton - case secureConnectionButton + case connectButton case selectLocationButton case closeSelectLocationButton case settingsButton @@ -132,7 +132,7 @@ public enum AccessibilityIdentifier: Equatable { case selectLocationTableView case settingsTableView case vpnSettingsTableView - case tunnelControlView + case connectionView case problemReportView case problemReportSubmittedView case revokedDeviceView @@ -156,6 +156,7 @@ public enum AccessibilityIdentifier: Equatable { case logOutSpinnerAlertView case connectionPanelInAddressRow case connectionPanelOutAddressRow + case connectionPanelOutIpv6AddressRow case customSwitch case customWireGuardPortTextField case dnsContentBlockersHeaderView diff --git a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift index 7a8145ddca51..0c55f7e1af46 100644 --- a/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/TunnelCoordinator.swift @@ -12,7 +12,12 @@ import UIKit class TunnelCoordinator: Coordinator, Presenting { private let tunnelManager: TunnelManager + + #if DEBUG + private let controller: FI_TunnelViewController + #else private let controller: TunnelViewController + #endif private var tunnelObserver: TunnelObserver? @@ -39,7 +44,11 @@ class TunnelCoordinator: Coordinator, Presenting { ipOverrideRepository: ipOverrideRepository ) + #if DEBUG + controller = FI_TunnelViewController(interactor: interactor) + #else controller = TunnelViewController(interactor: interactor) + #endif super.init() diff --git a/ios/MullvadVPN/Extensions/String+Helpers.swift b/ios/MullvadVPN/Extensions/String+Helpers.swift index a3112819405b..512adaa6f766 100644 --- a/ios/MullvadVPN/Extensions/String+Helpers.swift +++ b/ios/MullvadVPN/Extensions/String+Helpers.swift @@ -6,7 +6,6 @@ // Copyright © 2020 Mullvad VPN AB. All rights reserved. // -import Foundation import UIKit extension String { @@ -19,4 +18,9 @@ extension String { return (0 ..< resultCount) .map { dropFirst($0 * length).prefix(length) } } + + func width(using font: UIFont) -> CGFloat { + let fontAttributes = [NSAttributedString.Key.font: font] + return self.size(withAttributes: fontAttributes).width + } } diff --git a/ios/MullvadVPN/Extensions/View+TapAreaSize.swift b/ios/MullvadVPN/Extensions/View+TapAreaSize.swift index 1e4ed64d3790..bf81cd57be35 100644 --- a/ios/MullvadVPN/Extensions/View+TapAreaSize.swift +++ b/ios/MullvadVPN/Extensions/View+TapAreaSize.swift @@ -17,8 +17,8 @@ extension View { } private struct TappablePadding: ViewModifier { - @State var actualViewSize: CGSize = .zero - let tappableViewSize = UIMetrics.Button.minimumTappableAreaSize + @State private var actualViewSize: CGSize = .zero + private let tappableViewSize = UIMetrics.Button.minimumTappableAreaSize func body(content: Content) -> some View { content diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/Contents.json b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/Contents.json index ff6e72343274..fc394e1bfbbb 100644 --- a/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/Contents.json +++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "IconReload.pdf", + "filename" : "icon-reload.svg", "idiom" : "universal" } ], diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/IconReload.pdf b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/IconReload.pdf deleted file mode 100644 index d58fb05aa5f85791ec24399c337106dc6b8a5e48..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1236 zcmY!laB6B^|0=W56?w_G&GL1#bH=>pDYs;?ec27I4i zaqG48jM;aBH40uZF6?~o`GGrdS8d;V!~XZb=gx~)-}|4Z_o$qk>->~ebGnSXRU$RR zxo2*kH933JLNC3zrzOhq$ATx<6s?ylQTI=&n#dNeakOO9QNh~2iFfoo?zC-DN}ON9 zmc5|v_Q8dlPGny?CVk`alHAvQe1EUpp4KTV^|CU;H{0B4QsFNtk8>wA3na7B-!WbD z;F|O@r_<@~ouCj_VV#UiuV)mCy+~ac@q2;SwVC`ARPO{l)PJ|6QdzDq=Ed3d+>xh( zqHSzUt{rRCc$;$3F3;FzL!s$xUqy*8N3Vw6x8261x!k;YSCn`gYwz*I_IF(TM=}gf zY?Ujp-^@L^eg$h?LG>+*iIYwoF!*@qFxR%s8JaAIZ}=)qS(lwssQ9F@d{Jv|BGcOI zS~;#_EH@NOn^?NH6h8DkDY^W_{YQ6SJiafruXp-K?zQXN9l28TQlLQ&WPu_J6hj~y z7)Qot1|WG5&lnz+;K*~WC~*%i0Y;pHjSWZu2=o&TfE)#VXGaA?1^whiAPJ04pahTv zBB(h=aB~#&Ln;eW74!oV(^C~x6%4`V1|$}x=9K`o!cvKEYD#9JQ+|a)G*HMu!PL+Q zjAOy7o%8cbfXa&%KnV?!s@#k6OAC-31+fDx3Ug;L(B`7Vyy60oUCEU&VNZpUqS90t z&l#x7CABOwIW@@L2_y*di+)gQaeir0a%!;xC`5vgdrx~3-BB4=RcDap*wa{)z-hLM4hfq}V^rJ0URipnVK4#Dx@jE#LUfr5ds1Vd2lgP z6QKXl#7xXF)fpH96Ck=eQ((xUiCGv~Vu)Fq03!vauB0e2GbgnOT=)cMR;2=+42p`N o{QMFHkjEkE#WOE0UjY>I;HWGvNh~S>`_R%7Scs{*`nz!f09k*DVgLXD diff --git a/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/icon-reload.svg b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/icon-reload.svg new file mode 100644 index 000000000000..6d443ac8b4c0 --- /dev/null +++ b/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/icon-reload.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index 1d0f98e8f06c..8eeb56c1598f 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -138,6 +138,11 @@ enum UIMetrics { enum MainButton { static let cornerRadius: CGFloat = 4 } + + enum FeatureIndicators { + static let chipViewHorisontalPadding: CGFloat = 8 + static let chipViewTrailingMargin: CGFloat = 6 + } } extension UIMetrics { diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipFeatures.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipFeatures.swift deleted file mode 100644 index c005b3f080ed..000000000000 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipFeatures.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// ChipFeatures.swift -// MullvadVPN -// -// Created by Mojgan on 2024-12-06. -// Copyright © 2024 Mullvad VPN AB. All rights reserved. -// -import Foundation -import MullvadSettings -import SwiftUI - -protocol ChipFeature { - var isEnabled: Bool { get } - var name: LocalizedStringKey { get } -} - -struct DaitaFeature: ChipFeature { - let settings: LatestTunnelSettings - - var isEnabled: Bool { - settings.daita.daitaState.isEnabled - } - - var name: LocalizedStringKey { - LocalizedStringKey("DAITA") - } -} - -struct QuantumResistanceFeature: ChipFeature { - let settings: LatestTunnelSettings - var isEnabled: Bool { - settings.tunnelQuantumResistance.isEnabled - } - - var name: LocalizedStringKey { - LocalizedStringKey("Quantum resistance") - } -} - -struct MultihopFeature: ChipFeature { - let settings: LatestTunnelSettings - var isEnabled: Bool { - settings.tunnelMultihopState.isEnabled - } - - var name: LocalizedStringKey { - LocalizedStringKey("Multihop") - } -} - -struct ObfuscationFeature: ChipFeature { - let settings: LatestTunnelSettings - - var isEnabled: Bool { - settings.wireGuardObfuscation.state.isEnabled - } - - var name: LocalizedStringKey { - LocalizedStringKey("Obfuscation") - } -} - -struct DNSFeature: ChipFeature { - let settings: LatestTunnelSettings - - var isEnabled: Bool { - settings.dnsSettings.enableCustomDNS || !settings.dnsSettings.blockingOptions.isEmpty - } - - var name: LocalizedStringKey { - if !settings.dnsSettings.blockingOptions.isEmpty { - return LocalizedStringKey("DNS content blockers") - } - return LocalizedStringKey("Custom DNS") - } -} - -struct IPOverrideFeature: ChipFeature { - let overrides: [IPOverride] - - var isEnabled: Bool { - !overrides.isEmpty - } - - var name: LocalizedStringKey { - LocalizedStringKey("Server IP override") - } -} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift index f06124ed4811..e64874ad22ad 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift @@ -10,46 +10,42 @@ import SwiftUI struct ChipContainerView: View where ViewModel: ChipViewModelProtocol { @ObservedObject var viewModel: ViewModel + @Binding var isExpanded: Bool - @State var chipHeight: CGFloat = 0 - @State var fullContainerHeight: CGFloat = 0 - @State var visibleContainerHeight: CGFloat = 0 + @State private var chipContainerHeight: CGFloat = .zero + private let verticalPadding: CGFloat = 6 var body: some View { GeometryReader { geo in let containerWidth = geo.size.width - let chipsOverflow = !viewModel.isExpanded && (fullContainerHeight > chipHeight) - let numberOfChips = chipsOverflow ? 2 : viewModel.chips.count + + let (chipsToAdd, showMoreButton) = if isExpanded { + (viewModel.chips, false) + } else { + viewModel.chipsToAdd(forContainerWidth: containerWidth) + } HStack { ZStack(alignment: .topLeading) { - createChipViews(chips: Array(viewModel.chips.prefix(numberOfChips)), containerWidth: containerWidth) + createChipViews(chips: chipsToAdd, containerWidth: containerWidth) } - .sizeOfView { visibleContainerHeight = $0.height } - if chipsOverflow { - Text(LocalizedStringKey("\(viewModel.chips.count - numberOfChips) more...")) + if showMoreButton { + Text(LocalizedStringKey("\(viewModel.chips.count - chipsToAdd.count) more...")) .font(.subheadline) .lineLimit(1) .foregroundStyle(UIColor.primaryTextColor.color) - .padding(.bottom, 12) + .onTapGesture { + isExpanded.toggle() + } } Spacer() } - .background(preRenderViewSize(containerWidth: containerWidth)) - }.frame(height: visibleContainerHeight) - } - - // Renders all chips on screen, in this case specifically to get their combined height. - // Used to determine if content would overflow if view was not expanded and should - // only be called from a background modifier. - private func preRenderViewSize(containerWidth: CGFloat) -> some View { - ZStack(alignment: .topLeading) { - createChipViews(chips: viewModel.chips, containerWidth: containerWidth) + .sizeOfView { chipContainerHeight = $0.height } } - .hidden() - .sizeOfView { fullContainerHeight = $0.height } + .frame(height: chipContainerHeight) + .padding(.vertical, -(verticalPadding - 1)) // Remove extra padding from chip views on top and bottom. } private func createChipViews(chips: [ChipModel], containerWidth: CGFloat) -> some View { @@ -58,14 +54,21 @@ struct ChipContainerView: View where ViewModel: ChipViewModelProtocol return ForEach(chips) { data in ChipView(item: data) - .padding(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 8)) + .padding( + EdgeInsets( + top: verticalPadding, + leading: 0, + bottom: verticalPadding, + trailing: UIMetrics.FeatureIndicators.chipViewTrailingMargin + ) + ) .alignmentGuide(.leading) { dimension in if abs(width - dimension.width) > containerWidth { width = 0 height -= dimension.height } let result = width - if data.id == chips.last!.id { + if data.id == chips.last?.id { width = 0 } else { width -= dimension.width @@ -74,22 +77,27 @@ struct ChipContainerView: View where ViewModel: ChipViewModelProtocol } .alignmentGuide(.top) { _ in let result = height - if data.id == chips.last!.id { + if data.id == chips.last?.id { height = 0 } return result } - .sizeOfView { chipHeight = $0.height } } } } #Preview("Normal") { - ChipContainerView(viewModel: MockFeatureIndicatorsViewModel()) - .background(UIColor.secondaryColor.color) + ChipContainerView( + viewModel: MockFeatureIndicatorsViewModel(), + isExpanded: .constant(false) + ) + .background(UIColor.secondaryColor.color) } #Preview("Expanded") { - ChipContainerView(viewModel: MockFeatureIndicatorsViewModel(isExpanded: true)) - .background(UIColor.secondaryColor.color) + ChipContainerView( + viewModel: MockFeatureIndicatorsViewModel(), + isExpanded: .constant(true) + ) + .background(UIColor.secondaryColor.color) } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipFeature.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipFeature.swift new file mode 100644 index 000000000000..ea1d130dbede --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipFeature.swift @@ -0,0 +1,123 @@ +// +// ChipFeature.swift +// MullvadVPN +// +// Created by Mojgan on 2024-12-06. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// +import MullvadSettings +import SwiftUI + +protocol ChipFeature { + var isEnabled: Bool { get } + var name: String { get } +} + +struct DaitaFeature: ChipFeature { + let settings: LatestTunnelSettings + + var isEnabled: Bool { + settings.daita.daitaState.isEnabled + } + + var name: String { + NSLocalizedString( + "FEATURE_INDICATORS_CHIP_DAITA", + tableName: "FeatureIndicatorsChip", + value: "DAITA", + comment: "" + ) + } +} + +struct QuantumResistanceFeature: ChipFeature { + let settings: LatestTunnelSettings + var isEnabled: Bool { + settings.tunnelQuantumResistance.isEnabled + } + + var name: String { + NSLocalizedString( + "FEATURE_INDICATORS_CHIP_QUANTUM_RESISTANCE", + tableName: "FeatureIndicatorsChip", + value: "Quantum resistance", + comment: "" + ) + } +} + +struct MultihopFeature: ChipFeature { + let settings: LatestTunnelSettings + var isEnabled: Bool { + settings.tunnelMultihopState.isEnabled + } + + var name: String { + NSLocalizedString( + "FEATURE_INDICATORS_CHIP_MULTIHOP", + tableName: "FeatureIndicatorsChip", + value: "Multihop", + comment: "" + ) + } +} + +struct ObfuscationFeature: ChipFeature { + let settings: LatestTunnelSettings + + var isEnabled: Bool { + settings.wireGuardObfuscation.state.isEnabled + } + + var name: String { + NSLocalizedString( + "FEATURE_INDICATORS_CHIP_OBFUSCATION", + tableName: "FeatureIndicatorsChip", + value: "Obfuscation", + comment: "" + ) + } +} + +struct DNSFeature: ChipFeature { + let settings: LatestTunnelSettings + + var isEnabled: Bool { + settings.dnsSettings.enableCustomDNS || !settings.dnsSettings.blockingOptions.isEmpty + } + + var name: String { + if !settings.dnsSettings.blockingOptions.isEmpty { + NSLocalizedString( + "FEATURE_INDICATORS_CHIP_CONTENT_BLOCKERS", + tableName: "FeatureIndicatorsChip", + value: "DNS content blockers", + comment: "" + ) + } else { + NSLocalizedString( + "FEATURE_INDICATORS_CHIP_CUSTOM_DNS", + tableName: "FeatureIndicatorsChip", + value: "Custom DNS", + comment: "" + ) + } + } +} + +struct IPOverrideFeature: ChipFeature { + let overrides: [IPOverride] + + var isEnabled: Bool { + !overrides.isEmpty + } + + var name: String { + NSLocalizedString( + "FEATURE_INDICATORS_CHIP_IP_OVERRIDE", + tableName: "FeatureIndicatorsChip", + value: "Server IP Override", + comment: "" + ) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipModel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipModel.swift index c1e990a1b1d9..a746897c06d2 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipModel.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipModel.swift @@ -11,5 +11,5 @@ import SwiftUI struct ChipModel: Identifiable { let id = UUID() - let name: LocalizedStringKey + let name: String } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipView.swift index 6d6614973f5f..1c6a5eb52267 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipView.swift @@ -11,20 +11,20 @@ import SwiftUI struct ChipView: View { let item: ChipModel var body: some View { - Text(item.name) + Text(LocalizedStringKey(item.name)) .font(.subheadline) .lineLimit(1) .foregroundStyle(UIColor.primaryTextColor.color) - .padding(.horizontal, 8) + .padding(.horizontal, UIMetrics.FeatureIndicators.chipViewHorisontalPadding) .padding(.vertical, 4) .background( - RoundedRectangle(cornerRadius: 8.0) + RoundedRectangle(cornerRadius: 8) .stroke( UIColor.primaryColor.color, lineWidth: 1 ) .background( - RoundedRectangle(cornerRadius: 8.0) + RoundedRectangle(cornerRadius: 8) .fill(UIColor.secondaryColor.color) ) ) @@ -33,7 +33,7 @@ struct ChipView: View { #Preview { ZStack { - ChipView(item: ChipModel(name: LocalizedStringKey("Example"))) + ChipView(item: ChipModel(name: "Example")) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(UIColor.secondaryColor.color) diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipViewModelProtocol.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipViewModelProtocol.swift index 65e3b0ccef38..fabec7f8c9e9 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipViewModelProtocol.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipViewModelProtocol.swift @@ -10,23 +10,54 @@ import SwiftUI protocol ChipViewModelProtocol: ObservableObject { var chips: [ChipModel] { get } - var isExpanded: Bool { get set } } -class MockFeatureIndicatorsViewModel: ChipViewModelProtocol { - @Published var chips: [ChipModel] = [ - ChipModel(name: LocalizedStringKey("DAITA")), - ChipModel(name: LocalizedStringKey("Obfuscation")), - ChipModel(name: LocalizedStringKey("Quantum resistance")), - ChipModel(name: LocalizedStringKey("Multihop")), - ChipModel(name: LocalizedStringKey("DNS content blockers")), - ChipModel(name: LocalizedStringKey("Custom DNS")), - ChipModel(name: LocalizedStringKey("Server IP override")), - ] +extension ChipViewModelProtocol { + func chipsToAdd(forContainerWidth containerWidth: CGFloat) -> (chips: [ChipModel], chipsWillOverflow: Bool) { + var chipsToAdd = [ChipModel]() + var chipsWillOverflow = false + + let moreTextWidth = "\(chips.count) more..." + .width(using: .preferredFont(forTextStyle: .subheadline)) + 4 // Some extra to be safe. + var totalChipsWidth: CGFloat = 0 + + for (index, chip) in chips.enumerated() { + let textWidth = chip.name.width(using: .preferredFont(forTextStyle: .subheadline)) + let chipWidth = textWidth + + UIMetrics.FeatureIndicators.chipViewHorisontalPadding * 2 + + UIMetrics.FeatureIndicators.chipViewTrailingMargin + let isLastChip = index == chips.count - 1 + + totalChipsWidth += chipWidth - @Published var isExpanded: Bool + let chipWillFitWithMoreText = (totalChipsWidth + moreTextWidth) <= containerWidth + let chipWillFit = totalChipsWidth <= containerWidth - init(isExpanded: Bool = false) { - self.isExpanded = isExpanded + if chipWillFitWithMoreText { + // If a chip can fit together with the "more" text, add it. + chipsToAdd.append(chip) + chipsWillOverflow = !isLastChip + } else if chipWillFit && isLastChip { + // If a chip can fit and it's the last one, add it. + chipsToAdd.append(chip) + chipsWillOverflow = false + } else { + break + } + } + + return (chipsToAdd, chipsWillOverflow) } } + +class MockFeatureIndicatorsViewModel: ChipViewModelProtocol { + @Published var chips: [ChipModel] = [ + ChipModel(name: "DAITA"), + ChipModel(name: "Obfuscation"), + ChipModel(name: "Quantum resistance"), + ChipModel(name: "Multihop"), + ChipModel(name: "DNS content blockers"), + ChipModel(name: "Custom DNS"), + ChipModel(name: "Server IP override"), + ] +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift index 3a1bf7d9afe3..d066bb47f21d 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift @@ -6,21 +6,23 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // -import MullvadSettings import SwiftUI -typealias ButtonAction = (ConnectionViewViewModel.TunnelControlAction) -> Void +typealias ButtonAction = (ConnectionViewViewModel.TunnelAction) -> Void struct ConnectionView: View { - @StateObject var viewModel: ConnectionViewViewModel + @StateObject var connectionViewModel: ConnectionViewViewModel @StateObject var indicatorsViewModel: FeatureIndicatorsViewModel + @State private(set) var isExpanded = false + var action: ButtonAction? var onContentUpdate: (() -> Void)? var body: some View { + Spacer() VStack(spacing: 22) { - if viewModel.showsActivityIndicator { + if connectionViewModel.showsActivityIndicator { CustomProgressView(style: .large) } @@ -28,63 +30,184 @@ struct ConnectionView: View { BlurView(style: .dark) VStack(alignment: .leading, spacing: 16) { - ConnectionPanel(viewModel: viewModel) + ConnectionHeader(viewModel: connectionViewModel, isExpanded: $isExpanded) - if !indicatorsViewModel.chips.isEmpty { - FeatureIndicatorsView(viewModel: indicatorsViewModel) + if connectionViewModel.showConnectionDetails { + ConnectionDetailsContainer( + viewModel: connectionViewModel, + indicatorsViewModel: indicatorsViewModel, + isExpanded: $isExpanded + ) } - ButtonPanel(viewModel: viewModel, action: action) + ButtonPanel(viewModel: connectionViewModel, action: action) } .padding(16) } .cornerRadius(12) .padding(16) } - .padding(.bottom, 8) // Adding some spacing so to not overlap with the map legal link. - .onReceive( - indicatorsViewModel.$isExpanded - .combineLatest( - viewModel.$tunnelState, - viewModel.$showsActivityIndicator - ) - ) { _ in + .padding(.bottom, 8) // Adding some spacing so as not to overlap with the map legal link. + .accessibilityIdentifier(AccessibilityIdentifier.connectionView.asString) + .onChange(of: isExpanded) { _ in + onContentUpdate?() + } + .onReceive(connectionViewModel.combinedState) { _, _ in onContentUpdate?() + + if !connectionViewModel.showConnectionDetails { + isExpanded = false + } } } } -#Preview { - ConnectionView( - viewModel: ConnectionViewViewModel(tunnelState: .disconnected), - indicatorsViewModel: FeatureIndicatorsViewModel(tunnelSettings: LatestTunnelSettings(), ipOverrides: []) - ) { action in - print(action) - } - .background(UIColor.secondaryColor.color) +#Preview("ConnectionView (Normal)") { + ConnectionViewPreview(configuration: .normal).make() } -private struct ConnectionPanel: View { +#Preview("ConnectionView (Normal, no indicators)") { + ConnectionViewPreview(configuration: .normalNoIndicators).make() +} + +#Preview("ConnectionView (Expanded)") { + ConnectionViewPreview(configuration: .expanded).make() +} + +#Preview("ConnectionView (Expanded, no indicators)") { + ConnectionViewPreview(configuration: .expandedNoIndicators).make() +} + +private struct ConnectionHeader: View { @StateObject var viewModel: ConnectionViewViewModel + @Binding var isExpanded: Bool var body: some View { - VStack(alignment: .leading) { - Text(viewModel.localizedTitleForSecureLabel) - .textCase(.uppercase) - .font(.title3.weight(.semibold)) - .foregroundStyle(viewModel.textColorForSecureLabel.color) - .padding(.bottom, 4) - - if let countryAndCity = viewModel.titleForCountryAndCity, let server = viewModel.titleForServer { - Text(countryAndCity) + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 0) { + Text(viewModel.localizedTitleForSecureLabel) + .textCase(.uppercase) .font(.title3.weight(.semibold)) - .foregroundStyle(UIColor.primaryTextColor.color) - Text(server) - .font(.body) + .foregroundStyle(viewModel.textColorForSecureLabel.color) + .accessibilityIdentifier(viewModel.accessibilityIdForSecureLabel.asString) + + if let countryAndCity = viewModel.titleForCountryAndCity { + Text(countryAndCity) + .font(.title3.weight(.semibold)) + .foregroundStyle(UIColor.primaryTextColor.color) + .padding(.top, 4) + } + + if let server = viewModel.titleForServer { + Text(server) + .font(.body) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + .padding(.top, 2) + } + } + .accessibilityLabel(viewModel.localizedAccessibilityLabelForSecureLabel) + + if viewModel.showConnectionDetails { + Spacer() + Image(.iconChevron) + .renderingMode(.template) + .rotationEffect(isExpanded ? .degrees(-90) : .degrees(90)) + .foregroundStyle(.white) + .transaction { transaction in + transaction.animation = nil + } + } + } + .accessibilityIdentifier(AccessibilityIdentifier.relayStatusCollapseButton.asString) + .contentShape(Rectangle()) + .onTapGesture { + isExpanded.toggle() + } + } +} + +private struct ConnectionDetailsContainer: View { + @StateObject var viewModel: ConnectionViewViewModel + @StateObject var indicatorsViewModel: FeatureIndicatorsViewModel + @Binding var isExpanded: Bool + + @State private var scrollViewHeight: CGFloat = 0 + + var body: some View { + if isExpanded { + Divider() + .background(UIColor.secondaryTextColor.color) + } + + // This geometry reader is somewhat of a workaround. It's "smart" in that it takes up as much + // space as it can and thereby helps the view to understand the maximum allowed height when + // placed in a UIKit context. If ConnectionView would ever be placed as a subview of SwiftUI + // parent, this reader could probably be removed. + GeometryReader { _ in + ScrollView { + VStack(spacing: 16) { + if !indicatorsViewModel.chips.isEmpty { + FeatureIndicatorsView( + viewModel: indicatorsViewModel, + isExpanded: $isExpanded + ) + } + + if isExpanded { + ConnectionDetails(viewModel: viewModel) + } + } + .sizeOfView { scrollViewHeight = $0.height } + } + } + .frame(maxHeight: scrollViewHeight) + } +} + +private struct ConnectionDetails: View { + @StateObject var viewModel: ConnectionViewViewModel + @State private var columnWidth: CGFloat = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(LocalizedStringKey("Connection details")) + .font(.footnote.weight(.semibold)) .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + Spacer() + } + + VStack(alignment: .leading, spacing: 0) { + if let inAddress = viewModel.inAddress { + connectionDetailRow(title: LocalizedStringKey("In"), value: inAddress) + .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelInAddressRow.asString) + } + if viewModel.tunnelIsConnected { + if let outAddressIpv4 = viewModel.outAddressIpv4 { + connectionDetailRow(title: LocalizedStringKey("Out IPv4"), value: outAddressIpv4) + .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelOutAddressRow.asString) + } + if let outAddressIpv6 = viewModel.outAddressIpv6 { + connectionDetailRow(title: LocalizedStringKey("Out IPv6"), value: outAddressIpv6) + .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelOutAddressRow.asString) + } + } } } - .accessibilityLabel(viewModel.localizedAccessibilityLabel) + } + + @ViewBuilder + private func connectionDetailRow(title: LocalizedStringKey, value: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text(title) + .font(.subheadline) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + .frame(minWidth: columnWidth, alignment: .leading) + .sizeOfView { columnWidth = max(columnWidth, $0.width) } + Text(value) + .font(.subheadline) + .foregroundStyle(UIColor.primaryTextColor.color) + } } } @@ -95,29 +218,31 @@ private struct ButtonPanel: View { var body: some View { VStack(spacing: 16) { locationButton(with: action) + .disabled(viewModel.disableButtons) actionButton(with: action) + .disabled(viewModel.disableButtons) } } @ViewBuilder private func locationButton(with action: ButtonAction?) -> some View { - switch viewModel.tunnelState { + switch viewModel.tunnelStatus.state { case .connecting, .connected, .reconnecting, .waitingForConnectivity, .negotiatingEphemeralPeer, .error: SplitMainButton( text: viewModel.localizedTitleForSelectLocationButton, image: .iconReload, style: .default, - disabled: viewModel.disableButtons, primaryAction: { action?(.selectLocation) }, secondaryAction: { action?(.reconnect) } ) + .accessibilityIdentifier(AccessibilityIdentifier.selectLocationButton.asString) case .disconnecting, .pendingReconnect, .disconnected: MainButton( text: viewModel.localizedTitleForSelectLocationButton, style: .default, - disabled: viewModel.disableButtons, action: { action?(.selectLocation) } ) + .accessibilityIdentifier(AccessibilityIdentifier.selectLocationButton.asString) } } @@ -128,27 +253,31 @@ private struct ButtonPanel: View { MainButton( text: LocalizedStringKey("Connect"), style: .success, - disabled: viewModel.disableButtons, action: { action?(.connect) } ) + .accessibilityIdentifier(AccessibilityIdentifier.connectButton.asString) case .disconnect: MainButton( text: LocalizedStringKey("Disconnect"), style: .danger, - disabled: viewModel.disableButtons, action: { action?(.disconnect) } ) + .accessibilityIdentifier(AccessibilityIdentifier.disconnectButton.asString) case .cancel: MainButton( text: LocalizedStringKey( - viewModel.tunnelState == .waitingForConnectivity(.noConnection) + viewModel.tunnelStatus.state == .waitingForConnectivity(.noConnection) ? "Disconnect" : "Cancel" ), style: .danger, - disabled: viewModel.disableButtons, action: { action?(.cancel) } ) + .accessibilityIdentifier( + viewModel.tunnelStatus.state == .waitingForConnectivity(.noConnection) + ? AccessibilityIdentifier.disconnectButton.asString + : AccessibilityIdentifier.cancelButton.asString + ) } } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewPreview.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewPreview.swift new file mode 100644 index 000000000000..0575d4658fde --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewPreview.swift @@ -0,0 +1,81 @@ +// +// ConnectionViewPreview.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-12-18. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import MullvadMockData +import MullvadSettings +import MullvadTypes +import PacketTunnelCore +import SwiftUI + +struct ConnectionViewPreview { + enum Configuration { + case normal, normalNoIndicators, expanded, expandedNoIndicators + } + + private let configuration: Configuration + + private let populatedTunnelSettings = LatestTunnelSettings( + wireGuardObfuscation: WireGuardObfuscationSettings(state: .udpOverTcp), + tunnelQuantumResistance: .on, + tunnelMultihopState: .on, + daita: DAITASettings(daitaState: .on) + ) + + private let viewModel = ConnectionViewViewModel( + tunnelStatus: TunnelStatus( + observedState: .connected(ObservedConnectionState( + selectedRelays: SelectedRelaysStub.selectedRelays, + relayConstraints: RelayConstraints(entryLocations: .any, exitLocations: .any, port: .any, filter: .any), + networkReachability: .reachable, + connectionAttemptCount: 0, + transportLayer: .udp, + remotePort: 80, + isPostQuantum: true, + isDaitaEnabled: true + )), + state: .connected(SelectedRelaysStub.selectedRelays, isPostQuantum: true, isDaita: true) + ) + ) + + init(configuration: Configuration) { + self.configuration = configuration + } + + @ViewBuilder + func make() -> some View { + VStack { + switch configuration { + case .normal: + connectionView(with: populatedTunnelSettings, viewModel: viewModel) + case .normalNoIndicators: + connectionView(with: LatestTunnelSettings(), viewModel: viewModel) + case .expanded: + connectionView(with: populatedTunnelSettings, viewModel: viewModel, isExpanded: true) + case .expandedNoIndicators: + connectionView(with: LatestTunnelSettings(), viewModel: viewModel, isExpanded: true) + } + } + .background(UIColor.secondaryColor.color) + } + + @ViewBuilder + private func connectionView( + with settings: LatestTunnelSettings, + viewModel: ConnectionViewViewModel, + isExpanded: Bool = false + ) -> some View { + ConnectionView( + connectionViewModel: viewModel, + indicatorsViewModel: FeatureIndicatorsViewModel( + tunnelSettings: settings, + ipOverrides: [] + ), + isExpanded: isExpanded + ) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift index 29a4748b4100..2f7af8a3b572 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift @@ -6,16 +6,17 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // +import Combine import SwiftUI class ConnectionViewViewModel: ObservableObject { - enum TunnelControlActionButton { + enum TunnelActionButton { case connect case disconnect case cancel } - enum TunnelControlAction { + enum TunnelAction { case connect case disconnect case cancel @@ -23,42 +24,69 @@ class ConnectionViewViewModel: ObservableObject { case selectLocation } - @Published var tunnelState: TunnelState + @Published var tunnelStatus: TunnelStatus + @Published var outgoingConnectionInfo: OutgoingConnectionInfo? @Published var showsActivityIndicator = false - init(tunnelState: TunnelState) { - self.tunnelState = tunnelState + var combinedState: Publishers.CombineLatest< + Published.Publisher, + Published.Publisher + > { + $tunnelStatus.combineLatest($showsActivityIndicator) + } + + var tunnelIsConnected: Bool { + if case .connected = tunnelStatus.state { + true + } else { + false + } + } + + init(tunnelStatus: TunnelStatus) { + self.tunnelStatus = tunnelStatus } } extension ConnectionViewViewModel { + var showConnectionDetails: Bool { + switch tunnelStatus.state { + case .connecting, .reconnecting, .waitingForConnectivity(.noConnection), .negotiatingEphemeralPeer, + .connected, .pendingReconnect, .waitingForConnectivity(.noNetwork): + true + case .disconnecting, .disconnected, .error: + false + } + } + var textColorForSecureLabel: UIColor { - switch tunnelState { - case .connecting, .reconnecting, .waitingForConnectivity(.noConnection), .negotiatingEphemeralPeer: + switch tunnelStatus.state { + case .connecting, .reconnecting, .waitingForConnectivity(.noConnection), .negotiatingEphemeralPeer, + .pendingReconnect, .disconnecting: .white case .connected: .successColor - case .disconnecting, .disconnected, .pendingReconnect, .waitingForConnectivity(.noNetwork), .error: + case .disconnected, .waitingForConnectivity(.noNetwork), .error: .dangerColor } } var disableButtons: Bool { - if case .waitingForConnectivity(.noNetwork) = tunnelState { - return true + if case .waitingForConnectivity(.noNetwork) = tunnelStatus.state { + true + } else { + false } - - return false } var localizedTitleForSecureLabel: LocalizedStringKey { - switch tunnelState { + switch tunnelStatus.state { case .connecting, .reconnecting, .negotiatingEphemeralPeer: - LocalizedStringKey("Connecting") + LocalizedStringKey("Connecting...") case .connected: LocalizedStringKey("Connected") case .disconnecting(.nothing): - LocalizedStringKey("Disconnecting") + LocalizedStringKey("Disconnecting...") case .disconnecting(.reconnect), .pendingReconnect: LocalizedStringKey("Reconnecting") case .disconnected: @@ -70,17 +98,19 @@ extension ConnectionViewViewModel { } } - var localizedTitleForSelectLocationButton: LocalizedStringKey { - switch tunnelState { - case .disconnecting, .pendingReconnect, .disconnected: - LocalizedStringKey("Select location") - case .connecting, .connected, .reconnecting, .waitingForConnectivity, .negotiatingEphemeralPeer, .error: - LocalizedStringKey("Switch location") + var accessibilityIdForSecureLabel: AccessibilityIdentifier { + switch tunnelStatus.state { + case .connected: + .connectionStatusConnectedLabel + case .connecting: + .connectionStatusConnectingLabel + default: + .connectionStatusNotConnectedLabel } } - var localizedAccessibilityLabel: LocalizedStringKey { - switch tunnelState { + var localizedAccessibilityLabelForSecureLabel: LocalizedStringKey { + switch tunnelStatus.state { case .disconnected, .waitingForConnectivity, .disconnecting, .pendingReconnect, .error: localizedTitleForSecureLabel case let .connected(tunnelInfo, _, _): @@ -98,8 +128,17 @@ extension ConnectionViewViewModel { } } - var actionButton: TunnelControlActionButton { - switch tunnelState { + var localizedTitleForSelectLocationButton: LocalizedStringKey { + switch tunnelStatus.state { + case .disconnecting, .pendingReconnect, .disconnected: + LocalizedStringKey("Select location") + case .connecting, .connected, .reconnecting, .waitingForConnectivity, .negotiatingEphemeralPeer, .error: + LocalizedStringKey("Switch location") + } + } + + var actionButton: TunnelActionButton { + switch tunnelStatus.state { case .disconnected, .disconnecting(.nothing), .waitingForConnectivity(.noNetwork): .connect case .connecting, .pendingReconnect, .disconnecting(.reconnect), .waitingForConnectivity(.noConnection), @@ -111,7 +150,7 @@ extension ConnectionViewViewModel { } var titleForCountryAndCity: LocalizedStringKey? { - guard tunnelState.isSecured, let tunnelRelays = tunnelState.relays else { + guard let tunnelRelays = tunnelStatus.state.relays else { return nil } @@ -119,7 +158,7 @@ extension ConnectionViewViewModel { } var titleForServer: LocalizedStringKey? { - guard tunnelState.isSecured, let tunnelRelays = tunnelState.relays else { + guard let tunnelRelays = tunnelStatus.state.relays else { return nil } @@ -132,4 +171,50 @@ extension ConnectionViewViewModel { LocalizedStringKey("\(exitName)") } } + + var inAddress: String? { + guard let tunnelRelays = tunnelStatus.state.relays else { + return nil + } + + let observedTunnelState = tunnelStatus.observedState + + var portAndTransport = "" + if let inPort = observedTunnelState.connectionState?.remotePort { + let protocolLayer = observedTunnelState.connectionState?.transportLayer == .tcp ? "TCP" : "UDP" + portAndTransport = ":\(inPort) \(protocolLayer)" + } + + guard + let address = tunnelRelays.entry?.endpoint.ipv4Relay.ip + ?? tunnelStatus.state.relays?.exit.endpoint.ipv4Relay.ip + else { + return nil + } + + return "\(address)\(portAndTransport)" + } + + var outAddressIpv4: String? { + guard + let outgoingConnectionInfo, + let address = outgoingConnectionInfo.ipv4.exitIP ? outgoingConnectionInfo.ipv4.ip : nil + else { + return nil + } + + return "\(address)" + } + + var outAddressIpv6: String? { + guard + let outgoingConnectionInfo, + let ipv6 = outgoingConnectionInfo.ipv6, + let address = ipv6.exitIP ? ipv6.ip : nil + else { + return nil + } + + return "\(address)" + } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift index 9aed89004110..8d3f4c78d527 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift @@ -53,14 +53,14 @@ class FI_TunnelViewController: UIViewController, RootContainment { self.interactor = interactor tunnelState = interactor.tunnelStatus.state - connectionViewViewModel = ConnectionViewViewModel(tunnelState: tunnelState) + connectionViewViewModel = ConnectionViewViewModel(tunnelStatus: interactor.tunnelStatus) indicatorsViewViewModel = FeatureIndicatorsViewModel( tunnelSettings: interactor.tunnelSettings, ipOverrides: interactor.ipOverrides ) connectionView = ConnectionView( - viewModel: self.connectionViewViewModel, + connectionViewModel: self.connectionViewViewModel, indicatorsViewModel: self.indicatorsViewViewModel ) @@ -86,10 +86,15 @@ class FI_TunnelViewController: UIViewController, RootContainment { } interactor.didUpdateTunnelStatus = { [weak self] tunnelStatus in + self?.connectionViewViewModel.tunnelStatus = tunnelStatus self?.setTunnelState(tunnelStatus.state, animated: true) self?.view.setNeedsLayout() } + interactor.didGetOutGoingAddress = { [weak self] connectionInfo in + self?.connectionViewViewModel.outgoingConnectionInfo = connectionInfo + } + interactor.didUpdateTunnelSettings = { [weak self] tunnelSettings in self?.indicatorsViewViewModel.tunnelSettings = tunnelSettings } @@ -142,7 +147,6 @@ class FI_TunnelViewController: UIViewController, RootContainment { private func setTunnelState(_ tunnelState: TunnelState, animated: Bool) { self.tunnelState = tunnelState - connectionViewViewModel.tunnelState = tunnelState setNeedsHeaderBarStyleAppearanceUpdate() @@ -211,7 +215,7 @@ class FI_TunnelViewController: UIViewController, RootContainment { connectionController.didMove(toParent: self) view.addConstrainedSubviews([connectionViewProxy]) { - connectionViewProxy.pinEdgesToSuperview(.all().excluding(.top)) + connectionViewProxy.pinEdgesToSuperview(.all()) } } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift index eb1a29ea8195..b1a369f99eb0 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift @@ -10,22 +10,26 @@ import SwiftUI struct FeatureIndicatorsView: View where ViewModel: ChipViewModelProtocol { @ObservedObject var viewModel: ViewModel + @Binding var isExpanded: Bool var body: some View { VStack(alignment: .leading, spacing: 0) { - Text(LocalizedStringKey("Active features")) - .font(.footnote.weight(.semibold)) - .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + if isExpanded { + Text(LocalizedStringKey("Active features")) + .font(.footnote.weight(.semibold)) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + .padding(.bottom, 8) + } - ChipContainerView(viewModel: viewModel) - .onTapGesture { - viewModel.isExpanded.toggle() - } + ChipContainerView(viewModel: viewModel, isExpanded: $isExpanded) } } } -#Preview("FeatureIndicatorsView") { - FeatureIndicatorsView(viewModel: MockFeatureIndicatorsViewModel(isExpanded: true)) - .background(UIColor.secondaryColor.color) +#Preview { + FeatureIndicatorsView( + viewModel: MockFeatureIndicatorsViewModel(), + isExpanded: .constant(true) + ) + .background(UIColor.secondaryColor.color) } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsViewModel.swift index 42376b45608b..97eac59ca8f3 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsViewModel.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsViewModel.swift @@ -6,18 +6,16 @@ // Copyright © 2024 Mullvad VPN AB. All rights reserved. // -import Foundation import MullvadSettings +import SwiftUI class FeatureIndicatorsViewModel: ChipViewModelProtocol { @Published var tunnelSettings: LatestTunnelSettings @Published var ipOverrides: [IPOverride] - @Published var isExpanded = false - init(tunnelSettings: LatestTunnelSettings, ipOverrides: [IPOverride], isExpanded: Bool = false) { + init(tunnelSettings: LatestTunnelSettings, ipOverrides: [IPOverride]) { self.tunnelSettings = tunnelSettings self.ipOverrides = ipOverrides - self.isExpanded = isExpanded } var chips: [ChipModel] { diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift index 88c933493b00..cff522297669 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelControlView.swift @@ -60,7 +60,7 @@ final class TunnelControlView: UIView { private let connectButton: AppButton = { let button = AppButton(style: .success) - button.setAccessibilityIdentifier(.secureConnectionButton) + button.setAccessibilityIdentifier(.connectButton) button.translatesAutoresizingMaskIntoConstraints = false return button }() @@ -115,7 +115,7 @@ final class TunnelControlView: UIView { backgroundColor = .clear directionalLayoutMargins = UIMetrics.contentLayoutMargins accessibilityContainerType = .semanticGroup - setAccessibilityIdentifier(.tunnelControlView) + setAccessibilityIdentifier(.connectionView) addSubviews() addButtonHandlers() diff --git a/ios/MullvadVPN/Views/MainButton.swift b/ios/MullvadVPN/Views/MainButton.swift index 679b34a2cd9d..a4240c433db6 100644 --- a/ios/MullvadVPN/Views/MainButton.swift +++ b/ios/MullvadVPN/Views/MainButton.swift @@ -11,7 +11,6 @@ import SwiftUI struct MainButton: View { var text: LocalizedStringKey var style: MainButtonStyle.Style - var disabled = false var action: () -> Void @@ -23,7 +22,7 @@ struct MainButton: View { Spacer() } }) - .buttonStyle(MainButtonStyle(style, disabled: disabled)) + .buttonStyle(MainButtonStyle(style)) .cornerRadius(UIMetrics.MainButton.cornerRadius) } } diff --git a/ios/MullvadVPN/Views/MainButtonStyle.swift b/ios/MullvadVPN/Views/MainButtonStyle.swift index f32a27fa068a..ceabd7761b10 100644 --- a/ios/MullvadVPN/Views/MainButtonStyle.swift +++ b/ios/MullvadVPN/Views/MainButtonStyle.swift @@ -10,7 +10,8 @@ import SwiftUI struct MainButtonStyle: ButtonStyle { var style: Style - @State var disabled: Bool + var disabled: Bool + @Environment(\.isEnabled) private var isEnabled: Bool init(_ style: Style, disabled: Bool = false) { self.style = style @@ -18,20 +19,19 @@ struct MainButtonStyle: ButtonStyle { } func makeBody(configuration: Configuration) -> some View { - configuration.label - .padding(.horizontal, 8) + return configuration.label .frame(height: 44) .foregroundColor( - disabled - ? UIColor.primaryTextColor.withAlphaComponent(0.2).color - : UIColor.primaryTextColor.color + isEnabled + ? UIColor.primaryTextColor.color + : UIColor.primaryTextColor.withAlphaComponent(0.2).color ) .background( - disabled - ? style.disabledColor - : configuration.isPressed + isEnabled + ? configuration.isPressed ? style.pressedColor : style.color + : style.disabledColor ) .font(.body.weight(.semibold)) } @@ -46,11 +46,11 @@ extension MainButtonStyle { var color: Color { switch self { case .default: - Color(UIColor.primaryColor) + UIColor.primaryColor.color case .danger: - Color(UIColor.dangerColor) + UIColor.dangerColor.color case .success: - Color(UIColor.successColor) + UIColor.successColor.color } } diff --git a/ios/MullvadVPN/Views/SplitMainButton.swift b/ios/MullvadVPN/Views/SplitMainButton.swift index 11336f424ba2..6f36b839befe 100644 --- a/ios/MullvadVPN/Views/SplitMainButton.swift +++ b/ios/MullvadVPN/Views/SplitMainButton.swift @@ -12,13 +12,12 @@ struct SplitMainButton: View { var text: LocalizedStringKey var image: ImageResource var style: MainButtonStyle.Style - var disabled = false + + @State private var secondaryButtonWidth: CGFloat = 0 var primaryAction: () -> Void var secondaryAction: () -> Void - @State private var width: CGFloat = 0 - var body: some View { HStack(spacing: 1) { Button(action: primaryAction, label: { @@ -27,18 +26,19 @@ struct SplitMainButton: View { Text(text) Spacer() } - .padding(.trailing, -width) + .padding(.trailing, -secondaryButtonWidth) }) Button(action: secondaryAction, label: { Image(image) .resizable() .scaledToFit() - .padding(4) + .frame(width: 24, height: 24) + .padding(10) }) .aspectRatio(1, contentMode: .fit) - .sizeOfView { width = $0.width } + .sizeOfView { secondaryButtonWidth = $0.width } } - .buttonStyle(MainButtonStyle(style, disabled: disabled)) + .buttonStyle(MainButtonStyle(style)) .cornerRadius(UIMetrics.MainButton.cornerRadius) } } diff --git a/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift b/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift index 6929ee1f5ac5..da47a2c33404 100644 --- a/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift +++ b/ios/MullvadVPNUITests/Pages/TunnelControlPage.swift @@ -74,7 +74,7 @@ class TunnelControlPage: Page { @discardableResult override init(_ app: XCUIApplication) { super.init(app) - self.pageElement = app.otherElements[.tunnelControlView] + self.pageElement = app.otherElements[.connectionView] waitForPageToBeShown() } @@ -84,7 +84,7 @@ class TunnelControlPage: Page { } @discardableResult func tapSecureConnectionButton() -> Self { - app.buttons[AccessibilityIdentifier.secureConnectionButton].tap() + app.buttons[AccessibilityIdentifier.connectButton].tap() return self } diff --git a/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift b/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift index 469bf0fa20d9..9fbc650d7027 100644 --- a/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift +++ b/ios/PacketTunnelCore/Actor/PacketTunnelActor.swift @@ -99,6 +99,7 @@ public actor PacketTunnelActor { } } + // swiftlint:disable:next function_body_length func executeEffect(_ effect: Effect) async { switch effect { case .startDefaultPathObserver: @@ -136,7 +137,6 @@ public actor PacketTunnelActor { state = .disconnected case let .configureForErrorState(reason): await setErrorStateInternal(with: reason) - case let .cacheActiveKey(lastKeyRotation): cacheActiveKey(lastKeyRotation: lastKeyRotation) case let .reconfigureForEphemeralPeer(configuration, configurationSemaphore): diff --git a/ios/convert-assets.rb b/ios/convert-assets.rb index 5419e6282d9e..637068cfe668 100755 --- a/ios/convert-assets.rb +++ b/ios/convert-assets.rb @@ -32,7 +32,6 @@ "icon-extLink.svg", "icon-fail.svg", "icon-info.svg", - "icon-reload.svg", "icon-settings.svg", "icon-spinner.svg", "icon-success.svg", From 164d189f21e1251417f3beb0c291d5623528ce3d Mon Sep 17 00:00:00 2001 From: Andrew Bulhak Date: Fri, 3 Jan 2025 16:56:55 +0100 Subject: [PATCH 3/4] Split ConnectionView, improve previews, attempt animation --- ios/MullvadVPN.xcodeproj/project.pbxproj | 42 ++- .../ChipView/ChipContainerView.swift | 22 +- .../FeatureIndicators/ConnectionView.swift | 283 ------------------ .../ConnectionView/ButtonPanel.swift | 90 ++++++ .../ConnectionView/ConnectionView.swift | 71 +++++ .../ConnectionViewViewModel.swift | 0 .../ConnectionView/DetailsContainer.swift | 61 ++++ .../ConnectionView/DetailsView.swift | 64 ++++ .../ConnectionView/HeaderView.swift | 67 +++++ .../ConnectionViewComponentPreview.swift | 69 +++++ .../Preview}/ConnectionViewPreview.swift | 6 +- 11 files changed, 470 insertions(+), 305 deletions(-) delete mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift rename ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/{ => ConnectionView}/ConnectionViewViewModel.swift (100%) create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift create mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewComponentPreview.swift rename ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/{ => ConnectionView/Preview}/ConnectionViewPreview.swift (87%) diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index d15e9a768017..9ba658df15a5 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -43,6 +43,9 @@ 44075DFB2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44075DFA2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift */; }; 440E5AB02CDBD67D00B09614 /* StatefulPreviewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440E5AAF2CDBD67D00B09614 /* StatefulPreviewWrapper.swift */; }; 440E5AB42CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */; }; + 4419AA892D282687001B13C9 /* DetailsContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4419AA882D282687001B13C9 /* DetailsContainer.swift */; }; + 4419AA8B2D2826E5001B13C9 /* DetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4419AA8A2D2826E5001B13C9 /* DetailsView.swift */; }; + 4419AA8E2D2828A4001B13C9 /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4419AA8D2D2828A4001B13C9 /* HeaderView.swift */; }; 4422C0712CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4422C0702CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift */; }; 4424CDD32CDBD4A6009D8C9F /* SingleChoiceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4424CDD22CDBD4A6009D8C9F /* SingleChoiceList.swift */; }; 447F3D8A2CDE1853006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 447F3D882CDE1852006E3462 /* ShadowsocksObfuscationSettingsViewModel.swift */; }; @@ -52,6 +55,8 @@ 4495ECD52D131A4800A7358B /* ShadowsocksObfuscationSettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4495ECD42D131A3E00A7358B /* ShadowsocksObfuscationSettingsPage.swift */; }; 449872E12B7BBC5400094DDC /* TunnelSettingsUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */; }; 449872E42B7CB96300094DDC /* TunnelSettingsUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449872E32B7CB96300094DDC /* TunnelSettingsUpdateTests.swift */; }; + 449E9A6D2D283A2500F8574A /* ConnectionViewComponentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449E9A6C2D283A2500F8574A /* ConnectionViewComponentPreview.swift */; }; + 449E9A6F2D283C7400F8574A /* ButtonPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449E9A6E2D283C7400F8574A /* ButtonPanel.swift */; }; 449EBA262B975B9700DFA4EB /* EphemeralPeerReceiving.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449EBA252B975B9700DFA4EB /* EphemeralPeerReceiving.swift */; }; 44B02E3B2BC5732D008EDF34 /* LoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44B02E3A2BC5732D008EDF34 /* LoggingTests.swift */; }; 44B02E3C2BC5B8A5008EDF34 /* Bundle+ProductVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5891BF1B25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift */; }; @@ -1443,6 +1448,9 @@ 44075DFA2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPOverTCPObfuscationSettingsViewModel.swift; sourceTree = ""; }; 440E5AAF2CDBD67D00B09614 /* StatefulPreviewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPreviewWrapper.swift; sourceTree = ""; }; 440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelObfuscationSettingsWatchingObservableObject.swift; sourceTree = ""; }; + 4419AA882D282687001B13C9 /* DetailsContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsContainer.swift; sourceTree = ""; }; + 4419AA8A2D2826E5001B13C9 /* DetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsView.swift; sourceTree = ""; }; + 4419AA8D2D2828A4001B13C9 /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = ""; }; 4422C0702CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPOverTCPObfuscationSettingsView.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 = ""; }; @@ -1453,6 +1461,8 @@ 4495ECD42D131A3E00A7358B /* ShadowsocksObfuscationSettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowsocksObfuscationSettingsPage.swift; sourceTree = ""; }; 449872E02B7BBC5400094DDC /* TunnelSettingsUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsUpdate.swift; sourceTree = ""; }; 449872E32B7CB96300094DDC /* TunnelSettingsUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsUpdateTests.swift; sourceTree = ""; }; + 449E9A6C2D283A2500F8574A /* ConnectionViewComponentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionViewComponentPreview.swift; sourceTree = ""; }; + 449E9A6E2D283C7400F8574A /* ButtonPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonPanel.swift; sourceTree = ""; }; 449EB9FC2B95F8AD00DFA4EB /* DeviceMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceMock.swift; sourceTree = ""; }; 449EB9FE2B95FF2500DFA4EB /* AccountMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMock.swift; sourceTree = ""; }; 449EBA252B975B9700DFA4EB /* EphemeralPeerReceiving.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EphemeralPeerReceiving.swift; sourceTree = ""; }; @@ -2694,6 +2704,20 @@ path = Protocols; sourceTree = ""; }; + 4419AA862D28264D001B13C9 /* ConnectionView */ = { + isa = PBXGroup; + children = ( + 449E9A6B2D2839FD00F8574A /* Preview */, + 7AA130982CFF365A00640DF9 /* ConnectionView.swift */, + 7A0EAEA32D06DF8200D3EB8B /* ConnectionViewViewModel.swift */, + 4419AA882D282687001B13C9 /* DetailsContainer.swift */, + 4419AA8A2D2826E5001B13C9 /* DetailsView.swift */, + 4419AA8D2D2828A4001B13C9 /* HeaderView.swift */, + 449E9A6E2D283C7400F8574A /* ButtonPanel.swift */, + ); + path = ConnectionView; + sourceTree = ""; + }; 4422C06F2CCFF6520001A385 /* Obfuscation */ = { isa = PBXGroup; children = ( @@ -2734,6 +2758,15 @@ path = MullvadSettings; sourceTree = ""; }; + 449E9A6B2D2839FD00F8574A /* Preview */ = { + isa = PBXGroup; + children = ( + 7AF84F472D12C9CF00C72690 /* ConnectionViewPreview.swift */, + 449E9A6C2D283A2500F8574A /* ConnectionViewComponentPreview.swift */, + ); + path = Preview; + sourceTree = ""; + }; 449EBA242B975B7C00DFA4EB /* Protocols */ = { isa = PBXGroup; children = ( @@ -4086,11 +4119,9 @@ 7AA130972CFF364F00640DF9 /* FeatureIndicators */ = { isa = PBXGroup; children = ( + 4419AA862D28264D001B13C9 /* ConnectionView */, F0ADF1CF2D01B50B00299F09 /* ChipView */, 7AFBE3862D084C96002335FC /* ActivityIndicator.swift */, - 7AA130982CFF365A00640DF9 /* ConnectionView.swift */, - 7AF84F472D12C9CF00C72690 /* ConnectionViewPreview.swift */, - 7A0EAEA32D06DF8200D3EB8B /* ConnectionViewViewModel.swift */, F0B4957B2D03154200CFEC2A /* FeatureIndicatorsView.swift */, F0ADF1D22D01B6B400299F09 /* FeatureIndicatorsViewModel.swift */, 7AFBE3882D08915D002335FC /* FI_TunnelViewController.swift */, @@ -5908,6 +5939,7 @@ 58421030282D8A3C00F24E46 /* UpdateAccountDataOperation.swift in Sources */, 7A8A190A2CE5FFE9000BCB5B /* SettingsDAITAView.swift in Sources */, F0E8E4C92A604E7400ED26A3 /* AccountDeletionInteractor.swift in Sources */, + 449E9A6D2D283A2500F8574A /* ConnectionViewComponentPreview.swift in Sources */, 7A5869952B32E9C700640D27 /* LinkButton.swift in Sources */, F09A297D2A9F8A9B00EA3B6F /* RedeemVoucherContentView.swift in Sources */, 5803B4B02940A47300C23744 /* TunnelConfiguration.swift in Sources */, @@ -6042,6 +6074,7 @@ 7A6F2FAB2AFD3097006D0856 /* CustomDNSCellFactory.swift in Sources */, 58A99ED3240014A0006599E9 /* TermsOfServiceViewController.swift in Sources */, 7A6000FE2B628E9F001CF0D9 /* ListCellContentView.swift in Sources */, + 4419AA8B2D2826E5001B13C9 /* DetailsView.swift in Sources */, 58CCA0162242560B004F3011 /* UIColor+Palette.swift in Sources */, 587CBFE322807F530028DED3 /* UIColor+Helpers.swift in Sources */, 7A9CCCBE2A96302800DD6A34 /* AccountDeletionCoordinator.swift in Sources */, @@ -6105,6 +6138,7 @@ 587EB66A270EFACB00123C75 /* CharacterSet+IPAddress.swift in Sources */, 5888AD83227B11080051EB06 /* LocationCell.swift in Sources */, 5891BF1C25E3E3EB006D6FB0 /* Bundle+ProductVersion.swift in Sources */, + 4419AA892D282687001B13C9 /* DetailsContainer.swift in Sources */, 5878A26F2907E7E00096FC88 /* ProblemReportInteractor.swift in Sources */, 7AB4CCBB2B691BBB006037F5 /* IPOverrideInteractor.swift in Sources */, 7A3353912AAA014400F0A71C /* SimulatorVPNConnection.swift in Sources */, @@ -6156,6 +6190,8 @@ 58CE5E64224146200008646E /* AppDelegate.swift in Sources */, F0DA87492A9CBA9F006044F1 /* AccountDeviceRow.swift in Sources */, 58FF9FE42B075BDD00E4C97D /* EditAccessMethodItemIdentifier.swift in Sources */, + 449E9A6F2D283C7400F8574A /* ButtonPanel.swift in Sources */, + 4419AA8E2D2828A4001B13C9 /* HeaderView.swift in Sources */, 5878A27329091D6D0096FC88 /* TunnelBlockObserver.swift in Sources */, 7A27E3D12CC299F90088BCFF /* VPNSettingsDetailsButtonItem.swift in Sources */, A9E034642ABB302000E59A5A /* UIEdgeInsets+Extensions.swift in Sources */, diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift index e64874ad22ad..333761fa30ab 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift @@ -86,18 +86,12 @@ struct ChipContainerView: View where ViewModel: ChipViewModelProtocol } } -#Preview("Normal") { - ChipContainerView( - viewModel: MockFeatureIndicatorsViewModel(), - isExpanded: .constant(false) - ) - .background(UIColor.secondaryColor.color) -} - -#Preview("Expanded") { - ChipContainerView( - viewModel: MockFeatureIndicatorsViewModel(), - isExpanded: .constant(true) - ) - .background(UIColor.secondaryColor.color) +#Preview("Tap to expand") { + StatefulPreviewWrapper(false) { isExpanded in + ChipContainerView( + viewModel: MockFeatureIndicatorsViewModel(), + isExpanded: isExpanded + ) + .background(UIColor.secondaryColor.color) + } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift deleted file mode 100644 index d066bb47f21d..000000000000 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView.swift +++ /dev/null @@ -1,283 +0,0 @@ -// -// ConnectionView.swift -// MullvadVPN -// -// Created by Jon Petersson on 2024-12-03. -// Copyright © 2024 Mullvad VPN AB. All rights reserved. -// - -import SwiftUI - -typealias ButtonAction = (ConnectionViewViewModel.TunnelAction) -> Void - -struct ConnectionView: View { - @StateObject var connectionViewModel: ConnectionViewViewModel - @StateObject var indicatorsViewModel: FeatureIndicatorsViewModel - - @State private(set) var isExpanded = false - - var action: ButtonAction? - var onContentUpdate: (() -> Void)? - - var body: some View { - Spacer() - VStack(spacing: 22) { - if connectionViewModel.showsActivityIndicator { - CustomProgressView(style: .large) - } - - ZStack { - BlurView(style: .dark) - - VStack(alignment: .leading, spacing: 16) { - ConnectionHeader(viewModel: connectionViewModel, isExpanded: $isExpanded) - - if connectionViewModel.showConnectionDetails { - ConnectionDetailsContainer( - viewModel: connectionViewModel, - indicatorsViewModel: indicatorsViewModel, - isExpanded: $isExpanded - ) - } - - ButtonPanel(viewModel: connectionViewModel, action: action) - } - .padding(16) - } - .cornerRadius(12) - .padding(16) - } - .padding(.bottom, 8) // Adding some spacing so as not to overlap with the map legal link. - .accessibilityIdentifier(AccessibilityIdentifier.connectionView.asString) - .onChange(of: isExpanded) { _ in - onContentUpdate?() - } - .onReceive(connectionViewModel.combinedState) { _, _ in - onContentUpdate?() - - if !connectionViewModel.showConnectionDetails { - isExpanded = false - } - } - } -} - -#Preview("ConnectionView (Normal)") { - ConnectionViewPreview(configuration: .normal).make() -} - -#Preview("ConnectionView (Normal, no indicators)") { - ConnectionViewPreview(configuration: .normalNoIndicators).make() -} - -#Preview("ConnectionView (Expanded)") { - ConnectionViewPreview(configuration: .expanded).make() -} - -#Preview("ConnectionView (Expanded, no indicators)") { - ConnectionViewPreview(configuration: .expandedNoIndicators).make() -} - -private struct ConnectionHeader: View { - @StateObject var viewModel: ConnectionViewViewModel - @Binding var isExpanded: Bool - - var body: some View { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 0) { - Text(viewModel.localizedTitleForSecureLabel) - .textCase(.uppercase) - .font(.title3.weight(.semibold)) - .foregroundStyle(viewModel.textColorForSecureLabel.color) - .accessibilityIdentifier(viewModel.accessibilityIdForSecureLabel.asString) - - if let countryAndCity = viewModel.titleForCountryAndCity { - Text(countryAndCity) - .font(.title3.weight(.semibold)) - .foregroundStyle(UIColor.primaryTextColor.color) - .padding(.top, 4) - } - - if let server = viewModel.titleForServer { - Text(server) - .font(.body) - .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) - .padding(.top, 2) - } - } - .accessibilityLabel(viewModel.localizedAccessibilityLabelForSecureLabel) - - if viewModel.showConnectionDetails { - Spacer() - Image(.iconChevron) - .renderingMode(.template) - .rotationEffect(isExpanded ? .degrees(-90) : .degrees(90)) - .foregroundStyle(.white) - .transaction { transaction in - transaction.animation = nil - } - } - } - .accessibilityIdentifier(AccessibilityIdentifier.relayStatusCollapseButton.asString) - .contentShape(Rectangle()) - .onTapGesture { - isExpanded.toggle() - } - } -} - -private struct ConnectionDetailsContainer: View { - @StateObject var viewModel: ConnectionViewViewModel - @StateObject var indicatorsViewModel: FeatureIndicatorsViewModel - @Binding var isExpanded: Bool - - @State private var scrollViewHeight: CGFloat = 0 - - var body: some View { - if isExpanded { - Divider() - .background(UIColor.secondaryTextColor.color) - } - - // This geometry reader is somewhat of a workaround. It's "smart" in that it takes up as much - // space as it can and thereby helps the view to understand the maximum allowed height when - // placed in a UIKit context. If ConnectionView would ever be placed as a subview of SwiftUI - // parent, this reader could probably be removed. - GeometryReader { _ in - ScrollView { - VStack(spacing: 16) { - if !indicatorsViewModel.chips.isEmpty { - FeatureIndicatorsView( - viewModel: indicatorsViewModel, - isExpanded: $isExpanded - ) - } - - if isExpanded { - ConnectionDetails(viewModel: viewModel) - } - } - .sizeOfView { scrollViewHeight = $0.height } - } - } - .frame(maxHeight: scrollViewHeight) - } -} - -private struct ConnectionDetails: View { - @StateObject var viewModel: ConnectionViewViewModel - @State private var columnWidth: CGFloat = 0 - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(LocalizedStringKey("Connection details")) - .font(.footnote.weight(.semibold)) - .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) - Spacer() - } - - VStack(alignment: .leading, spacing: 0) { - if let inAddress = viewModel.inAddress { - connectionDetailRow(title: LocalizedStringKey("In"), value: inAddress) - .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelInAddressRow.asString) - } - if viewModel.tunnelIsConnected { - if let outAddressIpv4 = viewModel.outAddressIpv4 { - connectionDetailRow(title: LocalizedStringKey("Out IPv4"), value: outAddressIpv4) - .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelOutAddressRow.asString) - } - if let outAddressIpv6 = viewModel.outAddressIpv6 { - connectionDetailRow(title: LocalizedStringKey("Out IPv6"), value: outAddressIpv6) - .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelOutAddressRow.asString) - } - } - } - } - } - - @ViewBuilder - private func connectionDetailRow(title: LocalizedStringKey, value: String) -> some View { - HStack(alignment: .top, spacing: 8) { - Text(title) - .font(.subheadline) - .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) - .frame(minWidth: columnWidth, alignment: .leading) - .sizeOfView { columnWidth = max(columnWidth, $0.width) } - Text(value) - .font(.subheadline) - .foregroundStyle(UIColor.primaryTextColor.color) - } - } -} - -private struct ButtonPanel: View { - @StateObject var viewModel: ConnectionViewViewModel - var action: ButtonAction? - - var body: some View { - VStack(spacing: 16) { - locationButton(with: action) - .disabled(viewModel.disableButtons) - actionButton(with: action) - .disabled(viewModel.disableButtons) - } - } - - @ViewBuilder - private func locationButton(with action: ButtonAction?) -> some View { - switch viewModel.tunnelStatus.state { - case .connecting, .connected, .reconnecting, .waitingForConnectivity, .negotiatingEphemeralPeer, .error: - SplitMainButton( - text: viewModel.localizedTitleForSelectLocationButton, - image: .iconReload, - style: .default, - primaryAction: { action?(.selectLocation) }, - secondaryAction: { action?(.reconnect) } - ) - .accessibilityIdentifier(AccessibilityIdentifier.selectLocationButton.asString) - case .disconnecting, .pendingReconnect, .disconnected: - MainButton( - text: viewModel.localizedTitleForSelectLocationButton, - style: .default, - action: { action?(.selectLocation) } - ) - .accessibilityIdentifier(AccessibilityIdentifier.selectLocationButton.asString) - } - } - - @ViewBuilder - private func actionButton(with action: ButtonAction?) -> some View { - switch viewModel.actionButton { - case .connect: - MainButton( - text: LocalizedStringKey("Connect"), - style: .success, - action: { action?(.connect) } - ) - .accessibilityIdentifier(AccessibilityIdentifier.connectButton.asString) - case .disconnect: - MainButton( - text: LocalizedStringKey("Disconnect"), - style: .danger, - action: { action?(.disconnect) } - ) - .accessibilityIdentifier(AccessibilityIdentifier.disconnectButton.asString) - case .cancel: - MainButton( - text: LocalizedStringKey( - viewModel.tunnelStatus.state == .waitingForConnectivity(.noConnection) - ? "Disconnect" - : "Cancel" - ), - style: .danger, - action: { action?(.cancel) } - ) - .accessibilityIdentifier( - viewModel.tunnelStatus.state == .waitingForConnectivity(.noConnection) - ? AccessibilityIdentifier.disconnectButton.asString - : AccessibilityIdentifier.cancelButton.asString - ) - } - } -} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift new file mode 100644 index 000000000000..bfbe4370418d --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift @@ -0,0 +1,90 @@ +// +// ButtonPanel.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension ConnectionView { + internal struct ButtonPanel: View { + typealias Action = (ConnectionViewViewModel.TunnelAction) -> Void + + @StateObject var viewModel: ConnectionViewViewModel + var action: Action? + + var body: some View { + VStack(spacing: 16) { + locationButton(with: action) + .disabled(viewModel.disableButtons) + actionButton(with: action) + .disabled(viewModel.disableButtons) + } + } + + @ViewBuilder + private func locationButton(with action: Action?) -> some View { + switch viewModel.tunnelStatus.state { + case .connecting, .connected, .reconnecting, .waitingForConnectivity, .negotiatingEphemeralPeer, .error: + SplitMainButton( + text: viewModel.localizedTitleForSelectLocationButton, + image: .iconReload, + style: .default, + primaryAction: { action?(.selectLocation) }, + secondaryAction: { action?(.reconnect) } + ) + .accessibilityIdentifier(AccessibilityIdentifier.selectLocationButton.asString) + case .disconnecting, .pendingReconnect, .disconnected: + MainButton( + text: viewModel.localizedTitleForSelectLocationButton, + style: .default, + action: { action?(.selectLocation) } + ) + .accessibilityIdentifier(AccessibilityIdentifier.selectLocationButton.asString) + } + } + + @ViewBuilder + private func actionButton(with action: Action?) -> some View { + switch viewModel.actionButton { + case .connect: + MainButton( + text: LocalizedStringKey("Connect"), + style: .success, + action: { action?(.connect) } + ) + .accessibilityIdentifier(AccessibilityIdentifier.connectButton.asString) + case .disconnect: + MainButton( + text: LocalizedStringKey("Disconnect"), + style: .danger, + action: { action?(.disconnect) } + ) + .accessibilityIdentifier(AccessibilityIdentifier.disconnectButton.asString) + case .cancel: + MainButton( + text: LocalizedStringKey( + viewModel.tunnelStatus.state == .waitingForConnectivity(.noConnection) + ? "Disconnect" + : "Cancel" + ), + style: .danger, + action: { action?(.cancel) } + ) + .accessibilityIdentifier( + viewModel.tunnelStatus.state == .waitingForConnectivity(.noConnection) + ? AccessibilityIdentifier.disconnectButton.asString + : AccessibilityIdentifier.cancelButton.asString + ) + } + } + } +} + +#Preview { + ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { _, vm, _ in + ConnectionView.ButtonPanel(viewModel: vm, action: nil) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift new file mode 100644 index 000000000000..929e0b17a3f2 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift @@ -0,0 +1,71 @@ +// +// ConnectionView.swift +// MullvadVPN +// +// Created by Jon Petersson on 2024-12-03. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +struct ConnectionView: View { + @StateObject var connectionViewModel: ConnectionViewViewModel + @StateObject var indicatorsViewModel: FeatureIndicatorsViewModel + + @State private(set) var isExpanded = false + + var action: ButtonPanel.Action? + var onContentUpdate: (() -> Void)? + + var body: some View { + Spacer() + VStack(spacing: 22) { + if connectionViewModel.showsActivityIndicator { + CustomProgressView(style: .large) + } + + ZStack { + BlurView(style: .dark) + + VStack(alignment: .leading, spacing: 16) { + HeaderView(viewModel: connectionViewModel, isExpanded: $isExpanded) + + if connectionViewModel.showConnectionDetails { + DetailsContainer( + viewModel: connectionViewModel, + indicatorsViewModel: indicatorsViewModel, + isExpanded: $isExpanded + ) + } + + ButtonPanel(viewModel: connectionViewModel, action: action) + } + .padding(16) + } + .cornerRadius(12) + .padding(16) + } + .padding(.bottom, 8) // Adding some spacing so as not to overlap with the map legal link. + .accessibilityIdentifier(AccessibilityIdentifier.connectionView.asString) + .onChange(of: isExpanded) { _ in + onContentUpdate?() + } + .onReceive(connectionViewModel.combinedState) { _, _ in + onContentUpdate?() + + if !connectionViewModel.showConnectionDetails { +// withAnimation { + isExpanded = false +// } + } + } + } +} + +#Preview("ConnectionView (Indicators)") { + ConnectionViewPreview(configuration: .normal).make() +} + +#Preview("ConnectionView (No indicators)") { + ConnectionViewPreview(configuration: .normalNoIndicators).make() +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewViewModel.swift similarity index 100% rename from ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewViewModel.swift rename to ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewViewModel.swift diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift new file mode 100644 index 000000000000..7da9aef5b8f6 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift @@ -0,0 +1,61 @@ +// +// ConnectionDetailsContainer.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension ConnectionView { + internal struct DetailsContainer: View { + @StateObject var viewModel: ConnectionViewViewModel + @StateObject var indicatorsViewModel: FeatureIndicatorsViewModel + @Binding var isExpanded: Bool + + @State private var scrollViewHeight: CGFloat = 0 + + var body: some View { +// if isExpanded { + Divider() + .background(UIColor.secondaryTextColor.color) + .opacity(isExpanded ? 1.0 : 0.0) +// } + + // This geometry reader is somewhat of a workaround. It's "smart" in that it takes up as much + // space as it can and thereby helps the view to understand the maximum allowed height when + // placed in a UIKit context. If ConnectionView would ever be placed as a subview of SwiftUI + // parent, this reader could probably be removed. + GeometryReader { _ in + ScrollView { + VStack(spacing: 16) { + if !indicatorsViewModel.chips.isEmpty { + FeatureIndicatorsView( + viewModel: indicatorsViewModel, + isExpanded: $isExpanded + ) + } + + if isExpanded { + DetailsView(viewModel: viewModel) + .transition(.move(edge: .bottom)) + } + } + .sizeOfView { scrollViewHeight = $0.height } + } + } + .frame(maxHeight: scrollViewHeight) + } + } +} + +#Preview { + ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { indicatorModel, viewModel, isExpanded in + ConnectionView.DetailsContainer( + viewModel: viewModel, + indicatorsViewModel: indicatorModel, + isExpanded: isExpanded + ) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift new file mode 100644 index 000000000000..25559757a1ce --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift @@ -0,0 +1,64 @@ +// +// ConnectionDetails.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension ConnectionView { + internal struct DetailsView: View { + @StateObject var viewModel: ConnectionViewViewModel + @State private var columnWidth: CGFloat = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(LocalizedStringKey("Connection details")) + .font(.footnote.weight(.semibold)) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + Spacer() + } + + VStack(alignment: .leading, spacing: 0) { + if let inAddress = viewModel.inAddress { + connectionDetailRow(title: LocalizedStringKey("In"), value: inAddress) + .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelInAddressRow.asString) + } + if viewModel.tunnelIsConnected { + if let outAddressIpv4 = viewModel.outAddressIpv4 { + connectionDetailRow(title: LocalizedStringKey("Out IPv4"), value: outAddressIpv4) + .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelOutAddressRow.asString) + } + if let outAddressIpv6 = viewModel.outAddressIpv6 { + connectionDetailRow(title: LocalizedStringKey("Out IPv6"), value: outAddressIpv6) + .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelOutAddressRow.asString) + } + } + } + } + } + + @ViewBuilder + private func connectionDetailRow(title: LocalizedStringKey, value: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text(title) + .font(.subheadline) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + .frame(minWidth: columnWidth, alignment: .leading) + .sizeOfView { columnWidth = max(columnWidth, $0.width) } + Text(value) + .font(.subheadline) + .foregroundStyle(UIColor.primaryTextColor.color) + } + } + } +} + +#Preview { + ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { _, vm, _ in + ConnectionView.DetailsView(viewModel: vm) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift new file mode 100644 index 000000000000..5ec0eb9316cb --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift @@ -0,0 +1,67 @@ +// +// HeaderView.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension ConnectionView { + internal struct HeaderView: View { + @StateObject var viewModel: ConnectionViewViewModel + @Binding var isExpanded: Bool + + var body: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 0) { + Text(viewModel.localizedTitleForSecureLabel) + .textCase(.uppercase) + .font(.title3.weight(.semibold)) + .foregroundStyle(viewModel.textColorForSecureLabel.color) + .accessibilityIdentifier(viewModel.accessibilityIdForSecureLabel.asString) + + if let countryAndCity = viewModel.titleForCountryAndCity { + Text(countryAndCity) + .font(.title3.weight(.semibold)) + .foregroundStyle(UIColor.primaryTextColor.color) + .padding(.top, 4) + } + + if let server = viewModel.titleForServer { + Text(server) + .font(.body) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + .padding(.top, 2) + } + } + .accessibilityLabel(viewModel.localizedAccessibilityLabelForSecureLabel) + + if viewModel.showConnectionDetails { + Spacer() + Image(.iconChevron) + .renderingMode(.template) + .rotationEffect(isExpanded ? .degrees(-90) : .degrees(90)) + .foregroundStyle(.white) + .transaction { transaction in + transaction.animation = nil + } + } + } + .accessibilityIdentifier(AccessibilityIdentifier.relayStatusCollapseButton.asString) + .contentShape(Rectangle()) + .onTapGesture { +// withAnimation { + isExpanded.toggle() +// } + } + } + } +} + +#Preview { + ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { _, vm, isExpanded in + ConnectionView.HeaderView(viewModel: vm, isExpanded: isExpanded) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewComponentPreview.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewComponentPreview.swift new file mode 100644 index 000000000000..89b3fb0aaf17 --- /dev/null +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewComponentPreview.swift @@ -0,0 +1,69 @@ +// +// ConnectionViewComponentPreview.swift +// MullvadVPN +// +// Created by Andrew Bulhak on 2025-01-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import MullvadMockData +import MullvadSettings +import MullvadTypes +import PacketTunnelCore +import SwiftUI + +struct ConnectionViewComponentPreview: View { + let showIndicators: Bool + + private var tunnelSettings: LatestTunnelSettings { + LatestTunnelSettings( + wireGuardObfuscation: WireGuardObfuscationSettings(state: showIndicators ? .udpOverTcp : .off), + tunnelQuantumResistance: showIndicators ? .on : .off, + tunnelMultihopState: showIndicators ? .on : .off, + daita: DAITASettings(daitaState: showIndicators ? .on : .off) + ) + } + + private let viewModel = ConnectionViewViewModel( + tunnelStatus: TunnelStatus( + observedState: .connected(ObservedConnectionState( + selectedRelays: SelectedRelaysStub.selectedRelays, + relayConstraints: RelayConstraints(entryLocations: .any, exitLocations: .any, port: .any, filter: .any), + networkReachability: .reachable, + connectionAttemptCount: 0, + transportLayer: .udp, + remotePort: 80, + isPostQuantum: true, + isDaitaEnabled: true + )), + state: .connected(SelectedRelaysStub.selectedRelays, isPostQuantum: true, isDaita: true) + ) + ) + + var content: (FeatureIndicatorsViewModel, ConnectionViewViewModel, Binding) -> Content + + @State var isExpanded: Bool + + init( + showIndicators: Bool, + isExpanded: Bool, + content: @escaping (FeatureIndicatorsViewModel, ConnectionViewViewModel, Binding) -> Content + ) { + self.showIndicators = showIndicators + self._isExpanded = State(wrappedValue: isExpanded) + self.content = content + } + + var body: some View { + VStack { + content( + FeatureIndicatorsViewModel( + tunnelSettings: tunnelSettings, + ipOverrides: [] + ), + viewModel, + $isExpanded + ) + }.background(UIColor.secondaryColor.color) + } +} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewPreview.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewPreview.swift similarity index 87% rename from ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewPreview.swift rename to ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewPreview.swift index 0575d4658fde..4f2fd49a924a 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionViewPreview.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewPreview.swift @@ -14,7 +14,7 @@ import SwiftUI struct ConnectionViewPreview { enum Configuration { - case normal, normalNoIndicators, expanded, expandedNoIndicators + case normal, normalNoIndicators } private let configuration: Configuration @@ -54,10 +54,6 @@ struct ConnectionViewPreview { connectionView(with: populatedTunnelSettings, viewModel: viewModel) case .normalNoIndicators: connectionView(with: LatestTunnelSettings(), viewModel: viewModel) - case .expanded: - connectionView(with: populatedTunnelSettings, viewModel: viewModel, isExpanded: true) - case .expandedNoIndicators: - connectionView(with: LatestTunnelSettings(), viewModel: viewModel, isExpanded: true) } } .background(UIColor.secondaryColor.color) From d81b0a7d13a0a271fb6e4b0130c6ad8aacfa4a9a Mon Sep 17 00:00:00 2001 From: Jon Petersson Date: Tue, 7 Jan 2025 09:48:29 +0100 Subject: [PATCH 4/4] Fix multiple small issues in ConnectionView --- ios/MullvadVPN.xcodeproj/project.pbxproj | 22 ++---- .../Extensions/View+Conditionals.swift | 41 ++++++++++ .../Extensions/View+TapAreaSize.swift | 1 - .../ConnectionView/ButtonPanel.swift | 4 +- .../ChipView/ChipContainerView.swift | 14 ++-- .../ChipView/ChipFeature.swift | 4 + .../ChipView/ChipModel.swift | 0 .../ChipView/ChipView.swift | 2 +- .../ChipView/ChipViewModelProtocol.swift | 30 ++++---- .../ConnectionView/ConnectionView.swift | 60 ++++++++++----- .../ConnectionViewComponentPreview.swift | 19 +++-- .../ConnectionViewViewModel.swift | 9 ++- .../ConnectionView/DetailsContainer.swift | 46 +++++------ .../ConnectionView/DetailsView.swift | 4 +- .../ConnectionView/HeaderView.swift | 12 +-- .../Preview/ConnectionViewPreview.swift | 77 ------------------- .../FI_TunnelViewController.swift | 2 +- .../FeatureIndicatorsView.swift | 12 ++- .../Tunnel/TunnelViewController.swift | 2 +- .../TunnelViewControllerInteractor.swift | 4 +- ios/MullvadVPN/Views/MainButtonStyle.swift | 4 +- ios/MullvadVPN/Views/SplitMainButton.swift | 5 ++ 22 files changed, 172 insertions(+), 202 deletions(-) create mode 100644 ios/MullvadVPN/Extensions/View+Conditionals.swift rename ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/{ => ConnectionView}/ChipView/ChipContainerView.swift (87%) rename ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/{ => ConnectionView}/ChipView/ChipFeature.swift (93%) rename ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/{ => ConnectionView}/ChipView/ChipModel.swift (100%) rename ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/{ => ConnectionView}/ChipView/ChipView.swift (96%) rename ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/{ => ConnectionView}/ChipView/ChipViewModelProtocol.swift (67%) rename ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/{Preview => }/ConnectionViewComponentPreview.swift (86%) delete mode 100644 ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewPreview.swift diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 9ba658df15a5..e1cda675d735 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -621,6 +621,7 @@ 7AA1309F2D007B2500640DF9 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA1309E2D007B2500640DF9 /* VisualEffectView.swift */; }; 7AA130A12D01B1E200640DF9 /* SplitMainButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA130A02D01B1E200640DF9 /* SplitMainButton.swift */; }; 7AA513862BC91C6B00D081A4 /* LogRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */; }; + 7AA636382D2D3BB0009B2C89 /* View+Conditionals.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA636372D2D3BAC009B2C89 /* View+Conditionals.swift */; }; 7AA7046A2C8EFE2B0045699D /* StoredRelays.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA704682C8EFE050045699D /* StoredRelays.swift */; }; 7AB2B6702BA1EB8C00B03E3B /* ListCustomListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */; }; 7AB2B6712BA1EB8C00B03E3B /* ListCustomListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */; }; @@ -661,7 +662,6 @@ 7AF36A9A2CA2964200E1D497 /* AnyIPAddressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF36A992CA2964000E1D497 /* AnyIPAddressTests.swift */; }; 7AF6E5F02A95051E00F2679D /* RouterBlockDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF6E5EF2A95051E00F2679D /* RouterBlockDelegate.swift */; }; 7AF84F462D12C5B000C72690 /* SelectedRelaysStub+Stubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF84F452D12C59F00C72690 /* SelectedRelaysStub+Stubs.swift */; }; - 7AF84F482D12C9D400C72690 /* ConnectionViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF84F472D12C9CF00C72690 /* ConnectionViewPreview.swift */; }; 7AF9BE882A30C62100DBFEDB /* SelectableSettingsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A264A2A29D65E00B978AA /* SelectableSettingsCell.swift */; }; 7AF9BE8C2A321D1F00DBFEDB /* RelayFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8A2A321BEF00DBFEDB /* RelayFilter.swift */; }; 7AF9BE8E2A331C7B00DBFEDB /* RelayFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */; }; @@ -2008,6 +2008,7 @@ 7AA1309E2D007B2500640DF9 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; 7AA130A02D01B1E200640DF9 /* SplitMainButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitMainButton.swift; sourceTree = ""; }; 7AA513852BC91C6B00D081A4 /* LogRotationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRotationTests.swift; sourceTree = ""; }; + 7AA636372D2D3BAC009B2C89 /* View+Conditionals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Conditionals.swift"; sourceTree = ""; }; 7AA704682C8EFE050045699D /* StoredRelays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredRelays.swift; sourceTree = ""; }; 7AB2B66E2BA1EB8C00B03E3B /* ListCustomListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListCustomListViewController.swift; sourceTree = ""; }; 7AB2B66F2BA1EB8C00B03E3B /* ListCustomListCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListCustomListCoordinator.swift; sourceTree = ""; }; @@ -2041,7 +2042,6 @@ 7AF36A992CA2964000E1D497 /* AnyIPAddressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyIPAddressTests.swift; sourceTree = ""; }; 7AF6E5EF2A95051E00F2679D /* RouterBlockDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterBlockDelegate.swift; sourceTree = ""; }; 7AF84F452D12C59F00C72690 /* SelectedRelaysStub+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SelectedRelaysStub+Stubs.swift"; sourceTree = ""; }; - 7AF84F472D12C9CF00C72690 /* ConnectionViewPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionViewPreview.swift; sourceTree = ""; }; 7AF9BE8A2A321BEF00DBFEDB /* RelayFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilter.swift; sourceTree = ""; }; 7AF9BE8D2A331C7B00DBFEDB /* RelayFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayFilterViewModel.swift; sourceTree = ""; }; 7AF9BE8F2A39F26000DBFEDB /* Collection+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Sorting.swift"; sourceTree = ""; }; @@ -2707,13 +2707,14 @@ 4419AA862D28264D001B13C9 /* ConnectionView */ = { isa = PBXGroup; children = ( - 449E9A6B2D2839FD00F8574A /* Preview */, + F0ADF1CF2D01B50B00299F09 /* ChipView */, + 449E9A6E2D283C7400F8574A /* ButtonPanel.swift */, 7AA130982CFF365A00640DF9 /* ConnectionView.swift */, + 449E9A6C2D283A2500F8574A /* ConnectionViewComponentPreview.swift */, 7A0EAEA32D06DF8200D3EB8B /* ConnectionViewViewModel.swift */, 4419AA882D282687001B13C9 /* DetailsContainer.swift */, 4419AA8A2D2826E5001B13C9 /* DetailsView.swift */, 4419AA8D2D2828A4001B13C9 /* HeaderView.swift */, - 449E9A6E2D283C7400F8574A /* ButtonPanel.swift */, ); path = ConnectionView; sourceTree = ""; @@ -2758,15 +2759,6 @@ path = MullvadSettings; sourceTree = ""; }; - 449E9A6B2D2839FD00F8574A /* Preview */ = { - isa = PBXGroup; - children = ( - 7AF84F472D12C9CF00C72690 /* ConnectionViewPreview.swift */, - 449E9A6C2D283A2500F8574A /* ConnectionViewComponentPreview.swift */, - ); - path = Preview; - sourceTree = ""; - }; 449EBA242B975B7C00DFA4EB /* Protocols */ = { isa = PBXGroup; children = ( @@ -3182,6 +3174,7 @@ 7A21DACE2A30AA3700A787A9 /* UITextField+Appearance.swift */, 5878F4FF29CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift */, 7A516C2D2B6D357500BBD33D /* URL+Scoping.swift */, + 7AA636372D2D3BAC009B2C89 /* View+Conditionals.swift */, 7A0EAE9D2D01BCBF00D3EB8B /* View+Size.swift */, 7A8A18FA2CE4B66C000BCB5B /* View+TapAreaSize.swift */, ); @@ -4120,7 +4113,6 @@ isa = PBXGroup; children = ( 4419AA862D28264D001B13C9 /* ConnectionView */, - F0ADF1CF2D01B50B00299F09 /* ChipView */, 7AFBE3862D084C96002335FC /* ActivityIndicator.swift */, F0B4957B2D03154200CFEC2A /* FeatureIndicatorsView.swift */, F0ADF1D22D01B6B400299F09 /* FeatureIndicatorsViewModel.swift */, @@ -5958,6 +5950,7 @@ F01DAE332C2B032A00521E46 /* RelaySelection.swift in Sources */, 58B993B12608A34500BA7811 /* LoginContentView.swift in Sources */, 7A516C2E2B6D357500BBD33D /* URL+Scoping.swift in Sources */, + 7AA636382D2D3BB0009B2C89 /* View+Conditionals.swift in Sources */, 5878A27529093A310096FC88 /* StorePaymentEvent.swift in Sources */, 7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */, F062000C2CB7EB5D002E6DB9 /* UIImage+Helpers.swift in Sources */, @@ -5977,7 +5970,6 @@ 7A27E3CB2CAE861D0088BCFF /* SettingsViewModel.swift in Sources */, 588527B2276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift in Sources */, 7A9F29392CABFAFC005F2089 /* InfoHeaderView.swift in Sources */, - 7AF84F482D12C9D400C72690 /* ConnectionViewPreview.swift in Sources */, 58DFF7D22B0256A300F864E0 /* MarkdownStylingOptions.swift in Sources */, 5867770E29096984006F721F /* OutOfTimeInteractor.swift in Sources */, F03580252A13842C00E5DAFD /* IncreasedHitButton.swift in Sources */, diff --git a/ios/MullvadVPN/Extensions/View+Conditionals.swift b/ios/MullvadVPN/Extensions/View+Conditionals.swift new file mode 100644 index 000000000000..39e4405da8e6 --- /dev/null +++ b/ios/MullvadVPN/Extensions/View+Conditionals.swift @@ -0,0 +1,41 @@ +// +// View+Conditionals.swift +// MullvadVPN +// +// Created by Jon Petersson on 2025-01-07. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +extension View { + @ViewBuilder func `if`( + _ conditional: Bool, + @ViewBuilder _ content: (Self) -> Content + ) -> some View { + if conditional { + content(self) + } else { + self + } + } + + @ViewBuilder func ifLet( + _ conditional: T?, + @ViewBuilder _ content: (Self, _ value: T) -> Content + ) -> some View { + if let value = conditional { + content(self, value) + } else { + self + } + } + + @ViewBuilder func showIf(_ conditional: Bool) -> some View { + if conditional { + self + } else { + EmptyView() + } + } +} diff --git a/ios/MullvadVPN/Extensions/View+TapAreaSize.swift b/ios/MullvadVPN/Extensions/View+TapAreaSize.swift index bf81cd57be35..c9ab7ee14422 100644 --- a/ios/MullvadVPN/Extensions/View+TapAreaSize.swift +++ b/ios/MullvadVPN/Extensions/View+TapAreaSize.swift @@ -28,6 +28,5 @@ private struct TappablePadding: ViewModifier { height: max(actualViewSize.height, tappableViewSize.height) ) .contentShape(Rectangle()) - .frame(width: actualViewSize.width, height: actualViewSize.height) } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift index bfbe4370418d..515912004628 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ButtonPanel.swift @@ -12,7 +12,7 @@ extension ConnectionView { internal struct ButtonPanel: View { typealias Action = (ConnectionViewViewModel.TunnelAction) -> Void - @StateObject var viewModel: ConnectionViewViewModel + @ObservedObject var viewModel: ConnectionViewViewModel var action: Action? var body: some View { @@ -32,10 +32,10 @@ extension ConnectionView { text: viewModel.localizedTitleForSelectLocationButton, image: .iconReload, style: .default, + accessibilityId: .selectLocationButton, primaryAction: { action?(.selectLocation) }, secondaryAction: { action?(.reconnect) } ) - .accessibilityIdentifier(AccessibilityIdentifier.selectLocationButton.asString) case .disconnecting, .pendingReconnect, .disconnected: MainButton( text: viewModel.localizedTitleForSelectLocationButton, diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipContainerView.swift similarity index 87% rename from ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift rename to ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipContainerView.swift index 333761fa30ab..0d417542f35c 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipContainerView.swift @@ -30,15 +30,13 @@ struct ChipContainerView: View where ViewModel: ChipViewModelProtocol createChipViews(chips: chipsToAdd, containerWidth: containerWidth) } - if showMoreButton { - Text(LocalizedStringKey("\(viewModel.chips.count - chipsToAdd.count) more...")) - .font(.subheadline) - .lineLimit(1) - .foregroundStyle(UIColor.primaryTextColor.color) - .onTapGesture { - isExpanded.toggle() - } + Button(LocalizedStringKey("\(viewModel.chips.count - chipsToAdd.count) more...")) { + isExpanded.toggle() } + .font(.subheadline) + .lineLimit(1) + .foregroundStyle(UIColor.primaryTextColor.color) + .showIf(showMoreButton) Spacer() } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipFeature.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipFeature.swift similarity index 93% rename from ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipFeature.swift rename to ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipFeature.swift index ea1d130dbede..a661a2cefc23 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipFeature.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipFeature.swift @@ -8,6 +8,10 @@ import MullvadSettings import SwiftUI +// Opting to use NSLocalizedString instead of LocalizedStringKey here in order +// to be able to fetch the string value at a later point (eg. in ChipViewModelProtocol, +// when calculating the text widths of the chips). + protocol ChipFeature { var isEnabled: Bool { get } var name: String { get } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipModel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipModel.swift similarity index 100% rename from ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipModel.swift rename to ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipModel.swift diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipView.swift similarity index 96% rename from ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipView.swift rename to ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipView.swift index 1c6a5eb52267..57fc7cb042d3 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipView.swift @@ -11,7 +11,7 @@ import SwiftUI struct ChipView: View { let item: ChipModel var body: some View { - Text(LocalizedStringKey(item.name)) + Text(item.name) .font(.subheadline) .lineLimit(1) .foregroundStyle(UIColor.primaryTextColor.color) diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipViewModelProtocol.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipViewModelProtocol.swift similarity index 67% rename from ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipViewModelProtocol.swift rename to ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipViewModelProtocol.swift index fabec7f8c9e9..3a4c9da337da 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipViewModelProtocol.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ChipView/ChipViewModelProtocol.swift @@ -13,12 +13,19 @@ protocol ChipViewModelProtocol: ObservableObject { } extension ChipViewModelProtocol { - func chipsToAdd(forContainerWidth containerWidth: CGFloat) -> (chips: [ChipModel], chipsWillOverflow: Bool) { + func chipsToAdd(forContainerWidth containerWidth: CGFloat) -> (chips: [ChipModel], isOverflowing: Bool) { var chipsToAdd = [ChipModel]() - var chipsWillOverflow = false + var isOverflowing = false - let moreTextWidth = "\(chips.count) more..." - .width(using: .preferredFont(forTextStyle: .subheadline)) + 4 // Some extra to be safe. + let moreTextWidth = String( + format: NSLocalizedString( + "CONNECTION_VIEW_CHIPS_MORE", + tableName: "ConnectionView", + value: "@d more...", + comment: "" + ), arguments: [chips.count] + ) + .width(using: .preferredFont(forTextStyle: .subheadline)) + 4 // Some extra to be safe. var totalChipsWidth: CGFloat = 0 for (index, chip) in chips.enumerated() { @@ -33,20 +40,15 @@ extension ChipViewModelProtocol { let chipWillFitWithMoreText = (totalChipsWidth + moreTextWidth) <= containerWidth let chipWillFit = totalChipsWidth <= containerWidth - if chipWillFitWithMoreText { - // If a chip can fit together with the "more" text, add it. - chipsToAdd.append(chip) - chipsWillOverflow = !isLastChip - } else if chipWillFit && isLastChip { - // If a chip can fit and it's the last one, add it. - chipsToAdd.append(chip) - chipsWillOverflow = false - } else { + guard (chipWillFit && isLastChip) || chipWillFitWithMoreText else { + isOverflowing = true break } + + chipsToAdd.append(chip) } - return (chipsToAdd, chipsWillOverflow) + return (chipsToAdd, isOverflowing) } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift index 929e0b17a3f2..6dfee3abed08 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionView.swift @@ -9,8 +9,8 @@ import SwiftUI struct ConnectionView: View { - @StateObject var connectionViewModel: ConnectionViewViewModel - @StateObject var indicatorsViewModel: FeatureIndicatorsViewModel + @ObservedObject var connectionViewModel: ConnectionViewViewModel + @ObservedObject var indicatorsViewModel: FeatureIndicatorsViewModel @State private(set) var isExpanded = false @@ -19,53 +19,71 @@ struct ConnectionView: View { var body: some View { Spacer() + .accessibilityIdentifier(AccessibilityIdentifier.connectionView.asString) + VStack(spacing: 22) { - if connectionViewModel.showsActivityIndicator { - CustomProgressView(style: .large) - } + CustomProgressView(style: .large) + .showIf(connectionViewModel.showsActivityIndicator) ZStack { BlurView(style: .dark) - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 0) { HeaderView(viewModel: connectionViewModel, isExpanded: $isExpanded) + .padding(.bottom, headerViewBottomPadding) - if connectionViewModel.showConnectionDetails { - DetailsContainer( - viewModel: connectionViewModel, - indicatorsViewModel: indicatorsViewModel, - isExpanded: $isExpanded - ) - } + DetailsContainer( + connectionViewModel: connectionViewModel, + indicatorsViewModel: indicatorsViewModel, + isExpanded: $isExpanded + ) + .showIf(connectionViewModel.showConnectionDetails) ButtonPanel(viewModel: connectionViewModel, action: action) + .padding(.top, 16) } .padding(16) } .cornerRadius(12) .padding(16) } - .padding(.bottom, 8) // Adding some spacing so as not to overlap with the map legal link. - .accessibilityIdentifier(AccessibilityIdentifier.connectionView.asString) + .padding(.bottom, 8) // Some spacing to avoid overlap with the map legal link. .onChange(of: isExpanded) { _ in onContentUpdate?() } .onReceive(connectionViewModel.combinedState) { _, _ in - onContentUpdate?() - + // Only update expanded state when connections details should be hidden. + // This will contract the view on eg. disconnect, but leave it as-is on + // eg. connect. if !connectionViewModel.showConnectionDetails { -// withAnimation { isExpanded = false -// } + return } + + onContentUpdate?() } } } +extension ConnectionView { + var headerViewBottomPadding: CGFloat { + let hasIndicators = !indicatorsViewModel.chips.isEmpty + let showConnectionDetails = connectionViewModel.showConnectionDetails + + return isExpanded + ? 16 + : hasIndicators && showConnectionDetails ? 16 : 0 + } +} + #Preview("ConnectionView (Indicators)") { - ConnectionViewPreview(configuration: .normal).make() + ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { indicatorModel, viewModel, _ in + ConnectionView(connectionViewModel: viewModel, indicatorsViewModel: indicatorModel) + } } #Preview("ConnectionView (No indicators)") { - ConnectionViewPreview(configuration: .normalNoIndicators).make() + ConnectionViewComponentPreview(showIndicators: false, isExpanded: true) { indicatorModel, viewModel, _ in + ConnectionView(connectionViewModel: viewModel, indicatorsViewModel: indicatorModel) + } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewComponentPreview.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewComponentPreview.swift similarity index 86% rename from ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewComponentPreview.swift rename to ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewComponentPreview.swift index 89b3fb0aaf17..cc24537f13d7 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewComponentPreview.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewComponentPreview.swift @@ -55,15 +55,14 @@ struct ConnectionViewComponentPreview: View { } var body: some View { - VStack { - content( - FeatureIndicatorsViewModel( - tunnelSettings: tunnelSettings, - ipOverrides: [] - ), - viewModel, - $isExpanded - ) - }.background(UIColor.secondaryColor.color) + content( + FeatureIndicatorsViewModel( + tunnelSettings: tunnelSettings, + ipOverrides: [] + ), + viewModel, + $isExpanded + ) + .background(UIColor.secondaryColor.color) } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewViewModel.swift index 2f7af8a3b572..962eaa0d63fd 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewViewModel.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/ConnectionViewViewModel.swift @@ -52,9 +52,9 @@ extension ConnectionViewViewModel { var showConnectionDetails: Bool { switch tunnelStatus.state { case .connecting, .reconnecting, .waitingForConnectivity(.noConnection), .negotiatingEphemeralPeer, - .connected, .pendingReconnect, .waitingForConnectivity(.noNetwork): + .connected, .pendingReconnect: true - case .disconnecting, .disconnected, .error: + case .disconnecting, .disconnected, .waitingForConnectivity(.noNetwork), .error: false } } @@ -130,9 +130,10 @@ extension ConnectionViewViewModel { var localizedTitleForSelectLocationButton: LocalizedStringKey { switch tunnelStatus.state { - case .disconnecting, .pendingReconnect, .disconnected: + case .disconnecting, .pendingReconnect, .disconnected, .waitingForConnectivity(.noNetwork): LocalizedStringKey("Select location") - case .connecting, .connected, .reconnecting, .waitingForConnectivity, .negotiatingEphemeralPeer, .error: + case .connecting, .connected, .reconnecting, .waitingForConnectivity(.noConnection), + .negotiatingEphemeralPeer, .error: LocalizedStringKey("Switch location") } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift index 7da9aef5b8f6..6b2bb00399a1 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsContainer.swift @@ -1,5 +1,5 @@ // -// ConnectionDetailsContainer.swift +// DetailsContainer.swift // MullvadVPN // // Created by Andrew Bulhak on 2025-01-03. @@ -10,42 +10,38 @@ import SwiftUI extension ConnectionView { internal struct DetailsContainer: View { - @StateObject var viewModel: ConnectionViewViewModel - @StateObject var indicatorsViewModel: FeatureIndicatorsViewModel + @ObservedObject var connectionViewModel: ConnectionViewViewModel + @ObservedObject var indicatorsViewModel: FeatureIndicatorsViewModel @Binding var isExpanded: Bool @State private var scrollViewHeight: CGFloat = 0 var body: some View { -// if isExpanded { - Divider() - .background(UIColor.secondaryTextColor.color) - .opacity(isExpanded ? 1.0 : 0.0) -// } + VStack(spacing: 16) { + Divider() + .background(UIColor.secondaryTextColor.color) + .showIf(isExpanded) - // This geometry reader is somewhat of a workaround. It's "smart" in that it takes up as much - // space as it can and thereby helps the view to understand the maximum allowed height when - // placed in a UIKit context. If ConnectionView would ever be placed as a subview of SwiftUI - // parent, this reader could probably be removed. - GeometryReader { _ in ScrollView { VStack(spacing: 16) { - if !indicatorsViewModel.chips.isEmpty { - FeatureIndicatorsView( - viewModel: indicatorsViewModel, - isExpanded: $isExpanded - ) - } + FeatureIndicatorsView( + viewModel: indicatorsViewModel, + isExpanded: $isExpanded + ) + .showIf(!indicatorsViewModel.chips.isEmpty) - if isExpanded { - DetailsView(viewModel: viewModel) - .transition(.move(edge: .bottom)) - } + DetailsView(viewModel: connectionViewModel) + .showIf(isExpanded) } .sizeOfView { scrollViewHeight = $0.height } } + .frame(maxHeight: scrollViewHeight) + .onTapGesture { + // If this callback is not set the child views will not reliably register tap events. + // This is a bug in iOS 16 and 17, but seemingly fixed in 18. Once we set the lowest + // supported version to iOS 18 we can probably remove it. + } } - .frame(maxHeight: scrollViewHeight) } } } @@ -53,7 +49,7 @@ extension ConnectionView { #Preview { ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { indicatorModel, viewModel, isExpanded in ConnectionView.DetailsContainer( - viewModel: viewModel, + connectionViewModel: viewModel, indicatorsViewModel: indicatorModel, isExpanded: isExpanded ) diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift index 25559757a1ce..ff07dc94b582 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/DetailsView.swift @@ -1,5 +1,5 @@ // -// ConnectionDetails.swift +// DetailsView.swift // MullvadVPN // // Created by Andrew Bulhak on 2025-01-03. @@ -10,7 +10,7 @@ import SwiftUI extension ConnectionView { internal struct DetailsView: View { - @StateObject var viewModel: ConnectionViewViewModel + @ObservedObject var viewModel: ConnectionViewViewModel @State private var columnWidth: CGFloat = 0 var body: some View { diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift index 5ec0eb9316cb..fa1112a80e1c 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/HeaderView.swift @@ -10,7 +10,7 @@ import SwiftUI extension ConnectionView { internal struct HeaderView: View { - @StateObject var viewModel: ConnectionViewViewModel + @ObservedObject var viewModel: ConnectionViewViewModel @Binding var isExpanded: Bool var body: some View { @@ -38,23 +38,19 @@ extension ConnectionView { } .accessibilityLabel(viewModel.localizedAccessibilityLabelForSecureLabel) - if viewModel.showConnectionDetails { + Group { Spacer() Image(.iconChevron) .renderingMode(.template) .rotationEffect(isExpanded ? .degrees(-90) : .degrees(90)) .foregroundStyle(.white) - .transaction { transaction in - transaction.animation = nil - } + .accessibilityIdentifier(AccessibilityIdentifier.relayStatusCollapseButton.asString) } + .showIf(viewModel.showConnectionDetails) } - .accessibilityIdentifier(AccessibilityIdentifier.relayStatusCollapseButton.asString) .contentShape(Rectangle()) .onTapGesture { -// withAnimation { isExpanded.toggle() -// } } } } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewPreview.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewPreview.swift deleted file mode 100644 index 4f2fd49a924a..000000000000 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ConnectionView/Preview/ConnectionViewPreview.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// ConnectionViewPreview.swift -// MullvadVPN -// -// Created by Jon Petersson on 2024-12-18. -// Copyright © 2024 Mullvad VPN AB. All rights reserved. -// - -import MullvadMockData -import MullvadSettings -import MullvadTypes -import PacketTunnelCore -import SwiftUI - -struct ConnectionViewPreview { - enum Configuration { - case normal, normalNoIndicators - } - - private let configuration: Configuration - - private let populatedTunnelSettings = LatestTunnelSettings( - wireGuardObfuscation: WireGuardObfuscationSettings(state: .udpOverTcp), - tunnelQuantumResistance: .on, - tunnelMultihopState: .on, - daita: DAITASettings(daitaState: .on) - ) - - private let viewModel = ConnectionViewViewModel( - tunnelStatus: TunnelStatus( - observedState: .connected(ObservedConnectionState( - selectedRelays: SelectedRelaysStub.selectedRelays, - relayConstraints: RelayConstraints(entryLocations: .any, exitLocations: .any, port: .any, filter: .any), - networkReachability: .reachable, - connectionAttemptCount: 0, - transportLayer: .udp, - remotePort: 80, - isPostQuantum: true, - isDaitaEnabled: true - )), - state: .connected(SelectedRelaysStub.selectedRelays, isPostQuantum: true, isDaita: true) - ) - ) - - init(configuration: Configuration) { - self.configuration = configuration - } - - @ViewBuilder - func make() -> some View { - VStack { - switch configuration { - case .normal: - connectionView(with: populatedTunnelSettings, viewModel: viewModel) - case .normalNoIndicators: - connectionView(with: LatestTunnelSettings(), viewModel: viewModel) - } - } - .background(UIColor.secondaryColor.color) - } - - @ViewBuilder - private func connectionView( - with settings: LatestTunnelSettings, - viewModel: ConnectionViewViewModel, - isExpanded: Bool = false - ) -> some View { - ConnectionView( - connectionViewModel: viewModel, - indicatorsViewModel: FeatureIndicatorsViewModel( - tunnelSettings: settings, - ipOverrides: [] - ), - isExpanded: isExpanded - ) - } -} diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift index 8d3f4c78d527..bb754a0c25fa 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FI_TunnelViewController.swift @@ -91,7 +91,7 @@ class FI_TunnelViewController: UIViewController, RootContainment { self?.view.setNeedsLayout() } - interactor.didGetOutGoingAddress = { [weak self] connectionInfo in + interactor.didGetOutgoingAddress = { [weak self] connectionInfo in self?.connectionViewViewModel.outgoingConnectionInfo = connectionInfo } diff --git a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift index b1a369f99eb0..70e49a9c0493 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift @@ -13,13 +13,11 @@ struct FeatureIndicatorsView: View where ViewModel: ChipViewModelProt @Binding var isExpanded: Bool var body: some View { - VStack(alignment: .leading, spacing: 0) { - if isExpanded { - Text(LocalizedStringKey("Active features")) - .font(.footnote.weight(.semibold)) - .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) - .padding(.bottom, 8) - } + VStack(alignment: .leading, spacing: 8) { + Text(LocalizedStringKey("Active features")) + .font(.footnote.weight(.semibold)) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + .showIf(isExpanded) ChipContainerView(viewModel: viewModel, isExpanded: $isExpanded) } diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift index 7cf879f3bf70..78bd6c27b0e9 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift @@ -65,7 +65,7 @@ class TunnelViewController: UIViewController, RootContainment { self?.updateViewModel(tunnelStatus: tunnelStatus) } - interactor.didGetOutGoingAddress = { [weak self] outgoingConnectionInfo in + interactor.didGetOutgoingAddress = { [weak self] outgoingConnectionInfo in self?.updateViewModel(outgoingConnectionInfo: outgoingConnectionInfo) } diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift index ef902e637a44..e072ec283e3b 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewControllerInteractor.swift @@ -22,7 +22,7 @@ final class TunnelViewControllerInteractor { var didUpdateDeviceState: ((_ deviceState: DeviceState, _ previousDeviceState: DeviceState) -> Void)? var didUpdateTunnelSettings: ((LatestTunnelSettings) -> Void)? var didUpdateIpOverrides: (([IPOverride]) -> Void)? - var didGetOutGoingAddress: (@MainActor (OutgoingConnectionInfo) -> Void)? + var didGetOutgoingAddress: (@MainActor (OutgoingConnectionInfo) -> Void)? var tunnelStatus: TunnelStatus { tunnelManager.tunnelStatus @@ -64,7 +64,7 @@ final class TunnelViewControllerInteractor { .getOutgoingConnectionInfo() else { return } - await self?.didGetOutGoingAddress?(outgoingConnectionInfo) + await self?.didGetOutgoingAddress?(outgoingConnectionInfo) } } }, diff --git a/ios/MullvadVPN/Views/MainButtonStyle.swift b/ios/MullvadVPN/Views/MainButtonStyle.swift index ceabd7761b10..e13758a15537 100644 --- a/ios/MullvadVPN/Views/MainButtonStyle.swift +++ b/ios/MullvadVPN/Views/MainButtonStyle.swift @@ -10,12 +10,10 @@ import SwiftUI struct MainButtonStyle: ButtonStyle { var style: Style - var disabled: Bool @Environment(\.isEnabled) private var isEnabled: Bool - init(_ style: Style, disabled: Bool = false) { + init(_ style: Style) { self.style = style - self.disabled = disabled } func makeBody(configuration: Configuration) -> some View { diff --git a/ios/MullvadVPN/Views/SplitMainButton.swift b/ios/MullvadVPN/Views/SplitMainButton.swift index 6f36b839befe..72a879893547 100644 --- a/ios/MullvadVPN/Views/SplitMainButton.swift +++ b/ios/MullvadVPN/Views/SplitMainButton.swift @@ -12,6 +12,7 @@ struct SplitMainButton: View { var text: LocalizedStringKey var image: ImageResource var style: MainButtonStyle.Style + var accessibilityId: AccessibilityIdentifier? @State private var secondaryButtonWidth: CGFloat = 0 @@ -28,6 +29,10 @@ struct SplitMainButton: View { } .padding(.trailing, -secondaryButtonWidth) }) + .ifLet(accessibilityId) { view, value in + view.accessibilityIdentifier(value.asString) + } + Button(action: secondaryAction, label: { Image(image) .resizable()