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/MullvadRustRuntimeTests/MullvadPostQuantum+Stubs.swift b/ios/MullvadRustRuntimeTests/MullvadPostQuantum+Stubs.swift index 30ce49a891c8..8839abaf9637 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 { @@ -31,8 +30,8 @@ class TunnelProviderStub: TunnelProvider { 0 } - func wgFunctions() -> MullvadTypes.WgFuncPointers { - return MullvadTypes.WgFuncPointers( + func wgFunctions() -> WgFunctionPointers { + return WgFunctionPointers( open: { _, _, _ in return 0 }, close: { _, _ in return 0 }, receive: { _, _, _, _ in return 0 }, @@ -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 991c24673d2a..e0b6ce6dd4f8 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -653,6 +653,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 */; }; @@ -1003,7 +1005,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 */; }; @@ -2022,6 +2024,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 = ""; }; @@ -2249,7 +2253,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 = ""; }; @@ -3638,7 +3642,6 @@ A9D9A4C12C36D53C004088DD /* MullvadRustRuntimeTests */, 58CE5E61224146200008646E /* Products */, 584F991F2902CBDD001F858D /* Frameworks */, - 7A0EAE982D01B29E00D3EB8B /* Recovered References */, ); sourceTree = ""; }; @@ -3939,13 +3942,6 @@ path = Edit; sourceTree = ""; }; - 7A0EAE982D01B29E00D3EB8B /* Recovered References */ = { - isa = PBXGroup; - children = ( - ); - name = "Recovered References"; - sourceTree = ""; - }; 7A2960F72A964A3500389B82 /* Alert */ = { isa = PBXGroup; children = ( @@ -4082,8 +4078,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 */, @@ -4396,6 +4392,7 @@ F0ACE32E2BE4EA8B006D5333 /* MockProxyFactory.swift */, 58FE25EF2AA77664003D1918 /* RelaySelectorStub.swift */, A900E9B92ACC5D0600C95F67 /* RESTRequestExecutor+Stubs.swift */, + 7AF84F452D12C59F00C72690 /* SelectedRelaysStub+Stubs.swift */, ); path = MullvadREST; sourceTree = ""; @@ -4413,6 +4410,7 @@ isa = PBXGroup; children = ( F0B495752D02025200CFEC2A /* ChipContainerView.swift */, + F0B495792D02F41F00CFEC2A /* ChipFeature.swift */, F0ADF1D02D01B55C00299F09 /* ChipModel.swift */, F0ADF1D42D01DCFD00299F09 /* ChipView.swift */, F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.swift */, @@ -5935,6 +5933,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 */, @@ -6130,7 +6129,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 */, @@ -6514,6 +6513,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 5aa1e0c86823..4f93def48b96 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/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 d58fb05aa5f8..000000000000 Binary files a/ios/MullvadVPN/Supporting Files/Assets.xcassets/IconReload.imageset/IconReload.pdf and /dev/null differ 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/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift index f06124ed4811..e40e9a7ef27a 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipContainerView.swift @@ -10,46 +10,43 @@ 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 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 { + var chipsToAdd = viewModel.chips + var showMoreButton = false + + if !isExpanded { + (chipsToAdd, showMoreButton) = viewModel.chipsToAdd(forContainerWidth: containerWidth) + } + + return HStack { ZStack(alignment: .topLeading) { - createChipViews(chips: Array(viewModel.chips.prefix(numberOfChips)), containerWidth: containerWidth) + if isExpanded { + createChipViews(chips: viewModel.chips, containerWidth: containerWidth) + } else { + 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) } 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, -5) } private func createChipViews(chips: [ChipModel], containerWidth: CGFloat) -> some View { @@ -65,7 +62,7 @@ struct ChipContainerView: View where ViewModel: ChipViewModelProtocol 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 +71,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/ChipFeatures.swift b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipFeature.swift similarity index 68% rename from ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipFeatures.swift rename to ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipFeature.swift index c005b3f080ed..4dce7dc27899 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipFeatures.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipFeature.swift @@ -1,17 +1,16 @@ // -// ChipFeatures.swift +// ChipFeature.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 } + var name: String { get } } struct DaitaFeature: ChipFeature { @@ -21,8 +20,8 @@ struct DaitaFeature: ChipFeature { settings.daita.daitaState.isEnabled } - var name: LocalizedStringKey { - LocalizedStringKey("DAITA") + var name: String { + String("DAITA") } } @@ -32,8 +31,8 @@ struct QuantumResistanceFeature: ChipFeature { settings.tunnelQuantumResistance.isEnabled } - var name: LocalizedStringKey { - LocalizedStringKey("Quantum resistance") + var name: String { + String("Quantum resistance") } } @@ -43,8 +42,8 @@ struct MultihopFeature: ChipFeature { settings.tunnelMultihopState.isEnabled } - var name: LocalizedStringKey { - LocalizedStringKey("Multihop") + var name: String { + String("Multihop") } } @@ -55,8 +54,8 @@ struct ObfuscationFeature: ChipFeature { settings.wireGuardObfuscation.state.isEnabled } - var name: LocalizedStringKey { - LocalizedStringKey("Obfuscation") + var name: String { + String("Obfuscation") } } @@ -67,11 +66,11 @@ struct DNSFeature: ChipFeature { settings.dnsSettings.enableCustomDNS || !settings.dnsSettings.blockingOptions.isEmpty } - var name: LocalizedStringKey { + var name: String { if !settings.dnsSettings.blockingOptions.isEmpty { - return LocalizedStringKey("DNS content blockers") + return String("DNS content blockers") } - return LocalizedStringKey("Custom DNS") + return String("Custom DNS") } } @@ -82,7 +81,7 @@ struct IPOverrideFeature: ChipFeature { !overrides.isEmpty } - var name: LocalizedStringKey { - LocalizedStringKey("Server IP override") + var name: String { + String("Server IP override") } } 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..42e0e508cac9 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(.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..f02218da3d7b 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipViewModelProtocol.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/ChipView/ChipViewModelProtocol.swift @@ -10,23 +10,51 @@ 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)) + var totalChipsWidth: CGFloat = 0 + + for (index, chip) in chips.enumerated() { + let textWidth = chip.name.width(using: .preferredFont(forTextStyle: .subheadline)) + let chipWidth = textWidth + 16 /* inside horisontal padding */ + 8 /* outside trailing padding */ + 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..cb5cd6cc4a8d 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,183 @@ 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() +} + +#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 ConnectionPanel: View { +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, let server = viewModel.titleForServer { + Text(countryAndCity) + .font(.title3.weight(.semibold)) + .foregroundStyle(UIColor.primaryTextColor.color) + .padding(.top, 4) + Text(server) + .font(.body) + .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6)) + .padding(.top, 2) + } + } + .accessibilityLabel(viewModel.localizedAccessibilityLabelForSecureLabel) + + if viewModel.showConnectionDetails { + Spacer() + Button( + action: { isExpanded.toggle() }, + label: { + Image(.iconChevron) + .renderingMode(.template) + .rotationEffect(isExpanded ? .degrees(-90) : .degrees(90)) + .frame(width: 44, height: 44, alignment: .topTrailing) + .foregroundStyle(.white) + .transaction { transaction in + transaction.animation = nil + } + } + ) + .accessibilityIdentifier(AccessibilityIdentifier.relayStatusCollapseButton.asString) + } + } + } +} + +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) + } } } @@ -101,7 +223,7 @@ private struct ButtonPanel: View { @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, @@ -111,6 +233,7 @@ private struct ButtonPanel: View { primaryAction: { action?(.selectLocation) }, secondaryAction: { action?(.reconnect) } ) + .accessibilityIdentifier(AccessibilityIdentifier.selectLocationButton.asString) case .disconnecting, .pendingReconnect, .disconnected: MainButton( text: viewModel.localizedTitleForSelectLocationButton, @@ -118,6 +241,7 @@ private struct ButtonPanel: View { disabled: viewModel.disableButtons, action: { action?(.selectLocation) } ) + .accessibilityIdentifier(AccessibilityIdentifier.selectLocationButton.asString) } } @@ -131,6 +255,7 @@ private struct ButtonPanel: View { disabled: viewModel.disableButtons, action: { action?(.connect) } ) + .accessibilityIdentifier(AccessibilityIdentifier.connectButton.asString) case .disconnect: MainButton( text: LocalizedStringKey("Disconnect"), @@ -138,10 +263,11 @@ private struct ButtonPanel: View { 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" ), @@ -149,6 +275,11 @@ private struct ButtonPanel: View { 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..cfdd533cd367 100644 --- a/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift +++ b/ios/MullvadVPN/View controllers/Tunnel/FeatureIndicators/FeatureIndicatorsView.swift @@ -10,22 +10,29 @@ 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) + } + .onTapGesture { + isExpanded.toggle() } } } -#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..3cb0046deb75 100644 --- a/ios/MullvadVPN/Views/MainButton.swift +++ b/ios/MullvadVPN/Views/MainButton.swift @@ -11,7 +11,8 @@ import SwiftUI struct MainButton: View { var text: LocalizedStringKey var style: MainButtonStyle.Style - var disabled = false + + @State var disabled = false var action: () -> Void diff --git a/ios/MullvadVPN/Views/MainButtonStyle.swift b/ios/MullvadVPN/Views/MainButtonStyle.swift index f32a27fa068a..78717d50a8f6 100644 --- a/ios/MullvadVPN/Views/MainButtonStyle.swift +++ b/ios/MullvadVPN/Views/MainButtonStyle.swift @@ -19,7 +19,6 @@ struct MainButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .padding(.horizontal, 8) .frame(height: 44) .foregroundColor( disabled diff --git a/ios/MullvadVPN/Views/SplitMainButton.swift b/ios/MullvadVPN/Views/SplitMainButton.swift index 11336f424ba2..80e58ca21a1e 100644 --- a/ios/MullvadVPN/Views/SplitMainButton.swift +++ b/ios/MullvadVPN/Views/SplitMainButton.swift @@ -12,13 +12,13 @@ struct SplitMainButton: View { var text: LocalizedStringKey var image: ImageResource var style: MainButtonStyle.Style - var disabled = false + + @State 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,16 +27,17 @@ 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)) .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/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",