Skip to content

Commit

Permalink
VPN Geoswitching - initial draft (#1978)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/0/1206183910299715/f

Description:

This adds an initial implementation of location switching for the VPN. Known issues:

Some of the layout / spacing is not quite right
The tap area of the items on the VPN Location selection screen is too small
The styling of the list sections on the VPN Location screen is not finished.
There seems to be a bug where the initial selection is not respected when connecting after selecting
There is a momentary delay before the list items load.
Needs unit tests (I promise I will add them, they are very similar to the iOS ones so it won’t take long. I just ran out of time before the holidays).
  • Loading branch information
graeme authored Dec 15, 2023
1 parent 91324f4 commit 2982a23
Show file tree
Hide file tree
Showing 10 changed files with 690 additions and 1 deletion.
48 changes: 48 additions & 0 deletions DuckDuckGo.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions DuckDuckGo/Common/Localizables/UserText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,34 @@ struct UserText {

// VPN Setting Titles

static let vpnLocationTitle = NSLocalizedString("vpn.location.title", value: "Location", comment: "Location section title in VPN settings")
static let vpnGeneralTitle = NSLocalizedString("vpn.general.title", value: "General", comment: "General section title in VPN settings")
static let vpnNotificationsSettingsTitle = NSLocalizedString("vpn.notifications.settings.title", value: "Notifications", comment: "Notifications section title in VPN settings")
static let vpnAdvancedSettingsTitle = NSLocalizedString("vpn.advanced.settings.title", value: "Advanced", comment: "VPN Advanced section title in VPN settings")

// VPN Location

static let vpnLocationChangeButtonTitle = NSLocalizedString("vpn.location.change.button.title", value: "Change...", comment: "Title of the VPN location preference change button")
static let vpnLocationListTitle = NSLocalizedString("vpn.location.list.title", value: "VPN Location", comment: "Title of the VPN location list screen")
static let vpnLocationRecommendedSectionTitle = NSLocalizedString("vpn.location.recommended.section.title", value: "Recommended", comment: "Title of the VPN location list recommended section")
static let vpnLocationCustomSectionTitle = NSLocalizedString("vpn.location.custom.section.title", value: "Custom", comment: "Title of the VPN location list custom section")
static let vpnLocationSubmitButtonTitle = NSLocalizedString("vpn.location.submit.button.title", value: "Submit", comment: "Title of the VPN location list submit button")
static let vpnLocationCancelButtonTitle = NSLocalizedString("vpn.location.custom.section.title", value: "Cancel", comment: "Title of the VPN location list cancel button")
static let vpnLocationNearest = NSLocalizedString(
"vpn.location.description.nearest",
value: "Nearest",
comment: "Nearest city setting description")
static let vpnLocationNearestAvailable = NSLocalizedString(
"vpn.location.description.nearest.available",
value: "Nearest Available",
comment: "Nearest available location setting description")
static let vpnLocationNearestAvailableSubtitle = NSLocalizedString("vpn.location.nearest.available.title", value: "Automatically connect to the nearest server we can find.", comment: "Subtitle underneath the nearest available vpn location preference text.")

static func vpnLocationCountryItemFormattedCitiesCount(_ count: Int) -> String {
let message = NSLocalizedString("network.protection.vpn.location.country.item.formatted.cities.count", value: "%d cities", comment: "Subtitle of countries item when there are multiple cities, example : ")
return String(format: message, count)
}

// VPN Settings

static let vpnConnectOnLoginSettingTitle = NSLocalizedString(
Expand Down
4 changes: 4 additions & 0 deletions DuckDuckGo/FeatureFlagging/Model/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public enum FeatureFlag: String {
/// Add experimental atb parameter to SERP queries for internal users to display Privacy Reminder
/// https://app.asana.com/0/1199230911884351/1205979030848528/f
case appendAtbToSerpQueries

case vpnGeoswitching
}

extension FeatureFlag: FeatureFlagSourceProviding {
Expand All @@ -34,6 +36,8 @@ extension FeatureFlag: FeatureFlagSourceProviding {
return .internalOnly
case .appendAtbToSerpQueries:
return .internalOnly
case .vpnGeoswitching:
return .internalOnly
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// NetworkProtectionVPNCountryLabelsModel.swift
//
// Copyright © 2023 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

#if NETWORK_PROTECTION

import Foundation
import NetworkProtection

struct NetworkProtectionVPNCountryLabelsModel {
let emoji: String
let title: String

init(country: String) {
self.title = Locale.current.localizedString(forRegionCode: country) ?? country.capitalized
self.emoji = Self.flag(country: country)
}

private static func flag(country: String) -> String {
let flagBase = UnicodeScalar("🇦").value - UnicodeScalar("A").value

let flag = country
.uppercased()
.unicodeScalars
.compactMap({ UnicodeScalar(flagBase + $0.value)?.description })
.joined()
return flag
}
}

#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//
// NetworkProtectionVPNLocationPreferenceItem.swift
//
// Copyright © 2023 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

#if NETWORK_PROTECTION

import Foundation
import SwiftUI

struct VPNLocationPreferenceItem: View {
let model: VPNLocationPreferenceItemModel
@State private var isShowingLocationSheet: Bool = false

var body: some View {
VStack(alignment: .leading) {
HStack(spacing: 10) {
switch model.icon {
case .defaultIcon:
Image(systemName: "location.fill")
.resizable()
.frame(width: 18, height: 18)
case .emoji(let string):
Text(string).font(.system(size: 20))
}

VStack(alignment: .leading) {
Text(model.title)
.font(.system(size: 13))
.foregroundColor(.primary)
if let subtitle = model.subtitle {
Text(subtitle)
.font(.system(size: 11))
.foregroundColor(.secondary)
}
}
Spacer()
Button(UserText.vpnLocationChangeButtonTitle) {
isShowingLocationSheet = true
}
.sheet(isPresented: $isShowingLocationSheet) {
VPNLocationView(isPresented: $isShowingLocationSheet)
}
}
}
.frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .topLeading)
.padding(10)
.background(Color("BlackWhite1"))
.roundedBorder()
}

}

#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// NetworkProtectionLocationSettingsItemModel.swift
//
// Copyright © 2023 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

#if NETWORK_PROTECTION

import Foundation
import NetworkProtection

struct VPNLocationPreferenceItemModel {
enum LocationIcon {
case defaultIcon
case emoji(String)
}

let title: String
let subtitle: String?
let icon: LocationIcon

init(selectedLocation: VPNSettings.SelectedLocation) {
switch selectedLocation {
case .nearest:
title = UserText.vpnLocationNearestAvailable
subtitle = UserText.vpnLocationNearestAvailableSubtitle
icon = .defaultIcon
case .location(let location):
let countryLabelsModel = NetworkProtectionVPNCountryLabelsModel(country: location.country)
title = countryLabelsModel.title
subtitle = selectedLocation.location?.city
icon = .emoji(countryLabelsModel.emoji)
}
}
}

#endif
Loading

0 comments on commit 2982a23

Please sign in to comment.