summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj26
-rw-r--r--ios/MullvadVPN/Extensions/View+Modifier.swift21
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ButtonPanel.swift2
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipContainerView.swift40
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipModel.swift2
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionView.swift98
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewComponentPreview.swift52
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift11
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/DetailsContainer.swift57
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/DetailsView.swift4
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicators/FeatureIndicatorsView.swift33
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicators/FeatureIndicatorsViewModel.swift35
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicatorsViewModel.swift45
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/ConnectionView/HeaderView.swift89
-rw-r--r--ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift4
15 files changed, 267 insertions, 252 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index aae2c05e05..f4c48539b2 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -43,7 +43,6 @@
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 */; };
@@ -1021,7 +1020,6 @@
F0B495762D02025200CFEC2A /* ChipContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495752D02025200CFEC2A /* ChipContainerView.swift */; };
F0B495782D02038B00CFEC2A /* ChipViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.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 */; };
F0B894F32BF7526700817A42 /* RelaySelector+Wireguard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */; };
@@ -1067,6 +1065,7 @@
F0F56B092C0E058A009D676B /* ObserverList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CC40EE24A601900019D96E /* ObserverList.swift */; };
F0FADDEA2BE90AAA000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; };
F0FADDEC2BE90AB0000D0B02 /* LaunchArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F1EF8C2BE8FF0A00CED01D /* LaunchArguments.swift */; };
+ F910A4012D3FF23A002FF3BB /* View+Modifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A4002D3FF22E002FF3BB /* View+Modifier.swift */; };
F910A8572D523812002FF3BB /* TunnelSettingsV7.swift in Sources */ = {isa = PBXBuildFile; fileRef = F910A8562D523812002FF3BB /* TunnelSettingsV7.swift */; };
F95C1C252D3E5E8E00EBE769 /* UIAlertController+InAppPurchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = F95C1C242D3E5E7A00EBE769 /* UIAlertController+InAppPurchase.swift */; };
F998EFF82D359C4600D88D01 /* SKProduct+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FD5BEF24238EB300112C88 /* SKProduct+Formatting.swift */; };
@@ -1466,7 +1465,6 @@
44075DFA2CDA4F7400F61139 /* UDPOverTCPObfuscationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPOverTCPObfuscationSettingsViewModel.swift; sourceTree = "<group>"; };
440E5AAF2CDBD67D00B09614 /* StatefulPreviewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPreviewWrapper.swift; sourceTree = "<group>"; };
440E5AB32CDCF24500B09614 /* TunnelObfuscationSettingsWatchingObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelObfuscationSettingsWatchingObservableObject.swift; sourceTree = "<group>"; };
- 4419AA882D282687001B13C9 /* DetailsContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsContainer.swift; sourceTree = "<group>"; };
4419AA8A2D2826E5001B13C9 /* DetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsView.swift; sourceTree = "<group>"; };
4419AA8D2D2828A4001B13C9 /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = "<group>"; };
4422C0702CCFF6790001A385 /* UDPOverTCPObfuscationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UDPOverTCPObfuscationSettingsView.swift; sourceTree = "<group>"; };
@@ -2295,7 +2293,6 @@
F0B495752D02025200CFEC2A /* ChipContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipContainerView.swift; sourceTree = "<group>"; };
F0B495772D02038B00CFEC2A /* ChipViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipViewModelProtocol.swift; sourceTree = "<group>"; };
F0B495792D02F41F00CFEC2A /* ChipFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChipFeature.swift; sourceTree = "<group>"; };
- F0B4957B2D03154200CFEC2A /* FeatureIndicatorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIndicatorsView.swift; sourceTree = "<group>"; };
F0B894EE2BF751C500817A42 /* RelayWithLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithLocation.swift; sourceTree = "<group>"; };
F0B894F02BF751E300817A42 /* RelayWithDistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayWithDistance.swift; sourceTree = "<group>"; };
F0B894F22BF7526700817A42 /* RelaySelector+Wireguard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RelaySelector+Wireguard.swift"; sourceTree = "<group>"; };
@@ -2337,6 +2334,7 @@
F0F316182BF3572B0078DBCF /* RelaySelectorResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySelectorResult.swift; sourceTree = "<group>"; };
F0F3161A2BF358590078DBCF /* NoRelaysSatisfyingConstraintsError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoRelaysSatisfyingConstraintsError.swift; sourceTree = "<group>"; };
F0FBD98E2C4A60CC00EE5323 /* KeyExchangingResultStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyExchangingResultStub.swift; sourceTree = "<group>"; };
+ F910A4002D3FF22E002FF3BB /* View+Modifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Modifier.swift"; sourceTree = "<group>"; };
F910A8562D523812002FF3BB /* TunnelSettingsV7.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV7.swift; sourceTree = "<group>"; };
F95C1C242D3E5E7A00EBE769 /* UIAlertController+InAppPurchase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+InAppPurchase.swift"; sourceTree = "<group>"; };
F998EFF92D3656B100D88D01 /* SKProduct+Sorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SKProduct+Sorting.swift"; sourceTree = "<group>"; };
@@ -2735,15 +2733,14 @@
4419AA862D28264D001B13C9 /* ConnectionView */ = {
isa = PBXGroup;
children = (
- 44E1F7572D3EA82C003A60FF /* DestinationDescriber.swift */,
- F0ADF1CF2D01B50B00299F09 /* ChipView */,
- 7AA130972CFF364F00640DF9 /* FeatureIndicators */,
449E9A6E2D283C7400F8574A /* ButtonPanel.swift */,
+ F0ADF1CF2D01B50B00299F09 /* ChipView */,
7AA130982CFF365A00640DF9 /* ConnectionView.swift */,
449E9A6C2D283A2500F8574A /* ConnectionViewComponentPreview.swift */,
7A0EAEA32D06DF8200D3EB8B /* ConnectionViewViewModel.swift */,
- 4419AA882D282687001B13C9 /* DetailsContainer.swift */,
+ 44E1F7572D3EA82C003A60FF /* DestinationDescriber.swift */,
4419AA8A2D2826E5001B13C9 /* DetailsView.swift */,
+ F0ADF1D22D01B6B400299F09 /* FeatureIndicatorsViewModel.swift */,
4419AA8D2D2828A4001B13C9 /* HeaderView.swift */,
);
path = ConnectionView;
@@ -3201,6 +3198,7 @@
5878F4FF29CDA742003D4BE2 /* UIView+AutoLayoutBuilder.swift */,
7A516C2D2B6D357500BBD33D /* URL+Scoping.swift */,
7AA636372D2D3BAC009B2C89 /* View+Conditionals.swift */,
+ F910A4002D3FF22E002FF3BB /* View+Modifier.swift */,
7A0EAE9D2D01BCBF00D3EB8B /* View+Size.swift */,
7A8A18FA2CE4B66C000BCB5B /* View+TapAreaSize.swift */,
);
@@ -4137,15 +4135,6 @@
path = SelectLocation;
sourceTree = "<group>";
};
- 7AA130972CFF364F00640DF9 /* FeatureIndicators */ = {
- isa = PBXGroup;
- children = (
- F0B4957B2D03154200CFEC2A /* FeatureIndicatorsView.swift */,
- F0ADF1D22D01B6B400299F09 /* FeatureIndicatorsViewModel.swift */,
- );
- path = FeatureIndicators;
- sourceTree = "<group>";
- };
7AD63A422CDA661B00445268 /* Extensions */ = {
isa = PBXGroup;
children = (
@@ -5994,6 +5983,7 @@
5878A27529093A310096FC88 /* StorePaymentEvent.swift in Sources */,
7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */,
F062000C2CB7EB5D002E6DB9 /* UIImage+Helpers.swift in Sources */,
+ F910A4012D3FF23A002FF3BB /* View+Modifier.swift in Sources */,
7A6389EB2B7FAD7A008E77E1 /* SettingsFieldValidationErrorContentView.swift in Sources */,
7A8A19282CF603EB000BCB5B /* SettingsViewControllerFactory.swift in Sources */,
58B26E2A2943545A00D5980C /* NotificationManagerDelegate.swift in Sources */,
@@ -6168,7 +6158,6 @@
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 */,
@@ -6244,7 +6233,6 @@
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 */,
diff --git a/ios/MullvadVPN/Extensions/View+Modifier.swift b/ios/MullvadVPN/Extensions/View+Modifier.swift
new file mode 100644
index 0000000000..2f89ec9dd8
--- /dev/null
+++ b/ios/MullvadVPN/Extensions/View+Modifier.swift
@@ -0,0 +1,21 @@
+//
+// View+Modifier.swift
+// MullvadVPN
+//
+// Created by Steffen Ernst on 2025-01-21.
+// Copyright © 2025 Mullvad VPN AB. All rights reserved.
+//
+
+import SwiftUI
+
+// A view modifier that can be used to conditionally apply other view modifiers. Here an example
+// .apply {
+// if #available(iOS 16.4, *) {
+// $0.scrollBounceBehavior(.basedOnSize)
+// } else {
+// $0
+// }
+// }
+extension View {
+ func apply<V: View>(@ViewBuilder _ block: (Self) -> V) -> V { block(self) }
+}
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ButtonPanel.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ButtonPanel.swift
index 5159120046..5c9b49498d 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ButtonPanel.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ButtonPanel.swift
@@ -84,7 +84,7 @@ extension ConnectionView {
}
#Preview {
- ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { _, vm, _ in
+ ConnectionViewComponentPreview(showIndicators: true) { _, vm, _ in
ConnectionView.ButtonPanel(viewModel: vm, action: nil)
}
}
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipContainerView.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipContainerView.swift
index ea0e0e8794..8b67445b6f 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipContainerView.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipContainerView.swift
@@ -10,10 +10,11 @@ import SwiftUI
struct ChipContainerView<ViewModel>: View where ViewModel: ChipViewModelProtocol {
@ObservedObject var viewModel: ViewModel
+ let tunnelState: TunnelState
@Binding var isExpanded: Bool
@State private var chipContainerHeight: CGFloat = .zero
- private let verticalPadding: CGFloat = 6
+ private let verticalPadding: CGFloat = 8
var body: some View {
GeometryReader { geo in
@@ -31,19 +32,25 @@ struct ChipContainerView<ViewModel>: View where ViewModel: ChipViewModelProtocol
}
Button(LocalizedStringKey("\(viewModel.chips.count - chipsToAdd.count) more...")) {
- isExpanded.toggle()
+ withAnimation {
+ isExpanded.toggle()
+ }
}
.font(.subheadline)
.lineLimit(1)
.foregroundStyle(UIColor.primaryTextColor.color)
.showIf(showMoreButton)
+ .transition(.move(edge: .bottom).combined(with: .opacity))
Spacer()
}
- .sizeOfView { chipContainerHeight = $0.height }
+ .sizeOfView { size in
+ withAnimation {
+ chipContainerHeight = size.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 {
@@ -88,6 +95,31 @@ struct ChipContainerView<ViewModel>: View where ViewModel: ChipViewModelProtocol
StatefulPreviewWrapper(false) { isExpanded in
ChipContainerView(
viewModel: MockFeatureIndicatorsViewModel(),
+ tunnelState: .connected(
+ .init(
+ entry: nil,
+ exit: .init(
+ endpoint: .init(
+ ipv4Relay: .init(ip: .allHostsGroup, port: 1234),
+ ipv4Gateway: .allHostsGroup,
+ ipv6Gateway: .broadcast,
+ publicKey: Data()
+ ),
+ hostname: "hostname",
+ location: .init(
+ country: "Sweden",
+ countryCode: "SE",
+ city: "Gothenburg",
+ cityCode: "gbg",
+ latitude: 1234,
+ longitude: 1234
+ )
+ ),
+ retryAttempt: 0
+ ),
+ isPostQuantum: false,
+ isDaita: false
+ ),
isExpanded: isExpanded
)
.background(UIColor.secondaryColor.color)
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipModel.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipModel.swift
index 8cdaa076fd..829459b15f 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipModel.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ChipView/ChipModel.swift
@@ -10,6 +10,6 @@ import Foundation
import SwiftUI
struct ChipModel: Identifiable {
- let id = UUID()
+ var id: String { name }
let name: String
}
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionView.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionView.swift
index 00be5f526a..b5ab52d84e 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionView.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionView.swift
@@ -14,57 +14,87 @@ struct ConnectionView: View {
@State private(set) var isExpanded = false
+ @State private(set) var scrollViewHeight: CGFloat = 0
+ var hasFeatureIndicators: Bool { !indicatorsViewModel.chips.isEmpty }
var action: ButtonPanel.Action?
var body: some View {
- Spacer()
- .accessibilityIdentifier(AccessibilityIdentifier.connectionView.asString)
+ VStack {
+ Spacer()
+ .accessibilityIdentifier(AccessibilityIdentifier.connectionView.asString)
+ VStack(spacing: 16) {
+ VStack(alignment: .leading, spacing: 0) {
+ HeaderView(viewModel: connectionViewModel, isExpanded: $isExpanded)
+ .padding(.bottom, 8)
+ Divider()
+ .background(UIColor.secondaryTextColor.color)
+ .padding(.bottom, 16)
+ .showIf(isExpanded)
- VStack(alignment: .leading, spacing: 0) {
- HeaderView(viewModel: connectionViewModel, isExpanded: $isExpanded)
- .padding(.bottom, headerViewBottomPadding)
+ ScrollView {
+ HStack {
+ VStack(alignment: .leading, spacing: 0) {
+ Text(LocalizedStringKey("Active features"))
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6))
+ .showIf(isExpanded && hasFeatureIndicators)
- DetailsContainer(
- connectionViewModel: connectionViewModel,
- indicatorsViewModel: indicatorsViewModel,
- isExpanded: $isExpanded
- )
- .showIf(connectionViewModel.showsConnectionDetails)
+ ChipContainerView(
+ viewModel: indicatorsViewModel,
+ tunnelState: connectionViewModel.tunnelStatus.state,
+ isExpanded: $isExpanded
+ )
+ .padding(.bottom, isExpanded ? 16 : 0)
+ .showIf(hasFeatureIndicators)
- ButtonPanel(viewModel: connectionViewModel, action: action)
- .padding(.top, 16)
- }
- .padding(16)
- .background(BlurView(style: .dark))
- .cornerRadius(12)
- .padding(EdgeInsets(top: 16, leading: 16, bottom: 24, trailing: 16))
- .onReceive(connectionViewModel.combinedState) { _ in
- if !connectionViewModel.showsConnectionDetails {
- isExpanded = false
+ DetailsView(viewModel: connectionViewModel)
+ .padding(.bottom, 8)
+ .showIf(isExpanded)
+ }
+ Spacer()
+ }
+ .sizeOfView { size in
+ withAnimation {
+ scrollViewHeight = size.height
+ }
+ }
+ }
+ .frame(maxHeight: scrollViewHeight)
+ .apply {
+ if #available(iOS 16.4, *) {
+ $0.scrollBounceBehavior(.basedOnSize)
+ } else {
+ $0
+ }
+ }
+ }
+ .transformEffect(.identity)
+ .animation(.default, value: hasFeatureIndicators)
+ ButtonPanel(viewModel: connectionViewModel, action: action)
+ }
+ .padding(16)
+ .background(BlurView(style: .dark))
+ .cornerRadius(12)
+ .padding(16)
+ .onChange(of: connectionViewModel.showsConnectionDetails) { newValue in
+ if !newValue {
+ withAnimation {
+ isExpanded = false
+ }
+ }
}
}
}
}
-extension ConnectionView {
- var headerViewBottomPadding: CGFloat {
- let hasIndicators = !indicatorsViewModel.chips.isEmpty
- let showConnectionDetails = connectionViewModel.showsConnectionDetails
-
- return isExpanded
- ? showConnectionDetails ? 16 : 0
- : hasIndicators && showConnectionDetails ? 16 : 0
- }
-}
-
#Preview("ConnectionView (Indicators)") {
- ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { indicatorModel, viewModel, _ in
+ ConnectionViewComponentPreview(showIndicators: true) { indicatorModel, viewModel, _ in
ConnectionView(connectionViewModel: viewModel, indicatorsViewModel: indicatorModel)
}
}
#Preview("ConnectionView (No indicators)") {
- ConnectionViewComponentPreview(showIndicators: false, isExpanded: true) { indicatorModel, viewModel, _ in
+ ConnectionViewComponentPreview(showIndicators: false) { indicatorModel, viewModel, _ in
ConnectionView(connectionViewModel: viewModel, indicatorsViewModel: indicatorModel)
}
}
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewComponentPreview.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewComponentPreview.swift
index 60e963231b..1dd83fc6cf 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewComponentPreview.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewComponentPreview.swift
@@ -15,6 +15,19 @@ import SwiftUI
struct ConnectionViewComponentPreview<Content: View>: View {
let showIndicators: Bool
+ let connectedTunnelStatus = 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)
+ )
private var tunnelSettings: LatestTunnelSettings {
LatestTunnelSettings(
@@ -25,44 +38,39 @@ struct ConnectionViewComponentPreview<Content: View>: View {
)
}
- 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)
- ),
- relayConstraints: RelayConstraints(),
- relayCache: RelayCache(cacheDirectory: ApplicationConfiguration.containerURL),
- customListRepository: CustomListRepository()
- )
+ private let viewModel: ConnectionViewViewModel
var content: (FeatureIndicatorsViewModel, ConnectionViewViewModel, Binding<Bool>) -> Content
- @State var isExpanded: Bool
+ @State var isExpanded = false
init(
showIndicators: Bool,
- isExpanded: Bool,
content: @escaping (FeatureIndicatorsViewModel, ConnectionViewViewModel, Binding<Bool>) -> Content
) {
self.showIndicators = showIndicators
- self._isExpanded = State(wrappedValue: isExpanded)
self.content = content
+ viewModel = ConnectionViewViewModel(
+ tunnelStatus: connectedTunnelStatus,
+ relayConstraints: RelayConstraints(),
+ relayCache: RelayCache(cacheDirectory: ApplicationConfiguration.containerURL),
+ customListRepository: CustomListRepository()
+ )
+ viewModel.outgoingConnectionInfo = OutgoingConnectionInfo(
+ ipv4: .init(ip: .allHostsGroup, exitIP: true),
+ ipv6: IPV6ConnectionData(
+ ip: .broadcast,
+ exitIP: true
+ )
+ )
}
var body: some View {
content(
FeatureIndicatorsViewModel(
tunnelSettings: tunnelSettings,
- ipOverrides: []
+ ipOverrides: [],
+ tunnelState: connectedTunnelStatus.state
),
viewModel,
$isExpanded
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift
index 1540b79b32..a40881eb8d 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift
@@ -34,13 +34,6 @@ class ConnectionViewViewModel: ObservableObject {
@Published var relayConstraints: RelayConstraints
let destinationDescriber: DestinationDescribing
- var combinedState: Publishers.CombineLatest<
- Published<TunnelStatus>.Publisher,
- Published<Bool>.Publisher
- > {
- $tunnelStatus.combineLatest($showsActivityIndicator)
- }
-
var tunnelIsConnected: Bool {
if case .connected = tunnelStatus.state {
true
@@ -72,10 +65,6 @@ class ConnectionViewViewModel: ObservableObject {
func update(tunnelStatus: TunnelStatus) {
self.tunnelStatus = tunnelStatus
-
- if !tunnelIsConnected {
- outgoingConnectionInfo = nil
- }
}
}
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/DetailsContainer.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/DetailsContainer.swift
deleted file mode 100644
index 6b2bb00399..0000000000
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/DetailsContainer.swift
+++ /dev/null
@@ -1,57 +0,0 @@
-//
-// DetailsContainer.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 {
- @ObservedObject var connectionViewModel: ConnectionViewViewModel
- @ObservedObject var indicatorsViewModel: FeatureIndicatorsViewModel
- @Binding var isExpanded: Bool
-
- @State private var scrollViewHeight: CGFloat = 0
-
- var body: some View {
- VStack(spacing: 16) {
- Divider()
- .background(UIColor.secondaryTextColor.color)
- .showIf(isExpanded)
-
- ScrollView {
- VStack(spacing: 16) {
- FeatureIndicatorsView(
- viewModel: indicatorsViewModel,
- isExpanded: $isExpanded
- )
- .showIf(!indicatorsViewModel.chips.isEmpty)
-
- 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.
- }
- }
- }
- }
-}
-
-#Preview {
- ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { indicatorModel, viewModel, isExpanded in
- ConnectionView.DetailsContainer(
- connectionViewModel: viewModel,
- indicatorsViewModel: indicatorModel,
- isExpanded: isExpanded
- )
- }
-}
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/DetailsView.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/DetailsView.swift
index 87daea5046..4e26307914 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/DetailsView.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/DetailsView.swift
@@ -48,6 +48,8 @@ extension ConnectionView {
}
}
}
+ .animation(.default, value: viewModel.inAddress)
+ .animation(.default, value: viewModel.tunnelIsConnected)
}
@ViewBuilder
@@ -72,7 +74,7 @@ extension ConnectionView {
}
#Preview {
- ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { _, vm, _ in
+ ConnectionViewComponentPreview(showIndicators: true) { _, vm, _ in
ConnectionView.DetailsView(viewModel: vm)
}
}
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicators/FeatureIndicatorsView.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicators/FeatureIndicatorsView.swift
deleted file mode 100644
index 4d636e21ff..0000000000
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicators/FeatureIndicatorsView.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-//
-// FeaturesIndicatorsView.swift
-// MullvadVPN
-//
-// Created by Mojgan on 2024-12-06.
-// Copyright © 2025 Mullvad VPN AB. All rights reserved.
-//
-
-import SwiftUI
-
-struct FeatureIndicatorsView<ViewModel>: View where ViewModel: ChipViewModelProtocol {
- @ObservedObject var viewModel: ViewModel
- @Binding var isExpanded: Bool
-
- var body: some View {
- 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)
- }
- }
-}
-
-#Preview {
- FeatureIndicatorsView(
- viewModel: MockFeatureIndicatorsViewModel(),
- isExpanded: .constant(true)
- )
- .background(UIColor.secondaryColor.color)
-}
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicators/FeatureIndicatorsViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicators/FeatureIndicatorsViewModel.swift
deleted file mode 100644
index 86635f20c8..0000000000
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicators/FeatureIndicatorsViewModel.swift
+++ /dev/null
@@ -1,35 +0,0 @@
-//
-// FeatureIndicatorsViewModel.swift
-// MullvadVPN
-//
-// Created by Mojgan on 2024-12-05.
-// Copyright © 2025 Mullvad VPN AB. All rights reserved.
-//
-
-import MullvadSettings
-import SwiftUI
-
-class FeatureIndicatorsViewModel: ChipViewModelProtocol {
- @Published var tunnelSettings: LatestTunnelSettings
- @Published var ipOverrides: [IPOverride]
-
- init(tunnelSettings: LatestTunnelSettings, ipOverrides: [IPOverride]) {
- self.tunnelSettings = tunnelSettings
- self.ipOverrides = ipOverrides
- }
-
- 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/ConnectionView/FeatureIndicatorsViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicatorsViewModel.swift
new file mode 100644
index 0000000000..d07ee2c69e
--- /dev/null
+++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/FeatureIndicatorsViewModel.swift
@@ -0,0 +1,45 @@
+//
+// FeatureIndicatorsViewModel.swift
+// MullvadVPN
+//
+// Created by Mojgan on 2024-12-05.
+// Copyright © 2025 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadSettings
+import SwiftUI
+
+class FeatureIndicatorsViewModel: ChipViewModelProtocol {
+ @Published var tunnelSettings: LatestTunnelSettings
+ @Published var ipOverrides: [IPOverride]
+ @Published var tunnelState: TunnelState
+
+ init(tunnelSettings: LatestTunnelSettings, ipOverrides: [IPOverride], tunnelState: TunnelState) {
+ self.tunnelSettings = tunnelSettings
+ self.ipOverrides = ipOverrides
+ self.tunnelState = tunnelState
+ }
+
+ var chips: [ChipModel] {
+ // Here can be a check if a feature indicator should show in other connection states
+ // e.g. Access local network in blocked state
+ switch tunnelState {
+ case .connecting, .reconnecting, .negotiatingEphemeralPeer,
+ .connected, .pendingReconnect:
+ 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) }
+ default:
+ return []
+ }
+ }
+}
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/HeaderView.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/HeaderView.swift
index 1700f00d06..e73100796b 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/HeaderView.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/HeaderView.swift
@@ -13,52 +13,75 @@ extension ConnectionView {
@ObservedObject 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)
- .accessibilityLabel(viewModel.localizedAccessibilityLabelForSecureLabel)
+ @State var titleForCountryAndCity: LocalizedStringKey?
+ @State var titleForServer: LocalizedStringKey?
- if let countryAndCity = viewModel.titleForCountryAndCity {
- Text(countryAndCity)
+ var body: some View {
+ Button {
+ withAnimation {
+ isExpanded.toggle()
+ }
+ } label: {
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 0) {
+ Text(viewModel.localizedTitleForSecureLabel)
+ .textCase(.uppercase)
.font(.title3.weight(.semibold))
- .foregroundStyle(UIColor.primaryTextColor.color)
- .padding(.top, 4)
+ .foregroundStyle(viewModel.textColorForSecureLabel.color)
+ .accessibilityIdentifier(viewModel.accessibilityIdForSecureLabel.asString)
+ .accessibilityLabel(viewModel.localizedAccessibilityLabelForSecureLabel)
+ if let titleForCountryAndCity {
+ Text(titleForCountryAndCity)
+ .font(.title3.weight(.semibold))
+ .foregroundStyle(UIColor.primaryTextColor.color)
+ .padding(.top, 4)
+ }
+ if let titleForServer {
+ Text(titleForServer)
+ .font(.body)
+ .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6))
+ .padding(.top, 2)
+ .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelServerLabel.asString)
+ }
}
- if let server = viewModel.titleForServer {
- Text(server)
- .font(.body)
- .foregroundStyle(UIColor.primaryTextColor.color.opacity(0.6))
- .padding(.top, 2)
- .accessibilityIdentifier(AccessibilityIdentifier.connectionPanelServerLabel.asString)
+ Group {
+ Spacer()
+ Button {
+ withAnimation {
+ isExpanded.toggle()
+ }
+ } label: {
+ Image(.iconChevronUp)
+ .renderingMode(.template)
+ .rotationEffect(isExpanded ? .degrees(-180) : .degrees(0))
+ .foregroundStyle(.white)
+ .accessibilityIdentifier(AccessibilityIdentifier.relayStatusCollapseButton.asString)
+ }
}
+ .showIf(viewModel.showsConnectionDetails)
}
-
- Group {
- Spacer()
- Image(.iconChevronUp)
- .renderingMode(.template)
- .rotationEffect(isExpanded ? .degrees(180) : .degrees(0))
- .foregroundStyle(.white)
- .accessibilityIdentifier(AccessibilityIdentifier.relayStatusCollapseButton.asString)
+ .onAppear {
+ titleForServer = viewModel.titleForServer
+ titleForCountryAndCity = viewModel.titleForCountryAndCity
}
- .showIf(viewModel.showsConnectionDetails)
- }
- .contentShape(Rectangle())
- .onTapGesture {
- isExpanded.toggle()
+ .onChange(of: viewModel.titleForCountryAndCity, perform: { newValue in
+ withAnimation {
+ titleForCountryAndCity = newValue
+ }
+ })
+ .onChange(of: viewModel.titleForServer, perform: { newValue in
+ withAnimation {
+ titleForServer = newValue
+ }
+ })
}
}
}
}
#Preview {
- ConnectionViewComponentPreview(showIndicators: true, isExpanded: true) { _, vm, isExpanded in
+ ConnectionViewComponentPreview(showIndicators: true) { _, vm, isExpanded in
ConnectionView.HeaderView(viewModel: vm, isExpanded: isExpanded)
}
}
diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift
index b7cdeb454d..02eb82a4b4 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift
+++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift
@@ -69,7 +69,8 @@ class TunnelViewController: UIViewController, RootContainment {
)
indicatorsViewViewModel = FeatureIndicatorsViewModel(
tunnelSettings: interactor.tunnelSettings,
- ipOverrides: interactor.ipOverrides
+ ipOverrides: interactor.ipOverrides,
+ tunnelState: tunnelState
)
connectionView = ConnectionView(
@@ -94,6 +95,7 @@ class TunnelViewController: UIViewController, RootContainment {
interactor.didUpdateTunnelStatus = { [weak self] tunnelStatus in
self?.connectionViewViewModel.update(tunnelStatus: tunnelStatus)
self?.setTunnelState(tunnelStatus.state, animated: true)
+ self?.indicatorsViewViewModel.tunnelState = tunnelStatus.state
self?.view.setNeedsLayout()
}